feat(agent): assert the request path against stored history - #149
Merged
Conversation
Extracts the pure deriveRequestMessages and adds opt-in WithRequestInvariant, which checks that the pipeline neither writes through to stored history nor sends conversation a re-derivation cannot reproduce. Off by default.
hung12ct
force-pushed
the
feat/request-derivation-invariant
branch
from
August 17, 2026 16:29
6d28a5d to
eb41598
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The messages sent to a provider are not stored history:
enforceTokenBudgetapplies the budget policy, thenbuildMsgsForLLMlayers on five injections (soft-landing hint, memory notes, tool-chaining hint, plan-mode hint, dynamic context) plus theAutoCacheSystemstamp.Two properties keep that pipeline honest, and both were held by convention alone:
Property 1 has been violated before —
buildMsgsForLLMcopies before stampingCacheHintprecisely because the stamp used to leak into the caller's session-loaded slice, and its own comment records that. A doc comment cannot hold that invariant; a check can. Today, any edit that threads the derived slice intosaveSessionwould silently corrupt adopter history with nothing to catch it.Off by default. Nil handler means no snapshot and one nil comparison per iteration.
Changes
deriveRequestMessages(stored, maxTokenBudget) contextDerivation— the extracted pure half ofenforceTokenBudget: no receiver, no emit, same input/output.enforceTokenBudgetis now a thin emitting wrapper over it, and shorter for it.WithRequestInvariant(RequestViolationFunc)— enables the check; the handler runs synchronously on the loop goroutine and the turn continues either way, so a handler that fails the build is the intended use.snapshotRequest/checkRequestInvariantin a newrequest_invariant.go.iterationState.sentMessagesrecords the exact list handed to the provider, populated only when the invariant is on.The check compares the conversation, not the whole request
Two of the injectors do more than rewrite the system message:
withPlanModeHintprepends a system message andwithDynamicContextappends ausermessage. An index-aligned comparison would false-positive on both, and a noisy invariant is a useless one. So:dynamicContextSentinel, or it is content reaching the model that no re-derivation accounts for.Comparison is field-wise rather than
reflect.DeepEqual:Partscan carry megabytes of media (compared by length — the pruning and injection stages only rewriteContent), andCacheHintis stamped on the request copy by design.Testing
TestRequestInvariant_CleanRunReportsNothing— the false-positive gate, and the test that matters most. Four subtests across the paths that legitimately reshape a request (defaults, budget-forced pruning, dynamic-context user injection, memory notes), all required to report nothing. Verified non-vacuous: a temporary probe insidecheckRequestInvariantfired 8 times across these subtests, confirming the check is genuinely reached and genuinely silent.TestStoredUnchanged— rewritten content, dropped message, rewritten tool call.TestDerivationReproduces— system reshaping and declared injection admitted; undeclared injection, rewritten conversation, and dropped conversation all caught.TestDeriveRequestMessages_IsPure— across four budgets, the derivation is deterministic and does not mutate its input.TestRequestInvariant_DisabledTakesNoSnapshot— no allocation when off, and the check is inert rather than panicking on a nil snapshot.gofmt -l .empty,make lint0 issues,make build,make test,make test-raceall clean.Follow-up not in this PR
Fork/ForkAtLastUsercould carry the same assertion at the branch point, where a snapped boundary could drop a tool-call pair. Not built — no evidence it is needed yet.Checklist
gofmt -l .prints nothingmake lintis cleanmake buildpassesmake test(andmake test-race) passesCHANGELOG.mdupdated if this PR is cut as a release tagpkg/llm/,pkg/history/, orpkg/telemetry/