From ebce3a5f04045e85bd76e5133fa9ecd0befb2862 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 28 Aug 2026 17:44:11 +0800 Subject: [PATCH 01/83] 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/83] 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/83] 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/83] 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/83] 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/83] 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 5d9603b7638a9913c9fff952c1e54c9997cf033f Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Fri, 4 Sep 2026 16:14:56 +0800 Subject: [PATCH 07/83] feat(web): localize slash command descriptions --- ...eb-command-surfaces-and-assembly.i18n.yaml | 4 +- ...07-25-web-command-surfaces-and-assembly.md | 4 +- ...25-web-command-surfaces-and-assembly.zh.md | 4 +- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...8-23-locale-owned-client-ui-copy.i18n.yaml | 4 +- .../2026-08-23-locale-owned-client-ui-copy.md | 4 +- ...26-08-23-locale-owned-client-ui-copy.zh.md | 4 +- apps/web/tests/lifecycle-chrome.e2e.ts | 26 +- package.json | 1 + packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 4 +- packages/client/locale/README.zh.md | 4 +- packages/client/ui-commands/README.i18n.yaml | 4 +- packages/client/ui-commands/README.md | 4 +- packages/client/ui-commands/README.zh.md | 4 +- .../client/ui-commands/src/client/contract.ts | 4 +- .../client/ui-commands/src/client/locales.ts | 12 + .../client/ui-commands/src/client/service.ts | 25 +- .../ui-commands/tests/service.client.spec.ts | 35 ++- .../client/ui-input-trigger/README.i18n.yaml | 4 +- packages/client/ui-input-trigger/README.md | 2 +- packages/client/ui-input-trigger/README.zh.md | 2 +- .../ui-input-trigger/src/client/controller.ts | 10 + .../ui-input-trigger/src/client/service.ts | 4 + .../tests/service.client.spec.ts | 38 +++ .../ui-model-selection/src/client/index.ts | 6 +- scripts/run-gates.ts | 2 + .../verify-host-command-descriptions.spec.ts | 73 ++++++ scripts/verify-host-command-descriptions.ts | 227 ++++++++++++++++++ .../command-menu-zh.expected.md | 9 + 32 files changed, 492 insertions(+), 44 deletions(-) create mode 100644 scripts/verify-host-command-descriptions.spec.ts create mode 100644 scripts/verify-host-command-descriptions.ts create mode 100644 snapshots/web/lifecycle-chrome/command-menu-zh.expected.md diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index c04c301afa..f882a6977d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.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-25-web-command-surfaces-and-assembly.md -2026-07-25-web-command-surfaces-and-assembly.md: 943b68416d896c674783156b05997840c6df3255 -2026-07-25-web-command-surfaces-and-assembly.zh.md: c35d47202894af99377932df57e5de118186c1ce +2026-07-25-web-command-surfaces-and-assembly.md: bf3b14623b429709505dfeb9801945cce70b981e +2026-07-25-web-command-surfaces-and-assembly.zh.md: ea22fcb75bc4a035cbc36c068e341d59e1429046 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index 943b68416d..bf3b14623b 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -21,7 +21,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx - The `ClientSessionContext { sessionId }` projection is self-held in the ui-input-trigger contract (types.ts): sessions are always agent-backed, so session identity is the entire projection of command capability; the wire addresses by `{sessionId}` (both `command.list` and `command.execute`; the host resolves the Agent from the session header). - The directory is compartmented by `SessionId`, with per-key single-flight + an epoch guard (an old pull never overwrites newer state); `commands/changed` soft-invalidates every key (the old snapshot keeps serving while the repull runs in the background), `connection/reset` hard-invalidates every key and rewarms, Enter strong-waits on the current key, and a failure keeps the draft with no downgrade. Prewarming hangs on the source's `warm` hook — once over the full roster at scope birth, which covers the entire session lifecycle (session capability is constant from birth). -- `register(contribution)` registers client commands (a descriptor + `available(projection)` + a popupSelect spec); candidate synthesis = the host directory + contribution availability filtering, then the query/position pass, and a host/contribution name clash fails loud. +- `register(contribution)` registers client commands (a localized description resolver + `available(projection)` + a popupSelect spec); candidate synthesis combines the host directory with available contributions, localizes the six first-party Host descriptions only when their command name and canonical English copy both match, then applies query/position filtering. Scoped or third-party Host descriptions remain verbatim, and a host/contribution name clash fails loud. A locale switch re-fetches every open trigger controller in place, while a source gate keeps each first-party Host registration aligned with the English dictionary and `HOST_DESCRIPTION_KEYS`. - The three command kinds derive from the registration surfaces; developers never declare positions: a host descriptor with `input` = **leadingInput** (backfill `/name ␣` + claim, keep typing arguments, leading position only); a client-registered popupSelect spec = **popupSelect** (the official select-box shell, business ships zero components); neither = **execute** (run on selection, zero UI). - The dispatch decision table: the menu can trigger all three kinds; Space recognizes only leadingInput (the misfire defense: irreversible side effects keep explicit entry points only); Enter runs execute / opens the shell only on a bare token, while leadingInput tolerates trailing arguments. - The popup from `popupFor(actx)`: search filters locally, select is single-flight, the projection is captured at open, onSelect consumes the token through the consume-token event only on success, a failure is retained for retry, and a session switch merely hides it. The popup shell is a transient layer (never in the state machine): the box holds focus, Enter/↑↓/Escape belong to it, and clicking outside the box dismisses (clicking the textarea also returns focus). @@ -58,5 +58,5 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx - Shipping a business command = a host registration plus one client `command.register` (popupSelect) or zero registration (execute/leadingInput derive automatically), with zero skeleton changes; the cost is that the three-kind semantics concentrate in ui-commands, and a hypothetical fourth kind means changing it. - The resident directory cache plus push invalidation buys zero-latency menus and reliable enter adjudication; the cost is three invalidation paths (the change frame, reconnect, the epoch guard) that all need tests pinning them. -- sessionId addressing puts the host's per-agent effective directory (global + scoped shadows) straight on the wire, with the client presenting it as-is. +- sessionId addressing puts the host's per-agent effective directory (global + scoped shadows) straight on the wire. The client preserves command identity, input metadata, and noncanonical descriptions while resolving exact first-party description pairs through its current locale. - Known gaps: the popupSelect shell has no shipped business consumer yet (model selection and its kin arrive with the host `selectModel` work in live-mutation shape, serving as the onboarding template then); the queue's second cut (per-item Inbox operations), rich result cards, and roster configurability sit in the ledger awaiting their triggers. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index c35d472028..ea22fcb75b 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -21,7 +21,7 @@ Status: implemented - 投影 `ClientSessionContext { sessionId }` 自持于 ui-input-trigger 约定(types.ts):会话恒 agent-backed,会话身份即命令能力的全部投影;wire 以 `{sessionId}` 寻址(`command.list` / `command.execute` 均是;host 从会话 header 解析 Agent)。 - 目录按 `SessionId` 分区,per-key single-flight + epoch guard(旧拉取永不覆盖新态),`commands/changed` 全 key 软失效(旧快照继续服务、后台重拉)、`connection/reset` 全 key 硬失效并预热,Enter 必须等待当前 key 就绪、失败留草稿不降级。预热挂 source 的 `warm` 钩子——scope 出生时对全 roster 一次,即覆盖整个会话生命周期(会话能力自出生恒定)。 -- `register(contribution)` 注册 client 命令(descriptor + `available(projection)` + popupSelect spec);候选合成 = host 目录 + contribution 可用性过滤,再过 query/position,host/contribution 重名 fail loud。 +- `register(contribution)` 注册 client 命令(本地化说明解析函数 + `available(projection)` + popupSelect spec);候选合成会合并 host 目录与可用 contribution,仅在命令名和规范英文文案都匹配时本地化六个第一方 Host 说明,再执行 query/position 过滤。会话作用域内或第三方的 Host 说明保持原样,host/contribution 重名 fail loud。切换语言会原地重新拉取每个已打开触发器 controller 的候选项;源码门禁则保持每个第一方 Host 注册与英语字典及 `HOST_DESCRIPTION_KEYS` 一致。 - 命令三型按注册面派生,开发者不声明位置:host descriptor 带 `input` = **leadingInput**(回填 `/name ␣` + claim,继续打参数,仅限行首);client 注册 popupSelect spec = **popupSelect**(官方选择框壳,业务零组件);两者皆无 = **execute**(选中即执行,零 UI)。 - 派发决策表:菜单可触发三型;Space 只认 leadingInput(误触发防线:不可逆副作用只留显式入口);Enter 裸 token 才 execute/开壳、leadingInput 容忍尾随参数。 - `popupFor(actx)` 的 popup:search 本地过滤、select single-flight、open 时捕获投影、onSelect 成功才经 consume-token 事件消 token、失败保留可重试、会话切换只隐藏。popup 壳是瞬态层(不进状态机):框持焦点、Enter/↑↓/Escape 归它、点框外即 dismiss(点 textarea 同时归还焦点)。 @@ -58,5 +58,5 @@ Status: implemented - 业务命令上架 = host 注册 + client 一笔 `command.register`(popupSelect)或零注册(execute/leadingInput 自动派生),零骨架改动;代价是三型语义集中在 ui-commands,假想的第四型意味着改它。 - 常驻目录缓存 + 推失效换来菜单零延迟与回车裁决可靠;代价是三条失效路径(change 帧、重连、epoch guard)都需测试钉住。 -- sessionId 寻址让 host 的 per-agent 有效目录(全局 + scoped shadows)直接上 wire,client 原样呈现。 +- sessionId 寻址让 host 的 per-agent 有效目录(全局 + scoped shadows)直接上 wire。client 保留命令身份、输入元数据与非规范说明,同时通过当前 locale 解析完全匹配的第一方说明。 - 已知欠账:popupSelect 壳暂无已上架业务消费方(模型选择等将随 host `selectModel` 工作以 live-mutation 形态到来,届时作接入样板);队列第二刀(逐项 Inbox 操作)、富结果卡、roster 可配置性入台账待触发。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index 19e4ac9d22..0562d383a1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: 2d7c919d420f5681007843d5b8aae5c9c53cc275 -2026-07-30-client-locale-full-rollout.zh.md: 8546d06a365cabad50cd26c0f50e45e762671588 +2026-07-30-client-locale-full-rollout.md: f04c9f36ff0b7cda55c3df5aea7979baf98f3139 +2026-07-30-client-locale-full-rollout.zh.md: 0285b92ce7d797bdcb7f020b079114e2cd6839cd diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index 2d7c919d42..f04c9f36ff 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -18,7 +18,7 @@ After the typed locale standard seat landed (`locale:` on register → framework **Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionIndicator`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). -**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation. +**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider text, externally authored plugin or wire text, and protocol or code tokens remain verbatim data. Wire transport does not make first-party product copy external: the command UI recognizes each built-in Host description by its exact command name and canonical English source before translating it. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation. **Derivation layers keep display text out of identity.** ui-workspace's `relativeTime` returns structured `{unit, n}` composed with dictionary templates by the renderer; blank session titles and the Ungrouped label derive from the `blank` flag / absent `workspaceId`, while internal values stay empty or stable; **blank rows are excluded from search entirely** (a bilingual display title cannot match a single-language query stably). Dates use no Intl: format templates live in the dictionaries (message clock `clock.md`/`clock.ymd`, workspace hover `date.ymd`) and the formatters take `t` as a parameter. diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 8546d06a36..0285b92ce7 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -18,7 +18,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionIndicator` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。 -**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。 +**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方文本、外部编写的插件或 wire 文本以及协议或代码 token 仍作为数据原样呈现。经 wire 传输不会使第一方产品文案变成外部文本:命令 UI 会先用确切命令名与规范英文原文识别每个内置 Host 说明,再进行翻译。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。 **派生层不让展示文本承担身份。** ui-workspace 的 `relativeTime` 返回结构化 `{unit, n}`,由渲染组合字典模板;blank 会话标题和未分组 label 从 `blank` 标志/`workspaceId` 缺席派生,内部值保持为空或稳定;**搜索态 blank 行一律排除**(双语标题无法与单语查询稳定匹配)。日期不引 Intl:格式模板进字典(消息时钟 `clock.md`/`clock.ymd`,workspace hover `date.ymd`),格式化函数接收 `t` 参数。 diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml index a0680163b7..5790768485 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.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-23-locale-owned-client-ui-copy.md -2026-08-23-locale-owned-client-ui-copy.md: 5f645a34c386ba340c5a8d52e8bdef2258dd77bd -2026-08-23-locale-owned-client-ui-copy.zh.md: 996ef56b17637a4ca60b8793075e9975faf0e1e1 +2026-08-23-locale-owned-client-ui-copy.md: 29123976b4d8801ee6efca666494c863904f7597 +2026-08-23-locale-owned-client-ui-copy.zh.md: 42c3546796e48c9567cd0102bb0001bb59b199ba diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md index 5f645a34c3..29123976b4 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md @@ -10,7 +10,7 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d ## Decision -**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated. +**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, external plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated. First-party product copy transported over the wire remains product wording: the command UI translates a built-in Host description only after its command name and canonical English source both match, which preserves scoped shadows and third-party descriptions. **Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning. @@ -22,7 +22,7 @@ The product-authored error and design-literal exclusions, primitive defaults, an ## Verification -The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise both direct translated seats and locale-prop adapters, and the assembled web replay plus the required real-server GIF demonstrate the shipped locale switch on the actual trajectory surface. +The AST check's own Vitest spec pins direct JSX, template branches, semantic copy props, label data, formatter returns, locale-key calls, structural attributes, and dictionary owners. Locale dictionary parity pins identical `zh`/`en` keys. Client component suites exercise direct translated seats, locale-prop adapters, and command descriptions across a live locale change. The assembled web replay pins the Chinese built-in command menu, while the required real-server GIF demonstrates the shipped locale switch on the actual trajectory surface. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md index 996ef56b17..42c3546796 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md @@ -10,7 +10,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整 ## Decision -**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。 +**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、外部插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。经 wire 传输的第一方产品文案仍是产品措辞:命令 UI 只有在命令名和规范英文原文都匹配后才翻译内置 Host 说明,从而保留会话作用域内的同名覆盖与第三方说明。 **Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。 @@ -22,7 +22,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整 ## Verification -AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试同时覆盖直接翻译席位与 locale prop 适配器;组装 web 回放和规定的真实服务器 GIF 在实际 trajectory 界面上展示发布的语言切换。 +AST 检查自身的 Vitest spec 固定直接 JSX、模板分支、语义文案 prop、label 数据、格式化函数返回值、locale key 调用、结构属性和字典 owner。locale 字典对等性固定 `zh`/`en` key 一致。client 组件测试覆盖直接翻译席位、locale prop 适配器及实时 locale 切换前后的命令说明。组装 web 回放固定中文内置命令菜单,规定的真实服务器 GIF 则在实际 trajectory 界面上展示发布的语言切换。 ## Alternatives considered diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 3abee08a82..92d85a9c5c 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -23,7 +23,7 @@ import { launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) @@ -31,6 +31,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v2.jsonl') const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md') @@ -103,6 +104,26 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => menu.count()).toBe(0) }) + it.skipIf(MODE === 'record')('localizes slash-command descriptions from the browser language', async () => { + const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const zhTripwire = watchConsole(zhPage) + onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-command-menu-zh')) + try { + await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const launcher = zhPage.getByRole('button', { name: '指令' }) + await launcher.click() + const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) + await menu.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE) + expect(zhTripwire.pageErrors).toEqual([]) + expect(zhTripwire.warnings).toEqual([]) + } finally { + await zhPage.close() + } + }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { const activeScaffold = await launchWebScaffold() const activePage = await newEnglishPage(browser) @@ -392,7 +413,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v2.jsonl', 'replay.override.json', 'command-menu.expected.md', - 'command-menu-fuzzy.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.expected.md', + 'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md', + 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) diff --git a/package.json b/package.json index 4116523e99..97f63c6b74 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", + "verify-host-command-descriptions": "tsx scripts/verify-host-command-descriptions.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index a159c6cc4f..1ba228ccf7 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/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/client/locale/README.md -README.md: da0931ff5cf78b16d57354a8ac6abe6bf1878e50 -README.zh.md: 18eb233e80ba8a68621b2fa34442cdda3c4329a0 +README.md: bc77ee27e740595925e4c145b991d8088b7e98a8 +README.zh.md: ce68ea4191eb48d61c40233b6667c1bffea4ebde diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index da0931ff5c..bc77ee27e7 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -124,9 +124,9 @@ None; this package neither assembles nor sends a provider request. -These limits define where localization is incomplete or frozen at registration time. They are current package constraints, not a task backlog. +These limits define where localization requires explicit consumer support. They are current package constraints, not a task backlog. -- **Registry-held text reads its translation once** — copy captured at registration time outside the slot render path (e.g. the `/model` command description in the command registry) keeps the language it was registered under until re-registration; slot-rendered copy follows switches live. +- **Non-slot registries must retain a resolver** — `LocaleRuntime` refreshes subscribed render paths, but a consumer that stores the result of `t(...)` instead of a resolver keeps that language until re-registration. The command registry stores description resolvers so its rows follow language changes. - **Language packs own language-specific behavior** — the registry supplies selection, persistence, browser matching, key fallback, and ``; it does not add plural rules or bidirectional layout. diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 18eb233e80..ce68ea4191 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -124,9 +124,9 @@ Host 通过 settings 服务为 loopback 页面持久化偏好。Client 会刻意 -这些限制说明本地化在哪些地方不完整,或在注册时被冻结。它们是当前包约束,不是任务积压。 +这些限制说明本地化在哪些地方需要消费方明确配合。它们是当前包约束,不是任务积压。 -- **注册表持有的文本只读取一次翻译**——在 slot 渲染路径之外于注册时捕获的文案(例如 command 注册表中的 `/model` 命令描述)在重新注册前保持注册时的语言;slot 渲染的文案随切换实时更新。 +- **非 slot 注册表必须保留解析函数**——`LocaleRuntime` 会刷新已订阅的渲染路径,但消费方若存储 `t(...)` 的结果而非解析函数,该文本会保持注册时的语言,直至重新注册。命令注册表会存储说明解析函数,因此其菜单行会跟随语言切换。 - **语言包负责语言特有行为**——注册表提供选择、持久化、浏览器匹配、逐 key 回退和 ``;它不增加复数规则或双向布局。 diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index e87a9b0483..ca79ee414d 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/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/client/ui-commands/README.md -README.md: 045364d43e27d8be07ce5847af5d02e461d10aa6 -README.zh.md: aa9c5d8b0729202098f9323892da710f54d0f9a9 +README.md: 6eb3bdc76ad837dfa7720842b9998186943cd801 +README.zh.md: 7d8fd500940d3bfcc59606218442a50288097685 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index 045364d43e..6eb3bdc76a 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` so ### Kinds and decorations -A contribution is a client-owned command — a host-name collision fails loud. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first. +A contribution is a client-owned command — a host-name collision fails loud. Its description is a resolver read for every candidate request, so it follows the active locale. A decoration adds a bare-invocation popup to an EXISTING host command: the host command keeps its catalog row, its argument claim, and its lifecycle logging, and a decorated name with no host row in the session's directory never fires. The six shipped Host commands use bilingual descriptions when both the command name and canonical English description match; scoped shadows and third-party commands keep their reported copy verbatim. The `verify-host-command-descriptions` gate keeps those Host strings and the Client allowlist aligned. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names; prefixes rank first. ### Attachment-carrying submissions @@ -43,7 +43,7 @@ When the composer submits with images or generic files, only a host command decl
Implementation internals — click to expand -`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution. Decision record: the [web command surfaces note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md); the [fuzzy discovery note](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md) covers menu ranking. +`src/client/contract.ts` is the fixed business contract: `CommandUiContract.register(name, spec)` and `decorate(name, spec)` are everything a business package consumes. `CommandDirectory` is the one wire-derived cache, keyed by session: ordinary sessions fetch through `command.list({sessionId})`, entries are soft-invalidated by the forwarded `commands/change` owner event and hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. Candidate synthesis localizes exact built-in Host descriptors and reads contribution description resolvers through the current locale while leaving every other reported description unchanged. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure. After `command.execute` returns a matched result, the browser emits a local `command/executed` acknowledgment; other clients receive the durable command nodes through the Host event stream but never this acknowledgment. `PopupSelectController` is the headless shell state; `PopupSelectView` self-registers into `conversation.input.overlay` with per-session resolution. Decision record: the [web command surfaces note](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md); the [fuzzy discovery note](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md) covers menu ranking.
diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index aa9c5d8b07..7d8fd50094 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 种类与装饰 -贡献项是客户端自有命令——与宿主命令同名会明确报错。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。 +贡献项是客户端自有命令——与宿主命令同名会明确报错。它的说明是一个在每次请求候选项时读取的解析函数,因此会跟随当前语言。装饰为**已存在的**宿主命令添加裸调用弹窗:宿主命令保留其目录行、参数声明与生命周期记账,被装饰的名字在会话目录中无宿主行时永不触发。六个随产品发布的宿主命令仅在命令名和规范英文说明都匹配时使用双语说明;会话作用域内的同名覆盖与第三方命令仍原样显示宿主报告的文案。`verify-host-command-descriptions` 门禁会保持这些宿主文案与客户端允许列表一致。菜单查询按顺序且不区分大小写地模糊匹配命令名的子序列;前缀排名最高。 ### 带附件提交 @@ -43,7 +43,7 @@ composer 携带图片或通用文件提交时,只有声明了 `input.attachmen
实现细节——点击展开 -`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。决策记录:[Web 命令表面笔记](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md);[模糊发现笔记](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md) 说明菜单排名。 +`src/client/contract.ts` 是固定的业务约定:`CommandUiContract.register(name, spec)` 与 `decorate(name, spec)` 是业务包消费的全部内容。`CommandDirectory` 是唯一的 wire 派生缓存,以会话为 key:普通会话经 `command.list({sessionId})` 拉取;条目由转发的 `commands/change` owner 事件软失效、由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。候选项合成会本地化完全匹配的内置宿主说明,并通过当前 locale 读取贡献项的说明解析函数;其余宿主报告的说明保持不变。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝。`command.execute` 返回匹配结果后,浏览器发布本地 `command/executed` 确认;其他客户端经宿主事件流收到持久命令节点,但收不到这条确认。`PopupSelectController` 是不含界面的外壳状态;`PopupSelectView` 自注册进 `conversation.input.overlay`,按会话解析。决策记录:[Web 命令表面笔记](../../../.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md);[模糊发现笔记](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.zh.md)说明菜单排名。
diff --git a/packages/client/ui-commands/src/client/contract.ts b/packages/client/ui-commands/src/client/contract.ts index 4f4374735b..fe479990f4 100644 --- a/packages/client/ui-commands/src/client/contract.ts +++ b/packages/client/ui-commands/src/client/contract.ts @@ -46,8 +46,8 @@ export type CommandUiSpec = { export interface CommandContribution { /** Command name without the leading slash (unique across contributions). */ readonly name: string - /** Menu row description. */ - readonly description: string + /** Resolve the localized menu row description when candidates are requested. */ + readonly description: () => string /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean /** The command's UI behavior (this phase: popupSelect only). */ diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 384597a9b2..0a875d467c 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -2,6 +2,12 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'description.compact': '压缩以上对话内容', + 'description.export': '将当前会话内容导出为 ZIP', + 'description.feedback': '发送关于当前会话的反馈', + 'description.goal': '设置或查看长期任务目标', + 'description.permission': '切换权限预设(沙箱模式与审批策略)', + 'description.plan': '进入或退出计划模式', 'search.placeholder': '搜索…', 'search.aria': '筛选选项', 'status.loading': '正在加载选项…', @@ -17,6 +23,12 @@ export type CommandKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'description.compact': 'Compact older conversation history', + 'description.export': 'Download this Session log as a ZIP archive', + 'description.feedback': 'record feedback about this session', + 'description.goal': 'set or view the goal for a long-running task', + 'description.permission': 'Switch the permission preset (sandbox mode + approval policy)', + 'description.plan': 'Enter or leave plan mode', 'search.placeholder': 'Search…', 'search.aria': 'Filter options', 'status.loading': 'Loading options…', diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index ae7d350e46..f14f13f0be 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -26,6 +26,7 @@ import type { import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' import { CommandDirectory } from './directory.ts' +import { en, type CommandKey } from './locales.ts' import { PopupSelectController } from './popup.ts' import type { TokenSegment } from './popup.ts' @@ -58,6 +59,16 @@ interface LiveState { readonly popups: Map> } +/** Locale keys for the canonical first-party Host command descriptions. */ +const HOST_DESCRIPTION_KEYS = new Map([ + ['compact', 'description.compact'], + ['export', 'description.export'], + ['feedback', 'description.feedback'], + ['goal', 'description.goal'], + ['permission', 'description.permission'], + ['plan', 'description.plan'], +]) + /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -192,14 +203,18 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const seen = new Set() for (const c of list) { seen.add(c.name) - rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }) + rows.push({ + name: c.name, + description: this.hostDescription(c), + ...(c.input !== undefined ? { hint: c.input.hint } : {}), + }) } for (const contribution of this.live.contributions.values()) { if (!contribution.available(session)) continue if (seen.has(contribution.name)) { throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`) } - rows.push({ name: contribution.name, description: contribution.description }) + rows.push({ name: contribution.name, description: contribution.description() }) } return rankByName( rows.filter(c => req.position === 'leading' || c.hint === undefined), @@ -207,6 +222,12 @@ export class CommandUiRuntime extends Service implements CommandUiContract { ) } + /** Translate exact built-in Host copy while preserving scoped or third-party descriptors verbatim. */ + private hostDescription(command: CommandDescriptor): string { + const key = HOST_DESCRIPTION_KEYS.get(command.name) + return key !== undefined && command.description === en[key] ? this.t(key) : command.description + } + /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ private dispatch(pick: InputTriggerPick): PickOutcome { const name = pick.candidate.name diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 23ce7ae505..f387ebffc1 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -39,6 +39,7 @@ interface BenchOptions { /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }> execute?: (payload: { sessionId: SessionId; line: string }) => Promise + translate?: (namespace: string, key: string, params?: Record) => string addressed?: SessionId } @@ -98,7 +99,8 @@ async function bench(opts: BenchOptions = {}) { // Deterministic key-echo translator: notice assertions read `key{json}`. ctx.provide('locale', { bind: (ns: string) => (key: string, params?: Record) => - `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, + opts.translate?.(ns, key, params) + ?? `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, }) // Real scope tags behind a fake sessions face. const scopes = new Map } }>() @@ -164,7 +166,7 @@ const themeUi = (over: Partial = {}): CommandUiSpec => ({ const themeContribution = (over: Partial = {}): CommandContribution => ({ name: 'theme', - description: 'client popup kind', + description: () => 'client popup kind', available: () => true, ui: themeUi(), ...over, @@ -251,6 +253,35 @@ describe('candidates', () => { expect(names).toEqual(['theme']) }) + it('localizes canonical built-in and contribution descriptions on every candidate request', async () => { + let locale = 'zh' + const commands: CommandDescriptor[] = [ + { name: 'compact', description: 'Compact older conversation history' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + ] + const { command, source } = await bench({ + commands: () => Promise.resolve({ commands }), + translate: (namespace, key) => `${locale}:${namespace}:${key}`, + }) + command.register(themeContribution({ description: () => `${locale}:theme` })) + + await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ + { name: 'compact', description: 'zh:command:description.compact' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + { name: 'theme', description: 'zh:theme' }, + ]) + + locale = 'en' + await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ + { name: 'compact', description: 'en:command:description.compact' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + { name: 'theme', description: 'en:theme' }, + ]) + }) + it('a contribution/host name collision fails loud', async () => { const { command, source } = await bench() command.register(themeContribution({ name: 'plan' })) diff --git a/packages/client/ui-input-trigger/README.i18n.yaml b/packages/client/ui-input-trigger/README.i18n.yaml index 61bfb92d3f..a1b7996adf 100644 --- a/packages/client/ui-input-trigger/README.i18n.yaml +++ b/packages/client/ui-input-trigger/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/client/ui-input-trigger/README.md -README.md: 9ad568027e4be8f2e9cc641270cbf6b2f870c59c -README.zh.md: 2926eb87d4738387cf6dc56d38a02f579ed267c4 +README.md: 47747ee055c50ffa4be0116de9091074a0722367 +README.zh.md: e63a9d2b9397a15efde6e22b1a94de827f8bcc78 diff --git a/packages/client/ui-input-trigger/README.md b/packages/client/ui-input-trigger/README.md index 9ad568027e..47747ee055 100644 --- a/packages/client/ui-input-trigger/README.md +++ b/packages/client/ui-input-trigger/README.md @@ -39,7 +39,7 @@ The composer surface keeps focus while the menu is open: rows pick on mousedown,
Implementation internals — click to expand -`src/core/` is the pure core — trigger detection, menu reduction, and exact match, with zero React/DOM/cordis — while `src/client/service.ts` wires the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources dropping silently with a console record), and the pick paths. One `InputTriggerController` resolves per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. A source is warmed into every session controller it can reach; sources whose `lexicon` rolls change after warm implement `subscribeLexicon` and the controller re-polls on each notification. `MenuView` self-registers into `conversation.input.overlay` (list kind, session scope) and renders null while closed. The `listbox` role sits on its scrolling viewport rather than the bounded shell, because a breadcrumb header is not an option and a listbox may not carry one; crumbs ride their own snapshot store beside the menu store, so the frozen reducer stays unaware of them. The overlay SlotMap merge lives here because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import. +`src/core/` is the pure core — trigger detection, menu reduction, and exact match, with zero React/DOM/cordis — while `src/client/service.ts` wires the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources dropping silently with a console record), and the pick paths. One `InputTriggerController` resolves per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. A locale switch re-fetches candidates for every open controller without closing its menu or changing its query, so source-owned localized descriptions update immediately. A source is warmed into every session controller it can reach; sources whose `lexicon` rolls change after warm implement `subscribeLexicon` and the controller re-polls on each notification. `MenuView` self-registers into `conversation.input.overlay` (list kind, session scope) and renders null while closed. The `listbox` role sits on its scrolling viewport rather than the bounded shell, because a breadcrumb header is not an option and a listbox may not carry one; crumbs ride their own snapshot store beside the menu store, so the frozen reducer stays unaware of them. The overlay SlotMap merge lives here because the dependency direction (ui-conversation → ui-input-trigger) admits no reverse type import.
diff --git a/packages/client/ui-input-trigger/README.zh.md b/packages/client/ui-input-trigger/README.zh.md index 2926eb87d4..e63a9d2b93 100644 --- a/packages/client/ui-input-trigger/README.zh.md +++ b/packages/client/ui-input-trigger/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference"
实现细节——点击展开 -`src/core/` 是纯内核——触发器检测、菜单归约与精确匹配,零 React/DOM/cordis——而 `src/client/service.ts` 把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代、失败的 source 静默丢弃并留一条 console 记录)与 pick 路径上。每个会话 scope 各解析一个 `InputTriggerController`(`sessionOf`);对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 会被预热进它能触达的每个会话 controller;`lexicon` 名录在预热后变化的 source 实现 `subscribeLexicon`,controller 每收到通知就重拉。`MenuView` 自注册进 `conversation.input.overlay`(列表类,会话 scope),菜单关闭期间渲染 null。`listbox` 角色落在其滚动视口而非有界外壳上,因为面包屑头部不是选项,listbox 也不得承载它;面包屑走菜单 store 之外的独立快照 store,冻结的归约器因此对它一无所知。overlay 的 SlotMap 合并放在本包,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。 +`src/core/` 是纯内核——触发器检测、菜单归约与精确匹配,零 React/DOM/cordis——而 `src/client/service.ts` 把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代、失败的 source 静默丢弃并留一条 console 记录)与 pick 路径上。每个会话 scope 各解析一个 `InputTriggerController`(`sessionOf`);对话接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。切换语言时,每个打开的 controller 都会重新拉取候选项,不关闭菜单也不改变查询,因此 source 提供的本地化说明会立即更新。source 会被预热进它能触达的每个会话 controller;`lexicon` 名录在预热后变化的 source 实现 `subscribeLexicon`,controller 每收到通知就重拉。`MenuView` 自注册进 `conversation.input.overlay`(列表类,会话 scope),菜单关闭期间渲染 null。`listbox` 角色落在其滚动视口而非有界外壳上,因为面包屑头部不是选项,listbox 也不得承载它;面包屑走菜单 store 之外的独立快照 store,冻结的归约器因此对它一无所知。overlay 的 SlotMap 合并放在本包,因为依赖方向(ui-conversation → ui-input-trigger)不允许反向的类型导入。
diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 7821885d85..d8e5c2a5cb 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -364,6 +364,16 @@ export class InputTriggerController { this.reduce({ type: 'close' }) } + /** Re-fetch the currently open menu without changing its hit or visible rows. */ + refreshOpenMenu(): void { + if (this.disposed || !this.menu.getSnapshot().open || this.hit === null) return + const launched = this.launcher.getSnapshot() + const roster = this.deps.roster.sources(this.hit.trigger) + .filter(source => launched === null || source.name === launched) + if (roster.length === 0) return + this.fetchCandidates(this.hit, roster) + } + /** Scope teardown: close and abort (the service deletes the map entry). */ dispose(): void { this.disposed = true diff --git a/packages/client/ui-input-trigger/src/client/service.ts b/packages/client/ui-input-trigger/src/client/service.ts index a22627b398..54ccb299da 100644 --- a/packages/client/ui-input-trigger/src/client/service.ts +++ b/packages/client/ui-input-trigger/src/client/service.ts @@ -9,6 +9,7 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerSource } from '../types.ts' import { InputTriggerController } from './controller.ts' @@ -37,6 +38,9 @@ export class InputTriggerService extends Service implements InputTriggerServiceC */ constructor(ctx: Context) { super(ctx, 'inputTriggers') + ctx.on('locale/change', () => { + for (const controller of this.live.controllers.values()) controller.refreshOpenMenu() + }) } /** diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index a1243a7513..563d5f8a92 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -10,6 +10,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' +import type { LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { InputTriggerController, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { @@ -225,6 +226,43 @@ describe('sessionOf', () => { expect(ca.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }]) expect(cb.menu.getSnapshot().open).toBe(false) }) + + it('re-fetches every open menu when the active locale changes', async () => { + const { root, inputTriggers, mint } = await serviceBench() + let locale = 'en' + const candidates = vi.fn(() => Promise.resolve([{ name: 'compact', description: locale }])) + inputTriggers.registerSource({ + trigger: '/', + name: 'command', + candidates, + onPick: () => undefined, + }) + const first = inputTriggers.sessionOf(mint('a').actx) + const second = inputTriggers.sessionOf(mint('b').actx) + const closed = inputTriggers.sessionOf(mint('c').actx) + first.track('/c', 2, { tier: 'plain' }, 1) + second.track('/c', 2, { tier: 'plain' }, 1) + await tick() + expect(first.menu.getSnapshot()).toMatchObject({ + open: true, + hit: { query: 'c' }, + groups: [{ source: 'command', status: 'ready', items: [{ name: 'compact', description: 'en' }] }], + }) + + locale = 'zh' + root.emit('locale/change', { active: 'zh', locales: [], revision: 1 } as LocaleSnapshot) + expect(first.menu.getSnapshot().open).toBe(true) + expect(second.menu.getSnapshot().open).toBe(true) + await tick() + expect(candidates).toHaveBeenCalledTimes(4) + expect(first.menu.getSnapshot()).toMatchObject({ + open: true, + hit: { query: 'c' }, + groups: [{ source: 'command', status: 'ready', items: [{ name: 'compact', description: 'zh' }] }], + }) + expect(second.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'compact', description: 'zh' }]) + expect(closed.menu.getSnapshot().open).toBe(false) + }) }) describe('track', () => { diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index dd7d4a89fd..b6c9e0562b 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -120,16 +120,14 @@ export function apply(ctx: ClientContext): void { // a locale change reaches the next publish. ctx.plugin(ModelDirectoryResolver, { blockReason: () => t('blocked.composer') }) - // Entry 1: the /model popupSelect over the shared directory. The command - // description is registry-held text: it reads t() once at registration and - // refreshes only on re-registration, not on locale change. + // Entry 1: the /model popupSelect over the shared directory. ctx.inject(['commandUi', 'modelDirectories'], (scope: ClientContext) => { const command = scope.get('commandUi') as CommandUiContract const models = scope.modelDirectories const sessions = scope.sessions scope.effect(() => command.register({ name: 'model', - description: t('command.description'), + description: () => t('command.description'), available: session => sessions.subagentAddress(session.sessionId) === undefined, ui: { kind: 'popupSelect', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index a98533e73f..1fc7ac1aca 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -304,6 +304,7 @@ function ciSharedStaticGates(): Gate[] { }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), + pnpmScript('host-command-descriptions', 'verify-host-command-descriptions', { label: 'Host command descriptions' }), pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] @@ -700,6 +701,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), + pnpmScript('host-command-descriptions', 'verify-host-command-descriptions', { label: 'Host command descriptions' }), pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), ] } diff --git a/scripts/verify-host-command-descriptions.spec.ts b/scripts/verify-host-command-descriptions.spec.ts new file mode 100644 index 0000000000..48459ffcdc --- /dev/null +++ b/scripts/verify-host-command-descriptions.spec.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + findHostCommandRegistrations, + hostCommandDescriptionViolations, +} from './verify-host-command-descriptions.ts' + +const locales = ` + export const en = { + 'description.compact': 'Compact older conversation history', + 'description.plan': 'Enter or leave plan mode', + } +` + +const service = ` + const HOST_DESCRIPTION_KEYS = new Map([ + ['compact', 'description.compact'], + ['plan', 'description.plan'], + ]) +` + +describe('Host command description alignment', () => { + it('discovers direct object registrations through any commands receiver', () => { + const registrations = findHostCommandRegistrations('packages/example/src/index.ts', ` + yield ctx.commands.register({ name: 'compact', description: 'Compact older conversation history', handler }) + commandCtx.commands.register({ name: \`plan\`, description: \`Enter or leave plan mode\`, handler }) + commandUi.register({ name: 'client-only', description: 'Client contribution' }) + ctx.commands.register(definition) + `) + expect(registrations).toEqual([ + { + file: 'packages/example/src/index.ts', + line: 2, + name: 'compact', + description: 'Compact older conversation history', + }, + { + file: 'packages/example/src/index.ts', + line: 3, + name: 'plan', + description: 'Enter or leave plan mode', + }, + { file: 'packages/example/src/index.ts', line: 5 }, + ]) + }) + + it('accepts exact Host, English dictionary, and allowlist alignment', () => { + const registrations = findHostCommandRegistrations('packages/example/src/index.ts', ` + ctx.commands.register({ name: 'compact', description: 'Compact older conversation history' }) + ctx.commands.register({ name: 'plan', description: 'Enter or leave plan mode' }) + `) + expect(hostCommandDescriptionViolations(registrations, locales, service)).toEqual([]) + }) + + it('rejects non-literals, missing allowlist entries, drift, duplicates, and stale entries', () => { + const registrations = [ + ...findHostCommandRegistrations('packages/example/src/a.ts', ` + ctx.commands.register({ name: commandName, description: 'dynamic' }) + ctx.commands.register({ name: 'compact', description: 'Changed upstream' }) + ctx.commands.register({ name: 'compact', description: 'Changed upstream' }) + `), + ] + expect(hostCommandDescriptionViolations( + registrations, + 'export const en = { \'description.compact\': \'Old copy\' }', + 'const HOST_DESCRIPTION_KEYS = new Map([[\'compact\', \'description.compact\'], [\'stale\', \'description.stale\']])', + )).toEqual([ + 'packages/example/src/a.ts:2 Host command name and description must be string literals.', + 'packages/example/src/a.ts:4 duplicates /compact, first registered at packages/example/src/a.ts:3.', + 'packages/client/ui-commands/src/client/locales.ts description.compact must equal the Host description "Changed upstream".', + 'packages/client/ui-commands/src/client/service.ts HOST_DESCRIPTION_KEYS contains stale /stale.', + ]) + }) +}) diff --git a/scripts/verify-host-command-descriptions.ts b/scripts/verify-host-command-descriptions.ts new file mode 100644 index 0000000000..e66263659f --- /dev/null +++ b/scripts/verify-host-command-descriptions.ts @@ -0,0 +1,227 @@ +/** + * Keep first-party Host command descriptions aligned with the Client's + * English locale dictionary and localization allowlist. + */ +import { globSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import ts from 'typescript' + +const root = resolve(import.meta.dirname, '..') +const MINIMUM_HOST_SOURCES = 1_500 +const MINIMUM_HOST_COMMANDS = 6 +const LOCALES_FILE = 'packages/client/ui-commands/src/client/locales.ts' +const SERVICE_FILE = 'packages/client/ui-commands/src/client/service.ts' + +/** One literal `*.commands.register({ name, description })` call. */ +export interface HostCommandRegistration { + file: string + line: number + name?: string + description?: string +} + +function stringProperty(object: ts.ObjectLiteralExpression, key: string): string | undefined { + for (const property of object.properties) { + if (!ts.isPropertyAssignment(property)) continue + const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined + if (name !== key) continue + return ts.isStringLiteral(property.initializer) || ts.isNoSubstitutionTemplateLiteral(property.initializer) + ? property.initializer.text + : undefined + } + return undefined +} + +function isCommandsRegister(call: ts.CallExpression): boolean { + const register = call.expression + return ts.isPropertyAccessExpression(register) + && register.name.text === 'register' + && ts.isPropertyAccessExpression(register.expression) + && register.expression.name.text === 'commands' +} + +function unwrapExpression(expression: ts.Expression): ts.Expression { + let current = expression + while (ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isSatisfiesExpression(current) + || ts.isTypeAssertionExpression(current)) { + current = current.expression + } + return current +} + +/** + * Find literal Host command registrations in one production source. + * @param file - Repository-relative path used in diagnostics. + * @param sourceText - TypeScript source. + * @returns Registration calls in source order; missing literal fields remain undefined. + */ +export function findHostCommandRegistrations(file: string, sourceText: string): HostCommandRegistration[] { + const source = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const registrations: HostCommandRegistration[] = [] + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node) && isCommandsRegister(node)) { + const argument = node.arguments[0] + const position = source.getLineAndCharacterOfPosition(node.getStart(source)) + const registration: HostCommandRegistration = { file, line: position.line + 1 } + if (argument !== undefined && ts.isObjectLiteralExpression(argument)) { + const name = stringProperty(argument, 'name') + const description = stringProperty(argument, 'description') + if (name !== undefined) registration.name = name + if (description !== undefined) registration.description = description + } + registrations.push(registration) + } + ts.forEachChild(node, visit) + } + visit(source) + return registrations +} + +/** Read string properties from a named object-literal variable. */ +function objectDictionary(sourceText: string, variableName: string): Map { + const source = ts.createSourceFile(variableName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const values = new Map() + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.name.text === variableName + && node.initializer !== undefined) { + const initializer = unwrapExpression(node.initializer) + if (!ts.isObjectLiteralExpression(initializer)) return + for (const property of initializer.properties) { + if (!ts.isPropertyAssignment(property)) continue + const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined + const value = ts.isStringLiteral(property.initializer) || ts.isNoSubstitutionTemplateLiteral(property.initializer) + ? property.initializer.text + : undefined + if (key !== undefined && value !== undefined) values.set(key, value) + } + } + ts.forEachChild(node, visit) + } + visit(source) + return values +} + +/** Read `[command, localeKey]` tuples from `HOST_DESCRIPTION_KEYS`. */ +function hostDescriptionKeys(sourceText: string): Map { + const source = ts.createSourceFile(SERVICE_FILE, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const values = new Map() + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) + && ts.isIdentifier(node.name) + && node.name.text === 'HOST_DESCRIPTION_KEYS' + && node.initializer !== undefined + && ts.isNewExpression(node.initializer)) { + const entries = node.initializer.arguments?.[0] + if (entries !== undefined && ts.isArrayLiteralExpression(entries)) { + for (const entry of entries.elements) { + if (!ts.isArrayLiteralExpression(entry) || entry.elements.length !== 2) continue + const name = entry.elements[0] + const key = entry.elements[1] + if (name !== undefined && key !== undefined && ts.isStringLiteral(name) && ts.isStringLiteral(key)) { + values.set(name.text, key.text) + } + } + } + } + ts.forEachChild(node, visit) + } + visit(source) + return values +} + +/** + * Compare Host registrations with the Client's English strings and allowlist. + * @param registrations - Syntax-aware production registrations. + * @param localesSource - `ui-commands` locale dictionary source. + * @param serviceSource - `ui-commands` service source owning the allowlist. + * @returns Human-readable violations. + */ +export function hostCommandDescriptionViolations( + registrations: readonly HostCommandRegistration[], + localesSource: string, + serviceSource: string, +): string[] { + const violations: string[] = [] + const commands = new Map() + for (const registration of registrations) { + const where = `${registration.file}:${registration.line}` + if (registration.name === undefined || registration.description === undefined) { + violations.push(`${where} Host command name and description must be string literals.`) + continue + } + const prior = commands.get(registration.name) + if (prior !== undefined) { + violations.push(`${where} duplicates /${registration.name}, first registered at ${prior.file}:${prior.line}.`) + continue + } + commands.set(registration.name, registration) + } + + const english = objectDictionary(localesSource, 'en') + const keys = hostDescriptionKeys(serviceSource) + for (const [name, registration] of commands) { + const expectedKey = `description.${name}` + const key = keys.get(name) + if (key !== expectedKey) { + violations.push(`${registration.file}:${registration.line} /${name} must map to ${expectedKey} in HOST_DESCRIPTION_KEYS.`) + continue + } + const localizedEnglish = english.get(key) + if (localizedEnglish !== registration.description) { + violations.push( + `${LOCALES_FILE} ${key} must equal the Host description ${JSON.stringify(registration.description)}.`, + ) + } + } + for (const name of keys.keys()) { + if (!commands.has(name)) violations.push(`${SERVICE_FILE} HOST_DESCRIPTION_KEYS contains stale /${name}.`) + } + return violations +} + +function sourceFiles(): string[] { + return globSync('packages/*/*/src/**/*.{ts,tsx}', { cwd: root }) + .map(file => file.replaceAll('\\', '/')) + .filter(file => !file.endsWith('.d.ts')) + .sort() +} + +function main(): void { + const files = sourceFiles() + if (files.length < MINIMUM_HOST_SOURCES) { + throw new Error( + `verify-host-command-descriptions: discovery narrowed to ${files.length} source file(s); expected at least ${MINIMUM_HOST_SOURCES}.`, + ) + } + const registrations = files.flatMap(file => + findHostCommandRegistrations(file, readFileSync(resolve(root, file), 'utf8'))) + if (registrations.length < MINIMUM_HOST_COMMANDS) { + throw new Error( + `verify-host-command-descriptions: discovery found ${registrations.length} Host command registration(s); expected at least ${MINIMUM_HOST_COMMANDS}.`, + ) + } + const violations = hostCommandDescriptionViolations( + registrations, + readFileSync(resolve(root, LOCALES_FILE), 'utf8'), + readFileSync(resolve(root, SERVICE_FILE), 'utf8'), + ) + if (violations.length > 0) { + console.error(`verify-host-command-descriptions: ${violations.length} violation(s):`) + for (const violation of violations) console.error(` ${violation}`) + process.exitCode = 1 + return + } + console.log( + `verify-host-command-descriptions: ${registrations.length} Host command description(s) match the Client English dictionary.`, + ) +} + +if (import.meta.filename === resolve(process.argv[1] ?? '')) main() diff --git a/snapshots/web/lifecycle-chrome/command-menu-zh.expected.md b/snapshots/web/lifecycle-chrome/command-menu-zh.expected.md new file mode 100644 index 0000000000..c093f3c096 --- /dev/null +++ b/snapshots/web/lifecycle-chrome/command-menu-zh.expected.md @@ -0,0 +1,9 @@ +- listbox "触发候选建议": + - text: 指令 + - option "compact 压缩以上对话内容" [selected] + - option "export 将当前会话内容导出为 ZIP" + - option "feedback 发送关于当前会话的反馈" + - option "goal 设置或查看长期任务目标" + - option "permission 切换权限预设(沙箱模式与审批策略)" + - option "plan 进入或退出计划模式" + - option "model 选择本会话使用的模型" From a19065017fc38e5dc0e558b279b927b76e0f1b83 Mon Sep 17 00:00:00 2001 From: Kaige-Gao <102539609+Kaige-Gao@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:30:46 +0800 Subject: [PATCH 08/83] chore(ci): deduplicate client static gates --- scripts/run-gates.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 1fc7ac1aca..52f854fc17 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -290,6 +290,15 @@ export function gatesForMode(selected: Mode): Gate[] { } } +function clientStaticGates(): Gate[] { + return [ + pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), + pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), + pnpmScript('host-command-descriptions', 'verify-host-command-descriptions', { label: 'Host command descriptions' }), + pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), + ] +} + function ciSharedStaticGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), @@ -302,10 +311,7 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { label: 'optional dependency imports', }), - pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), - pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), - pnpmScript('host-command-descriptions', 'verify-host-command-descriptions', { label: 'Host command descriptions' }), - pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), + ...clientStaticGates(), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] } @@ -699,10 +705,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { label: 'optional dependency imports', }), - pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), - pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), - pnpmScript('host-command-descriptions', 'verify-host-command-descriptions', { label: 'Host command descriptions' }), - pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), + ...clientStaticGates(), ] } From 8b94ed9c21fa7329edbb46016defce9205b75833 Mon Sep 17 00:00:00 2001 From: Kaige-Gao <102539609+Kaige-Gao@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:59:46 +0800 Subject: [PATCH 09/83] test: align hygiene gate expectation --- scripts/run-gates.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 4e5e12e2bf..18d66b4d77 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -184,8 +184,8 @@ describe('gate graph validation', () => { expect(ids).toEqual([ 'rescope-vendor', 'publint', 'constraints', 'package-dependencies', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', - 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config', - 'runtime-closure', 'vendored-links', + 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'host-command-descriptions', + 'no-bare-dispatcher', 'cordis-config', 'runtime-closure', 'vendored-links', ]) expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ workers: 4, From 8551b04e95ea3e9b79380d15f90d571fd2fa0803 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 7 Sep 2026 12:11:18 +0800 Subject: [PATCH 10/83] 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 9da174c3043aece0f71eb972dc2133d13e5c52a9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 7 Sep 2026 15:34:19 +0800 Subject: [PATCH 11/83] fix(web): display local media paths referenced in session prose Assistant prose that references a workspace-contained local media path (e.g. `![](/Users/.../x.png)`) now renders through a same-origin `GET|HEAD /api/file?path=` route instead of inert alt text. - ui-primitives: MarkdownText gains a settled-only MarkdownPathImages vocabulary gate (same posture as file mentions); no vocabulary means byte-identical output. - ui-chat: AssistantMarkdown supplies a page-stable rewrite vocabulary for absolute POSIX paths (local-path-media.ts). - session-controller: SessionMediaReferences plugin contribution mounts the route on the authenticated connection.fetch channel; per-request policy = workspace-root containment after realpath, regular file, allowlisted media extension (images additionally signature-checked), range/HEAD streaming, private no-store + nosniff, fail-closed statuses. - Agent Note added (feature/2026-09-07-session-prose-local-media-display). Closes #3662. --- ...ession-prose-local-media-display.i18n.yaml | 6 + ...09-07-session-prose-local-media-display.md | 39 +++ ...07-session-prose-local-media-display.zh.md | 39 +++ packages/api/session-controller/src/index.ts | 2 + .../src/media-references.ts | 257 +++++++++++++++++ .../tests/media-references.host.spec.ts | 265 ++++++++++++++++++ .../api/session-controller/tsconfig.host.json | 2 + .../src/client/chat/AssistantMarkdown.tsx | 11 +- .../src/client/chat/local-path-media.ts | 24 ++ ...stant-markdown-path-images.client.spec.tsx | 46 +++ .../tests/local-path-media.client.spec.ts | 30 ++ packages/client/ui-primitives/src/index.ts | 2 +- .../src/markdown/MarkdownText.tsx | 21 +- .../ui-primitives/src/markdown/render.tsx | 57 +++- .../markdown-path-images.client.spec.tsx | 83 ++++++ .../markdown-render-units.client.spec.tsx | 1 + 16 files changed, 872 insertions(+), 13 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md create mode 100644 .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md create mode 100644 packages/api/session-controller/src/media-references.ts create mode 100644 packages/api/session-controller/tests/media-references.host.spec.ts create mode 100644 packages/client/ui-chat/src/client/chat/local-path-media.ts create mode 100644 packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx create mode 100644 packages/client/ui-chat/tests/local-path-media.client.spec.ts create mode 100644 packages/client/ui-primitives/tests/markdown-path-images.client.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml new file mode 100644 index 0000000000..db2a7595b2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.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/feature/2026-09-07-session-prose-local-media-display.md +2026-09-07-session-prose-local-media-display.md: e2693a6192cb78641d8a96937b39a4b06e884f04 +2026-09-07-session-prose-local-media-display.zh.md: bc0c62868e3ffab44fc3effc1030d9caee331f26 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md new file mode 100644 index 0000000000..e2693a6192 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md @@ -0,0 +1,39 @@ +# Agent Note: Session prose local media paths display through a same-origin file route + +Status: implemented + +English | [中文](2026-09-07-session-prose-local-media-display.zh.md) + +## Problem + +Assistant prose sometimes references an image by its local filesystem path (markdown `![](/Users/.../x.png)`). The Web renderer only allowed absolute HTTP(S) image destinations, so such references fell back to inert alt text: the browser cannot read Host files, and nothing served them. Searches of the formal and external issue trackers found no existing record, and issue #3662 logged the gap (Web cannot display local-path images referenced by agent answers). + +## Decision + +Local media paths in session prose render through one same-origin file route, with the rewrite vocabulary and the serving policy each owned where the repo's seams say they belong. + +- **Renderer seam (`ui-primitives`)**: `MarkdownText` gained a `MarkdownPathImages` vocabulary (`pathImages` prop) with the same settled-only gate as `fileMentions`: while a message streams, frozen cached blocks never bake in a vocabulary handler; the settled pass rewrites image destinations that fail the remote-URL allowlist, and a rewritten destination is emitted only when it is an absolute `http(s)`/`blob`/`data` URL. Without a vocabulary the renderer output is byte-identical to before. +- **Chat wiring (`ui-chat`)**: `AssistantMarkdown` supplies a page-stable vocabulary mapping absolute POSIX paths to same-origin `/api/file?path=…` GETs (`local-path-media.ts`); non-HTTP transports (Electron `file://`) and relative/protocol-relative destinations stay inert. +- **Host route (`session-controller`)**: `SessionMediaReferences`, a plugin contribution registered beside `SessionFileReferences`, mounts `GET|HEAD /api/file` on the shared authenticated `connection.fetch` channel (same trust fence and browser authentication as `/api` RPC). Per request it fail-closes: the path must be absolute, its `realpath` must lie inside a registered workspace root, the file must be regular, and its extension must name an allowlisted media type (PNG/JPEG/GIF/WebP/AVIF, MP4/WebM/MOV/OGG, MP3/WAV/Ogg/M4A/AAC/FLAC); image extensions are additionally checked against their byte signature. Responses stream with HTTP range support (206/416) so video and audio can seek, and carry `private, no-store` and `nosniff`. The contribution activates only where `connection` and `workspaceRegistry` are composed (pending-until-composed, like the package's other optional contributions). + +The route is presentational and stateless: it never writes, follows no redirects, and returns 400/403/404/415/416 instead of approximating another file-serving behavior. + +## Alternatives considered + +- **Register on the Typert gateway**: the gateway owns Remote RPC dispatch (endpoint claims, WebSocket mux, forwarded events), not file serving; placing the route there put HTTP presentation of workspace files into the RPC transport layer. Rejected and fully reverted. +- **Register under `workspace-controller`**: that package owns workspace registry lifecycle (CRUD, ordering, feed), and shares only the registry as a policy data source. Rejected and fully reverted. +- **Fetch bytes over the session RPC and show blob/data URLs**: attachment images already do this, but markdown rewriting needs a deterministic synchronous URL at render time (streaming freeze caches, memoization); an async round trip cannot be the render seam. Rejected. +- **A per-image or image-only route**: media types share one path-and-contain policy; video/audio need range streaming anyway. One `/api/file` route with an extension allowlist plus signature checks for images covers all current and next media types. Rejected as narrower alternatives. +- **Per-request interactive authorization, client-negotiated endpoints, or arbitrary host paths**: the rewrite is presentation; the route re-validates every request and limits readable bytes to registered workspace roots and allowlisted media, and the same-origin endpoint is a fixed channel contract rather than a negotiated capability. Rejected for security and determinism reasons. + +## Consequences + +- Assistant prose that references workspace-contained image files now displays them in Web chat; previously inert alt text disappears only where the host can serve the bytes. +- Policy is enforced host-side per request; the client vocabulary never expands what the route allows. +- Scope is deliberately narrow: only files under registered workspace roots with allowlisted media types are served; anything else keeps the authored fallback. Trajectory and tool-card markdown consumers do not pass a vocabulary yet, and video/audio markdown nodes are not rendered as `