fix(agent): commit mutable setup at publication

Agent setup may await while a mutable contribution registry changes. The previous subagent path validated and committed its provisioning batch inside the setup callback. A revocation queued after that callback returned therefore treated the installation as resident and released it, even though AgentLoop had not published the child yet. AgentLoop could then admit and announce a child whose required capability had already disappeared.

Introduce AgentSetupCommit as the optional synchronous result of create and resume setup. AgentLoop now awaits setup, invokes that commit with no intervening asynchronous boundary, and only then enters the Session and Agent registries. A commit failure follows the existing private-transaction rollback, so neither identity is published and the caller can reuse the id.

Keep continuable-subagent installations provisional until this publication commit. Contribution removal still releases every installation immediately, but now marks an unpublished batch invalid so its commit rejects with ACTIVATION_SETUP_REVOKED. Once the commit succeeds, later removal remains ordinary live revocation.

Cover create and resume ordering, resume commit rejection and identity reuse, and an assembled microtask revocation that leaves only the parent Agent and Session. Update the public JSDoc, architecture flow, package contracts, current Agent Notes, Chinese counterparts, pairing records, and generated Cordis API to describe the new boundary.

Validated with the four focused Agent/subagent test files (91 tests), the isolated assembled regression, targeted TypeScript project builds, generated Cordis API freshness, export JSDoc verification, scoped translation pairing, Markdown wrapping, and Mermaid parsing.
This commit is contained in:
Tianyi Cui
2026-08-02 20:09:05 +08:00
parent dbe053fe08
commit b54381f3e7
28 changed files with 198 additions and 106 deletions

View File

@@ -1,6 +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-08-agent-scope-contexts.md: e4c076189a8e8a438b561232d3779ad1f6ab0d08
2026-07-08-agent-scope-contexts.zh.md: 35e725e43d402b048daf12c3b4be384b3fd2d2ce
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md
2026-07-08-agent-scope-contexts.md: 5e09bdbcae1e57e6b65eb7d1720a6e7a7f758a9f
2026-07-08-agent-scope-contexts.zh.md: 4714045f28e0386a3a53b53437d063462e75a9f1

View File

