Repository navigation
refactor(runtime): make the tool-result archive capability indivisible #2026
Description
Problem
The tool-result archive capability is currently three independent optional fields spread across two different configuration surfaces:
archiveToolResult(writer) → backend optionsreadToolResultArchive(replay hydration reader) → backend optionsarchiveResources(ref-addressed reader poweringArchiveRead) → tool options
Nothing structurally ties them together, so "writer enabled, reader tool absent" is a representable state. It has already bitten twice: once on the child-agent path (patched locally via CHILD_RECOVERY_TOOL_NAMES in packages/runtime/src/subagent-tools.ts:35) and once at the headless top-level surface (#2025).
Desktop happens to be correct only because both sides were wired by hand in two separate places (apps/desktop/src/main/tool-assembly.ts:328,398 and apps/desktop/src/main/session-stream.ts:333).
Proposal
Add a factory in packages/runtime that returns one indivisible bundle:
buildToolResultArchiveCapability({ recorder, resourceReader }) => {
archiveToolResult, // → backend input
readToolResultArchive, // → replay hydration
tools: [ArchiveRead], // → host tool surface
}Hosts supply only the host-specific storage pieces (desktop: artifactStore; Harbor: local archive dir). A host either has the whole capability or none of it. CLI keeps "none", which stays a valid state.
This makes the illegal state unrepresentable by construction rather than caught by an after-the-fact assertion.
Explicitly out of scope
- No generic
capability → toolderivation framework. Archiving is currently the only real instance of this "writer implies reader tool" coupling.runtimeResourcesandsnapshotImagelook similar but differ in kind: when missing they fail at the call site rather than leaving a dangling reference. Generalize when a second real case appears. - No bidirectional catalog invariant. The catalog is the full product vocabulary and host bindings are legitimately a subset (economy policy narrows surfaces, desktop-only tools are absent on headless,
disabledSurfaceIds: ['agent']is intentional).catalog ⊆ boundis never true; the correct invariant is the conditional implicationarchiveToolResult present ⟹ ArchiveRead ∈ bound. - No consolidation of
CHILD_RECOVERY_TOOL_NAMESandDEEP_RESEARCH_ALLOWED_TOOL_NAMES. They are consumer-side allowlists with distinct responsibilities (surface-narrowing recovery rule vs. mode-level product policy), not duplicate registration state. Extracting an exportedARCHIVE_READ_TOOL_NAMEconstant to remove string drift is sufficient. - No activation of the catalog's
effectsfield. It cannot express same-authority coupling between writer and reader.
Dependencies
Blocked by #2025. That one fixes behavior; this one is a behavior-neutral consolidation. They must not be combined — mixing a refactor with a behavior change makes benchmark deltas unattributable.
Activity
Astro-Han commentedon Aug 5, 2026
Claiming this — starting work now.
Two corrections to the premises in the issue body, both verified against current
main:1.
CHILD_RECOVERY_TOOL_NAMESis not a working patch. It addsArchiveReadto the candidate pool returned bybuildChildAgentTools, but every child surface is then re-narrowed by the strict allowlistbuildToolsForAgentDefinition(packages/runtime/src/agent-catalog.ts:293) againstdefinition.tools, and none of the three builtin agent definitions listsArchiveRead. The durable snapshot records[...definition.tools](session-manager.ts:2490,2549,3077), so resume behaves the same. Children cannot callArchiveReadtoday. The child path is not "patched locally" — it is the same defect as #2025, still live, and the only test covering it (subagent-tools.test.ts:457) asserts the pool rather than the child surface, sharing the same one-directional blind spot the implementation has.2. Four capability assembly authorities, not three: embedded desktop, runtime-host server, CLI, and headless/Harbor. Desktop's runtime-host client path is protocol-only and assembles no backend capability. CLI supplies none of the three fields — the legitimate "no archive at all" state, which stays expressible with no escape hatch.
Adjustment to the proposal
The factory as originally specified returns
tools: [ArchiveRead]for the host to bind. That still leaves the capability with two delivery addresses — backend options and the tool surface — constructed at different layers (desktop:session-stream.ts:332vstool-assembly.ts:189). A host can hold a complete bundle and deliver it to one address only, which reproduces exactly the state we are trying to eliminate. The factory makes a half-built capability unconstructible; it does not make a half-delivered one unrepresentable.So the capability carries the tool, and the backend binds it:
AiSdkBackendInputdropsarchiveToolResult(ai-sdk-compaction-contract.ts:160) andreadToolResultArchive(ai-sdk-backend.ts:604) for a single optionaltoolResultArchive, and resolves its own effective tool set from it.archiveResourcesdisappears frombuiltin-tools.ts:147,301andheadless/src/tools.ts:44,113.One address, so nothing to forget; no invariant assertion needed, because there is no longer a second surface to check against. The reasoning is that the placeholder is a runtime-generated protocol value and
ArchiveReadis its decoder — which host binds it was never a legitimate configuration question.This also removes the need for any
AgentDefinitionor durable-snapshot change:ArchiveReadnever entersdefinition.tools, so child sessions inherit it from the same backend seam andCHILD_RECOVERY_TOOL_NAMESis deleted rather than fixed.What this changes on purpose, and must not slip through review
BackendFactoryContext.tools(session-manager.ts:705) is currently documented as a hard ceiling: a host "may remove tools for stricter local policy, but must never append, substitute, or otherwise expose a tool outside this exact set." Binding a runtime protocol decoder inside the backend needs that contract rewritten explicitly to carve out runtime recovery tools as not-an-agent-permission, rather than quietly leaning on the fact that the constraint addresses hosts.Product-surface identity is computed before the backend, so it will not list the bound
ArchiveReadunless it is either derived from the backend's effective tools or given an explicitprotocolCapabilitiesfield. Benchmark execution identity should not silently under-report the real tool surface.Expected non-behavioral churn: request-shape hash baselines, and writer-only unit fixtures that currently construct the illegal state and will need a whole capability.
Delivery
One draft PR. This is an atomic type migration across
runtime,runtime-host,desktopandheadless; splitting it produces intermediate states where the new capability exists but the old split entry points still accept a half-wired archive. The child-surface fix is not a separable behavior change here — it falls out of the same seam.refactor(runtime): make the tool-result archive capability indivisible (