You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(runtime): prune and persist the tool DeferralTracker #4685
The runtime's DeferralTracker (runtime.go) keeps per-session state about which tools were present on a session's first call. Entries are never removed; only native compaction resets a session. In long-lived processes (serve api, serve a2a, serve mcp) the map grows with every session ever served.
The state is also not persisted. After a session is reloaded in a new process, its baseline resets, deferred tools can be sent inline again, and the tool prefix changes. That breaks the prompt cache, and on Claude 5.x models that check thinking prefixes, it invalidates replayed thinking.
Proposal
Drop a session's entry when the session ends or is evicted.
Decide whether the baseline should be persisted with the session or rebuilt from history on reload, so a reloaded session sends the same tools in the same order.
Acceptance criteria
The tracker doesn't grow with sessions that have ended.
A reloaded session sends the same tool list it sent before the reload, or the reset is documented.
Planned now, implemented later. The survey behind it covered every serve mode, both providers that defer tools, and the session store.
Goal:
A session's tool-deferral baseline stays the same for its whole life, across turns, runtimes, pooled servers and reloads, so its request prefix doesn't change.
Nothing accumulates for sessions that are gone.
Architecture:
State on the session. The tracker's state (the tool names present on the first model call, plus each later tool's load point) moves from the runtime's toolDeferrals map onto session.Session, as a small ToolDeferrals value with its own lock.
It goes away with its session, so it can't grow.
Pooled serve chat runtimes then share each conversation's baseline.
Persistence. A new SQLite migration adds a tool_deferrals TEXT (JSON) column, read and written like instruction_context.
The runtime saves the session when the state changes: the first call's baseline, or a new load point. It uses UpdateSession, as the harness state does, and skips sub-sessions, which are saved when they complete.
Lifecycle.
Native compaction resets the state, as it does today.
Clone keeps it. Branches and forks copy it, because they replay the same history and its load points.
Sub-sessions start empty.
Decisions (2026-10-22): the state lives on the session; it is persisted in a new column added by a migration.
Branch:fix/session-tool-deferrals from main.
Findings that shape the plan
Growth by mode:
serve chat pools runtimes across conversations, so entries grow without bound, and one conversation can see different baselines on different pooled runtimes.
serve api keeps a runtime per session until it is deleted.
The TUI keeps one runtime across /new and session loads.
serve mcp and serve a2a build a runtime per call or turn.
The reset is broader than the issue says.serve a2a builds a new runtime on every turn, so it loses the baseline every turn, not only after a restart.
What a reset changes on the wire:
Anthropic sends deferred tools with defer_loading after the inline ones, and replaces the load-point tool_result content with tool_reference blocks.
OpenAI leaves them out of tools and injects tool_search_call / tool_search_output after the load point.
A reset therefore changes both the tool list and past messages. Only these two providers use Deferred.
History can't rebuild the baseline. Messages record only the tools that were called, and Deferred / DeferredAtToolCallID are json:"-".
session_end hooks fire per run (per user turn), so they are the wrong place to prune.
Global Constraints
Same marking logic.Mark / MarkAt semantics don't change: search-only tools are ignored, load points come from the last successful tool call, and a tool seen before any tool call stays inline until one exists. The existing pkg/tools/deferrals_test.go cases keep passing against the new owner.
The migration only adds a nullable column. Existing sessions read as "no baseline yet": their next call records it, as today. Say so in the PR.
The state is private. It isn't surfaced in session summaries or API responses.
Test cost:
unit tests for the state and its JSON;
a SQLite round-trip test;
one runtime test, where the integration is the point: two runtimes, a reload in between, the same deferred request.
Each task ends green; chain lint and commit with &&.
Review Focus
Locking. The runtime marks from the run loop while other goroutines may clone or persist the session. The state needs its own mutex; Clone deep-copies it under that lock.
Save frequency. Save only when the state changes (first call, new load point), not on every model call.
Branch/fork copy. Load points are tool-call IDs in the copied history. Copy the state only when those IDs survive the branch point; otherwise start empty. Check branch.go's cut-off logic.
Phase 1: The state
Task 1.1: ToolDeferrals value
Files:pkg/tools/deferrals.go (or pkg/session, wherever the imports don't cycle), deferrals_test.go.
Step 1: Turn DeferralTracker's per-session maps into one value, ToolDeferrals{Initial []string, LoadPoints map[string]string}.
It has MarkAt(toolCallID, tools) (marked []Tool, changed bool) and Reset(), with its own mutex and JSON tags.
Port the existing tests; add changed assertions and a JSON round trip.
Step 2: Run the tests. Expected: PASS.
Step 3: Commit refactor(tools): make tool deferrals a per-session value.
SQLite: a session with a baseline and load points survives UpdateSession → reopen → GetSession.
An old row with no column value reads as empty.
Clone is a deep copy.
A branch copies the state when the load points are in the branch's history, and starts empty otherwise.
Step 2: Run them. Expected: FAIL.
Step 3: Implement.
Step 4: Run them again, plus go test ./pkg/session/.... Expected: PASS.
Step 5: Commit feat(session): persist the tool deferral baseline.
Phase 2: The runtime
Task 2.1: Use the session's state
Files:pkg/runtime/runtime.go (drop toolDeferrals), loop.go (mark), session_compaction.go (reset), the runtime tests that reference the tracker.
Step 1: Write a failing runtime test with the existing reprobe harness (TestReprobe_NewToolsAvailableAfterToolCall shape) and a SQLite store:
turn 1 calls a tool, after which a new tool appears, so it is deferred with load point call_1;
save, reopen the store, and build a new runtime;
turn 2 sends the new tool still Deferred, at call_1, and the first-call tools inline.
Step 2: Run it. Expected: FAIL (a new runtime re-baselines).
Step 3: Implement:
MarkAt on sess's state;
when changed and the session isn't a sub-session, UpdateSession (skip without a store);
compaction resets the session's state.
Step 4: Run the runtime tests, including TestReprobe_*. Expected: PASS.
Step 5: Mutation check: don't persist on change. The reload test must fail. Revert with the inverse edit.
Step 6: Commit fix(runtime): keep the tool deferral baseline with the session.
Task 2.2: No runtime-held state
Step 1:git grep -n "DeferralTracker\|toolDeferrals" finds no runtime-held tracker.
Step 2: A serve chat-style test: two pooled runtimes serving the same conversation in turn send the same deferred request. Reuse the chatserver test helpers if cheap; otherwise rely on Task 2.1 and note it in the PR.
Phase 3: Validation and PR
Task 3.1
Step 1: Run task build, task test, lint and go run ./lint ..
Step 2: Run go test -race -shuffle=on ./pkg/runtime/ ./pkg/session/... ./pkg/tools/....
a References table (Anthropic tool search defer_loading and tool_reference; prompt caching prefix).
Step 5: Check the PR for review comments.
Checklist
Phase 1: The state
Task 1.1: ToolDeferrals value
Task 1.2: On the session, persisted
Phase 2: The runtime
Task 2.1: Use the session's state
Task 2.2: No runtime-held state
Phase 3: Validation and PR
Task 3.1: Validate, review, PR, comments
Success Criteria
No runtime holds per-session deferral state, so nothing grows with sessions that ended.
A session sends the same tools, deferred at the same load points, across turns, new runtimes (serve a2a), pooled runtimes (serve chat) and SQLite reloads.
Existing sessions keep working: no baseline means the next call records one.
Native compaction still resets the baseline.
Build, tests, lint and race/shuffle pass. The reload test failed under its mutation check.
Activity
aheritier commentedon Oct 22, 2026
Implementation plan
Planned now, implemented later. The survey behind it covered every serve mode, both providers that defer tools, and the session store.
Goal:
Architecture:
toolDeferralsmap ontosession.Session, as a smallToolDeferralsvalue with its own lock.serve chatruntimes then share each conversation's baseline.tool_deferralsTEXT (JSON) column, read and written likeinstruction_context.UpdateSession, as the harness state does, and skips sub-sessions, which are saved when they complete.Clonekeeps it. Branches and forks copy it, because they replay the same history and its load points.Decisions (2026-10-22): the state lives on the session; it is persisted in a new column added by a migration.
Branch:
fix/session-tool-deferralsfrommain.Findings that shape the plan
serve chatpools runtimes across conversations, so entries grow without bound, and one conversation can see different baselines on different pooled runtimes.serve apikeeps a runtime per session until it is deleted./newand session loads.serve mcpandserve a2abuild a runtime per call or turn.serve a2abuilds a new runtime on every turn, so it loses the baseline every turn, not only after a restart.defer_loadingafter the inline ones, and replaces the load-pointtool_resultcontent withtool_referenceblocks.toolsand injectstool_search_call/tool_search_outputafter the load point.Deferred.Deferred/DeferredAtToolCallIDarejson:"-".session_endhooks fire per run (per user turn), so they are the wrong place to prune.Global Constraints
Mark/MarkAtsemantics don't change: search-only tools are ignored, load points come from the last successful tool call, and a tool seen before any tool call stays inline until one exists. The existingpkg/tools/deferrals_test.gocases keep passing against the new owner.&&.Review Focus
Clonedeep-copies it under that lock.branch.go's cut-off logic.Phase 1: The state
Task 1.1:
ToolDeferralsvalueFiles:
pkg/tools/deferrals.go(orpkg/session, wherever the imports don't cycle),deferrals_test.go.DeferralTracker's per-session maps into one value,ToolDeferrals{Initial []string, LoadPoints map[string]string}.MarkAt(toolCallID, tools) (marked []Tool, changed bool)andReset(), with its own mutex and JSON tags.changedassertions and a JSON round trip.refactor(tools): make tool deferrals a per-session value.Task 1.2: On the session, persisted
Files:
pkg/session/session.go(field, accessor,Clone),branch.go(copy rule);pkg/session/migrations.go(032tool_deferrals TEXT),store.go(select, insert, update, scan);tests next to each.
Step 1: Write failing tests.
UpdateSession→ reopen →GetSession.Cloneis a deep copy.Step 2: Run them. Expected: FAIL.
Step 3: Implement.
Step 4: Run them again, plus
go test ./pkg/session/.... Expected: PASS.Step 5: Commit
feat(session): persist the tool deferral baseline.Phase 2: The runtime
Task 2.1: Use the session's state
Files:
pkg/runtime/runtime.go(droptoolDeferrals),loop.go(mark),session_compaction.go(reset), the runtime tests that reference the tracker.TestReprobe_NewToolsAvailableAfterToolCallshape) and a SQLite store:call_1;Deferred, atcall_1, and the first-call tools inline.MarkAtonsess's state;changedand the session isn't a sub-session,UpdateSession(skip without a store);TestReprobe_*. Expected: PASS.fix(runtime): keep the tool deferral baseline with the session.Task 2.2: No runtime-held state
git grep -n "DeferralTracker\|toolDeferrals"finds no runtime-held tracker.serve chat-style test: two pooled runtimes serving the same conversation in turn send the same deferred request. Reuse the chatserver test helpers if cheap; otherwise rely on Task 2.1 and note it in the PR.Phase 3: Validation and PR
Task 3.1
task build,task test, lint andgo run ./lint ..go test -race -shuffle=on ./pkg/runtime/ ./pkg/session/... ./pkg/tools/....defer_loadingandtool_reference; prompt caching prefix).Checklist
ToolDeferralsvalueSuccess Criteria
serve a2a), pooled runtimes (serve chat) and SQLite reloads.