@@ -108,11 +108,11 @@ A listener registered with `{ global: true }` deliberately bypasses contextual a
### Creation publishes last and disposal revokes last
`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle.
`ctx.agents.create()` and `resume()` build an unpublished session, scope, agent, and driver. They await `setup`, synchronously invoke its optional `AgentSetupCommit`, admit the final session and agent entries, announce them in order, start the loop, and only then return a handle. The commit lets mutable provisioning revalidate at the exact publication boundary after every setup await; a throw rolls the private transaction back before either identity is announced, while revocation after a successful commit is ordinary live teardown.
An optional creation signal cancels work only while create or resume is pending. After the promise resolves, the returned `AgentHandle` owns explicit disposal.
If loading, setup, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid.
If loading, setup, the optional setup commit, admission, or publication fails, the private transaction rolls back everything it prepared. Concurrent operations using the same caller-supplied live ID may both reach setup, but final registry entry admits only one; every loser rejects and cleans its private resources. Sequential reuse after awaited disposal remains valid.
`AgentHandle.dispose()` reverses the boundary. It deactivates creation or driving, waits for synchronous publication to unwind, stops and drains the driver and final session flushes, detaches the agent and session, and finally disposes the scope. Repeated or racing disposal requests join one completion promise.
@@ -122,12 +122,14 @@ The calling Cordis context and the concrete AgentLoop factory are structural co-
flowchart TB
request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"]
privateWorld --> setup["Await composition through agent.ctx"]
setup --> admission["Admit final session and agent entries"]
setup --> setupCommit["Commit optional mutable provisioning"]
setupCommit --> admission["Admit final session and agent entries"]
admission --> publish["Announce lifecycle and start the driver"]
publish --> live["Return AgentHandle"]
privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"]
setup -->|"failure, cancellation, or owner loss"| rollback
setupCommit -->|"revalidation failure or owner loss"| rollback
admission -->|"duplicate or owner loss"| rollback
publish -->|"listener failure or owner loss"| rollback
live -->|"handle or owner disposal"| quiesce["Stop and drain work"]
@@ -166,6 +168,6 @@ Parentage describes lifetime and conversation lineage, not a universal merge pol
## Consequences
Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup is atomic from an observer's perspective, and teardown preserves local behavior until work stops.
Contributors use one familiar pattern: register shared behavior through a plugin context, register local behavior through `agent.ctx`, select the real agent on operations, and dispose the returned handle. Setup and its optional publication commit are atomic from an observer's perspective, and teardown preserves local behavior until work stops.
The cost is explicit subject selection, asynchronous programmatic creation, and service-specific scope adoption. Flat registration scope is intentionally not authority, and subagent composition controls remain a separate feature rather than hidden scope semantics.

View File

@@ -108,11 +108,11 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插
### 创建最后发布dispose 最后撤销
`ctx.agents.create()``resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`,准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。
`ctx.agents.create()``resume()` 构建未发布的会话、作用域、agent 和驱动器。它们等待 `setup`同步调用其可选的 `AgentSetupCommit`准入最终的会话和 agent 条目,按序公告,启动循环,然后才返回 handle。该提交操作让可变的配置状态在所有 setup 的 await 均结算后,于确切的发布边界重新校验;若其抛出异常,则会在公告任何一个身份前回滚私有事务,而成功提交后的撤销属于普通的实时拆卸。
可选的创建信号仅在创建或恢复挂起期间取消工作。promise resolve 后,返回的 `AgentHandle` 拥有显式 dispose 权。
如果加载、setup、准入或发布失败私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup但最终注册表条目只准入一个每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。
如果加载、setup、可选的 setup 提交、准入或发布失败,私有事务回滚其准备的一切。使用同一个调用方提供的存活 ID 的并发操作可能都到达 setup但最终注册表条目只准入一个每个失败者拒绝并清理其私有资源。在等待 dispose 完成后的顺序复用仍然有效。
`AgentHandle.dispose()` 反转边界。它停用创建或驱动,等待同步发布解除,停止并排空驱动器和最终会话刷写,分离 agent 和会话,最后 dispose 作用域。重复或竞争的 dispose 请求合并为一个完成 promise。
@@ -122,12 +122,14 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插
flowchart TB
request["Create or resume"] --> privateWorld["Build private session, scope, agent, and driver"]
privateWorld --> setup["Await composition through agent.ctx"]
setup --> admission["Admit final session and agent entries"]
setup --> setupCommit["Commit optional mutable provisioning"]
setupCommit --> admission["Admit final session and agent entries"]
admission --> publish["Announce lifecycle and start the driver"]
publish --> live["Return AgentHandle"]
privateWorld -->|"failure, cancellation, or owner loss"| rollback["Rollback private work"]
setup -->|"failure, cancellation, or owner loss"| rollback
setupCommit -->|"revalidation failure or owner loss"| rollback
admission -->|"duplicate or owner loss"| rollback
publish -->|"listener failure or owner loss"| rollback
live -->|"handle or owner disposal"| quiesce["Stop and drain work"]
@@ -166,6 +168,6 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、
## 后果
贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agentdispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。
贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agentdispose 返回的 handle。从观察者角度看setup 及其可选的发布提交是原子的,拆除则保留本地行为直到工作停止。
代价是显式的主体选择、异步的编程式创建以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。

View File

@@ -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 .agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md
2026-07-30-continuable-subagent-report-tool.md: 24922cfe88084bb0f9fea8c9363980a224b875da
2026-07-30-continuable-subagent-report-tool.zh.md: bb0b1847f157dba6116526851194cf1228e52e49
2026-07-30-continuable-subagent-report-tool.md: 8324f1fa08f7dace6153712575e7e70a07ee9344
2026-07-30-continuable-subagent-report-tool.zh.md: e7599c1d85328e83c7773718101b59e763a1e37f

View File

@@ -54,7 +54,7 @@ The first version provides no durable mailbox, idempotency key, delivery receipt
The subagent seam adds `registerContinuableSetup(contribution): () => void`, backed by `SubagentActivationSetupRegistry`. Each synchronous contribution receives the unpublished child context and returns the disposer for its installation. The continuation manager first applies base child composition, then current contributions in registration order through the same setup closure used for fresh creation and cold resume.
The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. A throwing or concurrently revoked contribution rejects before Activation publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures.
The registry owns registration, per-child installation records, setup rollback, child-scope cleanup, and immediate revocation. Applying a batch returns the Agent setup commit that revalidates provisioning after every setup await and immediately before Agent publication. A throwing or concurrently revoked contribution therefore rejects before either Agent or Session publication and rolls back the batch. New registrations affect a resident child only on its next Activation; removing a registration first closes it to new setup and then revokes every provisioning or resident installation immediately. Registration disposal and child-context disposal are idempotent and attempt every release before aggregating failures.
This seam keeps the continuation manager unaware of tool names. The report package installs only `report`; `@deepseek-ai/dsh-tool-subagent-control` independently installs parent-side `send_message` and `list_agents`. A deployment can install either direction, both, or neither. Providers remain data-only, durable descriptors do not snapshot report availability or delivery mode, and cold resume uses the deployment's current contributions and policy.
@@ -94,6 +94,10 @@ Mutating or cold-resuming an absent parent requires a new durable addressing, au
A result-bearing wrapper makes one report or one turn appear terminal and recreates the lifetime mismatch that continuable Activations removed. Explicit repeatable sends need no intermediate execution object.
### Validate setup after Agent creation
A post-creation revocation check can reject the Activation only after the Agent and Session have been published. Disposing the returned handle removes the live objects but cannot delete persistence through the current seam, leaving a resumable child that the continuation manager said was never established. Returning an `AgentSetupCommit` instead lets the Agent factory perform the same mutable-state check synchronously at its publication boundary.
## Consequences
- A continuable in-process child exposes exactly one scope-local `report` schema only while the report package's contribution is installed; unrelated Agents never expose it.
@@ -112,5 +116,3 @@ The acceptance boundary is weaker than durable end-to-end delivery. A crash can
Wakeup mode can amplify model work when nested children report frequently. Deployment ownership and a quiet default limit but do not remove that risk.
Registry presence is the parent liveness signal. A host-owned parent whose `AgentHandle.dispose()` has started but has not yet unwound its scope can still accept and append a report that it will not act on in this process. Closing that gap requires an Agent-level disposal-start signal rather than subagent-layer inference.
The final setup-revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, after lower-level Agent and Session publication. Revocation in this window rolls back the handle and prevents the subagent Activation start edge but may leave a persisted Session. Moving the cutoff before lower-level publication requires a future Agent-creation setup transaction seam.

View File

@@ -54,7 +54,7 @@ root、one-shot child、伪造对象、陈旧 Agent 和同 id 替换对象都以
subagent seam 新增 `registerContinuableSetup(contribution): () => void`,由 `SubagentActivationSetupRegistry` 支撑。每个同步贡献都会接收尚未发布的 child 上下文,并返回其安装的 disposer。继续执行管理器首先应用基础 child 组合,然后通过同一个用于首次创建与冷恢复的设置闭包,按注册顺序应用当前贡献。
注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。某项贡献抛出异常或被并发撤销时,会在 Activation 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。
注册表负责注册、每个 child 的安装记录、设置回滚、child 作用域清理和立即撤销。应用一个批次会返回 Agent setup 提交对象,用于在所有 setup 的 await 均结算后、紧邻 Agent 发布前重新校验配置状态。因此,某项贡献抛出异常或被并发撤销时,会在 Agent 与 Session 发布前拒绝操作并回滚该批次。新注册项只会在驻留 child 的下一个 Activation 生效;移除注册项时,会先将它对新设置关闭,再立即撤销为正在配置或驻留的每个 child 安装的实例。注册 dispose 与 child 上下文 dispose 都是幂等的,两者都会先尝试每项释放,再聚合失败。
该 seam 使继续执行管理器无需知道工具名。report 包只安装 `report``@deepseek-ai/dsh-tool-subagent-control` 则独立安装 parent 侧的 `send_message``list_agents`。部署时可安装任一方向、同时安装两者或两者均不安装。提供方仍只负责数据,持久化描述符不会对 report 可用性或投递模式建立快照,冷恢复则使用部署当前的贡献与策略。
@@ -94,6 +94,10 @@ ACPAgent Client Protocol快照 harness 新增 `waitForSubagentTurnEnd`
承载结果的包装层会让一次报告或一个轮次看似具有终止性,并重新引入可继续 Activation 已经移除的生命周期不匹配。显式、可重复的发送无需中间执行对象。
### 在 Agent 创建后校验 setup
创建完成后的撤销检查只能在 Agent 与 Session 均已发布后拒绝 Activation。对返回的 handle 执行 dispose 会移除实时对象,但当前 seam 无法删除持久化内容,因此会留下一个仍可恢复的 child而继续执行管理器却判定它从未建立。改为返回 `AgentSetupCommit`Agent 工厂便可在自身的发布边界同步执行同一项可变状态检查。
## 影响
- 只有安装 report 包贡献时,可继续进程内 child 才会恰好暴露一个作用域局部 `report` schema无关 Agent 永远不会暴露该 schema。
@@ -112,5 +116,3 @@ ACPAgent Client Protocol快照 harness 新增 `waitForSubagentTurnEnd`
wakeup 模式可能在嵌套 child 频繁报告时放大模型工作量。由部署所有者控制并默认静默,可以限制该风险,但无法完全消除。
注册表中的存在性就是 parent 在线信号。宿主拥有的 parent 如果已开始 `AgentHandle.dispose()` 但尚未展开其作用域,仍可能接受并追加一条本进程不会再处理的报告。要弥合这个缺口,需要 Agent 层面的 dispose 开始信号,不能由 subagent 层推断。
最终 setup 撤销检查发生在 `ctx.agents.create()``ctx.agents.resume()` 返回之后,此时底层 Agent 和 Session 已经发布。在该窗口内撤销会回滚 handle并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。若要把截止点移到底层发布之前需要未来提供 Agent 创建 setup 事务 seam。

View File

@@ -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 docs/architecture.md
architecture.md: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d
architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f
architecture.md: add76b6016ae5243115e88173971468a2c93287f
architecture.zh.md: 595f14566cac4301a0b52ded1140511cab948d90

View File

@@ -76,7 +76,7 @@ Creation without an id mints `<config-id>-session-<uuid>`; `sessionId` resumes o
```text
choose declarative identity and fresh/resume path
-> prepare private session + agent.ctx -> await unpublished setup
-> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit
-> enter session + agent -> session/created -> agent/created
-> enable driving -> agent/session-start(source) -> start driver
forever:
@@ -139,7 +139,7 @@ Turn and step events are turn-enclosed. Idle `user/message` and standalone `comp
### Agent Scope
Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, and command entries on globals while preserving domain views ([decision](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md)). Scoped listeners filter dispatch; contributions unwind with awaited cleanup. `CreateAgentOptions.setup(agentCtx)` composes before publication and may return a synchronous commit that the factory invokes immediately before registry entry, after every setup await. Typed resolvers derive carrier checks from merged `Events` and `scopeTarget` ([semantic gates](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md)). Details: [agent scope](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md), [subagent composition](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md). `AgentLoop` runs under `ctx.agents.withInitiator()`; private orchestration derives `agent.session`, but turn, step, signal, cwd, and authority stay explicit ([decision](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md)).
## State

View File

@@ -76,7 +76,7 @@ waterfall瀑布式事件是环绕中间件监听器通过 `next()` 委
```text
choose declarative identity and fresh/resume path
-> prepare private session + agent.ctx -> await unpublished setup
-> prepare private session + agent.ctx -> await unpublished setup -> invoke optional synchronous setup commit
-> enter session + agent -> session/created -> agent/created
-> enable driving -> agent/session-start(source) -> start driver
forever:
@@ -139,7 +139,7 @@ idle inject:
### Agent 作用域
每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合。类型化解析器从合并后的 `Events``scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop``ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
每个 agent 都拥有作用域化的 `agent.ctx`;共享存储会将其工具、提示词和命令条目叠加到全局条目之上,同时保留各领域视图([决策](../.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md))。作用域监听器会过滤分派;贡献都会在撤销时等待清理完成。`CreateAgentOptions.setup(agentCtx)` 在发布前完成组合,并可返回一个同步提交操作;所有 setup 的 await 均完成后,工厂会在进入注册表前立即调用该操作。类型化解析器从合并后的 `Events``scopeTarget` 推导载体检查([语义门禁](../.agents/notes/implemented/process/2026-07-14-typescript-program-backed-semantic-gates.md))。详情见 [agent 作用域](../.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md)和 [subagent 组合](../.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md)。`AgentLoop``ctx.agents.withInitiator()` 内运行;私有编排会派生 `agent.session`但轮次、步骤、信号、cwd 和权限仍保持显式([决策](../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md))。
## 状态

View File

@@ -216,7 +216,7 @@ roots(): Agent[]
Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md)
Source: [`packages/core/agent/src/index.ts:217`](../../packages/core/agent/src/index.ts)
Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts)
## `ctx.approval` — `ApprovalService`

View File

@@ -1607,6 +1607,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentOptions',
declaration: 'export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n}',
},
{
name: 'AgentSetup',
declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void;',
},
{
name: 'AgentSetupCommit',
declaration: 'export interface AgentSetupCommit {\n commit(): void;\n}',
},
{
name: 'AgentStatus',
declaration: 'export type AgentStatus = \'idle\' | \'running\';',
@@ -1833,7 +1841,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CreateAgentOptions',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'CreateGoalRequest',
@@ -2337,7 +2345,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ResumeAgentOptions',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise<void> | void;\n}',
declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}',
},
{
name: 'SandboxEnforcement',

View File

@@ -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 packages/core/agent-loop/README.md
README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6
README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817
README.md: f146ea3d2cc379303514286c5cfd2833397f23ed
README.zh.md: 767fbd420709be05e9a8f85dc8ec6fe03ae6aa66

View File

@@ -10,7 +10,7 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; synchronously invoke its optional publication commit; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Its optional commit revalidates mutable provisioning after every setup await and immediately before registry entry; a throw rolls the private transaction back without publishing either id. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
@@ -20,8 +20,8 @@ Each agent and its session share one caller-chosen `SessionId`, assumed globally
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits unpublished setup, invokes its optional synchronous commit at the publication boundary, and then enters both registries; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history under the same id, await setup against a fresh unpublished agent scope, invoke its optional synchronous commit, then use the same rollback-covered publication sequence. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.

View File

@@ -10,7 +10,7 @@
### 公开 API
创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup进入两个注册表依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
创建与恢复属于同一个受回滚保护的事务:构造私有会话、实体 agent 和带作用域的上下文;等待可选 setup同步调用其可选的发布提交;进入两个注册表;依次宣告 `session/created``agent/created`;发出 `agent/session-start`此后才启动驱动器。Setup 接收完整的带作用域 `Context`,作为受信任的同进程组合代码,并且不得驱动尚未发布的 agent。其可选提交会在所有 setup 的 await 均结算后、进入注册表之前立即重新校验可变的配置状态;若其抛出异常,则回滚私有事务且不发布任何一个 id。普通的类型化身份与选项输入遵循只读契约以借用方式传入seed 事件与会话元数据会跨越持久会话边界,因此系统会验证并快照它们。可选的 `AbortSignal` 只取消加载setup发布并在返回的 handle 可见前分离。
调用方 fiber 与 AgentLoop 提供方共同拥有 agent。`AgentFactory.createAgent(ownerCtx, options)``resume(ownerCtx, options)` 显式接收调用方所有权,而工厂为 `sessions`/`llm`/`tools`/`systemPrompt` 保留自身的依赖上下文;这样,调用方可以只注入 `agents`,而不会缩减新 agent 的服务接口。调用方卸载、handle dispose资源释放或提供方卸载都会汇合到同一个记忆化的完全停稳边界。提供方关闭会同时等待资源 teardown以及已经观测到停用的公开 create/resume 包装层,因此依赖消失后,任何 continuation 都无法继续发布。
@@ -20,8 +20,8 @@
`AgentLoop` 还实现 `AgentFactory` seam并通过 `ctx.agents.setFactory(this)` 注册自身,因此插件会通过接口 `ctx.agents` 创建/恢复 agent
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回`meta` 携带 cwd谱系seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)使用同一 id 注册 agent重建历史,然后针对全新且尚未发布的 agent 作用域等待 setup再执行受回滚保护发布。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表`meta` 携带 cwd谱系seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>`:通过 `ctx.sessionPersistence` 加载持久化会话(参见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)同一 id 重建历史,针对全新且尚未发布的 agent 作用域等待 setup调用其可选的同步提交,然后使用相同的受回滚保护发布序列。轮次编号和派生历史从已加载日志继续。此操作要求存在会话持久化后端(不会硬注入,因此非持久化 demo 仍能工作;缺少持久化时,`resume` 会以明确错误拒绝)。`signal` 仅用于创建。返回 `AgentHandle`
配置驱动的 `ctx.agentLoop.create()` 路径让循环 fiber 拥有其 agent该路径会丢弃 handle。对于以编程方式创建的 agenthandle 持有者是唯一面向消费方的 teardown 能力AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。

