From b54381f3e744453d975d131654e28b9fdade8f4c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 2 Aug 2026 20:09:05 +0800 Subject: [PATCH] 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. --- .../2026-07-08-agent-scope-contexts.i18n.yaml | 6 +- .../2026-07-08-agent-scope-contexts.md | 10 ++-- .../2026-07-08-agent-scope-contexts.zh.md | 10 ++-- ...continuable-subagent-report-tool.i18n.yaml | 4 +- ...-07-30-continuable-subagent-report-tool.md | 8 ++- ...-30-continuable-subagent-report-tool.zh.md | 8 ++- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 +++- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 6 +- packages/core/agent-loop/README.zh.md | 6 +- packages/core/agent-loop/src/index.ts | 8 ++- packages/core/agent-loop/tests/resume.spec.ts | 35 ++++++++++++ .../agent-loop/tests/scope-lifecycle.spec.ts | 8 +++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 6 +- packages/core/agent/README.zh.md | 6 +- packages/core/agent/src/index.ts | 57 +++++++++++++------ .../subagent/src/activation-setup-registry.ts | 31 ++++------ .../subagent/subagent/src/continuation.ts | 20 ++----- .../tests/activation-setup-registry.spec.ts | 7 +-- .../tool-subagent-report/README.i18n.yaml | 4 +- .../subagent/tool-subagent-report/README.md | 1 - .../tool-subagent-report/README.zh.md | 1 - .../tests/tool-subagent-report.spec.ts | 28 +++++++++ 28 files changed, 198 insertions(+), 106 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 67955d377d..cdee6ab259 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md index e4c076189a..5e09bdbcae 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 35e725e43d..4714045f28 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -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` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看 setup 是原子的,拆除则保留本地行为直到工作停止。 +贡献者使用一种熟悉的模式:通过插件上下文注册共享行为,通过 `agent.ctx` 注册本地行为,在操作中选择真实 agent,dispose 返回的 handle。从观察者角度看,setup 及其可选的发布提交是原子的,拆除则保留本地行为直到工作停止。 代价是显式的主体选择、异步的编程式创建,以及服务需要逐个采纳作用域。扁平注册作用域有意不等同于权限,subagent 组合控制作为独立功能存在,而非隐藏的作用域语义。 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml index 53d40cf299..54966549ba 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.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 .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 diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md index 24922cfe88..8324f1fa08 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md index bb0b1847f1..e7599c1d85 100644 --- a/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-continuable-subagent-report-tool.zh.md @@ -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 @@ ACP(Agent 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 @@ ACP(Agent 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。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 576ef05d32..a0d59b2993 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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 docs/architecture.md -architecture.md: 6aa942ba2702d8d30ae94d9968f07abb5e1fe88d -architecture.zh.md: c8aaa68527f34f4879f882a08260a4e0bd4f4c5f +architecture.md: add76b6016ae5243115e88173971468a2c93287f +architecture.zh.md: 595f14566cac4301a0b52ded1140511cab948d90 diff --git a/docs/architecture.md b/docs/architecture.md index 6aa942ba27..add76b6016 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -76,7 +76,7 @@ Creation without an id mints `-session-`; `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 diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index c8aaa68527..595f14566c 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -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))。 ## 状态 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 96b876a0eb..4744710ee3 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -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` diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a2a1bf3c5d..52610d78ad 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -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 | 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;\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;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'SandboxEnforcement', diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 6e9e63a22f..00643acbbc 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.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 packages/core/agent-loop/README.md -README.md: 1662b1076cc116888d048cb6af1be1c7ab8196f6 -README.zh.md: 2fca32a02fdd73961c912c988933e1cd1a1a5817 +README.md: f146ea3d2cc379303514286c5cfd2833397f23ed +README.zh.md: 767fbd420709be05e9a8f85dc8ec6fe03ae6aa66 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 1662b1076c..f146ea3d2c 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -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` — 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` — 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` — 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` — 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. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 2fca32a02f..767fbd4207 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -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`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup 事务,然后才返回;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 -- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `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`:使用调用方提供的共享 id 以编程方式创建。它会等待尚未发布的 setup,在发布边界调用其可选的同步提交,然后进入两个注册表;`meta` 携带 cwd/谱系/seed 边界元数据,`seed` 则在会话边界验证并快照持久值后,重建 fork 子级的前缀。`signal` 只在此 Promise 结算前生效。解析得到的 [`AgentHandle`](../agent/README.md) 拥有确切的 teardown。 +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise`:通过 `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)。对于以编程方式创建的 agent,handle 持有者是唯一面向消费方的 teardown 能力;AgentLoop 提供方卸载是一条独立的结构化 teardown 边,而不是向应用代码公开的另一个 handle。 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 1342fbd885..54b8f5c957 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -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() diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 7844aebd73..0bb7ce7d2e 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -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) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 5f4e51784a..2420d20e8f 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -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', diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 78473df4a7..1461dcf6da 100644 --- a/packages/core/agent/README.i18n.yaml +++ b/packages/core/agent/README.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 packages/core/agent/README.md -README.md: 8a6028352127c4638c0b5e0e3ee85964d1d7d734 -README.zh.md: ffa71ea987ab355ff2f30b6164376199cd5d0170 +README.md: 5f9fdc44794a562c2b0da39e1a43a01aa19c451b +README.zh.md: f088b8c354f0aec1b51d7ff8ae706ddc49265a20 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 8a60283521..5f9fdc4479 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -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` — 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` — 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` — 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` — 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 }`. 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. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index ffa71ea987..f088b8c354 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -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`:创建会话和 agent,在不发布的情况下等待可选 setup,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 -- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 +- `ctx.agents.create(options: CreateAgentOptions): Promise`:创建会话和 agent,在不发布的情况下等待可选 setup,调用其可选的同步提交,然后通过最终的 `SessionStore.enter()` 与 `AgentRegistry.enter()` 检查发布。不支持并发创建同一 ID:多个操作可以进行准备,但只有一个能进入;每个失败方都会回滚其私有作用域/会话/驱动器。可选且只用于创建的 `signal` 会取消未发布的 setup,并在返回 handle 前分离;之后的取消使用 `handle.dispose()` 或 `agent.cancel()`。发布包含在回滚范围内,回滚期间每条已交付创建边都会成对处理。未注册工厂时拒绝。 +- `ctx.agents.resume(options: ResumeAgentOptions): Promise`:加载持久化会话([会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),创建新的未发布 agent 作用域,等待可选 setup,调用其可选的同步提交,并使用相同的最终进入发布序列。其可选 `signal` 同样只用于创建。未注册工厂或未配置会话持久化时拒绝。 `AgentHandle = { agent: Agent; dispose(): Promise }`。Disposer 是一项 **消费方能力**;仅持有裸注册表条目的观察方不能 teardown agent。调用方 fiber 和已注册工厂提供方是结构化共同拥有者:调用方卸载会强制结构化所有权,而工厂卸载必须停止旧实例,因为它们的作用域依赖范围属于该提供方。任意拥有者调用 `dispose()` 都会到达同一个记忆化完全停稳边界:它停止循环,等待循环退出,注销 agent,从存储中移除其会话,最后撤销其作用域世界。`ctx.agents.get(id)` 仍返回裸 `Agent`;ACP 桥接层与进程内 subagent 后端持有消费方 handle,而配置创建的 agent 已由循环 fiber 拥有。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index d5c19aae3e..66dee4efb7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -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 | 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 + 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 + 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. diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index dca194f113..3e8ef4fe61 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -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 }, } diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 82f66d08cb..f690cab6cf 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -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) diff --git a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts index 059befe236..6353f486c5 100644 --- a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts +++ b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts @@ -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', () => { diff --git a/packages/subagent/tool-subagent-report/README.i18n.yaml b/packages/subagent/tool-subagent-report/README.i18n.yaml index 389a16e620..869dee55bf 100644 --- a/packages/subagent/tool-subagent-report/README.i18n.yaml +++ b/packages/subagent/tool-subagent-report/README.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 packages/subagent/tool-subagent-report/README.md -README.md: c1cff4d023e35ff246e592f58b0c85c8a10f327a -README.zh.md: 167a6338e8db9fbb5efce7037392f7c75e48f116 +README.md: cd73154dfb9c8b37f4a811c3beedbe6a63207f58 +README.zh.md: 4b31bed48ea0e50ec3a9d507548658defb94b8b8 diff --git a/packages/subagent/tool-subagent-report/README.md b/packages/subagent/tool-subagent-report/README.md index c1cff4d023..cd73154dfb 100644 --- a/packages/subagent/tool-subagent-report/README.md +++ b/packages/subagent/tool-subagent-report/README.md @@ -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. diff --git a/packages/subagent/tool-subagent-report/README.zh.md b/packages/subagent/tool-subagent-report/README.zh.md index 167a6338e8..4b31bed48e 100644 --- a/packages/subagent/tool-subagent-report/README.zh.md +++ b/packages/subagent/tool-subagent-report/README.zh.md @@ -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 才能重建带前缀的内容。 diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 4d44204fd6..84f33646bb 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -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({