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:
2026-07-11 20:22:30 +02:00
parent 76f76308cc
commit c3901641d1
3 changed files with 313 additions and 1 deletions

View File

@@ -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)