View File

@@ -558,7 +558,10 @@ export class AgentLoop extends Service implements AgentFactory {
const prepared = this.prepare(ownerCtx, options.sessionId, options.agentOptions ?? {}, session, options.signal)
const published = (async () => {
try {
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId)
const setupCommit = await raceAbort(
options.setup?.(prepared.agent.ctx), prepared.signal, options.sessionId,
)
setupCommit?.commit()
return prepared.publish('startup')
} catch (error: unknown) {
await prepared.dispose()
@@ -617,7 +620,8 @@ export class AgentLoop extends Service implements AgentFactory {
})
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
try {
await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
setupCommit?.commit()
return prepared.publish('resume')
} catch (error: unknown) {
await prepared.dispose()

View File

@@ -291,6 +291,13 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
setupStarted.resolve(undefined)
await gate.promise
order.push('setup:end')
return {
commit: () => {
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
order.push('setup:commit')
},
}
},
})
@@ -304,6 +311,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
expect(order).toEqual([
'setup:start',
'setup:end',
'setup:commit',
'session/created',
'setup-listener:session/created',
'agent/created',
@@ -359,6 +367,33 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx.fiber.dispose()
})
it('resume setup commit rejection publishes nothing and releases the identity', async () => {
const sessionId = SessionId('resume-setup-commit-reject')
const root = await persistSession(sessionId)
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
const published: string[] = []
ctx.on('session/created', () => void published.push('session/created'))
ctx.on('agent/created', () => void published.push('agent/created'))
await expect(ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
setup: () => ({
commit: () => { throw new Error('resume setup commit failed') },
}),
})).rejects.toThrow('resume setup commit failed')
expect(published).toEqual([])
expect(ctx.agents.get(sessionId)).toBeUndefined()
expect(ctx.sessions.get(sessionId)).toBeUndefined()
const retry = await ctx.agents.resume({
resumeSessionId: sessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
await retry.dispose()
await ctx.fiber.dispose()
})
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
const sessionId = SessionId('resume-setup-owner-unload')
const root = await persistSession(sessionId)

