diff --git a/docs/architecture.md b/docs/architecture.md
index a6c618b898..2644e787b6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -16,7 +16,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
| `ctx.sessions` | `dsh-session` | in-memory event-sourced sessions |
| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
-| `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events |
+| `ctx.agents` | `dsh-agent` | live agents, creation delegation, `agent/*` events, and process-local initiating Agent scope |
| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver |
### Capability Services
@@ -118,6 +118,10 @@ Every session event is turn-enclosed. Reloading preserves an interrupted tail an
Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, receive only that agent's dispatches, and unwind with it; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. Typed resolvers derive carrier checks from merged `Events` signatures and `scopeTarget` ([semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md) and [subagent composition controls](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md).
+### Initiating Agent Scope
+
+`AgentLoop` runs each process-local driver inside `ctx.agents.withInitiator()`; the [decision](rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns boundary and explicit-identity rules.
+
## State
### Session Log
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 58dabd691f..c119b79c18 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -49,7 +49,7 @@ flowchart LR
pkg_skill["skill"]
svc_skills["ctx.skills
Skill provider registry"]
pkg_skill_local["skill-local"]
- svc_agents["ctx.agents
Agent registry"]
+ svc_agents["ctx.agents
Agent service"]
svc_agentLoop["ctx.agentLoop
Concrete loop driver"]
pkg_agent_spine_demo["agent-spine-demo"]
pkg_bash["bash"]
@@ -215,7 +215,7 @@ flowchart LR
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-demo`](../packages/examples/stdio-demo), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. |
-| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
+| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-demo`](../packages/examples/stdio-demo), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors replace bash-local without touching them. |
| `ctx.bashEnv` | `core` | [`tool-bash`](../packages/bash/tool-bash) | - | - | - | Plugins declare effect-scoped DSH_* facts; tool-bash collects one trusted snapshot per execution and the executor rebuilds the namespace. |
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 78ec4c82c0..30ca04ba79 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -48,9 +48,46 @@ Source: [`packages/core/agent-loop/src/index.ts:407`](../../packages/core/agent-
## `ctx.agents` — `AgentRegistry`
-Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
+Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
```ts cordis-catalog
+/**
+ * Read the Agent that initiated the inherited asynchronous driver chain.
+ * @returns the inherited Agent, or `undefined` outside a driver and inside an explicit clearing boundary.
+ * @throws when this service instance has been disposed.
+ */
+currentInitiator(): Agent | undefined
+
+/**
+ * Read the initiating Agent and fail when no driver boundary is active.
+ * @returns the inherited Agent.
+ * @throws when no initiator is active or this service instance has been disposed.
+ */
+requireInitiator(): Agent
+
+/**
+ * Run an operation with one exact Agent as its process-local initiator. The
+ * exact synchronous value or Promise returned by the operation is preserved.
+ * If its inherited async chain starts an owning-fiber unload, the nested
+ * boundary lineage is excluded from the drain so teardown cannot wait on itself.
+ * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.
+ * @param operation - synchronous or asynchronous operation to invoke.
+ * @returns the exact value returned by `operation`.
+ * @throws when the initiator scope is closing/disposed, or when `operation` throws.
+ */
+withInitiator(agent: Agent, operation: () => T): T
+
+/**
+ * Run an operation inside a boundary that hides any inherited initiating
+ * Agent. The exact synchronous value or Promise is preserved.
+ * If its inherited async chain starts an owning-fiber unload, the nested
+ * boundary lineage is excluded from the drain so teardown cannot wait on itself.
+ * @param operation - synchronous or asynchronous operation to invoke without an initiator.
+ * @returns the exact value returned by `operation`.
+ * @throws when the initiator scope is closing/disposed, or when `operation` throws.
+ */
+withoutInitiator(operation: () => T): T
+
/**
* Register the agent-creation factory (the loop calls this on construction,
* effect-scoped). A traced Cordis service is canonicalized to its concrete
@@ -165,7 +202,7 @@ roots(): Agent[]
Types: [Agent](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/index.ts:201`](../../packages/core/agent/src/index.ts)
+Source: [`packages/core/agent/src/index.ts:211`](../../packages/core/agent/src/index.ts)
## `ctx.approval` — `ApprovalService`
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index dfb59b3ac6..ac2440cd7c 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -403,6 +403,10 @@ interface Agent {
The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, checkpoint, and waterfall contracts. Turn and step boundaries are durable session events rather than agent emits.
+## Initiating Agent
+
+The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns its lifetime and boundary rules.
+
## Interception decisions
Each `agent/*` interception waterfall returns a small, seam-specific typed union — the unified Decision idiom (the tool seams' `PreToolDecision`/`PostToolDecision` in [tools.md](tools.md) follow the same shape). A CC/Codex hook bridge maps its `permissionDecision`/`decision`/`continue`/`additionalContext` fields onto these; a native plugin returns them directly. Prompt and post-tool decisions share one model-facing context shape, `HookContext`, which is `inject()`ed as a `context/message` and therefore carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). Its optional `envelope` selects the canonical context tag or caller-owned raw framing, while JSON `meta` persists plugin state without exposing it to the model. Both decisions carry `additionalContexts[]` so every entry preserves its own provenance, framing, and metadata. Continuation reasons are steering messages instead and deliberately use the narrower content/source shape.
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 5a61146d73..78f878106c 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -53,5 +53,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event string | Dispatchers | Listeners |
| --- | --- | --- |
| `internal/dispatch` | - | [`invariants`](../packages/support/invariants) |
+| `internal/status` | - | [`agent`](../packages/core/agent) |
Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program.
diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md
index 79cfdb7a31..16a5563532 100644
--- a/docs/rfc/INDEX.md
+++ b/docs/rfc/INDEX.md
@@ -163,6 +163,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 |
| [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 |
| [Provider-routed LLM adapters and a generic pi-ai backend](implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) | 2026-07-14 |
+| [Initiating Agent scope over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-initiator-scope.md) | 2026-07-15 |
| [Advisory LLM catalogs and per-session ACP model selection](implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) | 2026-07-15 |
| [Replay token meter service](implemented/architecture/2026-07-15-replay-token-meter-service.md) | 2026-07-15 |
diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
new file mode 100644
index 0000000000..9e2a50bd57
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
@@ -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-initiator-scope.md: b3c9be0be1dea29568dfcdeb0578e643734486e8
+2026-07-15-agent-initiator-scope.zh.md: 55494977b8ade0d380fa21b25171bce65a46a9fb
diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md
new file mode 100644
index 0000000000..b3c9be0be1
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md
@@ -0,0 +1,63 @@
+# RFC: Initiating Agent scope over AsyncLocalStorage
+
+Status: implemented
+
+English | [中文](2026-07-15-agent-initiator-scope.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 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](../../../core-data-structures/core.md#initiating-agent) 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, ...)`. Concurrent drivers therefore 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`, 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.
+
+`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](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
+
+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, and root teardown. 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.
+
+Only a test-double host-aware transport consumes ambient identity; it 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)](https://nodejs.org/api/async_context.html#asynclocalstoragedisable) `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.
diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
new file mode 100644
index 0000000000..55494977b8
--- /dev/null
+++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
@@ -0,0 +1,63 @@
+# RFC: 基于 AsyncLocalStorage 的发起 Agent 作用域
+
+Status: implemented
+
+[English](2026-07-15-agent-initiator-scope.md) | 中文
+
+## 问题
+
+Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。
+
+进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。
+
+## 决策
+
+必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../core-data-structures/core.md#initiating-agent)标明了所携带的类型。
+
+`currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。
+
+`AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。
+
+隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.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 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `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`,由方法名标识边界,无需重复保存状态。
+
+**包含步骤级 `AbortSignal`、`cwd`、沙箱或授权。** 它们的生命周期及权限范围与驱动边界不一致,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。
+
+**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步延续执行之间相互覆盖,因此可变全局值只在 Harness 不具备的串行保证下才正确。
+
+**从模型可见参数推导身份。** 不能信任模型或用户输入来选择会话、租户或沙箱路由。
+
+**给每个能力 seam 增加路由身份。** 这会把宿主关注点扩散到提供方无关 API。宿主感知实现拥有其传输请求头,而公开边界继续显式传递身份。
+
+## 后果
+
+深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,AgentLoop 不增加新的必需服务,HMR 或根 Context dispose 会在禁用 ALS 前完成排空。
+
+该依赖不会出现在函数签名中,并且携带一个具有控制能力的 Agent 对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。
+
+该销毁设计有意依赖 Node 的 [Stability 1(实验性)](https://nodejs.org/api/async_context.html#asynclocalstoragedisable) API `AsyncLocalStorage.disable()`。Node 要求在 ALS 实例可被垃圾回收前调用 `disable()`,这对 HMR 替换 AgentRegistry 所拥有的实例尤为重要;服务状态守卫会阻止 dispose 后通过后续边界重新进入该实例。
+
+该作用域有意只携带 Agent,省略轮次、步骤、`signal`、`cwd`、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。
diff --git a/packages/README.md b/packages/README.md
index ee275aec8c..61a1e994c8 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -8,7 +8,7 @@ Packages live at `packages///`; groups are containers, while names r
| Group | Role | Release expectation |
|---|---|---|
-| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
+| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 7a02519b02..5d99506c8a 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -63,8 +63,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'agents',
- summary: 'Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package.',
+ summary: 'Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain.',
methods: [
+ 'currentInitiator(): Agent | undefined',
+ 'requireInitiator(): Agent',
+ 'withInitiator(agent: Agent, operation: () => T): T',
+ 'withoutInitiator(operation: () => T): T',
'setFactory(factory: AgentFactory): () => void',
'async create(options: CreateAgentOptions): Promise',
'async resume(options: ResumeAgentOptions): Promise',
diff --git a/packages/core/README.md b/packages/core/README.md
index b7e059c34e..fe98a78c22 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -8,11 +8,11 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
-| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
+| `agent/` | Agent interface, live registry, process-local initiator scope, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` |
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
-`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
+`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md
index 5b7e0307f1..db5861f8c1 100644
--- a/packages/core/agent-loop/README.md
+++ b/packages/core/agent-loop/README.md
@@ -50,7 +50,7 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati
### Loop lifecycle (`loop.ts`)
-The internal loop driver runs one agent for its whole lifetime:
+The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the driver boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts
index d93832b361..6ab02bfc13 100644
--- a/packages/core/agent-loop/src/agent.ts
+++ b/packages/core/agent-loop/src/agent.ts
@@ -387,7 +387,7 @@ export class ReactLoopAgent implements Agent {
[startDriver](): void {
if (this._status === 'disposed') return
this.driverStarted = true
- this.done = runLoop(this.loopCtx, this, {
+ this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, this, {
inbox: this.#inbox,
maxParallelToolCalls: this.maxParallelToolCalls,
setStatus: (status) => { this.setStatus(status) },
@@ -400,7 +400,7 @@ export class ReactLoopAgent implements Agent {
withToolBatch: run => this.withToolBatch(run),
// Pre-step cancellation re-parks without emitting a status transition.
settleIdle: () => { this.settleIdleWaiters() },
- })
+ }))
}
/**
diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts
new file mode 100644
index 0000000000..8e9b951fa1
--- /dev/null
+++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts
@@ -0,0 +1,335 @@
+import { describe, expect, it } from 'vitest'
+import { Context, type Fiber } from 'cordis'
+import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
+import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
+
+interface Harness {
+ ctx: Context
+ agentsFiber: Fiber
+ loopFiber: Fiber
+}
+
+async function harness(adapter: LlmAdapter): Promise {
+ const ctx = new Context()
+ await ctx.plugin(LlmService)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ const agentsFiber = await ctx.plugin(AgentRegistry)
+ const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
+ ctx.llm.registerAdapter(['mock'], adapter)
+ return { ctx, agentsFiber, loopFiber }
+}
+
+function waitForIdle(ctx: Context, agent: Agent): Promise {
+ return new Promise((resolve) => {
+ const dispose = ctx.on('agent/status', (subject, status) => {
+ if (subject === agent && status === 'idle') {
+ dispose()
+ resolve()
+ }
+ })
+ })
+}
+
+function send(agent: Agent, text: string): void {
+ agent.send([{ type: 'text', text }])
+}
+
+/** Adapter that holds both drivers at the same awaited continuation. */
+class OverlapAdapter extends LlmAdapter {
+ private readonly bothStarted = Promise.withResolvers()
+ private starts = 0
+ readonly observations: { sessionId: SessionId | undefined; before: Agent; after: Agent }[] = []
+
+ constructor(private readonly ctx: Context) {
+ super()
+ }
+
+ async * stream(options: GenerateOptions): AsyncIterable {
+ const before = this.ctx.agents.requireInitiator()
+ this.starts += 1
+ if (this.starts === 2) this.bothStarted.resolve(true)
+ await this.bothStarted.promise
+ await Promise.resolve()
+ const after = this.ctx.agents.requireInitiator()
+ this.observations.push({ sessionId: options.sessionId, before, after })
+ yield* textResponse('done')
+ }
+}
+
+/** Test-only transport that materializes ambient identity at its request boundary. */
+class TestCapabilityTransport {
+ readonly requests: { path: string; headers: Record }[] = []
+
+ constructor(private readonly agents: AgentRegistry) {}
+
+ async request(path: string): Promise> {
+ await Promise.resolve()
+ const headers = {
+ 'X-Harness-Session-Id': this.agents.requireInitiator().session.id,
+ }
+ this.requests.push({ path, headers })
+ return headers
+ }
+}
+
+/** Adapter whose first call waits for cancellation and whose later calls complete. */
+class ReloadAdapter extends LlmAdapter {
+ readonly firstStarted = Promise.withResolvers()
+ firstAgentDuringAbort: Agent | undefined
+ laterAgent: Agent | undefined
+ calls = 0
+ agents: AgentRegistry | undefined
+
+ async * stream(options: GenerateOptions): AsyncIterable {
+ const agents = this.agents
+ if (agents === undefined) throw new Error('agent service missing')
+ this.calls += 1
+ if (this.calls === 1) {
+ this.firstStarted.resolve(true)
+ try {
+ await new Promise((_resolve, reject) => {
+ const abort = (): void => { reject(new Error('aborted')) }
+ if (options.signal?.aborted === true) abort()
+ else options.signal?.addEventListener('abort', abort, { once: true })
+ })
+ } catch (error: unknown) {
+ await Promise.resolve()
+ this.firstAgentDuringAbort = agents.requireInitiator()
+ throw error
+ }
+ return
+ }
+ await Promise.resolve()
+ this.laterAgent = agents.requireInitiator()
+ yield* textResponse('reloaded')
+ }
+}
+
+describe('AgentLoop initiator scope', () => {
+ it('keeps overlapping driver continuations bound to their exact Agents', async () => {
+ const ctx = new Context()
+ const adapter = new OverlapAdapter(ctx)
+ await ctx.plugin(LlmService)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ ctx.llm.registerAdapter(['mock'], adapter)
+
+ const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
+ const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
+ const idleA = waitForIdle(ctx, a)
+ const idleB = waitForIdle(ctx, b)
+ send(a, 'a')
+ send(b, 'b')
+ await Promise.all([idleA, idleB])
+
+ expect(adapter.observations).toHaveLength(2)
+ expect(adapter.observations).toEqual(expect.arrayContaining([
+ { sessionId: a.session.id, before: a, after: a },
+ { sessionId: b.session.id, before: b, after: b },
+ ]))
+ expect(ctx.agents.currentInitiator()).toBeUndefined()
+ await ctx.fiber.dispose()
+ })
+
+ it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
+ const adapter = new MockAdapter([
+ toolCallResponse('spawn', 'spawn-child', {}),
+ toolCallResponse('observe', 'observe-child', {}),
+ textResponse('child done'),
+ textResponse('parent done'),
+ ])
+ const { ctx } = await harness(adapter)
+ let parentDuringSetup: Agent | undefined
+ let explicitChild: Agent | undefined
+ let childDuringDriver: Agent | undefined
+ let parentWhileChildDriverActive: Agent | undefined
+ let child: Agent | undefined
+
+ ctx.tools.register(defineTool({
+ name: 'spawn-child',
+ description: 'create one child agent',
+ parameters: {},
+ execute: async (_args, exec) => {
+ if (exec.agent === undefined) throw new Error('parent agent missing')
+ const handle = await exec.agent.ctx.agents.create({
+ sessionId: SessionId('child-session'),
+ agentOptions: { provider: 'mock', model: 'mock' },
+ setup: (agentCtx) => {
+ parentDuringSetup = ctx.agents.requireInitiator()
+ explicitChild = agentCtx.agent
+ agentCtx.tools.register(defineTool({
+ name: 'observe-child',
+ description: 'observe child execution identity',
+ parameters: {},
+ execute: async () => {
+ await Promise.resolve()
+ childDuringDriver = ctx.agents.requireInitiator()
+ return [{ type: 'text', text: 'observed' }]
+ },
+ }))
+ },
+ })
+ child = handle.agent
+ parentWhileChildDriverActive = ctx.agents.requireInitiator()
+ send(handle.agent, 'run child')
+ await handle.agent.whenIdle()
+ await handle.dispose()
+ return [{ type: 'text', text: 'child completed' }]
+ },
+ }))
+
+ const parentHandle = await ctx.agents.create({
+ sessionId: SessionId('parent-session'),
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ const idle = waitForIdle(ctx, parentHandle.agent)
+ send(parentHandle.agent, 'spawn')
+ await idle
+
+ expect(parentDuringSetup).toBe(parentHandle.agent)
+ expect(explicitChild).toBe(child)
+ expect(childDuringDriver).toBe(child)
+ expect(parentWhileChildDriverActive).toBe(parentHandle.agent)
+ expect(ctx.agents.currentInitiator()).toBeUndefined()
+ await parentHandle.dispose()
+ await ctx.fiber.dispose()
+ })
+
+ it('keeps agentless direct tools ambient-free and builds trusted transport headers internally', async () => {
+ const adapter = new MockAdapter([
+ toolCallResponse('capability', 'capability-request', { path: '/v1/capability' }),
+ textResponse('done'),
+ ])
+ const { ctx } = await harness(adapter)
+ const transport = new TestCapabilityTransport(ctx.agents)
+ let directAmbient: Agent | undefined
+ let captured: Agent | undefined
+
+ ctx.tools.register(defineTool({
+ name: 'agentless-probe',
+ description: 'observe an agentless call',
+ parameters: {},
+ execute: async () => {
+ await Promise.resolve()
+ directAmbient = ctx.agents.currentInitiator()
+ return [{ type: 'text', text: 'ok' }]
+ },
+ }))
+ ctx.tools.register(defineTool({
+ name: 'capability-request',
+ description: 'call the test capability transport',
+ parameters: { path: { type: 'string' } },
+ execute: async (args) => {
+ captured = ctx.agents.requireInitiator()
+ const path = (args as { path: string }).path
+ const headers = await transport.request(path)
+ return [{ type: 'text', text: JSON.stringify(headers) }]
+ },
+ }))
+
+ const direct = await ctx.tools.execute({
+ callId: CallId('direct'),
+ name: 'agentless-probe',
+ arguments: {},
+ })
+ expect(direct.isError).toBe(false)
+ expect(directAmbient).toBeUndefined()
+
+ const handle = await ctx.agents.create({
+ sessionId: SessionId('transport-session'),
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ const idle = waitForIdle(ctx, handle.agent)
+ send(handle.agent, 'call transport')
+ await idle
+
+ expect(transport.requests).toEqual([{
+ path: '/v1/capability',
+ headers: { 'X-Harness-Session-Id': 'transport-session' },
+ }])
+ const schema = adapter.requests[0]?.tools?.find(tool => tool.name === 'capability-request')
+ expect(JSON.stringify(schema?.parameters)).not.toMatch(/session|harness/i)
+ const call = handle.agent.session.events.find(event => event.type === 'tool/call')
+ expect(call?.type === 'tool/call' ? call.data.arguments : undefined)
+ .toBe(JSON.stringify({ path: '/v1/capability' }))
+ expect(captured).toBe(handle.agent)
+
+ await handle.dispose()
+ expect(captured?.status).toBe('disposed')
+ expect(ctx.agents.currentInitiator()).toBeUndefined()
+ await ctx.fiber.dispose()
+ })
+
+ it('drains the old driver before disabling ALS during agent-service restart', async () => {
+ const adapter = new ReloadAdapter()
+ const { ctx, agentsFiber, loopFiber } = await harness(adapter)
+ const oldService = ctx.agents
+ adapter.agents = oldService
+ const oldHandle = await ctx.agents.create({
+ sessionId: SessionId('before-restart-session'),
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ const oldAgent = oldHandle.agent
+ send(oldAgent, 'block')
+ await adapter.firstStarted.promise
+
+ await agentsFiber.restart()
+ await loopFiber.await()
+ expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
+ expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
+ expect(oldAgent.status).toBe('disposed')
+ expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed')
+ expect(ctx.agents).not.toBe(oldService)
+ adapter.agents = ctx.agents
+
+ const newHandle = await ctx.agents.create({
+ sessionId: SessionId('after-restart-session'),
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ const newAgent = newHandle.agent
+ const idle = waitForIdle(ctx, newAgent)
+ send(newAgent, 'continue')
+ await idle
+ expect(adapter.laterAgent?.id).toBe(newAgent.id)
+ expect(adapter.laterAgent?.session).toBe(newAgent.session)
+ await ctx.fiber.dispose()
+ })
+
+ it('keeps ALS readable while root disposal drains sibling AgentLoop fibers', async () => {
+ const ctx = new Context()
+ const adapter = new ReloadAdapter()
+ await ctx.plugin(LlmService)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRegistry)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ ctx.llm.registerAdapter(['mock'], adapter)
+ const service = ctx.agents
+ adapter.agents = service
+ const handle = await ctx.agents.create({
+ sessionId: SessionId('root-dispose-session'),
+ agentOptions: { provider: 'mock', model: 'mock' },
+ })
+ const agent = handle.agent
+ send(agent, 'block')
+ await adapter.firstStarted.promise
+
+ await ctx.fiber.dispose()
+ expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
+ expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
+ expect(agent.status).toBe('disposed')
+ expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+ })
+})
diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts
index 9ce72f9d4b..d23b69efb7 100644
--- a/packages/core/agent-loop/tests/agent.spec.ts
+++ b/packages/core/agent-loop/tests/agent.spec.ts
@@ -264,6 +264,7 @@ describe('Agent', () => {
// early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
const session = ctx.sessions.create(SessionId('test'))
const prepared = prepareReactLoopAgent(
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md
index 122e58f39d..3cf8bbb6ad 100644
--- a/packages/core/agent/README.md
+++ b/packages/core/agent/README.md
@@ -1,10 +1,10 @@
# dsh-agent
-Agent interface, registry, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
+Agent interface, registry, process-local initiator scope, and `agent/*` event vocabulary. Every plugin (UI, hooks, orchestrators) programs against the `Agent` handle defined here — it has zero loop dependency, so the loop is swappable.
## Service: `AgentRegistry` (ctx key: `agents`)
-Tracks live agents so UI, hook, and orchestrator plugins can find them without importing the concrete loop package.
+Tracks live agents and carries the initiating Agent through asynchronous driver work without importing the concrete loop package.
### Public API
@@ -17,6 +17,17 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-
- `ctx.agents.list(): Agent[]`
- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root.
+#### Initiating Agent scope
+
+`AgentLoop` runs each concrete driver's complete lifetime inside an initiator boundary. Concurrent drivers remain isolated: a child driver's continuations carry the child, while the parent continuation regains the parent as soon as `withInitiator()` returns; drain tracking continues until the child driver's Promise settles. Creation, persistence load, and unpublished setup remain outside the child's boundary, so setup initiated by a parent inherits the parent while `agentCtx.agent` identifies the child explicitly.
+
+- `ctx.agents.currentInitiator(): Agent | undefined` — read the inherited initiator without requiring one.
+- `ctx.agents.requireInitiator(): Agent` — read it or throw `no initiating agent is active`.
+- `ctx.agents.withInitiator(agent, operation)` — run with one exact Agent and preserve the operation's exact synchronous value or Promise.
+- `ctx.agents.withoutInitiator(operation)` — hide an inherited initiator for unrelated process-local work.
+
+The scope carries the `Agent` itself and is process-local. Ambient presence is neither liveness proof nor authorization; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. Teardown rejects new boundaries, lets injected dependents and returned-Promise boundaries drain, then disables the underlying `AsyncLocalStorage`; unreturned work remains owned by the subsystem that detached it. If a boundary's inherited async chain starts an owning Cordis fiber's unload, that nested boundary chain is released from the drain so the unload cannot wait on itself; its continuations observe the disposed service after teardown. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract.
+
#### Factory seam (creation)
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
@@ -72,6 +83,8 @@ The handle every plugin programs against:
## Known Limitations and Deferred Work
+- **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts materialize any required identity explicitly.
+- **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work.
- **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam.
- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead.
- **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)).
diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json
index 72cd18942e..bcf75ea7ca 100644
--- a/packages/core/agent/package.json
+++ b/packages/core/agent/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent",
- "description": "Agent interface, registry, and event vocabulary for the DeepSeek Harness",
+ "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts
index 8a9ed68ab6..24087011b7 100644
--- a/packages/core/agent/src/index.ts
+++ b/packages/core/agent/src/index.ts
@@ -1,11 +1,14 @@
/**
- * Agent registry service. Tracks live agents so plugins can find them without
- * depending on the concrete loop package. Agent creation belongs to the loop.
+ * Agent service: live registry, factory delegation, and process-local
+ * initiator scope. Concrete creation and driving belong to the loop.
*
* @module @deepseek-ai/dsh-agent
*/
-import { Context, getTraceable, Service, symbols } from 'cordis'
+import { Context, FiberState, getTraceable, Service, symbols } from 'cordis'
+import type { Fiber } from 'cordis'
+import { AsyncLocalStorage } from 'node:async_hooks'
+import { isPromise } from 'node:util/types'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
@@ -173,6 +176,8 @@ export interface AgentFactory {
/** Thrown when create/resume is called before an agent factory is registered. */
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
+const NO_INITIATOR_MESSAGE = 'no initiating agent is active'
+const DISPOSED_INITIATOR_MESSAGE = 'agent initiator scope is disposed'
/** All mutable lifecycle state for one exact registry entry. */
interface AgentEntry {
@@ -186,21 +191,32 @@ interface AgentEntry {
detachRequested: boolean
}
+/** One tracked boundary plus its inherited nesting chain. */
+interface InitiatorRun {
+ active: boolean
+ readonly parent: InitiatorRun | undefined
+}
+
/** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */
interface FactorySlot {
readonly target: AgentFactory
}
/**
- * Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
- * orchestrator plugins can find them without depending on the concrete loop
- * package. Agent *creation* is provided by whichever plugin implements the
- * {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
- * {@link setFactory}.
+ * Agent service (`ctx.agents`): tracks live agents and carries the initiating
+ * Agent through one process-local asynchronous driver chain. Agent *creation*
+ * is provided by whichever plugin implements the {@link AgentFactory}
+ * (`@deepseek-ai/dsh-agent-loop`), registered via {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map()
private factory: FactorySlot | undefined
+ private readonly initiators = new AsyncLocalStorage()
+ private readonly initiatorRuns = new AsyncLocalStorage()
+ private initiatorState: 'active' | 'closing' | 'disposed' = 'active'
+ private activeInitiatorRuns = 0
+ private initiatorDrain: PromiseWithResolvers | undefined
+ private initiatorDisposal: Promise | undefined
constructor(ctx: Context) {
super(ctx, 'agents')
@@ -211,6 +227,63 @@ export class AgentRegistry extends Service {
// accessor body never needs to resolve a scope itself. Effect-scoped:
// unwinds with this service's fiber.
ctx.accessor('agent', { get: () => undefined })
+ ctx.on('internal/status', (fiber) => {
+ if (fiber.state === FiberState.UNLOADING && this.hasLifecycleAncestor(fiber)) {
+ this.closeInitiators()
+ }
+ })
+ ctx.effect(function* (this: AgentRegistry) {
+ yield () => this.disposeInitiators()
+ yield () => { this.closeInitiators() }
+ }.bind(this), 'agents.initiatorLifecycle()')
+ }
+
+ /**
+ * Read the Agent that initiated the inherited asynchronous driver chain.
+ * @returns the inherited Agent, or `undefined` outside a driver and inside an explicit clearing boundary.
+ * @throws when this service instance has been disposed.
+ */
+ currentInitiator(): Agent | undefined {
+ this.assertInitiatorsReadable()
+ return this.initiators.getStore()
+ }
+
+ /**
+ * Read the initiating Agent and fail when no driver boundary is active.
+ * @returns the inherited Agent.
+ * @throws when no initiator is active or this service instance has been disposed.
+ */
+ requireInitiator(): Agent {
+ const agent = this.currentInitiator()
+ if (agent === undefined) throw new Error(NO_INITIATOR_MESSAGE)
+ return agent
+ }
+
+ /**
+ * Run an operation with one exact Agent as its process-local initiator. The
+ * exact synchronous value or Promise returned by the operation is preserved.
+ * If its inherited async chain starts an owning-fiber unload, the nested
+ * boundary lineage is excluded from the drain so teardown cannot wait on itself.
+ * @param agent - initiating Agent to inherit; presence is neither liveness proof nor authorization.
+ * @param operation - synchronous or asynchronous operation to invoke.
+ * @returns the exact value returned by `operation`.
+ * @throws when the initiator scope is closing/disposed, or when `operation` throws.
+ */
+ withInitiator(agent: Agent, operation: () => T): T {
+ return this.runWithInitiator(agent, operation)
+ }
+
+ /**
+ * Run an operation inside a boundary that hides any inherited initiating
+ * Agent. The exact synchronous value or Promise is preserved.
+ * If its inherited async chain starts an owning-fiber unload, the nested
+ * boundary lineage is excluded from the drain so teardown cannot wait on itself.
+ * @param operation - synchronous or asynchronous operation to invoke without an initiator.
+ * @returns the exact value returned by `operation`.
+ * @throws when the initiator scope is closing/disposed, or when `operation` throws.
+ */
+ withoutInitiator(operation: () => T): T {
+ return this.runWithInitiator(undefined, operation)
}
/**
@@ -471,6 +544,92 @@ export class AgentRegistry extends Service {
.filter(entry => entry.owner === undefined)
.map(entry => entry.agent)
}
+
+ /** Reject new initiator boundaries while inherited continuations drain. */
+ private closeInitiators(): void {
+ if (this.initiatorState === 'active') this.initiatorState = 'closing'
+ }
+
+ /** Wait for returned-Promise boundaries, then invalidate retained references. */
+ private disposeInitiators(): Promise {
+ return (this.initiatorDisposal ??= (async () => {
+ this.closeInitiators()
+ this.releaseReentrantInitiatorRuns()
+ if (this.activeInitiatorRuns !== 0) {
+ this.initiatorDrain ??= Promise.withResolvers()
+ await this.initiatorDrain.promise
+ }
+ this.initiatorState = 'disposed'
+ this.initiators.disable()
+ this.initiatorRuns.disable()
+ })())
+ }
+
+ /** Establish one tracked initiator or clearing boundary. */
+ private runWithInitiator(agent: Agent | undefined, operation: () => T): T {
+ if (this.initiatorState !== 'active') throw new Error(DISPOSED_INITIATOR_MESSAGE)
+ const run: InitiatorRun = {
+ active: true,
+ parent: this.initiatorRuns.getStore(),
+ }
+ this.activeInitiatorRuns += 1
+ let result: T
+ try {
+ result = this.initiatorRuns.run(run, () => this.initiators.run(agent, operation))
+ } catch (error: unknown) {
+ this.releaseInitiatorRun(run)
+ throw error
+ }
+ if (isPromise(result)) {
+ try {
+ void Promise.prototype.then.call(
+ result,
+ () => { this.releaseInitiatorRun(run) },
+ () => { this.releaseInitiatorRun(run) },
+ )
+ } catch {
+ // A branded Promise may expose a failing @@species. Observer setup did
+ // not attach, so preserve the exact return without leaking the run.
+ this.releaseInitiatorRun(run)
+ }
+ } else {
+ this.releaseInitiatorRun(run)
+ }
+ return result
+ }
+
+ /** Whether one unloading fiber owns this service's lifecycle. */
+ private hasLifecycleAncestor(candidate: Fiber): boolean {
+ let fiber = this.ctx.fiber
+ while (true) {
+ if (fiber === candidate) return true
+ const parent = fiber.parent.fiber
+ if (parent === fiber) return false
+ fiber = parent
+ }
+ }
+
+ private assertInitiatorsReadable(): void {
+ if (this.initiatorState === 'disposed') throw new Error(DISPOSED_INITIATOR_MESSAGE)
+ }
+
+ /** Exclude the boundary chain that initiated this teardown from its own drain. */
+ private releaseReentrantInitiatorRuns(): void {
+ let run = this.initiatorRuns.getStore()
+ while (run !== undefined) {
+ this.releaseInitiatorRun(run)
+ run = run.parent
+ }
+ }
+
+ private releaseInitiatorRun(run: InitiatorRun): void {
+ if (!run.active) return
+ run.active = false
+ this.activeInitiatorRuns -= 1
+ if (this.activeInitiatorRuns !== 0) return
+ this.initiatorDrain?.resolve()
+ this.initiatorDrain = undefined
+ }
}
export default AgentRegistry
diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts
new file mode 100644
index 0000000000..7e0b70d13c
--- /dev/null
+++ b/packages/core/agent/tests/agent-initiator.spec.ts
@@ -0,0 +1,265 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import { runInNewContext } from 'node:vm'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { SessionId } from '@deepseek-ai/dsh-session'
+
+function agent(id: string): Agent {
+ return { id: SessionId(id) } as Agent
+}
+
+async function harness(): Promise<{
+ ctx: Context
+ service: AgentRegistry
+ dispose: () => Promise
+}> {
+ const ctx = new Context()
+ const fiber = await ctx.plugin(AgentRegistry)
+ return {
+ ctx,
+ service: ctx.agents,
+ dispose: fiber.dispose,
+ }
+}
+
+/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
+async function promptly(task: Promise): Promise {
+ const timeout = Promise.withResolvers()
+ const timer = setTimeout(() => { timeout.reject(new Error('initiator teardown did not settle promptly')) }, 1000)
+ try {
+ return await Promise.race([task, timeout.promise])
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+describe('AgentRegistry initiator scope', () => {
+ it('reports an absent initiator and requires an active boundary', async () => {
+ const { service, dispose } = await harness()
+ expect(service.currentInitiator()).toBeUndefined()
+ expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
+ await dispose()
+ })
+
+ it('preserves exact synchronous and Promise return identities across await', async () => {
+ const { service, dispose } = await harness()
+ const initiator = agent('identity')
+ const value = { result: true }
+ expect(service.withInitiator(initiator, () => {
+ expect(service.requireInitiator()).toBe(initiator)
+ return value
+ })).toBe(value)
+
+ const promise = service.withInitiator(initiator, async () => {
+ expect(service.requireInitiator()).toBe(initiator)
+ await Promise.resolve()
+ expect(service.requireInitiator()).toBe(initiator)
+ return value
+ })
+ expect(service.withInitiator(initiator, () => promise)).toBe(promise)
+ await expect(promise).resolves.toBe(value)
+ expect(service.currentInitiator()).toBeUndefined()
+ await dispose()
+ })
+
+ it('tracks a branded Promise without calling its overridable then property', async () => {
+ const { service, dispose } = await harness()
+ const initiator = agent('overridden-then')
+ const release = Promise.withResolvers()
+ void Object.defineProperty(release.promise, 'then', {
+ value: () => { throw new Error('overridden then called') },
+ })
+
+ const pending = service.withInitiator(initiator, () => release.promise)
+ expect(pending).toBe(release.promise)
+
+ let disposed = false
+ const disposal = dispose().then(() => { disposed = true })
+ await Promise.resolve()
+ expect(disposed).toBe(false)
+
+ release.resolve(true)
+ await new Promise((resolve, reject) => {
+ void Promise.prototype.then.call(pending, resolve, reject)
+ })
+ await disposal
+ expect(disposed).toBe(true)
+ })
+
+ it('preserves a settled branded Promise when its species blocks observer construction', async () => {
+ const { service, dispose } = await harness()
+ const initiator = agent('invalid-species')
+ const promise = Promise.resolve()
+ const constructor = {}
+ Object.defineProperty(constructor, Symbol.species, {
+ get: () => { throw new Error('invalid species') },
+ })
+ void Object.defineProperty(promise, 'constructor', { value: constructor })
+
+ expect(service.withInitiator(initiator, () => promise)).toBe(promise)
+ await dispose()
+ })
+
+ it('isolates overlapping initiators', async () => {
+ const { service, dispose } = await harness()
+ const a = agent('a')
+ const b = agent('b')
+ const bothStarted = Promise.withResolvers()
+ const release = Promise.withResolvers()
+ let starts = 0
+ const run = (initiator: Agent): Promise => service.withInitiator(initiator, async () => {
+ expect(service.requireInitiator()).toBe(initiator)
+ starts += 1
+ if (starts === 2) bothStarted.resolve(true)
+ await release.promise
+ expect(service.requireInitiator()).toBe(initiator)
+ })
+
+ const pending = [run(a), run(b)]
+ await bothStarted.promise
+ expect(service.currentInitiator()).toBeUndefined()
+ release.resolve(true)
+ await Promise.all(pending)
+ await dispose()
+ })
+
+ it('restores nested and explicitly cleared boundaries', async () => {
+ const { service, dispose } = await harness()
+ const parent = agent('parent')
+ const child = agent('child')
+
+ service.withInitiator(parent, () => {
+ expect(service.requireInitiator()).toBe(parent)
+ service.withInitiator(child, () => { expect(service.requireInitiator()).toBe(child) })
+ expect(service.requireInitiator()).toBe(parent)
+ service.withoutInitiator(() => {
+ expect(service.currentInitiator()).toBeUndefined()
+ expect(() => service.requireInitiator()).toThrow('no initiating agent is active')
+ })
+ expect(service.requireInitiator()).toBe(parent)
+ })
+ expect(service.currentInitiator()).toBeUndefined()
+ await dispose()
+ })
+
+ it('restores the parent after synchronous throws and rejected operations', async () => {
+ const { service, dispose } = await harness()
+ const parent = agent('parent')
+ const child = agent('child')
+ const syncError = new Error('sync failure')
+ const asyncError = new Error('async failure')
+
+ service.withInitiator(parent, () => {
+ expect(() => service.withInitiator(child, () => { throw syncError })).toThrow(syncError)
+ expect(service.requireInitiator()).toBe(parent)
+ })
+ await expect(service.withInitiator(child, async () => {
+ await Promise.resolve()
+ throw asyncError
+ })).rejects.toBe(asyncError)
+ expect(service.currentInitiator()).toBeUndefined()
+ await dispose()
+ })
+
+ it('stops new boundaries, drains active Promises, and invalidates retained references', async () => {
+ const { ctx, service, dispose } = await harness()
+ const initiator = agent('draining')
+ const release = Promise.withResolvers()
+ const pending = service.withInitiator(initiator, async () => {
+ await release.promise
+ expect(service.requireInitiator()).toBe(initiator)
+ })
+ let disposed = false
+ const disposal = dispose().then(() => { disposed = true })
+ await Promise.resolve()
+
+ expect(() => service.withInitiator(initiator, () => 1)).toThrow('agent initiator scope is disposed')
+ expect(() => service.withoutInitiator(() => 1)).toThrow('agent initiator scope is disposed')
+ expect(disposed).toBe(false)
+ expect(ctx.get('agents')).toBeUndefined()
+ release.resolve(true)
+ await pending
+ await disposal
+ expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+ expect(() => service.requireInitiator()).toThrow('agent initiator scope is disposed')
+ })
+
+ it('drains cross-realm Promise boundaries before disposal', async () => {
+ const { service, dispose } = await harness()
+ const initiator = agent('cross-realm')
+ const release = Promise.withResolvers()
+ const operation = runInNewContext(
+ '(async () => { await release; inspect() })',
+ {
+ release: release.promise,
+ inspect: () => { expect(service.requireInitiator()).toBe(initiator) },
+ },
+ ) as () => Promise
+ const pending = service.withInitiator(initiator, operation)
+ expect(pending).not.toBeInstanceOf(Promise)
+
+ let disposed = false
+ const disposal = dispose().then(() => { disposed = true })
+ await Promise.resolve()
+ expect(disposed).toBe(false)
+
+ release.resolve(true)
+ await pending
+ await disposal
+ expect(disposed).toBe(true)
+ })
+
+ it('does not self-deadlock when a boundary returns service disposal', async () => {
+ const { service, dispose } = await harness()
+ const initiator = agent('service-disposer')
+
+ const returned = service.withInitiator(initiator, dispose)
+ await promptly(returned)
+
+ expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+ })
+
+ it('does not self-deadlock when nested boundaries return ancestor disposal', async () => {
+ const { ctx, service } = await harness()
+ const parent = agent('parent-disposer')
+ const child = agent('child-disposer')
+ let disposal: Promise | undefined
+
+ const returned = service.withInitiator(parent, () => service.withInitiator(child, () => {
+ disposal = ctx.fiber.dispose()
+ return disposal
+ }))
+ expect(returned).toBe(disposal)
+
+ await promptly(returned)
+ expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+ })
+
+ it('excludes an asynchronous teardown initiator while draining unrelated boundaries', async () => {
+ const { ctx, service } = await harness()
+ const initiator = agent('async-disposer')
+ const unrelated = agent('unrelated')
+ const release = Promise.withResolvers()
+ const pending = service.withInitiator(unrelated, async () => {
+ await release.promise
+ expect(service.requireInitiator()).toBe(unrelated)
+ })
+
+ const returned = service.withInitiator(initiator, async () => {
+ await Promise.resolve()
+ await ctx.fiber.dispose()
+ })
+ let disposed = false
+ void returned.then(() => { disposed = true })
+ await Promise.resolve()
+ await Promise.resolve()
+ expect(disposed).toBe(false)
+
+ release.resolve(true)
+ await pending
+ await promptly(returned)
+
+ expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
+ })
+})
diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md
index aa461e90db..409aef5ef2 100644
--- a/packages/examples/agent-spine-demo/README.md
+++ b/packages/examples/agent-spine-demo/README.md
@@ -16,7 +16,7 @@ Read this package for the whole plugin tree and its composition order.
@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline
@deepseek-ai/dsh-skill skill provider registry
@deepseek-ai/dsh-skill-local local filesystem skill provider
-@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
+@deepseek-ai/dsh-agent agent registry + initiator scope + agent/* events
@deepseek-ai/dsh-tasks generic background-task registry
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash schema
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index 3cea370a2c..6fe043cb25 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -156,10 +156,10 @@ const SERVICE_ROLES: ServiceRole[] = [
{
key: 'agents',
pkg: 'agent',
- title: 'Agent registry',
+ title: 'Agent service',
mode: 'core',
consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'stdio-demo', 'invariants'],
- note: 'Owns live Agent handles and the create/resume factory seam.',
+ note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
key: 'agentLoop',
diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md
index f92f69bbeb..923bd2748b 100644
--- a/website/zh-CN/api/harness/agents.md
+++ b/website/zh-CN/api/harness/agents.md
@@ -4,9 +4,62 @@
`AgentRegistry` — provided by `@deepseek-ai/dsh-agent`.
-Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
+Agent service (`ctx.agents`): tracks live agents and carries the initiating Agent through one process-local asynchronous driver chain. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L201)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L211)
+
+### ctx.agents.currentInitiator()
+
+```ts website-api
+currentInitiator(): Agent | undefined
+```
+
+Read the Agent that initiated the inherited asynchronous driver chain.
+
+**Returns** the inherited Agent, or `undefined` outside a driver and inside an explicit clearing boundary.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L246)
+
+### ctx.agents.requireInitiator()
+
+```ts website-api
+requireInitiator(): Agent
+```
+
+Read the initiating Agent and fail when no driver boundary is active.
+
+**Returns** the inherited Agent.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L256)
+
+### ctx.agents.withInitiator(agent, operation)
+
+```ts website-api
+withInitiator(agent: Agent, operation: () => T): T
+```
+
+Run an operation with one exact Agent as its process-local initiator. The exact synchronous value or Promise returned by the operation is preserved. If its inherited async chain starts an owning-fiber unload, the nested boundary lineage is excluded from the drain so teardown cannot wait on itself.
+
+- `agent` — initiating Agent to inherit; presence is neither liveness proof nor authorization.
+- `operation` — synchronous or asynchronous operation to invoke.
+
+**Returns** the exact value returned by `operation`.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L272)
+
+### ctx.agents.withoutInitiator(operation)
+
+```ts website-api
+withoutInitiator(operation: () => T): T
+```
+
+Run an operation inside a boundary that hides any inherited initiating Agent. The exact synchronous value or Promise is preserved. If its inherited async chain starts an owning-fiber unload, the nested boundary lineage is excluded from the drain so teardown cannot wait on itself.
+
+- `operation` — synchronous or asynchronous operation to invoke without an initiator.
+
+**Returns** the exact value returned by `operation`.
+
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L285)
### ctx.agents.setFactory(factory)
@@ -32,7 +85,7 @@ Register the agent-creation factory (the loop calls this on construction, effect
**Returns** the disposer that clears the factory slot. The exact Cordis effect disposer (single-shot): composite (generator) effects may yield it directly — exact identity nests the teardown in order.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L228)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L301)
### ctx.agents.create(options)
@@ -55,7 +108,7 @@ Create and publish a new agent through the registered factory. Distinct from reg
**Returns** the handle after setup, rollback-covered publication, and loop start complete.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L261)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L334)
### ctx.agents.resume(options)
@@ -76,7 +129,7 @@ Load a persisted session and resume an agent on it through the registered factor
**Returns** the handle after setup, rollback-covered publication, and loop start complete.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L280)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L353)
### ctx.agents.register(agent)
@@ -108,7 +161,7 @@ Register a live agent. Throws if an agent with the same id is already registered
**Returns** the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory's lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L306)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L379)
### ctx.agents.enter(agent, owner)
@@ -138,7 +191,7 @@ Insert an already-constructed agent without announcing it. This is the advanced
**Returns** an idempotent closure that removes this exact entry and emits `agent/disposed` with listener failures contained. When called from a synchronous `agent/created` listener, removal and disposal wait until that creation dispatch unwinds.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L330)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L403)
### ctx.agents.announce(agent)
@@ -157,7 +210,7 @@ Announce an agent previously inserted with enter.
- `agent` — the live inserted agent to announce.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L405)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L478)
### ctx.agents.get(id)
@@ -176,7 +229,7 @@ Look up a live agent.
**Returns** the agent, or undefined when no live agent has that id.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L439)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L512)
### ctx.agents.isOwnedBy(id, owner)
@@ -199,7 +252,7 @@ Test whether a live agent was created through one exact parent agent's scoped co
**Returns** true only while the exact child entry is live under that owner.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L451)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L524)
### ctx.agents.list()
@@ -215,7 +268,7 @@ All live agents, in registration order.
**Returns** a fresh array; mutating it does not affect the registry.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L459)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L532)
### ctx.agents.roots()
@@ -233,4 +286,4 @@ All live top-level agents in registration order. A top-level agent was created w
**Returns** a fresh array; mutating it does not affect the registry.
-[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L469)
+[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L542)