Merge PR #340 into codex/website-api-jsdoc

This commit is contained in:
Tianyi Cui
2026-07-19 15:29:42 +08:00
23 changed files with 1047 additions and 38 deletions

View File

@@ -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

View File

@@ -49,7 +49,7 @@ flowchart LR
pkg_skill["skill"]
svc_skills["ctx.skills<br/>Skill provider registry"]
pkg_skill_local["skill-local"]
svc_agents["ctx.agents<br/>Agent registry"]
svc_agents["ctx.agents<br/>Agent service"]
svc_agentLoop["ctx.agentLoop<br/>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. |

View File

@@ -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<T>(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<T>(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`

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

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-initiator-scope.md: b3c9be0be1dea29568dfcdeb0578e643734486e8
2026-07-15-agent-initiator-scope.zh.md: 55494977b8ade0d380fa21b25171bce65a46a9fb

View File

@@ -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.

View File

@@ -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`、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。

View File

@@ -8,7 +8,7 @@ Packages live at `packages/<group>/<pkg>/`; 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 |

View File

@@ -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<T>(agent: Agent, operation: () => T): T',
'withoutInitiator<T>(operation: () => T): T',
'setFactory(factory: AgentFactory): () => void',
'async create(options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(options: ResumeAgentOptions): Promise<AgentHandle>',

View File

@@ -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.

View File

@@ -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.

View File

@@ -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() },
})
}))
}
/**

View File

@@ -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<Harness> {
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<void> {
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<boolean>()
private starts = 0
readonly observations: { sessionId: SessionId | undefined; before: Agent; after: Agent }[] = []
constructor(private readonly ctx: Context) {
super()
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
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<string, string> }[] = []
constructor(private readonly agents: AgentRegistry) {}
async request(path: string): Promise<Record<string, string>> {
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<boolean>()
firstAgentDuringAbort: Agent | undefined
laterAgent: Agent | undefined
calls = 0
agents: AgentRegistry | undefined
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
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<void>((_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')
})
})

View File

@@ -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,

View File

@@ -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)).

View File

@@ -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",

View File

@@ -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<SessionId, AgentEntry>()
private factory: FactorySlot | undefined
private readonly initiators = new AsyncLocalStorage<Agent | undefined>()
private readonly initiatorRuns = new AsyncLocalStorage<InitiatorRun>()
private initiatorState: 'active' | 'closing' | 'disposed' = 'active'
private activeInitiatorRuns = 0
private initiatorDrain: PromiseWithResolvers<void> | undefined
private initiatorDisposal: Promise<void> | 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<T>(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<T>(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<void> {
return (this.initiatorDisposal ??= (async () => {
this.closeInitiators()
this.releaseReentrantInitiatorRuns()
if (this.activeInitiatorRuns !== 0) {
this.initiatorDrain ??= Promise.withResolvers<void>()
await this.initiatorDrain.promise
}
this.initiatorState = 'disposed'
this.initiators.disable()
this.initiatorRuns.disable()
})())
}
/** Establish one tracked initiator or clearing boundary. */
private runWithInitiator<T>(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

View File

@@ -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<void>
}> {
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<T>(task: Promise<T>): Promise<T> {
const timeout = Promise.withResolvers<never>()
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<boolean>()
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<void>((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<boolean>()
const release = Promise.withResolvers<boolean>()
let starts = 0
const run = (initiator: Agent): Promise<void> => 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<boolean>()
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<boolean>()
const operation = runInNewContext(
'(async () => { await release; inspect() })',
{
release: release.promise,
inspect: () => { expect(service.requireInitiator()).toBe(initiator) },
},
) as () => Promise<void>
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<void> | 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<boolean>()
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')
})
})

View File

@@ -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

View File

@@ -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',

View File

@@ -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<T>(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<T>(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)