DSH / Atlas
2026-07-15implementedarchitecture

Initiating Agent scope over AsyncLocalStorage

基于 AsyncLocalStorage 的发起 Agent 作用域

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 Ag

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 sometimes needs a trusted initiating Agent below explicit loop, tool, and request parameters—for example, a host-aware transport, tracing helper, logger, or gateway client. Requiring every private helper to forward agent adds repetition, while a process-global mutable slot is incorrect across await. Model-visible arguments are unsuitable because a model must not choose a trusted Session or routing header. The carrier belongs to the Agent service rather than optional model-visible context.

Decision

The mandatory ctx.agents service uses Node AsyncLocalStorage to carry the initiating Agent. It stores the exact Agent directly rather than introducing a one-field frame; a separate private run token records nested boundary lineage only for teardown bookkeeping and carries no identity. The core-data catalog identifies the carried type.

currentInitiator() reads optionally, requireInitiator() throws no initiating agent is active, and withInitiator(agent, operation) preserves the operation's exact synchronous value or Promise. withoutInitiator(operation) establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as agent.session; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners.

AgentLoop already injects ctx.agents and wraps each concrete driver's complete runLoop lifetime in agents.withInitiator(agent, ...). Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from ctx.agents, derive agent.session once, and let operation-local helpers capture it instead of forwarding the concrete driver or Session through shallow interfaces. A leaf helper keeps a narrow Session parameter when that is its actual interface rather than accepting a broader Context only for an ambient lookup.

Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as withInitiator() returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished setup(agentCtx) remain outside the child's driver 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, job 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.

AgentRegistry owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing ctx.agents then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling AsyncLocalStorage.disable(). If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. currentInitiator() and requireInitiator() remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw agent initiator scope is disposed. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering.

Initiator scope does not own detached work: registry drain tracks only the Promise returned by withInitiator() or withoutInitiator(). Asynchronous resources created inside a boundary inherit its store until they settle or ALS is disabled, so their owning seam must stop unreturned work explicitly. Agent-owned foreground work returns its lifetime and keeps its cancellation contract. Unrelated timers, queues, and deployment infrastructure start under withoutInitiator(operation); 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.agents.requireInitiator().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.

Verification

Agent service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, intrinsic Promise settlement observation, overlapping, nested, and cleared boundaries, restoration after throws or rejection, ordinary and reentrant drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, AgentRegistry restart, root teardown, and package-private loop and tool scheduling through the ambient lookup. Composition, module-graph, build, and runtime-closure checks keep ctx.agents wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses without another provider.

A test-double host-aware transport derives X-Harness-Session-Id internally and verifies that tool schema and logged arguments contain no identity field. The service deliberately does not drain async work omitted from the Promise returned by the boundary operation; that work remains subject to its owner's explicit stop contract.

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 repetitive forwarding 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.

Add a separate ctx.agentExecution service. The carrier has no independent backend, configuration, or identity type: it stores the same Agent that ctx.agents already owns, and AgentLoop already depends on that service. A second mandatory provider would add package, composition, lifecycle, generated-catalog, and test-harness wiring without separating a real capability.

Store a named or complete runtime frame. A one-field { agent } frame only wraps the value, while Agent, Session, inbox, cancellation, turn, step, tool execution, and persistence already have authoritative owners. Adding more fields would create stale snapshots and another lifecycle; carrying Agent directly keeps the boundary named by its methods without duplicating state.

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 gains no additional mandatory service, and HMR/root disposal reaches quiescence before ALS is disabled.

The dependency is implicit in function signatures and carries a capability-bearing Agent 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 AgentRegistry-owned instances; the service state guard prevents a later boundary from re-entering the instance after disposal.

The scope deliberately carries only the Agent, omitting 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.

中文

问题

harness 中存在两种有用但不同的上下文概念。Cordis Context 负责选择服务、注册归属和生命周期;agent.ctx 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 ctx.agent 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。

进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 agent 会造成重复,而进程级可变槽会在跨 await 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。

决策

