From ebce3a5f04045e85bd76e5133fa9ecd0befb2862 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 17:44:11 +0800 Subject: [PATCH 01/15] refactor(agent): make runtime identity explicit --- .../2026-07-08-agent-scope-contexts.i18n.yaml | 4 +- .../2026-07-08-agent-scope-contexts.md | 4 +- .../2026-07-08-agent-scope-contexts.zh.md | 4 +- ...07-12-agent-scope-runtime-design.i18n.yaml | 4 +- .../2026-07-12-agent-scope-runtime-design.md | 8 +- ...026-07-12-agent-scope-runtime-design.zh.md | 8 +- ...2026-07-15-agent-initiator-scope.i18n.yaml | 4 +- .../2026-07-15-agent-initiator-scope.md | 8 +- .../2026-07-15-agent-initiator-scope.zh.md | 8 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 24 +++-- docs/subsystems/core.zh.md | 24 +++-- packages/acp/acp/src/session.ts | 5 +- packages/api/gateway/src/index.ts | 11 +- packages/api/gateway/src/types.ts | 4 +- .../gateway/tests/gateway-stream.host.spec.ts | 100 +++--------------- .../api/gateway/tests/gateway.host.spec.ts | 1 - packages/api/remotes/package.json | 2 + packages/api/remotes/src/index.ts | 13 +-- .../remotes/tests/remote-events.host.spec.ts | 14 ++- packages/api/session-controller/src/agent.ts | 12 +-- .../tests/agent.host.spec.ts | 5 +- .../tests/session-fork.host.spec.ts | 4 +- .../tests/session-presets.host.spec.ts | 5 +- .../bundle/headless/tests/headless.spec.ts | 4 +- .../tests/compaction-loop-repro.spec.ts | 4 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/README.zh.md | 4 +- packages/core/agent-loop/src/agent.ts | 2 +- packages/core/agent-loop/src/index.ts | 33 ++++-- .../agent-loop/tests/agent-initiator.spec.ts | 6 +- packages/core/agent-loop/tests/resume.spec.ts | 6 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 15 ++- packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 4 +- packages/core/agent/README.zh.md | 4 +- packages/core/agent/src/index.ts | 46 ++++---- packages/core/agent/tests/agent.spec.ts | 31 ++++-- .../extensions/tool-cordis/src/api-catalog.ts | 30 +++--- .../schedule/schedule/tests/plugin.spec.ts | 2 +- .../server/tests/built-scope-carrier.e2e.ts | 2 +- packages/sdk/server/tests/server.spec.ts | 16 +-- .../subagent-in-process-driver/src/index.ts | 6 +- .../subagent/subagent/src/continuation.ts | 8 +- packages/subagent/tool-subagent/src/index.ts | 14 +-- packages/typert/protocol/README.i18n.yaml | 4 +- packages/typert/protocol/README.md | 2 +- packages/typert/protocol/README.zh.md | 2 +- packages/typert/protocol/src/index.ts | 2 - packages/typert/protocol/src/types.ts | 44 ++------ packages/typert/registry/README.i18n.yaml | 4 +- packages/typert/registry/README.md | 2 +- packages/typert/registry/README.zh.md | 2 +- packages/typert/registry/src/service.ts | 17 --- packages/typert/registry/tests/typert.spec.ts | 31 ------ packages/webhook/webhook/src/session.ts | 5 +- .../webhook/webhook/tests/runtime.spec.ts | 19 ++-- .../webhook/webhook/tests/session.spec.ts | 10 +- pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 1 - 61 files changed, 281 insertions(+), 396 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 20d0e4f85c..b2ddad3a3b 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 @@ -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/architecture/2026-07-08-agent-scope-contexts.md -2026-07-08-agent-scope-contexts.md: eb3f6f247bac1a1d81aa2644132c7b9cc04d602c -2026-07-08-agent-scope-contexts.zh.md: a0f4ffb0ef80dd2fc1ee61c9ab3f4730c28c687e +2026-07-08-agent-scope-contexts.md: a1fa4527db4dea17e3b97f47cdc5613967b6c23a +2026-07-08-agent-scope-contexts.zh.md: 80062d2a73548a0057cc7dbdd4afcb3e1102daa9 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 eb3f6f247b..a1fa4527db 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 @@ -16,6 +16,8 @@ The mechanism also needs a publication boundary. An agent must not become visibl Every live agent owns one flat registration layer exposed as `agent.ctx`. Code registers through the context that owns a contribution; scope-aware services combine deployment-global registrations with exactly one matching agent layer; operations choose that layer from their real agent; and the layer exists for the agent's complete published lifetime. +`agent.ctx` carries registration ownership and the scope key; it does not expose a reverse `agent` property. Code that needs the domain subject receives it explicitly: `AgentSetup` receives `(agentCtx, agent)`, and scoped events carry their subject in the payload. + Cordis is the plugin framework underneath the SDK. A Cordis **context** is the object plugins use to access services and register effects whose cleanup follows that context. The [Cordis primer](../../../../docs/cordis-primer.md) explains the framework in more detail. For most contributors, the complete contract is four rules: @@ -88,7 +90,7 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -Setup receives a full trusted Cordis context so it can compose ordinary plugins and services. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported. +Setup receives the full trusted Cordis context and unpublished Agent so it can compose ordinary plugins and services while reading the exact child Session when needed. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported. ### The operation chooses the view 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 a0f4ffb0ef..80062d2a73 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 @@ -16,6 +16,8 @@ Status: implemented 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 +`agent.ctx` 携带注册所有权和作用域键,不暴露反向的 `agent` 属性。需要领域主体的代码会显式接收它:`AgentSetup` 接收 `(agentCtx, agent)`,作用域事件则在 payload 中携带主体。 + Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。 对大多数贡献者而言,完整约定是四条规则: @@ -88,7 +90,7 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插件和服务。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 +setup 接收完整的受信 Cordis 上下文和未发布的 Agent,因此既可以组合普通插件和服务,也能在需要时读取确切的子 Session。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 ### 操作选择视图 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 241dcf71db..42e4f0cd35 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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/architecture/2026-07-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: 7aebac35d1a5477f0b1d3857e983680ea38bd9f5 -2026-07-12-agent-scope-runtime-design.zh.md: c636bf6f51c58aa5d9f1e3b6f9988b4f4ab5b86c +2026-07-12-agent-scope-runtime-design.md: 06f16e59357e6cca8382952bcc81dbe95fdd5583 +2026-07-12-agent-scope-runtime-design.zh.md: 12a3f916c5d5f2af0c929b7575064ce92ecd261e diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 7aebac35d1..06f16e5935 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -42,6 +42,8 @@ All agents share one Cordis service graph. A derived context does not clone `Too `agent.ctx` is such a derived context. Service calls still reach the shared instances, while a registration can inspect its calling context and store a contribution under the nearest scope key. Ordinary plugin contexts carry no scope key and therefore register globally. +The Agent context is exactly the context returned by `createScope`; it carries no second reverse association to the Agent. Subject-bearing APIs pass the Agent explicitly, leaving one formal scope mechanism for registration ownership and routing. + ### Fibers and effects make cleanup structural A Cordis fiber is the live instance created when a plugin or child context is activated. Its state records whether that lifecycle is active, unloading, failed, or disposed. `ctx.effect()` and `ctx.on()` return disposers and also attach those disposers to the registering fiber, so unloading a plugin or agent scope removes everything registered through that context without a separate inventory. @@ -68,7 +70,7 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live `createScope(parent, key)` returns a scope whose `ctx` shares the parent's services and whose effects are tagged with that key. `scopeOf(ctx)` reads the nearest registration key. `scopeTarget(base, key)` creates the event receiver whose filter preserves the base receiver's Cordis service filter, then admits unscoped listeners and listeners with that exact key. -The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`. +The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives an explicit setup parameter or event argument; code that needs registration ownership receives `agent.ctx`. ### Registry reads overlay one exact layer @@ -100,11 +102,11 @@ The transaction is installed under both the calling Cordis context and the concr Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm. -The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. This preserves dependency origin and caller ownership without stacking trace proxies. +The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator passes the parent Agent explicitly, and AgentRegistry forwards it separately from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. ### Setup is trusted composition inside a private world -Setup receives the full child context and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, but the public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. +Setup receives the full child context and the exact unpublished Agent, and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, and consumers that need the child's Session read it from the Agent parameter. The public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. The transaction races asynchronous load and setup against deactivation rather than waiting forever for a promise owned by external code. If cancellation or owner unload wins, public creation rejects after transaction-owned cleanup even when the external promise never settles. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index c636bf6f51..12a3f916c5 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -42,6 +42,8 @@ Status: implemented `agent.ctx` 就是这样一个派生上下文。服务调用仍然到达共享实例,而注册操作可以检查其调用上下文并将贡献存储在最近的作用域键下。普通的插件上下文不携带作用域键,因此注册到全局。 +Agent 上下文就是 `createScope` 返回的上下文,不携带第二份指回 Agent 的关联。需要主体的 API 显式传递 Agent,因此注册所有权与路由只依赖一种正式的作用域机制。 + ### Fiber 与 effect 使清理成为结构性的 Cordis fiber 是插件或子上下文被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该上下文注册的一切,无需单独的清单。 @@ -70,7 +72,7 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个 `createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建事件接收器,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。 -Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。 +Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的 setup 参数或事件参数;需要注册所有权的代码接收 `agent.ctx`。 ### 注册表读取叠加一个精确 layer @@ -102,11 +104,11 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册 创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。 -工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。这保留了依赖来源和调用方所有权,而不堆叠 trace 代理。 +工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方显式传入父 Agent,AgentRegistry 再将其与调用方 Context 分开转交。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。 ### Setup 是私有世界内的可信组合 -Setup 接收完整的子上下文,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect,但公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 +Setup 接收完整的子上下文和确切的未发布 Agent,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect;需要子 Session 的消费者从 Agent 参数读取它。公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 事务将异步加载和 setup 与停用进行竞争,而非无限等待外部代码拥有的 promise。如果取消或所有者卸载获胜,即使外部 promise 永不结算,公开创建也会在事务拥有的清理之后拒绝。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 570b68263c..978a18c39e 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md -2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277 -2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac +2026-07-15-agent-initiator-scope.md: 48962fb64e4cc0034d8e181d385e0fe86cc84675 +2026-07-15-agent-initiator-scope.zh.md: a41545576187ee1b88923b8925e3e44be6c811a3 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md index 63540c0ec6..48962fb64e 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -6,7 +6,7 @@ English | [中文](2026-07-15-agent-initiator-scope.zh.md) ## Problem -The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. +The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. A dynamic `ctx.agent` meaning “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. Deep process-local infrastructure sometimes needs a trusted initiating Agent below explicit loop, tool, and request parameters—for example, a host-aware transport, tracing helper, logger, or gateway client. Requiring every private helper to forward `agent` adds repetition, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are unsuitable because a model must not choose a trusted Session or routing header. The carrier belongs to the Agent service rather than optional model-visible context. @@ -18,9 +18,9 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in `AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from `ctx.agents`, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or `Session` through shallow interfaces. A leaf helper keeps a narrow `Session` parameter when that is its actual interface rather than accepting a broader `Context` only for an ambient lookup. -Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. +Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx, childAgent)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while the explicit `childAgent` parameter identifies the child. -Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, job ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. +Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, the Agent parameter of `AgentSetup`, `GenerateOptions.sessionId`, job ownership, parent/child requests, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. `AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering. @@ -40,7 +40,7 @@ A test-double host-aware transport derives `X-Harness-Session-Id` internally and **Pass Agent through every function.** Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds repetitive forwarding without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries. -**Make `ctx.agent` dynamic.** `ctx.agent` already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising. +**Expose a dynamic `ctx.agent`.** Context carries registration ownership, not a domain subject. Adding an accessor for the executing Agent would mix registration and execution scopes and make concurrent behavior surprising. **Add a separate `ctx.agentExecution` service.** The carrier has no independent backend, configuration, or identity type: it stores the same `Agent` that `ctx.agents` already owns, and AgentLoop already depends on that service. A second mandatory provider would add package, composition, lifecycle, generated-catalog, and test-harness wiring without separating a real capability. diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index 3ea893aa5f..a415455761 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 +harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若提供表示「当前正在运行的 Agent」的动态 `ctx.agent`,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。 @@ -18,9 +18,9 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 `AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 `ctx.agents` 恢复同一个 Agent,一次推导 `agent.session`,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 `Session`。若 `Session` 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 `Session` 参数,而不会只为隐式查找而接收更宽泛的 `Context`。 -因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 +因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx, childAgent)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而显式的 `childAgent` 参数标识子 Agent。 -隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 +隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`AgentSetup` 的 Agent 参数、`GenerateOptions.sessionId`、任务归属、父子请求、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 `AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose(资源释放)后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。 @@ -40,7 +40,7 @@ Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise **在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 -**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。 +**暴露动态的 `ctx.agent`。** Context 携带注册所有权,而非领域主体。为正在执行的 Agent 新增 accessor 会混合注册作用域与执行作用域,并让并发行为变得意外。 **新增独立的 `ctx.agentExecution` 服务。** 该载体没有独立后端、配置或身份类型:它存储的是 `ctx.agents` 已经管理的同一个 `Agent`,而 AgentLoop 本就依赖该服务。第二个必需提供方会增加包、组合、生命周期、生成目录及测试 harness 接线,却没有拆出真实能力。 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 06bfe0a937..574b4e9e88 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/subsystems/core.md -core.md: dbe12dfc9bfdafe0f59d7e52eefc5695b1c8d063 -core.zh.md: 50fd2e4e76ee72653c02c2ffeca06042191aa9c4 +core.md: 9f05fc3520439837f8f7b1e15fe7aa19a71ca19c +core.zh.md: b0ac0200cefc5a1e6f0f61c0ad117bafd7c1323f diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index dbe12dfc9b..9f05fc3520 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -46,9 +46,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, seed boundary, origin classification, delegation depth), an optional `seed` replay prefix for forks, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. +`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, seed boundary, origin classification, delegation depth), an optional `seed` replay prefix for forks, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` while both ids are still unpublished: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. -`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator passes its parent Agent explicitly; the registry passes that value separately from the caller Context to the factory. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -368,17 +368,19 @@ create(id: SessionId, options: AgentOptions = {}, meta: Pick +async createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise /** * Resume an owned agent from the configured persistence service. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. * @param options - persisted identity, loop options, setup, and cancellation. + * @param owner - live Agent that owns the resumed Agent at runtime, or undefined for a root owner. * @returns the published handle. */ -async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise +async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise ``` Types: [SessionHeader](persistence.md) @@ -621,7 +623,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution * that also supports agentless calls. When a parent creates a child, setup - * reports the causal parent while `agentCtx.agent` identifies the child. + * reports the causal parent while the setup callback's Agent parameter + * identifies the child. * @returns the inherited Agent, or `undefined` outside an initiator boundary * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. @@ -687,18 +690,20 @@ setFactory(factory: AgentFactory): () => void * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. * @param options - shared identity, session seed/metadata, and agent options. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async create(options: CreateAgentOptions): Promise +async create(options: CreateAgentOptions, owner?: Agent): Promise /** * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. * @param options - persisted identity, configuration, and optional setup. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async resume(options: ResumeAgentOptions): Promise +async resume(options: ResumeAgentOptions, owner?: Agent): Promise /** * Register a live agent. Throws if an agent with the same id is already @@ -709,6 +714,7 @@ async resume(options: ResumeAgentOptions): Promise * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always * requires passing the carrier). Returns the disposer. * @param agent - the already-constructed agent to record in the store. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact * identity is load-bearing: a composite (generator) effect that owns a @@ -718,7 +724,7 @@ async resume(options: ResumeAgentOptions): Promise * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ -register(agent: Agent): () => void +register(agent: Agent, owner?: Agent): () => void /** * Insert an already-constructed agent without announcing it. This is the @@ -727,7 +733,7 @@ register(agent: Agent): () => void * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - live agent whose scoped context created this agent, or + * @param owner - explicitly supplied live runtime owner, or * undefined for a top-level runtime root. This is runtime ownership, not * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 50fd2e4e76..b0ac0200ce 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -48,9 +48,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、seed 边界、来源分类、委派深度)、fork 用的可选 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 +`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、seed 边界、来源分类、委派深度)、fork 用的可选 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 则提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 -`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方显式传入父 Agent;注册表将该值与调用方 Context 分开传给工厂。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 @@ -378,17 +378,19 @@ create(id: SessionId, options: AgentOptions = {}, meta: Pick +async createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise /** * Resume an owned agent from the configured persistence service. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. * @param options - persisted identity, loop options, setup, and cancellation. + * @param owner - live Agent that owns the resumed Agent at runtime, or undefined for a root owner. * @returns the published handle. */ -async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise +async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise ``` Types: [SessionHeader](persistence.zh.md) @@ -631,7 +633,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution * that also supports agentless calls. When a parent creates a child, setup - * reports the causal parent while `agentCtx.agent` identifies the child. + * reports the causal parent while the setup callback's Agent parameter + * identifies the child. * @returns the inherited Agent, or `undefined` outside an initiator boundary * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. @@ -697,18 +700,20 @@ setFactory(factory: AgentFactory): () => void * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. * @param options - shared identity, session seed/metadata, and agent options. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async create(options: CreateAgentOptions): Promise +async create(options: CreateAgentOptions, owner?: Agent): Promise /** * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. * @param options - persisted identity, configuration, and optional setup. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async resume(options: ResumeAgentOptions): Promise +async resume(options: ResumeAgentOptions, owner?: Agent): Promise /** * Register a live agent. Throws if an agent with the same id is already @@ -719,6 +724,7 @@ async resume(options: ResumeAgentOptions): Promise * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always * requires passing the carrier). Returns the disposer. * @param agent - the already-constructed agent to record in the store. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact * identity is load-bearing: a composite (generator) effect that owns a @@ -728,7 +734,7 @@ async resume(options: ResumeAgentOptions): Promise * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ -register(agent: Agent): () => void +register(agent: Agent, owner?: Agent): () => void /** * Insert an already-constructed agent without announcing it. This is the @@ -737,7 +743,7 @@ register(agent: Agent): () => void * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - live agent whose scoped context created this agent, or + * @param owner - explicitly supplied live runtime owner, or * undefined for a top-level runtime root. This is runtime ownership, not * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts index a4e93f1e1a..3fff075359 100644 --- a/packages/acp/acp/src/session.ts +++ b/packages/acp/acp/src/session.ts @@ -150,10 +150,7 @@ export class AcpSession { resumeSessionId: options.sessionId, agentOptions: options.agentOptions, signal: options.signal, - setup: async (agentCtx) => { - const agent = agentCtx.agent - /* v8 ignore next -- Agent factory setup always carries its unpublished Agent. */ - if (agent === undefined) throw new Error('acp: resumed Agent is absent during setup') + setup: async (agentCtx, agent) => { modelControl = new AcpModelControl( ctx.llm, selectionFor(agent.session.requestHeader(), options.fallbackSelection), diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 1d754b05a7..c472def241 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -461,12 +461,7 @@ export class TypertGatewayService extends Service implements TypertGateway { private startRemoteEvent(source: TypertRemoteEventInvocation): void { try { assertRemoteEventName(source) - const context = this.ctx.typert.contexts.identifyHost(source.context.value) - if (context === undefined) { - source.resolve({ kind: 'next' }) - return - } - if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) { + if (!isRemoteEventAgentId(source.context.agentId)) { throw new TypeError( 'typert gateway: scoped Remote events require a non-empty Agent identity', ) @@ -480,7 +475,7 @@ export class TypertGatewayService extends Service implements TypertGateway { () => () => { this.cancelRemoteEvent( pending, - new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`), + new Error('typert gateway: Remote event Agent Context was released'), ) }, `api-gateway: Remote event ${JSON.stringify(source.event)}`, @@ -504,7 +499,7 @@ export class TypertGatewayService extends Service implements TypertGateway { type: 'waterfall', event: source.event, eventId: id, - agentId: context.identity, + agentId: source.context.agentId, request: projected.request, }, deliveries: new Set(), diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 9b4475cb9a..854f578ac2 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -28,10 +28,12 @@ export interface TypertRemoteEventFrame { /** Live Host values used to project one scoped Remote Event. */ export interface TypertRemoteEventContext { - /** Live Host Context identified by the registered Host adapters. */ + /** Live Agent Context that owns cancellation of the forwarded waterfall. */ readonly value: Context /** Agent object carried directly by the waterfall request. */ readonly subject: object + /** Agent identity read directly from the scoped event subject. */ + readonly agentId: string } /** Result returned from a Client waterfall, or delegation back to the Host chain. */ diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index c769192a4b..b22fec1c89 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -185,6 +185,7 @@ function pendingInvocation( context: Context, signal?: AbortSignal, prompt = 'ship', + identity: unknown = agentId('agent-1'), ): PendingInvocationProbe { const subject = { ctx: context } const settled = Promise.withResolvers() @@ -198,7 +199,7 @@ function pendingInvocation( dispatch: { event: 'fixture/approval', request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) }, - context: { value: context, subject }, + context: { value: context, subject, agentId: identity as string }, resolve, reject, }, @@ -463,13 +464,7 @@ describe('Typert Remote streams', () => { it('cancels a pending waterfall when its source rejects during removal', async () => { const { ctx } = await setup(true) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-removal') : undefined, - resolve: id => id === 'agent-removal' ? agent : undefined, - }) - const pending = pendingInvocation(agent) + const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-removal')) const rejected = expect(pending.outcome).rejects.toThrow( 'forwarded Remote event source was removed', ) @@ -494,7 +489,7 @@ describe('Typert Remote streams', () => { client.socket.close() }) - it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { + it('rejects malformed scoped invocations and delegates a released Context', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) @@ -511,28 +506,15 @@ describe('Typert Remote streams', () => { await rejected } - const unavailable = pendingInvocation(ctx) - source.push(unavailable.dispatch) - await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' }) - expect(unavailable.reject).not.toHaveBeenCalled() - let selected = ctx.extend() - let identity: unknown = 1n - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === selected ? identity as AgentWireId : undefined, - resolve: () => selected, - }) - const nonJsonIdentity = pendingInvocation(selected) + const nonJsonIdentity = pendingInvocation(selected, undefined, 'ship', 1n) const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow( 'require a non-empty Agent identity', ) source.push(nonJsonIdentity.dispatch) await nonJsonRejected - identity = 'agent-invalid-request' - const invalidRequest = pendingInvocation(selected) + const invalidRequest = pendingInvocation(selected, undefined, 'ship', agentId('agent-invalid-request')) const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow( 'must carry its scoped Agent directly', ) @@ -545,18 +527,16 @@ describe('Typert Remote streams', () => { const staleFiber = ctx.plugin(() => {}) await staleFiber selected = staleFiber.ctx - identity = 'agent-stale' await staleFiber.dispose() - const stale = pendingInvocation(selected) + const stale = pendingInvocation(selected, undefined, 'ship', agentId('agent-stale')) source.push(stale.dispatch) await expect(stale.outcome).resolves.toEqual({ kind: 'next' }) expect(stale.reject).not.toHaveBeenCalled() selected = ctx.extend() - identity = 'agent-cancelled' const abort = new AbortController() abort.abort('fixture non-error cancellation') - const cancelled = pendingInvocation(selected, abort.signal) + const cancelled = pendingInvocation(selected, abort.signal, 'ship', agentId('agent-cancelled')) const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({ message: 'typert gateway: Remote event was cancelled', cause: 'fixture non-error cancellation', @@ -594,19 +574,13 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-collision') : undefined, - resolve: id => id === 'agent-collision' ? agent : undefined, - }) const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) const firstAbort = new AbortController() const secondAbort = new AbortController() - const first = pendingInvocation(agent, firstAbort.signal, 'first') - const second = pendingInvocation(agent, secondAbort.signal, 'second') + const first = pendingInvocation(agent, firstAbort.signal, 'first', agentId('agent-collision')) + const second = pendingInvocation(agent, secondAbort.signal, 'second', agentId('agent-collision')) source.push(first.dispatch) await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) @@ -648,12 +622,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const first = await openEventClient(ctx, 'events-a') const second = await openEventClient(ctx, 'events-b') const pending = pendingInvocation(agent) @@ -702,14 +670,8 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined, - resolve: id => id === 'agent-rejected' ? agent : undefined, - }) const client = await openEventClient(ctx, 'events-rejected') - const pending = pendingInvocation(agent) + const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-rejected')) source.push(pending.dispatch) await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) const frame = deliveredInvocation(client)! @@ -742,12 +704,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const first = await openEventClient(ctx, 'events-next-a') const second = await openEventClient(ctx, 'events-next-b') const pending = pendingInvocation(agent) @@ -775,13 +731,7 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-late-client') : undefined, - resolve: id => id === 'agent-late-client' ? agent : undefined, - }) - const pending = pendingInvocation(agent, undefined, 'before-connect') + const pending = pendingInvocation(agent, undefined, 'before-connect', agentId('agent-late-client')) source.push(pending.dispatch) await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) @@ -808,12 +758,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const original = await openEventClient(ctx, 'events-original') const pending = pendingInvocation(agent) source.push(pending.dispatch) @@ -845,24 +789,10 @@ describe('Typert Remote streams', () => { const contextFiber = ctx.plugin(() => {}) await contextFiber const contextAgent = contextFiber.ctx - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: (candidate) => { - if (candidate === signalAgent) return agentId('agent-signal') - if (candidate === contextAgent) return agentId('agent-context') - return undefined - }, - resolve: (id) => { - if (id === 'agent-signal') return signalAgent - if (id === 'agent-context') return contextAgent - return undefined - }, - }) const client = await openEventClient(ctx, 'events-cancel') const abort = new AbortController() - const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal') + const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal', agentId('agent-signal')) source.push(signalPending.dispatch) await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) const signalFrame = deliveredInvocation(client)! @@ -883,7 +813,7 @@ describe('Typert Remote streams', () => { }) }) - const contextPending = pendingInvocation(contextAgent, undefined, 'context') + const contextPending = pendingInvocation(contextAgent, undefined, 'context', agentId('agent-context')) source.push(contextPending.dispatch) let contextFrame: RemoteEventInvocationFrame | undefined await vi.waitFor(() => { @@ -896,7 +826,7 @@ describe('Typert Remote streams', () => { && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined expect(contextFrame).toBeDefined() }) - const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released') + const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Agent Context was released') await contextFiber.dispose() await contextOutcome await vi.waitFor(() => { diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index ab1ee9d6b9..1a4e5cfa94 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -1340,7 +1340,6 @@ function contextProvider(context: Context) { return { wire: 'agentId', wireTypeSymbol: '@fixture/domain#AgentId', - identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined, resolve: (id: string) => id === 'agent-1' ? context : undefined, } } diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8a271fe29a..e23b9142b3 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -60,6 +60,7 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", @@ -83,6 +84,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index e775d3a5ae..d0a939c24d 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -2,6 +2,7 @@ import { homedir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' import type { TypertRemoteEventDispatch, TypertRemoteEventInvocation, @@ -56,17 +57,17 @@ function remoteEventSource(ctx: Context): TypertRemoteEventSource { request: object, next: () => unknown, ) { - const subject = carrierKeyOf(this) - if (subject === undefined) return next() - const value = Reflect.get(subject, 'ctx') as unknown - if (typeof value !== 'object' || value === null) { - throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`) + const carrierAgent = carrierKeyOf(this) + if (carrierAgent === undefined) return next() + const agent = (request as { readonly agent?: Agent }).agent + if (agent === undefined || agent !== carrierAgent) { + throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} must carry its Agent directly`) } return forwardWaterfall( queue, event, request, - { value: value as Context, subject }, + { value: agent.ctx, subject: agent, agentId: agent.id }, next, ) }) as never) diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 1d2e8c235d..7cb78d0a27 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -154,10 +154,18 @@ describe('Remote event Host source', () => { const abort = new AbortController() const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() const agentCtx = ctx.extend() - const agent = { ctx: agentCtx } + const agent = { id: 'agent-1', ctx: agentCtx } const target = scopeTarget(ctx, agent) const request = { questions: [], agent } + await expect(async () => waterfallRaw( + ctx, + target, + 'user-questions/request', + [{ questions: [], agent: { id: 'agent-2', ctx: ctx.extend() } }], + () => Promise.resolve('host fallback'), + )).rejects.toThrow('must carry its Agent directly') + const claimed = waterfallRaw( ctx, target, @@ -169,7 +177,7 @@ describe('Remote event Host source', () => { expect(claimedDispatch).toMatchObject({ event: 'user-questions/request', request, - context: { value: agentCtx, subject: agent }, + context: { value: agentCtx, subject: agent, agentId: 'agent-1' }, }) claimedDispatch.resolve({ kind: 'result', value: 'client answer' }) await expect(claimed).resolves.toBe('client answer') @@ -211,7 +219,7 @@ describe('Remote event Host source', () => { const abort = new AbortController() const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() const delivery = iterator.next() - const agent = { ctx: ctx.extend() } + const agent = { id: 'agent-1', ctx: ctx.extend() } const reason = new Error('forwarded event source removed') const pending = waterfallRaw( ctx, diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index f96c66b464..58652d8f68 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -380,12 +380,14 @@ export class ApiSessionAgentController { readonly setup: AgentSetup }> { const presets = this.ctx.get('agentPresets') - if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } } + if (presets === undefined) { + return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } + } const resolvedId = (await presets.resolve(presetId)).id return { agentPreset: resolvedId, - setup: async (agentCtx) => { - this.installSelection(agentCtx) + setup: async (agentCtx, agent) => { + this.installSelection(agent) await presets.mount(agentCtx, resolvedId) }, } @@ -494,9 +496,7 @@ export class ApiSessionAgentController { return { provider, model } } - private installSelection(agentCtx: Context): void { - const agent = agentCtx.agent - if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent') + private installSelection(agent: Agent): void { this.selectionFor(agent) } diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 3f4e721228..acd07544dd 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -421,15 +421,12 @@ describe('ApiSession create or adoption', () => { .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) - it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => { + it('surfaces directory creation failure', async () => { const { agents } = await harness() const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-')) const file = join(parent, 'file') writeFileSync(file, 'not a directory') await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) .rejects.toThrow('failed to ensure project directory') - - const composition = await agents.composeAgent(undefined) - expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent') }) }) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 06e7b5a3dd..a8290dc067 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -34,9 +34,9 @@ async function composed(workspaces: readonly Workspace[] = []): Promise ...options.meta === undefined ? {} : { meta: options.meta }, }) const agent = {} as Agent - const agentCtx = ownerCtx.extend({ agent }) + const agentCtx = ownerCtx Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx }) - await options.setup?.(agentCtx) + await options.setup?.(agentCtx, agent) ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } }, diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 10ce0e4269..960de2f628 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -49,9 +49,8 @@ async function harness(presets?: readonly string[]) { options.meta === undefined ? {} : { meta: options.meta }, ) const agent = stubAgent(session) - const agentCtx = ctx.extend({ agent }) - ;(agent as { ctx?: Context }).ctx = agentCtx - await options.setup?.(agentCtx) + ;(agent as { ctx?: Context }).ctx = ctx + await options.setup?.(ctx, agent) const unregister = ctx.agents.register(agent) return { agent, dispose: () => { unregister(); return Promise.resolve() } } }, diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index fb86ff8387..8e1cf04647 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -67,7 +67,7 @@ async function bench(script: Script): Promise<{ }) let idle = Promise.resolve() const agent = {} as Agent - const agentCtx = ownerCtx.extend({ agent }) + const agentCtx = ownerCtx Object.assign(agent, { id: session.id, options: options.agentOptions ?? {}, @@ -86,7 +86,7 @@ async function bench(script: Script): Promise<{ inject: () => {}, whenIdle: () => idle, } satisfies Partial) - await options.setup?.(agentCtx) + await options.setup?.(agentCtx, agent) script.before?.(session) ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 1dacfe2114..78f5379558 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -339,7 +339,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }, - }) + }, undefined) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })) await agent.whenIdle() @@ -413,7 +413,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', sessionId: SessionId('alternating-recovery'), seed: overflowHistorySeed(), agentOptions: { provider: 'mock', model: 'mock' }, - }) + }, undefined) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })) await agent.whenIdle() diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 0e12ae0be0..254bcc99a4 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: f1830c3293b4ca37fe102aa6868bbb612dc773a1 -README.zh.md: 671a117d64baeb77f083a6d429e5bb205e667fe6 +README.md: 0ab167ab3b6c44fea509cae53cbac3b12514f65c +README.zh.md: c1afc008eb1f1e2a1950aeb9f829e6fca015a770 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index f1830c3293..0ab167ab3b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -64,7 +64,7 @@ Plugins and hosts create agents through `ctx.agents.create()` and resume persist const handle = await ctx.agents.create({ sessionId, agentOptions: { provider: 'deepseek', model: 'deepseek-chat' }, - setup: (agentCtx) => { /* scoped tools, prompt sections, listeners */ }, + setup: (agentCtx, agent) => { /* scoped registrations plus explicit unpublished Agent */ }, }) ``` @@ -103,7 +103,7 @@ After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields an ### Creation and teardown -Creation is 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`; only then start the driver. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, unwind the scope, detach the agent, then detach the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement. +Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup with the context and Agent passed separately; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A caller creating a runtime child passes the parent Agent explicitly; the caller Context separately owns the transaction and live handle. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, unwind the scope, detach the agent, then detach the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement. ### Turn and step flow diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 671a117d64..c1afc008eb 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" const handle = await ctx.agents.create({ sessionId, agentOptions: { provider: 'deepseek', model: 'deepseek-chat' }, - setup: (agentCtx) => { /* scoped tools, prompt sections, listeners */ }, + setup: (agentCtx, agent) => { /* scoped registrations plus explicit unpublished Agent */ }, }) ``` @@ -103,7 +103,7 @@ const handle = await ctx.agents.create({ ### 创建与拆除 -创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 +创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待分别传入上下文与 Agent 的可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。创建运行时子 Agent 的调用方显式传入父 Agent;调用方 Context 则单独拥有事务和存活 handle。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 ### 轮次与步骤流程 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index f1377c1ac0..1341e50077 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -101,7 +101,7 @@ export class ReactLoopAgent implements Agent { const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0 this.phase = { kind: 'idle', lastTurn } this.scope = createScope(loopCtx, this) - this.ctx = this.scope.ctx.extend({ agent: this }) + this.ctx = this.scope.ctx this.runtimeContext = new RuntimeContextProjection(this.ctx, session) } diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 0f0f4858c7..be5c94ff6f 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -432,7 +432,7 @@ export class AgentLoop extends Service implements AgentFactory { void this.resumeWith(ctx, childCtx.sessionPersistence, { resumeSessionId, agentOptions: options, - }).catch((error: unknown) => { + }, undefined).catch((error: unknown) => { this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error) }) }) @@ -474,7 +474,7 @@ export class AgentLoop extends Service implements AgentFactory { await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId) if (!this.ownership.isActive()) return try { - await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }) + await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }, undefined) return } catch (error: unknown) { if (!this.ownership.isActive()) return @@ -516,7 +516,14 @@ export class AgentLoop extends Service implements AgentFactory { * BEFORE publication, so a mid-setup unload rolls everything back; `signal` * fuses caller cancellation with lifecycle teardown for setup awaits. */ - private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent { + private prepare( + ownerCtx: Context, + owner: Agent | undefined, + id: SessionId, + options: AgentOptions, + session: Session, + callerSignal?: AbortSignal, + ): PreparedAgent { assertAgentOptions(options) ownerCtx.fiber.assertActive() // Every caller reaches prepare() synchronously from a service method @@ -616,7 +623,7 @@ export class AgentLoop extends Service implements AgentFactory { publish: (source) => { assertLive() detachSession = agent.ctx.sessions.enter(session) - detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent) + detachAgent = loopCtx.agents.enter(agent, owner) agent.ctx.sessions.announce(session) assertLive() loopCtx.agents.announce(agent) @@ -648,7 +655,7 @@ export class AgentLoop extends Service implements AgentFactory { */ create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { using preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(id, { meta })) - const prepared = this.prepare(this.ctx, id, options, preparation.session) + const prepared = this.prepare(this.ctx, undefined, id, options, preparation.session) try { return prepared.publish('startup').agent } catch (error: unknown) { @@ -661,15 +668,17 @@ export class AgentLoop extends Service implements AgentFactory { * Create an owned agent on a caller-supplied session id. * @param ownerCtx - caller context that structurally owns the lifecycle. * @param options - identities, session seed/metadata, loop options, setup, and cancellation. + * @param owner - live Agent that owns the new Agent at runtime, or undefined for a root owner. * @returns the published handle. */ - async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { + async createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise { const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, })) const published = this.setupAndPublish( ownerCtx, + owner, options.sessionId, preparation, options.agentOptions ?? {}, @@ -684,6 +693,7 @@ export class AgentLoop extends Service implements AgentFactory { /** Prepare one Agent around an acquired Session, run setup, and publish it. */ private async setupAndPublish( ownerCtx: Context, + owner: Agent | undefined, id: SessionId, preparation: SessionPreparation, agentOptions: AgentOptions, @@ -693,9 +703,9 @@ export class AgentLoop extends Service implements AgentFactory { ): Promise { using ownedPreparation = preparation const session = ownedPreparation.session - const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal) + const prepared = this.prepare(ownerCtx, owner, id, agentOptions, session, signal) try { - const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id) + const setupCommit = await raceAbort(setup?.(prepared.agent.ctx, prepared.agent), prepared.signal, id) setupCommit?.commit() return prepared.publish(source) } catch (error: unknown) { @@ -708,14 +718,15 @@ export class AgentLoop extends Service implements AgentFactory { * Resume an owned agent from the configured persistence service. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. * @param options - persisted identity, loop options, setup, and cancellation. + * @param owner - live Agent that owns the resumed Agent at runtime, or undefined for a root owner. * @returns the published handle. */ - async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise { + async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise { const persistence = this.runtime.ctx.get('sessionPersistence') if (persistence === undefined) { throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)') } - return this.resumeWith(ownerCtx, persistence, options) + return this.resumeWith(ownerCtx, persistence, options, owner) } /** Resume through an explicit persistence handle used by the deferred config path. */ @@ -723,6 +734,7 @@ export class AgentLoop extends Service implements AgentFactory { ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions, + owner: Agent | undefined, ): Promise { const id = options.resumeSessionId const published = (async () => { @@ -754,6 +766,7 @@ export class AgentLoop extends Service implements AgentFactory { if (!this.ownership.isActive()) throw new Error('agent loop is not active') return await this.setupAndPublish( ownerCtx, + owner, id, preparation, options.agentOptions ?? {}, diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 395a0333c2..b4f48d6bc7 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -238,9 +238,9 @@ describe('AgentLoop initiator scope', () => { const handle = await exec.agent.ctx.agents.create({ sessionId: SessionId('child-session'), agentOptions: { provider: 'mock', model: 'mock' }, - setup: (agentCtx) => { + setup: (agentCtx, childAgent) => { parentDuringSetup = ctx.agents.requireInitiator() - explicitChild = agentCtx.agent + explicitChild = childAgent agentCtx.tools.register(defineContentToolFixture({ name: 'observe-child', description: 'observe child execution identity', @@ -252,7 +252,7 @@ describe('AgentLoop initiator scope', () => { }, })) }, - }) + }, exec.agent) child = handle.agent parentWhileChildDriverActive = ctx.agents.requireInitiator() send(handle.agent, 'run child') diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index df2849d39d..2c853f71a9 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -314,10 +314,10 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' }, - setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(sessionId) + setup: async (agentCtx, agent) => { + expect(agent.id).toBe(sessionId) // The two persisted events plus the end-seed marker. - expect(agentCtx.agent?.session.events).toHaveLength(3) + expect(agent.session.events).toHaveLength(3) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 6b78213200..e1cb6ee05c 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -142,17 +142,14 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { + it('tags agent.ctx with the Agent scope key', async () => { const ctx = await harness() const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) expect(scopeOf(agent.ctx)).toBe(agent) - expect(agent.ctx.agent).toBe(agent) - // The root accessor default: a plain context answers undefined, not a throw. - expect(ctx.agent).toBeUndefined() await agent.whenIdle() }) - it('records agents created through an agent context as non-root runtime children', async () => { + it('records an explicitly owned Agent as a non-root runtime child', async () => { const ctx = await harness() const root = await ctx.agents.create({ sessionId: SessionId('runtime-root'), @@ -161,7 +158,7 @@ describe('agent scope lifecycle', () => { const child = await root.agent.ctx.agents.create({ sessionId: SessionId('runtime-child'), agentOptions: { model: 'mock' }, - }) + }, root.agent) expect(ctx.agents.list()).toEqual([root.agent, child.agent]) expect(ctx.agents.roots()).toEqual([root.agent]) @@ -258,8 +255,8 @@ describe('agent scope lifecycle', () => { const creating = ctx.agents.create({ sessionId: SessionId('atomic'), agentOptions: acceptedOptions, - setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(SessionId('atomic')) + setup: async (agentCtx, agent) => { + expect(agent.id).toBe(SessionId('atomic')) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -586,7 +583,7 @@ describe('agent scope lifecycle', () => { await expect(loop.createAgent(ctx, { sessionId: SessionId('factory-inactive-s'), - })).rejects.toThrow(/agent loop is not active|inactive context/) + }, undefined)).rejects.toThrow(/agent loop is not active|inactive context/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index e072cef1ad..3b389cc1f7 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: 906f377fe0a5272ce3a4705536ccaa6b84c4afcf -README.zh.md: 7ae6dd766c78ca0088806c10e567ea6c389f4cf8 +README.md: de8db86c4083f0fc705ae09265b909e663fd37ff +README.zh.md: 21379a0184c60f0f9f59b2f9557bd5d7bf539c30 diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 906f377fe0..de8db86c40 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -40,7 +40,7 @@ const handle = await ctx.agents.create({ await handle.dispose() // stops the loop, unregisters, removes the session, unwinds the scope ``` -`AgentOptions` supplies the initial provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation request. An optional `setup(agentCtx)` callback composes the agent's scoped world before it is published — scoped tools, prompt sections, and listeners exist before any creation announcement. Setup is composition-only: drive the agent only after creation resolves. +`AgentOptions` supplies the initial provider/model route, optional adapter-owned `reasoningEffort`, and optional positive `maxTokens` output cap. The loop validates exact-model reasoning support, resolves adapter defaults, records the effective values in the request header, and applies them to each conversation request. An optional `setup(agentCtx, agent)` callback composes the agent's scoped world before it is published: `agentCtx` owns registrations, while the explicit unpublished Agent provides its Session; the Context has no reverse Agent property. Scoped tools, prompt sections, and listeners exist before any creation announcement. Setup is composition-only: drive the agent only after creation resolves. ### Drive an agent's conversation @@ -166,7 +166,7 @@ These limits define when this package needs special care. They are current packa - **Initiator scope is process-local** — workers, child processes, HTTP, durable queues, and restarts must materialize any required identity explicitly. - **Ambient identity may outlive liveness** — consumers still check `agent.status`, cancellation, and the owning capability contract before lifecycle-sensitive work. -- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. +- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx, agent)` transaction instead. - **`cancel()` clears the inbox by default** — it aborts the in-flight turn plus queued and steering work; `cancel(cause, { keepInbox: true })` aborts only the turn and preserves pending items, and there is no step-only abort that keeps the turn running. - **Each additional `UserMessage` carries exactly one `MessageSource`** — contributions from several plugins merged onto one message collapse under one source, so the message cannot name several producers. diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 7ae6dd766c..21379a0184 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -40,7 +40,7 @@ const handle = await ctx.agents.create({ await handle.dispose() // stops the loop, unregisters, removes the session, unwinds the scope ``` -`AgentOptions` 提供初始 provider/model 路由、可选的适配器所有 `reasoningEffort`,以及可选的正数 `maxTokens` 输出上限。循环会校验确切模型的推理支持、解析适配器默认值、把有效值记录在请求头中,并将它们应用到每个对话请求。可选的 `setup(agentCtx)` 回调会在 agent 发布之前组合其作用域世界——作用域工具、提示词段与监听器在任何创建公告之前就已存在。Setup 只做组合:创建完成后才能驱动 agent。 +`AgentOptions` 提供初始 provider/model 路由、可选的适配器所有 `reasoningEffort`,以及可选的正数 `maxTokens` 输出上限。循环会校验确切模型的推理支持、解析适配器默认值、把有效值记录在请求头中,并将它们应用到每个对话请求。可选的 `setup(agentCtx, agent)` 回调会在 agent 发布之前组合其作用域世界:`agentCtx` 拥有注册,显式的未发布 Agent 则提供其 Session;Context 不含反向 Agent 属性。作用域工具、提示词段与监听器在任何创建公告之前就已存在。Setup 只做组合:创建完成后才能驱动 agent。 ### 驱动 agent 的对话 @@ -166,7 +166,7 @@ await handle.agent.whenIdle() - **发起方作用域只存在于进程内**:worker、子进程、HTTP、持久队列和重启必须显式传递所需身份。 - **环境身份可能比存活状态更久**:消费方在生命周期敏感工作前,仍要检查 `agent.status`、取消状态和所属能力约定。 -- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx)` 事务。 +- **`agent/session-start` 不能为启动设置门禁**:它仍是同步且不可 veto 的通知;必须在发布前完成的异步组合属于工厂的 `setup(agentCtx, agent)` 事务。 - **`cancel()` 默认清空收件箱**:它会中止正在处理的轮次以及排队和 steering 工作;`cancel(cause, { keepInbox: true })` 只中止轮次并保留待处理项,且不存在让轮次继续运行、只中止步骤的操作。 - **每条附加 `UserMessage` 恰好携带一个 `MessageSource`**:多个插件合并到一条消息上的贡献会归入同一来源,因此该消息无法列出多个生产者。 diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index ef7371231b..d3ec675078 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -27,16 +27,6 @@ export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts' declare module '@deepseek-ai/cordis' { interface Context { agents: AgentRegistry - /** - * The agent association installed as an own property on `Agent.ctx`, or - * `undefined` on a plain context. Contexts derived from `Agent.ctx` inherit - * the association; a deliberately nested scope may carry a nearer - * `dsh-scope` tag while retaining it, so this field is DX context rather - * than the scope resolver. {@link AgentRegistry} registers a root accessor - * defaulting to `undefined`, and core packages below the agent layer use - * `scopeOf()` for layer selection instead of reading this field. - */ - agent?: Agent } } @@ -55,10 +45,12 @@ export interface AgentSetupCommit { /** * Compose an unpublished Agent scope and optionally return its publication commit. * @param agentCtx - unpublished Agent scope. + * @param agent - unpublished Agent being composed. * @returns an optional synchronous commit invoked after setup awaits settle and immediately before publication. */ export type AgentSetup = ( agentCtx: Context, + agent: Agent, ) => AgentSetupCommit | Promise | void /** @@ -188,9 +180,10 @@ export interface AgentFactory { * ownership from the factory object's registration context. * @param ownerCtx - caller-bound context that owns the transaction and live handle. * @param options - agent/session identity, configuration, and optional setup. + * @param owner - explicitly supplied live runtime owner, or undefined for a root owner. * @returns the owned handle after setup, both announcements, and loop start complete. */ - createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise + createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise /** * Prepare a persisted session and resume an agent on it. Async because it awaits * both `ctx.sessionPersistence.prepare` and the optional unpublished setup @@ -199,9 +192,10 @@ export interface AgentFactory { * 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. + * @param owner - explicitly supplied live runtime owner, or undefined for a root owner. * @returns the owned handle after setup, both announcements, and loop start complete. */ - resume(ownerCtx: Context, options: ResumeAgentOptions): Promise + resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise } /** Thrown when create/resume is called before an agent factory is registered. */ @@ -267,17 +261,9 @@ export class AgentRegistry extends Service { typeCtx.typert.contexts.registerHost('agent', { wire: 'agentId', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', - identity: candidate => candidate.agent?.id, resolve: sessionId => this.get(sessionId)?.ctx, }) }) - // The `ctx.agent` DX accessor: default `undefined` on every context, so a - // plain plugin context reads cleanly instead of hitting the Cordis - // unknown-property throw. Each Agent.ctx shadows it with an own property - // (own properties resolve before the context proxy is consulted), so the - // accessor body never needs to resolve a scope itself. Effect-scoped: - // unwinds with this service's fiber. - ctx.accessor('agent', { get: () => undefined }) ctx.on('internal/status', (fiber) => { if (fiber.state === FiberState.UNLOADING && this.hasLifecycleAncestor(fiber)) { this.closeInitiators() @@ -293,7 +279,8 @@ export class AgentRegistry extends Service { * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution * that also supports agentless calls. When a parent creates a child, setup - * reports the causal parent while `agentCtx.agent` identifies the child. + * reports the causal parent while the setup callback's Agent parameter + * identifies the child. * @returns the inherited Agent, or `undefined` outside an initiator boundary * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. @@ -392,9 +379,10 @@ export class AgentRegistry extends Service { * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. * @param options - shared identity, session seed/metadata, and agent options. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ - async create(options: CreateAgentOptions): Promise { + async create(options: CreateAgentOptions, owner?: Agent): Promise { const ownerCtx = this.ctx // Re-trace a Service-backed factory through the accessing context // explicitly. This preserves AgentLoop's dependency origin while binding @@ -403,7 +391,7 @@ export class AgentRegistry extends Service { const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver - return Reflect.apply(target.createAgent, receiver, [ownerCtx, options]) + return Reflect.apply(target.createAgent, receiver, [ownerCtx, options, owner]) } /** @@ -411,14 +399,15 @@ export class AgentRegistry extends Service { * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. * @param options - persisted identity, configuration, and optional setup. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ - async resume(options: ResumeAgentOptions): Promise { + async resume(options: ResumeAgentOptions, owner?: Agent): Promise { const ownerCtx = this.ctx const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver - return Reflect.apply(target.resume, receiver, [ownerCtx, options]) + return Reflect.apply(target.resume, receiver, [ownerCtx, options, owner]) } /** @@ -430,6 +419,7 @@ export class AgentRegistry extends Service { * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always * requires passing the carrier). Returns the disposer. * @param agent - the already-constructed agent to record in the store. + * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact * identity is load-bearing: a composite (generator) effect that owns a @@ -439,9 +429,9 @@ export class AgentRegistry extends Service { * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ - register(agent: Agent): () => void { + register(agent: Agent, owner?: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { - yield this.enter(agent, this.ctx.agent) + yield this.enter(agent, owner) this.announce(agent) }.bind(this), 'agents.register()') // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity @@ -455,7 +445,7 @@ export class AgentRegistry extends Service { * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - live agent whose scoped context created this agent, or + * @param owner - explicitly supplied live runtime owner, or * undefined for a top-level runtime root. This is runtime ownership, not * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 33ecfb3c7b..ab540cb288 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -149,7 +149,6 @@ describe('AgentRegistry', () => { await agentFiber await ctx.plugin(TypertRegistry) const agent = stubAgent('remote-agent') - Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) }) const disposeAgent = ctx.agents.register(agent) const lookup = ctx.typert.lookups.get('agent') @@ -161,8 +160,6 @@ describe('AgentRegistry', () => { }) expect(lookup?.resolve(agent.id)).toBe(agent) const context = ctx.typert.contexts.getHost('agent') - expect(context?.identity(agent.ctx)).toBe(agent.id) - expect(context?.identity(ctx)).toBeUndefined() expect(context?.resolve(agent.id)).toBe(agent.ctx) disposeAgent() @@ -362,16 +359,16 @@ describe('explicit cancellation contract', () => { describe('AgentRegistry factory seam', () => { function stubFactory() { const calls: { - create: Array<{ ownerCtx: Context; options: CreateAgentOptions }> - resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }> + create: Array<{ ownerCtx: Context; options: CreateAgentOptions; owner: Agent | undefined }> + resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions; owner: Agent | undefined }> } = { create: [], resume: [] } const factory: AgentFactory = { - async createAgent(ownerCtx, options) { - calls.create.push({ ownerCtx, options }) + async createAgent(ownerCtx, options, owner) { + calls.create.push({ ownerCtx, options, owner }) return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } }, - async resume(ownerCtx, options) { - calls.resume.push({ ownerCtx, options }) + async resume(ownerCtx, options, owner) { + calls.resume.push({ ownerCtx, options, owner }) return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } }, } @@ -393,6 +390,22 @@ describe('AgentRegistry factory seam', () => { }, { inject: ['agents'] })) expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber) expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber) + expect(calls.create[0]?.owner).toBeUndefined() + expect(calls.resume[0]?.owner).toBeUndefined() + }) + + it('passes the explicit runtime owner separately from the caller context', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const { factory, calls } = stubFactory() + ctx.agents.setFactory(factory) + const parent = stubAgent('parent') + const unregister = ctx.agents.register(parent) + + await ctx.agents.create({ sessionId: SessionId('child') }, parent) + + expect(calls.create[0]?.owner).toBe(parent) + unregister() }) it('rejects a second factory and clears the slot with its owner (HMR)', async () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 28e8d3dc06..65b3d7fadf 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -117,15 +117,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the published running agent.', }, { - signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', + signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise', description: 'Create an owned agent on a caller-supplied session id.', - parameters: [{ name: 'ownerCtx', description: 'caller context that structurally owns the lifecycle.' }, { name: 'options', description: 'identities, session seed/metadata, loop options, setup, and cancellation.' }], + parameters: [{ name: 'ownerCtx', description: 'caller context that structurally owns the lifecycle.' }, { name: 'options', description: 'identities, session seed/metadata, loop options, setup, and cancellation.' }, { name: 'owner', description: 'live Agent that owns the new Agent at runtime, or undefined for a root owner.' }], returns: 'the published handle.', }, { - signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', + signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise', description: 'Resume an owned agent from the configured persistence service.', - parameters: [{ name: 'ownerCtx', description: 'caller context that owns load, setup, and the live lifecycle.' }, { name: 'options', description: 'persisted identity, loop options, setup, and cancellation.' }], + parameters: [{ name: 'ownerCtx', description: 'caller context that owns load, setup, and the live lifecycle.' }, { name: 'options', description: 'persisted identity, loop options, setup, and cancellation.' }, { name: 'owner', description: 'live Agent that owns the resumed Agent at runtime, or undefined for a root owner.' }], returns: 'the published handle.', }, ], @@ -250,7 +250,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'currentInitiator(): Agent | undefined', - description: 'Read the Agent that initiated the inherited asynchronous driver chain. Use this optional form for logging, tracing, metrics, or host attribution that also supports agentless calls. When a parent creates a child, setup reports the causal parent while `agentCtx.agent` identifies the child.', + description: 'Read the Agent that initiated the inherited asynchronous driver chain. Use this optional form for logging, tracing, metrics, or host attribution that also supports agentless calls. When a parent creates a child, setup reports the causal parent while the setup callback\'s Agent parameter identifies the child.', parameters: [], returns: 'the inherited Agent, or `undefined` outside an initiator boundary and inside an explicit clearing boundary.', throws: ['when this service instance has been disposed.'], @@ -283,27 +283,27 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the disposer that clears the factory slot. The exact Cordis effect disposer (single-shot): composite (generator) effects may yield it directly — exact identity nests the teardown in order.', }, { - signature: 'async create(options: CreateAgentOptions): Promise', + signature: 'async create(options: CreateAgentOptions, owner?: Agent): Promise', description: 'Create and publish a new agent through the registered factory. Distinct from register (which records an already-constructed agent): this constructs the agent and its session. Rejects if no factory is registered or creation/setup fails. The resolved AgentHandle lets the owner tear down exactly this agent.', - parameters: [{ name: 'options', description: 'shared identity, session seed/metadata, and agent options.' }], + parameters: [{ name: 'options', description: 'shared identity, session seed/metadata, and agent options.' }, { name: 'owner', description: 'explicit live runtime owner, or undefined for a root Agent.' }], returns: 'the handle after setup, rollback-covered publication, and loop start complete.', }, { - signature: 'async resume(options: ResumeAgentOptions): Promise', + signature: 'async resume(options: ResumeAgentOptions, owner?: Agent): Promise', description: 'Load a persisted session and resume an agent on it through the registered factory. Rejects if no factory is registered; the factory rejects if session persistence is not configured or persistence/setup fails.', - parameters: [{ name: 'options', description: 'persisted identity, configuration, and optional setup.' }], + parameters: [{ name: 'options', description: 'persisted identity, configuration, and optional setup.' }, { name: 'owner', description: 'explicit live runtime owner, or undefined for a root Agent.' }], returns: 'the handle after setup, rollback-covered publication, and loop start complete.', }, { - signature: 'register(agent: Agent): () => void', + signature: 'register(agent: Agent, owner?: Agent): () => void', description: 'Register a live agent. Throws if an agent with the same id is already registered. Emits `agent/created` on registration and `agent/disposed` when the calling fiber is disposed — both with the agent\'s scope carrier (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the emits are scope-filtered regardless of which context invoked `register` (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always requires passing the carrier). Returns the disposer.', - parameters: [{ name: 'agent', description: 'the already-constructed agent to record in the store.' }], + parameters: [{ name: 'agent', description: 'the already-constructed agent to record in the store.' }, { name: 'owner', description: 'explicit live runtime owner, or undefined for a root Agent.' }], returns: 'the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory\'s lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining.', }, { signature: 'enter(agent: Agent, owner: Agent | undefined): () => void', description: 'Insert an already-constructed agent without announcing it. This is the advanced ordered-lifecycle primitive used by the async agent factory: it first completes setup while the agent is unpublished, then assigns the returned detach closure into its pre-installed composite teardown before calling announce. Ordinary callers use register.', - parameters: [{ name: 'agent', description: 'the prepared, unpublished agent.' }, { name: 'owner', description: 'live agent whose scoped context created this agent, or undefined for a top-level runtime root. This is runtime ownership, not the resumed session\'s durable parent lineage.' }], + parameters: [{ name: 'agent', description: 'the prepared, unpublished agent.' }, { name: 'owner', description: 'explicitly supplied live runtime owner, or undefined for a top-level runtime root. This is runtime ownership, not the resumed session\'s durable parent lineage.' }], returns: 'an idempotent closure that removes this exact entry and emits `agent/disposed` with listener failures contained. When called from a synchronous `agent/created` listener, removal and disposal wait until that creation dispatch unwinds.', }, { @@ -3396,7 +3396,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentFactory', - declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', + declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise;\n}', }, { name: 'AgentHandle', @@ -3428,7 +3428,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentSetup', - declaration: 'export type AgentSetup = (agentCtx: Context) => AgentSetupCommit | Promise | void;', + declaration: 'export type AgentSetup = (agentCtx: Context, agent: Agent) => AgentSetupCommit | Promise | void;', }, { name: 'AgentSetupCommit', @@ -5832,7 +5832,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertRemoteEventContext', - declaration: 'export interface TypertRemoteEventContext {\n readonly value: Context;\n readonly subject: object;\n}', + declaration: 'export interface TypertRemoteEventContext {\n readonly value: Context;\n readonly subject: object;\n readonly agentId: string;\n}', }, { name: 'TypertRemoteEventDispatch', diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index 09231bd792..546a7a0561 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -64,7 +64,7 @@ describe('Schedule plugin composition', () => { agentEvents(ctx, root.agent).emit('agent/status', { status: 'running' }) agentEvents(ctx, root.agent).emit('agent/status', { status: 'idle' }) - const child = await root.agent.ctx.agents.create({ sessionId: SessionId('schedule-child') }) + const child = await root.agent.ctx.agents.create({ sessionId: SessionId('schedule-child') }, root.agent) expect(ctx.agents.roots()).toEqual([existing.agent, root.agent]) expect(ctx.tools.get('schedule_create', child.agent)).toBeUndefined() diff --git a/packages/sdk/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts index ae4ab8ef05..d38f2a6809 100644 --- a/packages/sdk/server/tests/built-scope-carrier.e2e.ts +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts @@ -62,7 +62,7 @@ try { sessionId: SessionId("built-child"), meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, agentOptions: { model: "test" }, - }); + }, parent.agent); const result = Promise.withResolvers(); const unregister = ctx.subagents.registerProvider({ name: "built-local", diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 60c01bcdca..b3dbf6b9ee 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -450,7 +450,7 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('parentless-child-session'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek-official' }, - }) + }, parentHandle.agent) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), @@ -512,7 +512,7 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('remote-run-id'), meta: { cwd: storageDir, parentSession: SessionId('collision-parent') }, agentOptions: { model: 'deepseek-official' }, - }) + }, parentHandle.agent) await settleSubagent(ctx, parentHandle.agent, { provider: 'remote', @@ -551,7 +551,7 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('continuation-child'), meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') }, agentOptions: { model: 'deepseek-official' }, - }) + }, parentHandle.agent) await settleSubagent(ctx, parentHandle.agent, { provider: 'continuation', @@ -596,7 +596,7 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('reused-child'), meta: { cwd: storageDir, parentSession: SessionId('old-parent') }, agentOptions: { model: 'deepseek-official' }, - }) + }, oldParent.agent) const first = Promise.withResolvers() const sameLifetime = Promise.withResolvers() const replacement = Promise.withResolvers() @@ -637,7 +637,7 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('reused-child'), meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, agentOptions: { model: 'deepseek-official' }, - }) + }, newParent.agent) currentLocalAgent = newChild.agent const secondRun = await ctx.subagents.start('reused', { parent: newParent.agent, @@ -695,7 +695,7 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('provider-reuse-child'), meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') }, agentOptions: { model: 'deepseek-official' }, - }) + }, parent.agent) const localResult = Promise.withResolvers() const remoteResult = Promise.withResolvers() const unregisterLocal = ctx.subagents.registerProvider({ @@ -788,13 +788,13 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' }, - }) + }, parentHandle.agent) const fallbackChild = handle.agent failedHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' }, - }) + }, parentHandle.agent) const missedStartResult = Promise.withResolvers() const disposeMissedStartProvider = ctx.subagents.registerProvider({ name: 'fork', diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index fd1ebff2be..9d220b3b7c 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -117,8 +117,8 @@ export async function startInProcessRun( const inherited = captureDelegatedPolicyOverrides(parent) let structured: StructuredAttachment | undefined - const setup = (childCtx: Context): void => { - appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited) + const setup = (childCtx: Context, child: Agent): void => { + appendDelegatedPolicyOverrides(child.session, inherited) applyChildComposition(childCtx, parent, { persona: request.persona, toolFilter: request.toolFilter, @@ -136,7 +136,7 @@ export async function startInProcessRun( agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), signal: request.signal, setup, - }) + }, parent) return drivePublishedRun( handle, request.signal, diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 6b50947060..0f00190f64 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1066,12 +1066,12 @@ 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): AgentSetupCommit => { + const setup = (childCtx: Context, child: Agent): AgentSetupCommit => { // Only fresh creation seeds the delegation policy onto the child's own // log (after any fork seed, so fresh policy wins stale seed state); a // cold resume replays those persisted events instead. if (create !== undefined) { - appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.delegatedPolicies) + appendDelegatedPolicyOverrides(child.session, create.delegatedPolicies) } applyChildComposition(childCtx, parent, inputs.composition) return this.setupRegistry.apply(childCtx) @@ -1085,7 +1085,7 @@ export class SubagentContinuationManager { agentOptions: inputs.agentOptions, signal: inputs.signal, setup, - }) + }, parent) : await this.ownerCtx.agents.create({ sessionId: childId, meta: create.meta, @@ -1093,7 +1093,7 @@ export class SubagentContinuationManager { agentOptions: inputs.agentOptions, signal: inputs.signal, setup, - }) + }, parent) const activation: Activation = { childId, diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 387c948d53..d5e08f7bbd 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -618,7 +618,7 @@ export function apply(ctx: Context, config: Config): void { throw new Error('tool-subagent: `modelSelectionSettings` requires an Agent or preset scope') } - const selectForAgent = (agent: NonNullable): ModelSelectionPolicy | undefined => { + const selectForAgent = (agent: Agent): ModelSelectionPolicy | undefined => { let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, agent.session) if (allowedModels === undefined) { const parentId = agent.session.header.origin === 'subagent' @@ -640,11 +640,6 @@ export function apply(ctx: Context, config: Config): void { return allowedModels === undefined ? undefined : { routes: allowedModels } } - const agent = ctx.agent - if (agent !== undefined) { - install(ctx, selectForAgent(agent)) - return - } const agents = ctx.get('agents') /* v8 ignore next -- Agent and preset scopes are minted only by the Agent registry. */ if (agents === undefined) throw new Error('tool-subagent: scoped model-selection settings require the Agent registry') @@ -685,9 +680,9 @@ export function apply(ctx: Context, config: Config): void { else removeScoped(candidate) } } - // A shipped preset is mounted once in a standing scope. Its listener admits - // only descendant Agents and installs the sampled tool definition in each - // Agent's own scope, so a later settings change cannot mutate a live session. + // The scoped listener admits this Agent or preset's descendant Agents and + // installs the sampled tool definition in each Agent's own scope, so a later + // settings change cannot mutate a live session. ctx.on('agent/created', ({ agent: created }) => { installScoped(created) }) @@ -696,4 +691,5 @@ export function apply(ctx: Context, config: Config): void { // set and emits `tools/change`; reconcile the Agent-owned override with the // new ancestry. Other registry changes are idempotent no-ops here. ctx.on('tools/change', reconcileComposedAgents) + reconcileComposedAgents() } diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 437641bc61..39f1d44f80 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/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/typert/protocol/README.md -README.md: 34e685b841e3bf98d1b23306ed90fd3ca8381f79 -README.zh.md: d85cda322f9a3117a4b7bbe4405140b75d5915b8 +README.md: 6fd5390f68f148711f1b70f6e30fab99cf2868fa +README.zh.md: 80039ac2e9be5786632a5a38ba23b68cca9777b7 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index 34e685b841..6fd5390f68 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -46,7 +46,7 @@ Generation turns the method into a wire endpoint under the service's namespace; ### Associating Host objects and Contexts with wire identities -Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. Host and Client Context adapters both map `Context` to a wire identity and that identity back to `Context`; the Host adapter also owns the stable wire declaration. Host composition may override its synchronous or asynchronous resolver. A policy rejection can throw `TypertLookupFailure` to carry an adapter-owned failure value to the caller. +Complex Host objects cannot cross the wire directly. A business package declares the association through the merge-extensible `TypertLookupMap` and `TypertContextMap`. A Host Context adapter owns the stable wire declaration and resolves wire identities to live Contexts. A Client Context adapter maps in both directions because scoped calls originate from a Client Context and forwarded Host events resolve their explicit wire identity there. Host composition may override its synchronous or asynchronous resolver. A policy rejection can throw `TypertLookupFailure` to carry an adapter-owned failure value to the caller. ### Receiving forwarded Host events on the Client diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index d85cda322f..80039ac2e9 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -46,7 +46,7 @@ export class GoalService extends TypertRemoteService { ### 把 Host 对象与 Context 关联到 wire identity -复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host 与 Client Context adapter 都把 `Context` 映射为 wire identity,也把该 identity 映射回 `Context`;Host adapter 还拥有稳定 wire 声明。Host 组合可以覆盖其同步或异步 resolver。策略拒绝可以抛出 `TypertLookupFailure`,把适配器拥有的失败值带给调用方。 +复杂的 Host 对象不能直接跨 wire 传输。业务包通过可合并扩展的 `TypertLookupMap` 与 `TypertContextMap` 声明关联。Host Context adapter 拥有稳定 wire 声明,并把 wire identity 解析为活跃 Context。Client Context adapter 需要双向映射,因为作用域调用从 Client Context 发起,而转发的 Host 事件要在 Client 侧解析其显式 wire identity。Host 组合可以覆盖其同步或异步 resolver。策略拒绝可以抛出 `TypertLookupFailure`,把适配器拥有的失败值带给调用方。 ### 在 Client 侧接收转发的 Host 事件 diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index ad8d973f2f..40e09a6019 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -64,7 +64,6 @@ export type { TypertClientContextAdapter, TypertCodec, TypertContext, - TypertContextAdapter, TypertContextMap, TypertContextRegistry, TypertContextWire, @@ -72,7 +71,6 @@ export type { TypertForwardableEvent, TypertForwardableEventEntry, TypertHostContextAdapter, - TypertHostContextIdentity, TypertHostContextResolver, TypertLocalRegistry, TypertLookup, diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 8123b2a36a..0651a9d0b1 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -350,29 +350,18 @@ export interface TypertLookupDefinition { readonly wireTypeSymbol: string } -/** Bidirectional projection between one environment's Context and its wire identity. */ -export interface TypertContextAdapter { - /** - * Read the identity represented by a live Context. - * @param ctx - Context in this adapter's environment. - * @returns the wire identity, or `undefined` when the Context has another kind. - */ - identity(ctx: Context): Wire | undefined - /** - * Resolve a wire identity to a live Context in this adapter's environment. - * An asynchronous Client resolver may wait for its owner to create the Context. - * @param id - validated wire identity. - * @returns the Context, or `undefined` when it is unavailable. - */ - resolve(id: Wire): Context | undefined | Promise -} - -/** Host Context adapter plus the wire declaration used by strict Remote methods. */ -export interface TypertHostContextAdapter extends TypertContextAdapter { +/** Host wire-to-Context resolver plus the declaration used by strict Remote methods. */ +export interface TypertHostContextAdapter { /** Wire field carrying the Context identity. */ readonly wire: string /** Canonical wire type symbol used by strict generation. */ readonly wireTypeSymbol: string + /** + * Resolve a validated wire identity to a live Host Context. + * @param id - validated wire identity. + * @returns the Context, or `undefined` when it is unavailable. + */ + resolve(id: Wire): Context | undefined | Promise } /** Composition-owned resolver replacing one Host Context adapter's default lookup policy. */ @@ -396,14 +385,6 @@ export interface TypertClientContextAdapter { resolve(id: Wire): Context | undefined } -/** Host Context identity selected from the registered adapter set. */ -export interface TypertHostContextIdentity { - /** Merge-declared Context kind whose adapter recognized the Context. */ - readonly kind: string - /** Wire identity returned by that adapter. */ - readonly identity: unknown -} - /** Notification emitted after a Typert runtime registry changes. */ export interface TypertRegistryChange { readonly kind: 'local' | 'remote' | 'lookup' | 'host-context' | 'client-context' @@ -513,7 +494,7 @@ export interface TypertContextRegistry { /** * Register a Host Context adapter. * @param key - merge-declared Context key. - * @param adapter - owning package's bidirectional Host projection. + * @param adapter - owning package's Host resolver and wire declaration. * @returns disposer withdrawing the exact adapter. */ registerHost>( @@ -541,13 +522,6 @@ export interface TypertContextRegistry { key: K, adapter: TypertClientContextAdapter>, ): TypertDisposer - /** - * Identify a live Host Context through the sole registered adapter set. - * @param ctx - Context projected by a Host-to-Client scoped event. - * @returns its kind and wire identity, or `undefined` when no adapter recognizes it. - * @throws when more than one Context kind recognizes the same Context. - */ - identifyHost(ctx: Context): TypertHostContextIdentity | undefined /** * Look up a Host Context adapter. * @param key - descriptor Context key. diff --git a/packages/typert/registry/README.i18n.yaml b/packages/typert/registry/README.i18n.yaml index 3762eabb8b..45c2ceaab7 100644 --- a/packages/typert/registry/README.i18n.yaml +++ b/packages/typert/registry/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/typert/registry/README.md -README.md: a19226addaa9512eaaefb621d0f5ae70653bf216 -README.zh.md: 8c909bb698f1b6658fe90f9d56f2c0d543957ec9 +README.md: 369ab26064db7c685cb961af9714a2a32a82a520 +README.zh.md: 306a917c7ffab0d670f307f7a8b5126022b3c5d3 diff --git a/packages/typert/registry/README.md b/packages/typert/registry/README.md index a19226adda..369ab26064 100644 --- a/packages/typert/registry/README.md +++ b/packages/typert/registry/README.md @@ -45,7 +45,7 @@ Generated artifacts register through the [loader](../loader/README.md) in Loader ### Lookup and Context providers -Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs one bidirectional Host Context adapter and its wire declaration, while `configureHost()` replaces only its resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. `identifyHost(ctx)` asks the Host adapters for the single kind and identity represented by a live Context and rejects ambiguous recognition. +Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context. ----- diff --git a/packages/typert/registry/README.zh.md b/packages/typert/registry/README.zh.md index 8c909bb698..306a917c7f 100644 --- a/packages/typert/registry/README.zh.md +++ b/packages/typert/registry/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" ### Lookup 与 Context 提供方 -Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装一个双向 Host Context adapter 及其 wire 声明,`configureHost()` 只替换其中的 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。`identifyHost(ctx)` 通过 Host adapter 识别活 Context 所代表的唯一 kind 与 identity,并拒绝歧义识别。 +Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。 ----- diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 4f4e86d10f..20eaa32b7c 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -357,7 +357,6 @@ class ContextStore { key: K, adapter: TypertClientContextAdapter>, ) => this.registerClient(ctx, key, adapter), - identifyHost: context => this.identifyHost(context), getHost: key => this.getHost(key), getClient: key => this.clients.get(key)?.provider, subscribe: listener => this.changes.subscribe(ctx, listener), @@ -372,26 +371,10 @@ class ContextStore { return { wire: adapter.wire, wireTypeSymbol: adapter.wireTypeSymbol, - identity: context => adapter.identity(context), resolve: id => resolver.resolve(id), } } - private identifyHost(ctx: Context): ReturnType { - let match: ReturnType - for (const key of this.hosts.keys()) { - const identity = this.getHost(key)?.identity(ctx) - if (identity === undefined) continue - if (match !== undefined) { - throw new Error( - `typert: Host Context is recognized by both ${JSON.stringify(match.kind)} and ${JSON.stringify(key)}`, - ) - } - match = { kind: key, identity } - } - return match - } - private configureHost( ctx: Context, key: string, diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 546063b0de..73e0862d2e 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -332,7 +332,6 @@ describe('TypertRegistry', () => { const disposeHost = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', - identity: candidate => candidate === scoped ? object.id : undefined, resolve: id => id === object.id ? scoped : undefined, }) const disposeClient = ctx.typert.contexts.registerClient('registryFixture', { @@ -348,15 +347,9 @@ describe('TypertRegistry', () => { hostTypeSymbol: '@fixture/agent#Agent', wireTypeSymbol: '@fixture/session#SessionId', }]) - expect(ctx.typert.contexts.getHost('registryFixture')?.identity(scoped)).toBe('agent-1') expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('agent-1')).toBe(scoped) expect(ctx.typert.contexts.getClient('registryFixture')?.identity(scoped)).toBe('agent-1') expect(ctx.typert.contexts.getClient('registryFixture')?.resolve('agent-1')).toBe(scoped) - expect(ctx.typert.contexts.identifyHost(scoped)).toEqual({ - kind: 'registryFixture', - identity: 'agent-1', - }) - expect(ctx.typert.contexts.identifyHost(ctx)).toBeUndefined() await Promise.all([disposeClient(), disposeHost(), disposeLookup()]) expect(ctx.typert.lookups.keys()).toEqual([]) @@ -365,26 +358,6 @@ describe('TypertRegistry', () => { expect(ctx.typert.contexts.getClient('registryFixture')).toBeUndefined() }) - it('rejects a Host Context recognized by more than one registered kind', async () => { - const ctx = await makeCtx() - const scoped = ctx.extend() - ctx.typert.contexts.registerHost('registryFixture', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === scoped ? 'first' : undefined, - resolve: () => undefined, - }) - ctx.typert.contexts.registerHost('registryFixtureOther', { - wire: 'otherAgentId', - wireTypeSymbol: '@fixture#OtherAgentId', - identity: candidate => candidate === scoped ? 'second' : undefined, - resolve: () => undefined, - }) - - expect(() => ctx.typert.contexts.identifyHost(scoped)) - .toThrow('recognized by both "registryFixture" and "registryFixtureOther"') - }) - it('configures an asynchronous lookup resolver independently of provider load order', async () => { const ctx = await makeCtx() const fallback = { id: 'fallback' } @@ -430,11 +403,9 @@ describe('TypertRegistry', () => { const disposeProvider = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', - identity: candidate => candidate === fallback ? 'fallback' : undefined, resolve: id => id === 'fallback' ? fallback : undefined, }) await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) - expect(ctx.typert.contexts.getHost('registryFixture')?.identity(fallback)).toBe('fallback') expect(() => ctx.typert.contexts.configureHost('registryFixture', () => undefined)).toThrow('already configured') await disposeProvider() @@ -442,7 +413,6 @@ describe('TypertRegistry', () => { const disposeReloadedProvider = ctx.typert.contexts.registerHost('registryFixture', { wire: 'agentId', wireTypeSymbol: '@fixture/session#SessionId', - identity: candidate => candidate === fallback ? 'fallback' : undefined, resolve: id => id === 'fallback' ? fallback : undefined, }) await expect(ctx.typert.contexts.getHost('registryFixture')?.resolve('configured')).resolves.toBe(configured) @@ -471,7 +441,6 @@ describe('TypertRegistry', () => { const host = { wire: 'agentId', wireTypeSymbol: '@fixture#AgentId', - identity: (_candidate: Context) => undefined, resolve: () => undefined, } const client = { diff --git a/packages/webhook/webhook/src/session.ts b/packages/webhook/webhook/src/session.ts index eef687ce4e..e1ddb62dfe 100644 --- a/packages/webhook/webhook/src/session.ts +++ b/packages/webhook/webhook/src/session.ts @@ -89,11 +89,8 @@ function reportRollbackFailure(ctx: Context, subject: string, error: unknown): v /** Apply the creation-time selection until its first durable request header exists. */ function installInitialModelSelection(agentCtx: Context, selection: ModelSelection): void { - agentCtx.on('agent/request', async (_payload, next): Promise => { + agentCtx.on('agent/request', async ({ agent }, next): Promise => { const resolved = await next() - const agent = agentCtx.agent - /* v8 ignore next -- AgentRegistry setup always provides the unpublished scoped Agent. */ - if (agent === undefined) throw new Error('webhook Session setup has no scoped Agent') if (agent.session.requestHeader() !== undefined || resolved.provider !== selection.provider || resolved.model !== selection.model) return resolved diff --git a/packages/webhook/webhook/tests/runtime.spec.ts b/packages/webhook/webhook/tests/runtime.spec.ts index 6356b2eed1..f3c1f997f5 100644 --- a/packages/webhook/webhook/tests/runtime.spec.ts +++ b/packages/webhook/webhook/tests/runtime.spec.ts @@ -242,16 +242,17 @@ describe('WebhookRuntime', () => { } as never) ctx.provide('sessionTitle', { rename: () => ({}) } as never) ctx.provide('agents', { - create: async (options: { setup?: (agentCtx: unknown) => Promise }) => { - await options.setup?.({ on: () => () => {} }) - return { - agent: { - session, - followup: (message: unknown) => { - messages.push(message) - if (messages.length === 2) followedTwice.resolve(true) - }, + create: async (options: { setup?: (agentCtx: unknown, agent: unknown) => Promise }) => { + const agent = { + session, + followup: (message: unknown) => { + messages.push(message) + if (messages.length === 2) followedTwice.resolve(true) }, + } + await options.setup?.({ on: () => () => {} }, agent) + return { + agent, dispose: async () => {}, } }, diff --git a/packages/webhook/webhook/tests/session.spec.ts b/packages/webhook/webhook/tests/session.spec.ts index bdb9201fa9..12cf28e024 100644 --- a/packages/webhook/webhook/tests/session.spec.ts +++ b/packages/webhook/webhook/tests/session.spec.ts @@ -22,6 +22,7 @@ interface SessionHarness { readonly calls: string[] readonly messages: unknown[] readonly modelListeners: Map + readonly agent: unknown markRequestHeader(): void readonly controller: AbortController readonly request: WebhookSessionRequest @@ -116,16 +117,15 @@ function harness(options: HarnessOptions = {}): SessionHarness { }, }, agents: { - async create(createOptions: { setup?: (ctx: unknown) => Promise }) { + async create(createOptions: { setup?: (ctx: unknown, agent: unknown) => Promise }) { calls.push('agent-create') if (options.failAt === 'agent') throw new Error('agent failed') await createOptions.setup?.({ - agent, on(event: string, listener: unknown) { modelListeners.set(event, listener) return () => {} }, - }) + }, agent) if (options.abortAt === 'agent') controller.abort(new Error('abort after agent')) return handle }, @@ -143,6 +143,7 @@ function harness(options: HarnessOptions = {}): SessionHarness { calls, messages, modelListeners, + agent, markRequestHeader() { requestHeader = {} }, controller, request: { @@ -184,10 +185,11 @@ function modelRequestListener(test: SessionHarness): ( if (typeof listener !== 'function') { throw new Error('webhook Session did not install its initial model selection') } - return listener as ( + const request = listener as ( payload: unknown, next: () => Promise, ) => Promise + return (_payload, next) => request({ agent: test.agent }, next) } describe('webhook Session creation', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9049facb91..bdb322b52d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -677,6 +677,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1bae81253b..17fdf7ccf1 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -144,7 +144,6 @@ export const SERVICE_PAGE: Record = { * to a model as `cordis_runtime_inspect what:"client"`). */ export const SERVICE_WALK_EXEMPTIONS: Record = { - agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract', appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', From 71c34ff13a6d01c9a2080fa4f9a1e5622818471f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 18:18:07 +0800 Subject: [PATCH 02/15] docs: refresh module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 41e0d5d0e8..68472bff58 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: f9cafb86b22012c2dc1198ff810ededd988911f9 -module-graph.zh.md: ec4ac506cfef4d8ceaf3c5f83f07dad1fee66c50 +module-graph.md: 14c2c4548a27b18f7dae2007966d5a7914e28687 +module-graph.zh.md: f3980cbebf96be4239338789daaff54fb5df3430 diff --git a/docs/module-graph.md b/docs/module-graph.md index f9cafb86b2..14c2c4548a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1299,6 +1299,7 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver + pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller @@ -1936,7 +1937,7 @@ flowchart TD | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ec4ac506cf..f3980cbebf 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1301,6 +1301,7 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver + pkg_api_remotes --> pkg_agent pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller @@ -1938,7 +1939,7 @@ flowchart TD | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | From 03ec1ca57c220e843f54151400a1891777e5468d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 31 Aug 2026 17:40:47 +0800 Subject: [PATCH 03/15] fix(agent): preserve explicit identity lifecycle behavior --- .../2026-07-08-agent-scope-contexts.i18n.yaml | 4 +- .../2026-07-08-agent-scope-contexts.md | 2 +- .../2026-07-08-agent-scope-contexts.zh.md | 2 +- ...07-12-agent-scope-runtime-design.i18n.yaml | 4 +- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...026-07-12-agent-scope-runtime-design.zh.md | 2 +- ...2026-07-15-agent-initiator-scope.i18n.yaml | 4 +- .../2026-07-15-agent-initiator-scope.md | 2 +- .../2026-07-15-agent-initiator-scope.zh.md | 2 +- ...-explicit-agent-runtime-identity.i18n.yaml | 6 + ...6-08-31-explicit-agent-runtime-identity.md | 47 ++++++++ ...8-31-explicit-agent-runtime-identity.zh.md | 47 ++++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 4 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 4 +- docs/subsystems/subagent.zh.md | 4 +- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 2 +- packages/api/remotes/README.zh.md | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/subagent/subagent/package.json | 1 + .../subagent/tests/continuation.spec.ts | 10 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 4 +- packages/subagent/tool-subagent/README.zh.md | 4 +- packages/subagent/tool-subagent/src/index.ts | 105 +++++++++++------- .../src/model-selection-settings.ts | 10 +- .../subagent/tool-subagent/tests/harness.ts | 7 +- .../tests/model-selection-settings.spec.ts | 79 ++++++++++--- pnpm-lock.yaml | 3 + 32 files changed, 288 insertions(+), 101 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md create mode 100644 .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md 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 b2ddad3a3b..875360cfd5 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 @@ -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/architecture/2026-07-08-agent-scope-contexts.md -2026-07-08-agent-scope-contexts.md: a1fa4527db4dea17e3b97f47cdc5613967b6c23a -2026-07-08-agent-scope-contexts.zh.md: 80062d2a73548a0057cc7dbdd4afcb3e1102daa9 +2026-07-08-agent-scope-contexts.md: 35441ecb3e2aac4304f518a76dc4b5f90f65a4f4 +2026-07-08-agent-scope-contexts.zh.md: c790fff092f696578d197661caf8f3c248235682 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 a1fa4527db..35441ecb3e 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 @@ -47,7 +47,7 @@ flowchart LR The missing cross-edges are the isolation rule: Agent A's local registrations do not enter Agent B's view, and a parent's registrations do not enter a child merely because the parent owns the child's lifetime. -The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature. +The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [explicit runtime-identity Agent Note](2026-08-31-explicit-agent-runtime-identity.md) owns why lifecycle, event, and transport interfaces pass Agent identity instead of exposing it through Context. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature. ### Registration origin chooses visibility and cleanup 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 80062d2a73..c790fff092 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 @@ -47,7 +47,7 @@ flowchart LR 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md)阐述实现与正确性推理。[显式运行时身份 Agent Note](2026-08-31-explicit-agent-runtime-identity.zh.md)说明生命周期、事件和传输接口为何显式传递 Agent 身份,而不通过 Context 暴露该身份。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 42e4f0cd35..2559a04520 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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/architecture/2026-07-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: 06f16e59357e6cca8382952bcc81dbe95fdd5583 -2026-07-12-agent-scope-runtime-design.zh.md: 12a3f916c5d5f2af0c929b7575064ce92ecd261e +2026-07-12-agent-scope-runtime-design.md: 74780e075795ede44016c451d229b5a5c3fff23a +2026-07-12-agent-scope-runtime-design.zh.md: e3c9f050eabcc259fa2940e06c6d83e679433525 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 06f16e5935..74780e0757 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -102,7 +102,7 @@ The transaction is installed under both the calling Cordis context and the concr Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm. -The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator passes the parent Agent explicitly, and AgentRegistry forwards it separately from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. +The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator passes the parent Agent explicitly, and AgentRegistry forwards it separately from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) owns this separation and the continuable-child ownership rule that follows from it. ### Setup is trusted composition inside a private world diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 12a3f916c5..e3c9f050ea 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -104,7 +104,7 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册 创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。 -工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方显式传入父 Agent,AgentRegistry 再将其与调用方 Context 分开转交。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。 +工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方显式传入父 Agent,AgentRegistry 再将其与调用方 Context 分开转交。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)拥有这项分离原则及由此确定的可续跑子级归属规则。 ### Setup 是私有世界内的可信组合 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 978a18c39e..99577f250e 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md -2026-07-15-agent-initiator-scope.md: 48962fb64e4cc0034d8e181d385e0fe86cc84675 -2026-07-15-agent-initiator-scope.zh.md: a41545576187ee1b88923b8925e3e44be6c811a3 +2026-07-15-agent-initiator-scope.md: ab11da116a463cd706418e797eb58f1bc4ab9b1c +2026-07-15-agent-initiator-scope.zh.md: 343acba5f99379b6d2c3af41368e8fbe90b20611 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md index 48962fb64e..ab11da116a 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -28,7 +28,7 @@ Initiator scope does not own detached work: registry drain tracks only the Promi A host-aware transport may derive a deployment-owned header such as `X-Harness-Session-Id` from `ctx.agents.requireInitiator().session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam. -This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. +This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) keeps initiator scope limited to private asynchronous chains while lifecycle, ownership, event, and wire interfaces carry their subjects directly. ## Verification diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index a415455761..343acba5f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 -本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。 +本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)把发起方作用域限制在私有异步调用链内,同时让生命周期、归属、事件和协议接口直接携带各自的主体。 ## 验证 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml new file mode 100644 index 0000000000..2e4604118b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +2026-08-31-explicit-agent-runtime-identity.md: b9b97ba71d7f6bb9b3589a2c42cf638908a9bcae +2026-08-31-explicit-agent-runtime-identity.zh.md: e61c27211081192877d8c471002d9fc921678fa4 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md new file mode 100644 index 0000000000..b9b97ba71d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -0,0 +1,47 @@ +# Agent Note: Explicit Agent identity at runtime boundaries + +Status: implemented + +English | [中文](2026-08-31-explicit-agent-runtime-identity.zh.md) + +## Problem + +An Agent's Cordis Context owns registrations and their cleanup. Agent identity instead selects the Session, runtime owner, event subject, authority decision, or wire identity for one operation. A reverse Agent property on Context made those two facts appear interchangeable: a caller could choose a Context for effect ownership and accidentally let that choice determine domain identity. + +The reverse association also required compensating mechanisms after type erasure. Host Remote forwarding inspected a routed subject for its Context, creation inferred runtime parentage from the caller Context, and adapters maintained reverse identity scans. These mechanisms duplicated identity already present in typed requests and obscured which caller owned an Agent at runtime. + +Without an explicit owner, `SubagentContinuationManager` creates and resumes children through its private plugin Context, so Context-based inference classifies every continuable child as a runtime root even though the manager holds its exact parent. Root-only consumers could then attach scheduling tools, grant direct-human goal authority, or route user questions as if the child were top-level. + +## Decision + +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume receive an explicit runtime owner; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. + +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset keeps its existing lifecycle path: the event payload supplies the matching Agent, its Session supplies the policy target, and its Context owns the registrations. + +`SubagentContinuationManager` passes the exact parent to both fresh creation and cold resume. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. + +The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there. + +## Verification + +Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. + +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling, inheritance, and removal of every installed definition on preset unload. + +## Alternatives considered + +**Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. + +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must pass the owner it already knows. + +**Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. + +**Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary. + +**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent. + +## Consequences + +Lifecycle, event, service, and transport signatures carry more explicit Agent parameters, but each boundary states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. + +Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md new file mode 100644 index 0000000000..e61c272110 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 运行时边界显式携带 Agent 身份 + +Status: implemented + +[English](2026-08-31-explicit-agent-runtime-identity.md) | 中文 + +## 问题 + +Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操作选择会话、运行时所属方、事件主体、权限决策或协议身份。Context 上反向的 Agent 属性让这两个事实看起来可以互换:调用方选择用于管理 effect 所有权的 Context 时,可能意外地让该选择决定领域身份。 + +类型信息被擦除后,这项反向关联还需要补偿机制。Host Remote 转发会从已路由主体检查其 Context,创建流程会从调用方 Context 推断运行时父级,适配器则维护反向身份扫描。这些机制重复类型化请求中已有的身份,也掩盖了哪个调用方在运行时拥有 Agent。 + +若没有显式所属方,`SubagentContinuationManager` 会通过私有插件 Context 创建和恢复子级,因此基于 Context 的推断会把每个可续跑子级归类为 runtime root,尽管管理器持有其确切父级。仅限根级的消费方随后可能附加调度工具、授予直接人类输入对应的 Goal 权限,或像处理顶层 Agent 一样路由用户问题。 + +## 决策 + +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;Agent 创建与恢复接收显式运行时所属方;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 + +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 则保留既有生命周期路径:事件 payload 提供匹配 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 + +`SubagentContinuationManager` 会把确切父级同时传给全新创建与冷恢复。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 + +[Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。 + +## 验证 + +Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 + +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载时会移除所有已安装定义。 + +## 考虑过的替代方案 + +**保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 + +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须传入它已经知道的所属方。 + +**分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 + +**使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。 + +**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。 + +## 后果 + +生命周期、事件、服务和传输签名会携带更多显式 Agent 参数,但每个边界都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 + +可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e243871af1..5289c0f182 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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/config-catalog.md -config-catalog.md: 3c3699228bf5d4c0f49f8134d7c342f452de92c9 -config-catalog.zh.md: 0d96acf23a302d7365e6ebdb4aa3fe7cba4af400 +config-catalog.md: cc8e19d5929f98213e89f2a2410296971a21426d +config-catalog.zh.md: 38a3c5d1c13c80f5484cace2cac600cdd66beafb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c3699228b..cc8e19d592 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2961,8 +2961,8 @@ export interface Config { */ toolName?: string /** - * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. + * Sample the Host `subagent-model-selection` setting for each new top-level + * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0d96acf23a..38a3c5d1c1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2963,8 +2963,8 @@ export interface Config { */ toolName?: string /** - * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. + * Sample the Host `subagent-model-selection` setting for each new top-level + * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 325ef7599a..dabffaec01 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.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/subsystems/subagent.md -subagent.md: dfadbbdee28607c929e629a20c18af6b55f4f51e -subagent.zh.md: d4ffe30d69aff404733a6e8acbc2d1a19e2276d3 +subagent.md: 707876b58f1550ce806f909afc0bfa0682ec7586 +subagent.zh.md: 3d18671ad985277f6779fad8075d59762049a3b1 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index dfadbbdee2..707876b58f 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -501,11 +501,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` -Singleton settings owner read by delegation tools when an Agent is published. +Singleton settings owner read when delegation tools are composed for a Session. ```ts cordis-catalog /** - * Read a detached selection preference for the next eligible Agent publication. + * Read a detached selection preference for the next eligible Session composition. * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index d4ffe30d69..3d18671ad9 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -505,11 +505,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` -Singleton settings owner read by delegation tools when an Agent is published. +Singleton settings owner read when delegation tools are composed for a Session. ```ts cordis-catalog /** - * Read a detached selection preference for the next eligible Agent publication. + * Read a detached selection preference for the next eligible Session composition. * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 43991614b9..34d92da39b 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: 7f855bd1cd37c0799f10cadfaf2167fe34f7ea40 -README.zh.md: edad8d735dd914e44bcf283336f78b243e3715d9 +README.md: 8addafac4d99911cb8cfecd806d2626d53901191 +README.zh.md: cbe3e8b53dbe229c9d7bc86360cce363430e8a70 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 7f855bd1cd..8addafac4d 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -40,7 +40,7 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. -The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. ## Build boundary diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index edad8d735d..cbe3e8b53d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -40,7 +40,7 @@ Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 -Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 Agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 ## 构建边界 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 65b3d7fadf..379cafbc97 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2154,12 +2154,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'subagentModelSelection', - summary: 'Singleton settings owner read by delegation tools when an Agent is published.', - description: 'Singleton settings owner read by delegation tools when an Agent is published.', + summary: 'Singleton settings owner read when delegation tools are composed for a Session.', + description: 'Singleton settings owner read when delegation tools are composed for a Session.', methods: [ { signature: 'current(): SubagentModelSelectionSettings', - description: 'Read a detached selection preference for the next eligible Agent publication.', + description: 'Read a detached selection preference for the next eligible Session composition.', parameters: [], returns: 'the enabled state and exact allowed routes.', }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 425c7b9b03..fe8a2a5ea6 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -109,6 +109,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-schedule": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 31b5bdbcb5..544aac177c 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -10,6 +10,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import * as toolSchedule from '@deepseek-ai/dsh-schedule' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -69,7 +70,7 @@ afterEach(async () => { /** Boot the full continuable stack: loop, persistence, providers, and subagents. */ async function setupWith( adapter: LlmAdapter, - options: { persistence?: boolean; sessionQuery?: boolean } = {}, + options: { persistence?: boolean; schedule?: boolean; sessionQuery?: boolean } = {}, ) { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) @@ -89,6 +90,7 @@ async function setupWith( }) } await ctx.plugin(AgentLoop, { agents: [] }) + if (options.schedule) await ctx.plugin(toolSchedule) if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) @@ -754,13 +756,17 @@ describe('continuable child ownership', () => { { chunks: textResponse('child done') }, { chunks: textResponse('grandchild'), gate: releaseGrandchild.promise }, ]) - const { ctx, parent } = await setupWith(adapter) + const { ctx, parent } = await setupWith(adapter, { schedule: true }) const started = await ctx.subagents.startContinuable(startSpec(parent)) const child = await vi.waitFor(() => { const found = ctx.agents.get(started.childId) expect(found).toBeDefined() return found! }) + expect(ctx.agents.roots()).toEqual([parent]) + expect(ctx.agents.isOwnedBy(child.id, parent)).toBe(true) + expect(ctx.tools.get('schedule_create', parent)).toBeDefined() + expect(ctx.tools.get('schedule_create', child)).toBeUndefined() const grandchild = await ctx.subagents.startContinuable(startSpec(child)) await vi.waitFor(() => { diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 660bf0ba4d..e1852f958a 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/README.md -README.md: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3 -README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b +README.md: 899f96ab91642220ecd41a96c9111e2cbed2ef61 +README.zh.md: fb914fcfe3d6c09342c88add0e1f8bb8bcec29c1 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5520e98ddc..899f96ab91 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -44,7 +44,7 @@ Load the subagent service, an in-process or remote backend, and this tool; then |---|---|---| | `provider` | required | Provider name on `ctx.subagents` (e.g. `spawn`, `fork`, `acp`) | | `toolName` | `subagent` | Model-facing tool name; distinct for every loaded instance | -| `modelSelectionSettings` | `false` | Sample the Host's exact-route authorization preference for each new top-level Session; valid only in Agent scope and requires provider `agentOptions` support | +| `modelSelectionSettings` | `false` | Sample the Host's exact-route authorization preference for each top-level Session; a standing preset observes matching Sessions, while direct Agent setup passes its Session explicitly; requires provider `agentOptions` support | | `enableRunInBackground` | `true` | Expose `run_in_background`; disabling also rejects forced background calls | | `backgroundMode` | `one-shot` | Background policy: `one-shot` defaults calls to foreground; `continuable` defaults them to background and requires the provider's `prepareContinuable` capability | | `agentOptions` | — | Configured child `provider`, `model`, adapter-owned `reasoningEffort`, and positive `maxTokens` defaults; requires provider `agentOptions` support and overlays any provider-owned route defaults | @@ -80,7 +80,7 @@ This section explains how the tool mirrors provider lifecycle and settles runs; ### Design concept -One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name. +One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. Direct Agent setup passes its unpublished Session explicitly and awaits installation before publication. A settings-backed standing preset receives each matching Agent from lifecycle events, selects policy from its Session, installs through its Context, and awaits removal of every owned fiber when the preset unloads. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name. ### Foreground settlement diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 069eca4f8c..fb914fcfe3 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -44,7 +44,7 @@ kind: "package-reference" |---|---|---| | `provider` | 必填 | `ctx.subagents` 上的提供方名称(如 `spawn`、`fork`、`acp`) | | `toolName` | `subagent` | 面向模型的工具名称;每个已加载实例必须不同 | -| `modelSelectionSettings` | `false` | 为每个新顶层 Session 读取宿主的精确路由授权偏好;只在 Agent 作用域内有效,并要求提供方支持 `agentOptions` | +| `modelSelectionSettings` | `false` | 为每个顶层 Session 读取宿主的精确路由授权偏好;常驻 preset 观察匹配 Session,直接 Agent setup 则显式传入其 Session;要求提供方支持 `agentOptions` | | `enableRunInBackground` | `true` | 公开 `run_in_background`;禁用时也会拒绝强制后台调用 | | `backgroundMode` | `one-shot` | 后台策略:`one-shot` 默认前台调用;`continuable` 默认后台调用,并要求提供方具备 `prepareContinuable` 能力 | | `agentOptions` | — | 配置的子级 `provider`、`model`、适配器所有的 `reasoningEffort` 与正整数 `maxTokens` 默认值;要求提供方支持 `agentOptions`,并会覆盖提供方持有的路由默认值 | @@ -80,7 +80,7 @@ kind: "package-reference" ### 设计理念 -一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。 +一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。直接 Agent setup 显式传入尚未发布的 Session,并在发布前等待安装完成。由设置控制的常驻 preset 从生命周期事件接收每个匹配 Agent,从其 Session 选择策略,通过其 Context 安装,并在 preset 卸载时等待移除所有归它所有的 fiber。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。 ### 前台结算 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index d5e08f7bbd..1a17dc6a70 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -15,7 +15,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { JsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session } from '@deepseek-ai/dsh-session' import { assertSubagentMaxDepth, parentAgentOptionsForDelegation, @@ -56,8 +56,8 @@ export interface Config { */ toolName?: string /** - * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. + * Sample the Host `subagent-model-selection` setting for each new top-level + * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** @@ -306,7 +306,13 @@ function resolveDelegationRun( } } -export function apply(ctx: Context, config: Config): void { +/** + * Install one delegation-tool composition. + * @param ctx - Context that owns the registrations. + * @param config - delegation-tool configuration. + * @param session - unpublished Session supplied by a direct Agent setup; omit for a standing composition. + */ +export function apply(ctx: Context, config: Config, session?: Session): void { // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply // omission stays capless (the schema default only runs through the loader). if (config.maxDepth !== 'provider-managed') assertSubagentMaxDepth(config.maxDepth) @@ -613,83 +619,104 @@ export function apply(ctx: Context, config: Config): void { + '@deepseek-ai/dsh-tool-subagent/model-selection-settings in the Host scope', ) } - const compositionScope = scopeOf(ctx) - if (compositionScope === undefined) { - throw new Error('tool-subagent: `modelSelectionSettings` requires an Agent or preset scope') - } - - const selectForAgent = (agent: Agent): ModelSelectionPolicy | undefined => { - let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, agent.session) + const selectForSession = (target: Session): ModelSelectionPolicy | undefined => { + let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target) if (allowedModels === undefined) { - const parentId = agent.session.header.origin === 'subagent' - ? agent.session.header.parentSession + const parentId = target.header.origin === 'subagent' + ? target.header.parentSession : undefined if (parentId !== undefined) { - const parent = ctx.get('agents')?.get(parentId) + const sessions = ctx.get('sessions') + if (sessions === undefined) { + throw new Error('tool-subagent: child model-selection inheritance requires the Session registry') + } + const parent = sessions.get(parentId) allowedModels = parent === undefined ? undefined - : subagentModelSelectionPolicy(ctx.sessionProjections, parent.session) - } else if (agent.session.firstLiveSeq === 0) { + : subagentModelSelectionPolicy(ctx.sessionProjections, parent) + } else if (target.firstLiveSeq === 0) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } } if (allowedModels !== undefined) { - recordSubagentModelSelection(ctx.sessionProjections, agent.session, allowedModels) + recordSubagentModelSelection(ctx.sessionProjections, target, allowedModels) } return allowedModels === undefined ? undefined : { routes: allowedModels } } + if (session !== undefined) { + install(ctx, selectForSession(session)) + return + } + + const compositionScope = scopeOf(ctx) + if (compositionScope === undefined) { + throw new Error('tool-subagent: standing `modelSelectionSettings` requires a scoped preset Context') + } const agents = ctx.get('agents') - /* v8 ignore next -- Agent and preset scopes are minted only by the Agent registry. */ - if (agents === undefined) throw new Error('tool-subagent: scoped model-selection settings require the Agent registry') - const scopedInstalls = new WeakMap>() + /* v8 ignore next -- shipped preset compositions always include the Agent registry. */ + if (agents === undefined) throw new Error('tool-subagent: standing `modelSelectionSettings` requires the Agent registry') + const presetInstalls = new Map>() const installing = new WeakSet() + let stopping = false const belongsToComposition = (candidate: Agent): boolean => scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope) - const installScoped = (candidate: Agent): void => { - if (scopedInstalls.has(candidate) || installing.has(candidate)) return + const installPresetDefinition = (candidate: Agent): void => { + if (stopping || presetInstalls.has(candidate) || installing.has(candidate)) return // Reserve before the injected fiber runs: tool registration emits // `tools/change` synchronously, which re-enters the reconciliation below. installing.add(candidate) let fiber: ReturnType try { - const policy = selectForAgent(candidate) + const policy = selectForSession(candidate.session) fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { install(runtimeCtx, policy) }) } finally { installing.delete(candidate) } - scopedInstalls.set(candidate, fiber) + presetInstalls.set(candidate, fiber) } - const removeScoped = (candidate: Agent): void => { - const fiber = scopedInstalls.get(candidate) + const removePresetDefinition = (candidate: Agent): void => { + const fiber = presetInstalls.get(candidate) if (fiber === undefined) return - scopedInstalls.delete(candidate) + presetInstalls.delete(candidate) /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */ void fiber.dispose().catch((error: unknown) => { ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`) }) } - const reconcileComposedAgents = (): void => { - // Every Agent and preset scope is minted by the Agent registry; the scope - // check above makes this same-process typed relationship authoritative. + ctx.effect(() => async () => { + stopping = true + const fibers = [...presetInstalls.values()] + presetInstalls.clear() + const outcomes = await Promise.allSettled(fibers.map(fiber => fiber.dispose())) + const failures: unknown[] = [] + for (const outcome of outcomes) { + if (outcome.status === 'rejected') failures.push(outcome.reason as unknown) + } + if (failures.length === 1) throw failures[0] + if (failures.length > 1) { + throw new AggregateError(failures, 'tool-subagent: failed to remove standing-preset definitions') + } + }) + const reconcilePresetDefinitions = (): void => { for (const candidate of agents.list()) { - if (belongsToComposition(candidate)) installScoped(candidate) - else removeScoped(candidate) + if (belongsToComposition(candidate)) installPresetDefinition(candidate) + else removePresetDefinition(candidate) } } - // The scoped listener admits this Agent or preset's descendant Agents and - // installs the sampled tool definition in each Agent's own scope, so a later - // settings change cannot mutate a live session. + // The preset-scoped listener admits descendant Agents and installs the + // sampled tool definition in each Agent's own scope, so a later settings + // change cannot mutate a live session. ctx.on('agent/created', ({ agent: created }) => { - installScoped(created) + installPresetDefinition(created) }) - ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) }) + ctx.on('agent/disposed', ({ agent: disposed }) => { removePresetDefinition(disposed) }) // Reparenting an Agent between standing presets changes its inherited tool // set and emits `tools/change`; reconcile the Agent-owned override with the // new ancestry. Other registry changes are idempotent no-ops here. - ctx.on('tools/change', reconcileComposedAgents) - reconcileComposedAgents() + ctx.on('tools/change', reconcilePresetDefinitions) + reconcilePresetDefinitions() } diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index 74f59a32b4..d5845d09df 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -11,7 +11,7 @@ import { declare module '@deepseek-ai/cordis' { interface Context { - /** User preference sampled when a new Agent receives its delegation tools. */ + /** User preference sampled when a new Session receives delegation tools. */ subagentModelSelection: SubagentModelSelectionConfig } } @@ -41,7 +41,7 @@ export interface Config { allowedModels?: AllowedModelRoute[] } -/** Singleton settings owner read by delegation tools when an Agent is published. */ +/** Singleton settings owner read when delegation tools are composed for a Session. */ export class SubagentModelSelectionConfig extends Service { static Config: z = z.object({ enabled: z.boolean().default(false), @@ -68,15 +68,15 @@ export class SubagentModelSelectionConfig extends Service { { setSource: (source) => { this.source = source }, validate: (value) => { this.validate(value) }, - // Consumers sample at Agent publication, so a settings update never - // rebuilds the tool definitions of an Agent that is already running. + // Consumers snapshot per Session, so a settings update never rebuilds + // the tool definitions of a Session that is already running. onChange: () => {}, }, ) } /** - * Read a detached selection preference for the next eligible Agent publication. + * Read a detached selection preference for the next eligible Session composition. * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings { diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index acd1b02c7c..0437483185 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -58,8 +58,11 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial { - await agentCtx.plugin(tool, { ...config, modelSelectionSettings: true }) + setup: async (agentCtx, agent) => { + const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => { + tool.apply(runtimeCtx, { ...config, modelSelectionSettings: true }, agent.session) + }) + await fiber.await() }, }) setupAgents.set(ctx, handle.agent) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 5282ee0a63..d82660c103 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -55,8 +55,10 @@ function selectable(ctx: Context, agent: Awaited candidate.name === 'list_subagent_models') } -/** Mount the real settings, Agent, provider, and tool services. */ -async function boot(): Promise { +const modelSelectionPresets = new WeakMap>() + +/** Mount the real settings, Agent, provider, and optional preset tool services. */ +async function boot(withPreset = true): Promise { const ctx = new Context() await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) @@ -65,23 +67,30 @@ async function boot(): Promise { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + if (withPreset) { + const preset = createScope(ctx, { preset: 'model-selection-test' }) + await preset.ctx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + modelSelectionPresets.set(ctx, preset) + } return ctx } -/** Create one Agent whose setup mounts the settings-controlled tool preset row. */ +/** Create one Agent joined to the test's standing preset. */ async function createAgent(ctx: Context, id: string, options: { meta?: { parentSession: SessionId; origin: 'subagent' } seed?: readonly SessionEvent[] } = {}) { + const preset = modelSelectionPresets.get(ctx) + if (preset === undefined) throw new Error('context has no model-selection preset') const handle = await ctx.agents.create({ sessionId: SessionId(id), ...options, - setup: async (agentCtx) => { - await agentCtx.plugin(tool, { - provider: 'spawn', - modelSelectionSettings: true, - backgroundMode: 'continuable', - }) + setup: (agentCtx) => { + bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) }, }) return handle.agent @@ -146,7 +155,7 @@ describe('SubagentModelSelectionConfig', () => { await ctx.fiber.dispose() }) - it('samples each new root session without changing existing Agents', async () => { + it('samples each new root Session without changing existing definitions', async () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(false) @@ -168,6 +177,39 @@ describe('SubagentModelSelectionConfig', () => { await ctx.fiber.dispose() }) + it('installs a direct Agent setup before Session publication', async () => { + const ctx = await boot(false) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) + let prepared: Awaited>['agent'] | undefined + let visibleAtSessionCreated = false + ctx.on('session/created', () => { + visibleAtSessionCreated = prepared !== undefined && selectable(ctx, prepared) + }) + + const handle = await ctx.agents.create({ + sessionId: SessionId('direct-agent-setup'), + setup: async (agentCtx, agent) => { + prepared = agent + const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => { + tool.apply(runtimeCtx, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }, agent.session) + }) + await fiber.await() + }, + }) + + expect(visibleAtSessionCreated).toBe(true) + expect(selectable(ctx, handle.agent)).toBe(true) + await handle.dispose() + await ctx.fiber.dispose() + }) + it('rejects a forced route outside the Session policy before child creation', async () => { const ctx = await boot() await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { @@ -195,12 +237,12 @@ describe('SubagentModelSelectionConfig', () => { }) it('installs per-Agent definitions for a shared preset scope', async () => { - const ctx = await boot() + const ctx = await boot(false) await ctx.plugin(InvariantRegistry, { enabled: true }) await ctx.plugin(ToolInvariant) const preset = createScope(ctx, { preset: 'standard' }) const other = createScope(ctx, { preset: 'minimal' }) - await preset.ctx.plugin(tool, { + const mounted = await preset.ctx.plugin(tool, { provider: 'spawn', modelSelectionSettings: true, backgroundMode: 'continuable', @@ -245,6 +287,10 @@ describe('SubagentModelSelectionConfig', () => { await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) + await mounted.dispose() + expect(selectable(ctx, enabled.agent)).toBe(false) + expect(selectable(ctx, disabled.agent)).toBe(false) + await enabled.dispose() ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') await disabled.dispose() @@ -252,7 +298,7 @@ describe('SubagentModelSelectionConfig', () => { }) it('releases a shared-preset installation reservation after policy selection fails', async () => { - const ctx = await boot() + const ctx = await boot(false) const preset = createScope(ctx, { preset: 'standard' }) const other = createScope(ctx, { preset: 'minimal' }) await preset.ctx.plugin(tool, { @@ -320,7 +366,7 @@ describe('SubagentModelSelectionConfig', () => { await ctx.fiber.dispose() }) - it('requires both the Host setting owner and a composition scope', async () => { + it('requires both the Host setting owner and a scoped standing preset', async () => { const withoutSettings = new Context() await mountAgentLoopTestDependencies(withoutSettings) await withoutSettings.plugin(SessionProjectionRegistry) @@ -334,14 +380,15 @@ describe('SubagentModelSelectionConfig', () => { }).toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings') await withoutSettings.fiber.dispose() - const withoutAgent = await boot() + const withoutAgent = await boot(false) expect(() => { tool.apply(withoutAgent, { provider: 'spawn', modelSelectionSettings: true, backgroundMode: 'continuable', }) - }).toThrow('requires an Agent or preset scope') + }).toThrow('requires a scoped preset Context') + await withoutAgent.fiber.dispose() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdb322b52d..9473887843 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8520,6 +8520,9 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy + '@deepseek-ai/dsh-schedule': + specifier: workspace:^ + version: link:../../schedule/schedule '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope From 8292ea45ba367b362bf275dc07e0226c6e08aad7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 2 Sep 2026 13:58:11 +0800 Subject: [PATCH 04/15] fix(tool-subagent): simplify standing preset cleanup --- ...-explicit-agent-runtime-identity.i18n.yaml | 4 +- ...6-08-31-explicit-agent-runtime-identity.md | 4 +- ...8-31-explicit-agent-runtime-identity.zh.md | 4 +- packages/subagent/tool-subagent/src/index.ts | 45 +++++++------------ .../tests/model-selection-settings.spec.ts | 24 +++++++++- 5 files changed, 44 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index 2e4604118b..362c3f46e3 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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/architecture/2026-08-31-explicit-agent-runtime-identity.md -2026-08-31-explicit-agent-runtime-identity.md: b9b97ba71d7f6bb9b3589a2c42cf638908a9bcae -2026-08-31-explicit-agent-runtime-identity.zh.md: e61c27211081192877d8c471002d9fc921678fa4 +2026-08-31-explicit-agent-runtime-identity.md: 9177688866bf6b0c9c4a50f2b9169d4bc6a66c65 +2026-08-31-explicit-agent-runtime-identity.zh.md: 0cede058cb19c7f6a55d49d58e40c795f8c32a33 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index b9b97ba71d..9177688866 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -16,7 +16,7 @@ Without an explicit owner, `SubagentContinuationManager` creates and resumes chi Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume receive an explicit runtime owner; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. -Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset keeps its existing lifecycle path: the event payload supplies the matching Agent, its Session supplies the policy target, and its Context owns the registrations. +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset reserves one Cordis cleanup effect for each matching Agent before sampling policy: the event payload supplies the Agent, its Session supplies the policy target, its Context owns the registrations, and the preset effect joins their removal after reparenting or preset unload. `SubagentContinuationManager` passes the exact parent to both fresh creation and cold resume. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. @@ -26,7 +26,7 @@ The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. -Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling, inheritance, and removal of every installed definition on preset unload. +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling, inheritance, and removal of every installed definition before preset unload settles, including cleanup already started by reparenting. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index e61c272110..0cede058cb 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -16,7 +16,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;Agent 创建与恢复接收显式运行时所属方;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 -感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 则保留既有生命周期路径:事件 payload 提供匹配 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 在读取策略前,会为每个匹配 Agent 预留一个 Cordis 清理 effect:事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项,而 preset effect 会在重设父级或 preset 卸载后等待其清理完成。 `SubagentContinuationManager` 会把确切父级同时传给全新创建与冷恢复。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 @@ -26,7 +26,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 -Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载时会移除所有已安装定义。 +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级时已启动的清理。 ## 考虑过的替代方案 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index a1780e04cd..2e41cb7ed2 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-tool-subagent */ -import type { Context } from '@deepseek-ai/cordis' +import { FiberState, type Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -657,50 +657,37 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const agents = ctx.get('agents') /* v8 ignore next -- shipped preset compositions always include the Agent registry. */ if (agents === undefined) throw new Error('tool-subagent: standing `modelSelectionSettings` requires the Agent registry') - const presetInstalls = new Map>() - const installing = new WeakSet() - let stopping = false + const presetInstalls = new Map Promise>() const belongsToComposition = (candidate: Agent): boolean => scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope) const installPresetDefinition = (candidate: Agent): void => { - if (stopping || presetInstalls.has(candidate) || installing.has(candidate)) return - // Reserve before the injected fiber runs: tool registration emits - // `tools/change` synchronously, which re-enters the reconciliation below. - installing.add(candidate) - let fiber: ReturnType + if (ctx.fiber.uid === null || ctx.fiber.state === FiberState.UNLOADING || presetInstalls.has(candidate)) return + let fiber: ReturnType | undefined + const dispose = ctx.effect(() => async () => { + if (fiber !== undefined) await fiber.dispose() + }, `tool-subagent: standing-preset definitions for Agent "${candidate.id}"`) + // Reserve before policy sampling or injection can re-enter reconciliation. + presetInstalls.set(candidate, dispose) try { const policy = selectForSession(candidate.session) fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { install(runtimeCtx, policy) }) - } finally { - installing.delete(candidate) + } catch (error) { + presetInstalls.delete(candidate) + void dispose() + throw error } - presetInstalls.set(candidate, fiber) } const removePresetDefinition = (candidate: Agent): void => { - const fiber = presetInstalls.get(candidate) - if (fiber === undefined) return + const dispose = presetInstalls.get(candidate) + if (dispose === undefined) return presetInstalls.delete(candidate) /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */ - void fiber.dispose().catch((error: unknown) => { + void dispose().catch((error: unknown) => { ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`) }) } - ctx.effect(() => async () => { - stopping = true - const fibers = [...presetInstalls.values()] - presetInstalls.clear() - const outcomes = await Promise.allSettled(fibers.map(fiber => fiber.dispose())) - const failures: unknown[] = [] - for (const outcome of outcomes) { - if (outcome.status === 'rejected') failures.push(outcome.reason as unknown) - } - if (failures.length === 1) throw failures[0] - if (failures.length > 1) { - throw new AggregateError(failures, 'tool-subagent: failed to remove standing-preset definitions') - } - }) const reconcilePresetDefinitions = (): void => { for (const candidate of agents.list()) { if (belongsToComposition(candidate)) installPresetDefinition(candidate) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index b7d7bcb0cc..7b4c854b07 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -242,6 +242,18 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(ToolInvariant) const preset = createScope(ctx, { preset: 'standard' }) const other = createScope(ctx, { preset: 'minimal' }) + const cleanupGate = Promise.withResolvers() + const cleanupStarted = Promise.withResolvers() + const cleanup = { done: false, blockedParent: undefined as Context | undefined } + ctx.on('internal/plugin', (fiber) => { + if (Object.keys(fiber.inject).sort().join(',') !== 'subagents,systemPrompt,tools') return + fiber.ctx.effect(() => async () => { + if (fiber.parent !== cleanup.blockedParent) return + cleanupStarted.resolve(undefined) + await cleanupGate.promise + cleanup.done = true + }, 'tool-subagent test: delayed preset cleanup') + }) const mounted = await preset.ctx.plugin(tool, { provider: 'spawn', modelSelectionSettings: true, @@ -287,11 +299,19 @@ describe('SubagentModelSelectionConfig', () => { await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) - await mounted.dispose() + cleanup.blockedParent = enabled.agent.ctx + enabledBinding!.rebind(scopeOf(other.ctx)!) + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await cleanupStarted.promise + const unloading = mounted.dispose() + setImmediate(() => { cleanupGate.resolve(undefined) }) + await unloading + const cleanupFinishedAtUnload = cleanup.done + await enabled.dispose() + expect(cleanupFinishedAtUnload).toBe(true) expect(selectable(ctx, enabled.agent)).toBe(false) expect(selectable(ctx, disabled.agent)).toBe(false) - await enabled.dispose() ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') await disabled.dispose() await ctx.fiber.dispose() From 600dff4ca4155920923b4a0b7c4a04c2cd4a1dcc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 2 Sep 2026 14:34:10 +0800 Subject: [PATCH 05/15] test(tool-subagent): cover missing session registry --- .../tests/model-selection-settings.spec.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 7b4c854b07..eb7ccd36c8 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -416,6 +416,34 @@ describe('SubagentModelSelectionConfig', () => { await withoutAgent.fiber.dispose() }) + it('requires the Session registry when a child inherits its parent policy', async () => { + const ctx = new Context() + try { + await ctx.plugin(SubagentModelSelectionConfig) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SubagentRuntime) + const childId = SessionId('child-without-session-registry') + const child = Session.create(childId, undefined, { + version: 0, + id: childId, + createdAt: 1, + isSeeded: false, + origin: 'subagent', + parentSession: SessionId('missing-parent'), + }) + + expect(() => { + tool.apply(ctx, { + provider: 'missing', + modelSelectionSettings: true, + maxDepth: 'provider-managed', + }, child) + }).toThrow('child model-selection inheritance requires the Session registry') + } finally { + await ctx.fiber.dispose() + } + }) + it('checks model-selectable definitions without rejecting a policy-only preset', async () => { const ctx = await boot() await ctx.plugin(InvariantRegistry, { enabled: true }) From 0b8956aab9d5caab935249d06cdfec9755c84ea6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 2 Sep 2026 17:56:01 +0800 Subject: [PATCH 06/15] refactor(agent): move runtime parent into options --- ...07-12-agent-scope-runtime-design.i18n.yaml | 4 +-- .../2026-07-12-agent-scope-runtime-design.md | 2 +- ...026-07-12-agent-scope-runtime-design.zh.md | 2 +- ...-explicit-agent-runtime-identity.i18n.yaml | 4 +-- ...6-08-31-explicit-agent-runtime-identity.md | 8 ++--- ...8-31-explicit-agent-runtime-identity.zh.md | 8 ++--- docs/subsystems/core.i18n.yaml | 4 +-- docs/subsystems/core.md | 30 +++++++--------- docs/subsystems/core.zh.md | 30 +++++++--------- .../tests/compaction-loop-repro.spec.ts | 4 +-- packages/core/agent-loop/README.i18n.yaml | 4 +-- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/index.ts | 31 ++++++++-------- .../agent-loop/tests/agent-initiator.spec.ts | 3 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 5 +-- packages/core/agent/README.i18n.yaml | 4 +-- packages/core/agent/README.md | 2 +- packages/core/agent/README.zh.md | 2 +- packages/core/agent/src/index.ts | 36 +++++++++---------- packages/core/agent/tests/agent.spec.ts | 22 ++++++------ .../extensions/tool-cordis/src/api-catalog.ts | 28 +++++++-------- .../schedule/schedule/tests/plugin.spec.ts | 5 ++- .../server/tests/built-scope-carrier.e2e.ts | 3 +- packages/sdk/server/tests/server.spec.ts | 24 ++++++++----- .../subagent-in-process-driver/src/index.ts | 3 +- .../subagent/subagent/src/continuation.ts | 6 ++-- 27 files changed, 142 insertions(+), 136 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 2559a04520..fc252d0072 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.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/architecture/2026-07-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: 74780e075795ede44016c451d229b5a5c3fff23a -2026-07-12-agent-scope-runtime-design.zh.md: e3c9f050eabcc259fa2940e06c6d83e679433525 +2026-07-12-agent-scope-runtime-design.md: bb8ef6ca2257c3bf23a11df69e63a5cf94255992 +2026-07-12-agent-scope-runtime-design.zh.md: 8862f7bc3b0be1fc8c8539fd34a5d6dc32b269af diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 74780e0757..bb8ef6ca22 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -102,7 +102,7 @@ The transaction is installed under both the calling Cordis context and the concr Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm. -The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator passes the parent Agent explicitly, and AgentRegistry forwards it separately from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) owns this separation and the continuable-child ownership rule that follows from it. +The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator sets `parentAgent` in the create or resume options, and AgentRegistry forwards those options without deriving a parent from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) owns this separation and the continuable-child ownership rule that follows from it. ### Setup is trusted composition inside a private world diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index e3c9f050ea..8862f7bc3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -104,7 +104,7 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册 创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。 -工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方显式传入父 Agent,AgentRegistry 再将其与调用方 Context 分开转交。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)拥有这项分离原则及由此确定的可续跑子级归属规则。 +工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方在 create 或 resume options 中设置 `parentAgent`,AgentRegistry 转交这些 options,不从调用方 Context 推导父级。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)拥有这项分离原则及由此确定的可续跑子级归属规则。 ### Setup 是私有世界内的可信组合 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index 362c3f46e3..e8dff7067a 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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/architecture/2026-08-31-explicit-agent-runtime-identity.md -2026-08-31-explicit-agent-runtime-identity.md: 9177688866bf6b0c9c4a50f2b9169d4bc6a66c65 -2026-08-31-explicit-agent-runtime-identity.zh.md: 0cede058cb19c7f6a55d49d58e40c795f8c32a33 +2026-08-31-explicit-agent-runtime-identity.md: 6f0fafa243ee6843981bbb78af626988c4131f80 +2026-08-31-explicit-agent-runtime-identity.zh.md: 703ccd0384003c30e4c402e96e6a04083e473024 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 9177688866..6f0fafa243 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -14,11 +14,11 @@ Without an explicit owner, `SubagentContinuationManager` creates and resumes chi ## Decision -Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume receive an explicit runtime owner; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset reserves one Cordis cleanup effect for each matching Agent before sampling policy: the event payload supplies the Agent, its Session supplies the policy target, its Context owns the registrations, and the preset effect joins their removal after reparenting or preset unload. -`SubagentContinuationManager` passes the exact parent to both fresh creation and cold resume. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. +`SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there. @@ -32,7 +32,7 @@ Remote-event tests reject a missing or mismatched Agent before forwarding a scop **Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. -**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must pass the owner it already knows. +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options. **Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. @@ -42,6 +42,6 @@ Remote-event tests reject a missing or mismatched Agent before forwarding a scop ## Consequences -Lifecycle, event, service, and transport signatures carry more explicit Agent parameters, but each boundary states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. +Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index 0cede058cb..703ccd0384 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -14,11 +14,11 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 ## 决策 -运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;Agent 创建与恢复接收显式运行时所属方;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 在读取策略前,会为每个匹配 Agent 预留一个 Cordis 清理 effect:事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项,而 preset effect 会在重设父级或 preset 卸载后等待其清理完成。 -`SubagentContinuationManager` 会把确切父级同时传给全新创建与冷恢复。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 +`SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 [Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。 @@ -32,7 +32,7 @@ Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配 **保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 -**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须传入它已经知道的所属方。 +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。 **分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 @@ -42,6 +42,6 @@ Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配 ## 后果 -生命周期、事件、服务和传输签名会携带更多显式 Agent 参数,但每个边界都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 +生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index be927e0d4b..0ae5f0b7f6 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/subsystems/core.md -core.md: 67831787fd2223b9508111805266853526be4d19 -core.zh.md: c46d49d40a5b1ce8d87d843697ed4ed969c8d6e5 +core.md: c88d5dad32a4425c078867bfbb922796d5141b6a +core.zh.md: da72ca3f73adb9ee5404ef4f0188401d82236f59 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 67831787fd..c88d5dad32 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -46,9 +46,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` while both ids are still unpublished: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. +`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: an optional live `parentAgent`, session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `parentAgent`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` while both ids are still unpublished: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. -`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator passes its parent Agent explicitly; the registry passes that value separately from the caller Context to the factory. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator sets `options.parentAgent`; the registry passes the options and caller Context to the factory without deriving one from the other. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -368,20 +368,18 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick +async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** * Resume an owned agent from the configured persistence service. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. - * @param options - persisted identity, loop options, setup, and cancellation. - * @param owner - live Agent that owns the resumed Agent at runtime, or undefined for a root owner. + * @param options - persisted identity, optional live parent, loop options, setup, and cancellation. * @returns the published handle. */ -async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise +async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` Types: [SessionHeader](persistence.md) @@ -709,21 +707,19 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, session seed/metadata, and agent options. - * @param owner - explicit live runtime owner, or undefined for a root Agent. + * @param options - shared identity, optional live parent, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async create(options: CreateAgentOptions, owner?: Agent): Promise +async create(options: CreateAgentOptions): Promise /** * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, configuration, and optional setup. - * @param owner - explicit live runtime owner, or undefined for a root Agent. + * @param options - persisted identity, optional live parent, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async resume(options: ResumeAgentOptions, owner?: Agent): Promise +async resume(options: ResumeAgentOptions): Promise /** * Register a live agent. Throws if an agent with the same id is already @@ -732,9 +728,9 @@ async resume(options: ResumeAgentOptions, owner?: Agent): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * requires passing the carrier). The entry is a runtime root; factory-backed + * creation uses `options.parentAgent` for child ownership. Returns the disposer. * @param agent - the already-constructed agent to record in the store. - * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact * identity is load-bearing: a composite (generator) effect that owns a @@ -744,7 +740,7 @@ async resume(options: ResumeAgentOptions, owner?: Agent): Promise * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ -register(agent: Agent, owner?: Agent): () => void +register(agent: Agent): () => void /** * Insert an already-constructed agent without announcing it. This is the diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index c46d49d40a..da72ca3f73 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -48,9 +48,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 均未发布时接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 +`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:可选的存活 `parentAgent`、会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`parentAgent`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 均未发布时接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 -`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方显式传入父 Agent;注册表将该值与调用方 Context 分开传给工厂。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方设置 `options.parentAgent`;注册表把 options 与调用方 Context 传给工厂,不从其中一项推导另一项。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 @@ -378,20 +378,18 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick +async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** * Resume an owned agent from the configured persistence service. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. - * @param options - persisted identity, loop options, setup, and cancellation. - * @param owner - live Agent that owns the resumed Agent at runtime, or undefined for a root owner. + * @param options - persisted identity, optional live parent, loop options, setup, and cancellation. * @returns the published handle. */ -async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise +async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` Types: [SessionHeader](persistence.zh.md) @@ -719,21 +717,19 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, session seed/metadata, and agent options. - * @param owner - explicit live runtime owner, or undefined for a root Agent. + * @param options - shared identity, optional live parent, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async create(options: CreateAgentOptions, owner?: Agent): Promise +async create(options: CreateAgentOptions): Promise /** * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, configuration, and optional setup. - * @param owner - explicit live runtime owner, or undefined for a root Agent. + * @param options - persisted identity, optional live parent, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ -async resume(options: ResumeAgentOptions, owner?: Agent): Promise +async resume(options: ResumeAgentOptions): Promise /** * Register a live agent. Throws if an agent with the same id is already @@ -742,9 +738,9 @@ async resume(options: ResumeAgentOptions, owner?: Agent): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * requires passing the carrier). The entry is a runtime root; factory-backed + * creation uses `options.parentAgent` for child ownership. Returns the disposer. * @param agent - the already-constructed agent to record in the store. - * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact * identity is load-bearing: a composite (generator) effect that owns a @@ -754,7 +750,7 @@ async resume(options: ResumeAgentOptions, owner?: Agent): Promise * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ -register(agent: Agent, owner?: Agent): () => void +register(agent: Agent): () => void /** * Insert an already-constructed agent without announcing it. This is the diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 2e9965359d..c695031a0d 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -339,7 +339,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', provider: 'unconfigured-agent-fallback', model: 'unconfigured-agent-fallback', }, - }, undefined) + }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })) await agent.whenIdle() @@ -413,7 +413,7 @@ describe('context-overflow recovery across the real loop and compaction-basic', sessionId: SessionId('alternating-recovery'), seed: overflowHistorySeed(), agentOptions: { provider: 'mock', model: 'mock' }, - }, undefined) + }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })) await agent.whenIdle() diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 7c810392b0..4b76ed303e 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: bf5136159782c9e880871565bf7bce5730c4b575 -README.zh.md: 66dfa2fbc66ae58c2984218561fc294697b3230d +README.md: d646f5ad8a3d12feb4942026a288cbc89d2995c2 +README.zh.md: 6ad383671fce1aab60d40c6d49116cc7f7931ed4 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index bf51361597..d646f5ad8a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -103,7 +103,7 @@ After `agent/request`, `ctx.llm.prepareCall()` validates adapter-owned fields an ### Creation and teardown -Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup with the context and Agent passed separately; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A caller creating a runtime child passes the parent Agent explicitly; the caller Context separately owns the transaction and live handle. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, closes the session's write path, unwinds the scope, detaches the agent, then detaches the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement. +Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup with the context and Agent passed separately; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; only then start the driver. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. A setup throw, commit failure, or owner disposal rolls the transaction back without publishing either id. Teardown runs stop-and-drain, closes the session's write path, unwinds the scope, detaches the agent, then detaches the session, and every detach is bound to the exact entered object so a stale disposer cannot remove a later same-id replacement. ### Persistence integration diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 66dfa2fbc6..6ad383671f 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -103,7 +103,7 @@ const handle = await ctx.agents.create({ ### 创建与拆除 -创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待分别传入上下文与 Agent 的可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。创建运行时子 Agent 的调用方显式传入父 Agent;调用方 Context 则单独拥有事务和存活 handle。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、关闭会话的写路径、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 +创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待分别传入上下文与 Agent 的可选 setup;进入两个注册表;依次宣告 `session/created` 与 `agent/created`;发出 `agent/session-start`;此后才启动驱动器。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活 handle。Setup 抛出、commit 失败或所有者 dispose 都会回滚事务而不发布任一 id。Teardown 顺序是停止并排空、关闭会话的写路径、撤销作用域、detach agent、再 detach 会话,且每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 ### 持久化集成 diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 5bebbbd3be..764344bb45 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -445,7 +445,7 @@ export class AgentLoop extends Service implements AgentFactory { void this.resumeWith(ctx, childCtx.sessionPersistence, { resumeSessionId, agentOptions: options, - }, undefined).catch((error: unknown) => { + }).catch((error: unknown) => { this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error) }) }) @@ -487,7 +487,7 @@ export class AgentLoop extends Service implements AgentFactory { await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId) if (!this.ownership.isActive()) return try { - await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }, undefined) + await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }) return } catch (error: unknown) { if (!this.ownership.isActive()) return @@ -529,12 +529,12 @@ export class AgentLoop extends Service implements AgentFactory { */ private prepare( ownerCtx: Context, - owner: Agent | undefined, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal, handle?: SessionHandle, + parentAgent?: Agent, ): PreparedAgent { assertAgentOptions(options) ownerCtx.fiber.assertActive() @@ -655,7 +655,7 @@ export class AgentLoop extends Service implements AgentFactory { detachSession = agent.ctx.sessions.enter(session) // The mounted backend routes announced live events into the active // write handle by session id; the loop only owns the handle itself. - detachAgent = loopCtx.agents.enter(agent, owner) + detachAgent = loopCtx.agents.enter(agent, parentAgent) agent.ctx.sessions.announce(session) assertLive() loopCtx.agents.announce(agent) @@ -692,7 +692,7 @@ export class AgentLoop extends Service implements AgentFactory { const stored = await this.createStoredSession(preparation.session) let prepared: PreparedAgent try { - prepared = this.prepare(this.ctx, undefined, id, options, preparation.session, undefined, stored?.handle) + prepared = this.prepare(this.ctx, id, options, preparation.session, undefined, stored?.handle) } catch (error: unknown) { await stored?.handle.close().catch(() => {}) throw error @@ -749,11 +749,10 @@ export class AgentLoop extends Service implements AgentFactory { /** * Create an owned agent on a caller-supplied session id. * @param ownerCtx - caller context that structurally owns the lifecycle. - * @param options - identities, session seed/metadata, loop options, setup, and cancellation. - * @param owner - live Agent that owns the new Agent at runtime, or undefined for a root owner. + * @param options - identities, optional live parent, session seed/metadata, loop options, setup, and cancellation. * @returns the published handle. */ - async createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise { + async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise { const preparation = SessionPreparation.create(this.runtime.ctx.sessions.prepare(options.sessionId, { ...options.seed === undefined ? {} : { seed: options.seed }, ...options.meta === undefined ? {} : { meta: options.meta }, @@ -778,7 +777,6 @@ export class AgentLoop extends Service implements AgentFactory { } return this.setupAndPublish( ownerCtx, - owner, options.sessionId, preparation, options.agentOptions ?? {}, @@ -786,6 +784,7 @@ export class AgentLoop extends Service implements AgentFactory { options.signal, 'startup', stored, + options.parentAgent, ) })() this.ownership.trackWrapper(published) @@ -795,7 +794,6 @@ export class AgentLoop extends Service implements AgentFactory { /** Prepare one Agent around an acquired Session, run setup, and publish it. */ private async setupAndPublish( ownerCtx: Context, - owner: Agent | undefined, id: SessionId, preparation: SessionPreparation, agentOptions: AgentOptions, @@ -803,12 +801,13 @@ export class AgentLoop extends Service implements AgentFactory { signal: AbortSignal | undefined, source: SessionStartSource, stored?: StoredSession, + parentAgent?: Agent, ): Promise { using ownedPreparation = preparation const session = ownedPreparation.session let prepared: PreparedAgent try { - prepared = this.prepare(ownerCtx, owner, id, agentOptions, session, signal, stored?.handle) + prepared = this.prepare(ownerCtx, id, agentOptions, session, signal, stored?.handle, parentAgent) } catch (error: unknown) { await stored?.handle.close().catch(() => {}) throw error @@ -829,16 +828,15 @@ export class AgentLoop extends Service implements AgentFactory { /** * Resume an owned agent from the configured persistence service. * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. - * @param options - persisted identity, loop options, setup, and cancellation. - * @param owner - live Agent that owns the resumed Agent at runtime, or undefined for a root owner. + * @param options - persisted identity, optional live parent, loop options, setup, and cancellation. * @returns the published handle. */ - async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise { + async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise { const persistence = this.runtime.ctx.get('sessionPersistence') if (persistence === undefined) { throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)') } - return this.resumeWith(ownerCtx, persistence, options, owner) + return this.resumeWith(ownerCtx, persistence, options) } /** Resume through an explicit persistence handle used by the deferred config path. */ @@ -846,7 +844,6 @@ export class AgentLoop extends Service implements AgentFactory { ownerCtx: Context, persistence: SessionPersistence, options: ResumeAgentOptions, - owner: Agent | undefined, ): Promise { const id = options.resumeSessionId const published = (async () => { @@ -900,7 +897,6 @@ export class AgentLoop extends Service implements AgentFactory { handle = undefined // ownership passes to setupAndPublish/prepare return await this.setupAndPublish( ownerCtx, - owner, id, preparation, options.agentOptions ?? {}, @@ -908,6 +904,7 @@ export class AgentLoop extends Service implements AgentFactory { options.signal, 'resume', owned, + options.parentAgent, ) } finally { preparation?.[Symbol.dispose]() diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index df5db912da..0b9d32ec06 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -238,6 +238,7 @@ describe('AgentLoop initiator scope', () => { const handle = await exec.agent.ctx.agents.create({ sessionId: SessionId('child-session'), agentOptions: { provider: 'mock', model: 'mock' }, + parentAgent: exec.agent, setup: (agentCtx, childAgent) => { parentDuringSetup = ctx.agents.requireInitiator() explicitChild = childAgent @@ -252,7 +253,7 @@ describe('AgentLoop initiator scope', () => { }, })) }, - }, exec.agent) + }) child = handle.agent parentWhileChildDriverActive = ctx.agents.requireInitiator() send(handle.agent, 'run child') diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 980c1b8ac5..ca780be4fe 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -158,7 +158,8 @@ describe('agent scope lifecycle', () => { const child = await root.agent.ctx.agents.create({ sessionId: SessionId('runtime-child'), agentOptions: { model: 'mock' }, - }, root.agent) + parentAgent: root.agent, + }) expect(ctx.agents.list()).toEqual([root.agent, child.agent]) expect(ctx.agents.roots()).toEqual([root.agent]) @@ -586,7 +587,7 @@ describe('agent scope lifecycle', () => { await expect(loop.createAgent(ctx, { sessionId: SessionId('factory-inactive-s'), - }, undefined)).rejects.toThrow(/agent loop is not active|inactive context/) + })).rejects.toThrow(/agent loop is not active|inactive context/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent/README.i18n.yaml b/packages/core/agent/README.i18n.yaml index 3b389cc1f7..d8ac83e675 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: de8db86c4083f0fc705ae09265b909e663fd37ff -README.zh.md: 21379a0184c60f0f9f59b2f9557bd5d7bf539c30 +README.md: 08855877d9e37e1c8fd8124d981047be7147a5d3 +README.zh.md: 80d02bafdfe199a4367e93f4c4b32b5a7d63413a diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index de8db86c40..08855877d9 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -29,7 +29,7 @@ Mount `dsh-agent` wherever live agents exist: it provides `ctx.agents` and the ` ### Create or resume an agent -`ctx.agents.create()` builds a fresh agent and session under one identity; `ctx.agents.resume()` loads a persisted session and rebuilds the agent on it. Both delegate to the registered factory and return an `AgentHandle` — the only object that can tear that agent down. `get(id)`, `list()`, and `roots()` find live agents, and `isOwnedBy(id, owner)` tells whether one agent was created through another's scoped context. +`ctx.agents.create()` builds a fresh agent and session under one identity; `ctx.agents.resume()` loads a persisted session and rebuilds the agent on it. Both delegate to the registered factory and return an `AgentHandle` — the only object that can tear that agent down. Set `parentAgent` in either operation's options to make the result a runtime child; omit it for a runtime root. `get(id)`, `list()`, and `roots()` find live agents, and `isOwnedBy(id, parent)` tests that exact live relation. ```text const handle = await ctx.agents.create({ diff --git a/packages/core/agent/README.zh.md b/packages/core/agent/README.zh.md index 21379a0184..80d02bafdf 100644 --- a/packages/core/agent/README.zh.md +++ b/packages/core/agent/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 创建或恢复 agent -`ctx.agents.create()` 在一个身份下构建全新 agent 与会话;`ctx.agents.resume()` 加载持久化会话并在此基础上重建 agent。两者都委托给已注册工厂,并返回 `AgentHandle`——唯一能拆除该 agent 的对象。`get(id)`、`list()` 与 `roots()` 用于查找实时 agent;`isOwnedBy(id, owner)` 用于判断一个 agent 是否通过另一个 agent 的作用域上下文创建。 +`ctx.agents.create()` 在一个身份下构建全新 agent 与会话;`ctx.agents.resume()` 加载持久化会话并在此基础上重建 agent。两者都委托给已注册工厂,并返回 `AgentHandle`——唯一能拆除该 agent 的对象。在任一操作的 options 中设置 `parentAgent`,可使结果成为运行时子级;省略它则得到运行时根级。`get(id)`、`list()` 与 `roots()` 用于查找实时 agent;`isOwnedBy(id, parent)` 用于检验这项确切的存活关系。 ```text const handle = await ctx.agents.create({ diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 94750020b7..8c9df22103 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -63,6 +63,8 @@ export type AgentSetup = ( export interface CreateAgentOptions { /** The live agent/session identity. */ readonly sessionId: SessionId + /** Live parent Agent for runtime ownership; omit for a root Agent. */ + readonly parentAgent?: Agent /** * Session creation metadata: validated absolute `cwd`, `parentSession` * fork lineage, the `isSeeded` fork marker, the coarse `origin` @@ -124,6 +126,8 @@ export interface CreateAgentOptions { export interface ResumeAgentOptions { /** The persisted session id to load and use as the live agent/session identity. */ readonly resumeSessionId: SessionId + /** Live parent Agent for runtime ownership; omit for a root Agent. */ + readonly parentAgent?: Agent /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions /** Optional creation-only cancellation signal for persistence load/setup; detached before return. */ @@ -181,11 +185,10 @@ export interface AgentFactory { * transaction and resulting lifecycle to that owner; it must not infer * ownership from the factory object's registration context. * @param ownerCtx - caller-bound context that owns the transaction and live handle. - * @param options - agent/session identity, configuration, and optional setup. - * @param owner - explicitly supplied live runtime owner, or undefined for a root owner. + * @param options - agent/session identity, configuration, optional live parent, and setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ - createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise + createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise /** * Resume an agent on a persisted session. Async because it opens the * persisted session for write, reads and repairs the log, publishes it, and @@ -194,11 +197,10 @@ export interface AgentFactory { * 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. - * @param owner - explicitly supplied live runtime owner, or undefined for a root owner. + * @param options - persisted identity, configuration, optional live parent, and setup. * @returns the owned handle after setup, both announcements, and loop start complete. */ - resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise + resume(ownerCtx: Context, options: ResumeAgentOptions): Promise } /** Thrown when create/resume is called before an agent factory is registered. */ @@ -381,11 +383,10 @@ export class AgentRegistry extends Service { * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, session seed/metadata, and agent options. - * @param owner - explicit live runtime owner, or undefined for a root Agent. + * @param options - shared identity, optional live parent, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ - async create(options: CreateAgentOptions, owner?: Agent): Promise { + async create(options: CreateAgentOptions): Promise { const ownerCtx = this.ctx // Re-trace a Service-backed factory through the accessing context // explicitly. This preserves AgentLoop's dependency origin while binding @@ -394,23 +395,22 @@ export class AgentRegistry extends Service { const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver - return Reflect.apply(target.createAgent, receiver, [ownerCtx, options, owner]) + return Reflect.apply(target.createAgent, receiver, [ownerCtx, options]) } /** * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, configuration, and optional setup. - * @param owner - explicit live runtime owner, or undefined for a root Agent. + * @param options - persisted identity, optional live parent, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ - async resume(options: ResumeAgentOptions, owner?: Agent): Promise { + async resume(options: ResumeAgentOptions): Promise { const ownerCtx = this.ctx const { target } = this.requireFactory() const receiver = getTraceable(ownerCtx, target) // oxlint-disable-next-line typescript/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver - return Reflect.apply(target.resume, receiver, [ownerCtx, options, owner]) + return Reflect.apply(target.resume, receiver, [ownerCtx, options]) } /** @@ -420,9 +420,9 @@ export class AgentRegistry extends Service { * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * requires passing the carrier). The entry is a runtime root; factory-backed + * creation uses `options.parentAgent` for child ownership. Returns the disposer. * @param agent - the already-constructed agent to record in the store. - * @param owner - explicit live runtime owner, or undefined for a root Agent. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact * identity is load-bearing: a composite (generator) effect that owns a @@ -432,9 +432,9 @@ export class AgentRegistry extends Service { * owner unload, unregistering the agent (and emitting `agent/disposed`) * while its final turn is still draining. */ - register(agent: Agent, owner?: Agent): () => void { + register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { - yield this.enter(agent, owner) + yield this.enter(agent, undefined) this.announce(agent) }.bind(this), 'agents.register()') // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 08e4432669..79728f31e4 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -359,16 +359,16 @@ describe('explicit cancellation contract', () => { describe('AgentRegistry factory seam', () => { function stubFactory() { const calls: { - create: Array<{ ownerCtx: Context; options: CreateAgentOptions; owner: Agent | undefined }> - resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions; owner: Agent | undefined }> + create: Array<{ ownerCtx: Context; options: CreateAgentOptions }> + resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }> } = { create: [], resume: [] } const factory: AgentFactory = { - async createAgent(ownerCtx, options, owner) { - calls.create.push({ ownerCtx, options, owner }) + async createAgent(ownerCtx, options) { + calls.create.push({ ownerCtx, options }) return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } }, - async resume(ownerCtx, options, owner) { - calls.resume.push({ ownerCtx, options, owner }) + async resume(ownerCtx, options) { + calls.resume.push({ ownerCtx, options }) return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } }, } @@ -390,11 +390,11 @@ describe('AgentRegistry factory seam', () => { }, { inject: ['agents'] })) expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber) expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber) - expect(calls.create[0]?.owner).toBeUndefined() - expect(calls.resume[0]?.owner).toBeUndefined() + expect(calls.create[0]?.options.parentAgent).toBeUndefined() + expect(calls.resume[0]?.options.parentAgent).toBeUndefined() }) - it('passes the explicit runtime owner separately from the caller context', async () => { + it('keeps the runtime parent in options separately from the caller context', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) const { factory, calls } = stubFactory() @@ -402,9 +402,9 @@ describe('AgentRegistry factory seam', () => { const parent = stubAgent('parent') const unregister = ctx.agents.register(parent) - await ctx.agents.create({ sessionId: SessionId('child') }, parent) + await ctx.agents.create({ sessionId: SessionId('child'), parentAgent: parent }) - expect(calls.create[0]?.owner).toBe(parent) + expect(calls.create[0]?.options.parentAgent).toBe(parent) unregister() }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 190d1198e1..fbb974e886 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -117,15 +117,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the published running agent.', }, { - signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise', + signature: 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', description: 'Create an owned agent on a caller-supplied session id.', - parameters: [{ name: 'ownerCtx', description: 'caller context that structurally owns the lifecycle.' }, { name: 'options', description: 'identities, session seed/metadata, loop options, setup, and cancellation.' }, { name: 'owner', description: 'live Agent that owns the new Agent at runtime, or undefined for a root owner.' }], + parameters: [{ name: 'ownerCtx', description: 'caller context that structurally owns the lifecycle.' }, { name: 'options', description: 'identities, optional live parent, session seed/metadata, loop options, setup, and cancellation.' }], returns: 'the published handle.', }, { - signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise', + signature: 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', description: 'Resume an owned agent from the configured persistence service.', - parameters: [{ name: 'ownerCtx', description: 'caller context that owns load, setup, and the live lifecycle.' }, { name: 'options', description: 'persisted identity, loop options, setup, and cancellation.' }, { name: 'owner', description: 'live Agent that owns the resumed Agent at runtime, or undefined for a root owner.' }], + parameters: [{ name: 'ownerCtx', description: 'caller context that owns load, setup, and the live lifecycle.' }, { name: 'options', description: 'persisted identity, optional live parent, loop options, setup, and cancellation.' }], returns: 'the published handle.', }, ], @@ -289,21 +289,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the disposer that clears the factory slot. The exact Cordis effect disposer (single-shot): composite (generator) effects may yield it directly — exact identity nests the teardown in order.', }, { - signature: 'async create(options: CreateAgentOptions, owner?: Agent): Promise', + signature: 'async create(options: CreateAgentOptions): Promise', description: 'Create and publish a new agent through the registered factory. Distinct from register (which records an already-constructed agent): this constructs the agent and its session. Rejects if no factory is registered or creation/setup fails. The resolved AgentHandle lets the owner tear down exactly this agent.', - parameters: [{ name: 'options', description: 'shared identity, session seed/metadata, and agent options.' }, { name: 'owner', description: 'explicit live runtime owner, or undefined for a root Agent.' }], + parameters: [{ name: 'options', description: 'shared identity, optional live parent, session seed/metadata, and agent options.' }], returns: 'the handle after setup, rollback-covered publication, and loop start complete.', }, { - signature: 'async resume(options: ResumeAgentOptions, owner?: Agent): Promise', + signature: 'async resume(options: ResumeAgentOptions): Promise', description: 'Load a persisted session and resume an agent on it through the registered factory. Rejects if no factory is registered; the factory rejects if session persistence is not configured or persistence/setup fails.', - parameters: [{ name: 'options', description: 'persisted identity, configuration, and optional setup.' }, { name: 'owner', description: 'explicit live runtime owner, or undefined for a root Agent.' }], + parameters: [{ name: 'options', description: 'persisted identity, optional live parent, configuration, and setup.' }], returns: 'the handle after setup, rollback-covered publication, and loop start complete.', }, { - signature: 'register(agent: Agent, owner?: Agent): () => void', - description: 'Register a live agent. Throws if an agent with the same id is already registered. Emits `agent/created` on registration and `agent/disposed` when the calling fiber is disposed — both with the agent\'s scope carrier (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the emits are scope-filtered regardless of which context invoked `register` (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always requires passing the carrier). Returns the disposer.', - parameters: [{ name: 'agent', description: 'the already-constructed agent to record in the store.' }, { name: 'owner', description: 'explicit live runtime owner, or undefined for a root Agent.' }], + signature: 'register(agent: Agent): () => void', + description: 'Register a live agent. Throws if an agent with the same id is already registered. Emits `agent/created` on registration and `agent/disposed` when the calling fiber is disposed — both with the agent\'s scope carrier (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the emits are scope-filtered regardless of which context invoked `register` (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always requires passing the carrier). The entry is a runtime root; factory-backed creation uses `options.parentAgent` for child ownership. Returns the disposer.', + parameters: [{ name: 'agent', description: 'the already-constructed agent to record in the store.' }], returns: 'the EXACT Cordis effect disposer (single-shot; a repeat call returns undefined without awaiting an in-flight teardown). Exact identity is load-bearing: a composite (generator) effect that owns a teardown ORDER — the agent factory\'s lifecycle chain — must yield THIS function so Cordis nests the unregistration at that yield position; yielding a wrapper would leave it disposing as a concurrent sibling on owner unload, unregistering the agent (and emitting `agent/disposed`) while its final turn is still draining.', }, { @@ -3370,7 +3370,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'AgentFactory', - declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions, owner: Agent | undefined): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions, owner: Agent | undefined): Promise;\n}', + declaration: 'export interface AgentFactory {\n createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise;\n resume(ownerCtx: Context, options: ResumeAgentOptions): Promise;\n}', }, { name: 'AgentHandle', @@ -3778,7 +3778,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 isSeeded?: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly inheritedEventCount?: SessionLogOffset;\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly parentAgent?: Agent;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly isSeeded?: boolean;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly inheritedEventCount?: SessionLogOffset;\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'CreateGoalRequest', @@ -4670,7 +4670,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?: AgentSetup;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly parentAgent?: Agent;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, { name: 'RunnerFailureRule', diff --git a/packages/schedule/schedule/tests/plugin.spec.ts b/packages/schedule/schedule/tests/plugin.spec.ts index 681be68db1..496b941b52 100644 --- a/packages/schedule/schedule/tests/plugin.spec.ts +++ b/packages/schedule/schedule/tests/plugin.spec.ts @@ -122,7 +122,10 @@ describe('Schedule plugin composition', () => { agentEvents(ctx, root.agent).emit('agent/status', { status: 'running' }) agentEvents(ctx, root.agent).emit('agent/status', { status: 'idle' }) - const child = await root.agent.ctx.agents.create({ sessionId: SessionId('schedule-child') }, root.agent) + const child = await root.agent.ctx.agents.create({ + sessionId: SessionId('schedule-child'), + parentAgent: root.agent, + }) expect(ctx.agents.roots()).toEqual([existing.agent, root.agent]) expect(ctx.tools.get('schedule_create', child.agent)).toBeUndefined() diff --git a/packages/sdk/server/tests/built-scope-carrier.e2e.ts b/packages/sdk/server/tests/built-scope-carrier.e2e.ts index 44964bb49b..8b910e5d02 100644 --- a/packages/sdk/server/tests/built-scope-carrier.e2e.ts +++ b/packages/sdk/server/tests/built-scope-carrier.e2e.ts @@ -68,7 +68,8 @@ try { sessionId: SessionId("built-child"), meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, agentOptions: { model: "test" }, - }, parent.agent); + parentAgent: parent.agent, + }); const result = Promise.withResolvers(); const unregister = ctx.subagents.registerProvider({ name: "built-local", diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 308a7efad7..3a0dd41007 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -452,7 +452,8 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('parentless-child-session'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek-official' }, - }, parentHandle.agent) + parentAgent: parentHandle.agent, + }) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), @@ -514,7 +515,8 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('remote-run-id'), meta: { cwd: storageDir, parentSession: SessionId('collision-parent') }, agentOptions: { model: 'deepseek-official' }, - }, parentHandle.agent) + parentAgent: parentHandle.agent, + }) await settleSubagent(ctx, parentHandle.agent, { provider: 'remote', @@ -553,7 +555,8 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('continuation-child'), meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') }, agentOptions: { model: 'deepseek-official' }, - }, parentHandle.agent) + parentAgent: parentHandle.agent, + }) await settleSubagent(ctx, parentHandle.agent, { provider: 'continuation', @@ -598,7 +601,8 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('reused-child'), meta: { cwd: storageDir, parentSession: SessionId('old-parent') }, agentOptions: { model: 'deepseek-official' }, - }, oldParent.agent) + parentAgent: oldParent.agent, + }) const first = Promise.withResolvers() const sameLifetime = Promise.withResolvers() const replacement = Promise.withResolvers() @@ -639,7 +643,8 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('reused-child'), meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, agentOptions: { model: 'deepseek-official' }, - }, newParent.agent) + parentAgent: newParent.agent, + }) currentLocalAgent = newChild.agent const secondRun = await ctx.subagents.start('reused', { parent: newParent.agent, @@ -697,7 +702,8 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('provider-reuse-child'), meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') }, agentOptions: { model: 'deepseek-official' }, - }, parent.agent) + parentAgent: parent.agent, + }) const localResult = Promise.withResolvers() const remoteResult = Promise.withResolvers() const unregisterLocal = ctx.subagents.registerProvider({ @@ -790,13 +796,15 @@ describe('HarnessSdkJsonRpcServer', () => { sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' }, - }, parentHandle.agent) + parentAgent: parentHandle.agent, + }) const fallbackChild = handle.agent failedHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' }, - }, parentHandle.agent) + parentAgent: parentHandle.agent, + }) const missedStartResult = Promise.withResolvers() const disposeMissedStartProvider = ctx.subagents.registerProvider({ name: 'fork', diff --git a/packages/subagent/subagent-in-process-driver/src/index.ts b/packages/subagent/subagent-in-process-driver/src/index.ts index 6b4cabd6a3..c0886dc104 100644 --- a/packages/subagent/subagent-in-process-driver/src/index.ts +++ b/packages/subagent/subagent-in-process-driver/src/index.ts @@ -133,13 +133,14 @@ export async function startInProcessRun( const handle = await parent.ctx.agents.create({ sessionId: childId, + parentAgent: parent, meta: childSessionMeta(parent, childDepth, seed !== undefined), ...seed !== undefined ? { seed } : {}, ...seed === undefined ? {} : { inheritedEventCount: activationBoundary }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), signal: request.signal, setup, - }, parent) + }) return drivePublishedRun( handle, request.signal, diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index b84c06ef74..d782ebd6ef 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -1202,19 +1202,21 @@ export class SubagentContinuationManager { const handle: AgentHandle = create === undefined ? await this.ownerCtx.agents.resume({ resumeSessionId: childId, + parentAgent: parent, agentOptions: inputs.agentOptions, signal: inputs.signal, setup, - }, parent) + }) : await this.ownerCtx.agents.create({ sessionId: childId, + parentAgent: parent, meta: create.meta, seed: create.seed, inheritedEventCount: create.inheritedEventCount, agentOptions: inputs.agentOptions, signal: inputs.signal, setup, - }, parent) + }) const activation: Activation = { childId, From 8551b04e95ea3e9b79380d15f90d571fd2fa0803 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 7 Sep 2026 12:11:18 +0800 Subject: [PATCH 07/15] test(tool-subagent): use current session format in fixture --- .../tool-subagent/tests/model-selection-settings.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index eb7ccd36c8..41c47123e8 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { Session, SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import { SettingsProvider } from '@deepseek-ai/dsh-settings' @@ -424,7 +424,7 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(SubagentRuntime) const childId = SessionId('child-without-session-registry') const child = Session.create(childId, undefined, { - version: 0, + version: SESSION_FORMAT_VERSION, id: childId, createdAt: 1, isSeeded: false, From e7bde97aa0cfea0e55aa07f4a9362a56b58fa549 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 17:55:17 +0800 Subject: [PATCH 08/15] fix(tool-subagent): await agent-started preset cleanup --- ...-explicit-agent-runtime-identity.i18n.yaml | 4 +- ...6-08-31-explicit-agent-runtime-identity.md | 2 +- ...8-31-explicit-agent-runtime-identity.zh.md | 2 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 6 ++- .../tests/model-selection-settings.spec.ts | 53 +++++++++++++++++++ 8 files changed, 66 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index e8dff7067a..124d6d3e8b 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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/architecture/2026-08-31-explicit-agent-runtime-identity.md -2026-08-31-explicit-agent-runtime-identity.md: 6f0fafa243ee6843981bbb78af626988c4131f80 -2026-08-31-explicit-agent-runtime-identity.zh.md: 703ccd0384003c30e4c402e96e6a04083e473024 +2026-08-31-explicit-agent-runtime-identity.md: 48c8d0c205b6b8c2331e2373b1e11142e05d715b +2026-08-31-explicit-agent-runtime-identity.zh.md: a8358f5a8f4629289dc75722ed82d79206db3dc5 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 6f0fafa243..48c8d0c205 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -26,7 +26,7 @@ The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. -Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling, inheritance, and removal of every installed definition before preset unload settles, including cleanup already started by reparenting. +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling, inheritance, and removal of every installed definition before preset unload settles, including cleanup already started by reparenting or Agent disposal. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index 703ccd0384..a8358f5a8f 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -26,7 +26,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 -Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级时已启动的清理。 +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级或 Agent 销毁时已启动的清理。 ## 考虑过的替代方案 diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 302267bdd3..a0a696a96d 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/README.md -README.md: 35fcc0e65bd3ab9c9a0704e6085f5d731aff085c -README.zh.md: 4a95bf6dcee314159899f1b10a988a09ce6cce69 +README.md: 01052e98ac9a8dcf3539ed20a9b0ca3ff5c6c2cd +README.zh.md: d90bafdeec1c60ce3f96be1546cb95a4c614f084 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 35fcc0e65b..01052e98ac 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -80,7 +80,7 @@ This section explains how the tool mirrors provider lifecycle and settles runs; ### Design concept -One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. Direct Agent setup passes its unpublished Session explicitly and awaits installation before publication. A settings-backed standing preset receives each matching Agent from lifecycle events, selects policy from its Session, installs through its Context, and awaits removal of every owned fiber when the preset unloads. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name. +One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. Direct Agent setup passes its unpublished Session explicitly and awaits installation before publication. A settings-backed standing preset receives each matching Agent from lifecycle events, selects policy from its Session, installs through its Context, and awaits removal of every owned fiber when the preset unloads, including cleanup already started by Agent disposal. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name. ### Foreground settlement diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 4a95bf6dce..d90bafdeec 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -80,7 +80,7 @@ kind: "package-reference" ### 设计理念 -一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。直接 Agent setup 显式传入尚未发布的 Session,并在发布前等待安装完成。由设置控制的常驻 preset 从生命周期事件接收每个匹配 Agent,从其 Session 选择策略,通过其 Context 安装,并在 preset 卸载时等待移除所有归它所有的 fiber。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。 +一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。直接 Agent setup 显式传入尚未发布的 Session,并在发布前等待安装完成。由设置控制的常驻 preset 从生命周期事件接收每个匹配 Agent,从其 Session 选择策略,通过其 Context 安装,并在 preset 卸载时等待移除所有归它所有的 fiber,包括 Agent 销毁时已启动的清理。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。 ### 前台结算 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index d0f1db2cc3..0871599c63 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -664,7 +664,11 @@ export function apply(ctx: Context, config: Config, session?: Session): void { if (ctx.fiber.uid === null || ctx.fiber.state === FiberState.UNLOADING || presetInstalls.has(candidate)) return let fiber: ReturnType | undefined const dispose = ctx.effect(() => async () => { - if (fiber !== undefined) await fiber.dispose() + if (fiber !== undefined) { + await fiber.dispose() + // Agent teardown may already have claimed the single-shot disposer. + while (fiber.inertia !== undefined) await fiber.inertia + } }, `tool-subagent: standing-preset definitions for Agent "${candidate.id}"`) // Reserve before policy sampling or injection can re-enter reconciliation. presetInstalls.set(candidate, dispose) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index f61b823e89..54252fff6b 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -316,6 +316,59 @@ describe('SubagentModelSelectionConfig', () => { await ctx.fiber.dispose() }) + it('awaits Agent-started definition cleanup when a standing preset unloads', async () => { + const ctx = await boot(false) + const cleanupStarted = Promise.withResolvers() + const cleanupGate = Promise.withResolvers() + const settlements: string[] = [] + let definitionParent: Context | undefined + let disposingAgent: Promise | undefined + let unloadingPreset: Promise | undefined + try { + const preset = createScope(ctx, { preset: 'agent-first-cleanup' }) + ctx.on('internal/plugin', (fiber) => { + if (fiber.parent !== definitionParent + || Object.keys(fiber.inject).sort().join(',') !== 'subagents,systemPrompt,tools') return + fiber.ctx.effect(() => async () => { + cleanupStarted.resolve(undefined) + await cleanupGate.promise + settlements.push('definition-cleaned') + }, 'tool-subagent test: Agent-started cleanup barrier') + }) + const mounted = await preset.ctx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + const handle = await ctx.agents.create({ + sessionId: SessionId('agent-first-cleanup'), + setup: (agentCtx) => { + definitionParent = agentCtx + bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) + }, + }) + await vi.waitFor(() => { expect(ctx.tools.get('subagent', handle.agent)).toBeDefined() }) + + disposingAgent = handle.dispose() + await cleanupStarted.promise + unloadingPreset = Promise.resolve(mounted.dispose()).then(() => { + settlements.push('preset-unloaded') + }) + setImmediate(() => { cleanupGate.resolve(undefined) }) + await unloadingPreset + await disposingAgent + expect(settlements).toEqual(['definition-cleaned', 'preset-unloaded']) + expect(ctx.tools.get('subagent', handle.agent)).toBeUndefined() + } finally { + cleanupGate.resolve(undefined) + try { + await Promise.all([disposingAgent, unloadingPreset]) + } finally { + await ctx.fiber.dispose() + } + } + }) + it('releases a shared-preset installation reservation after policy selection fails', async () => { const ctx = await boot(false) const preset = createScope(ctx, { preset: 'standard' }) From 55660e8a9e2659220be43056b5d1aad16ff9e832 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 19:46:12 +0800 Subject: [PATCH 09/15] test(terminal): decouple descendant identity from startup --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +-- .../2026-07-16-persistent-pty-sessions.md | 2 ++ .../2026-07-16-persistent-pty-sessions.zh.md | 2 ++ .../terminal-bash/tests/local.spec.ts | 29 +++++++++---------- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index b1b7365597..1a3fc023a5 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: d62587ced78be75de121fe907ca258d07a2830a1 -2026-07-16-persistent-pty-sessions.zh.md: a26c3ed852b3a18842585e19ecb2a6994f8d9117 +2026-07-16-persistent-pty-sessions.md: 834f0e003350b97293d9d93ae0d8dd5259cfe1c1 +2026-07-16-persistent-pty-sessions.zh.md: fea4d0d025de71f5e753e5ed8e50065746c99a97 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index d62587ced7..834f0e0033 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -158,6 +158,8 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification +The disowned-descendant fixture's parent shell ignores `SIGTERM` before fork and publishes the child PID, so neither identity nor signal disposition depends on child startup. A file barrier also holds the descendant before its workload while natural shell exit and explicit close must still reach quiescence. + - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, supported kernel ABIs under user-mode emulation, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. - Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, the exact attribution when process syscalls are readable, its bounded idle fallback when host policy denies them, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence. The Linux native smoke keeps the PTY PID, session leader, controlling terminal, foreground `inputWaiting`, and readiness while a reparented `setsid` descendant remains owned by the scope; fallback suites retain identity-fenced observational cleanup coverage. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index a26c3ed852..fea4d0d025 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -158,6 +158,8 @@ plugins: ## 验证 +脱离作业表的后代进程由父 shell 在 fork 前设置忽略 `SIGTERM`,并由父 shell 发布其 PID,因此身份与信号处置都不依赖子进程启动。文件屏障还会将后代进程阻挡在工作负载之前,此时 shell 自然退出与显式关闭仍必须使其完全停稳。 + - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、用户态模拟下受支持的内核 ABI、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 与 PTY 消费方测试共同覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、进程 syscall 可读时的精确归因、宿主策略拒绝读取时的有界 idle fallback、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。Linux native 冒烟测试会在一个 reparent 的 `setsid` 后代仍由 scope 拥有时,保持 PTY PID、session leader、控制终端、前台 `inputWaiting` 与 readiness;fallback 测试套件继续覆盖带身份围栏的观察式清理。 diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 9800a79d54..1c03e5e1a9 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -238,27 +238,24 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => expect(() => process.kill(pid, 0)).toThrow() }, 10_000) - it('quiesces a disowned same-session descendant after the shell exits naturally', async () => { + it.each([false, true])('quiesces a disowned same-session descendant after the shell exits naturally (hold descendant: %s)', async (holdDescendant) => { const { ctx, root, agent } = await harness('danger-full-access') const created = await ctx.terminals.spawn(agent, { type: 'shell' }) - const pidFile = join(root, 'disowned.pid') + const releaseFile = join(root, 'release-descendant') + // Keep the descendant before its workload without delaying the parent's PID publication. + const barrier = holdDescendant ? 'while [ ! -e "$1" ]; do sleep 0.01; done; ' : '' let pid: number | undefined try { + // Ignore TERM before forking so even an unscheduled child inherits it. const background = ctx.terminals.startSend(agent, created.sessionId, { - text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`, + text: `trap "" TERM; sh -c '${barrier}exec sleep 60' dsh "${releaseFile}" & disown; trap - TERM; echo CHILD=$!`, submit: true, }) - await background.done - const pidDeadline = Date.now() + 2_000 - let childPid = 0 - while (childPid === 0 && Date.now() < pidDeadline) { - if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8')) - if (childPid > 0) break - await new Promise(resolve => setTimeout(resolve, 10)) - } - expect(existsSync(pidFile), ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true) - expect(childPid).toBeGreaterThan(0) - pid = childPid + const result = await background.done + const childPid = Number(/CHILD=(\d+)/.exec(result.viewport)?.[1]) + if (childPid > 0) pid = childPid + expectReadyForNextSend(result.waitReason) + expect(childPid, result.viewport).toBeGreaterThan(0) expect(() => process.kill(childPid, 0)).not.toThrow() await ctx.terminals.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done const deadline = Date.now() + 2_000 @@ -275,6 +272,8 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => } catch (_alreadyReaped) { // Product cleanup is the expected path; this only contains a failed regression. } + const disposedPid = pid + await expect.poll(() => processIsRunning(disposedPid)).toBe(false) } } }, 10_000) From 795a9cc22643e5ea9d8f5c42c5baf9e2b0252c0f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 19:46:50 +0800 Subject: [PATCH 10/15] test(web): sample queue alignment in one layout --- ...9-07-sidebar-responsive-tab-info.i18n.yaml | 4 +-- .../2026-09-07-sidebar-responsive-tab-info.md | 2 ++ ...26-09-07-sidebar-responsive-tab-info.zh.md | 2 ++ apps/web/tests/queue-actions.e2e.ts | 29 ++++++++++++------- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml index 0a04d3712e..1ac2be26ab 100644 --- a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.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/architecture/2026-09-07-sidebar-responsive-tab-info.md -2026-09-07-sidebar-responsive-tab-info.md: ade20148cccd2233224417f762e65492828943bb -2026-09-07-sidebar-responsive-tab-info.zh.md: 0903e4ff06ca187fcc1fd0e9d8de63f70d5cc427 +2026-09-07-sidebar-responsive-tab-info.md: a7a646d1d7525b305fbeade98e649a3345d97420 +2026-09-07-sidebar-responsive-tab-info.zh.md: 9366861804b6f46980e1f08b028c4fa805f7fa7d diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md index ade20148cc..a7a646d1d7 100644 --- a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md @@ -28,4 +28,6 @@ This decision supersedes the flat owner-props choice in [tab types and navigatio ## Consequences +Browser comparisons of related panel rectangles and CSS insets capture all values in one page execution. Responsive grid updates can occur between browser round trips, so separately sampled rectangles do not establish alignment even when each sample is accurate. + Tab extensions use a framework-injected reader and keep their own store actions separate from `tab.actions`. Layout, seat and docking tests cover width concessions, explicit reopening, body/title visibility, tab lifetimes, horizontal drop zones and divider limits; browser tests exercise the assembled application. Compact mobile controls and layout persistence remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md index 0903e4ff06..9366861804 100644 --- a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md @@ -28,4 +28,6 @@ Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与 ## 后果 +浏览器比较相关面板矩形与 CSS 内边距时,会在一次页面执行中获取全部值。响应式网格可能在浏览器往返调用之间更新,因此分次采样的矩形即使各自准确,也不能用于判断对齐。 + 标签扩展使用框架注入的读取接口,自身 store actions 与 `tab.actions` 保持分离。布局、seat 与停靠测试覆盖列宽让步、显式重开、正文与标题可见性、标签生命周期、水平放置区与分割比例;浏览器测试覆盖组装后的应用。紧凑移动端控件与布局持久化不属于本决策。 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 086bbce416..c24c8047f5 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -147,8 +147,22 @@ describe('web e2e: queue row actions', () => { ).toBe(2) await page.setViewportSize({ width: 640, height: 1000 }) - const queueBox = await page.locator('[data-queue-dock]').boundingBox() - const composerBox = await page.locator('[data-composer-card]').boundingBox() + // Compare one layout: viewport changes can move the grid between browser round trips. + const { queueBox, composerBox, dockInset } = await page.evaluate(() => { + const queue = document.querySelector('[data-queue-dock]') + const composer = document.querySelector('[data-composer-card]') + const box = (element: Element | null) => { + const rect = element?.getBoundingClientRect() + return rect === undefined ? null : { x: rect.x, width: rect.width } + } + return { + queueBox: box(queue), + composerBox: box(composer), + dockInset: composer === null ? null : Number.parseFloat( + getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset'), + ), + } + }) expect(queueBox).not.toBeNull() expect(composerBox).not.toBeNull() expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x) @@ -156,14 +170,9 @@ describe('web e2e: queue row actions', () => { .toBeLessThanOrEqual(composerBox!.x + composerBox!.width) const queueLeftInset = queueBox!.x - composerBox!.x const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width - const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => { - const style = getComputedStyle(element) - return { - dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')), - } - }) - expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1) - expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1) + expect(dockInset).not.toBeNull() + expect(queueLeftInset).toBeCloseTo(dockInset!, 1) + expect(queueRightInset).toBeCloseTo(dockInset!, 1) await page.setViewportSize({ width: 1680, height: 1000 }) const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT }) From 36bd2972935f7113715ba2b402610e498ebc2481 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 19:46:51 +0800 Subject: [PATCH 11/15] fix(visualizer): keep model registration independent of agent identity --- ...8-31-explicit-agent-runtime-identity.i18n.yaml | 4 ++-- .../2026-08-31-explicit-agent-runtime-identity.md | 2 ++ ...26-08-31-explicit-agent-runtime-identity.zh.md | 2 ++ docs/subsystems/tools.i18n.yaml | 4 ++-- docs/subsystems/tools.md | 4 ++-- docs/subsystems/tools.zh.md | 4 ++-- .../extensions/tool-cordis/src/api-catalog.ts | 4 ++-- .../visualizer/tool-visualizer/README.i18n.yaml | 4 ++-- packages/visualizer/tool-visualizer/README.md | 2 ++ packages/visualizer/tool-visualizer/README.zh.md | 2 ++ packages/visualizer/tool-visualizer/src/index.ts | 8 ++++---- .../tests/loader-composition.spec.ts | 2 +- .../tool-visualizer/tests/model-surface.spec.ts | 15 ++++++++------- 13 files changed, 33 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index 124d6d3e8b..294ff78f74 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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/architecture/2026-08-31-explicit-agent-runtime-identity.md -2026-08-31-explicit-agent-runtime-identity.md: 48c8d0c205b6b8c2331e2373b1e11142e05d715b -2026-08-31-explicit-agent-runtime-identity.zh.md: a8358f5a8f4629289dc75722ed82d79206db3dc5 +2026-08-31-explicit-agent-runtime-identity.md: fe57ea28d7c9661a2bd34d36b487c2963a60d541 +2026-08-31-explicit-agent-runtime-identity.zh.md: 985f52886b22f52f2358fd5628906b7f8dcc30c4 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 48c8d0c205..fe57ea28d7 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -18,6 +18,8 @@ Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset reserves one Cordis cleanup effect for each matching Agent before sampling policy: the event payload supplies the Agent, its Session supplies the policy target, its Context owns the registrations, and the preset effect joins their removal after reparenting or preset unload. +Visualizer model installation likewise uses Context only for scoped registrations. The shipped wrapper selects the preset scope; the installer neither classifies its opaque key nor resolves an Agent from it. Widget follow-up authorization receives its exact live Agent through the Remote. + `SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index a8358f5a8f..985f52886b 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -18,6 +18,8 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 在读取策略前,会为每个匹配 Agent 预留一个 Cordis 清理 effect:事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项,而 preset effect 会在重设父级或 preset 卸载后等待其清理完成。 +Visualizer 模型贡献的安装同样只使用 Context 管理作用域注册。随产品提供的 wrapper 选择 preset 作用域;安装器既不分类其不透明键,也不从中解析 Agent。Widget follow-up 授权通过 Remote 接收确切的存活 Agent。 + `SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 [Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。 diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 80669fece3..9434a134d4 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.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/subsystems/tools.md -tools.md: 27e5aa6481eb723110dd70e25ecd5e403f9413a6 -tools.zh.md: 52e579da6aaf544ab59ae21877bce3d43cab75ae +tools.md: 1d9b28aefc2dbd8b4a3f8b787714884edf4104ad +tools.zh.md: c266bd21f585055d45f7bcaa02c74f8181ca4984 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 27e5aa6481..1d9b28aefc 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -581,8 +581,8 @@ One Host service owns every authority-bearing part of the Visualizer extension. ```ts cordis-catalog /** - * Install the model-facing Visualizer contribution in the caller's preset scope. - * @param modelCtx - Preset-scoped context that owns the prompt and tool effects. + * Install the model-facing Visualizer contribution through the supplied scoped owner. + * @param modelCtx - Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope. */ installModelSurface(modelCtx: Context): void diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 52e579da6a..c266bd21f5 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -581,8 +581,8 @@ One Host service owns every authority-bearing part of the Visualizer extension. ```ts cordis-catalog /** - * Install the model-facing Visualizer contribution in the caller's preset scope. - * @param modelCtx - Preset-scoped context that owns the prompt and tool effects. + * Install the model-facing Visualizer contribution through the supplied scoped owner. + * @param modelCtx - Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope. */ installModelSurface(modelCtx: Context): void diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index d03db06dac..0b4fd55ae7 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2743,8 +2743,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'installModelSurface(modelCtx: Context): void', - description: 'Install the model-facing Visualizer contribution in the caller\'s preset scope.', - parameters: [{ name: 'modelCtx', description: 'Preset-scoped context that owns the prompt and tool effects.' }], + description: 'Install the model-facing Visualizer contribution through the supplied scoped owner.', + parameters: [{ name: 'modelCtx', description: 'Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope.' }], }, { signature: '@Remote(\'sendPrompt\') remoteSendPrompt(agent: Agent, request: WidgetPromptRequest): void', diff --git a/packages/visualizer/tool-visualizer/README.i18n.yaml b/packages/visualizer/tool-visualizer/README.i18n.yaml index eace1ffa47..8e306aed13 100644 --- a/packages/visualizer/tool-visualizer/README.i18n.yaml +++ b/packages/visualizer/tool-visualizer/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/visualizer/tool-visualizer/README.md -README.md: ea8424262e224edc44f3d7c97094e6fc3cef68b1 -README.zh.md: 77e92cba06eb3e7e5454dafd47684d4d44f5a843 +README.md: c3fe5f471448f8ee5d7282df0e8b6451e591fe69 +README.zh.md: a0e3259b621beea74f5cc57497aaa4efcf2be82c diff --git a/packages/visualizer/tool-visualizer/README.md b/packages/visualizer/tool-visualizer/README.md index ea8424262e..c3fe5f4714 100644 --- a/packages/visualizer/tool-visualizer/README.md +++ b/packages/visualizer/tool-visualizer/README.md @@ -37,6 +37,8 @@ Mount the root service only in a composition that supplies a compatible Client: The wrapper waits recoverably for root authority. It contributes no prompt or tools while authority is absent, activates the inherited preset surface when authority appears, withdraws it when authority leaves, and can activate again later. Child `toolFilter` restrictions continue to apply throughout that lifecycle. +Programmatic model installation requires a scoped Context and uses it only to own effects; shipped wrappers supply the preset Context. Follow-up authorization receives the Agent explicitly through the Remote. + ### Authority and follow-ups The generated Remote accepts a follow-up only for the exact live Agent and the exact persisted HTML `show_widget` result identified by `resultSeq`. The result must be an appended success whose single source event is a same-turn, same-step `show_widget` call with the matching tool-call ID. The Host derives the title from that source call and the widget kind from the result's presentation metadata, bounds the complete labelled follow-up to 4096 UTF-8 bytes, and applies a fixed limit of four admissions per Agent per rolling minute. Widget source is limited to 128 KiB. diff --git a/packages/visualizer/tool-visualizer/README.zh.md b/packages/visualizer/tool-visualizer/README.zh.md index 77e92cba06..a0e3259b62 100644 --- a/packages/visualizer/tool-visualizer/README.zh.md +++ b/packages/visualizer/tool-visualizer/README.zh.md @@ -37,6 +37,8 @@ kind: "package-reference" wrapper 会可恢复地等待根权威。权威不存在时它不贡献提示词或工具;权威出现时激活继承的 preset 模型面,权威撤销时移除该模型面,之后权威再次出现仍可重新激活。child `toolFilter` 限制在整个生命周期中都会继续生效。 +通过程序接口安装模型贡献时必须提供有作用域的 Context,它只负责 effect 所有权;随产品提供的 wrapper 传入 preset Context。follow-up 授权通过 Remote 显式接收 Agent。 + ### 权威与 follow-up 生成式 Remote 只接纳命中准确 live Agent,且由 `resultSeq` 标识的准确已持久化 HTML `show_widget` result。该 result 必须是 append 成功的事件,其唯一 source event 必须是同 turn、同 step 且 tool-call ID 匹配的 `show_widget` call。Host 从该 source call 派生标题,从 result 的 presentation metadata 派生 widget kind,将完整已标记 follow-up 限制在 4096 UTF-8 字节以内,并固定为每个 Agent 每个滚动分钟最多接纳 4 次。Widget 源码限制为 128 KiB。 diff --git a/packages/visualizer/tool-visualizer/src/index.ts b/packages/visualizer/tool-visualizer/src/index.ts index 3ef61a60da..2519a5b4c4 100644 --- a/packages/visualizer/tool-visualizer/src/index.ts +++ b/packages/visualizer/tool-visualizer/src/index.ts @@ -40,12 +40,12 @@ export class VisualizerService extends TypertRemoteService { } /** - * Install the model-facing Visualizer contribution in the caller's preset scope. - * @param modelCtx - Preset-scoped context that owns the prompt and tool effects. + * Install the model-facing Visualizer contribution through the supplied scoped owner. + * @param modelCtx - Scoped context that owns the prompt and tool effects; shipped wrappers use the preset scope. */ installModelSurface(modelCtx: Context): void { - if (scopeOf(modelCtx) === undefined || modelCtx.agent !== undefined) { - throw new Error('tool-visualizer: the model contribution requires a scoped context without an Agent') + if (scopeOf(modelCtx) === undefined) { + throw new Error('tool-visualizer: the model contribution requires a scoped context') } modelCtx.systemPrompt.section({ name: 'tool:visualizer', diff --git a/packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts b/packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts index df39da7188..5818b4db1a 100644 --- a/packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts +++ b/packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts @@ -97,7 +97,7 @@ function registerPresetAgent( const session = Session.create(SessionId(id)) const agent = { id: session.id, session, ctx } as unknown as Agent const agentScope = createScope(preset.base, agent, { parent: preset.presetKey }) - Object.assign(agent, { ctx: agentScope.ctx.extend({ agent }) }) + Object.assign(agent, { ctx: agentScope.ctx }) ctx.agents.register(agent) return agent } diff --git a/packages/visualizer/tool-visualizer/tests/model-surface.spec.ts b/packages/visualizer/tool-visualizer/tests/model-surface.spec.ts index 2a41520e34..e47284fddc 100644 --- a/packages/visualizer/tool-visualizer/tests/model-surface.spec.ts +++ b/packages/visualizer/tool-visualizer/tests/model-surface.spec.ts @@ -100,7 +100,7 @@ function recordedAgent( agent, presetParent === undefined ? undefined : { parent: presetParent }, ) - Object.assign(agent, { ctx: scope.ctx.extend({ agent }) }) + Object.assign(agent, { ctx: scope.ctx }) return agent } @@ -278,14 +278,14 @@ describe('Visualizer model surface', () => { await ctx.plugin(VisualizerService) expect(() => { ctx.visualizer.installModelSurface(ctx) }) - .toThrow('tool-visualizer: the model contribution requires a scoped context without an Agent') + .toThrow('tool-visualizer: the model contribution requires a scoped context') expect(ctx.tools.schemas()).toEqual([]) expect((await ctx.systemPrompt.assemble()).sections .some(section => section.name === 'tool:visualizer')).toBe(false) await ctx.fiber.dispose() }) - it('rejects installing the model surface in one Agent\'s own scope', async () => { + it('uses an Agent scope only as the registration owner', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) @@ -295,11 +295,12 @@ describe('Visualizer model surface', () => { const agent = recordedAgent(ctx, 'own-model-agent') ctx.agents.register(agent) - expect(() => { ctx.visualizer.installModelSurface(agent.ctx) }) - .toThrow('tool-visualizer: the model contribution requires a scoped context without an Agent') - expect(ctx.tools.schemas(agent)).toEqual([]) + ctx.visualizer.installModelSurface(agent.ctx) + expect(ctx.tools.schemas()).toEqual([]) + expect(ctx.tools.schemas(agent).map(tool => tool.name).sort()) + .toEqual(['show_widget', 'widget_guidelines']) expect((await ctx.systemPrompt.assemble({ agent, scope: agent })).sections - .some(section => section.name === 'tool:visualizer')).toBe(false) + .some(section => section.name === 'tool:visualizer')).toBe(true) await ctx.fiber.dispose() }) From 6c87f030a334652b67b5aa4eea8a5c6eb92a1ab6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 19:46:52 +0800 Subject: [PATCH 12/15] fix(checks): preserve visualizer composition preset identifiers --- scripts/rescope-vendor.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index 5a73201bf0..243c9575e4 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -93,6 +93,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [ { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] }, { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] }, + { file: 'packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts', upstream: ['cordis'] }, // The preset's own composition: its header comment and its system prompt name // the preset a model mounts, so the scoped name would send the model after an // id no roster reports. @@ -163,6 +164,8 @@ const POSTCONDITIONS: readonly PostCondition[] = [ // The preset id the shipped composition documents to its own model. { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, { file: 'packages/preset/agent-presets/presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, + // The Visualizer composition checks name the preset in its id union and mounted-preset list. + { file: 'packages/visualizer/tool-visualizer/tests/loader-composition.spec.ts', text: "'cordis'", count: 2 }, ] /** From 5045631a9a27687902987397f87909d9c3b2b9f2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 20:18:59 +0800 Subject: [PATCH 13/15] chore: remove unrelated sidebar test notes from agent refactor --- .../2026-09-07-sidebar-responsive-tab-info.i18n.yaml | 4 ++-- .../architecture/2026-09-07-sidebar-responsive-tab-info.md | 2 -- .../architecture/2026-09-07-sidebar-responsive-tab-info.zh.md | 2 -- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml index 1ac2be26ab..0a04d3712e 100644 --- a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.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/architecture/2026-09-07-sidebar-responsive-tab-info.md -2026-09-07-sidebar-responsive-tab-info.md: a7a646d1d7525b305fbeade98e649a3345d97420 -2026-09-07-sidebar-responsive-tab-info.zh.md: 9366861804b6f46980e1f08b028c4fa805f7fa7d +2026-09-07-sidebar-responsive-tab-info.md: ade20148cccd2233224417f762e65492828943bb +2026-09-07-sidebar-responsive-tab-info.zh.md: 0903e4ff06ca187fcc1fd0e9d8de63f70d5cc427 diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md index a7a646d1d7..ade20148cc 100644 --- a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md @@ -28,6 +28,4 @@ This decision supersedes the flat owner-props choice in [tab types and navigatio ## Consequences -Browser comparisons of related panel rectangles and CSS insets capture all values in one page execution. Responsive grid updates can occur between browser round trips, so separately sampled rectangles do not establish alignment even when each sample is accurate. - Tab extensions use a framework-injected reader and keep their own store actions separate from `tab.actions`. Layout, seat and docking tests cover width concessions, explicit reopening, body/title visibility, tab lifetimes, horizontal drop zones and divider limits; browser tests exercise the assembled application. Compact mobile controls and layout persistence remain outside this decision. diff --git a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md index 9366861804..0903e4ff06 100644 --- a/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md @@ -28,6 +28,4 @@ Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与 ## 后果 -浏览器比较相关面板矩形与 CSS 内边距时,会在一次页面执行中获取全部值。响应式网格可能在浏览器往返调用之间更新,因此分次采样的矩形即使各自准确,也不能用于判断对齐。 - 标签扩展使用框架注入的读取接口,自身 store actions 与 `tab.actions` 保持分离。布局、seat 与停靠测试覆盖列宽让步、显式重开、正文与标题可见性、标签生命周期、水平放置区与分割比例;浏览器测试覆盖组装后的应用。紧凑移动端控件与布局持久化不属于本决策。 From d5e6b4e2b2e81cb0f790996ff13a3d1042ed87dc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 20:19:09 +0800 Subject: [PATCH 14/15] chore: remove unrelated PTY test repair from agent refactor --- ...26-07-16-persistent-pty-sessions.i18n.yaml | 4 +-- .../2026-07-16-persistent-pty-sessions.md | 2 -- .../2026-07-16-persistent-pty-sessions.zh.md | 2 -- .../terminal-bash/tests/local.spec.ts | 29 ++++++++++--------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml index 1a3fc023a5..b1b7365597 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.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-16-persistent-pty-sessions.md -2026-07-16-persistent-pty-sessions.md: 834f0e003350b97293d9d93ae0d8dd5259cfe1c1 -2026-07-16-persistent-pty-sessions.zh.md: fea4d0d025de71f5e753e5ed8e50065746c99a97 +2026-07-16-persistent-pty-sessions.md: d62587ced78be75de121fe907ca258d07a2830a1 +2026-07-16-persistent-pty-sessions.zh.md: a26c3ed852b3a18842585e19ecb2a6994f8d9117 diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md index 834f0e0033..d62587ced7 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.md @@ -158,8 +158,6 @@ The package ships concise tool guidance explaining persistent state, owner isola ## Verification -The disowned-descendant fixture's parent shell ignores `SIGTERM` before fork and publishes the child PID, so neither identity nor signal disposition depends on child startup. A file barrier also holds the descendant before its workload while natural shell exit and explicit close must still reach quiescence. - - Per-file coverage pins owner fencing, concurrent reservations, cancellation during pre-write inspection, unpublished-spawn cancellation and awaited teardown, sandbox-mode change rejection, retriable lifecycle cleanup, readiness tiers, rejection of pre-write stdin waits and delayed earlier prompts, the configured handoff grace holding the idle fallback past one poll and its rejection below `pollIntervalMs`, sanitizer carry state, complete UTF-8 bounds, task integration, schemas, and exact render intents. - Subprocess process fixtures cover non-leader and non-main-thread stdin waits, thread-local fd tables, the `/dev/tty` alias, supported kernel ABIs under user-mode emulation, rejection of fd 0 backed by a pipe, zombie quiescence, unreadable process state, unsupported architectures, and other false-positive rejection; macOS inspector logic is injected into the same unit suite. - Real `node-pty` and PTY-consumer tests jointly exercise shell state, controlling-terminal input through `/dev/tty`, the exact attribution when process syscalls are readable, its bounded idle fallback when host policy denies them, shared sandbox policy, environment scrubbing, raw-mode foreground `SIGINT`, a TERM-ignoring descendant, and immediate post-disposal quiescence. The Linux native smoke keeps the PTY PID, session leader, controlling terminal, foreground `inputWaiting`, and readiness while a reparented `setsid` descendant remains owned by the scope; fallback suites retain identity-fenced observational cleanup coverage. diff --git a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md index fea4d0d025..a26c3ed852 100644 --- a/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-persistent-pty-sessions.zh.md @@ -158,8 +158,6 @@ plugins: ## 验证 -脱离作业表的后代进程由父 shell 在 fork 前设置忽略 `SIGTERM`,并由父 shell 发布其 PID,因此身份与信号处置都不依赖子进程启动。文件屏障还会将后代进程阻挡在工作负载之前,此时 shell 自然退出与显式关闭仍必须使其完全停稳。 - - 逐文件覆盖测试锁定了 owner 隔离、并发预留、写入前检查期间的取消、未发布 spawn 的取消与等待式 teardown、沙箱模式变更拒绝、可重试的生命周期清理、就绪层级、对写入前 stdin 等待与延迟到达的先前 prompt 的拒绝、配置化交接宽限把 idle fallback 顶过一次轮询以及低于 `pollIntervalMs` 时的拒绝、sanitizer carry state、完整 UTF-8 结果上限、task 集成、schema 和精确 render intent。 - 子进程 fixture(测试前置数据)覆盖非 leader 与非主线程的 stdin 等待、线程本地 fd 表、`/dev/tty` 别名、用户态模拟下受支持的内核 ABI、拒绝把指向管道的 fd 0 当作终端输入、僵尸进程完全停稳、不可读进程状态、不支持的架构和其他误报拒绝;同一单元测试套件通过注入覆盖 macOS 检查器逻辑。 - 真实 `node-pty` 与 PTY 消费方测试共同覆盖 shell 状态、通过 `/dev/tty` 读取控制终端输入、进程 syscall 可读时的精确归因、宿主策略拒绝读取时的有界 idle fallback、共享沙箱策略、环境清洗、raw mode 前台 `SIGINT`、忽略 `SIGTERM` 的后代进程,以及 dispose 返回后立即完全停稳。Linux native 冒烟测试会在一个 reparent 的 `setsid` 后代仍由 scope 拥有时,保持 PTY PID、session leader、控制终端、前台 `inputWaiting` 与 readiness;fallback 测试套件继续覆盖带身份围栏的观察式清理。 diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts index 1c03e5e1a9..9800a79d54 100644 --- a/packages/terminal/terminal-bash/tests/local.spec.ts +++ b/packages/terminal/terminal-bash/tests/local.spec.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -238,24 +238,27 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => expect(() => process.kill(pid, 0)).toThrow() }, 10_000) - it.each([false, true])('quiesces a disowned same-session descendant after the shell exits naturally (hold descendant: %s)', async (holdDescendant) => { + it('quiesces a disowned same-session descendant after the shell exits naturally', async () => { const { ctx, root, agent } = await harness('danger-full-access') const created = await ctx.terminals.spawn(agent, { type: 'shell' }) - const releaseFile = join(root, 'release-descendant') - // Keep the descendant before its workload without delaying the parent's PID publication. - const barrier = holdDescendant ? 'while [ ! -e "$1" ]; do sleep 0.01; done; ' : '' + const pidFile = join(root, 'disowned.pid') let pid: number | undefined try { - // Ignore TERM before forking so even an unscheduled child inherits it. const background = ctx.terminals.startSend(agent, created.sessionId, { - text: `trap "" TERM; sh -c '${barrier}exec sleep 60' dsh "${releaseFile}" & disown; trap - TERM; echo CHILD=$!`, + text: `sh -c 'trap "" TERM; printf "%s" "$$" > "$1"; sleep 60' dsh "${pidFile}" & disown`, submit: true, }) - const result = await background.done - const childPid = Number(/CHILD=(\d+)/.exec(result.viewport)?.[1]) - if (childPid > 0) pid = childPid - expectReadyForNextSend(result.waitReason) - expect(childPid, result.viewport).toBeGreaterThan(0) + await background.done + const pidDeadline = Date.now() + 2_000 + let childPid = 0 + while (childPid === 0 && Date.now() < pidDeadline) { + if (existsSync(pidFile)) childPid = Number(readFileSync(pidFile, 'utf8')) + if (childPid > 0) break + await new Promise(resolve => setTimeout(resolve, 10)) + } + expect(existsSync(pidFile), ctx.terminals.read(agent, created.sessionId, { offset: 0, count: 100 }).text).toBe(true) + expect(childPid).toBeGreaterThan(0) + pid = childPid expect(() => process.kill(childPid, 0)).not.toThrow() await ctx.terminals.startSend(agent, created.sessionId, { text: 'exit', submit: true }).done const deadline = Date.now() + 2_000 @@ -272,8 +275,6 @@ describe.skipIf(process.platform === 'win32')('terminal-bash real shell', () => } catch (_alreadyReaped) { // Product cleanup is the expected path; this only contains a failed regression. } - const disposedPid = pid - await expect.poll(() => processIsRunning(disposedPid)).toBe(false) } } }, 10_000) From 3a98d05a3dc29aec960c89fbb45913a3a1ecc271 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 8 Sep 2026 20:19:19 +0800 Subject: [PATCH 15/15] refactor(subagent): keep preset teardown outside identity changes --- ...-explicit-agent-runtime-identity.i18n.yaml | 4 +- ...6-08-31-explicit-agent-runtime-identity.md | 4 +- ...8-31-explicit-agent-runtime-identity.zh.md | 4 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 54 ++++++------- .../tests/model-selection-settings.spec.ts | 79 +------------------ 8 files changed, 35 insertions(+), 118 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml index 124d6d3e8b..4fac92ed8c 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.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/architecture/2026-08-31-explicit-agent-runtime-identity.md -2026-08-31-explicit-agent-runtime-identity.md: 48c8d0c205b6b8c2331e2373b1e11142e05d715b -2026-08-31-explicit-agent-runtime-identity.zh.md: a8358f5a8f4629289dc75722ed82d79206db3dc5 +2026-08-31-explicit-agent-runtime-identity.md: f52b8ec116c312a27306fe73dc0bd5b99fcd9039 +2026-08-31-explicit-agent-runtime-identity.zh.md: 6b6fd2f2ea1f1645069264f09fd53c3f71e1d02f diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md index 48c8d0c205..f52b8ec116 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -16,7 +16,7 @@ Without an explicit owner, `SubagentContinuationManager` creates and resumes chi Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. -Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. A settings-backed standing preset reserves one Cordis cleanup effect for each matching Agent before sampling policy: the event payload supplies the Agent, its Session supplies the policy target, its Context owns the registrations, and the preset effect joins their removal after reparenting or preset unload. +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. `SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. @@ -26,7 +26,7 @@ The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. -Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling, inheritance, and removal of every installed definition before preset unload settles, including cleanup already started by reparenting or Agent disposal. +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md index a8358f5a8f..6b6fd2f2ea 100644 --- a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -16,7 +16,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 -感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。由设置控制的常驻 preset 在读取策略前,会为每个匹配 Agent 预留一个 Cordis 清理 effect:事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项,而 preset effect 会在重设父级或 preset 卸载后等待其清理完成。 +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 `SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 @@ -26,7 +26,7 @@ Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操 Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 -Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取、继承,以及 preset 卸载完成前会移除所有已安装定义,包括重设父级或 Agent 销毁时已启动的清理。 +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 ## 考虑过的替代方案 diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index a0a696a96d..2483058cd6 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/README.md -README.md: 01052e98ac9a8dcf3539ed20a9b0ca3ff5c6c2cd -README.zh.md: d90bafdeec1c60ce3f96be1546cb95a4c614f084 +README.md: 97bb9b143f003c830b68ffae9e25dee588a8fa67 +README.zh.md: 1804f8f5da2dcd8fbdd043d247e6c234ea090529 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 01052e98ac..97bb9b143f 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -80,7 +80,7 @@ This section explains how the tool mirrors provider lifecycle and settles runs; ### Design concept -One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. Direct Agent setup passes its unpublished Session explicitly and awaits installation before publication. A settings-backed standing preset receives each matching Agent from lifecycle events, selects policy from its Session, installs through its Context, and awaits removal of every owned fiber when the preset unloads, including cleanup already started by Agent disposal. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name. +One instance is one provider plus one tool name. The plugin mirrors provider lifecycle: it registers the tool when the named provider appears and disposes it when the provider leaves, so sibling load order and HMR replacement cannot strand a dangling tool. Direct Agent setup passes its unpublished Session explicitly and awaits installation before publication. A settings-backed standing preset receives each matching Agent from lifecycle events, selects policy from its Session, and installs through its Context. A numeric `maxDepth` or configured LLM selection the provider cannot enforce fails the mount instead of the first delegation. At most one instance in a tool scope may own model selection because `list_subagent_models` has a global name. ### Foreground settlement diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index d90bafdeec..1804f8f5da 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -80,7 +80,7 @@ kind: "package-reference" ### 设计理念 -一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。直接 Agent setup 显式传入尚未发布的 Session,并在发布前等待安装完成。由设置控制的常驻 preset 从生命周期事件接收每个匹配 Agent,从其 Session 选择策略,通过其 Context 安装,并在 preset 卸载时等待移除所有归它所有的 fiber,包括 Agent 销毁时已启动的清理。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。 +一个实例就是一个提供方加一个工具名称。插件镜像提供方生命周期:具名提供方出现时注册工具,提供方离开时释放工具,因此同级加载顺序与 HMR 替换不会让工具悬空。直接 Agent setup 显式传入尚未发布的 Session,并在发布前等待安装完成。由设置控制的常驻 preset 从生命周期事件接收每个匹配 Agent,从其 Session 选择策略,并通过其 Context 安装。提供方无法执行的数值型 `maxDepth` 或已配置 LLM 选择会在挂载时失败,而不是在首次委派时失败。每个工具作用域内最多一个实例可以拥有模型选择,因为 `list_subagent_models` 使用全局名称。 ### 前台结算 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 0871599c63..5ca770d7d7 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-tool-subagent */ -import { FiberState, type Context } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import { defineTool } from '@deepseek-ai/dsh-tools' @@ -657,57 +657,51 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const agents = ctx.get('agents') /* v8 ignore next -- shipped preset compositions always include the Agent registry. */ if (agents === undefined) throw new Error('tool-subagent: standing `modelSelectionSettings` requires the Agent registry') - const presetInstalls = new Map Promise>() + const scopedInstalls = new WeakMap>() + const installing = new WeakSet() const belongsToComposition = (candidate: Agent): boolean => scopeChainOf(scopeOf(candidate.ctx)).includes(compositionScope) - const installPresetDefinition = (candidate: Agent): void => { - if (ctx.fiber.uid === null || ctx.fiber.state === FiberState.UNLOADING || presetInstalls.has(candidate)) return - let fiber: ReturnType | undefined - const dispose = ctx.effect(() => async () => { - if (fiber !== undefined) { - await fiber.dispose() - // Agent teardown may already have claimed the single-shot disposer. - while (fiber.inertia !== undefined) await fiber.inertia - } - }, `tool-subagent: standing-preset definitions for Agent "${candidate.id}"`) - // Reserve before policy sampling or injection can re-enter reconciliation. - presetInstalls.set(candidate, dispose) + const installScoped = (candidate: Agent): void => { + if (scopedInstalls.has(candidate) || installing.has(candidate)) return + // Reserve before the injected fiber runs: tool registration emits + // `tools/change` synchronously, which re-enters the reconciliation below. + installing.add(candidate) + let fiber: ReturnType try { const policy = selectForSession(candidate.session) fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { install(runtimeCtx, policy) }) - } catch (error) { - presetInstalls.delete(candidate) - void dispose() - throw error + } finally { + installing.delete(candidate) } + scopedInstalls.set(candidate, fiber) } - const removePresetDefinition = (candidate: Agent): void => { - const dispose = presetInstalls.get(candidate) - if (dispose === undefined) return - presetInstalls.delete(candidate) + const removeScoped = (candidate: Agent): void => { + const fiber = scopedInstalls.get(candidate) + if (fiber === undefined) return + scopedInstalls.delete(candidate) /* v8 ignore next 3 -- Cordis Fiber disposal contains registration cleanup failures; this is the final diagnostic sink. */ - void dispose().catch((error: unknown) => { + void fiber.dispose().catch((error: unknown) => { ctx.logger.warn(`tool-subagent: failed to remove recomposed Agent "${candidate.id}" definitions: ${String(error)}`) }) } - const reconcilePresetDefinitions = (): void => { + const reconcileComposedAgents = (): void => { for (const candidate of agents.list()) { - if (belongsToComposition(candidate)) installPresetDefinition(candidate) - else removePresetDefinition(candidate) + if (belongsToComposition(candidate)) installScoped(candidate) + else removeScoped(candidate) } } // The preset-scoped listener admits descendant Agents and installs the // sampled tool definition in each Agent's own scope, so a later settings // change cannot mutate a live session. ctx.on('agent/created', ({ agent: created }) => { - installPresetDefinition(created) + installScoped(created) }) - ctx.on('agent/disposed', ({ agent: disposed }) => { removePresetDefinition(disposed) }) + ctx.on('agent/disposed', ({ agent: disposed }) => { removeScoped(disposed) }) // Reparenting an Agent between standing presets changes its inherited tool // set and emits `tools/change`; reconcile the Agent-owned override with the // new ancestry. Other registry changes are idempotent no-ops here. - ctx.on('tools/change', reconcilePresetDefinitions) - reconcilePresetDefinitions() + ctx.on('tools/change', reconcileComposedAgents) + reconcileComposedAgents() } diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 54252fff6b..f5921a3fab 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -241,19 +241,7 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(ToolInvariant) const preset = createScope(ctx, { preset: 'standard' }) const other = createScope(ctx, { preset: 'minimal' }) - const cleanupGate = Promise.withResolvers() - const cleanupStarted = Promise.withResolvers() - const cleanup = { done: false, blockedParent: undefined as Context | undefined } - ctx.on('internal/plugin', (fiber) => { - if (Object.keys(fiber.inject).sort().join(',') !== 'subagents,systemPrompt,tools') return - fiber.ctx.effect(() => async () => { - if (fiber.parent !== cleanup.blockedParent) return - cleanupStarted.resolve(undefined) - await cleanupGate.promise - cleanup.done = true - }, 'tool-subagent test: delayed preset cleanup') - }) - const mounted = await preset.ctx.plugin(tool, { + await preset.ctx.plugin(tool, { provider: 'spawn', modelSelectionSettings: true, backgroundMode: 'continuable', @@ -298,77 +286,12 @@ describe('SubagentModelSelectionConfig', () => { await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) - cleanup.blockedParent = enabled.agent.ctx - enabledBinding!.rebind(scopeOf(other.ctx)!) - ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') - await cleanupStarted.promise - const unloading = mounted.dispose() - setImmediate(() => { cleanupGate.resolve(undefined) }) - await unloading - const cleanupFinishedAtUnload = cleanup.done await enabled.dispose() - expect(cleanupFinishedAtUnload).toBe(true) - expect(selectable(ctx, enabled.agent)).toBe(false) - expect(selectable(ctx, disabled.agent)).toBe(false) - ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') await disabled.dispose() await ctx.fiber.dispose() }) - it('awaits Agent-started definition cleanup when a standing preset unloads', async () => { - const ctx = await boot(false) - const cleanupStarted = Promise.withResolvers() - const cleanupGate = Promise.withResolvers() - const settlements: string[] = [] - let definitionParent: Context | undefined - let disposingAgent: Promise | undefined - let unloadingPreset: Promise | undefined - try { - const preset = createScope(ctx, { preset: 'agent-first-cleanup' }) - ctx.on('internal/plugin', (fiber) => { - if (fiber.parent !== definitionParent - || Object.keys(fiber.inject).sort().join(',') !== 'subagents,systemPrompt,tools') return - fiber.ctx.effect(() => async () => { - cleanupStarted.resolve(undefined) - await cleanupGate.promise - settlements.push('definition-cleaned') - }, 'tool-subagent test: Agent-started cleanup barrier') - }) - const mounted = await preset.ctx.plugin(tool, { - provider: 'spawn', - modelSelectionSettings: true, - backgroundMode: 'continuable', - }) - const handle = await ctx.agents.create({ - sessionId: SessionId('agent-first-cleanup'), - setup: (agentCtx) => { - definitionParent = agentCtx - bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) - }, - }) - await vi.waitFor(() => { expect(ctx.tools.get('subagent', handle.agent)).toBeDefined() }) - - disposingAgent = handle.dispose() - await cleanupStarted.promise - unloadingPreset = Promise.resolve(mounted.dispose()).then(() => { - settlements.push('preset-unloaded') - }) - setImmediate(() => { cleanupGate.resolve(undefined) }) - await unloadingPreset - await disposingAgent - expect(settlements).toEqual(['definition-cleaned', 'preset-unloaded']) - expect(ctx.tools.get('subagent', handle.agent)).toBeUndefined() - } finally { - cleanupGate.resolve(undefined) - try { - await Promise.all([disposingAgent, unloadingPreset]) - } finally { - await ctx.fiber.dispose() - } - } - }) - it('releases a shared-preset installation reservation after policy selection fails', async () => { const ctx = await boot(false) const preset = createScope(ctx, { preset: 'standard' })