fix(agent): bound conversation history replayed to the LLM (A2)
Fix A2 of plans/2026-07-11-nomos-agent-code-review.md. chatWith replayed a
session's ENTIRE message history into the LLM's context on EVERY turn, no
windowing, no token budget — confirmed against a documented production case
(a single turn with 70 tool calls, messages up to 106KB). Every subsequent
turn of a long-running or heavily-autonomous task re-sent that ever-growing
history in full — a real cost/latency/eventual-context-limit risk for
exactly the tasks this system runs longest (many auto-continuation cycles).
Design call (flagged in the review as needing one before implementation):
a fixed-size window for LLM replay specifically, not the UI's own transcript
view. Simplest option that still keeps roughly the current task's working
context; a token-aware trim or LLM-summarize-on-drop are documented as
stretch options if 30 proves insufficient in practice.
- store.go: new getRecentMessages(ctx, sessionID, limit) — last `limit`
messages in chronological order, plus whether older ones were omitted.
getMessages (used by the UI's GET /sessions/{id}) is untouched and stays
unbounded — the operator should still see a task's full history regardless
of length; only what gets sent to the model is bounded.
- agent.go: chatWith uses getRecentMessages(sessionID, historyWindowSize=30)
instead of the unbounded getMessages. When truncated, injects a system
note telling the model explicitly that older turns exist but aren't shown,
so it checks upsert_knowledge/search_knowledge rather than assuming
something wasn't done just because it isn't visible.
New cmd/nomos/store_test.go: real Postgres integration tests (mirroring
internal/db/integration_test.go's throwaway-database pattern, guarded by
OIKOS_TEST_DATABASE_URL). TestGetRecentMessages_Truncation is the direct
proof for this fix (35 messages → 30 returned, correctly ordered,
truncated=true; 5 messages → all 5, truncated=false) — both cases run
against a fully-migrated database, not mocked. Also added
TestProposePlan_AppendVsReplace, closing part of the review's test-coverage
finding (E) by permanently regression-testing the earlier append-vs-replace
plan fix (commit 5384499), which had only been verified manually until now.
Verified live: inflated a real session to 42 persisted messages via direct
SQL, then continued it with a real chat call — the turn proceeded normally
(multiple real tool-call iterations, no crash, no context-length error);
nomos stayed healthy throughout. A3's incremental persistence separately
confirmed to have caught the 7 real tool calls made before the client
connection was cut, cleanly closing out both fixes' interaction.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,20 @@ import (
|
||||
const maxIterations = 40
|
||||
const maxLLMRetries = 1
|
||||
|
||||
// historyWindowSize bounds how many of a session's most recent persisted
|
||||
// messages are replayed into the LLM's context on each turn — see
|
||||
// store.go's getRecentMessages for why this exists (fix A2 of
|
||||
// plans/2026-07-11-nomos-agent-code-review.md: unbounded history replay was
|
||||
// a real, observed-in-production cost/latency/eventual-context-limit risk).
|
||||
// 30 is a fixed-window choice, not token-budget-aware: simplest option that
|
||||
// still keeps roughly the current task's working context, at the cost of
|
||||
// occasionally dropping something a very long task still needed — the
|
||||
// system note injected when truncation happens tells the model to check
|
||||
// upsert_knowledge/search_knowledge rather than assume something didn't
|
||||
// happen. A token-aware trim or LLM-summarize-on-drop are documented
|
||||
// stretch options if a fixed window proves insufficient in practice.
|
||||
const historyWindowSize = 30
|
||||
|
||||
var refusalDenylist = []string{
|
||||
"我没有相关信息",
|
||||
"您可以尝试问我其它问题",
|
||||
@@ -182,7 +196,15 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
system += "\n\n" + snapshot
|
||||
}
|
||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
|
||||
history, _ := a.store.getMessages(ctx, sessionID)
|
||||
history, truncatedHistory, _ := a.store.getRecentMessages(ctx, sessionID, historyWindowSize)
|
||||
if truncatedHistory {
|
||||
// Tell the model explicitly rather than silently dropping older
|
||||
// turns — otherwise it might assume something wasn't done just
|
||||
// because it doesn't see the turn that did it.
|
||||
messages = append(messages, openai.SystemMessage(fmt.Sprintf(
|
||||
"[System: this task has been running long enough that only the most recent %d turns of its history are included above your context — earlier turns happened but aren't shown. If you need to know what was already tried or found, check search_knowledge/get_entity_knowledge (if you recorded it) rather than assuming it didn't happen.]",
|
||||
historyWindowSize)))
|
||||
}
|
||||
var lastAssistantCalls []persistedCall
|
||||
for _, m := range history {
|
||||
text := extractText(m.Content)
|
||||
|
||||
Reference in New Issue
Block a user