feat(core): add agent execution context

This commit is contained in:
Yichen Jiang
2026-07-16 16:29:46 +08:00
parent 04df615dd6
commit 7bcae0cd64
93 changed files with 1272 additions and 462 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-15-agent-execution-context.md: 9f41aee74dbd94fa5acf93bface57618c604ec17
2026-07-15-agent-execution-context.zh.md: 4747a506b4fb8a0ff798043ec772a4e810f84d7f

View File

@@ -0,0 +1,71 @@
# RFC: Agent execution context over AsyncLocalStorage
Status: implemented
English | [中文](2026-07-15-agent-execution-context.zh.md)
## 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.
## Decision
`@deepseek-ai/dsh-agent-execution` provides the mandatory `ctx.agentExecution` service using Node `AsyncLocalStorage`. The frame contains only the exact live Agent:
```text
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. 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](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning.
## 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.
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.
## 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 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.

View File

@@ -0,0 +1,71 @@
# RFC: 基于 AsyncLocalStorage 的 Agent 执行上下文
Status: implemented
[English](2026-07-15-agent-execution-context.md) | 中文
## 问题
Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」就会混淆这两种含义并在单进程并发驱动多个 Agent 时失效。
进程内深层基础设施仍需要可信的发起 Agent。能力传输层、追踪辅助函数、日志器和网关客户端可能位于显式 loop、工具及请求参数的下层。在每个私有辅助函数中传递 `agent` 会增加管道代码,而进程级可变槽会在 `await` 之间发生并发错误。模型可见参数同样不合适,因为模型不能选择可信的会话或路由请求头。
## 决策
`@deepseek-ai/dsh-agent-execution` 使用 Node `AsyncLocalStorage` 提供必载的 `ctx.agentExecution` 服务。该帧只包含准确的存活 Agent
```text
export interface AgentExecution {
readonly agent: Agent
}
export interface AgentExecutionService {
current(): AgentExecution | undefined
require(): AgentExecution
run<T>(execution: AgentExecution | undefined, operation: () => T): T
}
```
`current()` 执行可选读取,`require()` 抛出 `no agent execution context is active``run()` 保留操作返回的准确同步值或 Promise。`run(undefined, operation)` 会建立真实的清空边界,供不得继承 Agent 的工作使用。会话仍通过 `execution.agent.session` 推导轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。
`AgentLoop` 注入该服务,并用 `agentExecution.run({ agent }, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
隐式身份不会取代显式契约。`ToolExecution.agent``AssembleContext.agent``GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent``agentCtx.agent`、审批与 hook 主体、cwd 选择、取消、worker/进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。
提供方使用有序复合 effect。teardown 会先拒绝新边界,再移除服务并等待 AgentLoop 等注入方排空,随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。排空期间,进行中代码可通过保留的服务引用继续调用 `current()``require()`dispose 后,保留引用会抛出 `agent execution service is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown因此除 Cordis 依赖顺序外还必须统计活动边界。
`run()` 内创建的异步资源会继承其存储即使返回的操作没有等待它们。Agent 所拥有的前台工作可以继承 `{ agent }`,但仍使用其执行 seam 的显式取消和 dispose 契约。无关的定时器、队列和部署基础设施在 `run(undefined, operation)` 下启动并拥有显式停止操作。队列、worker、进程和协议边界必须序列化身份不能期待 ALS 传播。
宿主感知的传输层可以从 `ctx.agentExecution.require().agent.session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。
本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。
## 验证
服务测试锁定可选与必需读取、同步与跨 `await` 传播、并发与嵌套边界、显式清空、throw 或 rejection 后的恢复、准确返回值身份、排空顺序及已 dispose 引用错误。AgentLoop 集成测试覆盖重叠的真实驱动、嵌套父子创建、无 Agent 的直接工具执行、提供方或根 Context teardown 期间的取消、服务重启,以及 Agent dispose 后保留的引用。
测试替身能力传输层在内部推导 `X-Harness-Session-Id`,并断言工具 schema 与记录的参数都不包含身份字段。组合测试和生成目录确保默认 bundle、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方;缺少提供方时 AgentLoop 保持未激活。
## 考虑过的替代方案
**在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递但要求每个进程内私有辅助函数都携带 Agent 只会增加管道代码不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。
**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域并让并发行为变得意外。
**保存完整的可变运行时帧。** Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。重复保存会产生陈旧快照和另一套生命周期。包装对象为另行论证的陈旧安全标签保留扩展空间而不会把存储简化成裸 Agent。
**包含步骤级 `AbortSignal`、cwd、沙箱或授权。** 它们的生命周期与权限不匹配驱动边界,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。
**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步 continuation 间相互覆盖,因此可变全局值只在 Harness 不具备的串行保证下才正确。
**从模型可见参数推导身份。** 不能信任模型或用户输入来选择会话、租户或沙箱路由。
**给每个能力 seam 增加路由身份。** 这会把宿主关注点扩散到提供方无关 API。宿主感知实现拥有其传输请求头而公开边界继续显式传递身份。
## 后果
深层基础设施可以获得一个可信的进程内发起 Agent而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离缺少提供方时 AgentLoop 保持未激活HMR 或根 Context dispose 会在禁用 ALS 前达到静止状态。
该依赖不会出现在函数签名中并且携带一个存活能力对象。消费方必须将其限制在横切基础设施中把隐式存在视为既不证明存活、也不授予权限并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。
该帧有意省略轮次、步骤、signal、cwd、沙箱和授权。若真实消费方无法使用现有显式字段必须另行论证扩展陈旧字段最多只能误标遥测数据绝不能授予控制权。