Kaynağa Gözat

fix(agent): preserve explicit identity lifecycle behavior

_Kerman 1 hafta önce
ebeveyn
işleme
03ec1ca57c
32 değiştirilmiş dosya ile 288 ekleme ve 101 silme
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml
  8. 1 1
      .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md
  9. 1 1
      .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md
  10. 6 0
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml
  11. 47 0
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md
  12. 47 0
      .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 2 2
      docs/config-catalog.md
  15. 2 2
      docs/config-catalog.zh.md
  16. 2 2
      docs/subsystems/subagent.i18n.yaml
  17. 2 2
      docs/subsystems/subagent.md
  18. 2 2
      docs/subsystems/subagent.zh.md
  19. 2 2
      packages/api/remotes/README.i18n.yaml
  20. 1 1
      packages/api/remotes/README.md
  21. 1 1
      packages/api/remotes/README.zh.md
  22. 3 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  23. 1 0
      packages/subagent/subagent/package.json
  24. 8 2
      packages/subagent/subagent/tests/continuation.spec.ts
  25. 2 2
      packages/subagent/tool-subagent/README.i18n.yaml
  26. 2 2
      packages/subagent/tool-subagent/README.md
  27. 2 2
      packages/subagent/tool-subagent/README.zh.md
  28. 66 39
      packages/subagent/tool-subagent/src/index.ts
  29. 5 5
      packages/subagent/tool-subagent/src/model-selection-settings.ts
  30. 5 2
      packages/subagent/tool-subagent/tests/harness.ts
  31. 63 16
      packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts
  32. 3 0
      pnpm-lock.yaml

+ 2 - 2
.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

+ 1 - 1
.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
 

+ 1 - 1
.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` 功能。
 
 ### 注册来源决定可见性与清理
 

+ 2 - 2
.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

+ 1 - 1
.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
 

+ 1 - 1
.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 是私有世界内的可信组合
 

+ 2 - 2
.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

+ 1 - 1
.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
 

+ 1 - 1
.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)把发起方作用域限制在私有异步调用链内,同时让生命周期、归属、事件和协议接口直接携带各自的主体。
 
 ## 验证
 

+ 6 - 0
.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

+ 47 - 0
.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.

+ 47 - 0
.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 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。

+ 2 - 2
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

+ 2 - 2
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
   /**

+ 2 - 2
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
   /**

+ 2 - 2
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

+ 2 - 2
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

+ 2 - 2
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

+ 2 - 2
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

+ 1 - 1
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.
 
 <a id="build-boundary"></a>
 ## Build boundary

+ 1 - 1
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。
 
 <a id="build-boundary"></a>
 ## 构建边界

+ 3 - 3
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.',
       },

+ 1 - 0
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:^",

+ 8 - 2
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(() => {

+ 2 - 2
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

+ 2 - 2
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
 

+ 2 - 2
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` 使用全局名称。
 
 ### 前台结算
 

+ 66 - 39
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<Agent, ReturnType<Context['inject']>>()
+  /* 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<Agent, ReturnType<Context['inject']>>()
   const installing = new WeakSet<Agent>()
+  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<Context['inject']>
     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()
 }

+ 5 - 5
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<Config> = 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 {

+ 5 - 2
packages/subagent/tool-subagent/tests/harness.ts

@@ -58,8 +58,11 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial<mock.Co
     const handle = await ctx.agents.create({
       sessionId: SessionId(`model-selection-setup-${++setupAgentCounter}`),
       ...parentAgentOptions !== undefined ? { agentOptions: parentAgentOptions } : {},
-      setup: async (agentCtx) => {
-        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)

+ 63 - 16
packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts

@@ -55,8 +55,10 @@ function selectable(ctx: Context, agent: Awaited<ReturnType<Context['agents']['c
     && ctx.tools.schemas(agent).some(candidate => candidate.name === 'list_subagent_models')
 }
 
-/** Mount the real settings, Agent, provider, and tool services. */
-async function boot(): Promise<Context> {
+const modelSelectionPresets = new WeakMap<Context, ReturnType<typeof createScope>>()
+
+/** Mount the real settings, Agent, provider, and optional preset tool services. */
+async function boot(withPreset = true): Promise<Context> {
   const ctx = new Context()
   await ctx.plugin(MemorySettings)
   await ctx.plugin(SubagentModelSelectionConfig)
@@ -65,23 +67,30 @@ async function boot(): Promise<Context> {
   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<ReturnType<Context['agents']['create']>>['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()
   })
 

+ 3 - 0
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