View File

@@ -264,6 +264,13 @@ describe('agent scope lifecycle', () => {
setupStarted.resolve(undefined)
await gate.promise
order.push('setup:end')
return {
commit: () => {
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
order.push('setup:commit')
},
}
},
})
await setupStarted.promise
@@ -276,6 +283,7 @@ describe('agent scope lifecycle', () => {
expect(order).toEqual([
'setup:start',
'setup:end',
'setup:commit',
'session/created',
'setup-listener:session/created',
'agent/created',

View File

@@ -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 packages/core/agent/README.md
README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734
README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170
README.md: 5f9fdc44794a562c2b0da39e1a43a01aa19c451b
README.zh.md: f088b8c354f0aec1b51d7ff8ae706ddc49265a20

View File

@@ -12,7 +12,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver
### Public API
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves.
The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup may return an `AgentSetupCommit`; after every setup await settles, the factory invokes its synchronous `commit()` immediately before registry entry, and a throw rolls the private transaction back without publishing either id. Setup remains trusted, composition-only same-process code: drive the agent only after creation resolves.
`AgentOptions` supplies the initial provider/model route and an optional positive `maxTokens` output cap. The concrete loop resolves any exact-model adapter default, records the effective cap in the request header, and applies it to each conversation-model request; an explicit Agent option wins, while omission leaves the adapter or provider route default in control.
@@ -39,8 +39,8 @@ The scope carries the `Agent` itself and is process-local. Ambient presence is n
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.
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, invoke its optional synchronous commit, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, invoke its optional synchronous commit, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.

View File

@@ -12,7 +12,7 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
### 公开 API
带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在 dispose资源释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值。`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
带作用域的注册接口:`Agent.ctx` 是 agent 的作用域上下文(`dsh-scope`,键 = 该 agent。通过它注册工具变量监听器只对该 agent 生效,并在 dispose资源释放时全部撤销。`agentEvents(ctx, agent)` 是普通 agent 主体操作的融合分发器(一次完成载体 + 注入主体);其通知 mode 会调用每个监听器,并同时收容同步抛出和返回 Promise 的拒绝。注册表生命周期对复用一个稳定路由载体。`assembleContextFor(agent)` 构建按 agent 的组装上下文(同时包含 `agent` + `scope`)。`installAgentLlmTarget(agentCtx, target)` 在提示词组装期间快照可变的提供方模型推理reasoning强度选择将路由应用到提示词变量并将完整目标应用到一个步骤的请求路由如果没有选定推理强度则会清除继承的推理强度使该目标使用适配器提供方默认值。`CreateAgentOptions.setup(agentCtx)``ResumeAgentOptions.setup(agentCtx)` 在新建或恢复的 agent 尚未发布时组合其带作用域的世界。Setup 可以返回一个 `AgentSetupCommit`;所有 setup 的 await 均结算后,工厂会在进入注册表前立即调用其同步 `commit()`,若其抛出异常,则回滚私有事务且不发布任何一个 id。Setup 仍是受信任、仅用于组合的同进程代码:只有创建完成后才能驱动 agent。
`AgentOptions` 提供初始的提供方/模型路由,以及可选的正数 `maxTokens` 输出上限。实体循环会解析确切模型的适配器默认值,把生效上限记录到请求 header并应用到每次对话模型请求显式 Agent 选项优先,省略时由适配器或提供方路由默认值控制。
@@ -39,8 +39,8 @@ Agent 接口、注册表、进程本地发起方作用域,以及 `agent/*` 事
Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,并通过 `setFactory` 注册。这样,创建功能留在 `dsh-agent` 接口上消费方UI、ACP 桥接层)可以面向 `ctx.agents` 编程,而不依赖具体循环包。注册表会把已经 traced 的 Service 规范化为具体目标,并通过调用方上下文重新 trace 每次调用;这既避免嵌套 Cordis shadow也会把显式、绑定调用方的 `ownerCtx` 传给普通工厂。
- `ctx.agents.setFactory(factory: AgentFactory): () => void`注册创建工厂循环在构造时调用。第二个工厂会导致抛出dispose 时清空槽位。
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent在不发布的情况下等待可选 setup然后通过最终的 `SessionStore.enter()``AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID多个操作可以进行准备但只有一个能进入每个失败方都会回滚其私有作用域会话驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup并在返回 handle 前分离;之后的取消使用 `handle.dispose()``agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>`:创建会话和 agent在不发布的情况下等待可选 setup调用其可选的同步提交,然后通过最终的 `SessionStore.enter()``AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID多个操作可以进行准备但只有一个能进入每个失败方都会回滚其私有作用域会话驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup并在返回 handle 前分离;之后的取消使用 `handle.dispose()``agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent从存储中移除其会话最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`ACP 桥接层与进程内 subagent 后端持有消费方 handle而配置创建的 agent 已由循环 fiber 拥有。

View File

@@ -36,6 +36,27 @@ declare module 'cordis' {
}
}
/**
* Synchronous finalizer returned by unpublished Agent setup when its
* contributions need validation at the exact publication commit point.
*/
export interface AgentSetupCommit {
/**
* Validate and commit the prepared setup immediately before publication.
* @throws when publication must roll the unpublished Agent back.
*/
commit(): void
}
/**
* Compose an unpublished Agent scope and optionally return its publication commit.
* @param agentCtx - unpublished Agent scope.
* @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication.
*/
export type AgentSetup = (
agentCtx: Context,
) => AgentSetupCommit | Promise<AgentSetupCommit | void> | void
/**
* Options for programmatically creating an agent through the registry factory
* ({@link AgentRegistry.create}). The caller supplies the single live
@@ -80,17 +101,21 @@ export interface CreateAgentOptions {
* Creation-time composition of the agent's scoped world. The factory awaits
* setup after minting `agentCtx` but BEFORE inserting or announcing either
* the session or agent, so observers can never see a partially configured
* world. Everything registered through `agentCtx` (scoped tools, prompt
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
* before `session/created`, `agent/created`, `agent/session-start`, and the
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
* back without publishing either id.
* world. Setup may return an {@link AgentSetupCommit}; the factory invokes its
* synchronous `commit()` after every setup await settles and immediately
* before registry publication. This lets mutable provisioning revalidate at
* the exact publication boundary. Everything registered through `agentCtx`
* (scoped tools, prompt sections/variables, `restrict()`, listeners, awaited
* child plugins) exists before `session/created`, `agent/created`,
* `agent/session-start`, and the first prompt assembly. A setup
* throw/rejection, commit throw, or owner disposal rolls the scope back
* without publishing either id.
*
* **Setup composes, it never drives**: the callback is trusted same-process
* code and receives the full scoped context, so this is a contract rather
* than a runtime restriction. Drive the agent only after creation resolves.
*/
readonly setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: AgentSetup
}
/**
@@ -108,12 +133,12 @@ export interface ResumeAgentOptions {
* Resume-time composition of the agent's fresh scoped world. Persistence is
* loaded first; the factory then mints `agentCtx` and awaits setup while the
* reconstructed session and agent remain unpublished. The callback has the
* same trusted composition-only contract as
* {@link CreateAgentOptions.setup}: all registrations exist before either
* creation announcement, and rejection or owner disposal rolls the
* transaction back without publishing either id.
* same trusted composition-only contract and optional synchronous
* publication commit as {@link CreateAgentOptions.setup}: all registrations
* exist before either creation announcement, and rejection, commit failure,
* or owner disposal rolls the transaction back without publishing either id.
*/
readonly setup?: (agentCtx: Context) => Promise<void> | void
readonly setup?: AgentSetup
}
/**
@@ -144,9 +169,9 @@ export interface AgentHandle {
export interface AgentFactory {
/**
* Create a new agent on a caller-supplied session id. Async because creation
* awaits unpublished setup, inserts both session and agent, emits their
* creation notifications in order, emits `agent/session-start`, and only
* then starts the loop. The sequence is
* awaits unpublished setup, invokes its optional synchronous commit, inserts
* both session and agent, emits their creation notifications in order, emits
* `agent/session-start`, and only then starts the loop. The sequence is
* rollback-covered, but notifications delivered before a later listener
* failure remain observable; every agent or session creation announcement
* that began is paired by `agent/disposed` or `session/disposed` during
@@ -165,8 +190,8 @@ export interface AgentFactory {
* Load a persisted session and resume an agent on it. Async because it awaits
* both `ctx.sessionPersistence.load` and the optional unpublished setup
* transaction; must be called after that service exists (consumers inject
* `sessionPersistence`). Publication follows the same ordered boundary as
* {@link createAgent}.
* `sessionPersistence`). Publication follows the same setup-commit and
* ordered boundary as {@link createAgent}.
* @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
* @param options - persisted identity, configuration, and optional setup.
* @returns the owned handle after setup, both announcements, and loop start complete.

View File

@@ -12,6 +12,7 @@
*/
import type { Context } from 'cordis'
import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { SubagentError } from './error.ts'
@@ -47,17 +48,6 @@ interface TransactionState {
invalidated: boolean
}
/** Package-private setup transaction consumed by the continuation manager. */
export interface ActivationSetupTransaction {
/**
* Reject a batch invalidated by revocation before publication.
* @throws {SubagentError} code `ACTIVATION_SETUP_REVOKED` after revocation.
*/
assertIntact(): void
/** Promote this batch to resident installations. */
commit(): void
}
/** Re-read mutable removal state after a contribution may have revoked itself. */
function isRemoved(registration: Registration): boolean {
return registration.removed
@@ -95,9 +85,9 @@ export class SubagentActivationSetupRegistry {
/**
* Install every live contribution into one unpublished child context.
* @param childCtx - the child's unpublished scoped context.
* @returns the provisioning transaction.
* @returns the provisioning commit consumed at Agent publication.
*/
apply(childCtx: Context): ActivationSetupTransaction {
apply(childCtx: Context): AgentSetupCommit {
const state: TransactionState = { installations: [], invalidated: false }
try {
for (const registration of [...this.registrations]) {
@@ -138,15 +128,14 @@ export class SubagentActivationSetupRegistry {
throw error
}
return {
assertIntact: () => {
if (!state.invalidated) return
throw new SubagentError(
'a continuable-subagent setup contribution was revoked while this child was being built; '
+ 'the child was not established',
'ACTIVATION_SETUP_REVOKED',
)
},
commit: () => {
if (state.invalidated) {
throw new SubagentError(
'a continuable-subagent setup contribution was revoked while this child was being built; '
+ 'the child was not established',
'ACTIVATION_SETUP_REVOKED',
)
}
for (const installation of state.installations) installation.transaction = undefined
},
}

View File

@@ -20,6 +20,7 @@ import type {
Agent,
AgentHandle,
AgentOptions,
AgentSetupCommit,
CreateAgentOptions,
} from '@deepseek-ai/dsh-agent'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
@@ -799,19 +800,9 @@ export class SubagentContinuationManager {
// `AgentRegistry.enter()` is the authoritative collision boundary for an id
// some other owner holds — a duplicate would reject there with rollback.
inputs.signal.throwIfAborted()
const setup = (childCtx: Context): void => {
const setup = (childCtx: Context): AgentSetupCommit => {
applyChildComposition(childCtx, inputs.composition)
const setupTransaction = this.setupRegistry.apply(childCtx)
// Validate and freeze the batch inside the creation callback, before the
// factory can publish the session: a revoked contribution must reject
// the create/resume call pre-publication, so no persisted session is
// ever left behind for a child the manager rejects — rollback only
// disposes the live handle, and the persistence seam has no delete, so
// a post-publication rejection would leave a resumable ghost child.
// Committing here also means a later contribution removal releases the
// installation instead of invalidating a child already being established.
setupTransaction.assertIntact()
setupTransaction.commit()
return this.setupRegistry.apply(childCtx)
}
const observer = this.host.observeActivation(provider, childId, parent)
const { create } = inputs
@@ -867,9 +858,8 @@ export class SubagentContinuationManager {
for (const item of items) activation.accepted.delete(item.message.id)
this.wake(activation)
})
// Setup already validated and committed inside the creation callback;
// revocations from here on are immediate live revocation, never
// creation invalidation.
// Agent creation committed setup at its publication boundary;
// revocations from here on are immediate live revocation.
// Publish the start edge before any turn can run, so observers see this
// epoch before its first request.
observer.start(handle.agent)

View File

@@ -19,8 +19,7 @@ describe('SubagentActivationSetupRegistry', () => {
const transaction = registry.apply(child.ctx)
expect(order).toEqual(['first', 'second'])
expect(() => { transaction.assertIntact() }).not.toThrow()
transaction.commit()
expect(() => { transaction.commit() }).not.toThrow()
expect(order).toEqual(['first', 'second'])
})
@@ -68,7 +67,7 @@ describe('SubagentActivationSetupRegistry', () => {
remove()
expect(disposals).toBe(1)
expect(() => { transaction.assertIntact() }).toThrow(/revoked while this child was being built/)
expect(() => { transaction.commit() }).toThrow(/revoked while this child was being built/)
})
it('catches a contribution revoked inside its own installer', () => {
@@ -82,7 +81,7 @@ describe('SubagentActivationSetupRegistry', () => {
const transaction = registry.apply(childContext().ctx)
expect(disposals).toBe(1)
expect(() => { transaction.assertIntact() }).toThrow(/revoked/)
expect(() => { transaction.commit() }).toThrow(/revoked/)
})
it('attempts every contribution-removal disposer before reporting failures', () => {

View File

@@ -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 packages/subagent/tool-subagent-report/README.md
README.md: c1cff4d023e35ff246e592f58b0c85c8a10f327a
README.zh.md: 167a6338e8db9fbb5efce7037392f7c75e48f116
README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58
README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8

View File

@@ -58,7 +58,6 @@ Append-only; the report follows the parent's reusable request prefix. Waking del
## Known Limitations and Deferred Work
- **Setup revocation can follow lower-level Session publication** — the final revocation check runs after `ctx.agents.create()` or `ctx.agents.resume()` returns, by which point that call has already published its Agent and Session. Revocation in this window rolls back the handle and prevents the subagent Activation start edge, but may leave a persisted Session. Closing this gap requires a future Agent-creation setup transaction seam before lower-level publication.
- **A parent whose host-owned disposal already started can still accept** — `AgentHandle.dispose()` cancels, awaits quiescence, and only then unwinds the scope and leaves the registry; it exposes no signal for "disposal started." A report accepted in that window is appended to the parent's transcript, but that parent will not act on it in this process. A continuation-manager-owned parent rejects forest teardown through the manager's admission boundary.
- **Acceptance is weaker than durable delivery** — there is no durable mailbox, idempotency key, delivery receipt, retry protocol, or exactly-once claim. A process failure after one side recorded acceptance leaves the outcome ambiguous, and an external retry may duplicate the report.
- **A staged quiet report is not immediately reconstructable** — acceptance returns its stable `MessageId`, but the parent Session reconstructs the framed content only after pending context reaches its ordinary log boundary.

View File

@@ -58,7 +58,6 @@
## 已知限制与暂缓事项
- **setup 撤销可能发生在底层 Session 发布之后**:最终撤销检查发生在 `ctx.agents.create()``ctx.agents.resume()` 返回之后,此时该调用已发布其 Agent 和 Session。在这个窗口内撤销会回滚 handle并阻止 subagent Activation 的 start 边,但可能留下持久化 Session。要弥合这个缺口需要未来在底层发布之前提供 Agent 创建 setup 事务 seam。
- **父级可能在宿主启动 dispose 后继续接受报告**`AgentHandle.dispose()` 会先取消并等待完全停稳然后才撤销作用域并离开注册表它不公开「dispose 已开始」信号。在该窗口内接受的报告会追加到父级 transcript但该父级不会在本进程中处理它。对于由延续管理器拥有的父级管理器的准入边界会在整棵子树拆卸期间拒绝该上报。
- **接受弱于持久投递**:没有持久化 mailbox、幂等键、投递回执、重试协议也不保证恰好一次。任一侧记录接受后若进程失败结果都不明确外部重试可能产生重复上报。
- **暂存的静默报告无法立即重建**:接受时会返回其稳定 `MessageId`,但只有当待处理上下文到达普通日志边界后,父级 Session 才能重建带前缀的内容。

View File

@@ -350,6 +350,34 @@ describe('dsh-tool-subagent-report', () => {
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
})
it('rolls back materialization when setup revocation lands before publication', async () => {
const { ctx, parent } = await setup({ load: false })
const self: { revoke?: () => void } = {}
let installed = false
self.revoke = ctx.subagents.registerContinuableSetup(() => {
installed = true
queueMicrotask(() => { self.revoke?.() })
return () => { installed = false }
})
const announced: SessionId[] = []
const removeListener = ctx.on('session/created', (session) => { announced.push(session.id) })
await expect(ctx.subagents.startContinuable({
provider: 'spawn',
label: 'revoked child',
request: {
prompt: [{ type: 'text', text: 'revoked child' }],
parent,
},
signal: testSignal,
})).rejects.toMatchObject({ code: 'ACTIVATION_SETUP_REVOKED' })
removeListener()
expect(installed).toBe(false)
expect(announced).toEqual([])
expect(ctx.agents.list().map(agent => agent.id)).toEqual([parent.id])
expect(ctx.sessions.list()).toEqual([parent.session])
})
it('accepts a report into a host-disposing but still-registered parent', async () => {
const { ctx } = await setup()
const parentHandle = await ctx.agents.create({