From 04df615dd64f204e62cc865e00620f4e236c87c1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 16 Jul 2026 11:08:43 +0800 Subject: [PATCH 01/16] docs(rfc): propose agent execution context --- docs/rfc/INDEX.md | 1 + ...26-07-15-agent-execution-context.i18n.yaml | 6 + .../2026-07-15-agent-execution-context.md | 207 ++++++++++++++++++ .../2026-07-15-agent-execution-context.zh.md | 207 ++++++++++++++++++ 4 files changed, 421 insertions(+) create mode 100644 docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.i18n.yaml create mode 100644 docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md create mode 100644 docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a60579ef4c..2660132fb2 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -28,6 +28,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | +| [Agent execution context over AsyncLocalStorage](proposed/architecture/2026-07-15-agent-execution-context.md) | 2026-07-15 | | [SDK project editing architecture](proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) | 2026-07-15 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.i18n.yaml b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.i18n.yaml new file mode 100644 index 0000000000..65be95551b --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.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-execution-context.md: 7bea10fb1268c91a7668f7d269f83ae1250c373c +2026-07-15-agent-execution-context.zh.md: 5a7b12974818a076434ff1a1b3b4ab819866b3d7 diff --git a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md new file mode 100644 index 0000000000..7bea10fb12 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md @@ -0,0 +1,207 @@ +# RFC: Agent execution context over AsyncLocalStorage + +Status: proposed + +English | [中文](2026-07-15-agent-execution-context.zh.md) + +## Problem + +The harness has two useful but different notions of context: + +- A Cordis `Context` is a composition and lifetime object. The deployment context exposes shared services, while `agent.ctx` exposes the flat registration layer owned by one live Agent. +- Agent, Session, turn, step, and tool identity are execution subjects. The loop passes them explicitly through events, prompt assembly, LLM requests, and `ToolExecution`. + +These concepts must not be conflated. In particular, `agent.ctx.agent` is a static association on the Agent's scoped composition context. A plain root context deliberately returns `undefined`; it cannot be changed to mean "whichever Agent happens to be running now" because one Node process may run many Agents concurrently. + +This leaves a practical gap for deeply nested infrastructure. A capability transport, skill provider, tracing helper, logger, or gateway client may need to know which Agent initiated the current asynchronous operation. Passing `agent` through every intermediate helper is noisy, while deriving identity from a process-global mutable slot is incorrect as soon as two Agents overlap. Model-visible tool arguments are also the wrong carrier: the model must not be able to choose a trusted Session or sandbox-routing header. + +The gap becomes important when a single Harness runtime multiplexes Sessions for a multi-tenant hosting platform. Outbound capability requests must automatically carry the current Harness Session ID so the host can resolve the correct tenant and sandbox owner. Model-facing skills and tools should not know host-specific routing, but the selected capability implementation still needs a trusted current Agent at the transport boundary. + +## Proposal + +Add a narrow Agent execution-context facility backed by Node `AsyncLocalStorage`. It provides ambient access to the Agent associated with the current asynchronous execution chain without replacing Cordis contexts, explicit protocol fields, or durable Session state. + +The first version stores only the Agent: + +```text +export interface AgentExecution { + readonly agent: Agent +} + +export interface AgentExecutionService { + current(): AgentExecution | undefined + require(): AgentExecution + run(execution: AgentExecution | undefined, operation: () => T): T +} +``` + +`Session` is derived as `execution.agent.session`; it is not duplicated in the store. Turn, step, tool call, model, cwd, and sandbox identity remain outside the first version because they already have authoritative owners and no confirmed ambient consumer requires them yet. The single-field wrapper is deliberate: a later execution-frame refinement extends `AgentExecution` without changing `run()` callers, so implementations must not flatten the store to a bare `Agent`. + +`AgentExecution` deliberately retains the exact live `Agent`, not an id snapshot. This is the one capability admitted to the first-version store because it is the subject whose driver establishes the boundary and because existing scoped helpers operate on that exact object. Ambient presence is not proof of liveness or authorization: consumers must still honor the Agent lifecycle and the explicit capability contract before performing lifecycle-sensitive work. + +The API must always establish an ALS boundary, including when the supplied execution is `undefined`. This provides an explicit way to clear inherited context for unrelated detached work. A comparable implementation observed an uncleared ambient value crossing scheduled work into a later turn; the explicit undefined boundary prevents that class of leak. + +### Package and service placement + +Create `packages/core/agent-execution/` as `@deepseek-ai/dsh-agent-execution`. The package owns the Node-specific ALS implementation and augments Cordis with the mandatory `ctx.agentExecution` service. It belongs to `core/` because it is part of the stable Agent control spine that every concrete Agent loop and ambient-identity consumer programs against. + +The public key is `ctx.agentExecution`, settled here so every surface — service key, interface name, and package name — shares one word root. It names the Agent-owned asynchronous chain rather than one turn or tool call. `ctx.execution` is too broad; a runtime-flavored name would collide with `packages/code-runtime/` and with "Harness runtime" meaning the whole process; and changing `ctx.agent` is excluded because it already means the static Agent association of `agent.ctx`. + +The package exposes the service through Cordis rather than a mutable module-global slot: + +- the Agent Loop can inject the service explicitly; +- tests can mount an isolated service per Harness context; +- service disposal can disable its ALS instance after dependent Agent drivers quiesce; +- the dependency remains visible in Cordis configuration and generated catalogs. + +The service loads mandatorily with the standard agent composition bundle, and `dsh-agent-loop` declares it in `inject`: a composition that drives agents without it fails at load, per the fail-loud rule, rather than degrading to absent ambient identity at the first deep consumer. Configuration tests pin this policy. The facility relies only on stable Node `AsyncLocalStorage`, available without a polyfill across the supported `node ^22.19 || >=24` range. Node 24+ uses an `AsyncContextFrame`-backed implementation, while Node 22 uses the earlier implementation; this RFC accepts the always-on propagation cost for the invariant and makes no zero-overhead claim. + +Service teardown is ordered rather than transparent. The Agent Loop stops accepting new work, cancels and drains every driver, and only then may the service disable its ALS instance. HMR of the service rebuilds that dependent subtree; it does not preserve an in-flight turn across reload. A retained reference to a disposed service throws a stable disposed-service error from both `current()` and `require()` instead of silently returning `undefined`. + +### Lifecycle boundary + +Bind the execution context around each concrete Agent driver's `runLoop` lifetime: + +```text +agentExecution.run({ agent }, () => runLoop(ctx, agent, handle)) +``` + +This gives every operation initiated by that driver the same trusted Agent: + +- prompt interception and prompt assembly; +- LLM adapter calls; +- tool policy and tool bodies; +- capability providers and transports; +- synchronous and asynchronous helpers awaited by those operations. + +Concurrent drivers receive distinct ALS stores. A child Agent's own driver establishes a new boundary with the child, so child operations do not inherit the parent Agent merely because child creation started inside a parent tool call. When a nested boundary returns, ALS restores the parent automatically. + +Agent creation setup is deliberately outside this dynamic boundary. Setup already receives `agentCtx`, whose `agentCtx.agent` is the correct unpublished Agent. Publication and lifecycle ownership continue to use the existing explicit Agent and scoped carrier. One consequence is a defined contract, not an accident: when child creation starts inside a parent tool call, the child's setup and persistence load run under the PARENT's ambient identity, because the child's driver has not started. A transport reached during that window routes under the parent's Session — correct for trusted routing, since the parent initiated and owns the creation work. Setup code that needs the child's identity uses the explicit `agentCtx.agent`, never the ambient store. + +### Explicit subjects remain authoritative + +Ambient identity is a convenience for deep infrastructure, not a replacement for existing contracts: + +- `AgentEventDispatch` continues to carry the explicit Agent subject and scope. +- `AssembleContext.agent` remains explicit. +- `ToolExecution.agent` remains explicit and continues to select the scoped tool and policy view. +- `GenerateOptions.sessionId` remains explicit at the LLM boundary. +- Subagent requests and lifecycle events continue to carry explicit parent and child identity. +- Session events remain the durable truth for replay and resume. + +Code at a public service, process, worker, persistence, or wire boundary must materialize the identity it needs into that boundary's typed request. A remote process cannot access the parent's ALS store. + +### Trusted transport use + +A host-aware capability transport may read `ctx.agentExecution.require().agent.session.id` when constructing an outbound request and add a deployment-owned trusted header such as `X-Harness-Session-Id`. The header is not present in model-visible tool arguments and cannot be overridden by the model. Ambient presence alone does not authorize a request; the transport still runs inside its normal explicit capability and Agent-lifecycle contracts. + +The bash seam's existing `OwnerToken` is the nearest explicit-identity precedent and shows why it does not close this gap: `BashExecSpec.owner` is a background-task isolation key that `dsh-tool-bash` casts from the session id, foreground `run()` deliberately ignores it, and the filesystem seam has no counterpart — its provider methods carry no identity parameter at all. Extending every capability seam with a routing-identity parameter would push hosting concerns into seam vocabularies that are otherwise deployment-neutral; ambient identity lets the transport implementation own routing without widening any seam. + +The hosting platform remains responsible for resolving the Harness runtime Session ID to its product Session and sandbox owner. Harness does not learn the host's sandbox identifier, sandbox provider, or persistence model. + +Model-facing skill and tool plugins should not add hosting-specific headers themselves. They call a capability service; the selected provider owns remote execution and identity propagation. This preserves the separation between model behavior and backend routing. + +### Detached asynchronous work + +Node ALS is inherited by asynchronous resources created inside `run()`, even when callers do not await them. This is useful for an Agent-owned background operation, but it can also retain a stale turn's context in unrelated work. + +Identity inheritance does not replace cancellation ownership. Work started inside an Agent's boundary is either **foreground** — it inherits `{ agent }` and separately receives the explicit cancellation signal owned by its execution seam — or **detached** — it starts under `run(undefined, operation)` and owns its own lifecycle with an explicit stop. The caller must keep those choices aligned. The implementation must document and test these rules: + +- Work logically owned by the Agent is foreground: it may inherit `{ agent }`, receives cancellation through the existing explicit seam, and must honor the Agent's disposal contract. +- Long-lived deployment infrastructure, timers, and work queues unrelated to that Agent are detached: they must start under `run(undefined, operation)` and be stopped by their own owner, never implicitly by a turn ending. +- Code that enqueues data for later processing must serialize the required identity into the queue item; it must not expect ALS to cross the queue, process, or worker boundary. +- Consumers must not treat an ambient Agent reference as proof that the Agent is still live. Lifecycle-sensitive operations still check `agent.status`, an explicit signal, or the owning service's contract. + +`turn` and `step` remain outside the first version; they can join later as a separate immutable execution-frame refinement if a real cross-cutting consumer (tracing, logging) cannot use the existing explicit fields. The full `Agent` is the deliberate capability exception because it is the execution subject that establishes the boundary. Every additional field must be a stale-safe label whose stale copy can at worst mislabel a trace; another capability or control channel requires its own RFC. `AbortSignal` is excluded from the first version under that rule; see Alternatives considered. + +## Current Harness evidence + +The implementation Session should re-check these symbols on its target branch before editing because this handoff was prepared against a local source snapshot and the branch may have advanced. + +- `packages/core/agent/src/types.ts`: `Agent` already owns `session`, `status`, and `ctx`. Its `ctx` documentation defines a registration scope, not a dynamic request context. +- `packages/core/agent/src/index.ts`: Cordis `Context.agent` is installed as an Agent-scope DX association and defaults to `undefined` on a plain context. Do not change this semantic. +- `packages/core/agent-loop/src/agent.ts`: `ReactLoopAgent` already owns inbox, cancellation, per-step abort, status, and driver lifetime. Do not create a parallel mutable runtime-state object. +- `packages/core/agent-loop/src/loop.ts`: `runLoop(ctx, agent, handle)` has the exact lifetime boundary to wrap. It passes Agent, turn, step, and signal explicitly to narrower operations. +- `packages/core/tools/src/index.ts`: `ToolExecutionInput.agent` is explicit and selects scoped policy and tool resolution. It remains in the contract after ALS is added. +- `packages/core/agent/src/dispatch.ts`: `agentEvents()` deliberately fuses the Agent subject with its scoped carrier. Ambient context must not replace this correctness mechanism. +- `packages/core/README.md` and the existing core packages: they show that stable Agent control contracts belong in `core/`; `agent-execution` is mandatory control infrastructure rather than optional model-visible context enrichment. + +This proposal extends, rather than supersedes, [the Agent registration-scope decision](../../implemented/architecture/2026-07-08-agent-scope-contexts.md) and its [runtime design](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md). + +## Claude Code reference implementation + +| Claude Code | Harness translation | +|---|---| +| AppState store | Cordis deployment services and their owned live state | +| QueryEngine | `ReactLoopAgent` plus its loop-owned runtime state | +| ToolUseContext | Explicit Agent/tool/request parameters at capability seams | +| AgentContext ALS | Proposed narrow `AgentExecution` carrier | +| Transcript | Event-sourced `Session` and persistence backends | + +## Implementation handoff + +The implementation Session should perform the work in this order: + +1. Switch to the intended target branch and inspect the current versions of the files listed under "Current Harness evidence". Do not merge or copy changes from the branch on which this handoff was authored. +2. Add `packages/core/agent-execution/` with package metadata, README, exported types, the Cordis service, module augmentation, and focused tests. +3. Add the package to TypeScript project references, path candidates, runtime closure/configuration, and generated catalogs according to existing package gates. Prefer repository generators over hand-editing generated files. Also update the `core/` repository-layout line in root `AGENTS.md`, the package table in `packages/core/README.md`, and the package-group description in `packages/README.md`. +4. Make the Agent Loop declare and consume the service. Wrap each Agent driver's complete `runLoop` invocation in `{ agent }` without changing public Agent, event, tool, LLM, or Session signatures. +5. Add an integration test that overlaps two Agents in one process and observes the correct ambient Agent from inside asynchronous tool execution after at least one `await`. +6. Add nested-Agent coverage proving a child sees itself and the parent context is restored after the child boundary settles. +7. Add clearing and failure coverage: outside a boundary returns `undefined`, `require()` fails clearly, `run(undefined, ...)` masks an inherited Agent, and thrown/rejected operations do not contaminate later unrelated work. +8. Add a test-double capability transport to the integration suite. Keep the model-facing schema unchanged and assert that a trusted Session header is generated internally. Adapting a production remote backend is follow-up work outside this RFC. +9. Run typecheck, targeted tests, documentation gates, generated-catalog checks, and then the repository's normal CI/pre-push gate. + +Suggested focused test matrix: + +| Scenario | Required observation | +|---|---| +| Outside driver | `current()` is `undefined` | +| One Agent across awaits | Every continuation sees the same exact Agent | +| Two concurrent Agents | A never observes B and B never observes A | +| Nested child | Child sees child; parent is restored afterward | +| Child creation window | Setup inside a parent tool call sees the parent ambiently; `agentCtx.agent` is the child | +| Direct Agent-less tool call | Explicit tool behavior remains valid; ambient identity is absent | +| Cleared detached work | `run(undefined, ...)` hides the inherited Agent | +| Failure and cancellation | Context restores after throw, rejection, and abort | +| Agent disposal | Lifecycle-sensitive consumers reject work from a captured Agent after disposal | +| Service reload | Agent drivers drain before ALS disable; retained disposed-service calls throw the documented stable error | +| Capability transport boundary | Session identity is materialized into the typed request/header by the test-double transport | + +## Alternatives considered + +**Pass Agent through every function.** This remains the right choice at public and authority-bearing boundaries, but forcing it through every private helper creates plumbing that ambient execution context is designed to remove. The proposal keeps explicit subjects at seams and uses ALS only within one trusted asynchronous process. + +**Change `ctx.agent` to return the currently executing Agent.** Rejected because `ctx.agent` already denotes the static association of an Agent-scoped Cordis context. Making a root context dynamic would combine registration scope with execution scope, produce surprising behavior under concurrency, and break the implemented Agent-scope RFCs. + +**Store a complete mutable runtime object in ALS.** Rejected because Agent, Session, inbox, cancellation, turn/step state, tool execution, and durable log already have authoritative owners. Duplicating them creates stale snapshots, write-order questions, and another lifecycle to clean up. + +**Carry the step `AbortSignal` in the first-version ALS frame.** Rejected for this RFC. The signal is per-step while the proposed boundary is per-driver, so carrying it requires nested step and tool boundaries plus explicit rules for detached work, deadline ownership, and restoration. Existing execution seams already pass cancellation explicitly. A future RFC may revisit this only with a concrete cross-cutting consumer and tests that establish those nested lifecycle semantics. + +**Use one process-global mutable `currentAgent`.** Rejected because concurrent Agents and subagents overwrite one another across awaits. It is correct only under serialization, which multi-Agent execution explicitly does not guarantee. + +**Infer the Session from model-visible tool arguments.** Rejected because the model can alter those arguments. Sandbox routing and authorization require a trusted in-process identity, not user/model input. + +**Put a hosting platform's sandbox-owner identifier or provider data in Harness context.** Rejected because sandbox ownership is hosting-product state resolved outside Harness. Harness should carry only its own Session identity across the trusted transport boundary. + +## Acceptance criteria + +- One Node Harness process can execute at least two Agents concurrently, and asynchronous consumers always observe the exact initiating Agent. +- Outside Agent driver execution, ambient lookup returns `undefined` and `require()` throws a stable, actionable error. +- Nested Agent execution restores the parent context after the child settles. +- `agent.ctx`, `ctx.agent`, Agent events, prompt assembly, `ToolExecution.agent`, LLM `sessionId`, and Session persistence retain their existing semantics. +- No Agent, Session, turn, step, sandbox, or authorization identity becomes model-controlled. +- The implementation provides an explicit undefined boundary for unrelated detached work and tests it against context leakage without changing existing explicit cancellation contracts. +- The service loads with the standard agent bundle and `dsh-agent-loop` fails at load without it; a configuration test pins the policy. +- Disposal/HMR drains every dependent Agent driver before disabling ALS; retained calls on the disposed service fail with the documented stable error, and no active ALS state remains reachable through the disposed Cordis context. +- A test-double capability transport proves trusted Session ID propagation without adding a model-visible schema field. +- Package catalogs, dependency graphs, API docs, and relevant architecture docs are regenerated or updated, and the repository's documentation gates pass. + +## Risks + +- Ambient context hides a dependency from function signatures. Restricting it to deep cross-cutting infrastructure and retaining explicit public subjects limits that cost. +- ALS inheritance into detached promises and timers can retain semantically stale identity. An explicit undefined boundary, documentation, and regression tests are required rather than assumed cleanup. +- ALS does not cross worker threads, subprocesses, Redis, HTTP, or persisted queues. Every such boundary must serialize the required identity explicitly. +- The ambient store intentionally carries the full live Agent capability. A captured reference can outlive publication, so ambient presence alone never authorizes lifecycle-sensitive work and consumers must still honor Agent lifecycle and cancellation contracts. +- Mandatory loading adds a core runtime dependency to every agent composition; the RFC accepts that cost because an optional service would make ambient identity composition-dependent. Propagation cost remains measurable across supported Node versions and should be benchmarked separately. +- Adding turn, step, signal, cwd, or tool details prematurely would expand inheritance and staleness hazards. The first version deliberately accepts the limitation of Agent-only ambient identity; any additional capability or control field requires a separate RFC. diff --git a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md new file mode 100644 index 0000000000..5a7b129748 --- /dev/null +++ b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md @@ -0,0 +1,207 @@ +# RFC:基于 AsyncLocalStorage 的 agent(智能体)执行上下文 + +Status: proposed + +[English](2026-07-15-agent-execution-context.md) | 中文 + +## 问题 + +harness 中存在两种有用但含义不同的上下文: + +- Cordis `Context` 是依赖组合和生命周期对象。部署上下文暴露共享服务,`agent.ctx` 则暴露某个存活 Agent 所拥有的扁平注册层。 +- Agent、会话、轮次、步骤和工具身份是执行主体。agent loop(智能体循环)通过事件、提示词组装、LLM(大语言模型)请求和 `ToolExecution` 显式传递这些信息。 + +这两类概念不能混为一谈。尤其是,`agent.ctx.agent` 是 Agent 作用域组合上下文上的静态关联。普通根上下文会有意返回 `undefined`;不能把它改成“当前恰好正在运行的 Agent”,因为一个 Node 进程可能并发运行多个 Agent。 + +这给深层基础设施留下了一个实际缺口。能力传输层、skill(技能)提供方、追踪辅助函数、日志记录器或网关客户端,可能需要知道当前异步操作由哪个 Agent 发起。让每一层中间辅助函数都继续传递 `agent` 会产生大量样板代码,而从进程级可变全局槽推导身份,会在两个 Agent 并发后立即出错。模型可见的工具参数也不是合适的载体:模型不能选择可信的会话或沙箱路由请求头。 + +当单个 Harness 运行时为多租户宿主平台复用多个会话时,这个缺口会变得尤其重要。对外能力请求必须自动携带当前 Harness 会话 ID,以便宿主平台解析正确的租户和沙箱归属。模型侧的 skill 和工具不应理解宿主平台特有的路由,但所选能力实现仍需要在传输边界获得可信的当前 Agent。 + +## 提案 + +新增一套由 Node `AsyncLocalStorage` 支撑的窄粒度 Agent 执行上下文能力。它允许代码在当前异步执行链内访问关联的 Agent,但不会取代 Cordis 上下文、显式协议字段或持久化会话状态。 + +第一版只保存 Agent: + +```text +export interface AgentExecution { + readonly agent: Agent +} + +export interface AgentExecutionService { + current(): AgentExecution | undefined + require(): AgentExecution + run(execution: AgentExecution | undefined, operation: () => T): T +} +``` + +`Session` 通过 `execution.agent.session` 推导,不在存储中重复保存。轮次、步骤、工具调用、模型、cwd 和沙箱身份不进入第一版,因为它们已经有各自的真源,而且目前没有已确认的隐式上下文消费方需要这些信息。单字段包装是有意为之:后续的执行帧扩展可以在不改动 `run()` 调用方的前提下扩展 `AgentExecution`,因此实现不得把存储简化成裸 `Agent`。 + +`AgentExecution` 有意保留准确的存活 `Agent`,而不是 ID 快照。这是第一版存储中唯一获准的能力对象,因为它正是由驱动建立边界的执行主体,而且现有作用域辅助函数依赖这个准确对象。隐式存在不代表仍然存活或已经获得授权:消费方执行生命周期敏感工作前,仍须遵循 Agent 生命周期和显式能力契约。 + +API 必须始终建立 ALS 边界,即使传入的 execution 是 `undefined` 也不例外。这样可以显式清除无关分离任务继承到的上下文。一个同类实现曾观察到未清空的隐式值穿过已调度工作泄漏进后续轮次;显式 undefined 边界可以防止这类泄漏。 + +### 包与服务位置 + +在 `packages/core/agent-execution/` 新建 `@deepseek-ai/dsh-agent-execution`。该包拥有 Node 专用的 ALS 实现,并通过必载的 `ctx.agentExecution` 服务扩展 Cordis。它属于 `core/`,因为这是每个具体 Agent loop 和隐式身份消费方所依赖的稳定 Agent 控制主干。 + +公开键名在此定为 `ctx.agentExecution`,服务键、接口名和包名共用同一个词根。它表示某个 Agent 所拥有的异步调用链,而不是单个轮次、步骤或工具调用;名字也直接说明存储的内容。`ctx.execution` 含义过宽;带 runtime 字样的名字会与 `packages/code-runtime/` 以及指整个进程的 “Harness 运行时” 冲突;修改 `ctx.agent` 被排除,因为它已经表示 `agent.ctx` 与 Agent 之间的静态关联。 + +该包通过 Cordis 暴露服务,而不是使用可变模块全局槽: + +- Agent Loop 可以显式注入该服务; +- 测试可以为每个 Harness 上下文挂载隔离的服务; +- 服务 dispose(资源释放)时可以在依赖它的 Agent 驱动静止后禁用其 ALS 实例; +- 依赖关系在 Cordis 配置和生成目录中保持可见。 + +该服务随标准 agent 组合包强制加载,`dsh-agent-loop` 在 `inject` 中声明它:缺少该服务的 agent 组合按快速失败规则在加载时报错,而不是等到第一个深层消费方读取时才发现隐式身份缺失。配置测试锁定这一策略。该能力只依赖稳定的 Node `AsyncLocalStorage`,支持范围 `node ^22.19 || >=24` 全部可原生使用且无需 polyfill。Node 24 及以上使用基于 `AsyncContextFrame` 的实现,Node 22 使用此前的实现;本 RFC 为保证该不变量接受常驻传播成本,不作零开销承诺。 + +服务关闭是有顺序的,不提供透明的进行中延续。Agent Loop 必须先停止接受新驱动并取消或等待所有进行中的驱动收敛,随后 Cordis 才 dispose 服务并调用 `disable()`。HMR(热模块替换)会重建依赖该服务的子树,不承诺让进行中的轮次跨服务替换继续执行。如果旧调用方保留了已 dispose 的服务引用,`current()` 和 `require()` 都会抛出稳定的 “service disposed” 错误,而不是返回模糊的 `undefined`。 + +### 生命周期边界 + +在每个具体 Agent 驱动的 `runLoop` 整个生命周期外围绑定执行上下文: + +```text +agentExecution.run({ agent }, () => runLoop(ctx, agent, handle)) +``` + +这样,由该驱动发起的每项操作都能获得同一个可信 Agent: + +- 提示词拦截和提示词组装; +- LLM 适配器调用; +- 工具策略和工具主体; +- 能力提供方和传输层; +- 这些操作所等待的同步和异步辅助函数。 + +并发驱动会获得彼此独立的 ALS 存储。子 Agent 自己的驱动会用该子 Agent 建立新边界,因此即使子 Agent 是在父 Agent 的工具调用中创建的,其操作也不会错误继承父 Agent。嵌套边界返回后,ALS 会自动恢复父 Agent。 + +Agent 创建阶段有意置于这个动态边界之外。创建过程已经接收 `agentCtx`,其中 `agentCtx.agent` 就是正确的、尚未发布的 Agent。发布流程和生命周期归属继续使用现有的显式 Agent 与作用域载体。由此产生一条明确契约,而非偶然行为:当子 Agent 的创建发生在父 Agent 的工具调用内时,子 Agent 的创建流程和持久化加载运行在**父 Agent** 的隐式身份之下,因为子驱动尚未启动。这个窗口内触达的传输层按父会话路由——对可信路由而言这是正确的,因为创建工作由父 Agent 发起并归它所有。创建代码需要子身份时使用显式的 `agentCtx.agent`,绝不读隐式存储。 + +### 显式主体仍是真源 + +隐式身份只是深层基础设施的便利能力,不会取代现有契约: + +- `AgentEventDispatch` 继续携带显式 Agent 主体和作用域。 +- `AssembleContext.agent` 保持显式传递。 +- `ToolExecution.agent` 保持显式传递,并继续选择作用域内的工具和策略视图。 +- `GenerateOptions.sessionId` 在 LLM 边界上保持显式传递。 +- subagent 请求和生命周期事件继续携带显式的父子身份。 +- 会话事件仍然是回放和恢复的持久化真源。 + +代码跨越公开服务、进程、worker、持久化或协议边界时,必须把边界所需身份写入其类型化请求。远程进程无法访问父进程的 ALS 存储。 + +### 可信传输层用途 + +能力传输层可以在构造对外请求时读取 `ctx.agentExecution.require().agent.session.id`,并添加由部署方控制的可信身份,例如 `X-Harness-Session-Id` 请求头。该身份不出现在模型可见的参数中,模型也不能覆盖它。传输层仍须执行自身的能力和生命周期授权;隐式 Agent 只提供发起方身份,不授予调用权限。 + +bash seam 现有的 `OwnerToken` 是最接近的显式身份先例,它也说明了为什么显式方案补不上这个缺口:`BashExecSpec.owner` 是一个后台任务隔离键,由 `dsh-tool-bash` 从会话 id 转换而来,前台 `run()` 有意忽略它,而文件系统 seam 没有对应物——其提供方方法完全不携带身份参数。给每个能力 seam 都加一个路由身份参数,会把宿主平台的关注点塞进本应与部署无关的 seam 词汇;隐式身份让传输层实现自己拥有路由逻辑,而不必加宽任何 seam。 + +宿主平台继续负责把 Harness 运行时会话 ID 解析成产品会话和沙箱归属方。Harness 不需要理解宿主平台的沙箱标识、沙箱提供方或持久化模型。 + +模型侧 skill 和工具插件不应自行添加宿主平台特有的请求头。它们调用能力服务;所选提供方负责远程执行和身份传播。这样可以保持模型行为与后端路由之间的职责分离。 + +### 分离异步工作 + +Node ALS 会被 `run()` 内创建的异步资源继承,即使调用方没有等待它们。这对 Agent 所拥有的后台操作很有用,但也可能让无关任务保留陈旧轮次的上下文。 + +身份继承不取代取消归属。在 Agent 边界内启动的工作要么是**前台**的——继承 `{ agent }`,并通过其执行 seam 单独接收显式取消信号;要么是**分离**的——在 `run(undefined, operation)` 下启动,并拥有独立生命周期和显式停止操作。调用方必须让这两个选择保持一致。实现必须记录并测试以下规则: + +- 逻辑上归 Agent 所有的工作是前台工作:可以继承 `{ agent }`,通过现有显式 seam 接收取消,并且必须遵守该 Agent 的 dispose 契约。 +- 与该 Agent 无关的长生命周期部署基础设施、定时器和工作队列是分离工作:必须在 `run(undefined, operation)` 下启动,由自己的归属方停止,绝不因某个轮次结束而被隐式终止。 +- 把数据入队并留待后续处理的代码必须将所需身份序列化到队列项中;不能期待 ALS 跨越队列、进程或 worker 边界。 +- 消费方不能把隐式 Agent 引用视为 Agent 仍然存活的证明。生命周期敏感的操作仍须检查 `agent.status`、显式 signal 或归属服务的契约。 + +`turn` 和 `step` 不进入第一版;如果未来出现真实的横切消费方(追踪、日志)无法使用现有显式字段,可以再将它们作为独立的不可变执行帧扩展引入。完整 `Agent` 是刻意允许的能力例外,因为它就是建立边界的执行主体。每个额外字段都必须是陈旧安全的标签,其陈旧副本最坏只能误标一条追踪记录;其他能力或控制通道需要独立 RFC。第一版不携带 `AbortSignal`;见「考虑过的替代方案」。 + +## 当前 Harness 依据 + +由于这份交接基于本地源码快照编写,目标分支可能已经前进,后续实现会话应在编辑前重新检查这些符号。 + +- `packages/core/agent/src/types.ts`:`Agent` 已经拥有 `session`、`status` 和 `ctx`。其中 `ctx` 的文档将它定义为注册作用域,而不是动态请求上下文。 +- `packages/core/agent/src/index.ts`:Cordis `Context.agent` 作为 Agent 作用域的开发体验关联被安装,在普通上下文上默认返回 `undefined`。不要改变这一语义。 +- `packages/core/agent-loop/src/agent.ts`:`ReactLoopAgent` 已经拥有 inbox、取消逻辑、每步骤 abort、状态和驱动生命周期。不要再创建一套并行的可变运行时状态对象。 +- `packages/core/agent-loop/src/loop.ts`:`runLoop(ctx, agent, handle)` 正好是需要包裹的生命周期边界。它会将 Agent、轮次、步骤和 signal 显式传给更窄的操作。 +- `packages/core/tools/src/index.ts`:`ToolExecutionInput.agent` 是显式字段,并用于选择作用域内的策略和工具解析。增加 ALS 后,它仍然保留在契约中。 +- `packages/core/agent/src/dispatch.ts`:`agentEvents()` 有意把 Agent 主体与其作用域载体融合。隐式上下文不能取代这套正确性机制。 +- `packages/core/README.md` 和现有 core 包:它们表明稳定的 Agent 控制契约位于 `core/`;`agent-execution` 是必载控制基础设施,而不是模型可见的可选上下文增强。 + +本提案扩展而非取代[关于 Agent 注册作用域的既有决策](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)及其[运行时设计](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)。 + +## Claude Code 参考实现 + +| Claude Code | Harness 中的对应设计 | +|---|---| +| AppState store | Cordis 部署服务及其拥有的实时状态 | +| QueryEngine | `ReactLoopAgent` 及其 loop 所拥有的运行时状态 | +| ToolUseContext | 能力边界上的显式 Agent、工具和请求参数 | +| AgentContext ALS | 本提案的窄粒度 `AgentExecution` 载体 | +| Transcript | 事件溯源 `Session` 与持久化后端 | + +## 实现交接步骤 + +后续实现会话应按以下顺序开展工作: + +1. 切换到预期目标分支,检查“当前 Harness 依据”中列出文件的当前版本。不要合并或复制编写本交接文档所在分支的修改。 +2. 新增 `packages/core/agent-execution/`,包含包元数据、README、导出类型、Cordis 服务、模块扩展和聚焦测试。 +3. 按照现有包门禁,把该包加入 TypeScript 项目引用、路径候选、运行时闭包或配置以及生成目录。优先使用仓库生成器,不要手工编辑生成文件。同时更新根 `AGENTS.md` 中 repository layout 的 `core/` 行、`packages/core/README.md` 中的包表,以及 `packages/README.md` 中的包组说明。 +4. 让 Agent Loop 声明并消费该服务。在不改变公开 Agent、事件、工具、LLM 或会话签名的前提下,用 `{ agent }` 包裹每个 Agent 驱动的完整 `runLoop` 调用。 +5. 增加集成测试:让同一进程中的两个 Agent 重叠执行,并在至少一次 `await` 后从异步工具执行内部观察到正确的隐式 Agent。 +6. 增加嵌套 Agent 覆盖:证明子 Agent 能看到自己,且子边界结束后父上下文得到恢复。 +7. 增加清除和失败覆盖:边界外返回 `undefined`,`require()` 清晰失败,`run(undefined, ...)` 屏蔽继承的 Agent,抛出异常或 rejected 操作不会污染后续无关工作。 +8. 在集成测试中增加一个能力传输测试替身。保持模型侧 schema 不变,并断言可信会话请求头由内部生成。适配真实生产远程后端属于本 RFC 之外的后续工作。 +9. 运行类型检查、定向测试、文档门禁、生成目录检查,最后运行仓库常规 CI 或 pre-push 门禁。 + +建议的聚焦测试矩阵: + +| 场景 | 必须观察到的结果 | +|---|---| +| 驱动之外 | `current()` 为 `undefined` | +| 一个 Agent 跨越 await | 每个 continuation 都看到完全相同的 Agent | +| 两个并发 Agent | A 永远看不到 B,B 永远看不到 A | +| 嵌套子 Agent | 子 Agent 看到自己;随后恢复父 Agent | +| 子 Agent 创建窗口 | 父工具调用内的创建流程隐式看到父 Agent;`agentCtx.agent` 是子 Agent | +| 直接调用无 Agent 工具 | 显式工具行为仍然有效;隐式身份不存在 | +| 已清除的分离工作 | `run(undefined, ...)` 隐藏继承的 Agent | +| 失败和取消 | throw、rejection 和 abort 后上下文均得到恢复 | +| Agent dispose | 隐式引用不赋予 dispose 后的能力 | +| 服务重载 | Agent 驱动在 ALS disable 前收敛;保留的已 dispose 服务调用抛出文档约定的稳定错误 | +| 能力传输边界 | 会话身份由测试替身传输层写入类型化请求或请求头 | + +## 考虑过的替代方案 + +**让每个函数都传递 Agent。** 对公开边界和承载权限的边界而言,这仍然是正确选择;但如果要求每个私有辅助函数都传递 Agent,就会产生大量样板代码,而隐式执行上下文正适合消除这些代码。本提案在边界处保留显式主体,只在单个可信异步进程内部使用 ALS。 + +**修改 `ctx.agent`,让它返回当前正在执行的 Agent。** 拒绝此方案,因为 `ctx.agent` 已经表示 Agent 作用域 Cordis 上下文的静态关联。让根上下文变成动态语义,会把注册作用域和执行作用域混合起来,在并发时产生意外行为,并破坏已经实现的 Agent 作用域 RFC。 + +**在 ALS 中存储完整的可变运行时对象。** 拒绝此方案,因为 Agent、会话、inbox、取消状态、轮次或步骤状态、工具执行和持久化日志已经有各自的真源。重复保存会产生陈旧快照、写入顺序问题,以及另一套需要清理的生命周期。 + +**在第一版 ALS 帧中携带步骤级 `AbortSignal`。** 本 RFC 拒绝此方案。signal 的生命周期是每步骤,而提议的 ALS 边界是每驱动,因此携带它需要嵌套的步骤和工具边界,还要明确规定分离工作、deadline 归属和恢复语义。现有执行 seam 已经显式传递取消。未来只有在出现具体横切消费方,并通过测试建立这些嵌套生命周期语义后,才可由独立 RFC 重新评估。 + +**使用一个进程级可变 `currentAgent`。** 拒绝此方案,因为并发 Agent 和 subagent 会在 await 边界间相互覆盖。它只有在所有工作严格串行时才正确,而多 Agent 执行明确不保证这一点。 + +**从模型可见的工具参数推导会话。** 拒绝此方案,因为模型可以修改这些参数。沙箱路由和授权需要可信的进程内身份,而不是用户或模型输入。 + +**把宿主平台的沙箱归属标识或提供方数据放入 Harness 上下文。** 拒绝此方案,因为沙箱归属是由 Harness 外部解析的宿主产品状态。Harness 在可信传输边界上传递自己的会话身份即可。 + +## 验收标准 + +- 一个 Node Harness 进程至少能并发执行两个 Agent,异步消费方始终观察到准确的发起 Agent。 +- 在 Agent 驱动执行之外,隐式查询返回 `undefined`,且 `require()` 抛出稳定、可操作的错误。 +- 嵌套 Agent 执行结束后会恢复父上下文。 +- `agent.ctx`、`ctx.agent`、Agent 事件、提示词组装、`ToolExecution.agent`、LLM `sessionId` 和会话持久化保持现有语义。 +- Agent、会话、轮次、步骤、沙箱和授权身份都不能由模型控制。 +- 实现为无关分离任务提供显式 undefined 边界,并通过测试防止上下文泄漏,且不改变现有显式取消契约。 +- 该服务随标准 agent 组合包加载,缺少它时 `dsh-agent-loop` 在加载阶段失败;配置测试锁定这一策略。 +- dispose 或 HMR(热模块替换)会先让所有依赖的 Agent 驱动收敛,再禁用 ALS;已 dispose 服务上的保留调用会抛出文档约定的稳定错误,且已 dispose 的 Cordis 上下文不能继续访问活跃 ALS 状态。 +- 一个能力传输测试替身能证明可信会话 ID 得到传播,同时不新增模型可见的 schema 字段。 +- 包目录、依赖图、API 文档和相关架构文档得到重新生成或更新,仓库文档门禁通过。 + +## 风险 + +- 隐式上下文会从函数签名中隐藏依赖。将它限制在深层横切基础设施,并保留显式公开主体,可以控制这一成本。 +- ALS 对分离 promise 和定时器的继承可能保留语义上陈旧的身份。实现必须提供显式 undefined 边界、文档和回归测试,而不能假设清理会自然发生。 +- ALS 不会跨越 worker thread、子进程、Redis、HTTP 或持久化队列。每个此类边界都必须显式序列化所需身份。 +- 隐式存储有意携带完整的存活 Agent 能力。被捕获的引用可能比 Agent 的发布状态活得更久,因此隐式存在本身绝不授权生命周期敏感工作,消费方仍须遵循 Agent 生命周期和取消契约。 +- 强制加载给每个 agent 组合新增一个核心运行时依赖;本 RFC 接受这一成本,因为可选服务会让隐式身份依赖具体组合。支持范围内的 Node 版本仍存在可测量的传播成本,应另行基准测试。 +- 过早加入轮次、步骤、signal、cwd 或工具细节会扩大继承范围和陈旧状态风险。第一版有意接受只提供 Agent 隐式身份的限制;未来任何额外的能力或控制字段都需要独立 RFC。 From 7bcae0cd64c3cca52ba1ee3ed846138ef354d307 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 16 Jul 2026 16:29:46 +0800 Subject: [PATCH 02/16] feat(core): add agent execution context --- docs/architecture.md | 23 +- docs/capability-seams.md | 5 + docs/config-catalog.md | 7 +- docs/cordis-catalog/services.md | 16 +- docs/core-data-structures/core.md | 44 +++ docs/module-graph.md | 9 +- docs/rfc/INDEX.md | 2 +- ...26-07-15-agent-execution-context.i18n.yaml | 4 +- .../2026-07-15-agent-execution-context.md | 71 ++++ .../2026-07-15-agent-execution-context.zh.md | 71 ++++ .../2026-07-15-agent-execution-context.md | 207 ---------- .../2026-07-15-agent-execution-context.zh.md | 207 ---------- examples/coding-agent/tests/code-mode.e2e.ts | 2 + examples/coding-agent/tests/harness.ts | 2 + examples/cordis-agent/tests/harness.ts | 2 + packages/README.md | 2 +- packages/bash/tool-bash/package.json | 1 + .../bash/tool-bash/tests/integration.spec.ts | 2 + packages/compact/compact-basic/package.json | 1 + .../tests/compact-loop-repro.spec.ts | 2 + packages/context/time-context/package.json | 1 + .../time-context/tests/time-context.spec.ts | 2 + packages/cordis/tool-cordis/package.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 13 + .../tool-cordis/tests/integration.spec.ts | 2 + packages/core/README.md | 5 +- packages/core/agent-execution/README.md | 23 ++ packages/core/agent-execution/package.json | 31 ++ packages/core/agent-execution/src/index.ts | 139 +++++++ packages/core/agent-execution/src/types.ts | 12 + .../tests/agent-execution.spec.ts | 136 +++++++ packages/core/agent-execution/tsconfig.json | 21 + packages/core/agent-loop/README.md | 6 +- packages/core/agent-loop/package.json | 2 + packages/core/agent-loop/src/agent.ts | 4 +- packages/core/agent-loop/src/index.ts | 3 +- .../agent-loop/tests/agent-execution.spec.ts | 374 ++++++++++++++++++ packages/core/agent-loop/tests/agent.spec.ts | 5 + packages/core/agent-loop/tests/cancel.spec.ts | 4 + .../tests/config-session-id.spec.ts | 7 + .../tests/contract-regressions.spec.ts | 9 + .../agent-loop/tests/coverage-edges.spec.ts | 2 + .../agent-loop/tests/interception.spec.ts | 2 + packages/core/agent-loop/tests/loop.spec.ts | 4 + .../core/agent-loop/tests/properties.spec.ts | 2 + .../agent-loop/tests/request-cache.e2e.ts | 2 + .../tests/request-reconstruction.spec.ts | 2 + packages/core/agent-loop/tests/resume.spec.ts | 9 + .../agent-loop/tests/scope-lifecycle.spec.ts | 2 + .../core/agent-loop/tests/tool-order.spec.ts | 2 + .../core/agent-loop/tests/turn-stop.spec.ts | 2 + packages/core/agent-loop/tsconfig.json | 3 + .../agent/tests/gen-cordis-catalog.spec.ts | 20 + packages/examples/README.md | 2 +- packages/examples/agent-spine-demo/README.md | 1 + .../examples/agent-spine-demo/package.json | 4 +- .../examples/agent-spine-demo/src/index.ts | 2 + .../examples/agent-spine-demo/tsconfig.json | 3 + packages/fs/tool-fs/package.json | 1 + packages/fs/tool-fs/tests/harness.ts | 2 + packages/guard/repeat-tool-guard/package.json | 1 + .../tests/repeat-tool-guard.spec.ts | 3 + packages/hooks/hooks-claude/package.json | 1 + .../hooks/hooks-claude/tests/bridge.spec.ts | 4 + .../hooks/hooks-claude/tests/coverage.spec.ts | 5 + packages/hooks/hooks-codex/package.json | 1 + .../hooks/hooks-codex/tests/bridge.spec.ts | 4 + .../hooks/hooks-codex/tests/coverage.spec.ts | 4 + .../sdk/helper/src/features/builtin/spine.ts | 4 + packages/subagent/subagent-fork/package.json | 1 + .../tests/multi-subagent.spec.ts | 2 + .../subagent-fork/tests/subagent-fork.spec.ts | 2 + .../subagent/subagent-inprocess/package.json | 1 + .../tests/structured.spec.ts | 2 + .../tests/subagent-inprocess.spec.ts | 2 + packages/subagent/subagent-spawn/package.json | 1 + .../subagent/subagent-spawn/tests/harness.ts | 2 + .../tests/subagent-spawn.spec.ts | 4 + packages/todo/tool-todo/package.json | 1 + .../todo/tool-todo/tests/integration.spec.ts | 2 + packages/ui/acp/package.json | 1 + packages/ui/acp/tests/harness.ts | 2 + .../workflow-workerthread/package.json | 1 + .../tests/integration.spec.ts | 2 + .../tests/workflow-workerthread.e2e.ts | 2 + pnpm-lock.yaml | 79 +++- python/sdk-runtime/package.json | 1 + scripts/gen-cordis-catalog.ts | 39 +- scripts/gen-doc-graphs.ts | 8 + scripts/type-equiv.manifest.json | 2 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.build.json | 1 + tsconfig.json | 1 + 93 files changed, 1272 insertions(+), 462 deletions(-) rename docs/rfc/{proposed => implemented}/architecture/2026-07-15-agent-execution-context.i18n.yaml (64%) create mode 100644 docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md create mode 100644 docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md delete mode 100644 docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md delete mode 100644 docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md create mode 100644 packages/core/agent-execution/README.md create mode 100644 packages/core/agent-execution/package.json create mode 100644 packages/core/agent-execution/src/index.ts create mode 100644 packages/core/agent-execution/src/types.ts create mode 100644 packages/core/agent-execution/tests/agent-execution.spec.ts create mode 100644 packages/core/agent-execution/tsconfig.json create mode 100644 packages/core/agent-loop/tests/agent-execution.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 848980a45a..d4faf07f4b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,7 +4,7 @@ The **DeepSeek Harness SDK** builds agent harnesses on Cordis. The principle is ## Overview -A harness is one [Cordis](cordis-primer.md) context. Packages contribute service keys, typed events, and disposable registrations: services expose stable calls (`ctx.llm`, `ctx.tools`, `ctx.sessions`), events provide interception and notifications (`agent/request`, `tools/pre-execute`, `session/event`), and registrations install prompt sections, tools, providers, adapters, or listeners. +A harness is one [Cordis](cordis-primer.md) context whose plugins contribute services, typed events, and disposable registrations. `packages/core/` groups the default agent flow; surrounding capabilities are equally first-class Cordis plugins. @@ -17,6 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `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.agentExecution` | `dsh-agent-execution` | process-local ambient Agent identity for asynchronous driver work | | `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver | ### Capability Services @@ -43,9 +44,9 @@ Events form the service extension API; see the exhaustive [events catalog](cordi ### Event Domains -- **Session events** are durable, replayable facts. Turn and step boundaries, user input, assistant output, tool calls, tool results, steering, compaction records, and tool-owned durable facts append to the session log and flow through `session/event`. -- **Agent events** carry the live `Agent` handle for status, diagnostics, prompt admission, call-config shaping, result validation, and continuation policy. -- **Capability events** belong to the seam that owns the action. `tools/*`, `llm/*`, `system-prompt/*`, `fs/*`, and `subagent/*` let policy and adapters attach without importing the loop. +- **Session events** are durable facts: turn/step boundaries, model input/output, tool activity, steering, compaction, and tool-owned records append to the log and flow through `session/event`. +- **Agent events** carry the live `Agent` handle through request and lifecycle policy. +- **Capability events** belong to the owning seam; policy and adapters attach without importing the loop. ### Interception Semantics @@ -53,7 +54,7 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins. +The shipped loop drains work, assembles and streams requests, executes tools, applies continuation policy, and checkpoints through plugin-visible services and events. A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. @@ -97,13 +98,13 @@ forever: The loop renders one prompt assembly per step. Plugins contribute ordered sections, tool schemas, and `{{name}}` variables; unknown or valueless references fail the turn instead of shipping a hole. `dsh-system-prompt` owns the harness identity and default deployment persona; an agent-scoped persona may shadow the default. The loop supplies `model` and `cwd`. See the [prompt-ownership RFC](rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). -Post-tool context lands after all tool results so tool-call/result adjacency stays stable. Steering drains between steps; ordinary leftover steering after a turn is re-queued as input. A terminal `agent/turn-stop` is the explicit exception: it runs after ordinary continuation and steering folding, then remains authoritative through turn close and flush so steering from those later listeners is discarded rather than becoming another step or turn; ordinary queued prompts are preserved. +Post-tool context follows all results, preserving call/result adjacency. Steering drains between steps and otherwise requeues after a turn. A terminal `agent/turn-stop` remains authoritative through turn close and flush, discarding later steering but preserving queued prompts. ### Failure Boundaries -The turn is the containment boundary. A throwing listener, adapter error finish, or failed step ends the current turn with an error reason and reports live diagnostics through `agent/error`; it does not kill the driver loop. `cancel()` clears queued and steering work, aborts the active model/tool boundary when possible, and records the appropriate turn end. Disposal stops the loop, awaits quiescence, unregisters the agent, and lets service disposers drain. +The turn contains listener, adapter, and step failures: it records an error reason and emits `agent/error` without killing the driver. `cancel()` clears pending work, aborts active model/tool work when possible, and records the turn end. Disposal stops and drains the loop before unregistering the agent. -Every session event is turn-enclosed. Reloading a crashed session preserves the interrupted tail and closes it with a synthetic `interrupted` turn end. A failure after the durable turn has closed reports through `agent/error` only because no safe in-turn position remains. A turn ends with one `TurnEndReason` (`completed`, `aborted`, `error`, `disposed`, `max-tokens`, `rejected`, or `interrupted`); per-variant semantics are in [session.md § TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). +Every session event is turn-enclosed. Reload closes an interrupted tail with a synthetic `interrupted` end; failures after durable turn close only emit `agent/error`. A turn has one `TurnEndReason`; [TurnEndReasonMap](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap) defines each variant. ### Agent Handles @@ -111,7 +112,11 @@ Every session event is turn-enclosed. Reloading a crashed session preserves the ### Agent Scope -Every live agent owns a scoped `agent.ctx`. Its registrations shadow same-named globals, receive only that agent's dispatches, and unwind with the agent; async effects such as background-task cleanup are awaited. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. The [semantic-gates RFC](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md) defines typed resolvers that derive carrier checks from merged `Events` signatures and `scopeTarget`, eliminating the handwritten event table. See the [agent-scope RFC](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md); subagent composition controls are documented [separately](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). +Every live agent owns a scoped `agent.ctx`; its registrations shadow globals, receive only that agent's dispatches, and unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes the scope before publication. See the [scope](rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md), [typed carrier checks](rfc/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md), and [subagent composition](rfc/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) decisions. + +### Agent Execution Context + +`AgentLoop` wraps each concrete driver in process-local `ctx.agentExecution`; child creation and setup stay outside its boundary, and explicit identities remain authoritative. See the [package contract](../packages/core/agent-execution/README.md) and [decision](rfc/implemented/architecture/2026-07-15-agent-execution-context.md). ## State diff --git a/docs/capability-seams.md b/docs/capability-seams.md index af4beab19e..cee9d6ed17 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -45,6 +45,8 @@ flowchart LR svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent registry"] + pkg_agent_execution["agent-execution"] + svc_agentExecution["ctx.agentExecution
Agent execution context"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_bash["bash"] @@ -91,6 +93,7 @@ flowchart LR pkg_acp --> svc_approval pkg_acp --> svc_userInteraction pkg_agent --> svc_agents + pkg_agent_execution --> svc_agentExecution pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval pkg_bash --> svc_bash @@ -133,6 +136,7 @@ flowchart LR pkg_web_search_perplexity --> svc_web pkg_workflow --> svc_workflows pkg_workflow_workerthread --> svc_workflows + svc_agentExecution --> pkg_agent_loop svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -198,6 +202,7 @@ flowchart LR | `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), [`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.agentExecution` | `core` | [`agent-execution`](../packages/core/agent-execution) | - | [`agent-loop`](../packages/core/agent-loop) | - | Carries the exact initiating Agent across one process-local asynchronous driver chain; explicit identities remain authoritative at external boundaries. | | `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.sandbox` | `seam` | [`sandbox`](../packages/sandbox/sandbox) | [`sandbox-local`](../packages/sandbox/sandbox-local) | [`bash-sandbox`](../packages/bash/bash-sandbox) | - | Consumers hand over the exact argv they are about to spawn; same-world backends wrap it under a per-call policy and report enforcement. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 91fe7c6497..06547594ec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -65,7 +65,7 @@ Source: [`packages/examples/acp-demo/src/index.ts:31`](../packages/examples/acp- ## `@deepseek-ai/dsh-agent-loop` -Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` +Requires: `agents` · `agentExecution` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Plugin configuration for declarative startup agents. */ @@ -84,7 +84,7 @@ export interface Config { Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:322`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:323`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -134,7 +134,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:55`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:56`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -1250,6 +1250,7 @@ Source: [`packages/workflow/workflow-workerthread/src/index.ts:32`](../packages/ These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) +- `@deepseek-ai/dsh-agent-execution` ([`packages/core/agent-execution/src/index.ts`](../packages/core/agent-execution/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 18e00b7904..dc4976238f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -9,6 +9,20 @@ This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verifie The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. +## `ctx.agentExecution` — `AgentExecutionService` (abstract seam) + +Ambient Agent identity within one process-local asynchronous chain. + +```ts cordis-catalog +current(): AgentExecution | undefined +require(): AgentExecution +run(execution: AgentExecution | undefined, operation: () => T): T +``` + +Types: [AgentExecution](../core-data-structures/core.md) + +Source: [`packages/core/agent-execution/src/index.ts:17`](../../packages/core/agent-execution/src/index.ts) + ## `ctx.agentLoop` — `AgentLoop` Concrete ReactLoopAgent factory and driver service. @@ -19,7 +33,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:335`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:336`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 340212d37a..133458f55b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -351,6 +351,50 @@ 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. +## Agent execution context + +`AgentExecution` is the process-local ambient frame established around a concrete driver's lifetime. It holds the exact Agent rather than duplicating Session or step state; ambient presence is neither liveness proof nor authorization. + +Source: [`packages/core/agent-execution/src/types.ts`](../../packages/core/agent-execution/src/types.ts) + +```ts type-equiv +interface AgentExecution { + readonly agent: Agent +} +``` + +The mandatory service reads, requires, establishes, or explicitly clears that frame. `run()` preserves the operation's exact synchronous value or Promise. + +Source: [`packages/core/agent-execution/src/index.ts`](../../packages/core/agent-execution/src/index.ts) + +```ts type-equiv +interface AgentExecutionService { + /** + * Read the active execution without requiring one. + * @returns the inherited execution, or `undefined` outside/inside a cleared boundary. + * @throws when this service instance has been disposed. + */ + current(): AgentExecution | undefined + + /** + * Read the active execution and fail when no boundary is active. + * @returns the inherited execution. + * @throws when no execution is active or this service instance has been disposed. + */ + require(): AgentExecution + + /** + * Run an operation inside an execution boundary. Passing `undefined` clears + * an inherited execution; the exact synchronous value or Promise is returned. + * @param execution - execution to inherit, or `undefined` for a clearing boundary. + * @param operation - synchronous or asynchronous operation to invoke. + * @returns the exact value returned by `operation`. + * @throws when this service is closing/disposed, or when `operation` throws. + */ + run(execution: AgentExecution | undefined, operation: () => T): T +} +``` + ## 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. They share one envelope for model-facing context, `HookContext`, which is `inject()`ed as a `context/message` and so carries a REQUIRED `source` (a missing source would default to `{kind:'user'}` and mislabel plugin context as a user prompt). diff --git a/docs/module-graph.md b/docs/module-graph.md index 82763bdc55..3cd212c61d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -18,6 +18,7 @@ flowchart TD end subgraph group_core["packages/core"] pkg_agent["agent"] + pkg_agent_execution["agent-execution"] pkg_agent_loop["agent-loop"] pkg_scope["scope"] pkg_session["session"] @@ -175,6 +176,7 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox + pkg_agent_execution --> pkg_agent pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent @@ -230,6 +232,7 @@ flowchart TD pkg_stdio --> pkg_session pkg_stdio --> pkg_user_interaction pkg_agent_loop --> pkg_agent + pkg_agent_loop --> pkg_agent_execution pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session @@ -330,6 +333,7 @@ flowchart TD pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent pkg_agent_spine_demo --> pkg_agent + pkg_agent_spine_demo --> pkg_agent_execution pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_invariants pkg_agent_spine_demo --> pkg_llm @@ -409,6 +413,7 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | +| [`agent-execution`](../packages/core/agent-execution) | `core` | [`agent`](../packages/core/agent) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | @@ -425,7 +430,7 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`agent-execution`](../packages/core/agent-execution), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | @@ -447,7 +452,7 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools) | +| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-execution`](../packages/core/agent-execution), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 2660132fb2..bc355fa328 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -28,7 +28,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| | [Runtime schemas for the event vocabulary (Zod vs the merge-extensible-map pattern)](proposed/architecture/2026-06-16-typed-event-schemas.md) | 2026-06-16 | -| [Agent execution context over AsyncLocalStorage](proposed/architecture/2026-07-15-agent-execution-context.md) | 2026-07-15 | | [SDK project editing architecture](proposed/architecture/2026-07-15-sdk-project-editing-architecture.md) | 2026-07-15 | ### Process @@ -150,6 +149,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | | [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 | +| [Agent execution context over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-execution-context.md) | 2026-07-15 | ### Process diff --git a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml similarity index 64% rename from docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.i18n.yaml rename to docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml index 65be95551b..24dfc87f70 100644 --- a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-agent-execution-context.md: 7bea10fb1268c91a7668f7d269f83ae1250c373c -2026-07-15-agent-execution-context.zh.md: 5a7b12974818a076434ff1a1b3b4ab819866b3d7 +2026-07-15-agent-execution-context.md: 9f41aee74dbd94fa5acf93bface57618c604ec17 +2026-07-15-agent-execution-context.zh.md: 4747a506b4fb8a0ff798043ec772a4e810f84d7f diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md new file mode 100644 index 0000000000..9f41aee74d --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md @@ -0,0 +1,71 @@ +# RFC: Agent execution context over AsyncLocalStorage + +Status: implemented + +English | [中文](2026-07-15-agent-execution-context.zh.md) + +## Problem + +The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. + +Deep process-local infrastructure still needs a trusted initiating Agent. Capability transports, tracing helpers, loggers, and gateway clients may sit below the explicit loop, tool, and request parameters. Threading `agent` through every private helper adds plumbing, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are also unsuitable because a model must not choose a trusted Session or routing header. + +## Decision + +`@deepseek-ai/dsh-agent-execution` provides the mandatory `ctx.agentExecution` service using Node `AsyncLocalStorage`. The frame contains only the exact live Agent: + +```text +export interface AgentExecution { + readonly agent: Agent +} + +export interface AgentExecutionService { + current(): AgentExecution | undefined + require(): AgentExecution + run(execution: AgentExecution | undefined, operation: () => T): T +} +``` + +`current()` is optional, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a real clearing boundary for work that must not inherit an Agent. Session remains derived as `execution.agent.session`; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners. + +`AgentLoop` injects the service and wraps each concrete driver's complete `runLoop` lifetime in `agentExecution.run({ agent }, ...)`. Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. + +Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, cwd selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. + +The provider uses an ordered composite effect. Teardown first rejects new boundaries, then removes the service and awaits injected dependents such as AgentLoop, then waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. `current()` and `require()` remain usable through a retained in-flight service reference while that drain runs; after disposal, retained calls throw `agent execution service is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting is required in addition to Cordis dependency ordering. + +Asynchronous resources created inside `run()` inherit its store even when the returned operation does not await them. Agent-owned foreground work may inherit `{ agent }` but keeps the explicit cancellation and disposal contract of its execution seam. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop. Queue, worker, process, and wire boundaries serialize identity rather than expecting ALS propagation. + +A host-aware transport may derive a deployment-owned header such as `X-Harness-Session-Id` from `ctx.agentExecution.require().agent.session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam. + +This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. + +## Verification + +Service tests pin optional and required reads, synchronous and awaited propagation, overlapping and nested boundaries, explicit clearing, restoration after throw or rejection, exact return identity, drain ordering, and disposed-reference errors. AgentLoop integration tests run overlapping real drivers, nested parent/child creation, agentless direct tool execution, cancellation during provider/root teardown, service restart, and a captured Agent after disposal. + +The test-double capability transport derives `X-Harness-Session-Id` internally and asserts that neither its tool schema nor logged arguments contains an identity field. Composition tests and generated catalogs keep the provider present in the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses; a missing provider leaves AgentLoop inactive. + +## Alternatives considered + +**Pass Agent through every function.** Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds plumbing without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries. + +**Make `ctx.agent` dynamic.** `ctx.agent` already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising. + +**Store a complete mutable runtime frame.** Agent, Session, inbox, cancellation, turn, step, tool execution, and persistence already have authoritative owners. Duplicating them would create stale snapshots and another lifecycle. The wrapper leaves room for a separately justified stale-safe label without flattening the store to a bare Agent. + +**Include a step `AbortSignal`, cwd, sandbox, or authorization.** Their lifetimes and authority do not match the driver boundary, and their existing seams already pass them explicitly. Adding a control capability requires a separate decision and nested lifecycle contract. + +**Use a process-global `currentAgent`.** Concurrent Agents and subagents overwrite one another across awaited continuations, so a mutable global is correct only under a serialization guarantee the harness does not make. + +**Derive identity from model-visible arguments.** Model or user input cannot be trusted to select Session, tenant, or sandbox routing. + +**Add routing identity to every capability seam.** That spreads hosting concerns through provider-neutral APIs. A host-aware implementation owns its transport header while public boundaries remain explicit. + +## Consequences + +Deep infrastructure gains one trusted process-local initiating Agent without widening existing tool and capability requests. Concurrent and nested drivers isolate automatically, AgentLoop stays inactive when the provider is absent, and HMR/root disposal reaches quiescence before ALS is disabled. + +The dependency is implicit in function signatures and carries a live capability object. Consumers must restrict it to cross-cutting infrastructure, treat ambient presence as neither liveness nor authorization, and retain explicit cancellation and ownership checks. ALS also has an always-on propagation cost and does not cross worker, process, HTTP, or durable queue boundaries. + +The frame deliberately omits turn, step, signal, cwd, sandbox, and authorization. A real consumer that cannot use existing explicit fields must justify any refinement separately; a stale copied field may at most mislabel telemetry, never grant control. diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md new file mode 100644 index 0000000000..4747a506b4 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md @@ -0,0 +1,71 @@ +# RFC: 基于 AsyncLocalStorage 的 Agent 执行上下文 + +Status: implemented + +[English](2026-07-15-agent-execution-context.md) | 中文 + +## 问题 + +Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 + +进程内深层基础设施仍需要可信的发起 Agent。能力传输层、追踪辅助函数、日志器和网关客户端可能位于显式 loop、工具及请求参数的下层。在每个私有辅助函数中传递 `agent` 会增加管道代码,而进程级可变槽会在 `await` 之间发生并发错误。模型可见参数同样不合适,因为模型不能选择可信的会话或路由请求头。 + +## 决策 + +`@deepseek-ai/dsh-agent-execution` 使用 Node `AsyncLocalStorage` 提供必载的 `ctx.agentExecution` 服务。该帧只包含准确的存活 Agent: + +```text +export interface AgentExecution { + readonly agent: Agent +} + +export interface AgentExecutionService { + current(): AgentExecution | undefined + require(): AgentExecution + run(execution: AgentExecution | undefined, operation: () => T): T +} +``` + +`current()` 执行可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的准确同步值或 Promise。`run(undefined, operation)` 会建立真实的清空边界,供不得继承 Agent 的工作使用。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。 + +`AgentLoop` 注入该服务,并用 `agentExecution.run({ agent }, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 + +隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、cwd 选择、取消、worker/进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 + +提供方使用有序复合 effect。teardown 会先拒绝新边界,再移除服务并等待 AgentLoop 等注入方排空,随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。排空期间,进行中代码可通过保留的服务引用继续调用 `current()` 和 `require()`;dispose 后,保留引用会抛出 `agent execution service is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外还必须统计活动边界。 + +在 `run()` 内创建的异步资源会继承其存储,即使返回的操作没有等待它们。Agent 所拥有的前台工作可以继承 `{ agent }`,但仍使用其执行 seam 的显式取消和 dispose 契约。无关的定时器、队列和部署基础设施在 `run(undefined, operation)` 下启动,并拥有显式停止操作。队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。 + +宿主感知的传输层可以从 `ctx.agentExecution.require().agent.session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 + +本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 + +## 验证 + +服务测试锁定可选与必需读取、同步与跨 `await` 传播、并发与嵌套边界、显式清空、throw 或 rejection 后的恢复、准确返回值身份、排空顺序及已 dispose 引用错误。AgentLoop 集成测试覆盖重叠的真实驱动、嵌套父子创建、无 Agent 的直接工具执行、提供方或根 Context teardown 期间的取消、服务重启,以及 Agent dispose 后保留的引用。 + +测试替身能力传输层在内部推导 `X-Harness-Session-Id`,并断言工具 schema 与记录的参数都不包含身份字段。组合测试和生成目录确保默认 bundle、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方;缺少提供方时 AgentLoop 保持未激活。 + +## 考虑过的替代方案 + +**在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会增加管道代码,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 + +**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。 + +**保存完整的可变运行时帧。** Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。重复保存会产生陈旧快照和另一套生命周期。包装对象为另行论证的陈旧安全标签保留扩展空间,而不会把存储简化成裸 Agent。 + +**包含步骤级 `AbortSignal`、cwd、沙箱或授权。** 它们的生命周期与权限不匹配驱动边界,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。 + +**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步 continuation 间相互覆盖,因此可变全局值只在 Harness 不具备的串行保证下才正确。 + +**从模型可见参数推导身份。** 不能信任模型或用户输入来选择会话、租户或沙箱路由。 + +**给每个能力 seam 增加路由身份。** 这会把宿主关注点扩散到提供方无关 API。宿主感知实现拥有其传输请求头,而公开边界继续显式传递身份。 + +## 后果 + +深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,缺少提供方时 AgentLoop 保持未激活,HMR 或根 Context dispose 会在禁用 ALS 前达到静止状态。 + +该依赖不会出现在函数签名中,并且携带一个存活能力对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。 + +该帧有意省略轮次、步骤、signal、cwd、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。 diff --git a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md deleted file mode 100644 index 7bea10fb12..0000000000 --- a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.md +++ /dev/null @@ -1,207 +0,0 @@ -# RFC: Agent execution context over AsyncLocalStorage - -Status: proposed - -English | [中文](2026-07-15-agent-execution-context.zh.md) - -## Problem - -The harness has two useful but different notions of context: - -- A Cordis `Context` is a composition and lifetime object. The deployment context exposes shared services, while `agent.ctx` exposes the flat registration layer owned by one live Agent. -- Agent, Session, turn, step, and tool identity are execution subjects. The loop passes them explicitly through events, prompt assembly, LLM requests, and `ToolExecution`. - -These concepts must not be conflated. In particular, `agent.ctx.agent` is a static association on the Agent's scoped composition context. A plain root context deliberately returns `undefined`; it cannot be changed to mean "whichever Agent happens to be running now" because one Node process may run many Agents concurrently. - -This leaves a practical gap for deeply nested infrastructure. A capability transport, skill provider, tracing helper, logger, or gateway client may need to know which Agent initiated the current asynchronous operation. Passing `agent` through every intermediate helper is noisy, while deriving identity from a process-global mutable slot is incorrect as soon as two Agents overlap. Model-visible tool arguments are also the wrong carrier: the model must not be able to choose a trusted Session or sandbox-routing header. - -The gap becomes important when a single Harness runtime multiplexes Sessions for a multi-tenant hosting platform. Outbound capability requests must automatically carry the current Harness Session ID so the host can resolve the correct tenant and sandbox owner. Model-facing skills and tools should not know host-specific routing, but the selected capability implementation still needs a trusted current Agent at the transport boundary. - -## Proposal - -Add a narrow Agent execution-context facility backed by Node `AsyncLocalStorage`. It provides ambient access to the Agent associated with the current asynchronous execution chain without replacing Cordis contexts, explicit protocol fields, or durable Session state. - -The first version stores only the Agent: - -```text -export interface AgentExecution { - readonly agent: Agent -} - -export interface AgentExecutionService { - current(): AgentExecution | undefined - require(): AgentExecution - run(execution: AgentExecution | undefined, operation: () => T): T -} -``` - -`Session` is derived as `execution.agent.session`; it is not duplicated in the store. Turn, step, tool call, model, cwd, and sandbox identity remain outside the first version because they already have authoritative owners and no confirmed ambient consumer requires them yet. The single-field wrapper is deliberate: a later execution-frame refinement extends `AgentExecution` without changing `run()` callers, so implementations must not flatten the store to a bare `Agent`. - -`AgentExecution` deliberately retains the exact live `Agent`, not an id snapshot. This is the one capability admitted to the first-version store because it is the subject whose driver establishes the boundary and because existing scoped helpers operate on that exact object. Ambient presence is not proof of liveness or authorization: consumers must still honor the Agent lifecycle and the explicit capability contract before performing lifecycle-sensitive work. - -The API must always establish an ALS boundary, including when the supplied execution is `undefined`. This provides an explicit way to clear inherited context for unrelated detached work. A comparable implementation observed an uncleared ambient value crossing scheduled work into a later turn; the explicit undefined boundary prevents that class of leak. - -### Package and service placement - -Create `packages/core/agent-execution/` as `@deepseek-ai/dsh-agent-execution`. The package owns the Node-specific ALS implementation and augments Cordis with the mandatory `ctx.agentExecution` service. It belongs to `core/` because it is part of the stable Agent control spine that every concrete Agent loop and ambient-identity consumer programs against. - -The public key is `ctx.agentExecution`, settled here so every surface — service key, interface name, and package name — shares one word root. It names the Agent-owned asynchronous chain rather than one turn or tool call. `ctx.execution` is too broad; a runtime-flavored name would collide with `packages/code-runtime/` and with "Harness runtime" meaning the whole process; and changing `ctx.agent` is excluded because it already means the static Agent association of `agent.ctx`. - -The package exposes the service through Cordis rather than a mutable module-global slot: - -- the Agent Loop can inject the service explicitly; -- tests can mount an isolated service per Harness context; -- service disposal can disable its ALS instance after dependent Agent drivers quiesce; -- the dependency remains visible in Cordis configuration and generated catalogs. - -The service loads mandatorily with the standard agent composition bundle, and `dsh-agent-loop` declares it in `inject`: a composition that drives agents without it fails at load, per the fail-loud rule, rather than degrading to absent ambient identity at the first deep consumer. Configuration tests pin this policy. The facility relies only on stable Node `AsyncLocalStorage`, available without a polyfill across the supported `node ^22.19 || >=24` range. Node 24+ uses an `AsyncContextFrame`-backed implementation, while Node 22 uses the earlier implementation; this RFC accepts the always-on propagation cost for the invariant and makes no zero-overhead claim. - -Service teardown is ordered rather than transparent. The Agent Loop stops accepting new work, cancels and drains every driver, and only then may the service disable its ALS instance. HMR of the service rebuilds that dependent subtree; it does not preserve an in-flight turn across reload. A retained reference to a disposed service throws a stable disposed-service error from both `current()` and `require()` instead of silently returning `undefined`. - -### Lifecycle boundary - -Bind the execution context around each concrete Agent driver's `runLoop` lifetime: - -```text -agentExecution.run({ agent }, () => runLoop(ctx, agent, handle)) -``` - -This gives every operation initiated by that driver the same trusted Agent: - -- prompt interception and prompt assembly; -- LLM adapter calls; -- tool policy and tool bodies; -- capability providers and transports; -- synchronous and asynchronous helpers awaited by those operations. - -Concurrent drivers receive distinct ALS stores. A child Agent's own driver establishes a new boundary with the child, so child operations do not inherit the parent Agent merely because child creation started inside a parent tool call. When a nested boundary returns, ALS restores the parent automatically. - -Agent creation setup is deliberately outside this dynamic boundary. Setup already receives `agentCtx`, whose `agentCtx.agent` is the correct unpublished Agent. Publication and lifecycle ownership continue to use the existing explicit Agent and scoped carrier. One consequence is a defined contract, not an accident: when child creation starts inside a parent tool call, the child's setup and persistence load run under the PARENT's ambient identity, because the child's driver has not started. A transport reached during that window routes under the parent's Session — correct for trusted routing, since the parent initiated and owns the creation work. Setup code that needs the child's identity uses the explicit `agentCtx.agent`, never the ambient store. - -### Explicit subjects remain authoritative - -Ambient identity is a convenience for deep infrastructure, not a replacement for existing contracts: - -- `AgentEventDispatch` continues to carry the explicit Agent subject and scope. -- `AssembleContext.agent` remains explicit. -- `ToolExecution.agent` remains explicit and continues to select the scoped tool and policy view. -- `GenerateOptions.sessionId` remains explicit at the LLM boundary. -- Subagent requests and lifecycle events continue to carry explicit parent and child identity. -- Session events remain the durable truth for replay and resume. - -Code at a public service, process, worker, persistence, or wire boundary must materialize the identity it needs into that boundary's typed request. A remote process cannot access the parent's ALS store. - -### Trusted transport use - -A host-aware capability transport may read `ctx.agentExecution.require().agent.session.id` when constructing an outbound request and add a deployment-owned trusted header such as `X-Harness-Session-Id`. The header is not present in model-visible tool arguments and cannot be overridden by the model. Ambient presence alone does not authorize a request; the transport still runs inside its normal explicit capability and Agent-lifecycle contracts. - -The bash seam's existing `OwnerToken` is the nearest explicit-identity precedent and shows why it does not close this gap: `BashExecSpec.owner` is a background-task isolation key that `dsh-tool-bash` casts from the session id, foreground `run()` deliberately ignores it, and the filesystem seam has no counterpart — its provider methods carry no identity parameter at all. Extending every capability seam with a routing-identity parameter would push hosting concerns into seam vocabularies that are otherwise deployment-neutral; ambient identity lets the transport implementation own routing without widening any seam. - -The hosting platform remains responsible for resolving the Harness runtime Session ID to its product Session and sandbox owner. Harness does not learn the host's sandbox identifier, sandbox provider, or persistence model. - -Model-facing skill and tool plugins should not add hosting-specific headers themselves. They call a capability service; the selected provider owns remote execution and identity propagation. This preserves the separation between model behavior and backend routing. - -### Detached asynchronous work - -Node ALS is inherited by asynchronous resources created inside `run()`, even when callers do not await them. This is useful for an Agent-owned background operation, but it can also retain a stale turn's context in unrelated work. - -Identity inheritance does not replace cancellation ownership. Work started inside an Agent's boundary is either **foreground** — it inherits `{ agent }` and separately receives the explicit cancellation signal owned by its execution seam — or **detached** — it starts under `run(undefined, operation)` and owns its own lifecycle with an explicit stop. The caller must keep those choices aligned. The implementation must document and test these rules: - -- Work logically owned by the Agent is foreground: it may inherit `{ agent }`, receives cancellation through the existing explicit seam, and must honor the Agent's disposal contract. -- Long-lived deployment infrastructure, timers, and work queues unrelated to that Agent are detached: they must start under `run(undefined, operation)` and be stopped by their own owner, never implicitly by a turn ending. -- Code that enqueues data for later processing must serialize the required identity into the queue item; it must not expect ALS to cross the queue, process, or worker boundary. -- Consumers must not treat an ambient Agent reference as proof that the Agent is still live. Lifecycle-sensitive operations still check `agent.status`, an explicit signal, or the owning service's contract. - -`turn` and `step` remain outside the first version; they can join later as a separate immutable execution-frame refinement if a real cross-cutting consumer (tracing, logging) cannot use the existing explicit fields. The full `Agent` is the deliberate capability exception because it is the execution subject that establishes the boundary. Every additional field must be a stale-safe label whose stale copy can at worst mislabel a trace; another capability or control channel requires its own RFC. `AbortSignal` is excluded from the first version under that rule; see Alternatives considered. - -## Current Harness evidence - -The implementation Session should re-check these symbols on its target branch before editing because this handoff was prepared against a local source snapshot and the branch may have advanced. - -- `packages/core/agent/src/types.ts`: `Agent` already owns `session`, `status`, and `ctx`. Its `ctx` documentation defines a registration scope, not a dynamic request context. -- `packages/core/agent/src/index.ts`: Cordis `Context.agent` is installed as an Agent-scope DX association and defaults to `undefined` on a plain context. Do not change this semantic. -- `packages/core/agent-loop/src/agent.ts`: `ReactLoopAgent` already owns inbox, cancellation, per-step abort, status, and driver lifetime. Do not create a parallel mutable runtime-state object. -- `packages/core/agent-loop/src/loop.ts`: `runLoop(ctx, agent, handle)` has the exact lifetime boundary to wrap. It passes Agent, turn, step, and signal explicitly to narrower operations. -- `packages/core/tools/src/index.ts`: `ToolExecutionInput.agent` is explicit and selects scoped policy and tool resolution. It remains in the contract after ALS is added. -- `packages/core/agent/src/dispatch.ts`: `agentEvents()` deliberately fuses the Agent subject with its scoped carrier. Ambient context must not replace this correctness mechanism. -- `packages/core/README.md` and the existing core packages: they show that stable Agent control contracts belong in `core/`; `agent-execution` is mandatory control infrastructure rather than optional model-visible context enrichment. - -This proposal extends, rather than supersedes, [the Agent registration-scope decision](../../implemented/architecture/2026-07-08-agent-scope-contexts.md) and its [runtime design](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md). - -## Claude Code reference implementation - -| Claude Code | Harness translation | -|---|---| -| AppState store | Cordis deployment services and their owned live state | -| QueryEngine | `ReactLoopAgent` plus its loop-owned runtime state | -| ToolUseContext | Explicit Agent/tool/request parameters at capability seams | -| AgentContext ALS | Proposed narrow `AgentExecution` carrier | -| Transcript | Event-sourced `Session` and persistence backends | - -## Implementation handoff - -The implementation Session should perform the work in this order: - -1. Switch to the intended target branch and inspect the current versions of the files listed under "Current Harness evidence". Do not merge or copy changes from the branch on which this handoff was authored. -2. Add `packages/core/agent-execution/` with package metadata, README, exported types, the Cordis service, module augmentation, and focused tests. -3. Add the package to TypeScript project references, path candidates, runtime closure/configuration, and generated catalogs according to existing package gates. Prefer repository generators over hand-editing generated files. Also update the `core/` repository-layout line in root `AGENTS.md`, the package table in `packages/core/README.md`, and the package-group description in `packages/README.md`. -4. Make the Agent Loop declare and consume the service. Wrap each Agent driver's complete `runLoop` invocation in `{ agent }` without changing public Agent, event, tool, LLM, or Session signatures. -5. Add an integration test that overlaps two Agents in one process and observes the correct ambient Agent from inside asynchronous tool execution after at least one `await`. -6. Add nested-Agent coverage proving a child sees itself and the parent context is restored after the child boundary settles. -7. Add clearing and failure coverage: outside a boundary returns `undefined`, `require()` fails clearly, `run(undefined, ...)` masks an inherited Agent, and thrown/rejected operations do not contaminate later unrelated work. -8. Add a test-double capability transport to the integration suite. Keep the model-facing schema unchanged and assert that a trusted Session header is generated internally. Adapting a production remote backend is follow-up work outside this RFC. -9. Run typecheck, targeted tests, documentation gates, generated-catalog checks, and then the repository's normal CI/pre-push gate. - -Suggested focused test matrix: - -| Scenario | Required observation | -|---|---| -| Outside driver | `current()` is `undefined` | -| One Agent across awaits | Every continuation sees the same exact Agent | -| Two concurrent Agents | A never observes B and B never observes A | -| Nested child | Child sees child; parent is restored afterward | -| Child creation window | Setup inside a parent tool call sees the parent ambiently; `agentCtx.agent` is the child | -| Direct Agent-less tool call | Explicit tool behavior remains valid; ambient identity is absent | -| Cleared detached work | `run(undefined, ...)` hides the inherited Agent | -| Failure and cancellation | Context restores after throw, rejection, and abort | -| Agent disposal | Lifecycle-sensitive consumers reject work from a captured Agent after disposal | -| Service reload | Agent drivers drain before ALS disable; retained disposed-service calls throw the documented stable error | -| Capability transport boundary | Session identity is materialized into the typed request/header by the test-double transport | - -## Alternatives considered - -**Pass Agent through every function.** This remains the right choice at public and authority-bearing boundaries, but forcing it through every private helper creates plumbing that ambient execution context is designed to remove. The proposal keeps explicit subjects at seams and uses ALS only within one trusted asynchronous process. - -**Change `ctx.agent` to return the currently executing Agent.** Rejected because `ctx.agent` already denotes the static association of an Agent-scoped Cordis context. Making a root context dynamic would combine registration scope with execution scope, produce surprising behavior under concurrency, and break the implemented Agent-scope RFCs. - -**Store a complete mutable runtime object in ALS.** Rejected because Agent, Session, inbox, cancellation, turn/step state, tool execution, and durable log already have authoritative owners. Duplicating them creates stale snapshots, write-order questions, and another lifecycle to clean up. - -**Carry the step `AbortSignal` in the first-version ALS frame.** Rejected for this RFC. The signal is per-step while the proposed boundary is per-driver, so carrying it requires nested step and tool boundaries plus explicit rules for detached work, deadline ownership, and restoration. Existing execution seams already pass cancellation explicitly. A future RFC may revisit this only with a concrete cross-cutting consumer and tests that establish those nested lifecycle semantics. - -**Use one process-global mutable `currentAgent`.** Rejected because concurrent Agents and subagents overwrite one another across awaits. It is correct only under serialization, which multi-Agent execution explicitly does not guarantee. - -**Infer the Session from model-visible tool arguments.** Rejected because the model can alter those arguments. Sandbox routing and authorization require a trusted in-process identity, not user/model input. - -**Put a hosting platform's sandbox-owner identifier or provider data in Harness context.** Rejected because sandbox ownership is hosting-product state resolved outside Harness. Harness should carry only its own Session identity across the trusted transport boundary. - -## Acceptance criteria - -- One Node Harness process can execute at least two Agents concurrently, and asynchronous consumers always observe the exact initiating Agent. -- Outside Agent driver execution, ambient lookup returns `undefined` and `require()` throws a stable, actionable error. -- Nested Agent execution restores the parent context after the child settles. -- `agent.ctx`, `ctx.agent`, Agent events, prompt assembly, `ToolExecution.agent`, LLM `sessionId`, and Session persistence retain their existing semantics. -- No Agent, Session, turn, step, sandbox, or authorization identity becomes model-controlled. -- The implementation provides an explicit undefined boundary for unrelated detached work and tests it against context leakage without changing existing explicit cancellation contracts. -- The service loads with the standard agent bundle and `dsh-agent-loop` fails at load without it; a configuration test pins the policy. -- Disposal/HMR drains every dependent Agent driver before disabling ALS; retained calls on the disposed service fail with the documented stable error, and no active ALS state remains reachable through the disposed Cordis context. -- A test-double capability transport proves trusted Session ID propagation without adding a model-visible schema field. -- Package catalogs, dependency graphs, API docs, and relevant architecture docs are regenerated or updated, and the repository's documentation gates pass. - -## Risks - -- Ambient context hides a dependency from function signatures. Restricting it to deep cross-cutting infrastructure and retaining explicit public subjects limits that cost. -- ALS inheritance into detached promises and timers can retain semantically stale identity. An explicit undefined boundary, documentation, and regression tests are required rather than assumed cleanup. -- ALS does not cross worker threads, subprocesses, Redis, HTTP, or persisted queues. Every such boundary must serialize the required identity explicitly. -- The ambient store intentionally carries the full live Agent capability. A captured reference can outlive publication, so ambient presence alone never authorizes lifecycle-sensitive work and consumers must still honor Agent lifecycle and cancellation contracts. -- Mandatory loading adds a core runtime dependency to every agent composition; the RFC accepts that cost because an optional service would make ambient identity composition-dependent. Propagation cost remains measurable across supported Node versions and should be benchmarked separately. -- Adding turn, step, signal, cwd, or tool details prematurely would expand inheritance and staleness hazards. The first version deliberately accepts the limitation of Agent-only ambient identity; any additional capability or control field requires a separate RFC. diff --git a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md b/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md deleted file mode 100644 index 5a7b129748..0000000000 --- a/docs/rfc/proposed/architecture/2026-07-15-agent-execution-context.zh.md +++ /dev/null @@ -1,207 +0,0 @@ -# RFC:基于 AsyncLocalStorage 的 agent(智能体)执行上下文 - -Status: proposed - -[English](2026-07-15-agent-execution-context.md) | 中文 - -## 问题 - -harness 中存在两种有用但含义不同的上下文: - -- Cordis `Context` 是依赖组合和生命周期对象。部署上下文暴露共享服务,`agent.ctx` 则暴露某个存活 Agent 所拥有的扁平注册层。 -- Agent、会话、轮次、步骤和工具身份是执行主体。agent loop(智能体循环)通过事件、提示词组装、LLM(大语言模型)请求和 `ToolExecution` 显式传递这些信息。 - -这两类概念不能混为一谈。尤其是,`agent.ctx.agent` 是 Agent 作用域组合上下文上的静态关联。普通根上下文会有意返回 `undefined`;不能把它改成“当前恰好正在运行的 Agent”,因为一个 Node 进程可能并发运行多个 Agent。 - -这给深层基础设施留下了一个实际缺口。能力传输层、skill(技能)提供方、追踪辅助函数、日志记录器或网关客户端,可能需要知道当前异步操作由哪个 Agent 发起。让每一层中间辅助函数都继续传递 `agent` 会产生大量样板代码,而从进程级可变全局槽推导身份,会在两个 Agent 并发后立即出错。模型可见的工具参数也不是合适的载体:模型不能选择可信的会话或沙箱路由请求头。 - -当单个 Harness 运行时为多租户宿主平台复用多个会话时,这个缺口会变得尤其重要。对外能力请求必须自动携带当前 Harness 会话 ID,以便宿主平台解析正确的租户和沙箱归属。模型侧的 skill 和工具不应理解宿主平台特有的路由,但所选能力实现仍需要在传输边界获得可信的当前 Agent。 - -## 提案 - -新增一套由 Node `AsyncLocalStorage` 支撑的窄粒度 Agent 执行上下文能力。它允许代码在当前异步执行链内访问关联的 Agent,但不会取代 Cordis 上下文、显式协议字段或持久化会话状态。 - -第一版只保存 Agent: - -```text -export interface AgentExecution { - readonly agent: Agent -} - -export interface AgentExecutionService { - current(): AgentExecution | undefined - require(): AgentExecution - run(execution: AgentExecution | undefined, operation: () => T): T -} -``` - -`Session` 通过 `execution.agent.session` 推导,不在存储中重复保存。轮次、步骤、工具调用、模型、cwd 和沙箱身份不进入第一版,因为它们已经有各自的真源,而且目前没有已确认的隐式上下文消费方需要这些信息。单字段包装是有意为之:后续的执行帧扩展可以在不改动 `run()` 调用方的前提下扩展 `AgentExecution`,因此实现不得把存储简化成裸 `Agent`。 - -`AgentExecution` 有意保留准确的存活 `Agent`,而不是 ID 快照。这是第一版存储中唯一获准的能力对象,因为它正是由驱动建立边界的执行主体,而且现有作用域辅助函数依赖这个准确对象。隐式存在不代表仍然存活或已经获得授权:消费方执行生命周期敏感工作前,仍须遵循 Agent 生命周期和显式能力契约。 - -API 必须始终建立 ALS 边界,即使传入的 execution 是 `undefined` 也不例外。这样可以显式清除无关分离任务继承到的上下文。一个同类实现曾观察到未清空的隐式值穿过已调度工作泄漏进后续轮次;显式 undefined 边界可以防止这类泄漏。 - -### 包与服务位置 - -在 `packages/core/agent-execution/` 新建 `@deepseek-ai/dsh-agent-execution`。该包拥有 Node 专用的 ALS 实现,并通过必载的 `ctx.agentExecution` 服务扩展 Cordis。它属于 `core/`,因为这是每个具体 Agent loop 和隐式身份消费方所依赖的稳定 Agent 控制主干。 - -公开键名在此定为 `ctx.agentExecution`,服务键、接口名和包名共用同一个词根。它表示某个 Agent 所拥有的异步调用链,而不是单个轮次、步骤或工具调用;名字也直接说明存储的内容。`ctx.execution` 含义过宽;带 runtime 字样的名字会与 `packages/code-runtime/` 以及指整个进程的 “Harness 运行时” 冲突;修改 `ctx.agent` 被排除,因为它已经表示 `agent.ctx` 与 Agent 之间的静态关联。 - -该包通过 Cordis 暴露服务,而不是使用可变模块全局槽: - -- Agent Loop 可以显式注入该服务; -- 测试可以为每个 Harness 上下文挂载隔离的服务; -- 服务 dispose(资源释放)时可以在依赖它的 Agent 驱动静止后禁用其 ALS 实例; -- 依赖关系在 Cordis 配置和生成目录中保持可见。 - -该服务随标准 agent 组合包强制加载,`dsh-agent-loop` 在 `inject` 中声明它:缺少该服务的 agent 组合按快速失败规则在加载时报错,而不是等到第一个深层消费方读取时才发现隐式身份缺失。配置测试锁定这一策略。该能力只依赖稳定的 Node `AsyncLocalStorage`,支持范围 `node ^22.19 || >=24` 全部可原生使用且无需 polyfill。Node 24 及以上使用基于 `AsyncContextFrame` 的实现,Node 22 使用此前的实现;本 RFC 为保证该不变量接受常驻传播成本,不作零开销承诺。 - -服务关闭是有顺序的,不提供透明的进行中延续。Agent Loop 必须先停止接受新驱动并取消或等待所有进行中的驱动收敛,随后 Cordis 才 dispose 服务并调用 `disable()`。HMR(热模块替换)会重建依赖该服务的子树,不承诺让进行中的轮次跨服务替换继续执行。如果旧调用方保留了已 dispose 的服务引用,`current()` 和 `require()` 都会抛出稳定的 “service disposed” 错误,而不是返回模糊的 `undefined`。 - -### 生命周期边界 - -在每个具体 Agent 驱动的 `runLoop` 整个生命周期外围绑定执行上下文: - -```text -agentExecution.run({ agent }, () => runLoop(ctx, agent, handle)) -``` - -这样,由该驱动发起的每项操作都能获得同一个可信 Agent: - -- 提示词拦截和提示词组装; -- LLM 适配器调用; -- 工具策略和工具主体; -- 能力提供方和传输层; -- 这些操作所等待的同步和异步辅助函数。 - -并发驱动会获得彼此独立的 ALS 存储。子 Agent 自己的驱动会用该子 Agent 建立新边界,因此即使子 Agent 是在父 Agent 的工具调用中创建的,其操作也不会错误继承父 Agent。嵌套边界返回后,ALS 会自动恢复父 Agent。 - -Agent 创建阶段有意置于这个动态边界之外。创建过程已经接收 `agentCtx`,其中 `agentCtx.agent` 就是正确的、尚未发布的 Agent。发布流程和生命周期归属继续使用现有的显式 Agent 与作用域载体。由此产生一条明确契约,而非偶然行为:当子 Agent 的创建发生在父 Agent 的工具调用内时,子 Agent 的创建流程和持久化加载运行在**父 Agent** 的隐式身份之下,因为子驱动尚未启动。这个窗口内触达的传输层按父会话路由——对可信路由而言这是正确的,因为创建工作由父 Agent 发起并归它所有。创建代码需要子身份时使用显式的 `agentCtx.agent`,绝不读隐式存储。 - -### 显式主体仍是真源 - -隐式身份只是深层基础设施的便利能力,不会取代现有契约: - -- `AgentEventDispatch` 继续携带显式 Agent 主体和作用域。 -- `AssembleContext.agent` 保持显式传递。 -- `ToolExecution.agent` 保持显式传递,并继续选择作用域内的工具和策略视图。 -- `GenerateOptions.sessionId` 在 LLM 边界上保持显式传递。 -- subagent 请求和生命周期事件继续携带显式的父子身份。 -- 会话事件仍然是回放和恢复的持久化真源。 - -代码跨越公开服务、进程、worker、持久化或协议边界时,必须把边界所需身份写入其类型化请求。远程进程无法访问父进程的 ALS 存储。 - -### 可信传输层用途 - -能力传输层可以在构造对外请求时读取 `ctx.agentExecution.require().agent.session.id`,并添加由部署方控制的可信身份,例如 `X-Harness-Session-Id` 请求头。该身份不出现在模型可见的参数中,模型也不能覆盖它。传输层仍须执行自身的能力和生命周期授权;隐式 Agent 只提供发起方身份,不授予调用权限。 - -bash seam 现有的 `OwnerToken` 是最接近的显式身份先例,它也说明了为什么显式方案补不上这个缺口:`BashExecSpec.owner` 是一个后台任务隔离键,由 `dsh-tool-bash` 从会话 id 转换而来,前台 `run()` 有意忽略它,而文件系统 seam 没有对应物——其提供方方法完全不携带身份参数。给每个能力 seam 都加一个路由身份参数,会把宿主平台的关注点塞进本应与部署无关的 seam 词汇;隐式身份让传输层实现自己拥有路由逻辑,而不必加宽任何 seam。 - -宿主平台继续负责把 Harness 运行时会话 ID 解析成产品会话和沙箱归属方。Harness 不需要理解宿主平台的沙箱标识、沙箱提供方或持久化模型。 - -模型侧 skill 和工具插件不应自行添加宿主平台特有的请求头。它们调用能力服务;所选提供方负责远程执行和身份传播。这样可以保持模型行为与后端路由之间的职责分离。 - -### 分离异步工作 - -Node ALS 会被 `run()` 内创建的异步资源继承,即使调用方没有等待它们。这对 Agent 所拥有的后台操作很有用,但也可能让无关任务保留陈旧轮次的上下文。 - -身份继承不取代取消归属。在 Agent 边界内启动的工作要么是**前台**的——继承 `{ agent }`,并通过其执行 seam 单独接收显式取消信号;要么是**分离**的——在 `run(undefined, operation)` 下启动,并拥有独立生命周期和显式停止操作。调用方必须让这两个选择保持一致。实现必须记录并测试以下规则: - -- 逻辑上归 Agent 所有的工作是前台工作:可以继承 `{ agent }`,通过现有显式 seam 接收取消,并且必须遵守该 Agent 的 dispose 契约。 -- 与该 Agent 无关的长生命周期部署基础设施、定时器和工作队列是分离工作:必须在 `run(undefined, operation)` 下启动,由自己的归属方停止,绝不因某个轮次结束而被隐式终止。 -- 把数据入队并留待后续处理的代码必须将所需身份序列化到队列项中;不能期待 ALS 跨越队列、进程或 worker 边界。 -- 消费方不能把隐式 Agent 引用视为 Agent 仍然存活的证明。生命周期敏感的操作仍须检查 `agent.status`、显式 signal 或归属服务的契约。 - -`turn` 和 `step` 不进入第一版;如果未来出现真实的横切消费方(追踪、日志)无法使用现有显式字段,可以再将它们作为独立的不可变执行帧扩展引入。完整 `Agent` 是刻意允许的能力例外,因为它就是建立边界的执行主体。每个额外字段都必须是陈旧安全的标签,其陈旧副本最坏只能误标一条追踪记录;其他能力或控制通道需要独立 RFC。第一版不携带 `AbortSignal`;见「考虑过的替代方案」。 - -## 当前 Harness 依据 - -由于这份交接基于本地源码快照编写,目标分支可能已经前进,后续实现会话应在编辑前重新检查这些符号。 - -- `packages/core/agent/src/types.ts`:`Agent` 已经拥有 `session`、`status` 和 `ctx`。其中 `ctx` 的文档将它定义为注册作用域,而不是动态请求上下文。 -- `packages/core/agent/src/index.ts`:Cordis `Context.agent` 作为 Agent 作用域的开发体验关联被安装,在普通上下文上默认返回 `undefined`。不要改变这一语义。 -- `packages/core/agent-loop/src/agent.ts`:`ReactLoopAgent` 已经拥有 inbox、取消逻辑、每步骤 abort、状态和驱动生命周期。不要再创建一套并行的可变运行时状态对象。 -- `packages/core/agent-loop/src/loop.ts`:`runLoop(ctx, agent, handle)` 正好是需要包裹的生命周期边界。它会将 Agent、轮次、步骤和 signal 显式传给更窄的操作。 -- `packages/core/tools/src/index.ts`:`ToolExecutionInput.agent` 是显式字段,并用于选择作用域内的策略和工具解析。增加 ALS 后,它仍然保留在契约中。 -- `packages/core/agent/src/dispatch.ts`:`agentEvents()` 有意把 Agent 主体与其作用域载体融合。隐式上下文不能取代这套正确性机制。 -- `packages/core/README.md` 和现有 core 包:它们表明稳定的 Agent 控制契约位于 `core/`;`agent-execution` 是必载控制基础设施,而不是模型可见的可选上下文增强。 - -本提案扩展而非取代[关于 Agent 注册作用域的既有决策](../../implemented/architecture/2026-07-08-agent-scope-contexts.md)及其[运行时设计](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md)。 - -## Claude Code 参考实现 - -| Claude Code | Harness 中的对应设计 | -|---|---| -| AppState store | Cordis 部署服务及其拥有的实时状态 | -| QueryEngine | `ReactLoopAgent` 及其 loop 所拥有的运行时状态 | -| ToolUseContext | 能力边界上的显式 Agent、工具和请求参数 | -| AgentContext ALS | 本提案的窄粒度 `AgentExecution` 载体 | -| Transcript | 事件溯源 `Session` 与持久化后端 | - -## 实现交接步骤 - -后续实现会话应按以下顺序开展工作: - -1. 切换到预期目标分支,检查“当前 Harness 依据”中列出文件的当前版本。不要合并或复制编写本交接文档所在分支的修改。 -2. 新增 `packages/core/agent-execution/`,包含包元数据、README、导出类型、Cordis 服务、模块扩展和聚焦测试。 -3. 按照现有包门禁,把该包加入 TypeScript 项目引用、路径候选、运行时闭包或配置以及生成目录。优先使用仓库生成器,不要手工编辑生成文件。同时更新根 `AGENTS.md` 中 repository layout 的 `core/` 行、`packages/core/README.md` 中的包表,以及 `packages/README.md` 中的包组说明。 -4. 让 Agent Loop 声明并消费该服务。在不改变公开 Agent、事件、工具、LLM 或会话签名的前提下,用 `{ agent }` 包裹每个 Agent 驱动的完整 `runLoop` 调用。 -5. 增加集成测试:让同一进程中的两个 Agent 重叠执行,并在至少一次 `await` 后从异步工具执行内部观察到正确的隐式 Agent。 -6. 增加嵌套 Agent 覆盖:证明子 Agent 能看到自己,且子边界结束后父上下文得到恢复。 -7. 增加清除和失败覆盖:边界外返回 `undefined`,`require()` 清晰失败,`run(undefined, ...)` 屏蔽继承的 Agent,抛出异常或 rejected 操作不会污染后续无关工作。 -8. 在集成测试中增加一个能力传输测试替身。保持模型侧 schema 不变,并断言可信会话请求头由内部生成。适配真实生产远程后端属于本 RFC 之外的后续工作。 -9. 运行类型检查、定向测试、文档门禁、生成目录检查,最后运行仓库常规 CI 或 pre-push 门禁。 - -建议的聚焦测试矩阵: - -| 场景 | 必须观察到的结果 | -|---|---| -| 驱动之外 | `current()` 为 `undefined` | -| 一个 Agent 跨越 await | 每个 continuation 都看到完全相同的 Agent | -| 两个并发 Agent | A 永远看不到 B,B 永远看不到 A | -| 嵌套子 Agent | 子 Agent 看到自己;随后恢复父 Agent | -| 子 Agent 创建窗口 | 父工具调用内的创建流程隐式看到父 Agent;`agentCtx.agent` 是子 Agent | -| 直接调用无 Agent 工具 | 显式工具行为仍然有效;隐式身份不存在 | -| 已清除的分离工作 | `run(undefined, ...)` 隐藏继承的 Agent | -| 失败和取消 | throw、rejection 和 abort 后上下文均得到恢复 | -| Agent dispose | 隐式引用不赋予 dispose 后的能力 | -| 服务重载 | Agent 驱动在 ALS disable 前收敛;保留的已 dispose 服务调用抛出文档约定的稳定错误 | -| 能力传输边界 | 会话身份由测试替身传输层写入类型化请求或请求头 | - -## 考虑过的替代方案 - -**让每个函数都传递 Agent。** 对公开边界和承载权限的边界而言,这仍然是正确选择;但如果要求每个私有辅助函数都传递 Agent,就会产生大量样板代码,而隐式执行上下文正适合消除这些代码。本提案在边界处保留显式主体,只在单个可信异步进程内部使用 ALS。 - -**修改 `ctx.agent`,让它返回当前正在执行的 Agent。** 拒绝此方案,因为 `ctx.agent` 已经表示 Agent 作用域 Cordis 上下文的静态关联。让根上下文变成动态语义,会把注册作用域和执行作用域混合起来,在并发时产生意外行为,并破坏已经实现的 Agent 作用域 RFC。 - -**在 ALS 中存储完整的可变运行时对象。** 拒绝此方案,因为 Agent、会话、inbox、取消状态、轮次或步骤状态、工具执行和持久化日志已经有各自的真源。重复保存会产生陈旧快照、写入顺序问题,以及另一套需要清理的生命周期。 - -**在第一版 ALS 帧中携带步骤级 `AbortSignal`。** 本 RFC 拒绝此方案。signal 的生命周期是每步骤,而提议的 ALS 边界是每驱动,因此携带它需要嵌套的步骤和工具边界,还要明确规定分离工作、deadline 归属和恢复语义。现有执行 seam 已经显式传递取消。未来只有在出现具体横切消费方,并通过测试建立这些嵌套生命周期语义后,才可由独立 RFC 重新评估。 - -**使用一个进程级可变 `currentAgent`。** 拒绝此方案,因为并发 Agent 和 subagent 会在 await 边界间相互覆盖。它只有在所有工作严格串行时才正确,而多 Agent 执行明确不保证这一点。 - -**从模型可见的工具参数推导会话。** 拒绝此方案,因为模型可以修改这些参数。沙箱路由和授权需要可信的进程内身份,而不是用户或模型输入。 - -**把宿主平台的沙箱归属标识或提供方数据放入 Harness 上下文。** 拒绝此方案,因为沙箱归属是由 Harness 外部解析的宿主产品状态。Harness 在可信传输边界上传递自己的会话身份即可。 - -## 验收标准 - -- 一个 Node Harness 进程至少能并发执行两个 Agent,异步消费方始终观察到准确的发起 Agent。 -- 在 Agent 驱动执行之外,隐式查询返回 `undefined`,且 `require()` 抛出稳定、可操作的错误。 -- 嵌套 Agent 执行结束后会恢复父上下文。 -- `agent.ctx`、`ctx.agent`、Agent 事件、提示词组装、`ToolExecution.agent`、LLM `sessionId` 和会话持久化保持现有语义。 -- Agent、会话、轮次、步骤、沙箱和授权身份都不能由模型控制。 -- 实现为无关分离任务提供显式 undefined 边界,并通过测试防止上下文泄漏,且不改变现有显式取消契约。 -- 该服务随标准 agent 组合包加载,缺少它时 `dsh-agent-loop` 在加载阶段失败;配置测试锁定这一策略。 -- dispose 或 HMR(热模块替换)会先让所有依赖的 Agent 驱动收敛,再禁用 ALS;已 dispose 服务上的保留调用会抛出文档约定的稳定错误,且已 dispose 的 Cordis 上下文不能继续访问活跃 ALS 状态。 -- 一个能力传输测试替身能证明可信会话 ID 得到传播,同时不新增模型可见的 schema 字段。 -- 包目录、依赖图、API 文档和相关架构文档得到重新生成或更新,仓库文档门禁通过。 - -## 风险 - -- 隐式上下文会从函数签名中隐藏依赖。将它限制在深层横切基础设施,并保留显式公开主体,可以控制这一成本。 -- ALS 对分离 promise 和定时器的继承可能保留语义上陈旧的身份。实现必须提供显式 undefined 边界、文档和回归测试,而不能假设清理会自然发生。 -- ALS 不会跨越 worker thread、子进程、Redis、HTTP 或持久化队列。每个此类边界都必须显式序列化所需身份。 -- 隐式存储有意携带完整的存活 Agent 能力。被捕获的引用可能比 Agent 的发布状态活得更久,因此隐式存在本身绝不授权生命周期敏感工作,消费方仍须遵循 Agent 生命周期和取消契约。 -- 强制加载给每个 agent 组合新增一个核心运行时依赖;本 RFC 接受这一成本,因为可选服务会让隐式身份依赖具体组合。支持范围内的 Node 版本仍存在可测量的传播成本,应另行基准测试。 -- 过早加入轮次、步骤、signal、cwd 或工具细节会扩大继承范围和陈旧状态风险。第一版有意接受只提供 Agent 隐式身份的限制;未来任何额外的能力或控制字段都需要独立 RFC。 diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 3f6e109e3f..1994ae9c36 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -9,6 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -44,6 +45,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRegistry, { mode: 'code' }) await harness.plugin(AgentRegistry) + await harness.plugin(AgentExecutionProvider) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index dd0bc42a1b..d8790a0443 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -5,6 +5,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -55,6 +56,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 78e5b0bb93..5286957b8a 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -4,6 +4,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -28,6 +29,7 @@ export async function cordisHarness(): Promise { await ctx.plugin(SystemPrompt, { persona: PERSONA }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(ToolCordis) diff --git a/packages/README.md b/packages/README.md index 8f1bf3dde2..55bfa5cc7b 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: session, system-prompt, tools, agent, agent-execution, 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/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 035b16aeec..b0a1eaf779 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 238176b8a6..c07e49c91d 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -6,6 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -26,6 +27,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TaskService) await ctx.plugin(ToolTasks) diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 32852a060f..55327227c9 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index dbf8a3b737..03870c7a6e 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -8,6 +8,7 @@ import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' @@ -66,6 +67,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps)) ctx.tools.register(defineTool({ diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index f319c7a5b1..ee7a60c839 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 562b002b68..2b55dd509f 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -8,6 +8,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as timeContext from '@deepseek-ai/dsh-time-context' import type { Config } from '@deepseek-ai/dsh-time-context' @@ -94,6 +95,7 @@ async function loopHarness(adapter: ScriptedAdapter, config: Config = {}): Promi await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(timeContext, config) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/cordis/tool-cordis/package.json b/packages/cordis/tool-cordis/package.json index fd9c35e48e..7c5db05b23 100644 --- a/packages/cordis/tool-cordis/package.json +++ b/packages/cordis/tool-cordis/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 51b4a5155f..eb8a25d8b4 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -52,6 +52,15 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ + { + key: 'agentExecution', + summary: 'Ambient Agent identity within one process-local asynchronous chain.', + methods: [ + 'current(): AgentExecution | undefined', + 'require(): AgentExecution', + 'run(execution: AgentExecution | undefined, operation: () => T): T', + ], + }, { key: 'agentLoop', summary: 'Concrete ReactLoopAgent factory and driver service.', @@ -505,6 +514,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Agent', declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', }, + { + name: 'AgentExecution', + declaration: 'export interface AgentExecution {\n readonly agent: Agent;\n}', + }, { name: 'AgentFactory', declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 94331df7c0..920909c26e 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -25,6 +26,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolCordis) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/README.md b/packages/core/README.md index 921591d85e..8223b7b0b0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,10 +9,11 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `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-execution/` | Process-local ambient Agent identity for asynchronous driver work | `ctx.agentExecution` | | `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + 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-execution` is mandatory control infrastructure shared by concrete loops and deep process-local consumers. `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; other plugins depend on the `agent` vocabulary and execution service, 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` + `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. +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 + agent-execution + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `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-execution/README.md b/packages/core/agent-execution/README.md new file mode 100644 index 0000000000..a389699863 --- /dev/null +++ b/packages/core/agent-execution/README.md @@ -0,0 +1,23 @@ +# dsh-agent-execution + +Process-local ambient Agent identity for asynchronous work initiated by a concrete agent driver. The default export, `AgentExecutionProvider`, installs the mandatory `ctx.agentExecution` service; [`dsh-agent-loop`](../agent-loop/README.md) establishes one boundary around each driver's complete lifetime. + +## Service: `AgentExecutionService` (ctx key: `agentExecution`) + +- `current()` returns the inherited `AgentExecution` or `undefined` outside a driver and inside an explicit clearing boundary. +- `require()` returns the inherited execution or throws `no agent execution context is active`. +- `run(execution, operation)` returns the exact synchronous value or Promise from `operation`. Passing `undefined` establishes a real boundary that hides an inherited Agent. + +The store contains only `{ readonly agent: Agent }`. A Session is available through `agent.session`; turn, step, signal, cwd, sandbox, authorization, and other capability state remain with their explicit owners. Ambient presence identifies the initiator but does not prove that the Agent is live or that an operation is authorized. + +## Lifetime and detached work + +Provider teardown rejects new `run()` boundaries, removes the service so injected dependents drain, waits for returned Promise boundaries, then disables its `AsyncLocalStorage`. In-flight code retaining the service can call `current()` and `require()` while it drains; after disposal, all three methods throw `agent execution service is disposed`. + +Async resources created inside `run()` inherit its Agent even when the operation does not await them. Agent-owned foreground work may inherit the boundary but keeps using the explicit cancellation and disposal contract of its execution seam. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop. Queue, worker, process, and wire boundaries serialize any identity they need instead of relying on ALS propagation. + +## Known Limitations and Deferred Work + +- **Process-local only** — ALS does not cross workers, child processes, HTTP, durable queues, or restarts; each boundary materializes a typed identity explicitly. +- **Agent identity only** — turn, step, signal, cwd, sandbox, and authorization stay outside the frame until a concrete cross-cutting consumer justifies a separate design. +- **Ambient references may outlive liveness** — consumers still check `agent.status`, their explicit signal, and the owning capability contract before lifecycle-sensitive work. diff --git a/packages/core/agent-execution/package.json b/packages/core/agent-execution/package.json new file mode 100644 index 0000000000..e5ebbbf5f3 --- /dev/null +++ b/packages/core/agent-execution/package.json @@ -0,0 +1,31 @@ +{ + "name": "@deepseek-ai/dsh-agent-execution", + "description": "Agent-scoped asynchronous execution context for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/core/agent-execution/src/index.ts b/packages/core/agent-execution/src/index.ts new file mode 100644 index 0000000000..3aef4c1856 --- /dev/null +++ b/packages/core/agent-execution/src/index.ts @@ -0,0 +1,139 @@ +/** + * Process-local Agent execution context backed by Node AsyncLocalStorage. + * + * @module @deepseek-ai/dsh-agent-execution + */ + +import type { Context } from 'cordis' +import { AsyncLocalStorage } from 'node:async_hooks' +import type { AgentExecution } from './types.ts' + +export type { AgentExecution } from './types.ts' + +const NO_ACTIVE_EXECUTION = 'no agent execution context is active' +const DISPOSED_SERVICE = 'agent execution service is disposed' + +/** Ambient Agent identity within one process-local asynchronous chain. */ +export interface AgentExecutionService { + /** + * Read the active execution without requiring one. + * @returns the inherited execution, or `undefined` outside/inside a cleared boundary. + * @throws when this service instance has been disposed. + */ + current(): AgentExecution | undefined + + /** + * Read the active execution and fail when no boundary is active. + * @returns the inherited execution. + * @throws when no execution is active or this service instance has been disposed. + */ + require(): AgentExecution + + /** + * Run an operation inside an execution boundary. Passing `undefined` clears + * an inherited execution; the exact synchronous value or Promise is returned. + * @param execution - execution to inherit, or `undefined` for a clearing boundary. + * @param operation - synchronous or asynchronous operation to invoke. + * @returns the exact value returned by `operation`. + * @throws when this service is closing/disposed, or when `operation` throws. + */ + run(execution: AgentExecution | undefined, operation: () => T): T +} + +declare module 'cordis' { + interface Context { + agentExecution: AgentExecutionService + } +} + +/** One provider-owned ALS instance with quiescent shutdown. */ +class DefaultAgentExecutionService implements AgentExecutionService { + private readonly storage = new AsyncLocalStorage() + private state: 'active' | 'closing' | 'disposed' = 'active' + private activeRuns = 0 + private drainWaiter: PromiseWithResolvers | undefined + private disposalTask: Promise | undefined + + current(): AgentExecution | undefined { + this.assertReadable() + return this.storage.getStore() + } + + require(): AgentExecution { + const execution = this.current() + if (execution === undefined) throw new Error(NO_ACTIVE_EXECUTION) + return execution + } + + run(execution: AgentExecution | undefined, operation: () => T): T { + if (this.state !== 'active') throw new Error(DISPOSED_SERVICE) + this.activeRuns += 1 + let result: T + try { + result = this.storage.run(execution, operation) + } catch (error: unknown) { + this.releaseRun() + throw error + } + if (result instanceof Promise) { + void result.then( + () => { this.releaseRun() }, + () => { this.releaseRun() }, + ) + } else { + this.releaseRun() + } + return result + } + + /** Reject new boundaries while existing continuations remain readable. */ + close(): void { + if (this.state === 'active') this.state = 'closing' + } + + /** Wait for every returned Promise boundary, then invalidate retained references. */ + dispose(): Promise { + return (this.disposalTask ??= (async () => { + this.close() + if (this.activeRuns !== 0) { + this.drainWaiter ??= Promise.withResolvers() + await this.drainWaiter.promise + } + this.state = 'disposed' + this.storage.disable() + })()) + } + + private assertReadable(): void { + if (this.state === 'disposed') throw new Error(DISPOSED_SERVICE) + } + + private releaseRun(): void { + this.activeRuns -= 1 + if (this.activeRuns !== 0) return + this.drainWaiter?.resolve() + this.drainWaiter = undefined + } +} + +/** Cordis provider for the mandatory `ctx.agentExecution` service. */ +export class AgentExecutionProvider { + private readonly service = new DefaultAgentExecutionService() + + /** + * Install one isolated execution service and its ordered lifecycle. + * @param ctx - provider-owning Cordis context. + */ + constructor(ctx: Context) { + const service = this.service + ctx.effect(function* () { + // First yielded, disposed last: invalidate ALS only after dependents and active runs drain. + yield () => service.dispose() + yield ctx.provide('agentExecution', service) + // Last yielded, disposed first: prevent a teardown race from opening another boundary. + yield () => { service.close() } + }, 'agentExecution.lifecycle()') + } +} + +export default AgentExecutionProvider diff --git a/packages/core/agent-execution/src/types.ts b/packages/core/agent-execution/src/types.ts new file mode 100644 index 0000000000..ccafb4840a --- /dev/null +++ b/packages/core/agent-execution/src/types.ts @@ -0,0 +1,12 @@ +/** + * Public Agent execution-context types. + * + * @module @deepseek-ai/dsh-agent-execution/types + */ + +import type { Agent } from '@deepseek-ai/dsh-agent' + +/** The exact live Agent associated with one asynchronous execution chain. */ +export interface AgentExecution { + readonly agent: Agent +} diff --git a/packages/core/agent-execution/tests/agent-execution.spec.ts b/packages/core/agent-execution/tests/agent-execution.spec.ts new file mode 100644 index 0000000000..ccf17f6722 --- /dev/null +++ b/packages/core/agent-execution/tests/agent-execution.spec.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' +import type { AgentExecution, AgentExecutionService } from '@deepseek-ai/dsh-agent-execution' + +function execution(id: string): AgentExecution { + return { agent: { id: AgentId(id) } as Agent } +} + +async function harness(): Promise<{ + ctx: Context + service: AgentExecutionService + dispose: () => Promise +}> { + const ctx = new Context() + const fiber = await ctx.plugin(AgentExecutionProvider) + return { + ctx, + service: ctx.agentExecution, + dispose: fiber.dispose, + } +} + +describe('AgentExecutionProvider', () => { + it('reports an absent boundary and requires an active execution', async () => { + const { service, dispose } = await harness() + expect(service.current()).toBeUndefined() + expect(() => service.require()).toThrow('no agent execution context is active') + await dispose() + }) + + it('preserves exact synchronous and Promise return identities across await', async () => { + const { service, dispose } = await harness() + const active = execution('identity') + const value = { result: true } + expect(service.run(active, () => { + expect(service.require()).toBe(active) + return value + })).toBe(value) + + const promise = service.run(active, async () => { + expect(service.require()).toBe(active) + await Promise.resolve() + expect(service.require()).toBe(active) + return value + }) + expect(service.run(active, () => promise)).toBe(promise) + await expect(promise).resolves.toBe(value) + expect(service.current()).toBeUndefined() + await dispose() + }) + + it('isolates overlapping executions', async () => { + const { service, dispose } = await harness() + const a = execution('a') + const b = execution('b') + const bothStarted = Promise.withResolvers() + const release = Promise.withResolvers() + let starts = 0 + const run = (active: AgentExecution): Promise => service.run(active, async () => { + expect(service.require()).toBe(active) + starts += 1 + if (starts === 2) bothStarted.resolve(true) + await release.promise + expect(service.require()).toBe(active) + }) + + const pending = [run(a), run(b)] + await bothStarted.promise + expect(service.current()).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 = execution('parent') + const child = execution('child') + + service.run(parent, () => { + expect(service.require()).toBe(parent) + service.run(child, () => { expect(service.require()).toBe(child) }) + expect(service.require()).toBe(parent) + service.run(undefined, () => { + expect(service.current()).toBeUndefined() + expect(() => service.require()).toThrow('no agent execution context is active') + }) + expect(service.require()).toBe(parent) + }) + expect(service.current()).toBeUndefined() + await dispose() + }) + + it('restores context after synchronous throws and rejected operations', async () => { + const { service, dispose } = await harness() + const parent = execution('parent') + const child = execution('child') + const syncError = new Error('sync failure') + const asyncError = new Error('async failure') + + service.run(parent, () => { + expect(() => service.run(child, () => { throw syncError })).toThrow(syncError) + expect(service.require()).toBe(parent) + }) + await expect(service.run(child, async () => { + await Promise.resolve() + throw asyncError + })).rejects.toBe(asyncError) + expect(service.current()).toBeUndefined() + await dispose() + }) + + it('stops new boundaries, drains active Promises, and invalidates retained references', async () => { + const { ctx, service, dispose } = await harness() + const active = execution('draining') + const release = Promise.withResolvers() + const pending = service.run(active, async () => { + await release.promise + expect(service.require()).toBe(active) + }) + let disposed = false + const disposal = dispose().then(() => { disposed = true }) + await Promise.resolve() + + expect(() => service.run(active, () => 1)).toThrow('agent execution service is disposed') + expect(disposed).toBe(false) + expect(ctx.get('agentExecution')).toBeUndefined() + release.resolve(true) + await pending + await disposal + expect(() => service.current()).toThrow('agent execution service is disposed') + expect(() => service.require()).toThrow('agent execution service is disposed') + }) +}) diff --git a/packages/core/agent-execution/tsconfig.json b/packages/core/agent-execution/tsconfig.json new file mode 100644 index 0000000000..a06784e926 --- /dev/null +++ b/packages/core/agent-execution/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + } + ] +} diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a07aa5fa70..c961969edc 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -23,7 +23,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ### Injected services -`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services. +`agents`, `agentExecution`, `sessions`, `llm`, `tools`, `systemPrompt` — all six interface services. The loop cannot activate without `agentExecution`; the default bundle loads its provider before the loop. ### Configuration (schemastery) @@ -48,7 +48,9 @@ Configured agents start automatically. `cwd` applies only to fresh sessions; `re ### Loop lifecycle (`loop.ts`) -The driver owns one agent for its lifetime. It records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. +The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the child boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [execution-context package](../agent-execution/README.md) owns propagation and detached-work rules. + +The loop records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. Plugin failure ends the current turn, not the loop. Cancellation clears pending work and aborts the current step without leaking to the next prompt. Terminal continuation stops remain authoritative through turn close and durability flush. diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 7e2fb235a2..6a7fe2d937 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -22,6 +22,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-execution": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -35,6 +36,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index a2288c65b7..7194d6c7e8 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -327,7 +327,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.agentExecution.run({ agent: this }, () => runLoop(this.loopCtx, this, { inbox: this.#inbox, setStatus: (status) => { this.setStatus(status) }, setAbort: controller => void (this.currentAbort = controller), @@ -338,7 +338,7 @@ export class ReactLoopAgent implements Agent { clearCancel: () => { this.cancelRequested = false }, // Pre-step cancellation re-parks without emitting a status transition. settleIdle: () => { this.settleIdleWaiters() }, - }) + })) } /** diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 6cd66f622c..51dc0f311f 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -11,6 +11,7 @@ import z from 'schemastery' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent-execution' import type { AgentFactory, AgentHandle, @@ -333,7 +334,7 @@ export interface Config { /** Concrete ReactLoopAgent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { - static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] + static inject = ['agents', 'agentExecution', 'sessions', 'llm', 'tools', 'systemPrompt'] /** Runtime schema for declarative agents. */ static Config = z.object({ diff --git a/packages/core/agent-loop/tests/agent-execution.spec.ts b/packages/core/agent-loop/tests/agent-execution.spec.ts new file mode 100644 index 0000000000..98cebcbc97 --- /dev/null +++ b/packages/core/agent-loop/tests/agent-execution.spec.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from 'vitest' +import { Context, FiberState, type Fiber } from 'cordis' +import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' +import type { AgentExecutionService } from '@deepseek-ai/dsh-agent-execution' +import AgentLoop, { type ReactLoopAgent } 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 + providerFiber: 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) + await ctx.plugin(AgentRegistry) + const providerFiber = await ctx.plugin(AgentExecutionProvider) + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return { ctx, providerFiber, loopFiber } +} + +function waitForIdle(ctx: Context, agent: ReactLoopAgent | 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.agentExecution.require().agent + this.starts += 1 + if (this.starts === 2) this.bothStarted.resolve(true) + await this.bothStarted.promise + await Promise.resolve() + const after = this.ctx.agentExecution.require().agent + 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 execution: AgentExecutionService) {} + + async request(path: string): Promise> { + await Promise.resolve() + const headers = { + 'X-Harness-Session-Id': this.execution.require().agent.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 + execution: AgentExecutionService | undefined + + async * stream(options: GenerateOptions): AsyncIterable { + const execution = this.execution + if (execution === undefined) throw new Error('execution 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 = execution.require().agent + throw error + } + return + } + await Promise.resolve() + this.laterAgent = execution.require().agent + yield* textResponse('reloaded') + } +} + +describe('AgentLoop execution context', () => { + 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(AgentExecutionProvider) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + + const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const b = ctx.agentLoop.create(AgentId('b'), { 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.agentExecution.current()).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('keeps child setup under the parent boundary, switches for the child driver, then restores the parent', 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 parentAfterChild: 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({ + agentId: AgentId('child'), + sessionId: SessionId('child-session'), + agentOptions: { model: 'mock' }, + setup: (agentCtx) => { + parentDuringSetup = ctx.agentExecution.require().agent + explicitChild = agentCtx.agent + agentCtx.tools.register(defineTool({ + name: 'observe-child', + description: 'observe child execution identity', + parameters: {}, + execute: async () => { + await Promise.resolve() + childDuringDriver = ctx.agentExecution.require().agent + return [{ type: 'text', text: 'observed' }] + }, + })) + }, + }) + child = handle.agent + send(handle.agent, 'run child') + await handle.agent.whenIdle() + parentAfterChild = ctx.agentExecution.require().agent + await handle.dispose() + return [{ type: 'text', text: 'child completed' }] + }, + })) + + const parentHandle = await ctx.agents.create({ + agentId: AgentId('parent'), + sessionId: SessionId('parent-session'), + agentOptions: { 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(parentAfterChild).toBe(parentHandle.agent) + expect(ctx.agentExecution.current()).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.agentExecution) + 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.agentExecution.current()?.agent + 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.agentExecution.require().agent + 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({ + agentId: AgentId('transport'), + sessionId: SessionId('transport-session'), + agentOptions: { 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.agentExecution.current()).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('keeps AgentLoop inactive until the mandatory provider appears', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const loopFiber = ctx.plugin(AgentLoop, { agents: [] }) + await Promise.resolve() + expect(loopFiber.state).toBe(FiberState.PENDING) + + await ctx.plugin(AgentExecutionProvider) + await loopFiber + expect(loopFiber.state).toBe(FiberState.ACTIVE) + await ctx.fiber.dispose() + }) + + it('drains the old driver before disabling ALS during provider restart', async () => { + const ctx = new Context() + const adapter = new ReloadAdapter() + const { providerFiber, loopFiber } = await (async (): Promise => { + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const mountedProvider = await ctx.plugin(AgentExecutionProvider) + const mountedLoop = await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + return { ctx, providerFiber: mountedProvider, loopFiber: mountedLoop } + })() + const oldService = ctx.agentExecution + adapter.execution = oldService + const oldHandle = await ctx.agents.create({ + agentId: AgentId('before-restart'), + sessionId: SessionId('before-restart-session'), + agentOptions: { model: 'mock' }, + }) + const oldAgent = oldHandle.agent + send(oldAgent, 'block') + await adapter.firstStarted.promise + + await providerFiber.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.current()).toThrow('agent execution service is disposed') + expect(ctx.agentExecution).not.toBe(oldService) + adapter.execution = ctx.agentExecution + + const newHandle = await ctx.agents.create({ + agentId: AgentId('after-restart'), + sessionId: SessionId('after-restart-session'), + agentOptions: { 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(AgentExecutionProvider) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + const service = ctx.agentExecution + adapter.execution = service + const handle = await ctx.agents.create({ + agentId: AgentId('root-dispose'), + sessionId: SessionId('root-dispose-session'), + agentOptions: { 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.current()).toThrow('agent execution service is disposed') + }) +}) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 581b6fa207..bcc31a6172 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -6,6 +6,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -17,6 +18,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -51,6 +53,7 @@ function send(agent: ReactLoopAgent, text: string) { describe('ReactLoopAgent', () => { it('rejects access before context binding and a second driver for one session', async () => { const ctx = new Context() + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session) @@ -252,6 +255,7 @@ describe('ReactLoopAgent', () => { it('disposer is idempotent (double-stop)', async () => { // The internal start seam exposes one idle driver's disposer for repeated invocation. const ctx = new Context() + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) @@ -361,6 +365,7 @@ describe('ReactLoopAgent', () => { // Queue the internal waiter while running, then dispose the bare driver. Its disposed branch // must chain the loop's `done` promise rather than resolve before exit. const ctx = new Context() + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index cad830e827..7568ba0765 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -14,6 +14,7 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -24,6 +25,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -194,6 +196,7 @@ describe('Agent.cancel()', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -319,6 +322,7 @@ describe('Agent.cancel()', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 8a5d9ce264..182a76a7e9 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -9,6 +9,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -31,6 +32,7 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }], }) @@ -53,6 +55,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) + await ctx1.plugin(AgentExecutionProvider) await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) @@ -70,6 +73,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) @@ -93,6 +97,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) + await ctx1.plugin(AgentExecutionProvider) await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) @@ -109,6 +114,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -137,6 +143,7 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ee50261e9d..70528a7b0b 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -19,6 +20,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -524,6 +526,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) @@ -664,6 +667,7 @@ describe('turn and step boundary recovery', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1114,6 +1118,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1165,6 +1170,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1220,6 +1226,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1271,6 +1278,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1320,6 +1328,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 5deee8e159..8c0884d0fa 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -6,6 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -16,6 +17,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 4b37210993..0c8f52c6c4 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -10,6 +10,7 @@ import AgentRegistry, { type PromptDecision, type SessionStartSource, } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -29,6 +30,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index fb686928b1..79f9755220 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -15,6 +16,7 @@ async function harness(adapter: MockAdapter, persona = '') { await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -924,6 +926,7 @@ describe('agent loop', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('config-agent'), model: 'mock' }], }) @@ -947,6 +950,7 @@ describe('agent loop', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('config-agent'), model: 'mock', cwd: '/work/project' }], }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index dbc43ad985..d100000d14 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -13,6 +13,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' @@ -36,6 +37,7 @@ async function harness() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], new EchoAdapter()) return ctx diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 9b8513d16f..3b454236f1 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -42,6 +43,7 @@ async function loopHarness(): Promise { await created.plugin(SystemPrompt, { persona: SYSTEM }) await created.plugin(ToolRegistry) await created.plugin(AgentRegistry) + await created.plugin(AgentExecutionProvider) await created.plugin(AgentLoop, { agents: [] }) await created.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) created.tools.register(defineTool({ diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index c4fe471bd0..58c1017e81 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -14,6 +14,7 @@ import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-a import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -24,6 +25,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 3747824231..4b90453909 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -10,6 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -29,6 +30,7 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], adapter) @@ -138,6 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -166,6 +169,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -388,6 +392,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) @@ -449,6 +454,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -502,6 +508,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -531,6 +538,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) + await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -561,6 +569,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') })) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 40272ac42b..53e91defff 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -7,6 +7,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -18,6 +19,7 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return { ctx, loopFiber } diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 13e731b7b8..813f7892d9 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -14,6 +14,7 @@ import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -24,6 +25,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index c275fee88c..944c6fd265 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -17,6 +18,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index 5d7cf98bb7..1e17efa41f 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -35,6 +35,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/agent-execution" + }, { "path": "../../core/scope" } diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index 856ef899f7..53847b0e97 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -160,6 +160,26 @@ export class FixService { expect(services[0]?.methods).toHaveLength(3) }) + it('extracts an interface service as an abstract seam', () => { + const services = collectServices(makeService(`/** Fixture service interface. */ +export interface FixService { + /** + * Do the thing. + * @param id - which thing to do. + * @returns the outcome of doing it. + */ + run(id: string): string +}`)) + expect(services).toHaveLength(1) + expect(services[0]).toMatchObject({ + key: 'fix', + type: 'FixService', + abstract: true, + doc: 'Fixture service interface.', + }) + expect(services[0]?.methods).toEqual(['run(id: string): string']) + }) + it('hard-errors on a public method with no JSDoc at all', () => { expect(() => collectServices(makeService( '/** Fixture service. */\nexport class FixService {\n run(id: string): string { return id }\n}', diff --git a/packages/examples/README.md b/packages/examples/README.md index 5ee0206d0f..71867e0da6 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -4,7 +4,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) | +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + agent-execution + invariants + `tool-bash` + `tool-skill` + `agent-loop`) | | `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index eb56f9515c..442b8e1366 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -17,6 +17,7 @@ Read this package for the whole plugin tree and its composition order. @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-execution process-local ambient Agent execution context @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/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index d328043507..84e4259741 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + agent-execution + tasks + invariants + tool-bash + tool-skill + tool-tasks + agent-loop)", "version": "0.0.1", "private": true, "type": "module", @@ -24,6 +24,7 @@ "peerDependencies": { "@cordisjs/plugin-timer": "^1.1.2", "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-execution": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -41,6 +42,7 @@ "devDependencies": { "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index b3d6ca22bc..439897b347 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -18,6 +18,7 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import TaskService from '@deepseek-ai/dsh-tasks' import * as invariants from '@deepseek-ai/dsh-invariants' import * as toolBash from '@deepseek-ai/dsh-tool-bash' @@ -116,6 +117,7 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SkillService, config.skills?.registry ?? {}) ctx.plugin(SkillLocal, config.skills?.local ?? {}) ctx.plugin(AgentRegistry) + ctx.plugin(AgentExecutionProvider) ctx.plugin(TaskService) ctx.plugin(invariants) ctx.plugin(toolBash, config.toolBash ?? {}) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index faea4b949f..09cad875f1 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -41,6 +41,9 @@ { "path": "../../core/agent" }, + { + "path": "../../core/agent-execution" + }, { "path": "../../core/agent-loop" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index c21c806e98..98439e8163 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 0487962922..4dc4bb8437 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -4,6 +4,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' @@ -22,6 +23,7 @@ export async function fsHarness(fsCwd: string, persona = ''): Promise { await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalFileSystem, { cwd: fsCwd }) diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 0cc99b6976..8df2eac25f 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 565f1076b5..355c5ed2a8 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' @@ -26,6 +27,7 @@ async function harness(config: Config = {}): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(RepeatToolGuard, config) ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) @@ -368,6 +370,7 @@ describe('config validation fails loud', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) return ctx } diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 21f08965d8..03858f1aaf 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 506d613c73..c298a8d3f3 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -9,6 +9,7 @@ import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -47,6 +48,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const hooks = await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') }) @@ -342,6 +344,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) @@ -364,6 +367,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index f376708688..db61f4846f 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -8,6 +8,7 @@ import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -35,6 +36,7 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath, ...opts }) @@ -334,6 +336,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) // Direct apply with only configPath — bypasses schemastery's defaults, so @@ -599,6 +602,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the session cwd). await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) @@ -632,6 +636,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) // Executor default cwd = serverDir (deliberately NOT the child session cwd). await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index fe667b0302..1928b763ea 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -34,6 +34,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index d4a5797b96..fcf0617bff 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -9,6 +9,7 @@ import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -45,6 +46,7 @@ async function harness(dir: string, adapter: MockAdapter): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' }) @@ -146,6 +148,7 @@ describe('hooks-codex bridge', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) @@ -172,6 +175,7 @@ describe('hooks-codex bridge', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index c287d86b23..1dec4a4886 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -8,6 +8,7 @@ import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -26,6 +27,7 @@ function hooks(d: string, h: unknown): string { async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise { const ctx = new Context() await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) @@ -239,6 +241,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = new Context() await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never @@ -544,6 +547,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = new Context() await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir }) await ctx.plugin(HooksCodex, { configPath: join(serverDir, 'hooks.json'), model: 'm' }) diff --git a/packages/sdk/helper/src/features/builtin/spine.ts b/packages/sdk/helper/src/features/builtin/spine.ts index caf066865e..d2fb877ec4 100644 --- a/packages/sdk/helper/src/features/builtin/spine.ts +++ b/packages/sdk/helper/src/features/builtin/spine.ts @@ -36,6 +36,10 @@ class SpineOption extends FeatureOption { }, ['persona'], config => requiredString(config, 'persona')), ...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []), ...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }), + ...npmCordisConfigEntry(ID, { + id: 'agent-execution', + name: '@deepseek-ai/dsh-agent-execution', + }), ...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }), ...npmCordisConfigEntry(ID, { id: 'agent-loop', diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 8794884518..3cfecd435c 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 8060a77fd4..b4066ff932 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -32,6 +33,7 @@ async function setup(script: Script) { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(Spawn, { providerName: 'spawn' }) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index b5e129e54f..7871f5d13d 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -6,6 +6,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -39,6 +40,7 @@ async function setup(script: Script) { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(fork, { providerName: 'fork' }) diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index aa80dcac3e..6676a3992c 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -32,6 +32,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index a2c55995c2..e8d19fe4c5 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -6,6 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -56,6 +57,7 @@ async function setup(script: Script, options: SetupOptions = {}) { } await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const disposeProvider = ctx.subagents.registerProvider({ diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 0005246523..cecad34bfc 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -21,6 +22,7 @@ async function setup(script: Script) { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index f2500a4a56..c4629e72c2 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -31,6 +31,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index e4ee2e6ab7..4d47900663 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -4,6 +4,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -30,6 +31,7 @@ export async function spawnHarness(workdir: string): Promise { await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent. Report only when the requested work is done.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 43ec8ee1ac..df93357dbc 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -7,6 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -32,6 +33,7 @@ async function setup(script: Script) { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -309,6 +311,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) @@ -341,6 +344,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 9ff69d7c76..dd1fbdbcc6 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 739367a699..52ebc57683 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -6,6 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -23,6 +24,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ToolTodo) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 71efc51a07..173c68a66c 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 440120943f..be019b1e92 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -11,6 +11,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -193,6 +194,7 @@ export async function makeBridgeHarness(options: { await ctx.plugin(SystemPrompt, { persona: options.persona ?? '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir }) await ctx.plugin(UserInteractionService) diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index 485e252c97..4c07c8f118 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 0e1727f877..5897342535 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -32,6 +33,7 @@ async function setup(script: Script) { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) + await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 1d6f61432a..f502798194 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -5,6 +5,7 @@ import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -34,6 +35,7 @@ async function harness(): Promise { await built.plugin(SystemPrompt) await built.plugin(ToolRegistry) await built.plugin(AgentRegistry) + await built.plugin(AgentExecutionProvider) await built.plugin(AgentLoop, { agents: [] }) await built.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) await built.plugin(SubagentService) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a3a78030..37b9a70edc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -225,6 +228,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -259,6 +265,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -293,6 +302,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -336,6 +348,15 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/core/agent-execution: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../agent + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/core/agent-loop: dependencies: schemastery: @@ -345,6 +366,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../agent-execution '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -492,6 +516,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -645,6 +672,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -685,6 +715,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -725,6 +758,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -765,6 +801,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -819,7 +858,7 @@ importers: dependencies: '@earendil-works/pi-ai': specifier: ^0.79.1 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + version: 0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) schemastery: specifier: ^3.18.0 version: 3.18.0 @@ -1149,6 +1188,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1185,6 +1227,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1222,6 +1267,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1461,6 +1509,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1495,6 +1546,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1891,6 +1945,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1945,6 +2002,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-execution': + specifier: workspace:^ + version: link:../../packages/core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../packages/core/agent-loop @@ -3059,6 +3119,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -6137,11 +6201,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.79.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 - '@google/genai': 1.52.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@mistralai/mistralai': 2.2.1 '@smithy/node-http-handler': 4.7.3 http-proxy-agent: 7.0.2 @@ -6299,12 +6363,14 @@ snapshots: '@exodus/bytes@1.15.1': {} - '@google/genai@1.52.0': + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': dependencies: google-auth-library: 10.7.0 p-retry: 4.6.2 protobufjs: 7.6.4 ws: 8.21.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) transitivePeerDependencies: - bufferutil - supports-color @@ -6563,6 +6629,9 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.20.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -8034,6 +8103,8 @@ snapshots: jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jiti@2.7.0: {} diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 7fba79083a..ab651d0dfd 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -10,6 +10,7 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index be265b1014..fcee663b13 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -26,6 +26,8 @@ const FENCE = 'ts cordis-catalog' // TODO(catalog-type-links): verify or generate link-map coverage. export const LINK_MAP: Record = { Agent: 'core.md', + AgentExecution: 'core.md', + AgentExecutionService: 'core.md', ContentBlock: 'core.md', Message: 'core.md', MessageSource: 'core.md', @@ -84,13 +86,13 @@ interface ServiceEntry { key: string /** The service class/interface name, e.g. `LlmService`. */ type: string - /** Whether the service class is abstract (a seam interface). */ + /** Whether the service declaration is abstract (a seam interface). */ abstract: boolean - /** Class-level JSDoc prose, one line per paragraph. */ + /** Declaration-level JSDoc prose, one line per paragraph. */ doc: string /** Public method signatures (bodies stripped), in source order. */ methods: string[] - /** Source pointer of the class declaration. */ + /** Source pointer of the service declaration. */ source: string } @@ -172,8 +174,8 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { return entries } -/** Walk every harness `interface Context` block + its service class, hard- - * erroring (aggregated) on any JSDoc-completeness violation: a class or public +/** Walk every harness `interface Context` block + its service declaration, + * hard-erroring (aggregated) on any JSDoc-completeness violation: a service or public * method without JSDoc prose, an undocumented parameter, a stale `@param`, a * missing `@returns` on a non-void method, or an inferred (unannotated) return * type the pure-AST walk cannot classify. @@ -199,18 +201,23 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { } } if (keyToType.size === 0) continue - // Find each service class declared in the same file and emit an entry. + // Find each service declaration in the same file and emit an entry. for (const [key, type] of keyToType) { - const cls = sf.statements.find( - (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type, + const declaration = sf.statements.find( + (s): s is ts.ClassDeclaration | ts.InterfaceDeclaration => + (ts.isClassDeclaration(s) || ts.isInterfaceDeclaration(s)) && s.name?.text === type, ) - if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here - const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false - const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc - if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`) + if (!declaration) continue // a Pick-mixin member (e.g. timer helpers), not a declaration here + const abstract = ts.isInterfaceDeclaration(declaration) + || (declaration.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false) + const declarationDoc = parseJsDoc(rawJsDoc(text, declaration)).doc + if (!declarationDoc) { + const kind = ts.isInterfaceDeclaration(declaration) ? 'interface' : 'class' + violations.push(`service ctx.${key} (${pointer(rel, sf, declaration)}): ${kind} ${type} has no JSDoc.`) + } const methods: string[] = [] - for (const member of cls.members) { - if (!ts.isMethodDeclaration(member)) continue + for (const member of declaration.members) { + if (!ts.isMethodDeclaration(member) && !ts.isMethodSignature(member)) continue // Only instance methods callable through `ctx.` are surface; // private, protected, and static methods are not. const nonPublic = member.modifiers?.some(m => @@ -238,9 +245,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { key, type, abstract, - doc: clsDoc, + doc: declarationDoc, methods, - source: pointer(rel, sf, cls), + source: pointer(rel, sf, declaration), }) } } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 31df485e1d..ad131c93ba 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -152,6 +152,14 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'], note: 'Owns live Agent handles and the create/resume factory seam.', }, + { + key: 'agentExecution', + pkg: 'agent-execution', + title: 'Agent execution context', + mode: 'core', + consumers: ['agent-loop'], + note: 'Carries the exact initiating Agent across one process-local asynchronous driver chain; explicit identities remain authoritative at external boundaries.', + }, { key: 'agentLoop', pkg: 'agent-loop', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c3e4533ba5..221372aa5c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -11,6 +11,8 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "LlmCallConfig", "source": "packages/llm/llm/src/call-config.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecution", "source": "packages/core/agent-execution/src/types.ts" }, + { "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecutionService", "source": "packages/core/agent-execution/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 8220324210..6a35a3caf3 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -28,6 +28,7 @@ interface SentenceContract { * so an absent section cannot be mistaken for forgotten documentation. */ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { + 'packages/core/agent-execution': 'The package is model-agnostic ambient control infrastructure; model-facing consumers own any resulting request surface.', 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', } diff --git a/tsconfig.build.json b/tsconfig.build.json index 3a57169005..ce7403242c 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -21,6 +21,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/core/agent-execution" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 6585a987d6..34e9028918 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, + { "path": "./packages/core/agent-execution" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, From 5a5591205f0ff9fbf5958e58f1f5c35469db7c93 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 16 Jul 2026 16:58:34 +0800 Subject: [PATCH 03/16] docs(rfc): acknowledge experimental ALS teardown API --- .../architecture/2026-07-15-agent-execution-context.i18n.yaml | 4 ++-- .../architecture/2026-07-15-agent-execution-context.md | 2 ++ .../architecture/2026-07-15-agent-execution-context.zh.md | 2 ++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml index 24dfc87f70..9e77718e75 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-agent-execution-context.md: 9f41aee74dbd94fa5acf93bface57618c604ec17 -2026-07-15-agent-execution-context.zh.md: 4747a506b4fb8a0ff798043ec772a4e810f84d7f +2026-07-15-agent-execution-context.md: bbc33bb6a27c0222ec7ef582a5e5c20cdd566cd8 +2026-07-15-agent-execution-context.zh.md: 64bebc6e70519426bd88e2ea9bb9d40d64f1c58b diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md index 9f41aee74d..bbc33bb6a2 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md @@ -68,4 +68,6 @@ Deep infrastructure gains one trusted process-local initiating Agent without wid The dependency is implicit in function signatures and carries a live capability object. Consumers must restrict it to cross-cutting infrastructure, treat ambient presence as neither liveness nor authorization, and retain explicit cancellation and ownership checks. ALS also has an always-on propagation cost and does not cross worker, process, HTTP, or durable queue boundaries. +The 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 provider-owned instances; the service state guard prevents a later `run()` from re-entering the instance after disposal. + The frame deliberately omits turn, step, signal, cwd, sandbox, and authorization. A real consumer that cannot use existing explicit fields must justify any refinement separately; a stale copied field may at most mislabel telemetry, never grant control. diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md index 4747a506b4..64bebc6e70 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md @@ -68,4 +68,6 @@ export interface AgentExecutionService { 该依赖不会出现在函数签名中,并且携带一个存活能力对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。 +该 teardown 设计有意接受 Node [Stability 1(实验性)](https://nodejs.org/api/async_context.html#asynclocalstoragedisable) 的 `AsyncLocalStorage.disable()` 依赖。Node 要求在 ALS 实例可被垃圾回收前调用 `disable()`,这对 HMR 替换提供方所拥有的实例尤为重要;服务状态守卫会阻止 dispose 后通过后续 `run()` 重新进入该实例。 + 该帧有意省略轮次、步骤、signal、cwd、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。 From b1e19d8b697865d20db02facd990052e9d6347d0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 16 Jul 2026 18:07:23 +0800 Subject: [PATCH 04/16] fix(core): drain cross-realm execution promises --- docs/cordis-catalog/services.md | 2 +- packages/core/agent-execution/src/index.ts | 3 ++- .../tests/agent-execution.spec.ts | 26 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index dc4976238f..ac002b3e4b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -21,7 +21,7 @@ run(execution: AgentExecution | undefined, operation: () => T): T Types: [AgentExecution](../core-data-structures/core.md) -Source: [`packages/core/agent-execution/src/index.ts:17`](../../packages/core/agent-execution/src/index.ts) +Source: [`packages/core/agent-execution/src/index.ts:18`](../../packages/core/agent-execution/src/index.ts) ## `ctx.agentLoop` — `AgentLoop` diff --git a/packages/core/agent-execution/src/index.ts b/packages/core/agent-execution/src/index.ts index 3aef4c1856..cb2683dbaa 100644 --- a/packages/core/agent-execution/src/index.ts +++ b/packages/core/agent-execution/src/index.ts @@ -6,6 +6,7 @@ import type { Context } from 'cordis' import { AsyncLocalStorage } from 'node:async_hooks' +import { isPromise } from 'node:util/types' import type { AgentExecution } from './types.ts' export type { AgentExecution } from './types.ts' @@ -75,7 +76,7 @@ class DefaultAgentExecutionService implements AgentExecutionService { this.releaseRun() throw error } - if (result instanceof Promise) { + if (isPromise(result)) { void result.then( () => { this.releaseRun() }, () => { this.releaseRun() }, diff --git a/packages/core/agent-execution/tests/agent-execution.spec.ts b/packages/core/agent-execution/tests/agent-execution.spec.ts index ccf17f6722..e03010c569 100644 --- a/packages/core/agent-execution/tests/agent-execution.spec.ts +++ b/packages/core/agent-execution/tests/agent-execution.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { runInNewContext } from 'node:vm' import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import type { AgentExecution, AgentExecutionService } from '@deepseek-ai/dsh-agent-execution' @@ -133,4 +134,29 @@ describe('AgentExecutionProvider', () => { expect(() => service.current()).toThrow('agent execution service is disposed') expect(() => service.require()).toThrow('agent execution service is disposed') }) + + it('drains cross-realm Promise boundaries before disposal', async () => { + const { service, dispose } = await harness() + const active = execution('cross-realm') + const release = Promise.withResolvers() + const operation = runInNewContext( + '(async () => { await release; inspect() })', + { + release: release.promise, + inspect: () => { expect(service.require()).toBe(active) }, + }, + ) as () => Promise + const pending = service.run(active, 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) + }) }) From d261a9ca3bb24054f0ada2c4aafd90010fc41b20 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 18 Jul 2026 08:56:46 +0800 Subject: [PATCH 05/16] sync rfc --- AGENTS.md | 2 +- ...026-07-15-agent-execution-context.i18n.yaml | 4 ++-- .../2026-07-15-agent-execution-context.md | 18 ++++++++++++++---- .../2026-07-15-agent-execution-context.zh.md | 18 ++++++++++++++---- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21e9877e05..80f03eedbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every ``` vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// - core/ product API spine: session, system-prompt, tools, agent, agent-loop + core/ product API spine: session, system-prompt, tools, agent, agent-execution, agent-loop prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml index 9e77718e75..008e77eca4 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-agent-execution-context.md: bbc33bb6a27c0222ec7ef582a5e5c20cdd566cd8 -2026-07-15-agent-execution-context.zh.md: 64bebc6e70519426bd88e2ea9bb9d40d64f1c58b +2026-07-15-agent-execution-context.md: cdb39493fa611f86d81149fdeb91c7dabe953292 +2026-07-15-agent-execution-context.zh.md: a4ad8d1e16a99de5258212f86c09ce4559696447 diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md index bbc33bb6a2..a8d1338a6f 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md @@ -8,7 +8,7 @@ English | [中文](2026-07-15-agent-execution-context.zh.md) The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. -Deep process-local infrastructure still needs a trusted initiating Agent. Capability transports, tracing helpers, loggers, and gateway clients may sit below the explicit loop, tool, and request parameters. Threading `agent` through every private helper adds plumbing, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are also unsuitable because a model must not choose a trusted Session or routing header. +Deep process-local infrastructure still needs a trusted initiating Agent. Capability transports, tracing helpers, loggers, and gateway clients may sit below the explicit loop, tool, and request parameters. Threading `agent` through every private helper adds plumbing, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are also unsuitable because a model must not choose a trusted Session or routing header. This is mandatory control infrastructure rather than optional model-visible context enrichment. ## Decision @@ -26,7 +26,7 @@ export interface AgentExecutionService { } ``` -`current()` is optional, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a real clearing boundary for work that must not inherit an Agent. Session remains derived as `execution.agent.session`; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners. +`current()` is optional, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a real clearing boundary for work that must not inherit an Agent. A comparable implementation observed an uncleared ambient value crossing scheduled work into a later turn; the explicit undefined boundary prevents that class of leak. Session remains derived as `execution.agent.session`; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners. `AgentLoop` injects the service and wraps each concrete driver's complete `runLoop` lifetime in `agentExecution.run({ agent }, ...)`. Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. @@ -40,11 +40,21 @@ A host-aware transport may derive a deployment-owned header such as `X-Harness-S 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. +## Reference model + +| Claude Code | Harness design | +|---|---| +| AppState store | Cordis deployment services and their owned live state | +| QueryEngine | Agent driver plus loop-owned runtime state | +| ToolUseContext | Explicit Agent, tool, and request parameters at capability seams | +| AgentContext ALS | Narrow `AgentExecution` carrier | +| Transcript | Event-sourced `Session` and persistence backends | + ## Verification -Service tests pin optional and required reads, synchronous and awaited propagation, overlapping and nested boundaries, explicit clearing, restoration after throw or rejection, exact return identity, drain ordering, and disposed-reference errors. AgentLoop integration tests run overlapping real drivers, nested parent/child creation, agentless direct tool execution, cancellation during provider/root teardown, service restart, and a captured Agent after disposal. +Service tests pin optional and required reads, synchronous and awaited propagation, overlapping and nested boundaries, explicit clearing, restoration after throw or rejection, exact return identity, drain ordering, and disposed-reference errors. AgentLoop integration tests run overlapping real drivers, nested parent/child creation, agentless direct tool execution, cancellation during provider/root teardown, service restart, and a captured Agent after disposal. Detached-work coverage prevents context leakage without changing existing explicit cancellation contracts. -The test-double capability transport derives `X-Harness-Session-Id` internally and asserts that neither its tool schema nor logged arguments contains an identity field. Composition tests and generated catalogs keep the provider present in the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses; a missing provider leaves AgentLoop inactive. +The test-double capability transport derives `X-Harness-Session-Id` internally and asserts that neither its tool schema nor logged arguments contains an identity field. Composition tests and generated catalogs keep the provider present in the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses; a missing provider leaves AgentLoop inactive. Documentation checks keep the repository layout in `AGENTS.md`, the package table in `packages/core/README.md`, and the group description in `packages/README.md` aligned with the mandatory provider. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md index 64bebc6e70..4cf78ce2c6 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md @@ -8,7 +8,7 @@ Status: implemented Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 -进程内深层基础设施仍需要可信的发起 Agent。能力传输层、追踪辅助函数、日志器和网关客户端可能位于显式 loop、工具及请求参数的下层。在每个私有辅助函数中传递 `agent` 会增加管道代码,而进程级可变槽会在 `await` 之间发生并发错误。模型可见参数同样不合适,因为模型不能选择可信的会话或路由请求头。 +进程内深层基础设施仍需要可信的发起 Agent。能力传输层、追踪辅助函数、日志器和网关客户端可能位于显式 loop、工具及请求参数的下层。在每个私有辅助函数中传递 `agent` 会增加管道代码,而进程级可变槽会在 `await` 之间发生并发错误。模型可见参数同样不合适,因为模型不能选择可信的会话或路由请求头。它是必载控制基础设施,而不是模型可见的可选上下文增强。 ## 决策 @@ -26,7 +26,7 @@ export interface AgentExecutionService { } ``` -`current()` 执行可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的准确同步值或 Promise。`run(undefined, operation)` 会建立真实的清空边界,供不得继承 Agent 的工作使用。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。 +`current()` 执行可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的准确同步值或 Promise。`run(undefined, operation)` 会建立真实的清空边界,供不得继承 Agent 的工作使用。一个同类实现曾观察到未清空的隐式值穿过已调度工作泄漏进后续轮次;显式 undefined 边界可以防止这类泄漏。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。 `AgentLoop` 注入该服务,并用 `agentExecution.run({ agent }, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 @@ -40,11 +40,21 @@ export interface AgentExecutionService { 本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 +## 参考模型 + +| Claude Code | Harness 中的设计 | +|---|---| +| AppState store | Cordis 部署服务及其拥有的实时状态 | +| QueryEngine | Agent 驱动及 loop 所拥有的运行时状态 | +| ToolUseContext | 能力边界上的显式 Agent、工具和请求参数 | +| AgentContext ALS | 窄粒度 `AgentExecution` 载体 | +| Transcript | 事件溯源 `Session` 与持久化后端 | + ## 验证 -服务测试锁定可选与必需读取、同步与跨 `await` 传播、并发与嵌套边界、显式清空、throw 或 rejection 后的恢复、准确返回值身份、排空顺序及已 dispose 引用错误。AgentLoop 集成测试覆盖重叠的真实驱动、嵌套父子创建、无 Agent 的直接工具执行、提供方或根 Context teardown 期间的取消、服务重启,以及 Agent dispose 后保留的引用。 +服务测试锁定可选与必需读取、同步与跨 `await` 传播、并发与嵌套边界、显式清空、throw 或 rejection 后的恢复、准确返回值身份、排空顺序及已 dispose 引用错误。AgentLoop 集成测试覆盖重叠的真实驱动、嵌套父子创建、无 Agent 的直接工具执行、提供方或根 Context teardown 期间的取消、服务重启,以及 Agent dispose 后保留的引用。针对脱离主调用链的异步工作的测试会防止上下文泄漏,同时不改变现有显式取消契约。 -测试替身能力传输层在内部推导 `X-Harness-Session-Id`,并断言工具 schema 与记录的参数都不包含身份字段。组合测试和生成目录确保默认 bundle、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方;缺少提供方时 AgentLoop 保持未激活。 +测试替身能力传输层在内部推导 `X-Harness-Session-Id`,并断言工具 schema 与记录的参数都不包含身份字段。组合测试和生成目录确保默认 bundle、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方;缺少提供方时 AgentLoop 保持未激活。文档检查确保 `AGENTS.md` 中的仓库布局、`packages/core/README.md` 中的包表,以及 `packages/README.md` 中的分组说明都与该必载提供方保持一致。 ## 考虑过的替代方案 From a42c28cdc9cbf0c6dc5ba789eecea8cf08448cb7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 18 Jul 2026 09:00:22 +0800 Subject: [PATCH 06/16] docs(rfc): refresh agent execution context pairing record --- .../architecture/2026-07-15-agent-execution-context.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml index 008e77eca4..8131a9fa8e 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-agent-execution-context.md: cdb39493fa611f86d81149fdeb91c7dabe953292 -2026-07-15-agent-execution-context.zh.md: a4ad8d1e16a99de5258212f86c09ce4559696447 +2026-07-15-agent-execution-context.md: a8d1338a6fc277c3adcd1ed835a54dd0b667ce4b +2026-07-15-agent-execution-context.zh.md: 4cf78ce2c6ecba15eb7ae90479f877ce4e07cfe1 From 9912918a48438fade5a9b373df55682e2c9a31fb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:58:55 +0800 Subject: [PATCH 07/16] docs: tighten agent execution contracts --- docs/architecture.md | 2 +- docs/core-data-structures/core.md | 2 +- ...26-07-15-agent-execution-context.i18n.yaml | 4 +- .../2026-07-15-agent-execution-context.md | 48 +++++------------ .../2026-07-15-agent-execution-context.zh.md | 54 ++++++------------- packages/README.md | 2 +- packages/core/README.md | 2 +- packages/core/agent-execution/README.md | 4 +- packages/core/agent-execution/package.json | 2 +- packages/core/agent-execution/src/types.ts | 2 +- packages/core/agent-loop/README.md | 4 +- .../verify-package-readme-model-experience.ts | 2 +- 12 files changed, 41 insertions(+), 87 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 56e7fee812..2f47722ba7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Every live agent owns a scoped `agent.ctx`. Its registrations shadow globals, re ### Agent Execution Context -`AgentLoop` wraps each concrete driver in process-local `ctx.agentExecution`; child creation and setup stay outside its boundary, and explicit identities remain authoritative. See the [package contract](../packages/core/agent-execution/README.md) and [decision](rfc/implemented/architecture/2026-07-15-agent-execution-context.md). +`AgentLoop` establishes process-local `ctx.agentExecution` around each driver; the [decision](rfc/implemented/architecture/2026-07-15-agent-execution-context.md) owns boundary and explicit-identity rules. ## State diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 74dfa1ce78..1e802f3630 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -415,7 +415,7 @@ The [event taxonomy](../architecture.md#event) owns the `agent/*` lifecycle, che ## Agent execution context -`AgentExecution` is the process-local ambient frame established around a concrete driver's lifetime. It holds the exact Agent rather than duplicating Session or step state; ambient presence is neither liveness proof nor authorization. +`AgentExecution` is the process-local ambient frame established around a concrete driver's lifetime. It holds the exact Agent object rather than duplicating Session or step state; ambient presence is neither liveness proof nor authorization. Source: [`packages/core/agent-execution/src/types.ts`](../../packages/core/agent-execution/src/types.ts) diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml index 8131a9fa8e..371884096a 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-agent-execution-context.md: a8d1338a6fc277c3adcd1ed835a54dd0b667ce4b -2026-07-15-agent-execution-context.zh.md: 4cf78ce2c6ecba15eb7ae90479f877ce4e07cfe1 +2026-07-15-agent-execution-context.md: 2ea88cddf7a686713157c77ce1d7520082a9da28 +2026-07-15-agent-execution-context.zh.md: 1c4e0ba9c3fa787dfa816eff4c5d04cff5c2f7b1 diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md index a8d1338a6f..2ea88cddf7 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md @@ -8,63 +8,41 @@ English | [中文](2026-07-15-agent-execution-context.zh.md) The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. -Deep process-local infrastructure still needs a trusted initiating Agent. Capability transports, tracing helpers, loggers, and gateway clients may sit below the explicit loop, tool, and request parameters. Threading `agent` through every private helper adds plumbing, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are also unsuitable because a model must not choose a trusted Session or routing header. This is mandatory control infrastructure rather than optional model-visible context enrichment. +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 is mandatory control infrastructure rather than optional model-visible context. ## Decision -`@deepseek-ai/dsh-agent-execution` provides the mandatory `ctx.agentExecution` service using Node `AsyncLocalStorage`. The frame contains only the exact live Agent: +`@deepseek-ai/dsh-agent-execution` provides the mandatory `ctx.agentExecution` service using Node `AsyncLocalStorage`. The named `AgentExecution` frame contains only the exact Agent object; the [core-data catalog](../../../core-data-structures/core.md#agent-execution-context) owns the literal frame and service types. -```text -export interface AgentExecution { - readonly agent: Agent -} +`current()` reads optionally, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as `execution.agent.session`; turn, step, tool call, `signal`, model, `cwd`, sandbox, and authorization stay with their existing owners. -export interface AgentExecutionService { - current(): AgentExecution | undefined - require(): AgentExecution - run(execution: AgentExecution | undefined, operation: () => T): T -} -``` +`AgentLoop` injects the service and wraps each concrete driver's complete `runLoop` lifetime in `agentExecution.run({ agent }, ...)`. Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. -`current()` is optional, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a real clearing boundary for work that must not inherit an Agent. A comparable implementation observed an uncleared ambient value crossing scheduled work into a later turn; the explicit undefined boundary prevents that class of leak. Session remains derived as `execution.agent.session`; turn, step, tool call, signal, model, cwd, sandbox, and authorization stay with their existing owners. - -`AgentLoop` injects the service and wraps each concrete driver's complete `runLoop` lifetime in `agentExecution.run({ agent }, ...)`. Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. - -Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, cwd selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. +Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, task ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. The provider uses an ordered composite effect. Teardown first rejects new boundaries, then removes the service and awaits injected dependents such as AgentLoop, then waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. `current()` and `require()` remain usable through a retained in-flight service reference while that drain runs; after disposal, retained calls throw `agent execution service is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting is required in addition to Cordis dependency ordering. -Asynchronous resources created inside `run()` inherit its store even when the returned operation does not await them. Agent-owned foreground work may inherit `{ agent }` but keeps the explicit cancellation and disposal contract of its execution seam. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop. Queue, worker, process, and wire boundaries serialize identity rather than expecting ALS propagation. +`run()` does not own detached work: provider drain tracks only the Promise returned by `operation`. Asynchronous resources created inside the 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 `run(undefined, 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.agentExecution.require().agent.session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam. This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. -## Reference model - -| Claude Code | Harness design | -|---|---| -| AppState store | Cordis deployment services and their owned live state | -| QueryEngine | Agent driver plus loop-owned runtime state | -| ToolUseContext | Explicit Agent, tool, and request parameters at capability seams | -| AgentContext ALS | Narrow `AgentExecution` carrier | -| Transcript | Event-sourced `Session` and persistence backends | - ## Verification -Service tests pin optional and required reads, synchronous and awaited propagation, overlapping and nested boundaries, explicit clearing, restoration after throw or rejection, exact return identity, drain ordering, and disposed-reference errors. AgentLoop integration tests run overlapping real drivers, nested parent/child creation, agentless direct tool execution, cancellation during provider/root teardown, service restart, and a captured Agent after disposal. Detached-work coverage prevents context leakage without changing existing explicit cancellation contracts. +Service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, overlapping, nested, and cleared boundaries, restoration after throws or rejection, drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, missing-provider activation, service restart, and provider/root teardown. Composition, module-graph, build, and runtime-closure checks keep the provider wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses. -The test-double capability transport derives `X-Harness-Session-Id` internally and asserts that neither its tool schema nor logged arguments contains an identity field. Composition tests and generated catalogs keep the provider present in the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses; a missing provider leaves AgentLoop inactive. Documentation checks keep the repository layout in `AGENTS.md`, the package table in `packages/core/README.md`, and the group description in `packages/README.md` aligned with the mandatory 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 `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 plumbing without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries. +**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. -**Store a complete mutable runtime frame.** Agent, Session, inbox, cancellation, turn, step, tool execution, and persistence already have authoritative owners. Duplicating them would create stale snapshots and another lifecycle. The wrapper leaves room for a separately justified stale-safe label without flattening the store to a bare Agent. +**Store a complete mutable runtime frame.** Agent, Session, inbox, cancellation, turn, step, tool execution, and persistence already have authoritative owners. Duplicating them would create stale snapshots and another lifecycle. A named frame makes the execution-context boundary explicit without duplicating owner 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. +**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. @@ -76,8 +54,8 @@ The test-double capability transport derives `X-Harness-Session-Id` internally a Deep infrastructure gains one trusted process-local initiating Agent without widening existing tool and capability requests. Concurrent and nested drivers isolate automatically, AgentLoop stays inactive when the provider is absent, and HMR/root disposal reaches quiescence before ALS is disabled. -The dependency is implicit in function signatures and carries a live capability object. Consumers must restrict it to cross-cutting infrastructure, treat ambient presence as neither liveness nor authorization, and retain explicit cancellation and ownership checks. ALS also has an always-on propagation cost and does not cross worker, process, HTTP, or durable queue boundaries. +The 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 provider-owned instances; the service state guard prevents a later `run()` from re-entering the instance after disposal. -The frame deliberately omits turn, step, signal, cwd, sandbox, and authorization. A real consumer that cannot use existing explicit fields must justify any refinement separately; a stale copied field may at most mislabel telemetry, never grant control. +The frame deliberately omits turn, step, `signal`, `cwd`, sandbox, and authorization. A real consumer that cannot use existing explicit fields must justify any refinement separately; a stale copied field may at most mislabel telemetry, never grant control. diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md index 4cf78ce2c6..1c4e0ba9c3 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md @@ -8,65 +8,43 @@ Status: implemented Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 -进程内深层基础设施仍需要可信的发起 Agent。能力传输层、追踪辅助函数、日志器和网关客户端可能位于显式 loop、工具及请求参数的下层。在每个私有辅助函数中传递 `agent` 会增加管道代码,而进程级可变槽会在 `await` 之间发生并发错误。模型可见参数同样不合适,因为模型不能选择可信的会话或路由请求头。它是必载控制基础设施,而不是模型可见的可选上下文增强。 +进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体属于必需的控制基础设施,而非模型可见的可选上下文。 ## 决策 -`@deepseek-ai/dsh-agent-execution` 使用 Node `AsyncLocalStorage` 提供必载的 `ctx.agentExecution` 服务。该帧只包含准确的存活 Agent: +`@deepseek-ai/dsh-agent-execution` 使用 Node `AsyncLocalStorage` 提供必需的 `ctx.agentExecution` 服务。命名的 `AgentExecution` 帧仅包含同一个 Agent 对象;[核心数据目录](../../../core-data-structures/core.md#agent-execution-context)是帧与服务字面类型定义的真源。 -```text -export interface AgentExecution { - readonly agent: Agent -} +`current()` 用于可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的同步值或 Promise 本身。`run(undefined, operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。 -export interface AgentExecutionService { - current(): AgentExecution | undefined - require(): AgentExecution - run(execution: AgentExecution | undefined, operation: () => T): T -} -``` +`AgentLoop` 注入该服务,并用 `agentExecution.run({ agent }, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 -`current()` 执行可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的准确同步值或 Promise。`run(undefined, operation)` 会建立真实的清空边界,供不得继承 Agent 的工作使用。一个同类实现曾观察到未清空的隐式值穿过已调度工作泄漏进后续轮次;显式 undefined 边界可以防止这类泄漏。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、signal、模型、cwd、沙箱和授权继续由现有归属方管理。 - -`AgentLoop` 注入该服务,并用 `agentExecution.run({ agent }, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 - -隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、cwd 选择、取消、worker/进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 +隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 提供方使用有序复合 effect。teardown 会先拒绝新边界,再移除服务并等待 AgentLoop 等注入方排空,随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。排空期间,进行中代码可通过保留的服务引用继续调用 `current()` 和 `require()`;dispose 后,保留引用会抛出 `agent execution service is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外还必须统计活动边界。 -在 `run()` 内创建的异步资源会继承其存储,即使返回的操作没有等待它们。Agent 所拥有的前台工作可以继承 `{ agent }`,但仍使用其执行 seam 的显式取消和 dispose 契约。无关的定时器、队列和部署基础设施在 `run(undefined, operation)` 下启动,并拥有显式停止操作。队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。 +`run()` 不负责管理脱离返回链的工作:提供方排空只跟踪 `operation` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所有前台工作会把完整生命周期纳入返回值,并保留显式取消契约。无关的定时器、队列和部署基础设施在 `run(undefined, operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。 宿主感知的传输层可以从 `ctx.agentExecution.require().agent.session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 -## 参考模型 - -| Claude Code | Harness 中的设计 | -|---|---| -| AppState store | Cordis 部署服务及其拥有的实时状态 | -| QueryEngine | Agent 驱动及 loop 所拥有的运行时状态 | -| ToolUseContext | 能力边界上的显式 Agent、工具和请求参数 | -| AgentContext ALS | 窄粒度 `AgentExecution` 载体 | -| Transcript | 事件溯源 `Session` 与持久化后端 | - ## 验证 -服务测试锁定可选与必需读取、同步与跨 `await` 传播、并发与嵌套边界、显式清空、throw 或 rejection 后的恢复、准确返回值身份、排空顺序及已 dispose 引用错误。AgentLoop 集成测试覆盖重叠的真实驱动、嵌套父子创建、无 Agent 的直接工具执行、提供方或根 Context teardown 期间的取消、服务重启,以及 Agent dispose 后保留的引用。针对脱离主调用链的异步工作的测试会防止上下文泄漏,同时不改变现有显式取消契约。 +服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、缺少提供方时的激活行为、服务重启,以及提供方或根 Context 的销毁流程。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方。 -测试替身能力传输层在内部推导 `X-Harness-Session-Id`,并断言工具 schema 与记录的参数都不包含身份字段。组合测试和生成目录确保默认 bundle、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方;缺少提供方时 AgentLoop 保持未激活。文档检查确保 `AGENTS.md` 中的仓库布局、`packages/core/README.md` 中的包表,以及 `packages/README.md` 中的分组说明都与该必载提供方保持一致。 +只有测试替身形式的宿主感知传输层消费隐式身份;它在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空 `operation` 返回的 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 ## 考虑过的替代方案 -**在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会增加管道代码,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 +**在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 **让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。 -**保存完整的可变运行时帧。** Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。重复保存会产生陈旧快照和另一套生命周期。包装对象为另行论证的陈旧安全标签保留扩展空间,而不会把存储简化成裸 Agent。 +**保存完整的可变运行时帧。** Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。重复保存会产生陈旧快照和另一套生命周期。命名帧能够明确标识执行上下文边界,而不重复保存归属方状态。 -**包含步骤级 `AbortSignal`、cwd、沙箱或授权。** 它们的生命周期与权限不匹配驱动边界,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。 +**包含步骤级 `AbortSignal`、`cwd`、沙箱或授权。** 它们的生命周期及权限范围与驱动边界不一致,而且现有 seam 已经显式传递这些值。新增控制能力需要独立决策和嵌套生命周期契约。 -**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步 continuation 间相互覆盖,因此可变全局值只在 Harness 不具备的串行保证下才正确。 +**使用进程级 `currentAgent`。** 并发 Agent 和 subagent 会在异步延续执行之间相互覆盖,因此可变全局值只在 Harness 不具备的串行保证下才正确。 **从模型可见参数推导身份。** 不能信任模型或用户输入来选择会话、租户或沙箱路由。 @@ -74,10 +52,10 @@ export interface AgentExecutionService { ## 后果 -深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,缺少提供方时 AgentLoop 保持未激活,HMR 或根 Context dispose 会在禁用 ALS 前达到静止状态。 +深层基础设施可以获得一个可信的进程内发起 Agent,而无需加宽现有工具和能力请求。并发及嵌套驱动会自动隔离,缺少提供方时 AgentLoop 保持未激活,HMR 或根 Context dispose 会在禁用 ALS 前完成排空。 -该依赖不会出现在函数签名中,并且携带一个存活能力对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。 +该依赖不会出现在函数签名中,并且携带一个具有控制能力的 Agent 对象。消费方必须将其限制在横切基础设施中,把隐式存在视为既不证明存活、也不授予权限,并保留显式取消和归属检查。ALS 还有常驻传播成本,也无法跨越 worker、进程、HTTP 或持久化队列边界。 -该 teardown 设计有意接受 Node [Stability 1(实验性)](https://nodejs.org/api/async_context.html#asynclocalstoragedisable) 的 `AsyncLocalStorage.disable()` 依赖。Node 要求在 ALS 实例可被垃圾回收前调用 `disable()`,这对 HMR 替换提供方所拥有的实例尤为重要;服务状态守卫会阻止 dispose 后通过后续 `run()` 重新进入该实例。 +该销毁设计有意依赖 Node 的 [Stability 1(实验性)](https://nodejs.org/api/async_context.html#asynclocalstoragedisable) API `AsyncLocalStorage.disable()`。Node 要求在 ALS 实例可被垃圾回收前调用 `disable()`,这对 HMR 替换提供方所拥有的实例尤为重要;服务状态守卫会阻止 dispose 后通过后续 `run()` 重新进入该实例。 -该帧有意省略轮次、步骤、signal、cwd、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。 +该帧有意省略轮次、步骤、`signal`、`cwd`、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。 diff --git a/packages/README.md b/packages/README.md index 380a786d16..7e01fe30fe 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, agent-execution, and the concrete loop | Product — stable surface | +| [`core/`](core/README.md) | Product API spine, including agent execution 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/core/README.md b/packages/core/README.md index 6736e29af2..3c0ad38819 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -14,6 +14,6 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co `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-execution` is mandatory control infrastructure shared by concrete loops and deep process-local consumers. `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; other plugins depend on the `agent` vocabulary and execution service, never on `agent-loop` directly, so the loop stays swappable. +`agent-execution` is mandatory control infrastructure for concrete loops and deep process-local consumers. `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. Extension plugins depend on `agent` and, when they need ambient identity, `agent-execution`; they never depend 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 + agent-execution + 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-execution/README.md b/packages/core/agent-execution/README.md index a389699863..8c01f0b3c4 100644 --- a/packages/core/agent-execution/README.md +++ b/packages/core/agent-execution/README.md @@ -8,13 +8,13 @@ Process-local ambient Agent identity for asynchronous work initiated by a concre - `require()` returns the inherited execution or throws `no agent execution context is active`. - `run(execution, operation)` returns the exact synchronous value or Promise from `operation`. Passing `undefined` establishes a real boundary that hides an inherited Agent. -The store contains only `{ readonly agent: Agent }`. A Session is available through `agent.session`; turn, step, signal, cwd, sandbox, authorization, and other capability state remain with their explicit owners. Ambient presence identifies the initiator but does not prove that the Agent is live or that an operation is authorized. +The store contains only `{ readonly agent: Agent }`. The `Session` remains available through `agent.session`; turn, step, `signal`, `cwd`, sandbox, authorization, and other capability state remain with their explicit owners. Ambient presence identifies the initiator but does not prove that the Agent is live or that an operation is authorized. ## Lifetime and detached work Provider teardown rejects new `run()` boundaries, removes the service so injected dependents drain, waits for returned Promise boundaries, then disables its `AsyncLocalStorage`. In-flight code retaining the service can call `current()` and `require()` while it drains; after disposal, all three methods throw `agent execution service is disposed`. -Async resources created inside `run()` inherit its Agent even when the operation does not await them. Agent-owned foreground work may inherit the boundary but keeps using the explicit cancellation and disposal contract of its execution seam. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop. Queue, worker, process, and wire boundaries serialize any identity they need instead of relying on ALS propagation. +Async resources created inside `run()` inherit its Agent even if `operation` returns before they settle, but provider teardown waits only for the Promise returned by `operation`. The owning seam must stop unreturned work explicitly. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop; queue, worker, process, and wire boundaries serialize any identity they need instead of relying on ALS propagation. ## Known Limitations and Deferred Work diff --git a/packages/core/agent-execution/package.json b/packages/core/agent-execution/package.json index e5ebbbf5f3..7c2c527d3d 100644 --- a/packages/core/agent-execution/package.json +++ b/packages/core/agent-execution/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-execution", - "description": "Agent-scoped asynchronous execution context for the DeepSeek Harness", + "description": "Process-local ambient Agent context for asynchronous driver work", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/core/agent-execution/src/types.ts b/packages/core/agent-execution/src/types.ts index ccafb4840a..94b144785b 100644 --- a/packages/core/agent-execution/src/types.ts +++ b/packages/core/agent-execution/src/types.ts @@ -6,7 +6,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' -/** The exact live Agent associated with one asynchronous execution chain. */ +/** The exact Agent associated with one asynchronous execution chain. */ export interface AgentExecution { readonly agent: Agent } diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2acda4ab52..f45411923c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -50,9 +50,7 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati ### Loop lifecycle (`loop.ts`) -The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. Creation, persistence load, and unpublished setup stay outside the child boundary; explicit Agent fields remain authoritative at service, worker, process, persistence, and wire boundaries. The [execution-context package](../agent-execution/README.md) owns propagation and detached-work rules. - -The loop records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures. +The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ 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 [execution-context package](../agent-execution/README.md) 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/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index fc2347566d..407758afc7 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -28,7 +28,7 @@ interface SentenceContract { * so an absent section cannot be mistaken for forgotten documentation. */ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { - 'packages/core/agent-execution': 'The package is model-agnostic ambient control infrastructure; model-facing consumers own any resulting request surface.', + 'packages/core/agent-execution': 'The package adds no model-visible text or schema; consumers own any use in model requests.', 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', 'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', From dd9f8947357e23556a83b10593a33cc5dd3e631f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:01:43 +0800 Subject: [PATCH 08/16] test(agent-loop): wire execution context into session-id harness --- packages/core/agent-loop/tests/config-session-id.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 7badab97c1..8710ee5629 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -32,6 +32,7 @@ async function makeCoreContext(): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentExecutionProvider) return ctx } From c23214be5602a4d66cb1d70baa4ddaa4dae965c8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:30:45 +0800 Subject: [PATCH 09/16] refactor(core): fold initiator scope into agents --- AGENTS.md | 2 +- docs/architecture.md | 7 +- docs/capability-seams.md | 9 +- docs/config-catalog.md | 7 +- docs/cordis-catalog/events.md | 356 ++++++- docs/cordis-catalog/services.md | 902 +++++++++++++++++- docs/core-data-structures/core.md | 44 +- docs/module-graph.md | 10 +- docs/rfc/INDEX.md | 2 +- .../2026-07-15-agent-execution-context.md | 61 -- .../2026-07-15-agent-execution-context.zh.md | 61 -- ...026-07-15-agent-initiator-scope.i18n.yaml} | 4 +- .../2026-07-15-agent-initiator-scope.md | 63 ++ .../2026-07-15-agent-initiator-scope.zh.md | 63 ++ examples/coding-agent/tests/code-mode.e2e.ts | 3 - .../context/workspace-context/package.json | 1 - .../tests/workspace-context.e2e.ts | 2 - .../tests/workspace-context.spec.ts | 2 - .../cordis/tool-cordis/src/api-catalog.ts | 19 +- packages/core/README.md | 7 +- packages/core/agent-execution/README.md | 23 - packages/core/agent-execution/package.json | 31 - packages/core/agent-execution/src/index.ts | 140 --- packages/core/agent-execution/src/types.ts | 12 - .../tests/agent-execution.spec.ts | 163 ---- packages/core/agent-execution/tsconfig.json | 21 - packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/package.json | 2 - packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/index.ts | 3 +- ...cution.spec.ts => agent-initiator.spec.ts} | 101 +- packages/core/agent-loop/tests/agent.spec.ts | 6 +- packages/core/agent-loop/tests/cancel.spec.ts | 4 - .../tests/config-session-id.spec.ts | 8 - .../tests/contract-regressions.spec.ts | 9 - .../agent-loop/tests/coverage-edges.spec.ts | 2 - .../agent-loop/tests/interception.spec.ts | 2 - packages/core/agent-loop/tests/loop.spec.ts | 4 - .../core/agent-loop/tests/properties.spec.ts | 2 - .../agent-loop/tests/request-cache.e2e.ts | 2 - .../tests/request-reconstruction.spec.ts | 2 - packages/core/agent-loop/tests/resume.spec.ts | 9 - .../agent-loop/tests/scope-lifecycle.spec.ts | 2 - .../core/agent-loop/tests/tool-calls.spec.ts | 3 - .../core/agent-loop/tests/tool-order.spec.ts | 2 - .../core/agent-loop/tests/turn-stop.spec.ts | 2 - packages/core/agent-loop/tsconfig.json | 3 - packages/core/agent/README.md | 17 +- packages/core/agent/package.json | 2 +- packages/core/agent/src/index.ts | 121 ++- .../core/agent/tests/agent-initiator.spec.ts | 163 ++++ .../agent/tests/gen-cordis-catalog.spec.ts | 27 +- packages/examples/agent-spine-demo/README.md | 3 +- .../examples/agent-spine-demo/package.json | 4 +- .../examples/agent-spine-demo/src/index.ts | 2 - .../examples/agent-spine-demo/tsconfig.json | 3 - .../sdk/helper/src/features/builtin/spine.ts | 4 - packages/support/agent-loop-testkit/README.md | 2 +- .../support/agent-loop-testkit/package.json | 2 - .../support/agent-loop-testkit/src/index.ts | 2 - .../support/agent-loop-testkit/tsconfig.json | 3 - .../workflow-workerthread/package.json | 1 - .../tests/workflow-workerthread.e2e.ts | 2 - pnpm-lock.yaml | 27 - python/sdk-runtime/package.json | 1 - scripts/cordis-walk.ts | 41 +- scripts/gen-cordis-api.ts | 4 +- scripts/gen-cordis-catalog.ts | 83 +- scripts/gen-doc-graphs.ts | 12 +- scripts/gen-website-api.ts | 27 +- scripts/type-equiv.manifest.json | 2 - .../verify-package-readme-model-experience.ts | 1 - tsconfig.build.json | 1 - tsconfig.json | 1 - website/.vitepress/config/api-sidebar.json | 4 - website/zh-CN/api/harness/agent-execution.md | 48 - website/zh-CN/api/harness/agent-loop.md | 8 +- website/zh-CN/api/harness/agents.md | 77 +- website/zh-CN/api/harness/events.md | 2 +- 79 files changed, 1883 insertions(+), 1003 deletions(-) delete mode 100644 docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md delete mode 100644 docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md rename docs/rfc/implemented/architecture/{2026-07-15-agent-execution-context.i18n.yaml => 2026-07-15-agent-initiator-scope.i18n.yaml} (64%) create mode 100644 docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md create mode 100644 docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md delete mode 100644 packages/core/agent-execution/README.md delete mode 100644 packages/core/agent-execution/package.json delete mode 100644 packages/core/agent-execution/src/index.ts delete mode 100644 packages/core/agent-execution/src/types.ts delete mode 100644 packages/core/agent-execution/tests/agent-execution.spec.ts delete mode 100644 packages/core/agent-execution/tsconfig.json rename packages/core/agent-loop/tests/{agent-execution.spec.ts => agent-initiator.spec.ts} (76%) create mode 100644 packages/core/agent/tests/agent-initiator.spec.ts delete mode 100644 website/zh-CN/api/harness/agent-execution.md diff --git a/AGENTS.md b/AGENTS.md index a0ef9c7539..38799b8e96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ DeepSeek Harness SDK is a plugin-based agent harness on vendored Cordis: **every ``` vendor/ Vendored Cordis source — manifest + sync procedure in vendor/README.md packages/ @deepseek-ai/dsh- workspaces at packages/// - core/ product API spine: session, system-prompt, tools, agent, agent-execution, agent-loop + core/ product API spine: session, system-prompt, tools, agent, agent-loop prompt/ workspace instructions llm/ LLM seam + the DeepSeek adapters (hand-rolled + pi-ai design twin) bash/ bash executor seam + local impl + model-facing bash tools diff --git a/docs/architecture.md b/docs/architecture.md index 09cc43afeb..f791784e8e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,8 +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.agentExecution` | `dsh-agent-execution` | process-local ambient Agent identity for asynchronous driver work | +| `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 @@ -119,9 +118,9 @@ 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). -### Agent Execution Context +### Initiating Agent Scope -`AgentLoop` establishes process-local `ctx.agentExecution` around each driver; the [decision](rfc/implemented/architecture/2026-07-15-agent-execution-context.md) owns boundary and explicit-identity rules. +`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 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index feb31a358e..93c5335e61 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -48,9 +48,7 @@ flowchart LR pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] pkg_skill_local["skill-local"] - svc_agents["ctx.agents
Agent registry"] - pkg_agent_execution["agent-execution"] - svc_agentExecution["ctx.agentExecution
Agent execution context"] + svc_agents["ctx.agents
Agent service"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_bash["bash"] @@ -99,7 +97,6 @@ flowchart LR pkg_acp --> svc_approval pkg_acp --> svc_userInteraction pkg_agent --> svc_agents - pkg_agent_execution --> svc_agentExecution pkg_agent_loop --> svc_agentLoop pkg_approval --> svc_approval pkg_bash --> svc_bash @@ -145,7 +142,6 @@ flowchart LR pkg_web_search_perplexity --> svc_web pkg_workflow --> svc_workflows pkg_workflow_workerthread --> svc_workflows - svc_agentExecution --> pkg_agent_loop svc_agentLoop --> pkg_agent_spine_demo svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -216,8 +212,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), [`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.agentExecution` | `core` | [`agent-execution`](../packages/core/agent-execution) | - | [`agent-loop`](../packages/core/agent-loop) | - | Carries the exact initiating Agent across one process-local asynchronous driver chain; explicit identities remain authoritative at external boundaries. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`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/config-catalog.md b/docs/config-catalog.md index ee6003c6f6..47c7ff90b7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -75,7 +75,7 @@ Source: [`packages/examples/acp-demo/src/index.ts:33`](../packages/examples/acp- ## `@deepseek-ai/dsh-agent-loop` -Requires: `agents` · `agentExecution` · `sessions` · `llm` · `tools` · `systemPrompt` +Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -101,7 +101,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:370`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -157,7 +157,7 @@ export interface SkillConfig { Depends on: [`AgentLoopConfig`](#deepseek-aidsh-agent-loop) · [`SkillLocal`](../packages/skill/skill-local/src/index.ts) · [`SkillRegistryConfig`](#deepseek-aidsh-skill) · [`SystemPromptConfig`](#deepseek-aidsh-system-prompt) · [`toolBash`](../packages/bash/tool-bash/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`toolSkill`](../packages/skill/tool-skill/src/index.ts) · [`toolTasks`](../packages/tasks/tool-tasks/src/index.ts) · [`workspaceContext`](../packages/context/workspace-context/src/index.ts) -Source: [`packages/examples/agent-spine-demo/src/index.ts:58`](../packages/examples/agent-spine-demo/src/index.ts) +Source: [`packages/examples/agent-spine-demo/src/index.ts:57`](../packages/examples/agent-spine-demo/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -1415,7 +1415,6 @@ Source: [`packages/context/workspace-context/src/config.ts:16`](../packages/cont These load from a `cordis.yml` entry with no `config:` block; they declare no config surface. - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-agent-execution` ([`packages/core/agent-execution/src/index.ts`](../packages/core/agent-execution/src/index.ts)) - `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts)) - `@deepseek-ai/dsh-invariants` — requires `sessions` ([`packages/support/invariants/src/index.ts`](../packages/support/invariants/src/index.ts)) - `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4598390194..12f877ad48 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -3,9 +3,9 @@ # Cordis Events Catalog -Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration's JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. +Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. -This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. @@ -18,6 +18,16 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. ```ts cordis-catalog +/** + * A fully configured agent and live session were published. Setup is + * composition-only; `agent/session-start` is the first startup-driving seam. + * Synchronous listener failure vetoes publication, while returned-promise + * rejection is reported. Detach requested during dispatch waits until every + * creation listener has observed the stable entry. + * @param agent - the newly registered agent with its live session and completed setup. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/created'(this: Scoped, agent: Agent): void ``` @@ -30,6 +40,14 @@ Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/t An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract. ```ts cordis-catalog +/** + * An agent left the registry; AgentLoop emits this after driver quiescence + * but before session detachment and scoped-registration unwind. Custom + * registry users own their driver-ordering contract. + * @param agent - the exact agent removed from the registry. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/disposed'(this: Scoped, agent: Agent): void ``` @@ -42,6 +60,16 @@ Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/t A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event. ```ts cordis-catalog +/** + * A step or turn errored. The loop reports a failure here (plus the logger) + * even when the error has no in-turn position for a session `error` event. + * @param agent - the agent whose turn errored. + * @param turn - the turn in which the failure surfaced. + * @param step - the step at which the failure surfaced. + * @param error - the failure, verbatim. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void ``` @@ -54,6 +82,22 @@ Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/t Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Awaited serial checkpoint for session-surface mutation after prompt + * assembly and before `step/start`; appends land outside the pending step. + * The loop derives history once afterward, so compaction records and + * replacements are included without rewriting an assembled request. The + * prompt and prefix are the exact pressure inputs for that request, and + * `signal` cancels listener work. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent opening the step. + * @param turn - the open turn number. + * @param step - the pending step number. + * @param fullSystemPrompt - the assembled prompt. + * @param sessionPrefix - the frozen request prefix. + * @param signal - the turn abort signal. + * @mode serial + */ 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void ``` @@ -66,6 +110,15 @@ Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/t Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog +/** + * Allow, rewrite, or block one drained prompt before it becomes a user + * message. Call `next()` for the unchanged default. + * @param agent - the agent draining its inbox. + * @param content - the drained message's blocks, as queued. + * @param source - the message's resolved source. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` @@ -78,6 +131,15 @@ Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/t Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. ```ts cordis-catalog +/** + * Detached, frozen content entered the agent's inbox. Source defaults have + * already been applied, so these are the exact values retained for the log. + * @param agent - the agent whose inbox received the message. + * @param content - the accepted content blocks retained by the inbox. + * @param info - the accepted source plus whether it entered as steering. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void ``` @@ -90,6 +152,17 @@ Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/t Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. ```ts cordis-catalog +/** + * Replace the frozen call configuration. Model-visible content must use + * logged channels; this seam cannot mutate messages. Injection here joins + * the next request because the current step boundary is already fixed. + * @param agent - the agent making the model call. + * @param turn - the open turn number. + * @param step - the step whose request this is. + * @param config - the config the loop would use (frozen); return a replacement to switch. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise ``` @@ -102,6 +175,20 @@ Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/t Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Compose request-only messages placed before derived history. The frozen + * result is computed once per loop instance, logged on its anchoring request + * header, and reused so the provider prefix remains stable. Interrupted + * composition is discarded. Composition precedes the first `agent/pre-step` + * and request boundary, so listener appends join the current request and + * pressure accounting sees the composed prefix. Changing context belongs in + * history; contributors should prepend to `await next()` to preserve registration order. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent whose session prefix is being composed. + * @param prefix - the frozen seed; return an extended replacement. + * @param signal - aborts composition when the step is torn down. + * @mode waterfall + */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise ``` @@ -114,6 +201,16 @@ Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/t The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts. ```ts cordis-catalog +/** + * The session lifecycle began, once before the first turn. Use + * `agent.inject()` to seed model-facing context. This is a notification, not + * a veto; disposal requested by a lifecycle owner is rechecked before the + * driver starts. + * @param agent - the agent whose session lifecycle began. + * @param source - why the session started (fresh startup, resume, …). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void ``` @@ -126,6 +223,14 @@ Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/t Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog +/** + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does + * not enter `running` synchronously; drive lifecycle from this event. + * @param agent - the agent whose status flipped. + * @param status - the status just entered (the transition's destination). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void ``` @@ -138,6 +243,16 @@ Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/t Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). ```ts cordis-catalog +/** + * Waterfall: post-process the assembled assistant {@link Message} before + * tool dispatch (validation, content rewriting, …). + * @param agent - the agent that received the step's response. + * @param turn - the open turn number. + * @param step - the step that produced the message. + * @param message - the assistant message as assembled from the stream. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise ``` @@ -150,6 +265,15 @@ Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/t Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. ```ts cordis-catalog +/** + * Override whether the turn continues. The default continues after tool + * calls or steering and stops otherwise; a continue reason becomes steering. + * @param agent - the agent deciding whether to run another step. + * @param turn - the turn being continued or stopped. + * @param defaultDecision - what the loop would do absent an override. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise ``` @@ -162,6 +286,15 @@ Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/t Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive. ```ts cordis-catalog +/** + * Monotonic terminal-stop checkpoint after continuation and steering are + * folded; a stop remains authoritative through turn close and flush: + * steering queued in that window is discarded, while ordinary sends survive. + * @param agent - the agent whose composed continuation outcome may be stopped. + * @param turn - the turn at its terminal-stop checkpoint. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined ``` @@ -176,10 +309,19 @@ Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/t A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. Normal factory teardown suppresses failures from the cancelled startup attempt. ```ts cordis-catalog +/** + * A declarative agent entry failed before it could publish a live agent. + * Consumers that buffer work for the configured identity use this + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. + * @param sessionId - exact shared agent/session identity that failed startup. + * @param error - persistence, setup, or publication failure. + * @mode emit + */ 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` -Source: [`packages/core/agent-loop/src/index.ts:363`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:362`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` @@ -188,6 +330,13 @@ Source: [`packages/core/agent-loop/src/index.ts:363`](../../packages/core/agent- Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()`; failure yields the fail-closed default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @mode waterfall + */ 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise ``` @@ -202,6 +351,13 @@ Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-app Single-slot decision for the next FileSystem.editText. Calling `next()` yields an unconditional edit; the first returned guard wins. ```ts cordis-catalog +/** + * Single-slot decision for the next {@link FileSystem.editText}. Calling + * `next()` yields an unconditional edit; the first returned guard wins. + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` @@ -214,6 +370,14 @@ Source: [`packages/fs/fs/src/index.ts:61`](../../packages/fs/fs/src/index.ts) Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. ```ts cordis-catalog +/** + * Record a successful observation. Listeners must be synchronous recorders: + * throws fail the tool call and returned promises are not awaited. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. + * @mode emit + */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void ``` @@ -226,6 +390,14 @@ Source: [`packages/fs/fs/src/index.ts:70`](../../packages/fs/fs/src/index.ts) Single-slot decision for the next FileSystem.writeText. Calling `next()` yields the bare provider's unconditional write; the first listener that returns an intent owns the decision rather than composing with peers. ```ts cordis-catalog +/** + * Single-slot decision for the next {@link FileSystem.writeText}. Calling + * `next()` yields the bare provider's unconditional write; the first listener + * that returns an intent owns the decision rather than composing with peers. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` @@ -240,6 +412,17 @@ Source: [`packages/fs/fs/src/index.ts:53`](../../packages/fs/fs/src/index.ts) Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit. ```ts cordis-catalog +/** + * Waterfall around every streaming model call (retry, replay, routing). + * Bound to the {@link LlmService}; call `next()` to reach the resolved + * adapter's stream, or yield your own chunks to short-circuit. + * @param options - the full request. A LOOP-built request arrives + * deep-frozen (mutation throws): its content is a pure function of the + * session log (the reconstructability RFC), so listeners read it, never + * rewrite it. A hand-built one-shot (compaction summarize) is the + * caller's own object and stays mutable here. + * @mode waterfall + */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` @@ -254,6 +437,17 @@ Source: [`packages/llm/llm/src/index.ts:40`](../../packages/llm/llm/src/index.ts Creation announcement during session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. A returned-promise rejection is logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context. ```ts cordis-catalog +/** + * Creation announcement during session publication. A synchronous throw vetoes and rolls + * back with a paired disposal; detach requested during dispatch is deferred. + * A returned-promise rejection is logged but cannot retroactively veto this + * synchronous boundary. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only sessions entered through that agent's context. + * @param session - the session just entered and announced. + * @dshScopeScan unsupported + * @mode emit + */ 'session/created'(this: Scoped, session: Session): void ``` @@ -264,6 +458,15 @@ Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/sr Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin. Listener failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. ```ts cordis-catalog +/** + * Emitted once when an announced session leaves the store, including + * publication rollback, but never for an entry whose creation announcement + * did not begin. Listener failures are logged and contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. + * @param session - the session that is no longer live in the store. + * @dshScopeScan unsupported + * @mode emit + */ 'session/disposed'(this: Scoped, session: Session): void ``` @@ -274,6 +477,17 @@ Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/sr Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context. ```ts cordis-catalog +/** + * Post-commit, fire-and-forget append feed. The listener snapshot resolves + * before the log push, but callbacks run after it; observer failures are + * logged and contained without making the committed append fail. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only events from sessions entered through that agent's context. + * @param session - the session whose log grew. + * @param event - the appended event, exactly as recorded. + * @dshScopeScan unsupported + * @mode emit + */ 'session/event'(this: Scoped, session: Session, event: SessionEvent): void ``` @@ -286,6 +500,15 @@ Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/sr Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog +/** + * Awaited parallel durability checkpoint: every listener runs and the + * caller awaits all of them, with no waterfall veto. Dispatch through + * {@link SessionStore.flush}. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. + * @param session - the session whose buffered events must reach durable storage. + * @dshScopeScan unsupported + * @mode parallel + */ 'session/flush'(this: Scoped, session: Session): Promise | void ``` @@ -298,6 +521,14 @@ Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/sr A ready child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog +/** + * A ready child settled. Scope-filtered dispatch uses the same delegating + * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * same scoped audience. + * @param info - the run identity and terminal outcome. + * @dshScopeScan unsupported + * @mode emit + */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` @@ -308,6 +539,11 @@ Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/ A provider became resolvable in the registry. ```ts cordis-catalog +/** + * A provider became resolvable in the registry. + * @param provider - the registered provider. + * @mode emit + */ 'subagent/provider-added'(provider: SubagentProvider): void ``` @@ -318,6 +554,11 @@ Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/s A provider left the registry. Accepted runs remain holder-owned. ```ts cordis-catalog +/** + * A provider left the registry. Accepted runs remain holder-owned. + * @param name - the provider name that no longer resolves. + * @mode emit + */ 'subagent/provider-removed'(name: string): void ``` @@ -328,6 +569,16 @@ Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/s A provider established a ready child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. ```ts cordis-catalog +/** + * A provider established a ready child. For in-process providers, + * `ctx.agents.get(info.id)` resolves during this notification. + * Scope-filtered dispatch keys the carrier by the delegating parent, so a + * parent-scoped listener observes only its own delegations. Paired with + * `subagent/end`. + * @param info - the provider and ready child identity. + * @dshScopeScan unsupported + * @mode emit + */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` @@ -340,6 +591,14 @@ Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/ Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. ```ts cordis-catalog +/** + * Expert waterfall over the assembled sections, tools, and variables. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners + * receive only that scope's assemblies. The returned value is authoritative. + * @param assembly - the mutable assembly built from registered providers. + * @param context - the caller's per-assembly context. + * @mode waterfall + */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` @@ -350,6 +609,11 @@ Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/syst Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope. ```ts cordis-catalog +/** + * Emitted when any prompt provider changes. This registry notification is + * unfiltered because a global change affects every scope. + * @mode emit + */ 'system-prompt/change'(): void ``` @@ -362,6 +626,15 @@ Source: [`packages/core/system-prompt/src/index.ts:33`](../../packages/core/syst A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. ```ts cordis-catalog +/** + * A tool was registered or unregistered, or a scoped restriction changed + * (the available tool set changed — possibly for one scope only). An + * UNFILTERED registry-subject notification, deliberately not scope-filtered + * dispatch: a global change concerns every agent's next assembly, so a + * scoped listener subscribing here sees every change, not just its own + * scope's. + * @mode emit + */ 'tools/change'(): void ``` @@ -372,6 +645,14 @@ Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/i Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). + * @mode waterfall + */ 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` @@ -384,6 +665,14 @@ Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/in Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this seam as errors. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. + * @mode waterfall + */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` @@ -396,6 +685,13 @@ Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/in Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing + * approval support turns `ask` into denial. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the pending call (name, parsed arguments, caller agent). + * @mode waterfall + */ 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` @@ -408,6 +704,13 @@ Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/in Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. ```ts cordis-catalog +/** + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. + * @param exec - the execution object that traversed the pipeline. + * @param result - a deep-frozen snapshot of the final returned result. + * @mode emit + */ 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` @@ -422,6 +725,16 @@ Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/i One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'] by `agent.seq`, exactly once per started call on every stop path — on an engine termination path (a worker killed past its grace) the end is engine-synthesized with outcome `'cancelled'`. ```ts cordis-catalog +/** + * One `agent()` call settled (clean result, child failure, or run + * cancellation). Paired with {@link Events['workflow/agent-start']} by + * `agent.seq`, exactly once per started call on every stop path — on an + * engine termination path (a worker killed past its grace) the end is + * engine-synthesized with outcome `'cancelled'`. + * @param info - the run's identity snapshot. + * @param agent - the call identity plus its outcome. + * @mode emit + */ 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` @@ -432,6 +745,15 @@ Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/w One `agent()` call established a ready child run. Paired with Events['workflow/agent-end'] by `agent.seq`. A call that never receives a ready run from the provider emits neither event in this pair. ```ts cordis-catalog +/** + * One `agent()` call established a ready child run. Paired with + * {@link Events['workflow/agent-end']} by `agent.seq`. A call that never + * receives a ready run from the provider emits neither + * event in this pair. + * @param info - the run's identity snapshot. + * @param agent - the call's sequence number, label, phase, and child id. + * @mode emit + */ 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` @@ -442,6 +764,15 @@ Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/w A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start']. ```ts cordis-catalog +/** + * A workflow run settled (any stop reason). Fired when + * {@link WorkflowRun.result} resolves. Paired with + * {@link Events['workflow/start']}. + * @param info - the run's identity snapshot. + * @param result - the outcome data (stop reason, error, agent count) — + * deliberately WITHOUT the result value (see {@link WorkflowResultInfo}). + * @mode emit + */ 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` @@ -452,6 +783,12 @@ Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/w The script emitted a narration line (a `log(message)` call). ```ts cordis-catalog +/** + * The script emitted a narration line (a `log(message)` call). + * @param info - the run's identity snapshot. + * @param message - the logged message, verbatim. + * @mode emit + */ 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` @@ -462,6 +799,13 @@ Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/w The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics. ```ts cordis-catalog +/** + * The script entered a phase (a `phase(title)` call) — progress grouping + * for observers; no execution semantics. + * @param info - the run's identity snapshot. + * @param title - the phase title, verbatim. + * @mode emit + */ 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` @@ -472,6 +816,12 @@ Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/w A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end']. ```ts cordis-catalog +/** + * A workflow run started — the script's meta block validated, the body + * about to execute. Paired with {@link Events['workflow/end']}. + * @param info - the run's identity snapshot (id + meta). + * @mode emit + */ 'workflow/start'(info: WorkflowRunInfo): void ``` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 40bd029e41..22d4565830 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -3,66 +3,226 @@ # Cordis Services Catalog -Every `ctx.` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +Every `ctx.` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. -This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. -## `ctx.agentExecution` — `AgentExecutionService` (abstract seam) - -Ambient Agent identity within one process-local asynchronous chain. - -```ts cordis-catalog -current(): AgentExecution | undefined -require(): AgentExecution -run(execution: AgentExecution | undefined, operation: () => T): T -``` - -Types: [AgentExecution](../core-data-structures/core.md) - -Source: [`packages/core/agent-execution/src/index.ts:18`](../../packages/core/agent-execution/src/index.ts) - ## `ctx.agentLoop` — `AgentLoop` Concrete agent factory and driver service. ```ts cordis-catalog +/** + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. + * @param options - concrete loop options. + * @param meta - optional fresh-session workspace metadata. + * @returns the published running agent. + */ create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent + +/** + * Create an owned agent on a caller-supplied session id. + * @param ownerCtx - caller context that structurally owns the transaction. + * @param options - identities, session seed/metadata, loop options, setup, and cancellation. + * @returns the published handle. + */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise + +/** + * Resume an owned agent from the configured persistence service. + * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. + * @param options - persisted identity, loop options, setup, and cancellation. + * @returns the published handle. + */ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:408`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:407`](../../packages/core/agent-loop/src/index.ts) ## `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. + * @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. + * @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 + * target; each create/resume call is then traced through that caller's + * context so ownership follows the caller without stacking proxy layers. + * Throws if a factory is already registered. Returns the disposer; on + * dispose the factory slot is cleared. + * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. + * @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. + */ setFactory(factory: AgentFactory): () => void + +/** + * Create and publish a new agent through the registered factory. + * Distinct from {@link register} (which records an already-constructed + * agent): this constructs the agent and its session. Rejects if no factory is + * registered or creation/setup fails. The resolved {@link AgentHandle} lets + * the owner tear down exactly this agent. + * @param options - shared identity, session seed/metadata, and agent options. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async create(options: CreateAgentOptions): Promise + +/** + * Load a persisted session and resume an agent on it through the registered + * factory. Rejects if no factory is registered; the factory rejects if + * session persistence is not configured or persistence/setup fails. + * @param options - persisted identity, configuration, and optional setup. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async resume(options: ResumeAgentOptions): Promise + +/** + * Register a live agent. Throws if an agent with the same id is already + * registered. Emits `agent/created` on registration and `agent/disposed` + * when the calling fiber is disposed — both with the agent's scope carrier + * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the + * emits are scope-filtered regardless of which context invoked `register` + * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always + * requires passing the carrier). Returns the disposer. + * @param agent - the already-constructed agent to record in the store. + * @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. + */ register(agent: Agent): () => void + +/** + * Insert an already-constructed agent without announcing it. This is the + * advanced ordered-lifecycle primitive used by the async agent factory: it + * first completes setup while the agent is unpublished, then assigns the + * returned detach closure into its pre-installed composite teardown before + * calling {@link announce}. Ordinary callers use {@link register}. + * @param agent - the prepared, unpublished agent. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. + * @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. + */ enter(agent: Agent, owner: Agent | undefined): () => void + +/** + * Announce an agent previously inserted with {@link enter}. + * @param agent - the live inserted agent to announce. + * @throws if `agent` is not the exact live registry entry for its id, or its + * creation announcement already began (including a reentrant call from a + * creation listener). + */ announce(agent: Agent): void + +/** + * Look up a live agent. + * @param id - the shared agent/session id to look up. + * @returns the agent, or undefined when no live agent has that id. + */ get(id: SessionId): Agent | undefined + +/** + * Test whether a live agent was created through one exact parent agent's + * scoped context. Runtime ownership is independent of durable session + * lineage and remains unambiguous when unrelated providers reuse an id. + * @param id - the candidate child agent's shared agent/session id. + * @param owner - the expected runtime creator agent. + * @returns true only while the exact child entry is live under that owner. + */ isOwnedBy(id: SessionId, owner: Agent): boolean + +/** + * All live agents, in registration order. + * @returns a fresh array; mutating it does not affect the registry. + */ list(): Agent[] + +/** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ 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:204`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt and pre-step notices. ```ts cordis-catalog +/** + * Ask the composed answerers to decide one readonly same-process request. + * The service borrows the request, agent, session, and live signal directly. + * The request requires an open turn because the audit pair must be enclosed + * by the durable log's commit/replay boundary; an idle ask rejects before + * appending anything. The answerer phase always produces an outcome: an + * aborted signal yields `'cancelled'`, a missing or throwing answerer yields + * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is + * normalized to `'unavailable'`. A failure that prevents either audit append + * from committing still rejects because returning an unlogged decision would + * violate the pair. Session contains post-commit observer failures, so an + * authoritative append cannot reject the request or suppress its matching + * audit event. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @returns the closed outcome; `'allowed-once'` is the only grant. + * @throws when no turn is open or either audit event fails before the session + * append commit point. + */ async request(req: ApprovalRequest): Promise ``` @@ -82,8 +242,27 @@ Implementations must honor these semantics: - Disposal kills all running background processes and awaits their exit. ```ts cordis-catalog +/** + * Apply implementation-owned defaults and caps to a request before execution. + * @param request - the caller's request; omitted fields get this + * implementation's defaults, capped fields are clamped. + * @returns the fully-specified spec to hand to {@link run}/{@link start}. + */ abstract resolve(request: BashExecRequest): BashExecSpec + +/** + * Run a command in the foreground; resolves when it finishes. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the outcome; nonzero exits, timeout kills, and abort kills + * resolve with a descriptive result rather than reject. + */ abstract run(spec: BashExecSpec): Promise + +/** + * Start a background process and return its handle immediately. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the live process handle (reads, kill, quiescence promise). + */ abstract start(spec: BashExecSpec): BashProcess ``` @@ -96,8 +275,25 @@ Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/inde Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. ```ts cordis-catalog +/** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ register(contributor: BashEnvContributor): () => void + +/** + * Build the trusted `DSH_*` snapshot for one bash tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ collect(execution: ToolExecution): DshEnvironment + +/** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ list(): BashEnvVariableInfo[] ``` @@ -110,6 +306,15 @@ Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-ba Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Execute one program against the request's bindings and capture what it + * emitted. See the class doc for the resolution contract (error is a result + * field; rejection means seam misuse only). + * @param request - the program, its bindings, and the abort signal; the + * request carries everything the runtime acts on, with no hidden defaults. + * @returns the run's outcome: completion value (when transferable), the + * ordered log capture, and the failure (if any). + */ abstract run(request: CodeRunRequest): Promise ``` @@ -122,7 +327,40 @@ Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/co Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog +/** + * Check token pressure and compact if the conversation is too large. + * Estimate the next request, including its session prefix, derived history, + * and system prompt. Above threshold, compact a head-anchored range ending at + * a balanced tool boundary and reconsolidate any prior automatic checkpoint. + * Return `null` when no compaction is needed or an open tail leaves no safe + * cutoff. A single oversized retained unit or prefix cannot be repaired here. + * + * @param agent - agent context owning the session surface and model options. + * @param fullSystemPrompt - assembled system prompt, counted toward the estimate. + * @param sessionPrefix - the instance's composed session prefix, counted toward the + * estimate. + * @param signal - cancellation signal; model-backed implementations must forward it. + * @returns the compaction result, or `null` if no compaction was needed. + */ abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise + +/** + * Forcibly compact a range of surface nodes into a single summary node. + * `start` and `end` name an inclusive span by surface position, not numeric seq + * order; replacements can make visible seqs non-monotonic. Both edges must be + * balanced so assistant tool calls remain paired with their results. A model- + * backed implementation forwards cancellation and rejects active, missing, + * reversed, or unbalanced ranges. The target session is `agent.session`. + * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} + * for the edge checks. + * + * @param start - first surface seq, inclusive. + * @param end - last surface seq, inclusive. + * @param agent - context whose session is mutated and whose routing options guide summarization. + * @param signal - optional cancellation; model-backed implementations must forward it. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the appended event seqs, summary, replaced range, and token accounting. + */ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` @@ -135,13 +373,90 @@ Source: [`packages/compact/compact/src/index.ts:38`](../../packages/compact/comp Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. ```ts cordis-catalog +/** + * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a + * remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence + * async even though the local backend only normalizes + realpaths. + * + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - optional cwd override and cancellation signal. + * @returns the stable target; the same file yields the same `targetKey`. + */ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise + +/** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Return path metadata without following the final path component when it is a + * symbolic link. This is intentionally path-shaped, not target-shaped: + * {@link resolve} follows symlinks to produce the stable identity used by + * normal reads/writes, while `lstat` lets a consumer reject the path itself + * before that follow happens. + * + * `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is + * absent. + * @param path - the path to inspect; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent path. + */ abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise + +/** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ abstract readText(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Stream the whole regular text file as decoded text chunks (same text + * semantics as {@link readText}, for large files). The backend owns + * cross-chunk UTF-8 decoding and binary rejection so the policy layer never + * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. + */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> + +/** + * List direct children of a directory in stable name order. Returns resolved + * child targets plus cheap metadata only; never reads file contents. + * @param target - the resolved directory target. + * @param signal - aborts the listing. + * @returns one entry per direct child, in stable name order. + */ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Atomically create or replace UTF-8 text. `expected` guards intent and + * staleness; omission allows unconditional overwrite. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the write produced. + */ abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise + +/** + * Atomically edit literal text. When supplied, the version guard is checked + * before matching so stale content reports `FS_STALE_VERSION`; omission edits + * the current content without a freshness precondition. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. + */ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` @@ -154,9 +469,39 @@ Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. ```ts cordis-catalog +/** + * Register an adapter for the given provider routes. Throws `LlmError` with code + * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing). + * Disposed with the fiber. + * @param providers - every provider route this adapter should serve. + * @param adapter - the adapter that streams calls for those providers. + * @returns the disposer that unregisters all of them. + */ registerAdapter(providers: string[], adapter: LlmAdapter): () => void + +/** + * Describe provider routes with a registered adapter. + * @returns detached provider metadata in registration order. + */ listProviders(): LlmProviderInfo[] + +/** + * Discover models advertised by one registered provider. Catalog membership + * is advisory and never changes routing or request validation. + * @param provider - registered provider route to inspect. + * @returns detached model metadata in adapter-preferred order. + */ async listModels(provider: string): Promise + +/** + * Stream one model call as raw chunks (token-level deltas). Throws + * `LlmError` with code `NO_ADAPTER` if no adapter is registered for + * `options.provider`. Replay state is retained only when the same adapter + * instance owns its historical provider and the target provider. Dispatches + * through the `llm/stream` waterfall. + * @param options - the full request; `options.provider` selects the adapter. + * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. + */ stream(options: GenerateOptions): AsyncIterable ``` @@ -169,9 +514,38 @@ Source: [`packages/llm/llm/src/index.ts:94`](../../packages/llm/llm/src/index.ts Owns the deployment's permission presets and their write path. Requires a confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error. ```ts cordis-catalog +/** + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. + * @returns the effective preset name, or `custom` when nothing matches. + */ current(events: readonly SessionEvent[]): string + +/** + * Resolve a preset's knob bundle. + * @param name - the preset name to resolve. + * @returns the configured bundle. + * @throws when `name` is not in the table. + */ resolve(name: string): PresetSpec + +/** + * Build the client option for a table entry or {@link CUSTOM_PRESET}. A + * missing label falls back to the table key. + * @param name - a table key, or `custom`. + * @returns the option a client renders. + * @throws when `name` is neither a table key nor `custom`. + */ optionOf(name: string): PresetOption + +/** + * Record a changed preset, then update each changed knob through its own + * setter. Selecting the effective preset again appends nothing. + * @param session - the session the switch belongs to. + * @param name - the preset to switch to; unknown names throw. + */ set(session: Session, name: string): void ``` @@ -184,6 +558,17 @@ Source: [`packages/ui/permission/src/index.ts:94`](../../packages/ui/permission/ Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end. ```ts cordis-catalog +/** + * Wrap `argv` so it executes confined under `policy` on this host; the + * caller spawns the returned argv in place of its own. + * @param argv - the exact argv the caller is about to spawn (program plus + * arguments), NOT a shell string — a shell-shaped consumer passes + * `['bash', '-c', command]`. + * @param policy - the file-effect policy this execution runs under, + * carried per call (see {@link SandboxPolicy}). + * @returns the argv to spawn instead, plus the enforcement completeness + * the selected backend achieves for it. + */ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` @@ -196,10 +581,49 @@ Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/san Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. ```ts cordis-catalog +/** + * Resolve this backend's independent local artifact for a session without + * reading, creating, flushing, or otherwise materializing it. Backends such + * as SQLite that do not own one artifact per session return `undefined`. + * @param meta - the immutable session header whose artifact is requested. + * @returns the backend-specific absolute location, when one exists. + */ abstract locate(meta: SessionHeader): SessionLocation | undefined + +/** + * Register a new session's metadata. A backend MAY defer the physical write + * until the first {@link append} (lazy materialization), in which case a + * created-but-never-appended session is absent from {@link list} + * — abandoned sessions leave nothing behind. + * @param meta - the immutable header (id, version, cwd, lineage) to record. + */ abstract create(meta: SessionHeader): Promise + +/** + * Durably persist a batch of events (called from the write-behind drain at + * the `session/flush` checkpoint). Honors the append-only and contiguous-seq + * contracts: the first event's `seq` MUST equal the stored next-seq (after + * `load` has durably closed any interrupted turn). Rejects non-JSON- + * serializable `event.data` with an error naming the offending event type. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order. + */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise + +/** + * Load a header and balanced contiguous log. A complete interrupted final + * turn is preserved and durably closed with missing tool errors plus any open + * step and turn boundaries; only a torn final record is discarded. Unknown + * versions and corruption in the committed prefix reject. + * @param id - the persisted session to reload. + * @returns the header and a log ending on a balanced `turn/end`. + */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + +/** + * Lightweight listing from metadata, without a full-log parse. + * @returns one header per materialized session. + */ abstract list(): Promise ``` @@ -212,10 +636,40 @@ Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../ Live-preferred logical-corpus exact-read and relationship-tracing service. ```ts cordis-catalog +/** + * List the complete logical corpus using live-preferred records. + * @returns deterministic newest-first cloned session records. + */ listSessions(): Promise + +/** + * List lightweight raw-log event records for one logical session. + * @param sessionId - live-preferred session id to read. + * @returns event records in ascending seq order. + */ async listEvents(sessionId: SessionId): Promise + +/** + * Trace known ancestry and descendants from one corpus observation. + * @param sessionId - logical session id to trace. + * @returns a complete lineage or an explicit unresolved parent boundary. + * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. + */ async traceSession(sessionId: SessionId): Promise + +/** + * Trace one event's direct positional and provenance relationships. + * @param request - target session id and event seq. + * @returns direct links plus the target's positional replacement chain. + * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. + */ async traceEvent(request: SessionEventTraceRequest): Promise + +/** + * Read one full event plus a bounded raw-log context window. + * @param request - target session/seq and context sizes. + * @returns cloned target and neighboring events. + */ async readEvent(request: SessionEventReadRequest): Promise ``` @@ -228,13 +682,119 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. ```ts cordis-catalog +/** + * Create a session owned by the calling fiber: disposing that fiber stops + * event notification and removes the session from the store. `options.seed` + * populates the session with a copy of those events (replay/fork); + * `options.meta` attaches creation metadata (validated absolute `cwd`, + * `parentSession` lineage) as the immutable {@link SessionHeader} (the store + * fills `version`/`id`/`createdAt`). + * + * For an agent whose session must be torn down IN ORDER with its loop (so the + * loop's final flush is captured before the store attachment ends), do NOT use this + * — fold the session lifecycle into the agent's own effect via + * {@link prepare} + {@link enter} + {@link announce} (see + * `dsh-agent-loop`'s creation transaction). + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the live session, already entered and announced. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path (storage backends key directories off it). + */ create(id?: SessionId, options?: CreateSessionOptions): Session + +/** + * Build a session WITHOUT entering it into the store — validate the id/cwd and + * construct the {@link Session} (with its immutable {@link SessionHeader}). + * Pairs with {@link enter} + {@link announce}: a caller that owns a composite + * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE + * effect so a fiber unload tears the session + agent down as a single ORDERED + * chain rather than as racing sibling effects — which would remove the publication hooks + * before the loop's closing `session/flush`, dropping the closing events. + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the constructed session, NOT yet in the store. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path. + */ prepare(id?: SessionId, options?: CreateSessionOptions): Session + +/** + * Enter a {@link prepare}d session into the store: install the module-private + * append publication hooks and add it to the store. Returns the DETACH + * disposer (hooks + store removal). Does NOT emit `session/created` — + * the caller yields this disposer inside its effect and THEN calls + * {@link announce}, so a throwing `session/created` listener rolls the attach + * back instead of leaking it. + * + * Re-checks the id for a duplicate: `prepare` and `enter` are public + * cross-package primitives and a caller may interleave arbitrary work (or + * another create) between them, so a stale prepared session must NOT overwrite + * a live store entry of the same id — its detach disposer would later delete + * the REAL session. The {@link create} convenience and the agent factory call + * the two back-to-back so they never trip this, but the public seam cannot + * assume that. + * + * @param session - a {@link prepare}d session not yet in the store. + * @returns the detach disposer (publication hooks + store removal). When called from + * a synchronous `session/created` listener, removal and disposal wait until + * that creation dispatch unwinds. + * @throws if a session with this id is already in the store. + */ enter(session: Session): () => void + +/** Emit `session/created` exactly once for an {@link enter}ed session (with + * the carrier {@link enter} captured). Separate from {@link enter} so the + * caller can yield the detach disposer first (rollback safety — see + * {@link enter}). + * @param session - the entered session to announce to listeners. + * @throws if the session is not live or its announcement already began, + * including a reentrant call from a creation listener. */ announce(session: Session): void + +/** + * Dispatch the awaited `session/flush` durability checkpoint for `session`, + * with the carrier captured at {@link enter}. THE flush entry point: the + * store owns the carrier, so callers (the loop's turn-end checkpoint, idle + * injection, teardown drains) must come through here rather than dispatch a + * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the + * scoped-dispatch invariant can pin it. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when every flush listener has settled; after all settle, + * rejects with the first registered listener failure if any listener failed. + */ async flush(session: Session): Promise + +/** + * Look up a live session. + * @param id - the session id to look up. + * @returns the session, or undefined when no live session has that id. + */ get(id: SessionId): Session | undefined + +/** + * All live sessions, in creation order. + * @returns a fresh array; mutating it does not affect the store. + */ list(): Session[] + +/** + * Create a live child session from a turn-enclosed prefix of a live source. + * `boundary` is an inclusive source event seq; omitted means the source's + * current last event. A non-empty selected slice must end at `turn/end`. + * + * @param source - Live source session object or id. + * @param boundary - Inclusive source event seq to fork through; omitted means + * the source's current last event, and omitted on an empty source forks an + * empty child. + * @param childSessionId - Optional child session id; omitted delegates to + * `SessionStore`'s id policy. + * @returns The created live child session. + */ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` @@ -245,9 +805,42 @@ Source: [`packages/core/session/src/index.ts:577`](../../packages/core/session/s Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. ```ts cordis-catalog +/** + * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and + * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters + * the provider and invalidates catalog caches. + * @param provider - the provider to register by `provider.name`. + * @returns the exact Cordis effect disposer that unregisters this provider; + * composite effects may yield it directly to preserve teardown ordering. + */ registerProvider(provider: SkillProvider): () => void + +/** + * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which + * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and + * receives a no-op disposer so it cannot remove the winner. + * @param skill - the complete skill definition to expose for discovery. + * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. + */ register(skill: SkillRegistration): () => void + +/** + * List model-invocable skill summaries for a workspace. Lookup options and + * provider candidates are readonly same-process values borrowed throughout + * discovery. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries, excluding skills disabled for model invocation. + */ async list(options: SkillLookupOptions = {}): Promise + +/** + * Load and validate the winning candidate, passing its opaque discovery locator back to the + * provider. Cancellation is rechecked after selection, including cache hits, and raced against + * loading so an uncooperative provider cannot hang the caller. + * @param name - kebab-case skill name. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns the full skill, including body content, or `undefined`. + */ async get(name: string, options: SkillLookupOptions = {}): Promise ``` @@ -264,6 +857,11 @@ Semantics every implementation must honor: - `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable); the caller decides how to degrade (the spill policy treats a rejection as best-effort and keeps the inline result). ```ts cordis-catalog +/** + * Persist `input.content` to a session-scoped spill artifact. + * @param input - the owner, provenance, suggested name, and full text to save. + * @returns the saved artifact's {@link SpillRef}; rejects on a storage failure. + */ abstract saveText(input: SaveTextSpill): Promise ``` @@ -274,9 +872,37 @@ Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/ Named provider registry and capability-checked start surface. ```ts cordis-catalog +/** + * Register a provider under its name. Registration is effect-scoped and HMR + * safe; removing a provider blocks new starts but does not revoke runs that + * were already returned to their holders. + * @param provider - the trusted provider implementation. + * @returns the exact Cordis effect disposer. + */ registerProvider(provider: SubagentProvider): () => void + +/** + * Look up a provider by name. + * @param name - the provider name. + * @returns the provider, or undefined when absent. + */ getProvider(name: string): SubagentProvider | undefined + +/** + * List registered provider names in insertion order. + * @returns the registered names. + */ list(): string[] + +/** + * Establish a ready child on the named provider. Capability and semantic + * checks run before delegation. Provider ownership lasts until its promise + * fulfills; a rejection therefore has no run for the caller to dispose and + * emits no run lifecycle events. + * @param name - the provider to use. + * @param request - child prompt, parent, signal, and optional capabilities. + * @returns the ready holder-owned run. + */ async start(name: string, request: SubagentStartRequest): Promise ``` @@ -287,9 +913,42 @@ Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/ Registry service for the prompt inputs assembled before each model step. ```ts cordis-catalog +/** + * Register an ordered prompt section in the calling context's scope. A scoped + * section shadows a global section with the same name; duplicates within one + * layer and non-finite orders throw. Registration and disposal emit + * `system-prompt/change`. + * @param section - the section to register. + * @returns the exact Cordis effect disposer. + */ section(section: PromptSection): () => void + +/** + * Register a tool-schema provider in the calling context's scope. Global and + * matching scoped providers both contribute; returning the reserved + * {@link TOOL_ORDER_REST} name makes assembly fail. + * @param provider - evaluated for each assembly with its context. + * @returns the exact Cordis effect disposer. + */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void + +/** + * Register a prompt variable in the calling context's scope. Scoped values + * shadow globals; invalid or duplicate names throw. A provider may return + * `undefined`, but rendering a section that references that value then fails. + * @param name - the `[a-z][a-z0-9_]*` reference name. + * @param provider - evaluated for each assembly. + * @returns the exact Cordis effect disposer. + */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void + +/** + * Assemble global and scoped providers, detach tool parameters, apply + * canonical ordering, then run the assembly waterfall. Scoped sections and + * variables shadow globals; the returned waterfall value is authoritative. + * @param context - the optional scope and plugin-defined assembly fields. + * @returns the authoritative post-waterfall assembly. + */ async assemble(context: AssembleContext = {}): Promise ``` @@ -300,13 +959,83 @@ Source: [`packages/core/system-prompt/src/index.ts:209`](../../packages/core/sys The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts. ```ts cordis-catalog +/** + * Preflight access, validation, and owner cleanup before starting and + * atomically registering work. A throwing starter leaves nothing registered; + * after it returns, registration cannot fail. Settlement records the outcome, + * notifies listeners, and releases waiters. + * @param spec - task identity, owner, and synchronous starter. + * @returns the registry-issued `-N` id. + */ start(spec: TaskStart): TaskId + +/** + * List caller-owned and unowned tasks in registration order without exposing + * another session's labels. + * @param caller - reading agent; a non-agent caller sees only unowned tasks. + * @returns fresh snapshots. + */ list(caller?: Agent): TaskSnapshot[] + +/** + * Return a non-consuming snapshot without changing its read cursor or notice + * state. Throws for an unknown or foreign task. + * @param id - task to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ get(id: TaskId, caller?: Agent): TaskSnapshot + +/** + * Read the next stream delta, or the idempotent final output after settlement. + * A terminal read marks the task reported. Throws for an unknown or foreign + * task. + * @param id - task to read. + * @param caller - reading agent checked against the owner. + * @returns output text and the post-read snapshot. + */ read(id: TaskId, caller?: Agent): TaskRead + +/** + * Request cancellation, then mark the task stopping and reported. A producer + * throw propagates without changing task state. Throws for an unknown or + * foreign task. + * @param id - task to cancel. + * @param caller - killing agent checked against the owner. + * @param reason - logged reason forwarded to the producer. + * @returns `requested` for live work, otherwise `already-finished`. + */ kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' + +/** + * Wait for settlement or timeout without cancelling the task. Caller abort + * rejects only while the task is live; after settlement it returns the + * terminal snapshot so a notice suppressed for this waiter is still delivered. + * Timed-out and aborted waits detach their resolvers. Throws for invalid, + * unknown, or foreign input. + * @param id - task to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param caller - waiting agent checked against the owner. + * @param signal - optional cancellation of the wait itself. + * @returns snapshot at settlement or timeout. + */ async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise + +/** + * Register an effect-scoped completion listener. Each listener is contained; + * returned promises are observed but not awaited. No listener runs after + * service disposal. + * @param listener - receives each terminal snapshot and its exact owner. + * @returns disposer that unregisters the listener. + */ onTaskDone(listener: TaskDoneListener): () => void + +/** + * Attach an effect-scoped surface that can read and stop tasks. {@link start} + * refuses work while none is attached. + * @param name - diagnostic label; duplicate names remain independent. + * @returns disposer that detaches this surface. + */ attachSurface(name: string): () => void ``` @@ -319,7 +1048,29 @@ Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/ Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog +/** + * Measure current request pressure and surface through the durable tail. + * + * Provider usage is reused only when the latest successful call's canonical + * request envelope matches `requestHeader` and its total is no lower than + * that call's full heuristic anchor; otherwise the complete envelope and + * surface are heuristically repriced. + * + * `requestHeader` affects request pressure only; surface fields always + * describe the current session surface. Every call clones those positional + * nodes, so measurement is O(surface). + * + * @param session - session to replay through its current durable tail. + * @param requestHeader - optional effective request envelope replacing the latest logged header. + * @returns a detached deeply immutable pressure and surface measurement. + */ measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement + +/** + * Heuristically price one model-visible message. + * @param message - message to price without mutation. + * @returns content and role-framing tokens under the fixed service heuristic. + */ estimateMessage(message: Message): number ``` @@ -332,12 +1083,72 @@ Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-m Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Register globally or in the calling agent scope. Scoped tools shadow + * globals; duplicates within one layer and the reserved `run_code` name fail. + * @param definition - the tool schema, execution, and optional presentation functions. + * @returns the exact disposer that unregisters the tool. + */ register(definition: ToolDefinition): () => void + +/** + * Restrict global tools for the calling agent scope. Empty filters, unknown + * names, scope-local names, and reserved transport names fail. Restrictions + * intersect; scoped registrations remain visible. + * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). + * @returns the exact disposer that lifts this restriction. + */ restrict(filter: ToolRestriction): () => void + +/** + * Register a monotonic guard after the extensible `tools/pre-execute` + * waterfall. A plain-context guard applies globally; one registered through + * `agent.ctx` applies only to that agent. Any matching guard may deny by + * returning a reason, while no guard can force-allow a call another guard + * denied. The exact effect disposer is returned for ordered ownership and + * HMR cleanup. + * @param guard - synchronous check; a returned string denies the execution. + * @returns the exact disposer that unregisters the guard. + */ guard(guard: ToolGuard): () => void + +/** + * Look up a tool as one scope sees it (scoped + * shadows global; a restricted-away global reads as absent). Presenters pass + * the calling agent so the rendered card matches the definition that + * actually executed. + * @param name - the tool name as registered. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns the definition the scope resolves, or undefined when none is visible. + */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined + +/** + * Project visible definitions onto the allowlisted model-facing schema fields, + * excluding execution and presentation callbacks. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns one deep-cloned schema per visible tool. + */ schemas(scope?: ScopeKey): ToolSchema[] + +/** + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. + */ executionMode(exec: ToolExecutionInput): ToolExecutionMode + +/** + * Execute through pre-policy, guards, around-dispatch, post-policy, and final + * notification. Tool and listener failures resolve as materialized error + * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is + * the same lossless, frozen snapshot final observers receive. + * @param exec - the typed same-process call input. The registry assigns its + * correlation token before policy begins. + * @returns the materialized final result. + */ async execute(exec: ToolExecutionInput): Promise ``` @@ -350,7 +1161,20 @@ Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/i `ctx.userInteraction`: one active UI provider plus an `ask()` surface. ```ts cordis-catalog +/** + * Register the UI provider. Only one provider may be active in a context. + * + * @param provider UI-side implementation that collects answers. + * @returns Disposer that unregisters this provider. + */ registerProvider(provider: UserInteractionProvider): () => void + +/** + * Ask the active UI provider and wait for the user's answer. + * + * @param request Questions, owner agent, and abort signal. + * @returns The answer chosen or typed by the human. + */ async ask(request: AskUserQuestionRequest): Promise ``` @@ -370,9 +1194,43 @@ Selection semantics (resolved at execution time, never order-dependent): - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. ```ts cordis-catalog +/** + * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerSearchProvider(provider: WebSearchProvider): () => void + +/** + * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for fetch. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerFetchProvider(provider: WebFetchProvider): () => void + +/** + * Run one search through the selected provider. Resolves the provider at call + * time with the selection rules above; throws {@link WebError} when the + * capability cannot run. The seam enforces `request.maxResults` on the result: + * if the provider over-returns, `sources[]` is truncated and `truncated` set. + * @param request - the query plus result-shaping options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the provider's results, capped to `request.maxResults`. + */ async search(request: WebSearchRequest, signal?: AbortSignal): Promise + +/** + * Retrieve one URL through the selected provider. Resolves the provider at + * call time with the selection rules above; throws {@link WebError} when the + * capability cannot run. A non-2xx response is a result, not a throw. + * @param request - the URL plus retrieval options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the retrieval outcome; non-2xx responses resolve descriptively. + */ async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise ``` @@ -383,6 +1241,12 @@ Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts Workflow execution seam. Invalid requests throw before publication; a live run is holder-owned, its result never rejects, cancellation and disposal are bounded, and disposal waits for child cleanup within that bound. Lifecycle listener failures are contained, and `workflow/end` fires exactly once as the result settles. ```ts cordis-catalog +/** + * Parse and execute a workflow script. + * @param request - the script, its `args`, the parent agent, and an + * optional cancel signal. + * @returns the live run; its `result` resolves when the script settles. + */ abstract start(request: WorkflowStartRequest): WorkflowRun ``` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9efe04e63c..ac2440cd7c 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -403,49 +403,9 @@ 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. -## Agent execution context +## Initiating Agent -`AgentExecution` is the process-local ambient frame established around a concrete driver's lifetime. It holds the exact Agent object rather than duplicating Session or step state; ambient presence is neither liveness proof nor authorization. - -Source: [`packages/core/agent-execution/src/types.ts`](../../packages/core/agent-execution/src/types.ts) - -```ts type-equiv -interface AgentExecution { - readonly agent: Agent -} -``` - -The mandatory service reads, requires, establishes, or explicitly clears that frame. `run()` preserves the operation's exact synchronous value or Promise. - -Source: [`packages/core/agent-execution/src/index.ts`](../../packages/core/agent-execution/src/index.ts) - -```ts type-equiv -interface AgentExecutionService { - /** - * Read the active execution without requiring one. - * @returns the inherited execution, or `undefined` outside/inside a cleared boundary. - * @throws when this service instance has been disposed. - */ - current(): AgentExecution | undefined - - /** - * Read the active execution and fail when no boundary is active. - * @returns the inherited execution. - * @throws when no execution is active or this service instance has been disposed. - */ - require(): AgentExecution - - /** - * Run an operation inside an execution boundary. Passing `undefined` clears - * an inherited execution; the exact synchronous value or Promise is returned. - * @param execution - execution to inherit, or `undefined` for a clearing boundary. - * @param operation - synchronous or asynchronous operation to invoke. - * @returns the exact value returned by `operation`. - * @throws when this service is closing/disposed, or when `operation` throws. - */ - run(execution: AgentExecution | undefined, operation: () => T): T -} -``` +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 diff --git a/docs/module-graph.md b/docs/module-graph.md index c65b31a6fc..edb6034216 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -22,7 +22,6 @@ flowchart TD end subgraph group_core["packages/core"] pkg_agent["agent"] - pkg_agent_execution["agent-execution"] pkg_agent_loop["agent-loop"] pkg_scope["scope"] pkg_session["session"] @@ -194,7 +193,6 @@ flowchart TD pkg_llm_replay --> pkg_session pkg_sandbox_local --> pkg_llm pkg_sandbox_local --> pkg_sandbox - pkg_agent_execution --> pkg_agent pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_timeout pkg_compact_basic --> pkg_agent @@ -248,7 +246,6 @@ flowchart TD pkg_permission --> pkg_session pkg_permission --> pkg_user_approval pkg_agent_loop --> pkg_agent - pkg_agent_loop --> pkg_agent_execution pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session @@ -311,7 +308,6 @@ flowchart TD pkg_hooks_codex --> pkg_session_persistence pkg_hooks_codex --> pkg_tools pkg_agent_loop_testkit --> pkg_agent - pkg_agent_loop_testkit --> pkg_agent_execution pkg_agent_loop_testkit --> pkg_llm pkg_agent_loop_testkit --> pkg_session pkg_agent_loop_testkit --> pkg_system_prompt @@ -390,7 +386,6 @@ flowchart TD pkg_tui --> pkg_tools pkg_tui --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent - pkg_agent_spine_demo --> pkg_agent_execution pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_home pkg_agent_spine_demo --> pkg_invariants @@ -481,7 +476,6 @@ flowchart TD | [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`agent-execution`](../packages/core/agent-execution) | `core` | [`agent`](../packages/core/agent) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) | @@ -498,7 +492,7 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`agent-execution`](../packages/core/agent-execution), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`home`](../packages/util/home), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -510,7 +504,7 @@ flowchart TD | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | -| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`agent-execution`](../packages/core/agent-execution), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 73d395af4b..16a5563532 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -163,7 +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 | -| [Agent execution context over AsyncLocalStorage](implemented/architecture/2026-07-15-agent-execution-context.md) | 2026-07-15 | +| [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-execution-context.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md deleted file mode 100644 index 2ea88cddf7..0000000000 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.md +++ /dev/null @@ -1,61 +0,0 @@ -# RFC: Agent execution context over AsyncLocalStorage - -Status: implemented - -English | [中文](2026-07-15-agent-execution-context.zh.md) - -## Problem - -The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. - -Deep process-local infrastructure 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 is mandatory control infrastructure rather than optional model-visible context. - -## Decision - -`@deepseek-ai/dsh-agent-execution` provides the mandatory `ctx.agentExecution` service using Node `AsyncLocalStorage`. The named `AgentExecution` frame contains only the exact Agent object; the [core-data catalog](../../../core-data-structures/core.md#agent-execution-context) owns the literal frame and service types. - -`current()` reads optionally, `require()` throws `no agent execution context is active`, and `run()` preserves the operation's exact synchronous value or Promise. `run(undefined, operation)` establishes a clearing boundary for work that must not inherit an Agent. Session remains derived as `execution.agent.session`; turn, step, tool call, `signal`, model, `cwd`, sandbox, and authorization stay with their existing owners. - -`AgentLoop` injects the service and wraps each concrete driver's complete `runLoop` lifetime in `agentExecution.run({ agent }, ...)`. Concurrent drivers therefore receive independent stores, a child driver shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's 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. - -The provider uses an ordered composite effect. Teardown first rejects new boundaries, then removes the service and awaits injected dependents such as AgentLoop, then waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. `current()` and `require()` remain usable through a retained in-flight service reference while that drain runs; after disposal, retained calls throw `agent execution service is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting is required in addition to Cordis dependency ordering. - -`run()` does not own detached work: provider drain tracks only the Promise returned by `operation`. Asynchronous resources created inside the 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 `run(undefined, 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.agentExecution.require().agent.session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam. - -This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. - -## Verification - -Service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, overlapping, nested, and cleared boundaries, restoration after throws or rejection, drain ordering, and retained-reference errors. AgentLoop integration pins concurrent and nested drivers, agentless calls, missing-provider activation, service restart, and provider/root teardown. Composition, module-graph, build, and runtime-closure checks keep the provider wired through the default bundle, SDK spine, Python runtime closure, and direct AgentLoop harnesses. - -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 `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. - -**Store a complete mutable runtime frame.** Agent, Session, inbox, cancellation, turn, step, tool execution, and persistence already have authoritative owners. Duplicating them would create stale snapshots and another lifecycle. A named frame makes the execution-context boundary explicit without duplicating owner 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 stays inactive when the provider is absent, and HMR/root disposal reaches quiescence before ALS is disabled. - -The dependency is implicit in function signatures and carries a 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 provider-owned instances; the service state guard prevents a later `run()` from re-entering the instance after disposal. - -The frame deliberately omits turn, step, `signal`, `cwd`, sandbox, and authorization. A real consumer that cannot use existing explicit fields must justify any refinement separately; a stale copied field may at most mislabel telemetry, never grant control. diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md b/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md deleted file mode 100644 index 1c4e0ba9c3..0000000000 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.zh.md +++ /dev/null @@ -1,61 +0,0 @@ -# RFC: 基于 AsyncLocalStorage 的 Agent 执行上下文 - -Status: implemented - -[English](2026-07-15-agent-execution-context.md) | 中文 - -## 问题 - -Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 - -进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体属于必需的控制基础设施,而非模型可见的可选上下文。 - -## 决策 - -`@deepseek-ai/dsh-agent-execution` 使用 Node `AsyncLocalStorage` 提供必需的 `ctx.agentExecution` 服务。命名的 `AgentExecution` 帧仅包含同一个 Agent 对象;[核心数据目录](../../../core-data-structures/core.md#agent-execution-context)是帧与服务字面类型定义的真源。 - -`current()` 用于可选读取,`require()` 抛出 `no agent execution context is active`,`run()` 保留操作返回的同步值或 Promise 本身。`run(undefined, operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `execution.agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。 - -`AgentLoop` 注入该服务,并用 `agentExecution.run({ agent }, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 - -隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 - -提供方使用有序复合 effect。teardown 会先拒绝新边界,再移除服务并等待 AgentLoop 等注入方排空,随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。排空期间,进行中代码可通过保留的服务引用继续调用 `current()` 和 `require()`;dispose 后,保留引用会抛出 `agent execution service is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外还必须统计活动边界。 - -`run()` 不负责管理脱离返回链的工作:提供方排空只跟踪 `operation` 返回的 Promise。边界内创建的异步资源会继承其存储,直到自身结束或 ALS 被禁用;所属 seam 必须显式停止未纳入返回 Promise 的工作。Agent 所有前台工作会把完整生命周期纳入返回值,并保留显式取消契约。无关的定时器、队列和部署基础设施在 `run(undefined, operation)` 下启动;队列、worker、进程和协议边界必须序列化身份,不能期待 ALS 传播。 - -宿主感知的传输层可以从 `ctx.agentExecution.require().agent.session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 - -本决策扩展 [Agent 注册作用域契约](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 - -## 验证 - -服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、缺少提供方时的激活行为、服务重启,以及提供方或根 Context 的销毁流程。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 都装载提供方。 - -只有测试替身形式的宿主感知传输层消费隐式身份;它在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空 `operation` 返回的 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 - -## 考虑过的替代方案 - -**在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 - -**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。 - -**保存完整的可变运行时帧。** Agent、会话、inbox、取消、轮次、步骤、工具执行和持久化已经有各自的真源。重复保存会产生陈旧快照和另一套生命周期。命名帧能够明确标识执行上下文边界,而不重复保存归属方状态。 - -**包含步骤级 `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 替换提供方所拥有的实例尤为重要;服务状态守卫会阻止 dispose 后通过后续 `run()` 重新进入该实例。 - -该帧有意省略轮次、步骤、`signal`、`cwd`、沙箱和授权。若真实消费方无法使用现有显式字段,必须另行论证扩展;陈旧字段最多只能误标遥测数据,绝不能授予控制权。 diff --git a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml similarity index 64% rename from docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml rename to docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 371884096a..9d9e11522f 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-execution-context.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-agent-execution-context.md: 2ea88cddf7a686713157c77ce1d7520082a9da28 -2026-07-15-agent-execution-context.zh.md: 1c4e0ba9c3fa787dfa816eff4c5d04cff5c2f7b1 +2026-07-15-agent-initiator-scope.md: 480bb56f811567406e964ca94fca04fd3d4ace62 +2026-07-15-agent-initiator-scope.zh.md: 29ea3a47f42d607a4584b888ace232de124e7598 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..480bb56f81 --- /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; 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 shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's 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()`. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while that 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, overlapping, nested, and cleared boundaries, restoration after throws or rejection, 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..29ea3a47f4 --- /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`,不引入只有一个字段的帧;[核心数据目录](../../../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` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `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()`。排空期间,进行中代码可通过保留的服务引用继续调用 `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 拒绝后的恢复、排空顺序及保留引用的错误。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/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index e243270e29..a5e69b85ea 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -9,7 +9,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -49,7 +48,6 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(SystemPrompt, { persona: PERSONA }) await harness.plugin(ToolRegistry, { mode: 'code' }) await harness.plugin(AgentRegistry) - await harness.plugin(AgentExecutionProvider) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) await harness.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) @@ -68,7 +66,6 @@ async function workspaceCodeModeHarness(): Promise { await harness.plugin(LocalFileSystem, { cwd: '/' }) await harness.plugin(ToolFs) await harness.plugin(WorkspaceContext, { maxBytes: 65536 }) - await harness.plugin(AgentExecutionProvider) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] }) await harness.plugin(WorkerCodeRuntime, {}) diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index b6ff240cf1..7f704c838a 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -36,7 +36,6 @@ "devDependencies": { "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index 3710f77547..0c11da8ed4 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -9,7 +9,6 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context' @@ -44,7 +43,6 @@ async function harness(): Promise<{ ctx: Context; agent: Agent }> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) await ctx.plugin(WorkspaceContext, { maxBytes: 65536 }) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, { models: [{ id: 'deepseek-v4-flash' }] }) const handle = await ctx.agents.create({ diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index f161ac0bc9..2ff2067cf5 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -8,7 +8,6 @@ import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { type Agent, type HookContext } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { @@ -1587,7 +1586,6 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 4ff01c81ae..5d99506c8a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -52,15 +52,6 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ - { - key: 'agentExecution', - summary: 'Ambient Agent identity within one process-local asynchronous chain.', - methods: [ - 'current(): AgentExecution | undefined', - 'require(): AgentExecution', - 'run(execution: AgentExecution | undefined, operation: () => T): T', - ], - }, { key: 'agentLoop', summary: 'Concrete agent factory and driver service.', @@ -72,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', @@ -552,10 +547,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Agent', declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: InjectOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', }, - { - name: 'AgentExecution', - declaration: 'export interface AgentExecution {\n readonly agent: Agent;\n}', - }, { name: 'AgentFactory', declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', diff --git a/packages/core/README.md b/packages/core/README.md index 3c0ad38819..fe98a78c22 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -8,12 +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-execution/` | Process-local ambient Agent identity for asynchronous driver work | `ctx.agentExecution` | +| `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-execution` is mandatory control infrastructure for concrete loops and deep process-local consumers. `agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. Extension plugins depend on `agent` and, when they need ambient identity, `agent-execution`; they never depend 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 + agent-execution + 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. +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-execution/README.md b/packages/core/agent-execution/README.md deleted file mode 100644 index 8c01f0b3c4..0000000000 --- a/packages/core/agent-execution/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# dsh-agent-execution - -Process-local ambient Agent identity for asynchronous work initiated by a concrete agent driver. The default export, `AgentExecutionProvider`, installs the mandatory `ctx.agentExecution` service; [`dsh-agent-loop`](../agent-loop/README.md) establishes one boundary around each driver's complete lifetime. - -## Service: `AgentExecutionService` (ctx key: `agentExecution`) - -- `current()` returns the inherited `AgentExecution` or `undefined` outside a driver and inside an explicit clearing boundary. -- `require()` returns the inherited execution or throws `no agent execution context is active`. -- `run(execution, operation)` returns the exact synchronous value or Promise from `operation`. Passing `undefined` establishes a real boundary that hides an inherited Agent. - -The store contains only `{ readonly agent: Agent }`. The `Session` remains available through `agent.session`; turn, step, `signal`, `cwd`, sandbox, authorization, and other capability state remain with their explicit owners. Ambient presence identifies the initiator but does not prove that the Agent is live or that an operation is authorized. - -## Lifetime and detached work - -Provider teardown rejects new `run()` boundaries, removes the service so injected dependents drain, waits for returned Promise boundaries, then disables its `AsyncLocalStorage`. In-flight code retaining the service can call `current()` and `require()` while it drains; after disposal, all three methods throw `agent execution service is disposed`. - -Async resources created inside `run()` inherit its Agent even if `operation` returns before they settle, but provider teardown waits only for the Promise returned by `operation`. The owning seam must stop unreturned work explicitly. Unrelated timers, queues, and deployment infrastructure start under `run(undefined, operation)` and own an explicit stop; queue, worker, process, and wire boundaries serialize any identity they need instead of relying on ALS propagation. - -## Known Limitations and Deferred Work - -- **Process-local only** — ALS does not cross workers, child processes, HTTP, durable queues, or restarts; each boundary materializes a typed identity explicitly. -- **Agent identity only** — turn, step, signal, cwd, sandbox, and authorization stay outside the frame until a concrete cross-cutting consumer justifies a separate design. -- **Ambient references may outlive liveness** — consumers still check `agent.status`, their explicit signal, and the owning capability contract before lifecycle-sensitive work. diff --git a/packages/core/agent-execution/package.json b/packages/core/agent-execution/package.json deleted file mode 100644 index 7c2c527d3d..0000000000 --- a/packages/core/agent-execution/package.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-agent-execution", - "description": "Process-local ambient Agent context for asynchronous driver work", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/core/agent-execution/src/index.ts b/packages/core/agent-execution/src/index.ts deleted file mode 100644 index cb2683dbaa..0000000000 --- a/packages/core/agent-execution/src/index.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Process-local Agent execution context backed by Node AsyncLocalStorage. - * - * @module @deepseek-ai/dsh-agent-execution - */ - -import type { Context } from 'cordis' -import { AsyncLocalStorage } from 'node:async_hooks' -import { isPromise } from 'node:util/types' -import type { AgentExecution } from './types.ts' - -export type { AgentExecution } from './types.ts' - -const NO_ACTIVE_EXECUTION = 'no agent execution context is active' -const DISPOSED_SERVICE = 'agent execution service is disposed' - -/** Ambient Agent identity within one process-local asynchronous chain. */ -export interface AgentExecutionService { - /** - * Read the active execution without requiring one. - * @returns the inherited execution, or `undefined` outside/inside a cleared boundary. - * @throws when this service instance has been disposed. - */ - current(): AgentExecution | undefined - - /** - * Read the active execution and fail when no boundary is active. - * @returns the inherited execution. - * @throws when no execution is active or this service instance has been disposed. - */ - require(): AgentExecution - - /** - * Run an operation inside an execution boundary. Passing `undefined` clears - * an inherited execution; the exact synchronous value or Promise is returned. - * @param execution - execution to inherit, or `undefined` for a clearing boundary. - * @param operation - synchronous or asynchronous operation to invoke. - * @returns the exact value returned by `operation`. - * @throws when this service is closing/disposed, or when `operation` throws. - */ - run(execution: AgentExecution | undefined, operation: () => T): T -} - -declare module 'cordis' { - interface Context { - agentExecution: AgentExecutionService - } -} - -/** One provider-owned ALS instance with quiescent shutdown. */ -class DefaultAgentExecutionService implements AgentExecutionService { - private readonly storage = new AsyncLocalStorage() - private state: 'active' | 'closing' | 'disposed' = 'active' - private activeRuns = 0 - private drainWaiter: PromiseWithResolvers | undefined - private disposalTask: Promise | undefined - - current(): AgentExecution | undefined { - this.assertReadable() - return this.storage.getStore() - } - - require(): AgentExecution { - const execution = this.current() - if (execution === undefined) throw new Error(NO_ACTIVE_EXECUTION) - return execution - } - - run(execution: AgentExecution | undefined, operation: () => T): T { - if (this.state !== 'active') throw new Error(DISPOSED_SERVICE) - this.activeRuns += 1 - let result: T - try { - result = this.storage.run(execution, operation) - } catch (error: unknown) { - this.releaseRun() - throw error - } - if (isPromise(result)) { - void result.then( - () => { this.releaseRun() }, - () => { this.releaseRun() }, - ) - } else { - this.releaseRun() - } - return result - } - - /** Reject new boundaries while existing continuations remain readable. */ - close(): void { - if (this.state === 'active') this.state = 'closing' - } - - /** Wait for every returned Promise boundary, then invalidate retained references. */ - dispose(): Promise { - return (this.disposalTask ??= (async () => { - this.close() - if (this.activeRuns !== 0) { - this.drainWaiter ??= Promise.withResolvers() - await this.drainWaiter.promise - } - this.state = 'disposed' - this.storage.disable() - })()) - } - - private assertReadable(): void { - if (this.state === 'disposed') throw new Error(DISPOSED_SERVICE) - } - - private releaseRun(): void { - this.activeRuns -= 1 - if (this.activeRuns !== 0) return - this.drainWaiter?.resolve() - this.drainWaiter = undefined - } -} - -/** Cordis provider for the mandatory `ctx.agentExecution` service. */ -export class AgentExecutionProvider { - private readonly service = new DefaultAgentExecutionService() - - /** - * Install one isolated execution service and its ordered lifecycle. - * @param ctx - provider-owning Cordis context. - */ - constructor(ctx: Context) { - const service = this.service - ctx.effect(function* () { - // First yielded, disposed last: invalidate ALS only after dependents and active runs drain. - yield () => service.dispose() - yield ctx.provide('agentExecution', service) - // Last yielded, disposed first: prevent a teardown race from opening another boundary. - yield () => { service.close() } - }, 'agentExecution.lifecycle()') - } -} - -export default AgentExecutionProvider diff --git a/packages/core/agent-execution/src/types.ts b/packages/core/agent-execution/src/types.ts deleted file mode 100644 index 94b144785b..0000000000 --- a/packages/core/agent-execution/src/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Public Agent execution-context types. - * - * @module @deepseek-ai/dsh-agent-execution/types - */ - -import type { Agent } from '@deepseek-ai/dsh-agent' - -/** The exact Agent associated with one asynchronous execution chain. */ -export interface AgentExecution { - readonly agent: Agent -} diff --git a/packages/core/agent-execution/tests/agent-execution.spec.ts b/packages/core/agent-execution/tests/agent-execution.spec.ts deleted file mode 100644 index b18d19e63d..0000000000 --- a/packages/core/agent-execution/tests/agent-execution.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { runInNewContext } from 'node:vm' -import type { Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import type { AgentExecution, AgentExecutionService } from '@deepseek-ai/dsh-agent-execution' -import { SessionId } from '@deepseek-ai/dsh-session' - -function execution(id: string): AgentExecution { - return { agent: { id: SessionId(id) } as Agent } -} - -async function harness(): Promise<{ - ctx: Context - service: AgentExecutionService - dispose: () => Promise -}> { - const ctx = new Context() - const fiber = await ctx.plugin(AgentExecutionProvider) - return { - ctx, - service: ctx.agentExecution, - dispose: fiber.dispose, - } -} - -describe('AgentExecutionProvider', () => { - it('reports an absent boundary and requires an active execution', async () => { - const { service, dispose } = await harness() - expect(service.current()).toBeUndefined() - expect(() => service.require()).toThrow('no agent execution context is active') - await dispose() - }) - - it('preserves exact synchronous and Promise return identities across await', async () => { - const { service, dispose } = await harness() - const active = execution('identity') - const value = { result: true } - expect(service.run(active, () => { - expect(service.require()).toBe(active) - return value - })).toBe(value) - - const promise = service.run(active, async () => { - expect(service.require()).toBe(active) - await Promise.resolve() - expect(service.require()).toBe(active) - return value - }) - expect(service.run(active, () => promise)).toBe(promise) - await expect(promise).resolves.toBe(value) - expect(service.current()).toBeUndefined() - await dispose() - }) - - it('isolates overlapping executions', async () => { - const { service, dispose } = await harness() - const a = execution('a') - const b = execution('b') - const bothStarted = Promise.withResolvers() - const release = Promise.withResolvers() - let starts = 0 - const run = (active: AgentExecution): Promise => service.run(active, async () => { - expect(service.require()).toBe(active) - starts += 1 - if (starts === 2) bothStarted.resolve(true) - await release.promise - expect(service.require()).toBe(active) - }) - - const pending = [run(a), run(b)] - await bothStarted.promise - expect(service.current()).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 = execution('parent') - const child = execution('child') - - service.run(parent, () => { - expect(service.require()).toBe(parent) - service.run(child, () => { expect(service.require()).toBe(child) }) - expect(service.require()).toBe(parent) - service.run(undefined, () => { - expect(service.current()).toBeUndefined() - expect(() => service.require()).toThrow('no agent execution context is active') - }) - expect(service.require()).toBe(parent) - }) - expect(service.current()).toBeUndefined() - await dispose() - }) - - it('restores context after synchronous throws and rejected operations', async () => { - const { service, dispose } = await harness() - const parent = execution('parent') - const child = execution('child') - const syncError = new Error('sync failure') - const asyncError = new Error('async failure') - - service.run(parent, () => { - expect(() => service.run(child, () => { throw syncError })).toThrow(syncError) - expect(service.require()).toBe(parent) - }) - await expect(service.run(child, async () => { - await Promise.resolve() - throw asyncError - })).rejects.toBe(asyncError) - expect(service.current()).toBeUndefined() - await dispose() - }) - - it('stops new boundaries, drains active Promises, and invalidates retained references', async () => { - const { ctx, service, dispose } = await harness() - const active = execution('draining') - const release = Promise.withResolvers() - const pending = service.run(active, async () => { - await release.promise - expect(service.require()).toBe(active) - }) - let disposed = false - const disposal = dispose().then(() => { disposed = true }) - await Promise.resolve() - - expect(() => service.run(active, () => 1)).toThrow('agent execution service is disposed') - expect(disposed).toBe(false) - expect(ctx.get('agentExecution')).toBeUndefined() - release.resolve(true) - await pending - await disposal - expect(() => service.current()).toThrow('agent execution service is disposed') - expect(() => service.require()).toThrow('agent execution service is disposed') - }) - - it('drains cross-realm Promise boundaries before disposal', async () => { - const { service, dispose } = await harness() - const active = execution('cross-realm') - const release = Promise.withResolvers() - const operation = runInNewContext( - '(async () => { await release; inspect() })', - { - release: release.promise, - inspect: () => { expect(service.require()).toBe(active) }, - }, - ) as () => Promise - const pending = service.run(active, 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) - }) -}) diff --git a/packages/core/agent-execution/tsconfig.json b/packages/core/agent-execution/tsconfig.json deleted file mode 100644 index a06784e926..0000000000 --- a/packages/core/agent-execution/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../core/agent" - } - ] -} diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index f45411923c..db5861f8c1 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -25,7 +25,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ### Injected services -`agents`, `agentExecution`, `sessions`, `llm`, `tools`, `systemPrompt` — all six interface services. The loop cannot activate without `agentExecution`; the default bundle loads its provider before the loop. +`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services. ### Configuration (schemastery) @@ -50,7 +50,7 @@ The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publicati ### Loop lifecycle (`loop.ts`) -The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ 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 [execution-context package](../agent-execution/README.md) owns propagation, teardown, and detached-work rules. +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/package.json b/packages/core/agent-loop/package.json index 6a7fe2d937..7e2fb235a2 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -22,7 +22,6 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-execution": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -36,7 +35,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 67c9160332..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 = this.loopCtx.agentExecution.run({ agent: this }, () => 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) }, diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 6a0d714ef3..2a77afc983 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -11,7 +11,6 @@ import z from 'schemastery' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-agent-execution' import type { Agent, AgentFactory, @@ -406,7 +405,7 @@ function validateConfiguredAgents(agents: Config['agents']): void { /** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { - static inject = ['agents', 'agentExecution', 'sessions', 'llm', 'tools', 'systemPrompt'] + static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] /** Runtime schema for declarative agents. */ static Config = z.object({ diff --git a/packages/core/agent-loop/tests/agent-execution.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts similarity index 76% rename from packages/core/agent-loop/tests/agent-execution.spec.ts rename to packages/core/agent-loop/tests/agent-initiator.spec.ts index ee6f2d94c7..7f0903fc00 100644 --- a/packages/core/agent-loop/tests/agent-execution.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context, FiberState, type Fiber } from 'cordis' +import { Context, type Fiber } from 'cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' -import type { AgentExecutionService } from '@deepseek-ai/dsh-agent-execution' 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' @@ -13,7 +11,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' interface Harness { ctx: Context - providerFiber: Fiber + agentsFiber: Fiber loopFiber: Fiber } @@ -23,11 +21,10 @@ async function harness(adapter: LlmAdapter): Promise { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - const providerFiber = await ctx.plugin(AgentExecutionProvider) + const agentsFiber = await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - return { ctx, providerFiber, loopFiber } + return { ctx, agentsFiber, loopFiber } } function waitForIdle(ctx: Context, agent: Agent): Promise { @@ -56,12 +53,12 @@ class OverlapAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable { - const before = this.ctx.agentExecution.require().agent + 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.agentExecution.require().agent + const after = this.ctx.agents.requireInitiator() this.observations.push({ sessionId: options.sessionId, before, after }) yield* textResponse('done') } @@ -71,12 +68,12 @@ class OverlapAdapter extends LlmAdapter { class TestCapabilityTransport { readonly requests: { path: string; headers: Record }[] = [] - constructor(private readonly execution: AgentExecutionService) {} + constructor(private readonly agents: AgentRegistry) {} async request(path: string): Promise> { await Promise.resolve() const headers = { - 'X-Harness-Session-Id': this.execution.require().agent.session.id, + 'X-Harness-Session-Id': this.agents.requireInitiator().session.id, } this.requests.push({ path, headers }) return headers @@ -89,11 +86,11 @@ class ReloadAdapter extends LlmAdapter { firstAgentDuringAbort: Agent | undefined laterAgent: Agent | undefined calls = 0 - execution: AgentExecutionService | undefined + agents: AgentRegistry | undefined async * stream(options: GenerateOptions): AsyncIterable { - const execution = this.execution - if (execution === undefined) throw new Error('execution service missing') + const agents = this.agents + if (agents === undefined) throw new Error('agent service missing') this.calls += 1 if (this.calls === 1) { this.firstStarted.resolve(true) @@ -105,18 +102,18 @@ class ReloadAdapter extends LlmAdapter { }) } catch (error: unknown) { await Promise.resolve() - this.firstAgentDuringAbort = execution.require().agent + this.firstAgentDuringAbort = agents.requireInitiator() throw error } return } await Promise.resolve() - this.laterAgent = execution.require().agent + this.laterAgent = agents.requireInitiator() yield* textResponse('reloaded') } } -describe('AgentLoop execution context', () => { +describe('AgentLoop initiator scope', () => { it('keeps overlapping driver continuations bound to their exact Agents', async () => { const ctx = new Context() const adapter = new OverlapAdapter(ctx) @@ -125,7 +122,6 @@ describe('AgentLoop execution context', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -142,7 +138,7 @@ describe('AgentLoop execution context', () => { { sessionId: a.session.id, before: a, after: a }, { sessionId: b.session.id, before: b, after: b }, ])) - expect(ctx.agentExecution.current()).toBeUndefined() + expect(ctx.agents.currentInitiator()).toBeUndefined() await ctx.fiber.dispose() }) @@ -170,7 +166,7 @@ describe('AgentLoop execution context', () => { sessionId: SessionId('child-session'), agentOptions: { provider: 'mock', model: 'mock' }, setup: (agentCtx) => { - parentDuringSetup = ctx.agentExecution.require().agent + parentDuringSetup = ctx.agents.requireInitiator() explicitChild = agentCtx.agent agentCtx.tools.register(defineTool({ name: 'observe-child', @@ -178,7 +174,7 @@ describe('AgentLoop execution context', () => { parameters: {}, execute: async () => { await Promise.resolve() - childDuringDriver = ctx.agentExecution.require().agent + childDuringDriver = ctx.agents.requireInitiator() return [{ type: 'text', text: 'observed' }] }, })) @@ -187,7 +183,7 @@ describe('AgentLoop execution context', () => { child = handle.agent send(handle.agent, 'run child') await handle.agent.whenIdle() - parentAfterChild = ctx.agentExecution.require().agent + parentAfterChild = ctx.agents.requireInitiator() await handle.dispose() return [{ type: 'text', text: 'child completed' }] }, @@ -205,7 +201,7 @@ describe('AgentLoop execution context', () => { expect(explicitChild).toBe(child) expect(childDuringDriver).toBe(child) expect(parentAfterChild).toBe(parentHandle.agent) - expect(ctx.agentExecution.current()).toBeUndefined() + expect(ctx.agents.currentInitiator()).toBeUndefined() await parentHandle.dispose() await ctx.fiber.dispose() }) @@ -216,7 +212,7 @@ describe('AgentLoop execution context', () => { textResponse('done'), ]) const { ctx } = await harness(adapter) - const transport = new TestCapabilityTransport(ctx.agentExecution) + const transport = new TestCapabilityTransport(ctx.agents) let directAmbient: Agent | undefined let captured: Agent | undefined @@ -226,7 +222,7 @@ describe('AgentLoop execution context', () => { parameters: {}, execute: async () => { await Promise.resolve() - directAmbient = ctx.agentExecution.current()?.agent + directAmbient = ctx.agents.currentInitiator() return [{ type: 'text', text: 'ok' }] }, })) @@ -235,7 +231,7 @@ describe('AgentLoop execution context', () => { description: 'call the test capability transport', parameters: { path: { type: 'string' } }, execute: async (args) => { - captured = ctx.agentExecution.require().agent + captured = ctx.agents.requireInitiator() const path = (args as { path: string }).path const headers = await transport.request(path) return [{ type: 'text', text: JSON.stringify(headers) }] @@ -271,43 +267,15 @@ describe('AgentLoop execution context', () => { await handle.dispose() expect(captured?.status).toBe('disposed') - expect(ctx.agentExecution.current()).toBeUndefined() + expect(ctx.agents.currentInitiator()).toBeUndefined() await ctx.fiber.dispose() }) - it('keeps AgentLoop inactive until the mandatory provider appears', async () => { - const ctx = new Context() - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - const loopFiber = ctx.plugin(AgentLoop, { agents: [] }) - await Promise.resolve() - expect(loopFiber.state).toBe(FiberState.PENDING) - - await ctx.plugin(AgentExecutionProvider) - await loopFiber - expect(loopFiber.state).toBe(FiberState.ACTIVE) - await ctx.fiber.dispose() - }) - - it('drains the old driver before disabling ALS during provider restart', async () => { - const ctx = new Context() + it('drains the old driver before disabling ALS during agent-service restart', async () => { const adapter = new ReloadAdapter() - const { providerFiber, loopFiber } = await (async (): Promise => { - await ctx.plugin(LlmService) - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(AgentRegistry) - const mountedProvider = await ctx.plugin(AgentExecutionProvider) - const mountedLoop = await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) - return { ctx, providerFiber: mountedProvider, loopFiber: mountedLoop } - })() - const oldService = ctx.agentExecution - adapter.execution = oldService + 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' }, @@ -316,14 +284,14 @@ describe('AgentLoop execution context', () => { send(oldAgent, 'block') await adapter.firstStarted.promise - await providerFiber.restart() + 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.current()).toThrow('agent execution service is disposed') - expect(ctx.agentExecution).not.toBe(oldService) - adapter.execution = ctx.agentExecution + 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'), @@ -346,11 +314,10 @@ describe('AgentLoop execution context', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - const service = ctx.agentExecution - adapter.execution = service + const service = ctx.agents + adapter.agents = service const handle = await ctx.agents.create({ sessionId: SessionId('root-dispose-session'), agentOptions: { provider: 'mock', model: 'mock' }, @@ -363,6 +330,6 @@ describe('AgentLoop execution context', () => { expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id) expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session) expect(agent.status).toBe('disposed') - expect(() => service.current()).toThrow('agent execution service is 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 668190dd3d..d23b69efb7 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -5,7 +5,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -21,7 +20,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -56,7 +54,6 @@ function send(agent: Agent, text: string) { describe('Agent', () => { it('rejects access before context binding and a second driver for one session', async () => { const ctx = new Context() - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) const prepared = prepareReactLoopAgent( @@ -266,8 +263,8 @@ describe('Agent', () => { // test seam. Then call its disposer twice — the second call hits the // early-return branch. const ctx = new Context() - await ctx.plugin(AgentExecutionProvider) 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, @@ -386,7 +383,6 @@ describe('Agent', () => { // `done` (loop exit), not an eager resolve. A bare Agent + direct // internal driver disposer keeps the emit synchronous. const ctx = new Context() - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(LlmService) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index e65ffc42a6..2d32388171 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -14,7 +14,6 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -29,7 +28,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -200,7 +198,6 @@ describe('Agent.cancel()', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -325,7 +322,6 @@ describe('Agent.cancel()', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 8710ee5629..cadc176aea 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -10,7 +10,6 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -32,7 +31,6 @@ async function makeCoreContext(): Promise { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) return ctx } @@ -308,7 +306,6 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }], }) @@ -331,7 +328,6 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentExecutionProvider) await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) @@ -351,7 +347,6 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) @@ -376,7 +371,6 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentExecutionProvider) await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) @@ -393,7 +387,6 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -423,7 +416,6 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 891272a3c2..1875ce3757 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -5,7 +5,6 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -24,7 +23,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -824,7 +822,6 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) @@ -967,7 +964,6 @@ describe('turn and step boundary recovery', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1419,7 +1415,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1471,7 +1466,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1527,7 +1521,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1579,7 +1572,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) @@ -1629,7 +1621,6 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(Invariants) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index d7971c81bc..38fdaedf57 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -6,7 +6,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -21,7 +20,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index d3c66af720..a715265a97 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -5,7 +5,6 @@ import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from ' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -25,7 +24,6 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 105156f70e..63fef89087 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -5,7 +5,6 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -20,7 +19,6 @@ async function harness(adapter: MockAdapter, persona = '') { await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx @@ -1029,7 +1027,6 @@ describe('agent loop', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }], }) @@ -1054,7 +1051,6 @@ describe('agent loop', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 48c5d767c0..6f872cc423 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -18,7 +18,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' @@ -42,7 +41,6 @@ async function harness() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], new EchoAdapter()) return ctx diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 31c4865c61..c82eccbf85 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -5,7 +5,6 @@ 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -43,7 +42,6 @@ async function loopHarness(): Promise { await created.plugin(SystemPrompt, { persona: SYSTEM }) await created.plugin(ToolRegistry) await created.plugin(AgentRegistry) - await created.plugin(AgentExecutionProvider) await created.plugin(AgentLoop, { agents: [] }) await created.plugin(LlmDeepSeek) created.tools.register(defineTool({ diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 20000e9057..6622092f47 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -13,7 +13,6 @@ import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-a import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -24,7 +23,6 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { await ctx.plugin(SystemPrompt, { persona }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index bd00eb8bdd..74192dde90 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -11,7 +11,6 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -31,7 +30,6 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], adapter) @@ -139,7 +137,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -168,7 +165,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -383,7 +379,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')])) @@ -445,7 +440,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -499,7 +493,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -529,7 +522,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentExecutionProvider) await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) @@ -560,7 +552,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') })) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index aba59c8a68..73ee48f7a6 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -8,7 +8,6 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -20,7 +19,6 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return { ctx, loopFiber } diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index c8794d49e9..84c2fb34eb 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -11,7 +11,6 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmService from '@deepseek-ai/dsh-llm' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -22,7 +21,6 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [], ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls }, @@ -346,7 +344,6 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) ctx.llm.registerAdapter(['mock'], adapter) const gated = gatedParallelTool('p') diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 3346591811..a4b5f82c4a 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -14,7 +14,6 @@ import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -25,7 +24,6 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 9920cb1c29..8bbd26f2ac 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -5,7 +5,6 @@ import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-se import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -18,7 +17,6 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(Invariants) - await ctx.plugin(AgentExecutionProvider) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) return ctx diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index 1e17efa41f..5d7cf98bb7 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -35,9 +35,6 @@ { "path": "../../core/agent" }, - { - "path": "../../core/agent-execution" - }, { "path": "../../core/scope" } diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 122e58f39d..23d9d998c6 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 shadows its parent, and the parent returns after the child 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. 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..f2e774223d 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -1,11 +1,13 @@ /** - * 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 { 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 +175,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 { @@ -192,15 +196,19 @@ interface FactorySlot { } /** - * 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 initiatorState: 'active' | 'closing' | 'disposed' = 'active' + private activeInitiatorRuns = 0 + private initiatorDrain: PromiseWithResolvers | undefined + private initiatorDisposal: Promise | undefined constructor(ctx: Context) { super(ctx, 'agents') @@ -211,6 +219,54 @@ 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.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. + * @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. + * @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 +527,57 @@ 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() + if (this.activeInitiatorRuns !== 0) { + this.initiatorDrain ??= Promise.withResolvers() + await this.initiatorDrain.promise + } + this.initiatorState = 'disposed' + this.initiators.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) + this.activeInitiatorRuns += 1 + let result: T + try { + result = this.initiators.run(agent, operation) + } catch (error: unknown) { + this.releaseInitiatorRun() + throw error + } + if (isPromise(result)) { + void result.then( + () => { this.releaseInitiatorRun() }, + () => { this.releaseInitiatorRun() }, + ) + } else { + this.releaseInitiatorRun() + } + return result + } + + private assertInitiatorsReadable(): void { + if (this.initiatorState === 'disposed') throw new Error(DISPOSED_INITIATOR_MESSAGE) + } + + private releaseInitiatorRun(): void { + 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..87a8387e07 --- /dev/null +++ b/packages/core/agent/tests/agent-initiator.spec.ts @@ -0,0 +1,163 @@ +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, + } +} + +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('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) + }) +}) diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index 53847b0e97..24841c8c8e 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -6,7 +6,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts' +import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ @@ -58,6 +58,8 @@ describe('gen-cordis-catalog collectEvents', () => { )) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' }) + expect(events[0]?.jsDoc).toBe('/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */') + expect(renderEvents(events)).toContain("```ts cordis-catalog\n/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n'fix/happened'(id: string): void\n```") }) it('classifies a trailing-next signature as a waterfall', () => { @@ -158,26 +160,11 @@ export class FixService { expect(services).toHaveLength(1) expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' }) expect(services[0]?.methods).toHaveLength(3) - }) - - it('extracts an interface service as an abstract seam', () => { - const services = collectServices(makeService(`/** Fixture service interface. */ -export interface FixService { - /** - * Do the thing. - * @param id - which thing to do. - * @returns the outcome of doing it. - */ - run(id: string): string -}`)) - expect(services).toHaveLength(1) - expect(services[0]).toMatchObject({ - key: 'fix', - type: 'FixService', - abstract: true, - doc: 'Fixture service interface.', + expect(services[0]?.methods[0]).toEqual({ + signature: 'run(id: string): string', + jsDoc: '/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */', }) - expect(services[0]?.methods).toEqual(['run(id: string): string']) + expect(renderServices(services)).toContain('```ts cordis-catalog\n/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */\nrun(id: string): string\n\n/** Fire and forget (void needs no @returns). */\npoke(): void') }) it('hard-errors on a public method with no JSDoc at all', () => { diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 794df27760..409aef5ef2 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -16,8 +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-execution process-local ambient Agent execution context +@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/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 7d78b5eddb..772d6c059b 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", - "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + agent-execution + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", + "description": "The default executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + skills + agents + tasks + invariants + tool-bash + workspace-context + tool-skill + tool-tasks + agent-loop)", "version": "0.0.1", "private": true, "type": "module", @@ -24,7 +24,6 @@ "peerDependencies": { "@cordisjs/plugin-timer": "^1.1.2", "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-execution": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-home": "^0.0.1", @@ -44,7 +43,6 @@ "devDependencies": { "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 338c09b586..a4965ca457 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -18,7 +18,6 @@ import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import TaskService from '@deepseek-ai/dsh-tasks' import * as invariants from '@deepseek-ai/dsh-invariants' import * as toolBash from '@deepseek-ai/dsh-tool-bash' @@ -154,7 +153,6 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SkillService, config.skills?.registry ?? {}) ctx.plugin(SkillLocal, Object.assign({}, config.skills?.local, { dshHome })) ctx.plugin(AgentRegistry) - ctx.plugin(AgentExecutionProvider) ctx.plugin(TaskService) ctx.plugin(invariants) ctx.plugin(toolBash, Object.assign({}, config.toolBash, { dshHome })) diff --git a/packages/examples/agent-spine-demo/tsconfig.json b/packages/examples/agent-spine-demo/tsconfig.json index d1e01cf84c..89cb2accd8 100644 --- a/packages/examples/agent-spine-demo/tsconfig.json +++ b/packages/examples/agent-spine-demo/tsconfig.json @@ -41,9 +41,6 @@ { "path": "../../core/agent" }, - { - "path": "../../core/agent-execution" - }, { "path": "../../context/workspace-context" }, diff --git a/packages/sdk/helper/src/features/builtin/spine.ts b/packages/sdk/helper/src/features/builtin/spine.ts index d2fb877ec4..caf066865e 100644 --- a/packages/sdk/helper/src/features/builtin/spine.ts +++ b/packages/sdk/helper/src/features/builtin/spine.ts @@ -36,10 +36,6 @@ class SpineOption extends FeatureOption { }, ['persona'], config => requiredString(config, 'persona')), ...npmCordisConfigEntry(ID, { id: 'tools', name: '@deepseek-ai/dsh-tools' }, []), ...npmCordisConfigEntry(ID, { id: 'agent', name: '@deepseek-ai/dsh-agent' }), - ...npmCordisConfigEntry(ID, { - id: 'agent-execution', - name: '@deepseek-ai/dsh-agent-execution', - }), ...npmCordisConfigEntry(ID, { id: 'invariants', name: '@deepseek-ai/dsh-invariants' }), ...npmCordisConfigEntry(ID, { id: 'agent-loop', diff --git a/packages/support/agent-loop-testkit/README.md b/packages/support/agent-loop-testkit/README.md index cad60b21a4..350a8643e1 100644 --- a/packages/support/agent-loop-testkit/README.md +++ b/packages/support/agent-loop-testkit/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh-agent-loop-testkit` -Shared prerequisite mounting for tests that exercise the concrete `AgentLoop`. `mountAgentLoopTestDependencies(ctx, options?)` installs the LLM, session, system-prompt, tool, agent, and agent-execution services in dependency order, then returns before the loop is mounted. +Shared prerequisite mounting for tests that exercise the concrete `AgentLoop`. `mountAgentLoopTestDependencies(ctx, options?)` installs the LLM, session, system-prompt, tool, and agent services in dependency order, then returns before the loop is mounted. The caller registers adapters and optional plugins, mounts `AgentLoop` with the configuration under test, and disposes its own Context. System-prompt and tool-registry configuration can be forwarded through `options`; the helper does not provide test defaults beyond those owned by the services. A plugin-load failure rejects the helper call, while services activated earlier in the sequence remain owned by the caller's Context. diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index 712631ab29..423bd3e80d 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -23,7 +23,6 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-execution": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -32,7 +31,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts index 32549dbc2d..c7b0cb7304 100644 --- a/packages/support/agent-loop-testkit/src/index.ts +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -7,7 +7,6 @@ import type { Context } from 'cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -44,5 +43,4 @@ export async function mountAgentLoopTestDependencies( await ctx.plugin(SystemPrompt, options.systemPrompt ?? {}) await ctx.plugin(ToolRegistry, options.tools ?? {}) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentExecutionProvider) } diff --git a/packages/support/agent-loop-testkit/tsconfig.json b/packages/support/agent-loop-testkit/tsconfig.json index f8eddb5693..5e5b3c47f2 100644 --- a/packages/support/agent-loop-testkit/tsconfig.json +++ b/packages/support/agent-loop-testkit/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../core/agent" }, - { - "path": "../../core/agent-execution" - }, { "path": "../../llm/llm" }, diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index 7ccfbc3ebb..1cd6e07e17 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -41,7 +41,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index cbde053d45..680b2e25c7 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -5,7 +5,6 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -35,7 +34,6 @@ async function harness(): Promise { await built.plugin(SystemPrompt) await built.plugin(ToolRegistry) await built.plugin(AgentRegistry) - await built.plugin(AgentExecutionProvider) await built.plugin(AgentLoop, { agents: [] }) await built.plugin(LlmDeepSeek) await built.plugin(SubagentService) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b54201c207..6c7e4a3d05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -435,9 +435,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-execution': - specifier: workspace:^ - version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -533,15 +530,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/core/agent-execution: - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../agent - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/core/agent-loop: dependencies: schemastery: @@ -551,9 +539,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent - '@deepseek-ai/dsh-agent-execution': - specifier: workspace:^ - version: link:../agent-execution '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -704,9 +689,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-execution': - specifier: workspace:^ - version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -1698,9 +1680,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-execution': - specifier: workspace:^ - version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -2353,9 +2332,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent - '@deepseek-ai/dsh-agent-execution': - specifier: workspace:^ - version: link:../../core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop @@ -2413,9 +2389,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../packages/core/agent - '@deepseek-ai/dsh-agent-execution': - specifier: workspace:^ - version: link:../../packages/core/agent-execution '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../packages/core/agent-loop diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index f2646835d6..2d3560ec5c 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -10,7 +10,6 @@ "@cordisjs/plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-execution": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts index 617fd34bd5..44e87b2a21 100644 --- a/scripts/cordis-walk.ts +++ b/scripts/cordis-walk.ts @@ -2,7 +2,7 @@ * Shared AST walkers for the cordis documentation generators * (`gen-cordis-catalog.ts`, `gen-website-api.ts`): locating the cordis module * merge in a source file, enumerating its `interface Events` members, and - * resolving the `interface Context` service keys to their service declarations. + * resolving the `interface Context` service keys to their service classes. * One walk, two renderers — the catalog and the website page carry different * prose but must agree on WHAT exists. */ @@ -50,50 +50,45 @@ function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map` service declaration resolved from a Context merge. */ -export interface ServiceDeclaration { +/** One `ctx.` service class resolved from a Context merge. */ +export interface ServiceClass { key: string type: string - declaration: ts.ClassDeclaration | ts.InterfaceDeclaration + cls: ts.ClassDeclaration abstract: boolean - /** Declaration-level JSDoc prose (empty string when missing — also reported). */ + /** Class-level JSDoc prose (empty string when missing — also reported). */ doc: string } /** - * Resolve each `ctx.` of a merge to the service class or interface declared in the + * Resolve each `ctx.` of a merge to the service class declared in the * same file. A key whose type is not a class here (a Pick-mixin member, e.g. - * timer helpers) is skipped. A declaration without JSDoc prose is reported into + * timer helpers) is skipped. A class without JSDoc prose is reported into * `violations` (named `where` by the caller's gate). * * @param body — the cordis module merge body. * @param sf — the source file containing the merge. * @param rel — repo-relative path of `sf`, for violation pointers. * @param violations — sink for JSDoc-completeness violations. - * @returns the resolved service declarations, in Context-declaration order. + * @returns the resolved service classes, in Context-declaration order. */ -export function serviceDeclarations( +export function serviceClasses( body: ts.ModuleBlock, sf: ts.SourceFile, rel: string, violations: string[], -): ServiceDeclaration[] { +): ServiceClass[] { const text = sf.getFullText() - const out: ServiceDeclaration[] = [] + const out: ServiceClass[] = [] for (const [key, type] of contextKeyMap(body, sf)) { - const declaration = sf.statements.find( - (s): s is ts.ClassDeclaration | ts.InterfaceDeclaration => - (ts.isClassDeclaration(s) || ts.isInterfaceDeclaration(s)) && s.name?.text === type, + const cls = sf.statements.find( + (s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type, ) - if (!declaration) continue // a Pick-mixin member, not a service declaration here - const abstract = ts.isInterfaceDeclaration(declaration) - || (declaration.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false) - const doc = parseJsDoc(rawJsDoc(text, declaration)).doc - if (!doc) { - const kind = ts.isInterfaceDeclaration(declaration) ? 'interface' : 'class' - violations.push(`service ctx.${key} (${pointer(rel, sf, declaration)}): ${kind} ${type} has no JSDoc.`) - } - out.push({ key, type, declaration, abstract, doc }) + if (!cls) continue // a Pick-mixin member, not a class here + const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false + const doc = parseJsDoc(rawJsDoc(text, cls)).doc + if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`) + out.push({ key, type, cls, abstract, doc }) } return out } diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index cc87c379e9..5d8a70fba4 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -80,7 +80,7 @@ function referencedTypes(seeds: string[], decls: Map): { name: s function render(): string { const services = collectServices() const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name)) - const types = referencedTypes(services.flatMap(service => service.methods), collectTypeDecls()) + const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls()) const lines: string[] = [ '/**', ' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run', @@ -145,7 +145,7 @@ function render(): string { lines.push(' methods: [],') } else { lines.push(' methods: [') - for (const method of service.methods) lines.push(` ${quote(method)},`) + for (const method of service.methods) lines.push(` ${quote(method.signature)},`) lines.push(' ],') } lines.push(' },') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 293215a174..08f04947da 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -9,7 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { resolve, sep } from 'node:path' import ts from 'typescript' import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' -import { cordisModuleBody, eventMembers, serviceDeclarations } from './cordis-walk.ts' +import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts' const root = resolve(import.meta.dirname, '..') const OUT_EVENTS = 'docs/cordis-catalog/events.md' @@ -27,8 +27,6 @@ const FENCE = 'ts cordis-catalog' // TODO(catalog-type-links): verify or generate link-map coverage. export const LINK_MAP: Record = { Agent: 'core.md', - AgentExecution: 'core.md', - AgentExecutionService: 'core.md', ContentBlock: 'core.md', Message: 'core.md', MessageSource: 'core.md', @@ -74,6 +72,8 @@ interface EventEntry { scope: string /** Full signature text (the method-signature member, JSDoc stripped). */ signature: string + /** Original declaration JSDoc, dedented from its containing interface. */ + jsDoc: string /** Dispatch mode from the `@mode` tag. */ mode: Mode /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */ @@ -82,19 +82,27 @@ interface EventEntry { source: string } +/** One public service method and the source contract attached to it. */ +interface ServiceMethodEntry { + /** Public method signature (body stripped). */ + signature: string + /** Original method JSDoc, dedented from its containing class. */ + jsDoc: string +} + /** One harness service, extracted from an `interface Context` block. */ interface ServiceEntry { /** The `ctx.` name, e.g. `llm`. */ key: string /** The service class/interface name, e.g. `LlmService`. */ type: string - /** Whether the service declaration is abstract (a seam interface). */ + /** Whether the service class is abstract (a seam interface). */ abstract: boolean - /** Declaration-level JSDoc prose, one line per paragraph. */ + /** Class-level JSDoc prose, one line per paragraph. */ doc: string - /** Public method signatures (bodies stripped), in source order. */ - methods: string[] - /** Source pointer of the service declaration. */ + /** Public methods (bodies stripped), in source order. */ + methods: ServiceMethodEntry[] + /** Source pointer of the class declaration. */ source: string } @@ -117,6 +125,22 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() } +/** + * Copy a node's original JSDoc while removing only the indentation imposed by + * its containing interface or class. + */ +function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string { + const raw = rawJsDoc(text, node) + if (!raw) return '' + const start = text.lastIndexOf(raw, node.getStart(sf)) + const { line } = sf.getLineAndCharacterOfPosition(start) + const lineStart = sf.getPositionOfLineAndCharacter(line, 0) + const indent = text.slice(lineStart, start) + return raw.split('\n') + .map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText) + .join('\n') +} + /** Walk every harness `interface Events` block and extract its events, hard- * erroring (aggregated) on any JSDoc-completeness violation: a missing/ * contradicted `@mode`, missing description prose, or an undocumented payload @@ -157,15 +181,15 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { const { params } = parseTags(raw) checkParams(where, 'event', member.parameters, params, sf, p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) - if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) + if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src }) } } reportViolations('gen-cordis-catalog', violations) return entries } -/** Walk every harness `interface Context` block + its service declaration, hard- - * erroring (aggregated) on any JSDoc-completeness violation: a declaration or public +/** Walk every harness `interface Context` block + its service class, hard- + * erroring (aggregated) on any JSDoc-completeness violation: a class or public * method without JSDoc prose, an undocumented parameter, a stale `@param`, a * missing `@returns` on a non-void method, or an inferred (unannotated) return * type the pure-AST walk cannot classify. @@ -180,11 +204,11 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true) const body = cordisModuleBody(sf) if (!body) continue - // Resolve each ctx key to its service declaration (shared walk) and emit an entry. - for (const { key, type, declaration, abstract, doc: declarationDoc } of serviceDeclarations(body, sf, rel, violations)) { - const methods: string[] = [] - for (const member of declaration.members) { - if (!ts.isMethodDeclaration(member) && !ts.isMethodSignature(member)) continue + // Resolve each ctx key to its service class (shared walk) and emit an entry. + for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) { + const methods: ServiceMethodEntry[] = [] + for (const member of cls.members) { + if (!ts.isMethodDeclaration(member)) continue // Only instance methods callable through `ctx.` are surface; // private, protected, and static methods are not. const nonPublic = member.modifiers?.some(m => @@ -195,9 +219,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { if (nonPublic) continue const memberName = member.name.getText(sf) if (memberName.startsWith('[')) continue // computed/symbol members - methods.push(memberSignature(member, sf)) const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})` const raw = rawJsDoc(text, member) + methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) }) if (!raw) { violations.push(`${where} has no JSDoc.`); continue } if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`) const { params, returns } = parseTags(raw) @@ -212,9 +236,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { key, type, abstract, - doc: declarationDoc, + doc: clsDoc, methods, - source: pointer(rel, sf, declaration), + source: pointer(rel, sf, cls), }) } } @@ -277,7 +301,7 @@ function typeLinks(signature: string): string { function renderEvent(e: EventEntry): string[] { const out = [`### \`${e.name}\` — ${e.mode}`, ''] if (e.doc) out.push(e.doc, '') - out.push('```' + FENCE, e.signature, '```', '') + out.push('```' + FENCE, e.jsDoc, e.signature, '```', '') const links = typeLinks(e.signature) if (links) out.push(links, '') out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '') @@ -290,8 +314,13 @@ function renderService(s: ServiceEntry): string[] { const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] if (s.doc) out.push(s.doc, '') if (s.methods.length) { - out.push('```' + FENCE, ...s.methods, '```', '') - const links = typeLinks(s.methods.join('\n')) + const declarations = s.methods.flatMap((method, index) => [ + ...(index > 0 ? [''] : []), + method.jsDoc, + method.signature, + ]) + out.push('```' + FENCE, ...declarations, '```', '') + const links = typeLinks(s.methods.map(method => method.signature).join('\n')) if (links) out.push(links, '') } out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') @@ -306,15 +335,15 @@ const BANNER = [ ] /** The shared GENERATED + freshness-gate + fence notice paragraph. */ -const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.' +const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.' /** Render the events catalog (pure, deterministic given sorted inputs). */ -function renderEvents(events: EventEntry[]): string { +export function renderEvents(events: EventEntry[]): string { const lines: string[] = [ ...BANNER, '# Cordis Events Catalog', '', - 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.', + 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.', '', GATE_NOTICE, '', @@ -344,12 +373,12 @@ function renderEvents(events: EventEntry[]): string { } /** Render the services catalog (pure, deterministic given sorted inputs). */ -function renderServices(services: ServiceEntry[]): string { +export function renderServices(services: ServiceEntry[]): string { const lines: string[] = [ ...BANNER, '# Cordis Services Catalog', '', - 'Every `ctx.` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', + 'Every `ctx.` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', '', GATE_NOTICE, '', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 94836eaefd..19a124ccdf 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -156,18 +156,10 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'agents', pkg: 'agent', - title: 'Agent registry', + title: 'Agent service', mode: 'core', consumers: ['agent-loop', 'acp', 'subagent-inprocess', 'stdio-demo', 'invariants'], - note: 'Owns live Agent handles and the create/resume factory seam.', - }, - { - key: 'agentExecution', - pkg: 'agent-execution', - title: 'Agent execution context', - mode: 'core', - consumers: ['agent-loop'], - note: 'Carries the exact initiating Agent across one process-local asynchronous driver chain; explicit identities remain authoritative at external boundaries.', + note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, { key: 'agentLoop', diff --git a/scripts/gen-website-api.ts b/scripts/gen-website-api.ts index bae24c3f3d..0420ad2ff3 100644 --- a/scripts/gen-website-api.ts +++ b/scripts/gen-website-api.ts @@ -35,7 +35,7 @@ import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import ts from 'typescript' import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts' -import { cordisModuleBody, eventMembers, serviceDeclarations } from './cordis-walk.ts' +import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts' const root = resolve(import.meta.dirname, '..') @@ -197,18 +197,6 @@ function isPublicInstance(member: ts.ClassElement): boolean { return !member.name.getText().startsWith('_') } -type HarnessServiceMember = ts.MethodDeclaration | ts.MethodSignature | ts.PropertyDeclaration - | ts.PropertySignature | ts.GetAccessorDeclaration - -/** Whether a class/interface service member is renderable public API. */ -function isPublicServiceMember(member: HarnessServiceMember): boolean { - if (ts.isMethodSignature(member) || ts.isPropertySignature(member)) { - if (ts.isComputedPropertyName(member.name)) return false - return !member.name.getText().startsWith('_') - } - return isPublicInstance(member) -} - /** Whether a class member is renderable public STATIC API. */ function isPublicStatic(member: ts.ClassElement): boolean { const mods = ts.getCombinedModifierFlags(member) @@ -465,15 +453,14 @@ function collectHarnessServices(violations: string[]): HarnessService[] { // Manifest shape is repo-owned; `name` is the one field read here. const manifest = JSON.parse(readFileSync(pkgJson, 'utf8')) as { name: string } const pkg = manifest.name - for (const { key, type, declaration, abstract, doc: declarationDoc } of serviceDeclarations(body, sf, rel, violations)) { - const groups = new Map() - for (const member of declaration.members) { + for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) { + const groups = new Map() + for (const member of cls.members) { // Public properties are API too: ctx.codeRuntime.language/isolation // are readonly descriptors consumers key presentation off. - const renderable = ts.isMethodDeclaration(member) || ts.isMethodSignature(member) - || ts.isPropertyDeclaration(member) || ts.isPropertySignature(member) || ts.isGetAccessorDeclaration(member) + const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member) if (!renderable) continue - if (!isPublicServiceMember(member)) continue + if (!isPublicInstance(member)) continue const name = member.name.getText(sf) const group = groups.get(name) ?? [] group.push(member) @@ -481,7 +468,7 @@ function collectHarnessServices(violations: string[]): HarnessService[] { } const members = [...groups.entries()].map(([name, group]) => memberDoc(`ctx.${key}.${name} (${rel})`, name, group, rel, violations)) - services.push({ key, type, abstract, doc: declarationDoc, members, source: pointer(rel, sf, declaration), pkg }) + services.push({ key, type, abstract, doc: clsDoc, members, source: pointer(rel, sf, cls), pkg }) } } return services.sort((a, b) => a.key.localeCompare(b.key)) diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b6a92c55f6..566bdade7b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -15,8 +15,6 @@ { "doc": "docs/core-data-structures/core.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "InjectOptions", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "Agent", "source": "packages/core/agent/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecution", "source": "packages/core/agent-execution/src/types.ts" }, - { "doc": "docs/core-data-structures/core.md", "symbol": "AgentExecutionService", "source": "packages/core/agent-execution/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "HookContext", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "PromptDecision", "source": "packages/core/agent/src/types.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContinuationDecision", "source": "packages/core/agent/src/types.ts" }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index f42c346678..35cd2dae1b 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -28,7 +28,6 @@ interface SentenceContract { * so an absent section cannot be mistaken for forgotten documentation. */ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { - 'packages/core/agent-execution': 'The package adds no model-visible text or schema; consumers own any use in model requests.', 'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.', 'packages/util/brand': 'The package is a type-only primitive erased at compile time.', 'packages/util/paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.', diff --git a/tsconfig.build.json b/tsconfig.build.json index c631871672..31a42172e4 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -25,7 +25,6 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, - { "path": "./packages/core/agent-execution" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/tsconfig.json b/tsconfig.json index 8c8a2531c8..9d1a299b1b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -36,7 +36,6 @@ { "path": "./packages/session-query/session-query" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, - { "path": "./packages/core/agent-execution" }, { "path": "./packages/context/time-context" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, diff --git a/website/.vitepress/config/api-sidebar.json b/website/.vitepress/config/api-sidebar.json index c0774e23a4..3a2b11db3c 100644 --- a/website/.vitepress/config/api-sidebar.json +++ b/website/.vitepress/config/api-sidebar.json @@ -22,10 +22,6 @@ } ], "harness": [ - { - "text": "ctx.agentExecution", - "link": "/zh-CN/api/harness/agent-execution" - }, { "text": "ctx.agentLoop", "link": "/zh-CN/api/harness/agent-loop" diff --git a/website/zh-CN/api/harness/agent-execution.md b/website/zh-CN/api/harness/agent-execution.md deleted file mode 100644 index 2276d8ecbb..0000000000 --- a/website/zh-CN/api/harness/agent-execution.md +++ /dev/null @@ -1,48 +0,0 @@ - - -# ctx.agentExecution - -`AgentExecutionService` (abstract seam) — provided by `@deepseek-ai/dsh-agent-execution`. - -Ambient Agent identity within one process-local asynchronous chain. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-execution/src/index.ts#L18) - -### ctx.agentExecution.current() - -```ts website-api -current(): AgentExecution | undefined -``` - -Read the active execution without requiring one. - -**Returns** the inherited execution, or `undefined` outside/inside a cleared boundary. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-execution/src/index.ts#L24) - -### ctx.agentExecution.require() - -```ts website-api -require(): AgentExecution -``` - -Read the active execution and fail when no boundary is active. - -**Returns** the inherited execution. - -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-execution/src/index.ts#L31) - -### ctx.agentExecution.run(execution, operation) - -```ts website-api -run(execution: AgentExecution | undefined, operation: () => T): T -``` - -Run an operation inside an execution boundary. Passing `undefined` clears an inherited execution; the exact synchronous value or Promise is returned. - -- `execution` — execution to inherit, or `undefined` for a clearing boundary. -- `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-execution/src/index.ts#L41) diff --git a/website/zh-CN/api/harness/agent-loop.md b/website/zh-CN/api/harness/agent-loop.md index 79003deb84..6401898e24 100644 --- a/website/zh-CN/api/harness/agent-loop.md +++ b/website/zh-CN/api/harness/agent-loop.md @@ -6,7 +6,7 @@ Concrete agent factory and driver service. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L408) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L407) ### ctx.agentLoop.create(id, options?, meta?) @@ -22,7 +22,7 @@ Create an agent and session under one caller-supplied identity, owned by the acc **Returns** the published running agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L543) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L542) ### ctx.agentLoop.createAgent(ownerCtx, options) @@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L565) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L564) ### ctx.agentLoop.resume(ownerCtx, options) @@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service. **Returns** the published handle. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L597) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L596) diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index 8d6c84cc7a..328ea6097e 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#L204) + +### 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#L233) + +### 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#L243) + +### 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. + +- `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#L257) + +### 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. + +- `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#L268) ### ctx.agents.setFactory(factory) @@ -20,7 +73,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#L284) ### ctx.agents.create(options) @@ -34,7 +87,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#L317) ### ctx.agents.resume(options) @@ -48,7 +101,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#L336) ### ctx.agents.register(agent) @@ -62,7 +115,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#L362) ### ctx.agents.enter(agent, owner) @@ -77,7 +130,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#L386) ### ctx.agents.announce(agent) @@ -89,7 +142,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#L461) ### ctx.agents.get(id) @@ -103,7 +156,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#L495) ### ctx.agents.isOwnedBy(id, owner) @@ -118,7 +171,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#L507) ### ctx.agents.list() @@ -130,7 +183,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#L515) ### ctx.agents.roots() @@ -142,4 +195,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#L525) diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 66bec18578..8d494739ba 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -228,7 +228,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers - `sessionId` — exact shared agent/session identity that failed startup. - `error` — persistence, setup, or publication failure. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L363) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L362) ## approval/* From e0b5cadd2a97c56404ce4f23ef3070fb819ce9ee Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:37:11 +0800 Subject: [PATCH 10/16] chore(core): align merged initiator changes --- examples/repl-agent/tests/code-mode.e2e.ts | 1 + packages/README.md | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 1 + packages/core/agent-loop/tests/coverage-edges.spec.ts | 1 + packages/core/agent-loop/tests/interception.spec.ts | 1 + packages/core/agent-loop/tests/loop.spec.ts | 1 + packages/core/agent-loop/tests/properties.spec.ts | 1 + packages/core/agent-loop/tests/request-cache.e2e.ts | 1 + packages/core/agent-loop/tests/request-reconstruction.spec.ts | 1 + packages/core/agent-loop/tests/tool-order.spec.ts | 1 + packages/core/agent-loop/tests/turn-stop.spec.ts | 1 + .../workflow-workerthread/tests/workflow-workerthread.e2e.ts | 1 + 12 files changed, 12 insertions(+), 1 deletion(-) diff --git a/examples/repl-agent/tests/code-mode.e2e.ts b/examples/repl-agent/tests/code-mode.e2e.ts index e03dcc6b03..c7f5d48562 100644 --- a/examples/repl-agent/tests/code-mode.e2e.ts +++ b/examples/repl-agent/tests/code-mode.e2e.ts @@ -9,6 +9,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' diff --git a/packages/README.md b/packages/README.md index 7e01fe30fe..7ddda1d0a9 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, including agent execution 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/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 2d32388171..92eb046788 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -14,6 +14,7 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 38fdaedf57..b324c02b6c 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -6,6 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index a715265a97..ea8dcfd58a 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from ' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 63fef89087..13a63f0f3c 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 6f872cc423..2becadcc40 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -18,6 +18,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index c82eccbf85..9fc832fd0a 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -5,6 +5,7 @@ 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 6622092f47..46cfe3eb56 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -13,6 +13,7 @@ import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-a import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index a4b5f82c4a..bf78208a42 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -14,6 +14,7 @@ import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index 8bbd26f2ac..355e1e8e3d 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -5,6 +5,7 @@ import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-se import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 680b2e25c7..74d42f739b 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -5,6 +5,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' From 3ceccc87a0299f6ec88b44a5d267665424f1bdaa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:59:05 +0800 Subject: [PATCH 11/16] docs: drift-check public class APIs --- docs/AGENTS.md | 2 +- docs/core-data-structures/core.md | 4 +- docs/core-data-structures/llm-streaming.md | 69 ++++++++++ docs/core-data-structures/session.md | 120 ++++++++++++++++++ docs/development.i18n.yaml | 4 +- docs/development.md | 4 +- docs/development.zh.md | 4 +- ...2026-06-20-core-data-structures-catalog.md | 6 +- scripts/doc-typecheck.ts | 1 + scripts/type-equiv.manifest.json | 5 +- scripts/verify-type-equiv.ts | 102 +++++++++++++-- 11 files changed, 296 insertions(+), 25 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index dc06018c16..27db1d88d7 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -27,7 +27,7 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems. - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. -- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc are fenced ` ```ts type-equiv ` and registered in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). +- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses its `public-api` variant; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dfb59b3ac6..732b6d9599 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -36,9 +36,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | -> Type declarations and their JSDoc on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). - -FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog. +> Type declarations and their JSDoc on these pages are source-equivalent and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Ordinary blocks preserve complete declarations; `public-api` blocks preserve body-stripped public class declarations. Cordis services use the generated [service catalog](../cordis-catalog/services.md). ## The `…Map → derived-union` pattern diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 0ef9ae02f7..b27c21a69c 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -91,10 +91,79 @@ interface TokenUsage { `BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s, usage, finish reason, and replay state. The loop logs the raw chunks while feeding the same chunks through an assembler, then stores the assembled assistant content with its provider/model provenance. A consumer that needs the assembled result without re-implementing the fold uses this. +```ts type-equiv public-api +/** + * Incrementally assembles raw {@link StreamChunk}s into complete + * {@link ContentBlock}s and a final assistant {@link Message}. + * + * The agent loop feeds it while logging raw chunks for replay fidelity, then + * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends. + * + * Tolerant of delta-only protocols (no block-start/end); deltas arriving for + * an index already closed by `block-end` are ignored (malformed stream) so a + * misbehaving adapter cannot grow memory or corrupt a completed block. + */ +declare class BlockAssembler { + /** + * Feed one chunk into the assembly state. + * @param chunk - the next raw chunk, in stream order. + */ + push(chunk: StreamChunk): void; + /** + * Assemble all blocks seen so far, in stream order. + * @returns one block per seen index; an open block assembles from its + * accumulated deltas (an unknown block type never closed by `block-end` throws). + */ + blocks(): ContentBlock[]; + /** Usage from the `usage` chunk; undefined until one arrives. */ + get usage(): TokenUsage | undefined; + /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */ + get finish(): FinishReason; + /** Adapter-private replay state from the terminal finish chunk, if any. */ + get replayState(): unknown; + /** + * The assembled assistant message. + * @returns an assistant-role message over `blocks()` (same open-block assembly rules). + */ + message(): Message; +} +``` + ## The seam `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +```ts type-equiv public-api +/** + * Provider-wire adapter for the harness message and stream vocabulary. Register implementations + * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include + * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The hand-rolled + * DeepSeek and pi-ai adapters intentionally exercise this contract through different internals. + */ +declare abstract class LlmAdapter { + /** + * Describe one provider route owned by this adapter. + * @param provider - a route passed to `registerAdapter()` for this instance. + * @returns detached display metadata whose id must equal `provider`. + */ + providerInfo(provider: string): LlmProviderInfo; + /** + * List models this adapter can currently advertise for one owned provider. + * The result is advisory: an adapter may accept unlisted model ids, and + * consumers must not turn absence into request rejection. + * @param _provider - one provider route owned by this adapter. + * @returns discoverable models in adapter-preferred order. + */ + listModels(_provider: string): Promise; + /** + * Stream one model call as raw chunks. The only required method. + * @param options - the fully-assembled request; implementations must honor `options.signal`. + * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`. + */ + abstract stream(options: GenerateOptions): AsyncIterable; +} +``` + `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: ```ts type-equiv diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 3d3fca045a..1ece65c1c3 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -305,6 +305,126 @@ interface SurfaceFoldResult { } ``` +## `Session` public API + +The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore). + +```ts type-equiv public-api +/** + * An event-sourced session: an append-only log of {@link SessionEvent}s. + * + * Plain class (not a Service) — create instances via `ctx.sessions.create()`. + * Seeding with an existing event log replays/forks a session. + */ +declare class Session { + /** The ordered surface over this session's event log. */ + get surface(): SessionSurface; + /** + * Detached, deep-frozen creation metadata (format version, cwd, lineage, + * seed boundary). Supplied by the store via `ctx.sessions.create()`. When a + * `Session` is constructed bare (tests, ad-hoc replay), a minimal header is + * synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so + * `session.header` is always present. Kept out of the event log — it is a + * storage concern, not replayable conversation state. + */ + readonly header: SessionHeader; + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId; + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader); + /** + * An immutable snapshot of the append-only event log. The snapshot is reused + * until the next append; a previously returned array does not grow later. + * Events and their nested data are deep-frozen at acceptance, so neither a + * cast nor ordinary JavaScript can rewrite durable history. + */ + get events(): readonly SessionEvent[]; + /** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */ + get seq(): number; + /** + * Append one typed event to the log and synchronously notify observers via + * the store-owned, module-private publication hooks. The hot path never blocks + * on I/O — persistence plugins buffer asynchronously. Once the event enters + * the log, the append is committed: observer failures are logged and + * contained per listener, so they do not change the return value or prevent + * later listeners from observing the same accepted event. + * + * @param type - The event type (key of {@link SessionEventMap}). + * @param data - The event payload; must be JSON-serializable. + * @param opts - Surface metadata: `surfaceOp` controls how the event enters + * the ordered surface; `sourceEventSeqs` records provenance (the seq + * numbers of events this one derives from). REQUIRED for + * {@link SurfaceEventType} events (every message-producing event must + * declare how it joins the surface, the sole source of derived history) and + * rejected by the compiler for non-surface types like `turn/start` or + * `assistant/chunk`. + * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of + * `data` that entered the log, so reading `event.data` back sees the logged + * value, never the caller's still-mutable input. + * @throws if `data` or surface metadata is not losslessly JSON-serializable + * (BigInt, function, symbol, undefined, negative zero, non-finite number, + * circular reference, sparse array, or an exotic object such as + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and + * copies each nested value once, so a stateful getter cannot supply one value + * to validation and another to storage. The event log is the durable source + * of truth, so a bad event fails at the append site rather than later during + * a backend flush. A synchronous internal dispatch validation failure or an + * append reentered while this acceptance/publication boundary is open also + * rejects before the log changes. + */ + append( + type: T, + data: SessionEventMap[T], + ...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : [] + ): SessionEvent; + /** + * The {@link EpochHeader} in force after the log's last header event — the + * header the NEXT request will be compared against — or undefined before + * the first `request/header` snapshot. The live, incrementally-maintained + * form of `foldRequestHeader(session.events)`: each header event is folded + * once, when first seen, so a per-step read costs O(new events). + * @returns the folded header, or undefined when no header event exists yet. + */ + requestHeader(): EpochHeader | undefined; + /** + * Derive the LLM message history by walking the ordered sequences of + * message-producing events maintained by `surfaceOp` markers. The + * surface is the single source of derived history: every message-producing + * append records its `surfaceOp`, so a raw event with no marker (a chunk, a + * turn boundary) is correctly absent, and a compaction `replace` deletes the + * shadowed nodes from the derivation. The projection rules are + * {@link deriveEventMessage}, folded per node. + * + * CACHED: each surface node is projected exactly once, when first seen — a + * call costs O(new nodes), and a surface rewrite (a `replace`; + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is + * a fresh snapshot per call (later appends never grow an array a caller + * already holds); the `Message` objects in it are SHARED and **deep-frozen**. + * Their content reuses the already frozen durable event data, so the cache + * needs no second deep clone and consumers still cannot mutate the log. + * @returns a fresh array of the shared, frozen derived history. + */ + deriveMessages(): Message[]; + /** + * Project a single event into the LLM message it derives to, or null when + * it produces none — a non-surface event (chunk, boundary, log-only record) + * or an empty-content assistant/message (which exists only to host usage). + * The per-node pure function {@link deriveMessages} folds over the surface; + * an external reconstructor (or the dev invariant) folds the same function + * over a log prefix's surface to rebuild the exact messages any request was + * built from (the reconstructability RFC). The returned message wrapper is + * fresh; its content reuses the logged event's already deep-frozen durable + * data, so changing the wrapper cannot rewrite the log and changing content + * throws. + * @param event - the event to project. + * @returns the derived message, or null when the event produces none. + */ + deriveEventMessage(event: SessionEvent): Message | null; +} +``` + ## Derived history: `deriveMessages()` and `deriveEventMessage()` `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules: diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index e5b3feba33..e41578431b 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # 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 -development.md: 37811f7215001fc371ac4943fe109dd5512ea8b0 -development.zh.md: 5036e3e75516fcaf063675fc9ab4e63c1fca851a +development.md: 204b50d7733ba18aa6bac1ae714ad09972f3db25 +development.zh.md: 11a844fa6939aa0894d0657d207c5082f5406732 diff --git a/docs/development.md b/docs/development.md index 37811f7215..204b50d773 100644 --- a/docs/development.md +++ b/docs/development.md @@ -145,13 +145,13 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel ## Documenting types verbatim (`ts type-equiv`) -The [core data structures](core-data-structures/core.md) docs paste real type declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: +The [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts type-equiv public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both variants (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. ## Architecture context diff --git a/docs/development.zh.md b/docs/development.zh.md index 5036e3e755..11a844fa69 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -145,13 +145,13 @@ pnpm run demo:acp ## 逐字记录类型(`ts type-equiv`) -[核心数据结构](core-data-structures/core.md)文档会把真实类型声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts type-equiv public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种变体(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index bf70879c71..974a19a621 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -29,8 +29,8 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei The durability requirement was specific: the doc shows the **literal** current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) **and** is mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability* — a renamed field or changed JSDoc can pass. So: -- Type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch. -- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. +- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A `public-api` variant carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. +- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. - Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. @@ -52,7 +52,7 @@ The spine-vs-seam rule was tested against `BashExecRequest`, tool schemas and de ## Consequences -- The vocabulary now has a single home that **cannot silently drift**: a field rename in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. +- The vocabulary now has a single home that **cannot silently drift**: a field or public class-member change in source fails `verify-type-equiv` in the pre-push hook and CI until the paste is refreshed. Cordis service methods remain owned by the generated services catalog rather than being duplicated here. - The spine-vs-seam line is a reusable scoping tool, not a one-off: the same "the thing you write/hold/receive is core; the machinery that types/renders/persists it is a detail" rule is what later scoped the events/services catalog's harness-vs-inherited tiering. - The `ts type-equiv` fence is a third doc-block category alongside ` ```ts ` (compiled) and ` ```ts ignore-check ` (sketch). A later sibling added a fourth, ` ```ts cordis-catalog ` (generated signature), reusing the same skip-and-exclude treatment. - Adding or reshaping a core type now carries a documentation obligation the author must honor (the gate cannot detect a missing *new* type), backstopped by the `dsh-code-review` checklist. diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 7549ee982b..c4d6c04a35 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -33,6 +33,7 @@ const KIND_BY_INFO: Record = { 'ts': 'check', 'ts ignore-check': 'ignore', 'ts type-equiv': 'type-equiv', + 'ts type-equiv public-api': 'type-equiv', 'ts cordis-catalog': 'cordis-catalog', 'ts persistence-catalog': 'persistence-catalog', 'ts config-catalog': 'config-catalog', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 566bdade7b..09888916d0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,5 +1,5 @@ { - "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source declaration and original JSDoc it must match. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", + "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts type-equiv public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, @@ -33,6 +33,8 @@ { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "TokenUsage", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "AppIdentity", "source": "packages/llm/llm/src/attribution.ts" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "BlockAssembler", "source": "packages/llm/llm/src/assembler.ts", "projection": "public-api" }, + { "doc": "docs/core-data-structures/llm-streaming.md", "symbol": "LlmAdapter", "source": "packages/llm/llm/src/index.ts", "projection": "public-api" }, { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenMeasurement", "source": "packages/llm/token-meter/src/types.ts" }, { "doc": "docs/core-data-structures/token-meter.md", "symbol": "TokenSurfaceNode", "source": "packages/llm/token-meter/src/types.ts" }, @@ -50,6 +52,7 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SessionSurface", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "Session", "source": "packages/core/session/src/index.ts", "projection": "public-api" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 585796206c..58affaffca 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -1,8 +1,10 @@ /** * Verify every `ts type-equiv` block against the source symbol named by the - * manifest. Blocks and entries have a one-to-one relationship; comparison - * ignores whitespace and non-JSDoc comments but preserves declaration - * structure and every original JSDoc comment. + * manifest. Ordinary entries preserve the complete declaration; `public-api` + * entries preserve a class's body-stripped public declaration. Blocks and + * entries have a one-to-one relationship; comparison ignores whitespace and + * non-JSDoc comments but preserves declaration structure and every original + * JSDoc comment. */ import { globSync, readFileSync, existsSync } from 'node:fs' @@ -22,6 +24,8 @@ interface ManifestEntry { symbol: string /** Source file (repo-relative) that exports the symbol. */ source: string + /** Complete declaration (default), or a body-stripped public class API. */ + projection?: 'public-api' } /** One extracted ` ```ts type-equiv ` block. */ @@ -31,6 +35,8 @@ interface EquivBlock { line: number /** Symbol name parsed from the block's declaration. */ symbol: string + /** Complete declaration (default), or a body-stripped public class API. */ + projection?: 'public-api' /** Block body (the pasted declaration). */ code: string } @@ -75,7 +81,7 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { const text = readFileSync(resolve(root, docRel), 'utf8') const lines = text.split('\n') const blocks: EquivBlock[] = [] - let open: { line: number; body: string[] } | null = null + let open: { line: number; body: string[]; projection?: 'public-api' } | null = null for (let i = 0; i < lines.length; i++) { const raw = lines[i] ?? '' @@ -90,11 +96,19 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { if (!symbol) { throw new Error(`verify-type-equiv: ${docRel}:${open.line} — type-equiv block has no parseable interface/type/class declaration`) } - blocks.push({ doc: docRel, line: open.line, symbol, code }) + blocks.push({ + doc: docRel, + line: open.line, + symbol, + code, + ...(open.projection === undefined ? {} : { projection: open.projection }), + }) open = null continue } - if ((fence[2] ?? '').trim() === 'ts type-equiv') open = { line: i + 1, body: [] } + const info = (fence[2] ?? '').trim() + if (info === 'ts type-equiv') open = { line: i + 1, body: [] } + if (info === 'ts type-equiv public-api') open = { line: i + 1, body: [], projection: 'public-api' } } if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`) return blocks @@ -127,13 +141,77 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null { return null } +/** Leading source JSDoc attached to one declaration or member. */ +function sourceJSDoc(text: string, node: ts.Node): string { + return ts.getJSDocCommentsAndTags(node) + .filter(ts.isJSDoc) + .map(doc => text.slice(doc.pos, doc.end)) + .join('\n') +} + +/** Whether a class member is part of its public declaration. */ +function isPublicMember(member: ts.ClassElement): boolean { + if (ts.isClassStaticBlockDeclaration(member)) return false + const name = ts.getNameOfDeclaration(member) + if (name && ts.isPrivateIdentifier(name)) return false + const modifiers = ts.canHaveModifiers(member) ? ts.getModifiers(member) : undefined + return !(modifiers?.some(modifier => + modifier.kind === ts.SyntaxKind.PrivateKeyword + || modifier.kind === ts.SyntaxKind.ProtectedKeyword, + ) ?? false) +} + +/** Remove an implementation body while retaining the source signature. */ +function bodylessMember(text: string, sf: ts.SourceFile, member: ts.ClassElement): string { + const start = member.getStart(sf) + let end = member.end + if (ts.isConstructorDeclaration(member) || ts.isMethodDeclaration(member) + || ts.isGetAccessorDeclaration(member) || ts.isSetAccessorDeclaration(member)) { + if (member.body) end = member.body.getStart(sf) + } + if (ts.isPropertyDeclaration(member) && member.initializer) end = member.initializer.getStart(sf) + const signature = text.slice(start, end).trimEnd().replace(/;$/, '').replace(/=\s*$/, '').trimEnd() + return `${signature};` +} + +/** + * Render a class as an ambient declaration containing only its public fields, + * constructor, accessors, and methods. Implementation bodies and private or + * protected members are deliberately absent; original class/member JSDoc is + * retained so the projection is the source-owned public contract. + */ +function sourcePublicApi(sourceRel: string, symbol: string): string | null { + const abs = resolve(root, sourceRel) + const text = readFileSync(abs, 'utf8') + const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, /* setParentNodes */ true) + for (const stmt of sf.statements) { + if (!ts.isClassDeclaration(stmt) || stmt.name?.text !== symbol) continue + const classDoc = sourceJSDoc(text, stmt) + const abstract = stmt.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AbstractKeyword) ? 'abstract ' : '' + const typeParameters = stmt.typeParameters?.map(parameter => parameter.getText(sf)).join(', ') + const heritage = stmt.heritageClauses?.map(clause => clause.getText(sf)).join(' ') + const header = `declare ${abstract}class ${symbol}${typeParameters ? `<${typeParameters}>` : ''}${heritage ? ` ${heritage}` : ''} {` + const members = stmt.members + .filter(isPublicMember) + .map((member) => { + const jsDoc = sourceJSDoc(text, member) + const declaration = bodylessMember(text, sf, member) + return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}` + }) + const declaration = [header, ...members.map(member => member.split('\n').map(line => ` ${line}`).join('\n')), '}'].join('\n') + return classDoc === '' ? declaration : `${classDoc}\n${declaration}` + } + return null +} + const manifestRaw = readFileSync(resolve(root, 'scripts/type-equiv.manifest.json'), 'utf8') const manifest = JSON.parse(manifestRaw) as { entries: ManifestEntry[] } const entries = manifest.entries -// Key a block/entry by doc + symbol (a symbol may be documented in more than one -// doc, but at most once per doc). -const keyOf = (x: { doc: string; symbol: string }): string => `${x.doc}::${x.symbol}` +// Key a block/entry by doc + symbol + projection. A symbol may be documented in +// more than one doc, and a doc may carry both complete and projected forms. +const keyOf = (x: { doc: string; symbol: string; projection?: 'public-api' }): string => + `${x.doc}::${x.symbol}::${x.projection ?? 'declaration'}` // Collect every type-equiv block across ALL docs in scope — not only the docs // the manifest names — so a block in an unmanifested doc is found and reported @@ -152,7 +230,7 @@ for (const d of [...new Set(entries.map(e => e.doc))]) { else if (!docSet.has(d)) errors.push(`manifest references ${d}, which is outside the scanned markdown scope (${MARKDOWN_GLOBS.join(', ')})`) } -// Duplicate-block guard: the same symbol twice in one doc is ambiguous. +// Duplicate-block guard: the same projected symbol twice in one doc is ambiguous. const blockByKey = new Map() for (const b of blocks) { const k = keyOf(b) @@ -192,7 +270,9 @@ let verified = 0 for (const e of entries) { const b = blockByKey.get(keyOf(e)) if (!b) continue // already reported as an orphan entry - const decl = sourceDeclaration(e.source, e.symbol) + const decl = e.projection === 'public-api' + ? sourcePublicApi(e.source, e.symbol) + : sourceDeclaration(e.source, e.symbol) if (decl === null) { errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`) continue From 9163f6129947b4f908d785968fe3c9dbf963144c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:14:02 +0800 Subject: [PATCH 12/16] docs: include JSDoc in website API markers --- scripts/gen-website-api.ts | 58 ++- website/zh-CN/api/cordis/context.md | 100 +++++ website/zh-CN/api/cordis/events.md | 62 ++++ website/zh-CN/api/cordis/fiber.md | 86 +++++ website/zh-CN/api/cordis/registry.md | 28 ++ website/zh-CN/api/cordis/service.md | 8 + website/zh-CN/api/harness/agent-loop.md | 21 ++ website/zh-CN/api/harness/agents.md | 91 +++++ website/zh-CN/api/harness/approval.md | 18 + website/zh-CN/api/harness/bash-env.md | 15 + website/zh-CN/api/harness/bash.md | 22 ++ website/zh-CN/api/harness/code-runtime.md | 22 ++ website/zh-CN/api/harness/compact.md | 32 ++ website/zh-CN/api/harness/events.md | 350 ++++++++++++++++++ website/zh-CN/api/harness/fs.md | 70 ++++ website/zh-CN/api/harness/llm.md | 27 ++ website/zh-CN/api/harness/permission.md | 30 ++ website/zh-CN/api/harness/sandbox.md | 11 + .../zh-CN/api/harness/session-persistence.md | 35 ++ website/zh-CN/api/harness/session-query.md | 26 ++ website/zh-CN/api/harness/sessions.md | 99 +++++ website/zh-CN/api/harness/skills.md | 30 ++ website/zh-CN/api/harness/spill-store.md | 5 + website/zh-CN/api/harness/subagents.md | 25 ++ website/zh-CN/api/harness/system-prompt.md | 30 ++ website/zh-CN/api/harness/tasks.md | 63 ++++ website/zh-CN/api/harness/token-meter.md | 22 ++ website/zh-CN/api/harness/tools.md | 54 +++ website/zh-CN/api/harness/user-interaction.md | 12 + website/zh-CN/api/harness/web.md | 31 ++ website/zh-CN/api/harness/workflows.md | 6 + website/zh-CN/api/index.md | 2 +- 32 files changed, 1479 insertions(+), 12 deletions(-) diff --git a/scripts/gen-website-api.ts b/scripts/gen-website-api.ts index 0420ad2ff3..480c19779f 100644 --- a/scripts/gen-website-api.ts +++ b/scripts/gen-website-api.ts @@ -20,11 +20,12 @@ * cannot land undocumented without CI going red. Pages are English (the * planned zh translation flow arrives separately; see docs/i18n/README.md). * - * Signature fences use the ` ```ts website-api ` info string: doc-typecheck - * only processes its known info strings, so these bare (non-compilable) - * signature fragments are skipped there, while VitePress still highlights the - * `ts` token. The sidebar fragment `website/.vitepress/config/api-sidebar.json` - * is generated alongside so navigation can never drift from the page set. + * Signature fences use the ` ```ts website-api ` info string and retain the + * declaration's original source JSDoc. doc-typecheck only processes its known + * info strings, so these bare (non-compilable) fragments are skipped there, + * while VitePress still highlights the `ts` token. The sidebar fragment + * `website/.vitepress/config/api-sidebar.json` is generated alongside so + * navigation can never drift from the page set. * * `tsx scripts/gen-website-api.ts` → write pages + sidebar * `tsx scripts/gen-website-api.ts --check` → exit 1 if committed copies are @@ -64,6 +65,8 @@ interface MemberDoc { heading: string /** All overload signature lines (bodies stripped). */ signatures: string[] + /** Original source JSDoc, dedented only from its containing declaration. */ + jsDoc: string /** Description prose, one paragraph per line. */ doc: string /** Parameter name → `@param` text, in declaration order. */ @@ -166,6 +169,20 @@ function load(rel: string): { sf: ts.SourceFile; text: string } { // The module-merge walk (cordisModuleBody / eventMembers / serviceClasses) is // shared with gen-cordis-catalog.ts via cordis-walk.ts. +/** Original JSDoc with only the source container's indentation removed. */ +function sourceJSDoc(text: string, sf: ts.SourceFile, node: ts.Node): string { + const raw = rawJsDoc(text, node) + if (raw === '') return '' + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)) + const lineStart = sf.getPositionOfLineAndCharacter(line, 0) + const indent = text.slice(lineStart, node.getStart(sf)) + return raw.split('\n') + .map((sourceLine, index) => index > 0 && sourceLine.startsWith(indent) + ? sourceLine.slice(indent.length) + : sourceLine) + .join('\n') +} + /** Signature text of a member: full text minus body/initializer, whitespace * collapsed, trailing semicolon stripped. */ function signatureOf(member: ts.Node, sf: ts.SourceFile): string { @@ -219,7 +236,7 @@ function memberDoc( const first = group[0] if (!first) throw new Error(`gen-website-api: empty member group for ${name}`) // Doc from the first overload that carries JSDoc prose. - const rawDocs = group.map(m => rawJsDoc(text, m)) + const rawDocs = group.map(m => sourceJSDoc(text, sf, m)) const docIndex = rawDocs.findIndex(r => parseJsDoc(r).doc !== '') const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '') const doc = parseJsDoc(raw).doc @@ -255,6 +272,7 @@ function memberDoc( signatures: (ts.isMethodDeclaration(first) && funcLike.length > 1 ? funcLike.filter(m => ts.isMethodDeclaration(m) && !m.body) : group).map(m => signatureOf(m, sf)), + jsDoc: raw, doc, params, returns: returnsText, @@ -424,8 +442,13 @@ function declPaste(rel: string, symbol: string): { doc: string; code: string; so if (matches.length === 0) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`) const first = matches[0] if (!first) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`) - const doc = parseJsDoc(rawJsDoc(text, first)).doc - const code = matches.map(s => stripBodies(s, sf).replace(/^export\s+(default\s+)?/, '')).join('\n\n') + const firstJSDoc = sourceJSDoc(text, sf, first) + const doc = parseJsDoc(firstJSDoc).doc + const code = matches.map((statement) => { + const jsDoc = sourceJSDoc(text, sf, statement) + const declaration = stripBodies(statement, sf).replace(/^export\s+(default\s+)?/, '') + return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}` + }).join('\n\n') return { doc, code, source: pointer(rel, sf, first) } } @@ -480,6 +503,8 @@ interface HarnessEvent { scope: string mode: Mode | null signature: string + /** Original source event JSDoc, dedented from its module/interface. */ + jsDoc: string doc: string params: { name: string; text: string }[] source: string @@ -494,7 +519,7 @@ function collectHarnessEvents(violations: string[]): HarnessEvent[] { const body = cordisModuleBody(sf) if (!body) continue for (const { name, member } of eventMembers(body, sf)) { - const raw = rawJsDoc(text, member) + const raw = sourceJSDoc(text, sf, member) const { doc, mode } = parseJsDoc(raw) if (!mode) violations.push(`event '${name}' (${pointer(rel, sf, member)}) is missing @mode.`) if (!doc) violations.push(`event '${name}' (${pointer(rel, sf, member)}) has no JSDoc prose.`) @@ -509,7 +534,7 @@ function collectHarnessEvents(violations: string[]): HarnessEvent[] { const tag = tags.get(pname) if (tag) params.push({ name: pname, text: tag }) } - events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), doc, params, source: pointer(rel, sf, member) }) + events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), jsDoc: raw, doc, params, source: pointer(rel, sf, member) }) } } return events.sort((a, b) => a.name.localeCompare(b.name)) @@ -548,6 +573,7 @@ function renderMember(prefix: string, m: MemberDoc): string[] { const call = m.heading === '' ? '' : m.heading lines.push(`### ${prefix}${m.name}${call}`, '') lines.push('```' + FENCE) + lines.push(m.jsDoc) for (const sig of m.signatures) lines.push(sig) lines.push('```', '') lines.push(...prose(m.doc), '') @@ -620,7 +646,7 @@ function renderEventsPage(events: HarnessEvent[]): string { for (const e of events.filter(ev => ev.scope === scope)) { lines.push(`### ${e.name}`, '') lines.push(`**Mode:** \`${e.mode ?? 'unknown'}\``, '') - lines.push('```' + FENCE, e.signature, '```', '') + lines.push('```' + FENCE, e.jsDoc, e.signature, '```', '') lines.push(...prose(e.doc), '') if (e.params.length > 0) { for (const p of e.params) lines.push(`- \`${p.name}\` — ${unlink(p.text)}`) @@ -653,6 +679,16 @@ export function generate(): Map { const events = collectHarnessEvents(violations) files.set(`${PAGES_DIR}/harness/events.md`, renderEventsPage(events)) + for (const [rel, content] of files) { + if (!rel.endsWith('.md')) continue + for (const match of content.matchAll(/^```ts website-api\n([\s\S]*?)\n```$/gm)) { + const body = match[1] ?? '' + if (!body.startsWith('/**')) { + violations.push(`${rel}: a ts website-api fence does not begin with original source JSDoc.`) + } + } + } + reportViolations('gen-website-api', violations) const sidebar = { diff --git a/website/zh-CN/api/cordis/context.md b/website/zh-CN/api/cordis/context.md index cc08f4e28a..0fbb2fcc70 100644 --- a/website/zh-CN/api/cordis/context.md +++ b/website/zh-CN/api/cordis/context.md @@ -12,6 +12,15 @@ A context is a proxy: normal property reads go through the service resolver, whi ### ctx.extend(meta?) ```ts website-api +/** + * Create a child context with extra metadata on top of the current scope. + * + * The child prototypally inherits every property of this context; own + * properties of `meta` shadow the inherited ones. The parent is not mutated. + * + * @param meta — own properties (including symbol keys) to define on the child. + * @returns a child context inheriting from this one. + */ extend(meta = {}): this ``` @@ -27,6 +36,18 @@ The child prototypally inherits every property of this context; own properties o ### ctx.isolate(name, label?) ```ts website-api +/** + * Create a child context with an independent service scope for `name`. + * + * Below the returned context, reads and writes of the service `name` + * resolve against the new label instead of the parent's, so a different + * implementation can be provided without affecting the parent scope. + * Passing the same `label` to two `isolate()` calls joins their scopes. + * + * @param name — the service name to isolate. + * @param label — scope label to join; defaults to a fresh unique symbol. + * @returns a child context whose `name` service resolves in the new scope. + */ isolate(name: string, label?: symbol) ``` @@ -43,6 +64,18 @@ Below the returned context, reads and writes of the service `name` resolve again ### ctx.intercept(name, config) ```ts website-api +/** + * Add service-specific intercept config for plugins started below this + * context. + * + * Plugins loaded under the returned context see `config` merged into the + * service's resolved config (ancestor entries first; see + * `Service[symbols.resolveConfig]`). The parent context is not affected. + * + * @param name — the service name whose config to intercept. + * @param config — the intercept config to merge for that service. + * @returns a child context carrying the additional intercept entry. + */ intercept(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this intercept(name: string, config: any): this ``` @@ -60,6 +93,7 @@ Plugins loaded under the returned context see `config` merged into the service's ### ctx.root ```ts website-api +/** The root context of the application (every child context shares it). @experimental */ root: this ``` @@ -70,6 +104,7 @@ The root context of the application (every child context shares it). @experiment ### ctx.baseUrl ```ts website-api +/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */ baseUrl?: string ``` @@ -80,6 +115,7 @@ Base URL used to resolve relative plugin/module specifiers, if the runtime sets ### ctx.events ```ts website-api +/** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */ events: EventsService ``` @@ -90,6 +126,7 @@ The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...) ### ctx.logger ```ts website-api +/** The logging service. Call `ctx.logger(name)` for a named logger. */ logger: LoggerService ``` @@ -100,6 +137,7 @@ The logging service. Call `ctx.logger(name)` for a named logger. ### ctx.reflect ```ts website-api +/** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */ reflect: ReflectService ``` @@ -110,6 +148,7 @@ The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). ### ctx.registry ```ts website-api +/** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */ registry: RegistryService ``` @@ -122,6 +161,7 @@ The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject ### Context.effect ```ts website-api +/** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */ static readonly effect: unique symbol ``` @@ -132,6 +172,7 @@ Symbol key under which a disposer exposes its EffectMeta diagnostics tree. ### Context.filter ```ts website-api +/** Symbol key for a context's listener filter, consulted on every event dispatch. */ static readonly filter: unique symbol ``` @@ -142,6 +183,7 @@ Symbol key for a context's listener filter, consulted on every event dispatch. ### Context.isolate ```ts website-api +/** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */ static readonly isolate: unique symbol ``` @@ -152,6 +194,7 @@ Symbol key of the isolation map (see the `Context[symbols.isolate]` property). ### Context.intercept ```ts website-api +/** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */ static readonly intercept: unique symbol ``` @@ -162,6 +205,15 @@ Symbol key of the intercept map (see the `Context[symbols.intercept]` property). ### Context.is(value) ```ts website-api +/** + * Returns true for Cordis context proxies and context prototypes. + * + * Works across realms and across multiple copies of cordis, because the + * brand is keyed by a global symbol rather than by `instanceof`. + * + * @param value — the value to test. + * @returns `true` if `value` is a Cordis context, narrowing its type. + */ static is(value: any): value is Context ``` @@ -179,6 +231,14 @@ Works across realms and across multiple copies of cordis, because the brand is k ### ctx.get(name, strict?) ```ts website-api +/** + * Read a service from the store without the inject requirement. + * + * @param name — the service name. + * @param strict — when `true` (default), only return implementations + * whose providing fiber is currently active. + * @returns the service value, or `undefined` when not (yet) provided. + */ get(name: K, strict?: boolean): undefined | this[K] get(name: string, strict?: boolean): any ``` @@ -195,6 +255,15 @@ Read a service from the store without the inject requirement. ### ctx.set(name, value) ```ts website-api +/** + * Overwrite a provided service's value. + * + * Only the fiber that provided the service may set it; setting an + * unprovided name throws. + * + * @param name — the service name. + * @param value — the new service value. + */ set(name: K, value: undefined | this[K]): void set(name: string, value: any): void ``` @@ -210,6 +279,18 @@ Only the fiber that provided the service may set it; setting an unprovided name ### ctx.provide(name, value) ```ts website-api +/** + * Register a service implementation owned by the current fiber. + * + * The service becomes visible to dependents in the same isolation scope + * once the fiber is active; it is unregistered (waking dependents) when + * the returned disposer runs or the fiber unloads. Throws if the name is + * already provided in this scope or declared as an accessor. + * + * @param name — the service name. + * @param value — the service value. + * @returns a disposer that unregisters the service. + */ provide(name: K, value: undefined | this[K]): () => void provide(name: string, value?: any): () => void ``` @@ -227,6 +308,15 @@ The service becomes visible to dependents in the same isolation scope once the f ### ctx.accessor(name, options) ```ts website-api +/** + * Define a computed context property backed by get/set hooks. + * + * The accessor is removed when the current fiber unloads. Throws if the + * name is already declared. + * + * @param name — the context property name. + * @param options — the `get` hook and optional `set` hook. + */ accessor(name: string, options: Omit): void ``` @@ -241,6 +331,16 @@ The accessor is removed when the current fiber unloads. Throws if the name is al ### ctx.mixin(name, mixins) ```ts website-api +/** + * Expose selected members of a service directly on `ctx`. + * + * Each mixed-in key becomes an accessor that forwards to the service + * (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. + * Mixins are removed when the current fiber unloads. + * + * @param name — the context property holding the source service. + * @param mixins — keys to forward, or a source-key → ctx-key map. + */ mixin(name: K, mixins: (keyof this & keyof this[K])[] | Dict): void mixin(source: T, mixins: (keyof this & keyof T)[] | Dict): void ``` diff --git a/website/zh-CN/api/cordis/events.md b/website/zh-CN/api/cordis/events.md index b56f8096fe..77488b5d24 100644 --- a/website/zh-CN/api/cordis/events.md +++ b/website/zh-CN/api/cordis/events.md @@ -7,6 +7,13 @@ The event system mixed into every context. Harness-defined events are cataloged ### ctx.parallel(name, ...args) ```ts website-api +/** + * Dispatch an event, running all listeners concurrently. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + * @returns a promise resolving once every listener has settled. + */ parallel(name: K, ...args: Parameters): Promise parallel(thisArg: NoInfer>, name: K, ...args: Parameters): Promise ``` @@ -23,6 +30,12 @@ Dispatch an event, running all listeners concurrently. ### ctx.emit(name, ...args) ```ts website-api +/** + * Dispatch an event synchronously, ignoring listener return values. + * + * @param name — the event name. + * @param args — arguments passed to every listener. + */ emit(name: K, ...args: Parameters): void emit(thisArg: NoInfer>, name: K, ...args: Parameters): void ``` @@ -37,6 +50,13 @@ Dispatch an event synchronously, ignoring listener return values. ### ctx.serial(name, ...args) ```ts website-api +/** + * Dispatch an event, awaiting listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ serial(name: K, ...args: Parameters): Promisify> serial(thisArg: NoInfer>, name: K, ...args: Parameters): Promisify> ``` @@ -53,6 +73,13 @@ Dispatch an event, awaiting listeners in order until one bails. ### ctx.bail(name, ...args) ```ts website-api +/** + * Dispatch an event, calling listeners in order until one bails. + * + * @param name — the event name. + * @param args — arguments passed to each listener. + * @returns the first bail value (non-null, non-false, non-undefined), if any. + */ bail(name: K, ...args: Parameters): ReturnType bail(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType ``` @@ -69,6 +96,16 @@ Dispatch an event, calling listeners in order until one bails. ### ctx.waterfall(name, ...args) ```ts website-api +/** + * Dispatch an event whose last argument is a `next` continuation. + * + * Each listener wraps the rest of the chain: calling `next()` invokes the + * next listener (finally the built-in behavior); not calling it vetoes. + * + * @param name — the event name. + * @param args — listener arguments; the final one is the innermost `next`. + * @returns the outermost listener's return value. + */ waterfall(name: K, ...args: Parameters): ReturnType waterfall(thisArg: NoInfer>, name: K, ...args: Parameters): ReturnType ``` @@ -86,6 +123,14 @@ Each listener wraps the rest of the chain: calling `next()` invokes the next lis ### ctx.on(name, listener, options?) ```ts website-api +/** + * Register an event listener owned by the current fiber. + * + * @param name — the event name to listen for. + * @param listener — called with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ on(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean ``` @@ -102,6 +147,14 @@ Register an event listener owned by the current fiber. ### ctx.once(name, listener, options?) ```ts website-api +/** + * Same as `on()`, but the listener disposes itself after its first call. + * + * @param name — the event name to listen for. + * @param listener — called at most once with the dispatch arguments. + * @param options — listener options; a boolean is shorthand for `prepend`. + * @returns a disposer removing the listener; `true` if it was still registered. + */ once(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean ``` @@ -120,6 +173,7 @@ Same as `on()`, but the listener disposes itself after its first call. Options accepted by `ctx.on()` and `ctx.once()`. ```ts website-api +/** Options accepted by `ctx.on()` and `ctx.once()`. */ interface EventOptions { /** Add the listener before existing listeners for the same event. */ prepend?: boolean @@ -136,6 +190,14 @@ Event dispatch strategy used by the event service. `emit` runs synchronous listeners without awaiting them, `parallel` awaits all listeners together, `serial` awaits them in order until one bails, `bail` stops on the first synchronous bail value, and `waterfall` composes listeners around a final `next` callback. ```ts website-api +/** + * Event dispatch strategy used by the event service. + * + * `emit` runs synchronous listeners without awaiting them, `parallel` awaits + * all listeners together, `serial` awaits them in order until one bails, + * `bail` stops on the first synchronous bail value, and `waterfall` composes + * listeners around a final `next` callback. + */ type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' ``` diff --git a/website/zh-CN/api/cordis/fiber.md b/website/zh-CN/api/cordis/fiber.md index db93a6d273..f79adbaf20 100644 --- a/website/zh-CN/api/cordis/fiber.md +++ b/website/zh-CN/api/cordis/fiber.md @@ -7,6 +7,19 @@ A fiber is one loaded plugin instance: its lifecycle state, validated config, an ### ctx.effect(execute, label?) ```ts website-api +/** + * Register a cleanup-aware effect on this fiber. + * + * `execute` runs immediately; the disposers it produces are collected and + * run (in reverse order) either when the returned disposer is called or + * when the fiber unloads, whichever comes first. Calling the disposer twice + * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is + * already disposed, and `TypeError` if `execute` returns an invalid shape. + * + * @param execute — the effect body; see {@link Effect} for accepted shapes. + * @param label — effect label shown in `getEffects()` diagnostics. + * @returns a disposer that tears the effect down and settles once done. + */ effect(execute: () => SyncEffect, label?: string): Disposable> effect(execute: () => Effect, label?: string): AsyncDisposable> ``` @@ -24,6 +37,7 @@ Register a cleanup-aware effect on this fiber. ### ctx.fiber ```ts website-api +/** The fiber (plugin runtime instance) that owns this context. */ fiber: Fiber ``` @@ -41,6 +55,7 @@ A fiber tracks dependency state, validated config, lifecycle effects, and cleanu ### fiber.uid ```ts website-api +/** Unique id within the registry; 0 for the root fiber, `null` once disposed. */ public uid: number | null ``` @@ -51,6 +66,7 @@ Unique id within the registry; 0 for the root fiber, `null` once disposed. ### fiber.ctx ```ts website-api +/** The context this fiber's plugin runs in (extends the parent context). */ public readonly ctx: Context ``` @@ -61,6 +77,7 @@ The context this fiber's plugin runs in (extends the parent context). ### fiber.config ```ts website-api +/** The validated plugin config (updated by `update()`). */ public config: any ``` @@ -71,6 +88,7 @@ The validated plugin config (updated by `update()`). ### fiber.state ```ts website-api +/** Current lifecycle state; transitions emit `internal/status`. */ public state ``` @@ -81,6 +99,7 @@ Current lifecycle state; transitions emit `internal/status`. ### fiber.dispose ```ts website-api +/** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ public readonly dispose: () => Promise ``` @@ -91,6 +110,7 @@ Dispose this fiber: unload the plugin, then settle once cleanup finished. ### fiber.store ```ts website-api +/** Snapshot of required service implementations while loaded; `undefined` otherwise. */ public store: Dict | undefined ``` @@ -101,6 +121,7 @@ Snapshot of required service implementations while loaded; `undefined` otherwise ### fiber.inertia ```ts website-api +/** The in-flight load/unload transition, if one is currently running. */ public inertia: Promise | undefined ``` @@ -111,6 +132,7 @@ The in-flight load/unload transition, if one is currently running. ### fiber.name ```ts website-api +/** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */ get name() ``` @@ -121,6 +143,12 @@ The plugin's display name, inherited from the nearest named ancestor, else `'roo ### fiber.assertActive() ```ts website-api +/** + * Throw if the fiber has already been disposed. + * + * @returns nothing when the fiber is still active. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared. + */ assertActive() ``` @@ -133,6 +161,19 @@ Throw if the fiber has already been disposed. ### fiber.effect(execute, label?) ```ts website-api +/** + * Register a cleanup-aware effect on this fiber. + * + * `execute` runs immediately; the disposers it produces are collected and + * run (in reverse order) either when the returned disposer is called or + * when the fiber unloads, whichever comes first. Calling the disposer twice + * is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is + * already disposed, and `TypeError` if `execute` returns an invalid shape. + * + * @param execute — the effect body; see {@link Effect} for accepted shapes. + * @param label — effect label shown in `getEffects()` diagnostics. + * @returns a disposer that tears the effect down and settles once done. + */ effect(execute: () => SyncEffect, label?: string): Disposable> effect(execute: () => Effect, label?: string): AsyncDisposable> ``` @@ -150,6 +191,11 @@ Register a cleanup-aware effect on this fiber. ### fiber.getEffects() ```ts website-api +/** + * Return metadata for currently registered effects. + * + * @returns one {@link EffectMeta} tree per labeled live effect. + */ getEffects() ``` @@ -162,6 +208,12 @@ Return metadata for currently registered effects. ### fiber.await() ```ts website-api +/** + * Wait for current lifecycle work and rethrow startup errors. + * + * @returns this fiber, once it has settled into a stable state. + * @throws the config-validation or plugin-startup error, if any. + */ async await() ``` @@ -174,6 +226,12 @@ Wait for current lifecycle work and rethrow startup errors. ### fiber.restart() ```ts website-api +/** + * Dispose and immediately reload this plugin with its current config. + * + * @returns a promise resolving once the reload settled. + * @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed. + */ async restart() ``` @@ -186,6 +244,17 @@ Dispose and immediately reload this plugin with its current config. ### fiber.update(config, noSave?) ```ts website-api +/** + * Validate and apply new config, then restart the plugin. + * + * Runs the `internal/update` waterfall first, so update hooks (and HMR) + * can veto or replace the restart. + * + * @param config — the new raw config; validated before anything restarts. + * @param noSave — hint for persistence hooks not to write the change back. + * @returns nothing; the restart runs behind the `internal/update` waterfall. + * @throws {ValidationError} when the new config fails validation. + */ update(config: any, noSave = false) ``` @@ -205,6 +274,13 @@ Effect body result accepted by `ctx.effect()` and plugin startup. Either a single disposer, a promise of one, or a (possibly async) iterable yielding several — generator effects register each yielded disposer as it is produced. ```ts website-api +/** + * Effect body result accepted by `ctx.effect()` and plugin startup. + * + * Either a single disposer, a promise of one, or a (possibly async) iterable + * yielding several — generator effects register each yielded disposer as it + * is produced. + */ type Effect = | SyncEffect | AsyncEffect @@ -218,6 +294,12 @@ Function returned by an effect to release resources during disposal. Disposers run in reverse registration order when the owning fiber unloads; they may be async, in which case unloading awaits them. ```ts website-api +/** + * Function returned by an effect to release resources during disposal. + * + * Disposers run in reverse registration order when the owning fiber unloads; + * they may be async, in which case unloading awaits them. + */ type Disposable = () => T ``` @@ -228,6 +310,7 @@ type Disposable = () => T Tree node used to expose nested effect labels for diagnostics. ```ts website-api +/** Tree node used to expose nested effect labels for diagnostics. */ interface EffectMeta { /** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */ label: string @@ -243,6 +326,7 @@ interface EffectMeta { Framework error with a stable machine-readable code. ```ts website-api +/** Framework error with a stable machine-readable code. */ class CordisError extends Error { /** * @param code — the stable error code; also the default message. @@ -251,6 +335,7 @@ class CordisError extends Error { constructor(public code: CordisError.Code, message?: string) } +/** Cordis error code definitions. */ namespace CordisError { export type Code = keyof typeof Code @@ -267,6 +352,7 @@ namespace CordisError { Error raised when plugin configuration fails standard-schema validation. ```ts website-api +/** Error raised when plugin configuration fails standard-schema validation. */ class ValidationError extends TypeError { name = 'ValidationError' diff --git a/website/zh-CN/api/cordis/registry.md b/website/zh-CN/api/cordis/registry.md index 55f6d666e5..f91f5a72af 100644 --- a/website/zh-CN/api/cordis/registry.md +++ b/website/zh-CN/api/cordis/registry.md @@ -7,6 +7,16 @@ Plugin loading and dependency injection. ### ctx.inject(deps, callback) ```ts website-api +/** + * Run a callback once the requested services are available. + * + * Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback + * is unloaded and re-run whenever a required service changes. + * + * @param deps — required services, as an array or a name → config map. + * @param callback — plugin body called with `(ctx, config)`. + * @returns the fiber; awaiting it settles once loading finished. + */ inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike ``` @@ -23,6 +33,14 @@ Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloade ### ctx.plugin(plugin, ...args) ```ts website-api +/** + * Load a plugin in the current context. + * + * @param plugin — a function, class, or `{ apply }` object plugin. + * @param args — the plugin config, validated against its `Config` schema. + * @returns the fiber; awaiting it settles once loading finished + * (rejecting on config or startup errors). + */ plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike ``` @@ -40,11 +58,13 @@ Load a plugin in the current context. Supported plugin entrypoint shapes. ```ts website-api +/** Supported plugin entrypoint shapes. */ type Plugin = | Plugin.Function | Plugin.Constructor | Plugin.Object +/** Types associated with plugin entrypoints and runtime records. */ namespace Plugin { /** Shared metadata understood by the plugin registry and related tooling. */ export interface Base { @@ -104,8 +124,16 @@ Service dependency declaration accepted by plugins and the `@Inject` decorator. Array form requests services without intercept config. Object form maps each service name to optional intercept config for the plugin context. ```ts website-api +/** + * Service dependency declaration accepted by plugins and the `@Inject` + * decorator. + * + * Array form requests services without intercept config. Object form maps each + * service name to optional intercept config for the plugin context. + */ type Inject = (keyof M)[] | { [K in keyof M]?: M[K] } +/** Utilities for normalizing plugin dependency declarations. */ namespace Inject { /** * Convert array/object/class-inherited inject metadata into a plain map. diff --git a/website/zh-CN/api/cordis/service.md b/website/zh-CN/api/cordis/service.md index acd43163d6..13aa82a2ca 100644 --- a/website/zh-CN/api/cordis/service.md +++ b/website/zh-CN/api/cordis/service.md @@ -12,6 +12,7 @@ Subclasses call `super(ctx, name)` from their constructor. The service is regist ### service.name ```ts website-api +/** The service name this instance is registered under. */ public name!: string ``` @@ -24,6 +25,7 @@ The service name this instance is registered under. ### Service.init ```ts website-api +/** Symbol key of an instance method run after construction (class plugins). */ static readonly init: unique symbol ``` @@ -34,6 +36,7 @@ Symbol key of an instance method run after construction (class plugins). ### Service.check ```ts website-api +/** Symbol key of the availability predicate passed to `ctx.provide()`. */ static readonly check: unique symbol ``` @@ -44,6 +47,7 @@ Symbol key of the availability predicate passed to `ctx.provide()`. ### Service.config ```ts website-api +/** Symbol key of the phantom intercept-config type parameter. */ static readonly config: unique symbol ``` @@ -54,6 +58,7 @@ Symbol key of the phantom intercept-config type parameter. ### Service.invoke ```ts website-api +/** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */ static readonly invoke: unique symbol ``` @@ -64,6 +69,7 @@ Symbol key of the call body making a service callable (e.g. `ctx.logger()`). ### Service.extend ```ts website-api +/** Symbol key of the helper deriving an extended service instance. */ static readonly extend: unique symbol ``` @@ -74,6 +80,7 @@ Symbol key of the helper deriving an extended service instance. ### Service.tracker ```ts website-api +/** Symbol key of the tracker metadata used for context tracing. */ static readonly tracker: unique symbol ``` @@ -84,6 +91,7 @@ Symbol key of the tracker metadata used for context tracing. ### Service.resolveConfig ```ts website-api +/** Symbol key of the intercept-config resolution helper below. */ static readonly resolveConfig: unique symbol ``` diff --git a/website/zh-CN/api/harness/agent-loop.md b/website/zh-CN/api/harness/agent-loop.md index 6401898e24..9a43a1ba14 100644 --- a/website/zh-CN/api/harness/agent-loop.md +++ b/website/zh-CN/api/harness/agent-loop.md @@ -11,6 +11,15 @@ Concrete agent factory and driver service. ### ctx.agentLoop.create(id, options?, meta?) ```ts website-api +/** + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. + * @param options - concrete loop options. + * @param meta - optional fresh-session workspace metadata. + * @returns the published running agent. + */ create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent ``` @@ -27,6 +36,12 @@ Create an agent and session under one caller-supplied identity, owned by the acc ### ctx.agentLoop.createAgent(ownerCtx, options) ```ts website-api +/** + * Create an owned agent on a caller-supplied session id. + * @param ownerCtx - caller context that structurally owns the transaction. + * @param options - identities, session seed/metadata, loop options, setup, and cancellation. + * @returns the published handle. + */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` @@ -42,6 +57,12 @@ Create an owned agent on a caller-supplied session id. ### ctx.agentLoop.resume(ownerCtx, options) ```ts website-api +/** + * Resume an owned agent from the configured persistence service. + * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. + * @param options - persisted identity, loop options, setup, and cancellation. + * @returns the published handle. + */ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index 8d6c84cc7a..f92f69bbeb 100644 --- a/website/zh-CN/api/harness/agents.md +++ b/website/zh-CN/api/harness/agents.md @@ -11,6 +11,18 @@ Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator ### ctx.agents.setFactory(factory) ```ts website-api +/** + * Register the agent-creation factory (the loop calls this on construction, + * effect-scoped). A traced Cordis service is canonicalized to its concrete + * target; each create/resume call is then traced through that caller's + * context so ownership follows the caller without stacking proxy layers. + * Throws if a factory is already registered. Returns the disposer; on + * dispose the factory slot is cleared. + * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. + * @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. + */ setFactory(factory: AgentFactory): () => void ``` @@ -25,6 +37,15 @@ Register the agent-creation factory (the loop calls this on construction, effect ### ctx.agents.create(options) ```ts website-api +/** + * Create and publish a new agent through the registered factory. + * Distinct from {@link register} (which records an already-constructed + * agent): this constructs the agent and its session. Rejects if no factory is + * registered or creation/setup fails. The resolved {@link AgentHandle} lets + * the owner tear down exactly this agent. + * @param options - shared identity, session seed/metadata, and agent options. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async create(options: CreateAgentOptions): Promise ``` @@ -39,6 +60,13 @@ Create and publish a new agent through the registered factory. Distinct from reg ### ctx.agents.resume(options) ```ts website-api +/** + * Load a persisted session and resume an agent on it through the registered + * factory. Rejects if no factory is registered; the factory rejects if + * session persistence is not configured or persistence/setup fails. + * @param options - persisted identity, configuration, and optional setup. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async resume(options: ResumeAgentOptions): Promise ``` @@ -53,6 +81,24 @@ Load a persisted session and resume an agent on it through the registered factor ### ctx.agents.register(agent) ```ts website-api +/** + * Register a live agent. Throws if an agent with the same id is already + * registered. Emits `agent/created` on registration and `agent/disposed` + * when the calling fiber is disposed — both with the agent's scope carrier + * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the + * emits are scope-filtered regardless of which context invoked `register` + * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always + * requires passing the carrier). Returns the disposer. + * @param agent - the already-constructed agent to record in the store. + * @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. + */ register(agent: Agent): () => void ``` @@ -67,6 +113,21 @@ Register a live agent. Throws if an agent with the same id is already registered ### ctx.agents.enter(agent, owner) ```ts website-api +/** + * Insert an already-constructed agent without announcing it. This is the + * advanced ordered-lifecycle primitive used by the async agent factory: it + * first completes setup while the agent is unpublished, then assigns the + * returned detach closure into its pre-installed composite teardown before + * calling {@link announce}. Ordinary callers use {@link register}. + * @param agent - the prepared, unpublished agent. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. + * @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. + */ enter(agent: Agent, owner: Agent | undefined): () => void ``` @@ -82,6 +143,13 @@ Insert an already-constructed agent without announcing it. This is the advanced ### ctx.agents.announce(agent) ```ts website-api +/** + * Announce an agent previously inserted with {@link enter}. + * @param agent - the live inserted agent to announce. + * @throws if `agent` is not the exact live registry entry for its id, or its + * creation announcement already began (including a reentrant call from a + * creation listener). + */ announce(agent: Agent): void ``` @@ -94,6 +162,11 @@ Announce an agent previously inserted with enter. ### ctx.agents.get(id) ```ts website-api +/** + * Look up a live agent. + * @param id - the shared agent/session id to look up. + * @returns the agent, or undefined when no live agent has that id. + */ get(id: SessionId): Agent | undefined ``` @@ -108,6 +181,14 @@ Look up a live agent. ### ctx.agents.isOwnedBy(id, owner) ```ts website-api +/** + * Test whether a live agent was created through one exact parent agent's + * scoped context. Runtime ownership is independent of durable session + * lineage and remains unambiguous when unrelated providers reuse an id. + * @param id - the candidate child agent's shared agent/session id. + * @param owner - the expected runtime creator agent. + * @returns true only while the exact child entry is live under that owner. + */ isOwnedBy(id: SessionId, owner: Agent): boolean ``` @@ -123,6 +204,10 @@ Test whether a live agent was created through one exact parent agent's scoped co ### ctx.agents.list() ```ts website-api +/** + * All live agents, in registration order. + * @returns a fresh array; mutating it does not affect the registry. + */ list(): Agent[] ``` @@ -135,6 +220,12 @@ All live agents, in registration order. ### ctx.agents.roots() ```ts website-api +/** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ roots(): Agent[] ``` diff --git a/website/zh-CN/api/harness/approval.md b/website/zh-CN/api/harness/approval.md index fe3b090016..4aa2b8ad2d 100644 --- a/website/zh-CN/api/harness/approval.md +++ b/website/zh-CN/api/harness/approval.md @@ -11,6 +11,24 @@ Approval service that applies session policy before answerers and logs every ask ### ctx.approval.request(req) ```ts website-api +/** + * Ask the composed answerers to decide one readonly same-process request. + * The service borrows the request, agent, session, and live signal directly. + * The request requires an open turn because the audit pair must be enclosed + * by the durable log's commit/replay boundary; an idle ask rejects before + * appending anything. The answerer phase always produces an outcome: an + * aborted signal yields `'cancelled'`, a missing or throwing answerer yields + * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is + * normalized to `'unavailable'`. A failure that prevents either audit append + * from committing still rejects because returning an unlogged decision would + * violate the pair. Session contains post-commit observer failures, so an + * authoritative append cannot reject the request or suppress its matching + * audit event. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @returns the closed outcome; `'allowed-once'` is the only grant. + * @throws when no turn is open or either audit event fails before the session + * append commit point. + */ async request(req: ApprovalRequest): Promise ``` diff --git a/website/zh-CN/api/harness/bash-env.md b/website/zh-CN/api/harness/bash-env.md index f8baf32bac..906464080e 100644 --- a/website/zh-CN/api/harness/bash-env.md +++ b/website/zh-CN/api/harness/bash-env.md @@ -11,6 +11,12 @@ Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The names ### ctx.bashEnv.register(contributor) ```ts website-api +/** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ register(contributor: BashEnvContributor): () => void ``` @@ -25,6 +31,11 @@ Register one environment contributor. Names and keys are unique; built-in keys a ### ctx.bashEnv.collect(execution) ```ts website-api +/** + * Build the trusted `DSH_*` snapshot for one bash tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ collect(execution: ToolExecution): DshEnvironment ``` @@ -39,6 +50,10 @@ Build the trusted `DSH_*` snapshot for one bash tool execution. ### ctx.bashEnv.list() ```ts website-api +/** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ list(): BashEnvVariableInfo[] ``` diff --git a/website/zh-CN/api/harness/bash.md b/website/zh-CN/api/harness/bash.md index c3e4173763..f340697063 100644 --- a/website/zh-CN/api/harness/bash.md +++ b/website/zh-CN/api/harness/bash.md @@ -16,6 +16,11 @@ Implementations must honor these semantics: ### ctx.bash.sandboxMode ```ts website-api +/** + * The sandbox mode this executor applies by default, or `undefined` when it + * does not sandbox commands. + * @returns the configured default sandbox mode, when supported. + */ get sandboxMode(): SandboxMode | undefined ``` @@ -26,6 +31,12 @@ The sandbox mode this executor applies by default, or `undefined` when it does n ### ctx.bash.resolve(request) ```ts website-api +/** + * Apply implementation-owned defaults and caps to a request before execution. + * @param request - the caller's request; omitted fields get this + * implementation's defaults, capped fields are clamped. + * @returns the fully-specified spec to hand to {@link run}/{@link start}. + */ abstract resolve(request: BashExecRequest): BashExecSpec ``` @@ -40,6 +51,12 @@ Apply implementation-owned defaults and caps to a request before execution. ### ctx.bash.run(spec) ```ts website-api +/** + * Run a command in the foreground; resolves when it finishes. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the outcome; nonzero exits, timeout kills, and abort kills + * resolve with a descriptive result rather than reject. + */ abstract run(spec: BashExecSpec): Promise ``` @@ -54,6 +71,11 @@ Run a command in the foreground; resolves when it finishes. ### ctx.bash.start(spec) ```ts website-api +/** + * Start a background process and return its handle immediately. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the live process handle (reads, kill, quiescence promise). + */ abstract start(spec: BashExecSpec): BashProcess ``` diff --git a/website/zh-CN/api/harness/code-runtime.md b/website/zh-CN/api/harness/code-runtime.md index ee8300a9f0..fef72ce525 100644 --- a/website/zh-CN/api/harness/code-runtime.md +++ b/website/zh-CN/api/harness/code-runtime.md @@ -11,6 +11,13 @@ Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and subs ### ctx.codeRuntime.language ```ts website-api +/** + * The source language {@link run} expects `program` to be written in, as a + * lowercase identifier. Informational, not gating — a consumer that + * generates language-specific presentation (typed SDK stubs, usage + * instructions) switches on it and fails loud on a language it cannot + * present. Well-known value: `'typescript'`. + */ abstract readonly language: string ``` @@ -21,6 +28,12 @@ The source language run expects `program` to be written in, as a lowercase ident ### ctx.codeRuntime.isolation ```ts website-api +/** + * The execution substrate, as a lowercase identifier. Informational, not + * gating — a descriptor so deployments and diagnostics can tell backends + * apart, not a security claim. Well-known values: `'worker-thread'`, + * `'process'`, `'container'`. + */ abstract readonly isolation: string ``` @@ -31,6 +44,15 @@ The execution substrate, as a lowercase identifier. Informational, not gating ### ctx.codeRuntime.run(request) ```ts website-api +/** + * Execute one program against the request's bindings and capture what it + * emitted. See the class doc for the resolution contract (error is a result + * field; rejection means seam misuse only). + * @param request - the program, its bindings, and the abort signal; the + * request carries everything the runtime acts on, with no hidden defaults. + * @returns the run's outcome: completion value (when transferable), the + * ordered log capture, and the failure (if any). + */ abstract run(request: CodeRunRequest): Promise ``` diff --git a/website/zh-CN/api/harness/compact.md b/website/zh-CN/api/harness/compact.md index 67f17cc941..e06188ee27 100644 --- a/website/zh-CN/api/harness/compact.md +++ b/website/zh-CN/api/harness/compact.md @@ -11,6 +11,21 @@ Abstract compaction service. Implementations own trigger policy, retention, and ### ctx.compact.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal) ```ts website-api +/** + * Check token pressure and compact if the conversation is too large. + * Estimate the next request, including its session prefix, derived history, + * and system prompt. Above threshold, compact a head-anchored range ending at + * a balanced tool boundary and reconsolidate any prior automatic checkpoint. + * Return `null` when no compaction is needed or an open tail leaves no safe + * cutoff. A single oversized retained unit or prefix cannot be repaired here. + * + * @param agent - agent context owning the session surface and model options. + * @param fullSystemPrompt - assembled system prompt, counted toward the estimate. + * @param sessionPrefix - the instance's composed session prefix, counted toward the + * estimate. + * @param signal - cancellation signal; model-backed implementations must forward it. + * @returns the compaction result, or `null` if no compaction was needed. + */ abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise ``` @@ -28,6 +43,23 @@ Check token pressure and compact if the conversation is too large. Estimate the ### ctx.compact.compactRegion(start, end, agent, signal?) ```ts website-api +/** + * Forcibly compact a range of surface nodes into a single summary node. + * `start` and `end` name an inclusive span by surface position, not numeric seq + * order; replacements can make visible seqs non-monotonic. Both edges must be + * balanced so assistant tool calls remain paired with their results. A model- + * backed implementation forwards cancellation and rejects active, missing, + * reversed, or unbalanced ranges. The target session is `agent.session`. + * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} + * for the edge checks. + * + * @param start - first surface seq, inclusive. + * @param end - last surface seq, inclusive. + * @param agent - context whose session is mutated and whose routing options guide summarization. + * @param signal - optional cancellation; model-backed implementations must forward it. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the appended event seqs, summary, replaced range, and token accounting. + */ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 8d494739ba..fcfa426a81 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -11,6 +11,16 @@ Every event the harness packages declare on the cordis event bus (40 total), gro **Mode:** `emit` ```ts website-api +/** + * A fully configured agent and live session were published. Setup is + * composition-only; `agent/session-start` is the first startup-driving seam. + * Synchronous listener failure vetoes publication, while returned-promise + * rejection is reported. Detach requested during dispatch waits until every + * creation listener has observed the stable entry. + * @param agent - the newly registered agent with its live session and completed setup. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/created'(this: Scoped, agent: Agent): void ``` @@ -25,6 +35,14 @@ A fully configured agent and live session were published. Setup is composition-o **Mode:** `emit` ```ts website-api +/** + * An agent left the registry; AgentLoop emits this after driver quiescence + * but before session detachment and scoped-registration unwind. Custom + * registry users own their driver-ordering contract. + * @param agent - the exact agent removed from the registry. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/disposed'(this: Scoped, agent: Agent): void ``` @@ -39,6 +57,16 @@ An agent left the registry; AgentLoop emits this after driver quiescence but bef **Mode:** `emit` ```ts website-api +/** + * A step or turn errored. The loop reports a failure here (plus the logger) + * even when the error has no in-turn position for a session `error` event. + * @param agent - the agent whose turn errored. + * @param turn - the turn in which the failure surfaced. + * @param step - the step at which the failure surfaced. + * @param error - the failure, verbatim. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void ``` @@ -56,6 +84,22 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w **Mode:** `serial` ```ts website-api +/** + * Awaited serial checkpoint for session-surface mutation after prompt + * assembly and before `step/start`; appends land outside the pending step. + * The loop derives history once afterward, so compaction records and + * replacements are included without rewriting an assembled request. The + * prompt and prefix are the exact pressure inputs for that request, and + * `signal` cancels listener work. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent opening the step. + * @param turn - the open turn number. + * @param step - the pending step number. + * @param fullSystemPrompt - the assembled prompt. + * @param sessionPrefix - the frozen request prefix. + * @param signal - the turn abort signal. + * @mode serial + */ 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void ``` @@ -75,6 +119,15 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and **Mode:** `waterfall` ```ts website-api +/** + * Allow, rewrite, or block one drained prompt before it becomes a user + * message. Call `next()` for the unchanged default. + * @param agent - the agent draining its inbox. + * @param content - the drained message's blocks, as queued. + * @param source - the message's resolved source. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` @@ -91,6 +144,15 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca **Mode:** `emit` ```ts website-api +/** + * Detached, frozen content entered the agent's inbox. Source defaults have + * already been applied, so these are the exact values retained for the log. + * @param agent - the agent whose inbox received the message. + * @param content - the accepted content blocks retained by the inbox. + * @param info - the accepted source plus whether it entered as steering. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void ``` @@ -107,6 +169,17 @@ Detached, frozen content entered the agent's inbox. Source defaults have already **Mode:** `waterfall` ```ts website-api +/** + * Replace the frozen call configuration. Model-visible content must use + * logged channels; this seam cannot mutate messages. Injection here joins + * the next request because the current step boundary is already fixed. + * @param agent - the agent making the model call. + * @param turn - the open turn number. + * @param step - the step whose request this is. + * @param config - the config the loop would use (frozen); return a replacement to switch. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise ``` @@ -124,6 +197,20 @@ Replace the frozen call configuration. Model-visible content must use logged cha **Mode:** `waterfall` ```ts website-api +/** + * Compose request-only messages placed before derived history. The frozen + * result is computed once per loop instance, logged on its anchoring request + * header, and reused so the provider prefix remains stable. Interrupted + * composition is discarded. Composition precedes the first `agent/pre-step` + * and request boundary, so listener appends join the current request and + * pressure accounting sees the composed prefix. Changing context belongs in + * history; contributors should prepend to `await next()` to preserve registration order. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent whose session prefix is being composed. + * @param prefix - the frozen seed; return an extended replacement. + * @param signal - aborts composition when the step is torn down. + * @mode waterfall + */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise ``` @@ -140,6 +227,16 @@ Compose request-only messages placed before derived history. The frozen result i **Mode:** `emit` ```ts website-api +/** + * The session lifecycle began, once before the first turn. Use + * `agent.inject()` to seed model-facing context. This is a notification, not + * a veto; disposal requested by a lifecycle owner is rechecked before the + * driver starts. + * @param agent - the agent whose session lifecycle began. + * @param source - why the session started (fresh startup, resume, …). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void ``` @@ -155,6 +252,14 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to **Mode:** `emit` ```ts website-api +/** + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does + * not enter `running` synchronously; drive lifecycle from this event. + * @param agent - the agent whose status flipped. + * @param status - the status just entered (the transition's destination). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void ``` @@ -170,6 +275,16 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no **Mode:** `waterfall` ```ts website-api +/** + * Waterfall: post-process the assembled assistant {@link Message} before + * tool dispatch (validation, content rewriting, …). + * @param agent - the agent that received the step's response. + * @param turn - the open turn number. + * @param step - the step that produced the message. + * @param message - the assistant message as assembled from the stream. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise ``` @@ -187,6 +302,15 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va **Mode:** `waterfall` ```ts website-api +/** + * Override whether the turn continues. The default continues after tool + * calls or steering and stops otherwise; a continue reason becomes steering. + * @param agent - the agent deciding whether to run another step. + * @param turn - the turn being continued or stopped. + * @param defaultDecision - what the loop would do absent an override. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise ``` @@ -203,6 +327,15 @@ Override whether the turn continues. The default continues after tool calls or s **Mode:** `serial` ```ts website-api +/** + * Monotonic terminal-stop checkpoint after continuation and steering are + * folded; a stop remains authoritative through turn close and flush: + * steering queued in that window is discarded, while ordinary sends survive. + * @param agent - the agent whose composed continuation outcome may be stopped. + * @param turn - the turn at its terminal-stop checkpoint. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined ``` @@ -220,6 +353,15 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a **Mode:** `emit` ```ts website-api +/** + * A declarative agent entry failed before it could publish a live agent. + * Consumers that buffer work for the configured identity use this + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. + * @param sessionId - exact shared agent/session identity that failed startup. + * @param error - persistence, setup, or publication failure. + * @mode emit + */ 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` @@ -237,6 +379,13 @@ A declarative agent entry failed before it could publish a live agent. Consumers **Mode:** `waterfall` ```ts website-api +/** + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()`; failure yields the fail-closed default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @mode waterfall + */ 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise ``` @@ -253,6 +402,13 @@ Ask composed answerers for one decision. Return an outcome to claim the request **Mode:** `waterfall` ```ts website-api +/** + * Single-slot decision for the next {@link FileSystem.editText}. Calling + * `next()` yields an unconditional edit; the first returned guard wins. + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` @@ -268,6 +424,14 @@ Single-slot decision for the next FileSystem.editText. Calling `next()` yields a **Mode:** `emit` ```ts website-api +/** + * Record a successful observation. Listeners must be synchronous recorders: + * throws fail the tool call and returned promises are not awaited. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. + * @mode emit + */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void ``` @@ -284,6 +448,14 @@ Record a successful observation. Listeners must be synchronous recorders: throws **Mode:** `waterfall` ```ts website-api +/** + * Single-slot decision for the next {@link FileSystem.writeText}. Calling + * `next()` yields the bare provider's unconditional write; the first listener + * that returns an intent owns the decision rather than composing with peers. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` @@ -301,6 +473,17 @@ Single-slot decision for the next FileSystem.writeText. Calling `next()` yields **Mode:** `waterfall` ```ts website-api +/** + * Waterfall around every streaming model call (retry, replay, routing). + * Bound to the {@link LlmService}; call `next()` to reach the resolved + * adapter's stream, or yield your own chunks to short-circuit. + * @param options - the full request. A LOOP-built request arrives + * deep-frozen (mutation throws): its content is a pure function of the + * session log (the reconstructability RFC), so listeners read it, never + * rewrite it. A hand-built one-shot (compaction summarize) is the + * caller's own object and stays mutable here. + * @mode waterfall + */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` @@ -317,6 +500,17 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t **Mode:** `emit` ```ts website-api +/** + * Creation announcement during session publication. A synchronous throw vetoes and rolls + * back with a paired disposal; detach requested during dispatch is deferred. + * A returned-promise rejection is logged but cannot retroactively veto this + * synchronous boundary. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only sessions entered through that agent's context. + * @param session - the session just entered and announced. + * @dshScopeScan unsupported + * @mode emit + */ 'session/created'(this: Scoped, session: Session): void ``` @@ -331,6 +525,15 @@ Creation announcement during session publication. A synchronous throw vetoes and **Mode:** `emit` ```ts website-api +/** + * Emitted once when an announced session leaves the store, including + * publication rollback, but never for an entry whose creation announcement + * did not begin. Listener failures are logged and contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. + * @param session - the session that is no longer live in the store. + * @dshScopeScan unsupported + * @mode emit + */ 'session/disposed'(this: Scoped, session: Session): void ``` @@ -345,6 +548,17 @@ Emitted once when an announced session leaves the store, including publication r **Mode:** `emit` ```ts website-api +/** + * Post-commit, fire-and-forget append feed. The listener snapshot resolves + * before the log push, but callbacks run after it; observer failures are + * logged and contained without making the committed append fail. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * receive only events from sessions entered through that agent's context. + * @param session - the session whose log grew. + * @param event - the appended event, exactly as recorded. + * @dshScopeScan unsupported + * @mode emit + */ 'session/event'(this: Scoped, session: Session, event: SessionEvent): void ``` @@ -360,6 +574,15 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before **Mode:** `parallel` ```ts website-api +/** + * Awaited parallel durability checkpoint: every listener runs and the + * caller awaits all of them, with no waterfall veto. Dispatch through + * {@link SessionStore.flush}. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. + * @param session - the session whose buffered events must reach durable storage. + * @dshScopeScan unsupported + * @mode parallel + */ 'session/flush'(this: Scoped, session: Session): Promise | void ``` @@ -376,6 +599,14 @@ Awaited parallel durability checkpoint: every listener runs and the caller await **Mode:** `emit` ```ts website-api +/** + * A ready child settled. Scope-filtered dispatch uses the same delegating + * parent carrier as `subagent/start`, so the lifecycle pair reaches the + * same scoped audience. + * @param info - the run identity and terminal outcome. + * @dshScopeScan unsupported + * @mode emit + */ 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` @@ -390,6 +621,11 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c **Mode:** `emit` ```ts website-api +/** + * A provider became resolvable in the registry. + * @param provider - the registered provider. + * @mode emit + */ 'subagent/provider-added'(provider: SubagentProvider): void ``` @@ -404,6 +640,11 @@ A provider became resolvable in the registry. **Mode:** `emit` ```ts website-api +/** + * A provider left the registry. Accepted runs remain holder-owned. + * @param name - the provider name that no longer resolves. + * @mode emit + */ 'subagent/provider-removed'(name: string): void ``` @@ -418,6 +659,16 @@ A provider left the registry. Accepted runs remain holder-owned. **Mode:** `emit` ```ts website-api +/** + * A provider established a ready child. For in-process providers, + * `ctx.agents.get(info.id)` resolves during this notification. + * Scope-filtered dispatch keys the carrier by the delegating parent, so a + * parent-scoped listener observes only its own delegations. Paired with + * `subagent/end`. + * @param info - the provider and ready child identity. + * @dshScopeScan unsupported + * @mode emit + */ 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` @@ -434,6 +685,14 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( **Mode:** `waterfall` ```ts website-api +/** + * Expert waterfall over the assembled sections, tools, and variables. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners + * receive only that scope's assemblies. The returned value is authoritative. + * @param assembly - the mutable assembly built from registered providers. + * @param context - the caller's per-assembly context. + * @mode waterfall + */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` @@ -449,6 +708,11 @@ Expert waterfall over the assembled sections, tools, and variables. Scope-filter **Mode:** `emit` ```ts website-api +/** + * Emitted when any prompt provider changes. This registry notification is + * unfiltered because a global change affects every scope. + * @mode emit + */ 'system-prompt/change'(): void ``` @@ -463,6 +727,15 @@ Emitted when any prompt provider changes. This registry notification is unfilter **Mode:** `emit` ```ts website-api +/** + * A tool was registered or unregistered, or a scoped restriction changed + * (the available tool set changed — possibly for one scope only). An + * UNFILTERED registry-subject notification, deliberately not scope-filtered + * dispatch: a global change concerns every agent's next assembly, so a + * scoped listener subscribing here sees every change, not just its own + * scope's. + * @mode emit + */ 'tools/change'(): void ``` @@ -475,6 +748,14 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai **Mode:** `waterfall` ```ts website-api +/** + * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). + * @mode waterfall + */ 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` @@ -489,6 +770,14 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor **Mode:** `waterfall` ```ts website-api +/** + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this seam as errors. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. + * @mode waterfall + */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` @@ -504,6 +793,13 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts **Mode:** `waterfall` ```ts website-api +/** + * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing + * approval support turns `ask` into denial. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the pending call (name, parsed arguments, caller agent). + * @mode waterfall + */ 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` @@ -518,6 +814,13 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv **Mode:** `emit` ```ts website-api +/** + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. + * @param exec - the execution object that traversed the pipeline. + * @param result - a deep-frozen snapshot of the final returned result. + * @mode emit + */ 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` @@ -535,6 +838,16 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained **Mode:** `emit` ```ts website-api +/** + * One `agent()` call settled (clean result, child failure, or run + * cancellation). Paired with {@link Events['workflow/agent-start']} by + * `agent.seq`, exactly once per started call on every stop path — on an + * engine termination path (a worker killed past its grace) the end is + * engine-synthesized with outcome `'cancelled'`. + * @param info - the run's identity snapshot. + * @param agent - the call identity plus its outcome. + * @mode emit + */ 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` @@ -550,6 +863,15 @@ One `agent()` call settled (clean result, child failure, or run cancellation). P **Mode:** `emit` ```ts website-api +/** + * One `agent()` call established a ready child run. Paired with + * {@link Events['workflow/agent-end']} by `agent.seq`. A call that never + * receives a ready run from the provider emits neither + * event in this pair. + * @param info - the run's identity snapshot. + * @param agent - the call's sequence number, label, phase, and child id. + * @mode emit + */ 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` @@ -565,6 +887,15 @@ One `agent()` call established a ready child run. Paired with Events['workflow/a **Mode:** `emit` ```ts website-api +/** + * A workflow run settled (any stop reason). Fired when + * {@link WorkflowRun.result} resolves. Paired with + * {@link Events['workflow/start']}. + * @param info - the run's identity snapshot. + * @param result - the outcome data (stop reason, error, agent count) — + * deliberately WITHOUT the result value (see {@link WorkflowResultInfo}). + * @mode emit + */ 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` @@ -580,6 +911,12 @@ A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves **Mode:** `emit` ```ts website-api +/** + * The script emitted a narration line (a `log(message)` call). + * @param info - the run's identity snapshot. + * @param message - the logged message, verbatim. + * @mode emit + */ 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` @@ -595,6 +932,13 @@ The script emitted a narration line (a `log(message)` call). **Mode:** `emit` ```ts website-api +/** + * The script entered a phase (a `phase(title)` call) — progress grouping + * for observers; no execution semantics. + * @param info - the run's identity snapshot. + * @param title - the phase title, verbatim. + * @mode emit + */ 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` @@ -610,6 +954,12 @@ The script entered a phase (a `phase(title)` call) — progress grouping for obs **Mode:** `emit` ```ts website-api +/** + * A workflow run started — the script's meta block validated, the body + * about to execute. Paired with {@link Events['workflow/end']}. + * @param info - the run's identity snapshot (id + meta). + * @mode emit + */ 'workflow/start'(info: WorkflowRunInfo): void ``` diff --git a/website/zh-CN/api/harness/fs.md b/website/zh-CN/api/harness/fs.md index 1246bbd27f..aed69681f6 100644 --- a/website/zh-CN/api/harness/fs.md +++ b/website/zh-CN/api/harness/fs.md @@ -11,6 +11,15 @@ Abstract filesystem provider. Targets must preserve identity across aliases; rea ### ctx.fs.resolve(path, opts?) ```ts website-api +/** + * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a + * remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence + * async even though the local backend only normalizes + realpaths. + * + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - optional cwd override and cancellation signal. + * @returns the stable target; the same file yields the same `targetKey`. + */ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise ``` @@ -26,6 +35,12 @@ Resolve a model/plugin-supplied path into a stable FsTarget. May perform I/O (a ### ctx.fs.stat(target, signal?) ```ts website-api +/** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise ``` @@ -41,6 +56,20 @@ Return target metadata, or `undefined` when the target does not exist. ### ctx.fs.lstat(path, opts?, signal?) ```ts website-api +/** + * Return path metadata without following the final path component when it is a + * symbolic link. This is intentionally path-shaped, not target-shaped: + * {@link resolve} follows symlinks to produce the stable identity used by + * normal reads/writes, while `lstat` lets a consumer reject the path itself + * before that follow happens. + * + * `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is + * absent. + * @param path - the path to inspect; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent path. + */ abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise ``` @@ -58,6 +87,12 @@ Return path metadata without following the final path component when it is a sym ### ctx.fs.readText(target, signal?) ```ts website-api +/** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ abstract readText(target: FsTarget, signal?: AbortSignal): Promise ``` @@ -73,6 +108,15 @@ Read the whole regular text file as a single decoded string. ### ctx.fs.streamText(target, signal?) ```ts website-api +/** + * Stream the whole regular text file as decoded text chunks (same text + * semantics as {@link readText}, for large files). The backend owns + * cross-chunk UTF-8 decoding and binary rejection so the policy layer never + * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. + */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> ``` @@ -88,6 +132,13 @@ Stream the whole regular text file as decoded text chunks (same text semantics a ### ctx.fs.listDir(target, signal?) ```ts website-api +/** + * List direct children of a directory in stable name order. Returns resolved + * child targets plus cheap metadata only; never reads file contents. + * @param target - the resolved directory target. + * @param signal - aborts the listing. + * @returns one entry per direct child, in stable name order. + */ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise ``` @@ -103,6 +154,15 @@ List direct children of a directory in stable name order. Returns resolved child ### ctx.fs.writeText(target, content, expected?, signal?) ```ts website-api +/** + * Atomically create or replace UTF-8 text. `expected` guards intent and + * staleness; omission allows unconditional overwrite. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the write produced. + */ abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise ``` @@ -120,6 +180,16 @@ Atomically create or replace UTF-8 text. `expected` guards intent and staleness; ### ctx.fs.editText(target, edit, expected?, signal?) ```ts website-api +/** + * Atomically edit literal text. When supplied, the version guard is checked + * before matching so stale content reports `FS_STALE_VERSION`; omission edits + * the current content without a freshness precondition. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. + */ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index a70d5bd478..f762857881 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -11,6 +11,14 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surf ### ctx.llm.registerAdapter(providers, adapter) ```ts website-api +/** + * Register an adapter for the given provider routes. Throws `LlmError` with code + * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing). + * Disposed with the fiber. + * @param providers - every provider route this adapter should serve. + * @param adapter - the adapter that streams calls for those providers. + * @returns the disposer that unregisters all of them. + */ registerAdapter(providers: string[], adapter: LlmAdapter): () => void ``` @@ -26,6 +34,10 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` ### ctx.llm.listProviders() ```ts website-api +/** + * Describe provider routes with a registered adapter. + * @returns detached provider metadata in registration order. + */ listProviders(): LlmProviderInfo[] ``` @@ -38,6 +50,12 @@ Describe provider routes with a registered adapter. ### ctx.llm.listModels(provider) ```ts website-api +/** + * Discover models advertised by one registered provider. Catalog membership + * is advisory and never changes routing or request validation. + * @param provider - registered provider route to inspect. + * @returns detached model metadata in adapter-preferred order. + */ async listModels(provider: string): Promise ``` @@ -52,6 +70,15 @@ Discover models advertised by one registered provider. Catalog membership is adv ### ctx.llm.stream(options) ```ts website-api +/** + * Stream one model call as raw chunks (token-level deltas). Throws + * `LlmError` with code `NO_ADAPTER` if no adapter is registered for + * `options.provider`. Replay state is retained only when the same adapter + * instance owns its historical provider and the target provider. Dispatches + * through the `llm/stream` waterfall. + * @param options - the full request; `options.provider` selects the adapter. + * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. + */ stream(options: GenerateOptions): AsyncIterable ``` diff --git a/website/zh-CN/api/harness/permission.md b/website/zh-CN/api/harness/permission.md index fe05ef3a61..c9f99adbf1 100644 --- a/website/zh-CN/api/harness/permission.md +++ b/website/zh-CN/api/harness/permission.md @@ -11,6 +11,10 @@ Owns the deployment's permission presets and their write path. Requires a confin ### ctx.permission.names ```ts website-api +/** + * The advertised preset names, in the preset table's declaration order. + * @returns every switchable preset name. + */ get names(): readonly string[] ``` @@ -21,6 +25,13 @@ The advertised preset names, in the preset table's declaration order. ### ctx.permission.current(events) ```ts website-api +/** + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. + * @returns the effective preset name, or `custom` when nothing matches. + */ current(events: readonly SessionEvent[]): string ``` @@ -35,6 +46,12 @@ Resolve the preset matching the effective knob values. A still-matching last sel ### ctx.permission.resolve(name) ```ts website-api +/** + * Resolve a preset's knob bundle. + * @param name - the preset name to resolve. + * @returns the configured bundle. + * @throws when `name` is not in the table. + */ resolve(name: string): PresetSpec ``` @@ -49,6 +66,13 @@ Resolve a preset's knob bundle. ### ctx.permission.optionOf(name) ```ts website-api +/** + * Build the client option for a table entry or {@link CUSTOM_PRESET}. A + * missing label falls back to the table key. + * @param name - a table key, or `custom`. + * @returns the option a client renders. + * @throws when `name` is neither a table key nor `custom`. + */ optionOf(name: string): PresetOption ``` @@ -63,6 +87,12 @@ Build the client option for a table entry or CUSTOM_PRESET. A missing label fall ### ctx.permission.set(session, name) ```ts website-api +/** + * Record a changed preset, then update each changed knob through its own + * setter. Selecting the effective preset again appends nothing. + * @param session - the session the switch belongs to. + * @param name - the preset to switch to; unknown names throw. + */ set(session: Session, name: string): void ``` diff --git a/website/zh-CN/api/harness/sandbox.md b/website/zh-CN/api/harness/sandbox.md index bc5b1d38a5..45187e9d31 100644 --- a/website/zh-CN/api/harness/sandbox.md +++ b/website/zh-CN/api/harness/sandbox.md @@ -11,6 +11,17 @@ Abstract process-sandbox service. confine must return enforcing argv or fail clo ### ctx.sandbox.confine(argv, policy) ```ts website-api +/** + * Wrap `argv` so it executes confined under `policy` on this host; the + * caller spawns the returned argv in place of its own. + * @param argv - the exact argv the caller is about to spawn (program plus + * arguments), NOT a shell string — a shell-shaped consumer passes + * `['bash', '-c', command]`. + * @param policy - the file-effect policy this execution runs under, + * carried per call (see {@link SandboxPolicy}). + * @returns the argv to spawn instead, plus the enforcement completeness + * the selected backend achieves for it. + */ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` diff --git a/website/zh-CN/api/harness/session-persistence.md b/website/zh-CN/api/harness/session-persistence.md index e382a2f4a4..f200cb1801 100644 --- a/website/zh-CN/api/harness/session-persistence.md +++ b/website/zh-CN/api/harness/session-persistence.md @@ -11,6 +11,13 @@ Durable append-only session storage. Implementations preserve contiguous, lossle ### ctx.sessionPersistence.locate(meta) ```ts website-api +/** + * Resolve this backend's independent local artifact for a session without + * reading, creating, flushing, or otherwise materializing it. Backends such + * as SQLite that do not own one artifact per session return `undefined`. + * @param meta - the immutable session header whose artifact is requested. + * @returns the backend-specific absolute location, when one exists. + */ abstract locate(meta: SessionHeader): SessionLocation | undefined ``` @@ -25,6 +32,13 @@ Resolve this backend's independent local artifact for a session without reading, ### ctx.sessionPersistence.create(meta) ```ts website-api +/** + * Register a new session's metadata. A backend MAY defer the physical write + * until the first {@link append} (lazy materialization), in which case a + * created-but-never-appended session is absent from {@link list} + * — abandoned sessions leave nothing behind. + * @param meta - the immutable header (id, version, cwd, lineage) to record. + */ abstract create(meta: SessionHeader): Promise ``` @@ -37,6 +51,15 @@ Register a new session's metadata. A backend MAY defer the physical write until ### ctx.sessionPersistence.append(id, events) ```ts website-api +/** + * Durably persist a batch of events (called from the write-behind drain at + * the `session/flush` checkpoint). Honors the append-only and contiguous-seq + * contracts: the first event's `seq` MUST equal the stored next-seq (after + * `load` has durably closed any interrupted turn). Rejects non-JSON- + * serializable `event.data` with an error naming the offending event type. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order. + */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise ``` @@ -50,6 +73,14 @@ Durably persist a batch of events (called from the write-behind drain at the `se ### ctx.sessionPersistence.load(id) ```ts website-api +/** + * Load a header and balanced contiguous log. A complete interrupted final + * turn is preserved and durably closed with missing tool errors plus any open + * step and turn boundaries; only a torn final record is discarded. Unknown + * versions and corruption in the committed prefix reject. + * @param id - the persisted session to reload. + * @returns the header and a log ending on a balanced `turn/end`. + */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> ``` @@ -64,6 +95,10 @@ Load a header and balanced contiguous log. A complete interrupted final turn is ### ctx.sessionPersistence.list() ```ts website-api +/** + * Lightweight listing from metadata, without a full-log parse. + * @returns one header per materialized session. + */ abstract list(): Promise ``` diff --git a/website/zh-CN/api/harness/session-query.md b/website/zh-CN/api/harness/session-query.md index f081950aa7..cc826c7af4 100644 --- a/website/zh-CN/api/harness/session-query.md +++ b/website/zh-CN/api/harness/session-query.md @@ -11,6 +11,10 @@ Live-preferred logical-corpus exact-read and relationship-tracing service. ### ctx.sessionQuery.listSessions() ```ts website-api +/** + * List the complete logical corpus using live-preferred records. + * @returns deterministic newest-first cloned session records. + */ listSessions(): Promise ``` @@ -23,6 +27,11 @@ List the complete logical corpus using live-preferred records. ### ctx.sessionQuery.listEvents(sessionId) ```ts website-api +/** + * List lightweight raw-log event records for one logical session. + * @param sessionId - live-preferred session id to read. + * @returns event records in ascending seq order. + */ async listEvents(sessionId: SessionId): Promise ``` @@ -37,6 +46,12 @@ List lightweight raw-log event records for one logical session. ### ctx.sessionQuery.traceSession(sessionId) ```ts website-api +/** + * Trace known ancestry and descendants from one corpus observation. + * @param sessionId - logical session id to trace. + * @returns a complete lineage or an explicit unresolved parent boundary. + * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. + */ async traceSession(sessionId: SessionId): Promise ``` @@ -51,6 +66,12 @@ Trace known ancestry and descendants from one corpus observation. ### ctx.sessionQuery.traceEvent(request) ```ts website-api +/** + * Trace one event's direct positional and provenance relationships. + * @param request - target session id and event seq. + * @returns direct links plus the target's positional replacement chain. + * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. + */ async traceEvent(request: SessionEventTraceRequest): Promise ``` @@ -65,6 +86,11 @@ Trace one event's direct positional and provenance relationships. ### ctx.sessionQuery.readEvent(request) ```ts website-api +/** + * Read one full event plus a bounded raw-log context window. + * @param request - target session/seq and context sizes. + * @returns cloned target and neighboring events. + */ async readEvent(request: SessionEventReadRequest): Promise ``` diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md index 712852fc49..f59001009d 100644 --- a/website/zh-CN/api/harness/sessions.md +++ b/website/zh-CN/api/harness/sessions.md @@ -12,6 +12,27 @@ Persistence is intentionally not implemented here — persistence plugins subscr ### ctx.sessions.create(id?, options?) ```ts website-api +/** + * Create a session owned by the calling fiber: disposing that fiber stops + * event notification and removes the session from the store. `options.seed` + * populates the session with a copy of those events (replay/fork); + * `options.meta` attaches creation metadata (validated absolute `cwd`, + * `parentSession` lineage) as the immutable {@link SessionHeader} (the store + * fills `version`/`id`/`createdAt`). + * + * For an agent whose session must be torn down IN ORDER with its loop (so the + * loop's final flush is captured before the store attachment ends), do NOT use this + * — fold the session lifecycle into the agent's own effect via + * {@link prepare} + {@link enter} + {@link announce} (see + * `dsh-agent-loop`'s creation transaction). + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the live session, already entered and announced. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path (storage backends key directories off it). + */ create(id?: SessionId, options?: CreateSessionOptions): Session ``` @@ -28,6 +49,22 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop ### ctx.sessions.prepare(id?, options?) ```ts website-api +/** + * Build a session WITHOUT entering it into the store — validate the id/cwd and + * construct the {@link Session} (with its immutable {@link SessionHeader}). + * Pairs with {@link enter} + {@link announce}: a caller that owns a composite + * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE + * effect so a fiber unload tears the session + agent down as a single ORDERED + * chain rather than as racing sibling effects — which would remove the publication hooks + * before the loop's closing `session/flush`, dropping the closing events. + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the constructed session, NOT yet in the store. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path. + */ prepare(id?: SessionId, options?: CreateSessionOptions): Session ``` @@ -43,6 +80,28 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c ### ctx.sessions.enter(session) ```ts website-api +/** + * Enter a {@link prepare}d session into the store: install the module-private + * append publication hooks and add it to the store. Returns the DETACH + * disposer (hooks + store removal). Does NOT emit `session/created` — + * the caller yields this disposer inside its effect and THEN calls + * {@link announce}, so a throwing `session/created` listener rolls the attach + * back instead of leaking it. + * + * Re-checks the id for a duplicate: `prepare` and `enter` are public + * cross-package primitives and a caller may interleave arbitrary work (or + * another create) between them, so a stale prepared session must NOT overwrite + * a live store entry of the same id — its detach disposer would later delete + * the REAL session. The {@link create} convenience and the agent factory call + * the two back-to-back so they never trip this, but the public seam cannot + * assume that. + * + * @param session - a {@link prepare}d session not yet in the store. + * @returns the detach disposer (publication hooks + store removal). When called from + * a synchronous `session/created` listener, removal and disposal wait until + * that creation dispatch unwinds. + * @throws if a session with this id is already in the store. + */ enter(session: Session): () => void ``` @@ -58,6 +117,13 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package ### ctx.sessions.announce(session) ```ts website-api +/** Emit `session/created` exactly once for an {@link enter}ed session (with + * the carrier {@link enter} captured). Separate from {@link enter} so the + * caller can yield the detach disposer first (rollback safety — see + * {@link enter}). + * @param session - the entered session to announce to listeners. + * @throws if the session is not live or its announcement already began, + * including a reentrant call from a creation listener. */ announce(session: Session): void ``` @@ -70,6 +136,17 @@ Emit `session/created` exactly once for an entered session (with the carrier ent ### ctx.sessions.flush(session) ```ts website-api +/** + * Dispatch the awaited `session/flush` durability checkpoint for `session`, + * with the carrier captured at {@link enter}. THE flush entry point: the + * store owns the carrier, so callers (the loop's turn-end checkpoint, idle + * injection, teardown drains) must come through here rather than dispatch a + * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the + * scoped-dispatch invariant can pin it. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when every flush listener has settled; after all settle, + * rejects with the first registered listener failure if any listener failed. + */ async flush(session: Session): Promise ``` @@ -84,6 +161,11 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t ### ctx.sessions.get(id) ```ts website-api +/** + * Look up a live session. + * @param id - the session id to look up. + * @returns the session, or undefined when no live session has that id. + */ get(id: SessionId): Session | undefined ``` @@ -98,6 +180,10 @@ Look up a live session. ### ctx.sessions.list() ```ts website-api +/** + * All live sessions, in creation order. + * @returns a fresh array; mutating it does not affect the store. + */ list(): Session[] ``` @@ -110,6 +196,19 @@ All live sessions, in creation order. ### ctx.sessions.fork(source, boundary?, childSessionId?) ```ts website-api +/** + * Create a live child session from a turn-enclosed prefix of a live source. + * `boundary` is an inclusive source event seq; omitted means the source's + * current last event. A non-empty selected slice must end at `turn/end`. + * + * @param source - Live source session object or id. + * @param boundary - Inclusive source event seq to fork through; omitted means + * the source's current last event, and omitted on an empty source forks an + * empty child. + * @param childSessionId - Optional child session id; omitted delegates to + * `SessionStore`'s id policy. + * @returns The created live child session. + */ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` diff --git a/website/zh-CN/api/harness/skills.md b/website/zh-CN/api/harness/skills.md index 32c4d1b825..31ab6cd1d9 100644 --- a/website/zh-CN/api/harness/skills.md +++ b/website/zh-CN/api/harness/skills.md @@ -11,6 +11,14 @@ Registry of skill providers. It merges provider catalogs with stable first-wins ### ctx.skills.registerProvider(provider) ```ts website-api +/** + * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and + * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters + * the provider and invalidates catalog caches. + * @param provider - the provider to register by `provider.name`. + * @returns the exact Cordis effect disposer that unregisters this provider; + * composite effects may yield it directly to preserve teardown ordering. + */ registerProvider(provider: SkillProvider): () => void ``` @@ -25,6 +33,13 @@ Register a borrowed same-process provider synchronously during plugin apply. Dup ### ctx.skills.register(skill) ```ts website-api +/** + * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which + * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and + * receives a no-op disposer so it cannot remove the winner. + * @param skill - the complete skill definition to expose for discovery. + * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. + */ register(skill: SkillRegistration): () => void ``` @@ -39,6 +54,13 @@ Register a borrowed readonly runtime skill. Project entries outrank runtime entr ### ctx.skills.list(options?) ```ts website-api +/** + * List model-invocable skill summaries for a workspace. Lookup options and + * provider candidates are readonly same-process values borrowed throughout + * discovery. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries, excluding skills disabled for model invocation. + */ async list(options: SkillLookupOptions = {}): Promise ``` @@ -53,6 +75,14 @@ List model-invocable skill summaries for a workspace. Lookup options and provide ### ctx.skills.get(name, options?) ```ts website-api +/** + * Load and validate the winning candidate, passing its opaque discovery locator back to the + * provider. Cancellation is rechecked after selection, including cache hits, and raced against + * loading so an uncooperative provider cannot hang the caller. + * @param name - kebab-case skill name. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns the full skill, including body content, or `undefined`. + */ async get(name: string, options: SkillLookupOptions = {}): Promise ``` diff --git a/website/zh-CN/api/harness/spill-store.md b/website/zh-CN/api/harness/spill-store.md index b7fd81c934..942e811c6b 100644 --- a/website/zh-CN/api/harness/spill-store.md +++ b/website/zh-CN/api/harness/spill-store.md @@ -15,6 +15,11 @@ Semantics every implementation must honor: ### ctx.spillStore.saveText(input) ```ts website-api +/** + * Persist `input.content` to a session-scoped spill artifact. + * @param input - the owner, provenance, suggested name, and full text to save. + * @returns the saved artifact's {@link SpillRef}; rejects on a storage failure. + */ abstract saveText(input: SaveTextSpill): Promise ``` diff --git a/website/zh-CN/api/harness/subagents.md b/website/zh-CN/api/harness/subagents.md index 52632e820d..de71a5effe 100644 --- a/website/zh-CN/api/harness/subagents.md +++ b/website/zh-CN/api/harness/subagents.md @@ -11,6 +11,13 @@ Named provider registry and capability-checked start surface. ### ctx.subagents.registerProvider(provider) ```ts website-api +/** + * Register a provider under its name. Registration is effect-scoped and HMR + * safe; removing a provider blocks new starts but does not revoke runs that + * were already returned to their holders. + * @param provider - the trusted provider implementation. + * @returns the exact Cordis effect disposer. + */ registerProvider(provider: SubagentProvider): () => void ``` @@ -25,6 +32,11 @@ Register a provider under its name. Registration is effect-scoped and HMR safe; ### ctx.subagents.getProvider(name) ```ts website-api +/** + * Look up a provider by name. + * @param name - the provider name. + * @returns the provider, or undefined when absent. + */ getProvider(name: string): SubagentProvider | undefined ``` @@ -39,6 +51,10 @@ Look up a provider by name. ### ctx.subagents.list() ```ts website-api +/** + * List registered provider names in insertion order. + * @returns the registered names. + */ list(): string[] ``` @@ -51,6 +67,15 @@ List registered provider names in insertion order. ### ctx.subagents.start(name, request) ```ts website-api +/** + * Establish a ready child on the named provider. Capability and semantic + * checks run before delegation. Provider ownership lasts until its promise + * fulfills; a rejection therefore has no run for the caller to dispose and + * emits no run lifecycle events. + * @param name - the provider to use. + * @param request - child prompt, parent, signal, and optional capabilities. + * @returns the ready holder-owned run. + */ async start(name: string, request: SubagentStartRequest): Promise ``` diff --git a/website/zh-CN/api/harness/system-prompt.md b/website/zh-CN/api/harness/system-prompt.md index 56537ac407..ac22619752 100644 --- a/website/zh-CN/api/harness/system-prompt.md +++ b/website/zh-CN/api/harness/system-prompt.md @@ -11,6 +11,14 @@ Registry service for the prompt inputs assembled before each model step. ### ctx.systemPrompt.section(section) ```ts website-api +/** + * Register an ordered prompt section in the calling context's scope. A scoped + * section shadows a global section with the same name; duplicates within one + * layer and non-finite orders throw. Registration and disposal emit + * `system-prompt/change`. + * @param section - the section to register. + * @returns the exact Cordis effect disposer. + */ section(section: PromptSection): () => void ``` @@ -25,6 +33,13 @@ Register an ordered prompt section in the calling context's scope. A scoped sect ### ctx.systemPrompt.tools(provider) ```ts website-api +/** + * Register a tool-schema provider in the calling context's scope. Global and + * matching scoped providers both contribute; returning the reserved + * {@link TOOL_ORDER_REST} name makes assembly fail. + * @param provider - evaluated for each assembly with its context. + * @returns the exact Cordis effect disposer. + */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void ``` @@ -39,6 +54,14 @@ Register a tool-schema provider in the calling context's scope. Global and match ### ctx.systemPrompt.variable(name, provider) ```ts website-api +/** + * Register a prompt variable in the calling context's scope. Scoped values + * shadow globals; invalid or duplicate names throw. A provider may return + * `undefined`, but rendering a section that references that value then fails. + * @param name - the `[a-z][a-z0-9_]*` reference name. + * @param provider - evaluated for each assembly. + * @returns the exact Cordis effect disposer. + */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void ``` @@ -54,6 +77,13 @@ Register a prompt variable in the calling context's scope. Scoped values shadow ### ctx.systemPrompt.assemble(context?) ```ts website-api +/** + * Assemble global and scoped providers, detach tool parameters, apply + * canonical ordering, then run the assembly waterfall. Scoped sections and + * variables shadow globals; the returned waterfall value is authoritative. + * @param context - the optional scope and plugin-defined assembly fields. + * @returns the authoritative post-waterfall assembly. + */ async assemble(context: AssembleContext = {}): Promise ``` diff --git a/website/zh-CN/api/harness/tasks.md b/website/zh-CN/api/harness/tasks.md index 5ec3061471..1a43639871 100644 --- a/website/zh-CN/api/harness/tasks.md +++ b/website/zh-CN/api/harness/tasks.md @@ -11,6 +11,14 @@ The `tasks` service: the runtime-global background task registry. See the module ### ctx.tasks.start(spec) ```ts website-api +/** + * Preflight access, validation, and owner cleanup before starting and + * atomically registering work. A throwing starter leaves nothing registered; + * after it returns, registration cannot fail. Settlement records the outcome, + * notifies listeners, and releases waiters. + * @param spec - task identity, owner, and synchronous starter. + * @returns the registry-issued `-N` id. + */ start(spec: TaskStart): TaskId ``` @@ -25,6 +33,12 @@ Preflight access, validation, and owner cleanup before starting and atomically r ### ctx.tasks.list(caller?) ```ts website-api +/** + * List caller-owned and unowned tasks in registration order without exposing + * another session's labels. + * @param caller - reading agent; a non-agent caller sees only unowned tasks. + * @returns fresh snapshots. + */ list(caller?: Agent): TaskSnapshot[] ``` @@ -39,6 +53,13 @@ List caller-owned and unowned tasks in registration order without exposing anoth ### ctx.tasks.get(id, caller?) ```ts website-api +/** + * Return a non-consuming snapshot without changing its read cursor or notice + * state. Throws for an unknown or foreign task. + * @param id - task to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ get(id: TaskId, caller?: Agent): TaskSnapshot ``` @@ -54,6 +75,14 @@ Return a non-consuming snapshot without changing its read cursor or notice state ### ctx.tasks.read(id, caller?) ```ts website-api +/** + * Read the next stream delta, or the idempotent final output after settlement. + * A terminal read marks the task reported. Throws for an unknown or foreign + * task. + * @param id - task to read. + * @param caller - reading agent checked against the owner. + * @returns output text and the post-read snapshot. + */ read(id: TaskId, caller?: Agent): TaskRead ``` @@ -69,6 +98,15 @@ Read the next stream delta, or the idempotent final output after settlement. A t ### ctx.tasks.kill(id, caller?, reason?) ```ts website-api +/** + * Request cancellation, then mark the task stopping and reported. A producer + * throw propagates without changing task state. Throws for an unknown or + * foreign task. + * @param id - task to cancel. + * @param caller - killing agent checked against the owner. + * @param reason - logged reason forwarded to the producer. + * @returns `requested` for live work, otherwise `already-finished`. + */ kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' ``` @@ -85,6 +123,18 @@ Request cancellation, then mark the task stopping and reported. A producer throw ### ctx.tasks.wait(id, timeoutMs, caller?, signal?) ```ts website-api +/** + * Wait for settlement or timeout without cancelling the task. Caller abort + * rejects only while the task is live; after settlement it returns the + * terminal snapshot so a notice suppressed for this waiter is still delivered. + * Timed-out and aborted waits detach their resolvers. Throws for invalid, + * unknown, or foreign input. + * @param id - task to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param caller - waiting agent checked against the owner. + * @param signal - optional cancellation of the wait itself. + * @returns snapshot at settlement or timeout. + */ async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise ``` @@ -102,6 +152,13 @@ Wait for settlement or timeout without cancelling the task. Caller abort rejects ### ctx.tasks.onTaskDone(listener) ```ts website-api +/** + * Register an effect-scoped completion listener. Each listener is contained; + * returned promises are observed but not awaited. No listener runs after + * service disposal. + * @param listener - receives each terminal snapshot and its exact owner. + * @returns disposer that unregisters the listener. + */ onTaskDone(listener: TaskDoneListener): () => void ``` @@ -116,6 +173,12 @@ Register an effect-scoped completion listener. Each listener is contained; retur ### ctx.tasks.attachSurface(name) ```ts website-api +/** + * Attach an effect-scoped surface that can read and stop tasks. {@link start} + * refuses work while none is attached. + * @param name - diagnostic label; duplicate names remain independent. + * @returns disposer that detaches this surface. + */ attachSurface(name: string): () => void ``` diff --git a/website/zh-CN/api/harness/token-meter.md b/website/zh-CN/api/harness/token-meter.md index ab4dd8f684..30b83f79d4 100644 --- a/website/zh-CN/api/harness/token-meter.md +++ b/website/zh-CN/api/harness/token-meter.md @@ -11,6 +11,7 @@ Replay owner for one service-wide estimator and isolated per-session folds. ### ctx.tokenMeter.contextWindow ```ts website-api +/** Provider context-window capacity used by pressure consumers. */ readonly contextWindow: number ``` @@ -21,6 +22,22 @@ Provider context-window capacity used by pressure consumers. ### ctx.tokenMeter.measure(session, requestHeader?) ```ts website-api +/** + * Measure current request pressure and surface through the durable tail. + * + * Provider usage is reused only when the latest successful call's canonical + * request envelope matches `requestHeader` and its total is no lower than + * that call's full heuristic anchor; otherwise the complete envelope and + * surface are heuristically repriced. + * + * `requestHeader` affects request pressure only; surface fields always + * describe the current session surface. Every call clones those positional + * nodes, so measurement is O(surface). + * + * @param session - session to replay through its current durable tail. + * @param requestHeader - optional effective request envelope replacing the latest logged header. + * @returns a detached deeply immutable pressure and surface measurement. + */ measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement ``` @@ -38,6 +55,11 @@ Provider usage is reused only when the latest successful call's canonical reques ### ctx.tokenMeter.estimateMessage(message) ```ts website-api +/** + * Heuristically price one model-visible message. + * @param message - message to price without mutation. + * @returns content and role-framing tokens under the fixed service heuristic. + */ estimateMessage(message: Message): number ``` diff --git a/website/zh-CN/api/harness/tools.md b/website/zh-CN/api/harness/tools.md index 6ed4ce024d..eb416e8b75 100644 --- a/website/zh-CN/api/harness/tools.md +++ b/website/zh-CN/api/harness/tools.md @@ -11,6 +11,12 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v ### ctx.tools.register(definition) ```ts website-api +/** + * Register globally or in the calling agent scope. Scoped tools shadow + * globals; duplicates within one layer and the reserved `run_code` name fail. + * @param definition - the tool schema, execution, and optional presentation functions. + * @returns the exact disposer that unregisters the tool. + */ register(definition: ToolDefinition): () => void ``` @@ -25,6 +31,13 @@ Register globally or in the calling agent scope. Scoped tools shadow globals; du ### ctx.tools.restrict(filter) ```ts website-api +/** + * Restrict global tools for the calling agent scope. Empty filters, unknown + * names, scope-local names, and reserved transport names fail. Restrictions + * intersect; scoped registrations remain visible. + * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). + * @returns the exact disposer that lifts this restriction. + */ restrict(filter: ToolRestriction): () => void ``` @@ -39,6 +52,16 @@ Restrict global tools for the calling agent scope. Empty filters, unknown names, ### ctx.tools.guard(guard) ```ts website-api +/** + * Register a monotonic guard after the extensible `tools/pre-execute` + * waterfall. A plain-context guard applies globally; one registered through + * `agent.ctx` applies only to that agent. Any matching guard may deny by + * returning a reason, while no guard can force-allow a call another guard + * denied. The exact effect disposer is returned for ordered ownership and + * HMR cleanup. + * @param guard - synchronous check; a returned string denies the execution. + * @returns the exact disposer that unregisters the guard. + */ guard(guard: ToolGuard): () => void ``` @@ -53,6 +76,15 @@ Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A ### ctx.tools.get(name, scope?) ```ts website-api +/** + * Look up a tool as one scope sees it (scoped + * shadows global; a restricted-away global reads as absent). Presenters pass + * the calling agent so the rendered card matches the definition that + * actually executed. + * @param name - the tool name as registered. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns the definition the scope resolves, or undefined when none is visible. + */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined ``` @@ -68,6 +100,12 @@ Look up a tool as one scope sees it (scoped shadows global; a restricted-away gl ### ctx.tools.schemas(scope?) ```ts website-api +/** + * Project visible definitions onto the allowlisted model-facing schema fields, + * excluding execution and presentation callbacks. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns one deep-cloned schema per visible tool. + */ schemas(scope?: ScopeKey): ToolSchema[] ``` @@ -82,6 +120,13 @@ Project visible definitions onto the allowlisted model-facing schema fields, exc ### ctx.tools.executionMode(exec) ```ts website-api +/** + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. + */ executionMode(exec: ToolExecutionInput): ToolExecutionMode ``` @@ -96,6 +141,15 @@ Classify a pending call through the caller's visible tool definition. Only an ex ### ctx.tools.execute(exec) ```ts website-api +/** + * Execute through pre-policy, guards, around-dispatch, post-policy, and final + * notification. Tool and listener failures resolve as materialized error + * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is + * the same lossless, frozen snapshot final observers receive. + * @param exec - the typed same-process call input. The registry assigns its + * correlation token before policy begins. + * @returns the materialized final result. + */ async execute(exec: ToolExecutionInput): Promise ``` diff --git a/website/zh-CN/api/harness/user-interaction.md b/website/zh-CN/api/harness/user-interaction.md index 09db0f6107..072087f8ca 100644 --- a/website/zh-CN/api/harness/user-interaction.md +++ b/website/zh-CN/api/harness/user-interaction.md @@ -11,6 +11,12 @@ ### ctx.userInteraction.registerProvider(provider) ```ts website-api +/** + * Register the UI provider. Only one provider may be active in a context. + * + * @param provider UI-side implementation that collects answers. + * @returns Disposer that unregisters this provider. + */ registerProvider(provider: UserInteractionProvider): () => void ``` @@ -25,6 +31,12 @@ Register the UI provider. Only one provider may be active in a context. ### ctx.userInteraction.ask(request) ```ts website-api +/** + * Ask the active UI provider and wait for the user's answer. + * + * @param request Questions, owner agent, and abort signal. + * @returns The answer chosen or typed by the human. + */ async ask(request: AskUserQuestionRequest): Promise ``` diff --git a/website/zh-CN/api/harness/web.md b/website/zh-CN/api/harness/web.md index ca23406caf..5827430261 100644 --- a/website/zh-CN/api/harness/web.md +++ b/website/zh-CN/api/harness/web.md @@ -18,6 +18,13 @@ Selection semantics (resolved at execution time, never order-dependent): ### ctx.web.registerSearchProvider(provider) ```ts website-api +/** + * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerSearchProvider(provider: WebSearchProvider): () => void ``` @@ -32,6 +39,13 @@ Register a search provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id i ### ctx.web.registerFetchProvider(provider) ```ts website-api +/** + * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for fetch. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerFetchProvider(provider: WebFetchProvider): () => void ``` @@ -46,6 +60,15 @@ Register a fetch provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is ### ctx.web.search(request, signal?) ```ts website-api +/** + * Run one search through the selected provider. Resolves the provider at call + * time with the selection rules above; throws {@link WebError} when the + * capability cannot run. The seam enforces `request.maxResults` on the result: + * if the provider over-returns, `sources[]` is truncated and `truncated` set. + * @param request - the query plus result-shaping options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the provider's results, capped to `request.maxResults`. + */ async search(request: WebSearchRequest, signal?: AbortSignal): Promise ``` @@ -61,6 +84,14 @@ Run one search through the selected provider. Resolves the provider at call time ### ctx.web.fetch(request, signal?) ```ts website-api +/** + * Retrieve one URL through the selected provider. Resolves the provider at + * call time with the selection rules above; throws {@link WebError} when the + * capability cannot run. A non-2xx response is a result, not a throw. + * @param request - the URL plus retrieval options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the retrieval outcome; non-2xx responses resolve descriptively. + */ async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise ``` diff --git a/website/zh-CN/api/harness/workflows.md b/website/zh-CN/api/harness/workflows.md index 5518f3e564..f34c3d8a6c 100644 --- a/website/zh-CN/api/harness/workflows.md +++ b/website/zh-CN/api/harness/workflows.md @@ -11,6 +11,12 @@ Workflow execution seam. Invalid requests throw before publication; a live run i ### ctx.workflows.start(request) ```ts website-api +/** + * Parse and execute a workflow script. + * @param request - the script, its `args`, the parent agent, and an + * optional cancel signal. + * @returns the live run; its `result` resolves when the script settles. + */ abstract start(request: WorkflowStartRequest): WorkflowRun ``` diff --git a/website/zh-CN/api/index.md b/website/zh-CN/api/index.md index 7e1812bd5f..4357fcbfe3 100644 --- a/website/zh-CN/api/index.md +++ b/website/zh-CN/api/index.md @@ -1,6 +1,6 @@ # API 参考 -本节是 DeepSeek Harness 的 API 参考。除本页外,`cordis/` 与 `harness/` 下的所有页面**由脚本从源码生成**(`pnpm run gen-website-api`,CI 校验新鲜度),签名与说明永远与代码一致;生成页目前为英文,中文版将随统一翻译流程提供。 +本节是 DeepSeek Harness 的 API 参考。除本页外,`cordis/` 与 `harness/` 下的所有页面**由脚本从源码生成**(`pnpm run gen-website-api`,CI 校验新鲜度);签名代码块保留源码的原始 JSDoc,签名与说明永远与代码一致。生成页目前为英文,中文版将随统一翻译流程提供。 ## 框架 API From f66cde41d181cf2b6fcd80ee7426854889e8e655 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:16:07 +0800 Subject: [PATCH 13/16] docs(core): clarify initiator restoration timing --- .../2026-07-15-agent-initiator-scope.i18n.yaml | 4 ++-- .../architecture/2026-07-15-agent-initiator-scope.md | 2 +- .../architecture/2026-07-15-agent-initiator-scope.zh.md | 2 +- packages/core/agent-loop/tests/agent-initiator.spec.ts | 8 ++++---- packages/core/agent/README.md | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) 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 index 9d9e11522f..696b119797 100644 --- 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 @@ -2,5 +2,5 @@ # 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: 480bb56f811567406e964ca94fca04fd3d4ace62 -2026-07-15-agent-initiator-scope.zh.md: 29ea3a47f42d607a4584b888ace232de124e7598 +2026-07-15-agent-initiator-scope.md: 08c9eca50ed51925fec7c09eda7a82d0c61cb55b +2026-07-15-agent-initiator-scope.zh.md: 284d52126e02e44d512b88a5d64e35ba80552d9b 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 index 480bb56f81..08c9eca50e 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -16,7 +16,7 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in `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 shadows its parent, and the parent store returns when the child boundary settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. +`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. 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 index 29ea3a47f4..284d52126e 100644 --- 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 @@ -16,7 +16,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 `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` 生命周期。因此,并发驱动使用彼此独立的存储,子驱动会遮蔽父驱动,子边界结束后父存储得到恢复。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 +`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 只在进程内有效。 diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 7f0903fc00..8e9b951fa1 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -142,7 +142,7 @@ describe('AgentLoop initiator scope', () => { await ctx.fiber.dispose() }) - it('keeps child setup under the parent boundary, switches for the child driver, then restores the parent', async () => { + 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', {}), @@ -153,7 +153,7 @@ describe('AgentLoop initiator scope', () => { let parentDuringSetup: Agent | undefined let explicitChild: Agent | undefined let childDuringDriver: Agent | undefined - let parentAfterChild: Agent | undefined + let parentWhileChildDriverActive: Agent | undefined let child: Agent | undefined ctx.tools.register(defineTool({ @@ -181,9 +181,9 @@ describe('AgentLoop initiator scope', () => { }, }) child = handle.agent + parentWhileChildDriverActive = ctx.agents.requireInitiator() send(handle.agent, 'run child') await handle.agent.whenIdle() - parentAfterChild = ctx.agents.requireInitiator() await handle.dispose() return [{ type: 'text', text: 'child completed' }] }, @@ -200,7 +200,7 @@ describe('AgentLoop initiator scope', () => { expect(parentDuringSetup).toBe(parentHandle.agent) expect(explicitChild).toBe(child) expect(childDuringDriver).toBe(child) - expect(parentAfterChild).toBe(parentHandle.agent) + expect(parentWhileChildDriverActive).toBe(parentHandle.agent) expect(ctx.agents.currentInitiator()).toBeUndefined() await parentHandle.dispose() await ctx.fiber.dispose() diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 23d9d998c6..839907bf62 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -19,7 +19,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- #### Initiating Agent scope -`AgentLoop` runs each concrete driver's complete lifetime inside an initiator boundary. Concurrent drivers remain isolated, a child driver shadows its parent, and the parent returns after the child 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. +`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`. From e5721393969d581e3d7596544e99f94f4ee11798 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:23:59 +0800 Subject: [PATCH 14/16] docs: shorten public API fence marker --- docs/AGENTS.md | 2 +- docs/core-data-structures/llm-streaming.md | 4 +-- docs/core-data-structures/session.md | 2 +- docs/development.i18n.yaml | 4 +-- docs/development.md | 2 +- docs/development.zh.md | 2 +- ...2026-06-20-core-data-structures-catalog.md | 2 +- scripts/doc-typecheck.ts | 4 +-- scripts/type-equiv.manifest.json | 2 +- scripts/verify-type-equiv.ts | 27 ++++++++++--------- 10 files changed, 27 insertions(+), 24 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 27db1d88d7..f7635c659b 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -27,7 +27,7 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems. - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. -- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses its `public-api` variant; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). +- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index b27c21a69c..ce8c1bf074 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -91,7 +91,7 @@ interface TokenUsage { `BlockAssembler` ([`packages/llm/llm/src/assembler.ts`](../../packages/llm/llm/src/assembler.ts)) is the single shared implementation that folds a `StreamChunk` stream back into `ContentBlock`s, usage, finish reason, and replay state. The loop logs the raw chunks while feeding the same chunks through an assembler, then stores the assembled assistant content with its provider/model provenance. A consumer that needs the assembled result without re-implementing the fold uses this. -```ts type-equiv public-api +```ts public-api /** * Incrementally assembles raw {@link StreamChunk}s into complete * {@link ContentBlock}s and a final assistant {@link Message}. @@ -133,7 +133,7 @@ declare class BlockAssembler { `LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). -```ts type-equiv public-api +```ts public-api /** * Provider-wire adapter for the harness message and stream vocabulary. Register implementations * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 1ece65c1c3..3668191fb2 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -309,7 +309,7 @@ interface SurfaceFoldResult { The body-stripped declaration keeps the plain class's public constructor, state accessors, append boundary, and history projections synchronized with source. Store operations remain in the generated [`ctx.sessions` service catalog](../cordis-catalog/services.md#ctxsessions--sessionstore). -```ts type-equiv public-api +```ts public-api /** * An event-sourced session: an append-only log of {@link SessionEvent}s. * diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b0ce75ca4c..d7f82d7e92 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # 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 -development.md: 6938e922c7937f0e6ef6c6e611752827e6a06f82 -development.zh.md: dfb302f23a3f09ea97684b75fcfa7c7dc2e9ade5 +development.md: a3268164cd06fb8bf66f52391bc42c2dc3ca9396 +development.zh.md: f8f29c64aa6e49e3ed6d7ef12df2a4911c9182b0 diff --git a/docs/development.md b/docs/development.md index 6938e922c7..a3268164cd 100644 --- a/docs/development.md +++ b/docs/development.md @@ -151,7 +151,7 @@ The [core data structures](core-data-structures/core.md) docs paste source-equiv { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts type-equiv public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both variants (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `"projection": "public-api"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence by document, symbol, and projection, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips both fence kinds (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. ## Architecture context diff --git a/docs/development.zh.md b/docs/development.zh.md index dfb302f23a..f8f29c64aa 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -151,7 +151,7 @@ pnpm run demo:acp { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts type-equiv public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种变体(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `"projection": "public-api"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还按文档、符号和投影强制 1:1 对应,因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过两种围栏(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index 974a19a621..47dc370d22 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -29,7 +29,7 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei The durability requirement was specific: the doc shows the **literal** current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) **and** is mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability* — a renamed field or changed JSDoc can pass. So: -- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A `public-api` variant carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. +- Complete type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. A concise ` ```ts public-api ` fence carries the source-equivalent ambient projection for a class whose implementation bodies do not belong in the catalog. `doc-typecheck` recognizes both and skips them (the bare declarations are not standalone-compilable), and **excludes them from the opt-out ratio** — they are a separately-checked category, not unchecked sketches. - A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. Ordinary blocks retain the complete declaration. A `public-api` projection retains a class's public fields, constructor, accessors, and methods with their original JSDoc while removing implementation bodies and private or protected members. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. - Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index c4d6c04a35..34af82b48b 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -1,6 +1,6 @@ /** * Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as - * opt-outs; generated catalog fragments and `type-equiv` blocks are skipped here because their + * opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their * owning gates verify them. A build-coordinated mode consumes existing declarations without emit. */ @@ -33,7 +33,7 @@ const KIND_BY_INFO: Record = { 'ts': 'check', 'ts ignore-check': 'ignore', 'ts type-equiv': 'type-equiv', - 'ts type-equiv public-api': 'type-equiv', + 'ts public-api': 'type-equiv', 'ts cordis-catalog': 'cordis-catalog', 'ts persistence-catalog': 'persistence-catalog', 'ts config-catalog': 'config-catalog', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 09888916d0..831c45e610 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,5 +1,5 @@ { - "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts type-equiv public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", + "comment": "Maps each ` ```ts type-equiv ` or ` ```ts public-api ` block (by doc + declared symbol + projection) to the source declaration and original JSDoc it must match. Omit projection for the complete declaration; use public-api with a ` ```ts public-api ` block for a body-stripped public class declaration. verify-type-equiv.ts enforces a 1:1 correspondence: every source-equivalence block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a source-equivalence block; remove it when you remove the block.", "entries": [ { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 58affaffca..d703536f17 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -1,10 +1,10 @@ /** - * Verify every `ts type-equiv` block against the source symbol named by the - * manifest. Ordinary entries preserve the complete declaration; `public-api` - * entries preserve a class's body-stripped public declaration. Blocks and - * entries have a one-to-one relationship; comparison ignores whitespace and - * non-JSDoc comments but preserves declaration structure and every original - * JSDoc comment. + * Verify every `ts type-equiv` and `ts public-api` block against the source + * symbol named by the manifest. Ordinary entries preserve the complete + * declaration; `public-api` entries preserve a class's body-stripped public + * declaration. Blocks and entries have a one-to-one relationship; comparison + * ignores whitespace and non-JSDoc comments but preserves declaration + * structure and every original JSDoc comment. */ import { globSync, readFileSync, existsSync } from 'node:fs' @@ -16,9 +16,9 @@ const root = resolve(import.meta.dirname, '..') /** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */ const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md'] -/** One manifest entry: a documented type-equiv block and its source symbol. */ +/** One manifest entry: a source-equivalence block and its source symbol. */ interface ManifestEntry { - /** Doc file (repo-relative) containing the ` ```ts type-equiv ` block. */ + /** Doc file (repo-relative) containing the source-equivalence block. */ doc: string /** The declared symbol the block must match (e.g. `SessionEvent`). */ symbol: string @@ -28,7 +28,7 @@ interface ManifestEntry { projection?: 'public-api' } -/** One extracted ` ```ts type-equiv ` block. */ +/** One extracted ` ```ts type-equiv ` or ` ```ts public-api ` block. */ interface EquivBlock { doc: string /** 1-based line of the opening fence (for diagnostics). */ @@ -64,7 +64,7 @@ function stripExport(code: string): string { return code.replace(/^export\s+(default\s+)?/, '') } -/** Parse the declared symbol name from a type-equiv block body. */ +/** Parse the declared symbol name from a source-equivalence block body. */ function blockSymbol(code: string): string | null { const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS) for (const stmt of sf.statements) { @@ -76,7 +76,7 @@ function blockSymbol(code: string): string | null { return null } -/** Extract every ` ```ts type-equiv ` block from one Markdown file. */ +/** Extract every source-equivalence block from one Markdown file. */ function extractEquivBlocks(docRel: string): EquivBlock[] { const text = readFileSync(resolve(root, docRel), 'utf8') const lines = text.split('\n') @@ -107,8 +107,11 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { continue } const info = (fence[2] ?? '').trim() + if (info === 'ts type-equiv public-api') { + throw new Error(`verify-type-equiv: ${docRel}:${i + 1} — use the concise \`ts public-api\` fence`) + } if (info === 'ts type-equiv') open = { line: i + 1, body: [] } - if (info === 'ts type-equiv public-api') open = { line: i + 1, body: [], projection: 'public-api' } + if (info === 'ts public-api') open = { line: i + 1, body: [], projection: 'public-api' } } if (open) throw new Error(`verify-type-equiv: ${docRel}:${open.line} — unterminated type-equiv block`) return blocks From ee1a44793acc670ef99797a7ef2c16f60d5cf2bf Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:12:18 +0800 Subject: [PATCH 15/16] fix(core): make initiator teardown reentrant-safe --- docs/cordis-catalog/services.md | 6 +- docs/event-producer-consumer.md | 1 + ...2026-07-15-agent-initiator-scope.i18n.yaml | 4 +- .../2026-07-15-agent-initiator-scope.md | 6 +- .../2026-07-15-agent-initiator-scope.zh.md | 6 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 70 ++++++++++-- .../core/agent/tests/agent-initiator.spec.ts | 102 ++++++++++++++++++ website/zh-CN/api/harness/agents.md | 34 +++--- 9 files changed, 195 insertions(+), 36 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22d4565830..30ca04ba79 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -68,6 +68,8 @@ 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`. @@ -78,6 +80,8 @@ 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. @@ -198,7 +202,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:204`](../../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/event-producer-consumer.md b/docs/event-producer-consumer.md index abea24e3a5..40048b2e7c 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/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 696b119797..9e2a50bd57 100644 --- 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 @@ -2,5 +2,5 @@ # 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: 08c9eca50ed51925fec7c09eda7a82d0c61cb55b -2026-07-15-agent-initiator-scope.zh.md: 284d52126e02e44d512b88a5d64e35ba80552d9b +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 index 08c9eca50e..b3c9be0be1 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -12,7 +12,7 @@ Deep process-local infrastructure sometimes needs a trusted initiating Agent bel ## 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; the [core-data catalog](../../../core-data-structures/core.md#initiating-agent) identifies the carried type. +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. @@ -20,7 +20,7 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in 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()`. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while that 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. +`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. @@ -30,7 +30,7 @@ This decision extends the [Agent registration-scope contract](2026-07-08-agent-s ## Verification -Agent service tests pin optional and required reads, exact synchronous and cross-realm Promise identity, overlapping, nested, and cleared boundaries, restoration after throws or rejection, 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. +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. 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 index 284d52126e..55494977b8 100644 --- 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 @@ -12,7 +12,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 ## 决策 -必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;[核心数据目录](../../../core-data-structures/core.md#initiating-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`、沙箱和授权继续由现有归属方管理。 @@ -20,7 +20,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 隐式身份不会取代显式契约。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 -`AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose 后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。 +`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 传播。 @@ -30,7 +30,7 @@ Harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 ## 验证 -Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启及根 Context 销毁。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。 +Agent 服务测试锁定可选与必需读取、同步值和跨 realm Promise 的引用身份、内建 Promise 结束状态观察、并发、嵌套及清空边界、同步抛错或 Promise 拒绝后的恢复、普通与重入排空顺序及保留引用的错误。AgentLoop 集成测试锁定并发与嵌套驱动、无 Agent 调用、AgentRegistry 重启及根 Context 销毁。组合、模块图、构建及运行时闭包检查确保默认组合包、SDK 主干、Python 运行时闭包及直接 AgentLoop harness 通过 `ctx.agents` 完成接线,无需其他提供方。 只有测试替身形式的宿主感知传输层消费隐式身份;它在内部推导 `X-Harness-Session-Id`,并验证工具 schema 与记录参数都不包含身份字段。服务有意不排空边界操作所返回 Promise 之外的异步工作;这类工作仍由所属方的显式停止契约管理。 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 839907bf62..3cf8bbb6ad 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -26,7 +26,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- - `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. The [initiator-scope decision](../../../docs/rfc/implemented/architecture/2026-07-15-agent-initiator-scope.md) owns the detailed boundary and teardown contract. +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) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index f2e774223d..24087011b7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -5,7 +5,8 @@ * @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' @@ -190,6 +191,12 @@ 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 @@ -205,6 +212,7 @@ 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 @@ -219,6 +227,11 @@ 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() } @@ -249,6 +262,8 @@ export class AgentRegistry extends Service { /** * 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`. @@ -261,6 +276,8 @@ export class AgentRegistry extends Service { /** * 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. @@ -537,42 +554,77 @@ export class AgentRegistry extends Service { 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.initiators.run(agent, operation) + result = this.initiatorRuns.run(run, () => this.initiators.run(agent, operation)) } catch (error: unknown) { - this.releaseInitiatorRun() + this.releaseInitiatorRun(run) throw error } if (isPromise(result)) { - void result.then( - () => { this.releaseInitiatorRun() }, - () => { this.releaseInitiatorRun() }, - ) + 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() + 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) } - private releaseInitiatorRun(): void { + /** 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() diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts index 87a8387e07..7e0b70d13c 100644 --- a/packages/core/agent/tests/agent-initiator.spec.ts +++ b/packages/core/agent/tests/agent-initiator.spec.ts @@ -23,6 +23,17 @@ async function harness(): Promise<{ } } +/** 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() @@ -52,6 +63,44 @@ describe('AgentRegistry initiator scope', () => { 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') @@ -160,4 +209,57 @@ describe('AgentRegistry initiator scope', () => { 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/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index 328ea6097e..e2d94a84f4 100644 --- a/website/zh-CN/api/harness/agents.md +++ b/website/zh-CN/api/harness/agents.md @@ -6,7 +6,7 @@ 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#L204) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L211) ### ctx.agents.currentInitiator() @@ -18,7 +18,7 @@ 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#L233) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L246) ### ctx.agents.requireInitiator() @@ -30,7 +30,7 @@ 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#L243) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L256) ### ctx.agents.withInitiator(agent, operation) @@ -38,14 +38,14 @@ Read the initiating Agent and fail when no driver boundary is active. 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. +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#L257) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L272) ### ctx.agents.withoutInitiator(operation) @@ -53,13 +53,13 @@ Run an operation with one exact Agent as its process-local initiator. The exact withoutInitiator(operation: () => T): T ``` -Run an operation inside a boundary that hides any inherited initiating Agent. The exact synchronous value or Promise is preserved. +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#L268) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L285) ### ctx.agents.setFactory(factory) @@ -73,7 +73,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#L284) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L301) ### ctx.agents.create(options) @@ -87,7 +87,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#L317) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L334) ### ctx.agents.resume(options) @@ -101,7 +101,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#L336) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L353) ### ctx.agents.register(agent) @@ -115,7 +115,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#L362) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L379) ### ctx.agents.enter(agent, owner) @@ -130,7 +130,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#L386) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L403) ### ctx.agents.announce(agent) @@ -142,7 +142,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#L461) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L478) ### ctx.agents.get(id) @@ -156,7 +156,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#L495) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L512) ### ctx.agents.isOwnedBy(id, owner) @@ -171,7 +171,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#L507) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L524) ### ctx.agents.list() @@ -183,7 +183,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#L515) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L532) ### ctx.agents.roots() @@ -195,4 +195,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#L525) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L542) From e1a4a2963d2105056a5e85da2a7c47232f5095f6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:34:08 +0800 Subject: [PATCH 16/16] docs(core): document initiator API scenarios --- docs/cordis-catalog/services.md | 28 +++++++--- packages/core/agent/src/index.ts | 30 ++++++++--- website/zh-CN/api/harness/agents.md | 82 ++++++++++++++++++++++------- 3 files changed, 107 insertions(+), 33 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 30ca04ba79..84f38ec859 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -50,16 +50,25 @@ Source: [`packages/core/agent-loop/src/index.ts:407`](../../packages/core/agent- 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. +Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. + ```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. + * Use this optional form for logging, tracing, metrics, or host attribution + * that also supports agentless calls. When a parent creates a child, setup + * reports the causal parent while `agentCtx.agent` identifies the child. + * @returns the inherited Agent, or `undefined` outside an initiator boundary + * 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. + * Read the initiating Agent and fail when no initiator boundary is active. + * Use this for private helpers contractually below a driver, or for a + * deployment-owned outbound request whose contract forbids agentless calls. + * Generic or direct-call seams use optional lookup or explicit request fields. * @returns the inherited Agent. * @throws when no initiator is active or this service instance has been disposed. */ @@ -68,8 +77,11 @@ 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. + * Custom drivers and test harnesses wrap their complete returned foreground + * lifetime. + * A queue or wire receiver may establish this boundary only after validating + * explicit identity and resolving the exact live Agent; this method does neither. + * Detached work remains owned by the subsystem that starts it. * @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`. @@ -80,8 +92,10 @@ 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. + * Use this while creating lazy shared timers, queue pumps, pool maintenance, + * watchers, or exporters so they do not inherit the first Agent that happens + * to initialize them. It clears only initiator attribution, not explicit + * fields, and does not own or drain detached resources. * @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. @@ -202,7 +216,7 @@ roots(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:211`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 24087011b7..75f00daebb 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -207,6 +207,12 @@ interface FactorySlot { * 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}. + * + * Initiator methods provide same-process causal attribution only. Ambient + * presence is neither liveness proof nor authorization; subjects and owners + * remain explicit, as does identity at worker, process, persistence, and wire + * boundaries. Returned Promise boundaries drain during teardown, except a + * nested lineage that starts an owning-fiber unload is excluded from its own drain. */ export class AgentRegistry extends Service { private store = new Map() @@ -240,7 +246,11 @@ export class AgentRegistry extends Service { /** * 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. + * Use this optional form for logging, tracing, metrics, or host attribution + * that also supports agentless calls. When a parent creates a child, setup + * reports the causal parent while `agentCtx.agent` identifies the child. + * @returns the inherited Agent, or `undefined` outside an initiator boundary + * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. */ currentInitiator(): Agent | undefined { @@ -249,7 +259,10 @@ export class AgentRegistry extends Service { } /** - * Read the initiating Agent and fail when no driver boundary is active. + * Read the initiating Agent and fail when no initiator boundary is active. + * Use this for private helpers contractually below a driver, or for a + * deployment-owned outbound request whose contract forbids agentless calls. + * Generic or direct-call seams use optional lookup or explicit request fields. * @returns the inherited Agent. * @throws when no initiator is active or this service instance has been disposed. */ @@ -262,8 +275,11 @@ export class AgentRegistry extends Service { /** * 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. + * Custom drivers and test harnesses wrap their complete returned foreground + * lifetime. + * A queue or wire receiver may establish this boundary only after validating + * explicit identity and resolving the exact live Agent; this method does neither. + * Detached work remains owned by the subsystem that starts it. * @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`. @@ -276,8 +292,10 @@ export class AgentRegistry extends Service { /** * 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. + * Use this while creating lazy shared timers, queue pumps, pool maintenance, + * watchers, or exporters so they do not inherit the first Agent that happens + * to initialize them. It clears only initiator attribution, not explicit + * fields, and does not own or drain detached resources. * @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. diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index 923bd2748b..bba6a7a5d4 100644 --- a/website/zh-CN/api/harness/agents.md +++ b/website/zh-CN/api/harness/agents.md @@ -5,61 +5,103 @@ `AgentRegistry` — provided by `@deepseek-ai/dsh-agent`. 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. +Initiator methods provide same-process causal attribution only. Ambient presence is neither liveness proof nor authorization; subjects and owners remain explicit, as does identity at worker, process, persistence, and wire boundaries. Returned Promise boundaries drain during teardown, except a nested lineage that starts an owning-fiber unload is excluded from its own drain. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L211) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L217) ### ctx.agents.currentInitiator() ```ts website-api +/** + * Read the Agent that initiated the inherited asynchronous driver chain. + * Use this optional form for logging, tracing, metrics, or host attribution + * that also supports agentless calls. When a parent creates a child, setup + * reports the causal parent while `agentCtx.agent` identifies the child. + * @returns the inherited Agent, or `undefined` outside an initiator boundary + * and inside an explicit clearing boundary. + * @throws when this service instance has been disposed. + */ currentInitiator(): Agent | undefined ``` -Read the Agent that initiated the inherited asynchronous driver chain. +Read the Agent that initiated the inherited asynchronous driver chain. Use this optional form for logging, tracing, metrics, or host attribution that also supports agentless calls. When a parent creates a child, setup reports the causal parent while `agentCtx.agent` identifies the child. -**Returns** the inherited Agent, or `undefined` outside a driver and inside an explicit clearing boundary. +**Returns** the inherited Agent, or `undefined` outside an initiator boundary and inside an explicit clearing boundary. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L246) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L256) ### ctx.agents.requireInitiator() ```ts website-api +/** + * Read the initiating Agent and fail when no initiator boundary is active. + * Use this for private helpers contractually below a driver, or for a + * deployment-owned outbound request whose contract forbids agentless calls. + * Generic or direct-call seams use optional lookup or explicit request fields. + * @returns the inherited Agent. + * @throws when no initiator is active or this service instance has been disposed. + */ requireInitiator(): Agent ``` -Read the initiating Agent and fail when no driver boundary is active. +Read the initiating Agent and fail when no initiator boundary is active. Use this for private helpers contractually below a driver, or for a deployment-owned outbound request whose contract forbids agentless calls. Generic or direct-call seams use optional lookup or explicit request fields. **Returns** the inherited Agent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L256) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L269) ### ctx.agents.withInitiator(agent, operation) ```ts website-api +/** + * Run an operation with one exact Agent as its process-local initiator. The + * exact synchronous value or Promise returned by the operation is preserved. + * Custom drivers and test harnesses wrap their complete returned foreground + * lifetime. + * A queue or wire receiver may establish this boundary only after validating + * explicit identity and resolving the exact live Agent; this method does neither. + * Detached work remains owned by the subsystem that starts it. + * @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 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. +Run an operation with one exact Agent as its process-local initiator. The exact synchronous value or Promise returned by the operation is preserved. Custom drivers and test harnesses wrap their complete returned foreground lifetime. A queue or wire receiver may establish this boundary only after validating explicit identity and resolving the exact live Agent; this method does neither. Detached work remains owned by the subsystem that starts it. - `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) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L288) ### ctx.agents.withoutInitiator(operation) ```ts website-api +/** + * Run an operation inside a boundary that hides any inherited initiating + * Agent. The exact synchronous value or Promise is preserved. + * Use this while creating lazy shared timers, queue pumps, pool maintenance, + * watchers, or exporters so they do not inherit the first Agent that happens + * to initialize them. It clears only initiator attribution, not explicit + * fields, and does not own or drain detached resources. + * @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 ``` -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. +Run an operation inside a boundary that hides any inherited initiating Agent. The exact synchronous value or Promise is preserved. Use this while creating lazy shared timers, queue pumps, pool maintenance, watchers, or exporters so they do not inherit the first Agent that happens to initialize them. It clears only initiator attribution, not explicit fields, and does not own or drain detached resources. - `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) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L303) ### ctx.agents.setFactory(factory) @@ -85,7 +127,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#L301) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L319) ### ctx.agents.create(options) @@ -108,7 +150,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#L334) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L352) ### ctx.agents.resume(options) @@ -129,7 +171,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#L353) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L371) ### ctx.agents.register(agent) @@ -161,7 +203,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#L379) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L397) ### ctx.agents.enter(agent, owner) @@ -191,7 +233,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#L403) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L421) ### ctx.agents.announce(agent) @@ -210,7 +252,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#L478) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L496) ### ctx.agents.get(id) @@ -229,7 +271,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#L512) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L530) ### ctx.agents.isOwnedBy(id, owner) @@ -252,7 +294,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#L524) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L542) ### ctx.agents.list() @@ -268,7 +310,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#L532) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L550) ### ctx.agents.roots() @@ -286,4 +328,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#L542) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L560)