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)