Files
deepseek-harness/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md
Yichen Jiang d261a9ca3b sync rfc
2026-07-18 08:56:46 +08:00

9.3 KiB

RFC: Agent execution context over AsyncLocalStorage

Status: implemented

English | 中文

Problem

The harness has two useful but different notions of context. A Cordis Context selects services, registration ownership, and lifetime; agent.ctx is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root ctx.agent to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently.

Deep process-local infrastructure still needs a trusted initiating Agent. Capability transports, tracing helpers, loggers, and gateway clients may sit below the explicit loop, tool, and request parameters. Threading agent through every private helper adds plumbing, while a process-global mutable slot is incorrect across await. Model-visible arguments are also unsuitable because a model must not choose a trusted Session or routing header. This is mandatory control infrastructure rather than optional model-visible context enrichment.

Decision

@deepseek-ai/dsh-agent-execution provides the mandatory ctx.agentExecution service using Node AsyncLocalStorage. The frame contains only the exact live Agent:

export interface AgentExecution {
  readonly agent: Agent
}

export interface AgentExecutionService {
  current(): AgentExecution | undefined
  require(): AgentExecution
  run<T>(execution: AgentExecution | undefined, operation: () => T): T
}

current() is optional, require() throws no agent execution context is active, and run() preserves the operation's exact synchronous value or Promise. run(undefined, operation) establishes a real clearing boundary for work that must not inherit an Agent. A comparable implementation observed an uncleared ambient value crossing scheduled work into a later turn; the explicit undefined boundary prevents that class of leak. Session remains derived as execution.agent.session; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners.

AgentLoop injects the service and wraps each concrete driver's complete runLoop lifetime in agentExecution.run({ agent }, ...). Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished setup(agentCtx) remain outside the child's boundary: creation initiated by a parent runs under the parent identity, while agentCtx.agent explicitly identifies the child.

Ambient identity does not replace explicit contracts. ToolExecution.agent, AssembleContext.agent, GenerateOptions.sessionId, task ownership, parent/child requests, ctx.agent, agentCtx.agent, approval and hook subjects, cwd selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local.

The provider uses an ordered composite effect. Teardown first rejects new boundaries, then removes the service and awaits injected dependents such as AgentLoop, then waits for active returned-Promise boundaries before calling AsyncLocalStorage.disable(). current() and require() remain usable through a retained in-flight service reference while that drain runs; after disposal, retained calls throw agent execution service is disposed. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting is required in addition to Cordis dependency ordering.

Asynchronous resources created inside run() inherit its store even when the returned operation does not await them. Agent-owned foreground work may inherit { agent } but keeps the explicit cancellation and disposal contract of its execution seam. Unrelated timers, queues, and deployment infrastructure start under run(undefined, operation) and own an explicit stop. Queue, worker, process, and wire boundaries serialize identity rather than expecting ALS propagation.

A host-aware transport may derive a deployment-owned header such as X-Harness-Session-Id from ctx.agentExecution.require().agent.session.id; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam.

This decision extends the Agent registration-scope contract and its runtime design; it does not change their static agent.ctx meaning.

Reference model

Claude Code Harness design
AppState store Cordis deployment services and their owned live state
QueryEngine Agent driver plus loop-owned runtime state
ToolUseContext Explicit Agent, tool, and request parameters at capability seams
AgentContext ALS Narrow AgentExecution carrier
Transcript Event-sourced Session and persistence backends

Verification

Service tests pin optional and required reads, synchronous and awaited propagation, overlapping and nested boundaries, explicit clearing, restoration after throw or rejection, exact return identity, drain ordering, and disposed-reference errors. AgentLoop integration tests run overlapping real drivers, nested parent/child creation, agentless direct tool execution, cancellation during provider/root teardown, service restart, and a captured Agent after disposal. Detached-work coverage prevents context leakage without changing existing explicit cancellation contracts.

The test-double capability transport derives X-Harness-Session-Id internally and asserts that neither its tool schema nor logged arguments contains an identity field. Composition tests and generated catalogs keep the provider present in the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses; a missing provider leaves AgentLoop inactive. Documentation checks keep the repository layout in AGENTS.md, the package table in packages/core/README.md, and the group description in packages/README.md aligned with the mandatory provider.

Alternatives considered

Pass Agent through every function. Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds plumbing without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries.

Make ctx.agent dynamic. ctx.agent already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising.

Store a complete mutable runtime frame. Agent, Session, inbox, cancellation, turn, step, tool execution, and persistence already have authoritative owners. Duplicating them would create stale snapshots and another lifecycle. The wrapper leaves room for a separately justified stale-safe label without flattening the store to a bare Agent.

Include a step AbortSignal, cwd, sandbox, or authorization. Their lifetimes and authority do not match the driver boundary, and their existing seams already pass them explicitly. Adding a control capability requires a separate decision and nested lifecycle contract.

Use a process-global currentAgent. Concurrent Agents and subagents overwrite one another across awaited continuations, so a mutable global is correct only under a serialization guarantee the harness does not make.

Derive identity from model-visible arguments. Model or user input cannot be trusted to select Session, tenant, or sandbox routing.

Add routing identity to every capability seam. That spreads hosting concerns through provider-neutral APIs. A host-aware implementation owns its transport header while public boundaries remain explicit.

Consequences

Deep infrastructure gains one trusted process-local initiating Agent without widening existing tool and capability requests. Concurrent and nested drivers isolate automatically, AgentLoop stays inactive when the provider is absent, and HMR/root disposal reaches quiescence before ALS is disabled.

The dependency is implicit in function signatures and carries a live capability object. Consumers must restrict it to cross-cutting infrastructure, treat ambient presence as neither liveness nor authorization, and retain explicit cancellation and ownership checks. ALS also has an always-on propagation cost and does not cross worker, process, HTTP, or durable queue boundaries.

The teardown design deliberately accepts Node's Stability 1 (Experimental) AsyncLocalStorage.disable() dependency. Node requires disable() before an ALS instance can be garbage-collected, which matters when HMR replaces provider-owned instances; the service state guard prevents a later run() from re-entering the instance after disposal.

The frame deliberately omits turn, step, signal, cwd, sandbox, and authorization. A real consumer that cannot use existing explicit fields must justify any refinement separately; a stale copied field may at most mislabel telemetry, never grant control.