必需的 ctx.agents 服务使用 Node AsyncLocalStorage 携带发起 Agent。它直接存储同一个 Agent,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。核心数据目录标明了所携带的类型。

currentInitiator() 用于可选读取,requireInitiator() 抛出 no initiating agent is activewithInitiator(agent, operation) 保留操作返回的同步值或 Promise 本身。withoutInitiator(operation) 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 agent.session 推导;轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。

AgentLoop 已经注入 ctx.agents,并用 agents.withInitiator(agent, ...) 包裹每个具体驱动的完整 runLoop 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 ctx.agents 恢复同一个 Agent,一次推导 agent.session,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 Session。若 Session 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 Session 参数,而不会只为隐式查找而接收更宽泛的 Context

因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;withInitiator() 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 setup(agentCtx) 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 agentCtx.agent 显式标识子 Agent。

隐式身份不会取代显式约定。ToolExecution.agentAssembleContext.agentGenerateOptions.sessionId、任务归属、父子请求、ctx.agentagentCtx.agent、审批与 hook 主体、cwd 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。

AgentRegistry 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 ctx.agents 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 AsyncLocalStorage.disable()。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 currentInitiator()requireInitiator();dispose(资源释放)后,发起方方法会抛出 agent initiator scope is disposed。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。

发起方作用域不负责管理脱离返回链的工作:注册表排空只跟踪 withInitiator()withoutInitiator() 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所属的前台工作会把完整生命周期纳入返回值,并保留显式取消约定。无关的定时器、队列和部署基础设施在 withoutInitiator(operation) 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。

宿主感知的传输层可以从 ctx.agents.requireInitiator().session.id 推导由部署方拥有的 X-Harness-Session-Id 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。

本决策扩展 Agent 注册作用域约定及其运行时设计,不会改变其中 agent.ctx 的静态含义。

验证

Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise 的精确身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启、根 Context 销毁,以及包内私有的循环和工具调度通过隐式查找完成。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 ctx.agents 完成接线,无需其他提供方。

测试替身形式的宿主感知传输层在内部推导 X-Harness-Session-Id,并验证工具 schema 与日志中记录的参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止约定管理。

考虑过的替代方案

在每个函数中传递 Agent。 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。

ctx.agent 变成动态值。 ctx.agent 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。

新增独立的 ctx.agentExecution 服务。 该载体没有独立后端、配置或身份类型:它存储的是 ctx.agents 已经管理的同一个 Agent,而 AgentLoop 本就依赖该服务。第二个必需提供方会增加包、组合、生命周期、生成目录及测试 harness 接线,却没有拆出真实能力。

保存命名帧或完整运行时帧。 只有一个字段的 { agent } 帧只是包装该值,而 Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。增加更多字段会产生陈旧快照和另一套生命周期;直接携带 Agent,由方法名标识边界,无需重复保存状态。

包含步骤级 AbortSignalcwd、沙箱或授权。 它们的生命周期及权限范围与驱动边界不一致,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期约定。

使用进程级 currentAgent 并发 Agent 和 subagent 会在异步延续执行之间相互覆盖,因此可变全局值只在 harness 不具备的串行保证下才正确。

从模型可见参数推导身份。 不能信任模型或用户输入来选择会话、租户或沙箱路由。

给每个能力 seam 增加路由身份。 这会把宿主关注点扩散到提供方无关 API。宿主感知实现拥有其传输请求头,而公开边界继续显式传递身份。

后果

深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,AgentLoop 不增加新的必需服务,HMR(热模块替换)或根 Context dispose 会在禁用 ALS 前达到完全停稳。

该依赖不会出现在函数签名中,并且携带一个具有控制能力的 Agent 对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。

该销毁设计有意依赖 Node 的 Stability 1(实验性) API AsyncLocalStorage.disable()。Node 要求在 ALS 实例可被垃圾回收前调用 disable(),这对 HMR 替换 AgentRegistry 所拥有的实例尤为重要;服务状态守卫会阻止 dispose 后通过后续边界重新进入该实例。

该作用域有意只携带 Agent,省略轮次、步骤、signalcwd、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。