Skip to content

feat(core): declare create_sub_session only under qwen serve - #9425

Open
DragonnZhang wants to merge 1 commit into
QwenLM:mainfrom
DragonnZhang:feat/create-sub-session-daemon-only
Open

feat(core): declare create_sub_session only under qwen serve#9425
DragonnZhang wants to merge 1 commit into
QwenLM:mainfrom
DragonnZhang:feat/create-sub-session-daemon-only

Conversation

@DragonnZhang

Copy link
Copy Markdown
Collaborator

What this PR does

create_sub_session only works under qwen serve, where the daemon bridge exists — but until now it was declared in every session regardless of mode. This PR moves the tool's registration out of the always-on built-in tool set and into the exact place where the daemon ACP session wires its sub-session spawner: the tool now exists only in sessions that can actually spawn sub-sessions. In interactive TUI and headless runs it is simply not declared — it disappears from the function declarations, from ToolSearch results, and from the deferred-tools startup reminder. The tool's runtime "daemon-only" error path is kept as a defensive guard, and its docs/description now state that it is not declared at all outside daemon mode.

Why it's needed

In non-daemon sessions the tool can never succeed: there is no session bridge to spawn through. Declaring it there anyway pollutes the model's action space with a dead tool — it consumes prompt tokens, shows up in ToolSearch keyword matches, and invites the model to spend a call discovering an error it could never avoid. Registering it only where its spawner is wired keeps the tool surface honest: if the model can see create_sub_session, calling it will work.

Reviewer Test Plan

How to verify

  • Interactive/headless: start qwen (TUI or qwen -p ...) and inspect the tool surface (e.g. /tools or ToolSearch) — create_sub_session should be absent. Before this change it was present (as a deferred tool) in both modes.
  • Daemon: start qwen serve and open a session — the tool is declared as before and spawning sub-sessions works unchanged (registration happens at session construction, right after the spawner is wired).
  • Unit tests: cd packages/core && npx vitest run src/config/config.test.ts src/tools/create-sub-session.test.ts (asserts the built-in registry no longer registers the tool, and the tool's own behavior is unchanged) and cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts src/acp-integration/session/Session.worktree.test.ts src/acp-integration/session/Session.review-lease.test.ts (asserts the ACP session registers the tool alongside the spawner).

Observed locally: core 548/548 and the three ACP Session suites 676/676 pass, and tsc --build for core + cli succeeds.

Evidence (Before & After)

N/A (non-UI change; verified via the unit tests above — before: the tool appeared in the built-in registry for every session; after: it appears only in daemon ACP sessions).

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Local runtime: per-package npx vitest run unit tests and incremental tsc --build over core and cli on top of a full npm install build.

Risk & Scope

  • Main risk or tradeoff: anything that assumed create_sub_session is always present in the registry (e.g. checks that look the name up unconditionally) now sees it only under the daemon — which is the intended semantic; the runtime guard means even a stale direct call degrades to the same clear daemon-only error as before.
  • Not validated / out of scope: no behavior change under qwen serve; scheduled tasks, nested sub-sessions, and workspace sessions all run as daemon ACP sessions and register the tool through the same path.
  • Breaking changes / migration notes: none for daemon users; non-daemon sessions lose a tool that never worked there.

Linked Issues

None — standalone improvement.

中文说明

这个 PR 做了什么

create_sub_session 只在 qwen serve 下可用(那里才有 daemon 桥接),但此前它在所有模式的会话中都会被声明。本 PR 将该工具的注册从"总是注册"的内置工具集移出,放到 daemon ACP 会话接线子会话 spawner 的同一位置:工具只存在于真正能派生子会话的会话中。在交互式 TUI 和 headless 运行里它不再被声明——从函数声明列表、ToolSearch 结果和延迟工具启动提示中彻底消失。工具运行时的 "daemon-only" 错误路径作为防御性保护保留,其文档/描述也更新为"非 daemon 模式下完全不声明"。

为什么需要

在非 daemon 会话中该工具永远不可能成功:没有可用于派生的会话桥接。即便如此仍声明它,会用一个死工具污染模型的 action space——浪费提示词 token、出现在 ToolSearch 关键词匹配中,并诱导模型花一次调用去发现一个本可避免的错误。只在 spawner 接线处注册,可以让工具面保持诚实:模型只要能看到 create_sub_session,调用它就一定可用。

审阅者测试计划

如何验证

  • 交互/headless:启动 qwen(TUI 或 qwen -p ...),查看工具面(如 /tools 或 ToolSearch)——create_sub_session 应不存在。改动前它在两种模式下都存在(作为延迟工具)。
  • Daemon:启动 qwen serve 并打开会话——工具像以前一样被声明,派生子会话行为不变(注册发生在会话构造时、spawner 接线之后)。
  • 单元测试:cd packages/core && npx vitest run src/config/config.test.ts src/tools/create-sub-session.test.ts(断言内置注册表不再注册该工具、工具自身行为不变),以及 cd packages/cli && npx vitest run src/acp-integration/session/Session.test.ts src/acp-integration/session/Session.worktree.test.ts src/acp-integration/session/Session.review-lease.test.ts(断言 ACP 会话在接线 spawner 的同时注册该工具)。

本地观测:core 548/548 与三个 ACP Session 套件 676/676 通过,core + cli 的 tsc --build 成功。

前后对比证据

N/A(非 UI 变更;通过上述单元测试验证——改动前:该工具出现在每个会话的内置注册表中;改动后:仅出现在 daemon ACP 会话中)。

测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

本地运行时:在完整 npm install 构建基础上,使用各包内 npx vitest run 单元测试与 core、cli 的增量 tsc --build

风险与范围

  • 主要风险或权衡:任何假设 create_sub_session 始终存在于注册表中的逻辑(例如无条件按名查找)现在只在 daemon 下能看到它——这正是预期语义;即使有过时的直接调用,运行时保护也会退化为与之前相同的清晰 daemon-only 错误。
  • 未验证 / 超出范围:qwen serve 下行为无变化;定时任务、嵌套子会话与工作区会话都作为 daemon ACP 会话运行,经由同一路径注册该工具。
  • 破坏性变更 / 迁移说明:daemon 用户无感知;非 daemon 会话失去一个本就从未可用的工具。

关联 Issue

无——独立改进。

create_sub_session needs the daemon bridge, which only exists under `qwen serve`, yet it was declared in every session. Interactive TUI and headless runs therefore carried a tool that can never succeed, polluting the model's action space and ToolSearch results. The tool is now registered by the ACP session at the same point it wires the sub-session spawner, so it exists exactly where it can work and nowhere else.
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template: complete ✓

Problem: real and verifiable, not theoretical. create_sub_session was registered unconditionally in the built-in tool set, but it can only ever work under qwen serve — in interactive/headless runs its execute() could only return the daemon-only error. The old registration comment itself conceded the tradeoff ("registered unconditionally so the message is available"). So every non-daemon session paid prompt tokens for a dead entry that could also surface in ToolSearch keyword matches and burn a model call on a guaranteed failure.

Direction: aligned. Keeping the model's action space honest — a declared tool should be a callable tool — is the right direction, and deferred-tool availability is an actively maintained area upstream (Claude Code's CHANGELOG carries several recent fixes around deferred tools being unavailable in the wrong contexts). No direct reference to this exact change, but the area is clearly relevant.

Size: core paths are touched (packages/core/src/config/**, packages/core/src/tools/**, cross-package into packages/cli). Production logic: 44 lines (Session.ts 13, config.ts 16, index.ts 2, create-sub-session.ts 13) vs 34 test lines (Session.test.ts, Session.worktree.test.ts, Session.review-lease.test.ts, config.test.ts). Well under any size threshold; since it touches core, the 100%-confidence bar applies to the review.

Approach: focused and minimal — moving the registration from the always-on built-in set to the exact point where the spawner is wired is the natural spot, the runtime guard is kept as defense, and no drive-by changes. One implementation question worth checking in code review: the old path went through registerLazy, which applies the PermissionManager gate — the direct registry call needs to preserve that (verified in the next stage).

Risk: ⚠️ one changed file matches a high-risk path from revert-history analysis (packages/cli/src/acp-integration/), so review depth is escalated: full CI evidence is required before any approval. Not a blocker in itself.

Moving on to code review. 🔍

中文说明

感谢贡献!

**模板:**完整 ✓

**问题:**真实且可验证,不是理论性问题。create_sub_session 过去在内置工具集中无条件注册,但它只在 qwen serve 下可用——交互/headless 运行时其 execute() 只能返回 daemon-only 错误。旧的注册注释自己也承认了这个权衡("unconditionally registered so the message is available")。因此每个非 daemon 会话都在为一个死工具支付 prompt token,它还可能出现在 ToolSearch 关键词匹配中,诱导模型浪费一次注定失败的调用。

**方向:**对齐。保持模型 action space 的诚实性(声明的工具就应该是可调用的)是正确方向;deferred-tool 的可用性在上游也是活跃维护的领域(Claude Code 的 CHANGELOG 中有多条关于 deferred tools 在错误上下文中不可用的修复)。没有直接对应的条目,但该领域明显相关。

**规模:**触及核心路径(packages/core/src/config/**packages/core/src/tools/**,跨包到 packages/cli)。生产逻辑 44 行(Session.ts 13、config.ts 16、index.ts 2、create-sub-session.ts 13),测试 34 行。远低于任何规模阈值;因触及核心路径,审查需满足 100% 置信标准。

**方案:**聚焦且最小化——把注册从"总是开启"的内置工具集移到 spawner 接线处是最自然的位置,运行时保护作为防御保留,没有夹带无关改动。有一个实现问题需要在代码审查中核实:旧路径经过 registerLazy,会应用 PermissionManager 门控——直接调用 registry 注册需要保留该门控(下一阶段验证)。

**风险:**⚠️ 一个变更文件命中了 revert 历史分析中的高风险路径(packages/cli/src/acp-integration/),因此提升审查深度:批准前需要完整的 CI 证据。本身不构成阻塞。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at a926532c58cc23c228e4737424a943d2e03dae9e · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Code review

My independent proposal before reading the diff was exactly what this PR does: drop the unconditional registerLazy from the built-in set, export the tool class from core, register it in the ACP Session where the spawner is wired, keep the runtime guard. So the approach itself gets a thumbs-up — the review below is about one gate it skips.

One real finding — the registration bypasses the PermissionManager gate. The old path went through registerLazy, which checks PermissionManager.isToolEnabled() before registering, and config.ts carries an explicit warning about this exact pattern where the computer-use tools are registered: passing the bare registry "would bypass coreTools allowlist + whole-tool deny rules." The new code calls registry.registerTool(...) directly inside #registerSubSessionSpawner(), and registerTool only checks the disabledTools set. create_sub_session is in PermissionManager.CORE_TOOLS, so it is subject to the coreTools allowlist. Concretely: a daemon session in a workspace whose tools.core allowlist excludes it (or with a whole-tool deny rule) previously did not register the tool at all; after this PR it is declared — visible in the deferred-tools reminder and ToolSearch — and every call is denied at runtime by the scheduler's gate. Runtime enforcement still holds (no security impact), but that violates this PR's own stated invariant ("if the model can see create_sub_session, calling it will work") and reintroduces the dead-tool pollution the PR exists to remove, in exactly that configuration. Suggested fix: apply the same isToolEnabled(ToolNames.CREATE_SUB_SESSION) check before registering. One wrinkle for the author: #registerSubSessionSpawner() is synchronous and runs in the constructor while isToolEnabled is async, so this may want to move to the session's async init path — your call on the cleanest shape.

Everything else verified clean:

  • Daemon-side visibility is genuinely unchanged. shouldDefer=true / alwaysLoad=false live on the tool class, not the registration path, so under qwen serve the tool stays deferred (hidden until ToolSearch reveals it) exactly as before. The "declared as before" claim holds.
  • No cross-session leakage. Each managed ACP session gets its own Config (session-id conflict guard in acpAgent.ts), and the registry is per-config, so registering in the constructor stays session-scoped. Registration runs before the first model turn.
  • Non-daemon absence is complete. The removed registerLazy call was the only place the tool entered non-daemon registries (grepped) — so it disappears from function declarations, the deferred-tools startup reminder, and ToolSearch in one move. The dispose path (setSubSessionSpawner(undefined)) is still covered by the retained runtime guard.
  • Conventions: the cli imports the class through core's public exports (new index.ts entries for CreateSubSessionTool / CreateSubSessionParams, both exist) — no cross-package relative imports. The rescoped enableLiveScreenContext assertion in Session.test.ts is correct, since the constructor now legitimately registers the tool before that call.

Test evidence (PR's own CI — unattended run, PR code never executed here)

The PR's unit suite pins both sides of the change (config.test.ts asserts the built-in registry no longer registers the tool; the Session suites assert the ACP session registers it) and would fail if reverted. At review time the main suite was still running — table below is a snapshot and the finalize job will refresh it once CI settles:

Final CI results for a926532 (auto-updated by the triage finalize job after CI completed):

Check Conclusion
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
macos-latest / Java 21 ✅ success
Real daemon E2E / Java 11 ✅ success
Secret scan (TruffleHog) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Not verified: real rendered tool surface. The unit tests assert registration against mocked registries, which pins the mechanism but doesn't exercise what the model actually sees. Sandboxed verification would settle this: @qwen-code /tmux — drive a plain qwen TUI session and confirm create_sub_session is absent from the deferred-tools startup reminder and ToolSearch results, plus a qwen serve session confirming it is still present. (Author has write access, so the lane is directly available.)

中文说明

代码审查:方案与我独立设想的一致(从内置集移除无条件注册、在 spawner 接线处注册、保留运行时保护),方向正确。但发现一个实际问题:新注册直接调用 registry.registerTool(),绕过了旧 registerLazy 路径上的 PermissionManager.isToolEnabled() 门控——config.ts 中对 computer-use 工具的注释明确警告过这种绕过会"跳过 coreTools 白名单和整工具 deny 规则"。由于 create_sub_session 属于 CORE_TOOLS,在配置了 tools.core 白名单(未包含该工具)或整工具 deny 规则的 daemon 会话中:改动前工具不会注册,改动后它会被声明(出现在 deferred 提示和 ToolSearch 中)但每次调用都被运行时门控拒绝。运行时强制仍然有效(无安全问题),但这违反了本 PR 自己的核心不变量("模型能看到 create_sub_session 就一定能调用成功"),恰好在这类配置下重新引入了本 PR 要消除的死工具污染。建议:注册前补上与 registerLazy 相同的 isToolEnabled 检查;注意 #registerSubSessionSpawner() 是同步的且在构造函数中执行,而 isToolEnabled 是异步的,可能需要挪到会话的异步初始化路径,具体形式由作者决定。

其余均已核实:shouldDefer 在工具类上,daemon 侧可见性与之前完全一致;每个 ACP 会话有独立 Config/注册表,构造函数中注册不会跨会话泄漏;被删除的 registerLazy 调用是非 daemon 注册表引入该工具的唯一路径,因此移除后声明、启动提示、ToolSearch 三处同时消失;dispose 路径由保留的运行时保护兜底;跨包导入走 core 公共导出,符合规范;Session.test.ts 中收窄的断言正确。

测试证据:无人值守 CI 运行,未在此执行任何 PR 代码。单元测试从两侧钉住了改动(config 侧断言不再注册、Session 侧断言注册),revert 后必然失败。审查时主套件仍在运行,表格为快照,CI 结束后由 finalize 任务刷新。未验证项:真实渲染的工具面——单元测试基于 mock 注册表断言,未实际执行模型看到的界面;可用 @qwen-code /tmux 补上(启动普通 qwen 会话确认工具从 deferred 提示与 ToolSearch 中消失,再启动 qwen serve 确认仍存在)。

Qwen Code · qwen3.8-max

Reviewed at a926532c58cc23c228e4737424a943d2e03dae9e · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — everything is clean except one bypass of a documented permission gate; that needs resolving before approval.

Stepping back: this is a well-motivated, well-built PR. The approach matches what I'd have proposed independently, the diff is minimal, every edit earns its place, and the tests pin both sides of the behavior change (a revert fails them). The motivation is real, not theoretical — a dead tool in the action space costs tokens and invites wasted model calls.

What stops me short of approving is the finding above: the new registration path skips the PermissionManager.isToolEnabled() gate that registerLazy applied, and config.ts explicitly warns about this exact bypass pattern. There's a concrete (if narrow) configuration — a daemon session under a tools.core allowlist or whole-tool deny rule — where this PR would start declaring a tool that can never succeed, which is precisely the pollution the PR sets out to remove. Runtime enforcement still holds, so nothing dangerous happens; but the PR's own invariant ("if the model can see it, calling it will work") breaks there. It reads like an oversight rather than a tradeoff, and the fix is small — so I'd rather see it addressed than waived.

Holding approval until then. @DragonnZhang — if you agree the gate should be restored, the comment above sketches the wrinkle (sync constructor vs async check); if you think restricted-daemon sessions should deliberately keep the tool declared, say so and let's make that an explicit decision. @wenshao — flagging for a maintainer's eye on the call either way, since this touches core tool-registration semantics.

CI note: the main unit suite was still running at review time; no approval is being issued this run, so nothing is deferred on CI — the finding above is the open item.

中文说明

置信度:3/5 —— 除了一处绕过已记录的权限门控外全部干净;该问题解决前不予批准。

整体来看:这是一个动机充分、实现良好的 PR。方案与我独立设想的一致,diff 最小化,每处改动都有必要,测试从两侧钉住了行为变化(revert 后必然失败)。动机真实而非理论性——action space 中的死工具浪费 token 并诱导无效调用。

阻止我批准的是上面的发现:新的注册路径跳过了 registerLazy 原本应用的 PermissionManager.isToolEnabled() 门控,而 config.ts 中明确警告过这种绕过模式。存在一个具体(虽然狭窄)的配置场景——配置了 tools.core 白名单或整工具 deny 规则的 daemon 会话——改动后该 PR 会开始声明一个永远无法成功的工具,恰恰是本 PR 要消除的污染。运行时强制仍然有效,无安全风险;但 PR 自己的不变量("模型能看到就一定能调用成功")在该场景下被打破。这读起来像疏忽而非权衡,修复成本也很小——所以更希望解决它而不是豁免它。

因此暂缓批准。@DragonnZhang —— 如果你认同应恢复门控,上面的评论指出了关键难点(同步构造函数 vs 异步检查);如果你认为受限 daemon 会话就应该刻意保留该工具的声明,请说明,让我们把它变成一个明确的决定。@wenshao —— 提请维护者关注,因为这触及核心工具注册语义。

CI 说明:审查时主单元测试套件仍在运行;本次运行不发布任何批准,因此没有基于 CI 的延迟批准——上述发现是唯一的未决项。

Qwen Code · qwen3.8-max

Reviewed at a926532c58cc23c228e4737424a943d2e03dae9e · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially reviewed — gaps disclosed.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: reverse audit — stopped before round 6 by the review time budget.

中文说明

仅完成部分审查,审查缺口已披露。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:反向审计——评审时间预算不足,未能开始第 6 轮。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +8723 to +8724
// create_sub_session is daemon-only: it is registered by the ACP Session
// when it wires the sub-session spawner (see acp-integration's Session),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R1-1: Removing the unconditional registerLazy from createToolRegistry silently strips create_sub_session from every rebuilt sub-agent/override registry — rebuildToolRegistryOnOverride (agent.ts), InProcessBackend.ts, subagent-manager.ts, workflow-orchestrator.ts — all built via createToolRegistry(undefined, { forSubAgent: true }) + copyDiscoveredToolsFrom, which copies only DiscoveredTool/DiscoveredMCPTool. Pre-PR the lazy factory had no forSubAgent guard (the adjacent structured_output registration demonstrably has one), so daemon sub-agents had the tool, and the spawner is still reachable from those configs (override configs delegate to the base Config via Object.create). EXCLUDED_TOOLS_FOR_SUBAGENTS — the canonical list of tools sub-agents must not have — was not updated. This contradicts the PR's own "No behavior change under qwen serve" claim.

Failure scenario: under qwen serve, a daemon session delegates work via the Agent tool and the spawned sub-agent tries to fan out via create_sub_session (a flow that worked pre-PR) → the tool is simply absent from the sub-agent's declarations and ToolSearch; no error is raised, the capability silently disappears, and the PR description never mentions the removal.

Witness (merge-base vs HEAD, A/B):

BASE f0dcdfc157, config.ts:8728:
    await registerLazy(ToolNames.CREATE_SUB_SESSION, ...)
    // unconditional, shared section, no forSubAgent guard
PR HEAD: createToolRegistry carries no registration (comment only);
sole site is Session.ts:2890-2894; copyDiscoveredToolsFrom copies only
DiscoveredTool/DiscoveredMCPTool — rebuilt forSubAgent registries
never receive the built-in

(not run — registration presence/absence is a static fact quoted from both trees, and the declaration-resolution path is deterministic given it)

Suggested fix: decide and record intent — if daemon sub-agents should keep the capability, register the tool in the forSubAgent rebuild path too (or condition a core-side registration on a wired spawner); if the removal is deliberate, add ToolNames.CREATE_SUB_SESSION to EXCLUDED_TOOLS_FOR_SUBAGENTS and say so in the PR description and in the comment beside the new registration site ("every daemon session that can spawn sub-sessions declares the tool" is currently false for sub-agent registries).

中文说明

[Critical]createToolRegistry 移除无条件的 registerLazy 后,create_sub_session 被悄悄从所有重建的子代理/覆盖注册表中移除——rebuildToolRegistryOnOverride(agent.ts)、InProcessBackend.tssubagent-manager.tsworkflow-orchestrator.ts——这些注册表都通过 createToolRegistry(undefined, { forSubAgent: true }) + copyDiscoveredToolsFrom 构建,而后者只复制 DiscoveredTool/DiscoveredMCPTool。改动前该懒加载工厂没有 forSubAgent 守卫(相邻的 structured_output 注册明确有该守卫),因此 daemon 子代理原本拥有该工具,且 spawner 仍可从这些配置触达(覆盖配置通过 Object.create 委托到基础 Config)。EXCLUDED_TOOLS_FOR_SUBAGENTS(子代理不应拥有的工具的规范清单)并未更新。这与 PR 自己声称的"qwen serve 下无行为变化"相矛盾。

失败场景:在 qwen serve 下,daemon 会话通过 Agent 工具派发任务, spawned 出的子代理尝试用 create_sub_session 继续 fan-out(改动前该流程可用)→ 该工具在子代理的声明列表和 ToolSearch 中直接缺席;不报错,能力静默消失,PR 描述未提及这一移除。

证据(merge-base 与 HEAD 的 A/B 对比,见上方代码块):改动前 config.ts:8728 存在无条件的 registerLazy 注册;改动后 createToolRegistry 中无任何注册,唯一注册点是 Session.ts:2890-2894,而 copyDiscoveredToolsFrom 只复制 DiscoveredTool/DiscoveredMCPTool,重建的 forSubAgent 注册表永远拿不到该内置工具。(未实际运行——注册与否是可从两棵树直接引用的静态事实,声明解析路径在此前提下是确定的。)

建议修复:明确并记录意图——如果 daemon 子代理应保留该能力,请在 forSubAgent 重建路径中也注册该工具(或在 core 侧按 spawner 是否接线做条件注册);如果是有意移除,请将 ToolNames.CREATE_SUB_SESSION 加入 EXCLUDED_TOOLS_FOR_SUBAGENTS,并在 PR 描述和新注册点旁的注释中说明("每个能派生子会话的 daemon 会话都声明该工具"这一说法对子代理注册表目前不成立)。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +2891 to +2893
this.config
.getToolRegistry()
.registerTool(new CreateSubSessionTool(this.config));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-2: The direct registry.registerTool() bypasses the PermissionManager.isToolEnabled() gate (coreTools allowlist + whole-tool deny rules) that the removed registerLazy path applied — and create_sub_session is explicitly listed in PermissionManager.CORE_TOOLS. Execution remains blocked at runtime (verified: Session.runTool's L1 check at Session.ts:9763-9784 and CoreToolScheduler at coreToolScheduler.ts:2409-2437 both re-check the same isToolEnabled), so this is declaration-level: in operator-restricted daemon configurations the tool is advertised (function declarations, deferred-tools reminder, ToolSearch) but every call ends in EXECUTION_DENIED — reintroducing exactly the dead-tool pollution this PR exists to remove, violating its own stated invariant ("if the model can see create_sub_session, calling it will work"), and dropping defense-in-depth from two blocking layers to one. The codebase documents this exact hazard at config.ts:8773-8781 ("would bypass coreTools allowlist + whole-tool deny rules"). This corroborates the mechanism already raised in the triage stage-2 comment and settles its open runtime-enforcement question.

Failure scenario: qwen serve with settings tools.core: ["read_file", "edit"] (or --core-tools, or a specifier-less permissions.deny rule for the tool) → pre-PR isToolEnabled() returned false and the tool was never registered in daemon sessions; post-PR every Session constructor registers it unconditionally, and the model spends a call on it only to receive EXECUTION_DENIED.

Witness: not run — the claim reduces to "is pm.isToolEnabled called on the execution path", settled by direct quotation of the two call sites above.

Suggested fix: apply the same gate registerLazy used before registering:

// isToolEnabled is async while #registerSubSessionSpawner() runs in the
// constructor — move this registration into the session's async init path
// (e.g. createAndStoreSession, after config.initialize()):
const pm = this.config.getPermissionManager();
if (!pm || (await pm.isToolEnabled(ToolNames.CREATE_SUB_SESSION))) {
  this.config.getToolRegistry().registerTool(new CreateSubSessionTool(this.config));
}
中文说明

[Suggestion] 直接调用 registry.registerTool() 绕过了被移除的 registerLazy 路径所应用的 PermissionManager.isToolEnabled() 门控(coreTools 白名单 + 整工具 deny 规则)——而 create_sub_session 明确列于 PermissionManager.CORE_TOOLS。运行时执行仍会被拦截(已验证:Session.ts:9763-9784 的 L1 检查与 coreToolScheduler.ts:2409-2437 都会重新检查同一个 isToolEnabled),因此这是声明层面的问题:在运维受限的 daemon 配置下,工具被声明出来(函数声明、deferred-tools 启动提示、ToolSearch),但每次调用都以 EXECUTION_DENIED 结束——重新引入了本 PR 要消除的"死工具污染",违反其自身不变量("模型只要能看到 create_sub_session,调用它就一定可用"),并把纵深防御从两层拦截削弱为一层。代码库在 config.ts:8773-8781 明确警告过这一模式("会绕过 coreTools 白名单和整工具 deny 规则")。此发现与 triage stage-2 评论已提出的机制一致,并解决了其中关于运行时是否仍强制执行的疑问。

失败场景:qwen serve 配置 tools.core: ["read_file", "edit"](或 --core-tools、或针对该工具的无 specifier permissions.deny 规则)→ 改动前 isToolEnabled() 返回 false,daemon 会话从不注册该工具;改动后每个 Session 构造函数无条件注册,模型会花一次调用去尝试并收到 EXECUTION_DENIED。

证据:未实际运行——该 claim 归结为"执行路径上是否调用了 pm.isToolEnabled",上述两处调用点的直接引用即可定论。

建议修复:注册前补上与 registerLazy 相同的门控(代码示例见上)。由于 isToolEnabled 是异步的而 #registerSubSessionSpawner() 在构造函数中同步执行,可将注册移到会话的异步初始化路径(如 createAndStoreSession,在 config.initialize() 之后)。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +7637 to +7639
expect(
(registerToolMock as Mock).mock.calls.map((call) => call[0]),
).not.toContain(ToolNames.CREATE_SUB_SESSION);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: The new negative test asserts only on ToolRegistry.prototype.registerFactory mock calls (the local variable is misleadingly named registerToolMock). A future regression that re-adds the tool via a direct eager registry.registerTool(new CreateSubSessionTool(this)) inside createToolRegistry — the exact pattern this PR legitimizes in Session.ts — never touches registerFactory, so this test stays green while the action-space pollution it guards against returns in interactive/headless runs. Probe-verified in this worktree:

original oracle + simulated eager regression:  Tests 1 passed | 533 skipped
widened oracle  + same regression:             Tests 1 failed
  AssertionError: expected [ 'create_sub_session' ] to not include 'create_sub_session'

Suggested fix: also capture and assert on ToolRegistry.prototype.registerTool calls (assert neither registerFactory nor registerTool was called with ToolNames.CREATE_SUB_SESSION). Note: asserting on config.getToolRegistry().getAllToolNames() is not viable in this file — its mock hardcodes getAllToolNames = vi.fn(() => []).

中文说明

[Suggestion] 新增的负向测试只断言了 ToolRegistry.prototype.registerFactory 的 mock 调用(局部变量名 registerToolMock 有误导性)。未来若通过直接的 eager registry.registerTool(new CreateSubSessionTool(this)) 把该工具加回 createToolRegistry——正是本 PR 在 Session.ts 中合法化的模式——完全不会触碰 registerFactory,该测试仍为绿色,而它本要防范的 action-space 污染将在交互/headless 运行中复现。已在本 worktree 用探针验证(见上方输出:原始 oracle + 模拟的 eager 回归仍通过;扩展 oracle 后同一回归使其失败)。

建议修复:同时捕获并断言 ToolRegistry.prototype.registerTool 的调用(断言 registerFactoryregisterTool 都未以 ToolNames.CREATE_SUB_SESSION 被调用)。注意:断言 config.getToolRegistry().getAllToolNames() 在此文件不可行——该文件的 mock 将 getAllToolNames 硬编码为 vi.fn(() => [])

— qwen3.8-max via Qwen Code /review (v0.21.13)

Comment on lines +2888 to +2890
// Register the tool exactly where the spawner is wired: the registry is
// session-scoped, so every daemon session that can spawn sub-sessions
// declares the tool, and no non-daemon session ever does.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-4: The daemon's workspace-tools inventory (buildWorkspaceToolsStatus in acpAgent.ts, served via SERVE_STATUS_EXT_METHODS.workspaceTools and consumed by SDK DaemonClient.workspaceTools(), the WebUI tools dialog, serve/routes/workspace-status.ts, daemon-status.ts) reads the workspace/bootstrap Config registry via getAllToolNames(), which includes pending factories and applies only an MCP serverName filter (no shouldDefer filter). Pre-PR the bootstrap registry carried the tool's lazy factory, so the tool was listed in the operator panel with displayName/description and its enable toggle. Post-PR only per-session registries carry the tool, so it vanishes from the inventory on every qwen serve workspace, unconditionally: operators get no indication that a privileged session-spawning tool exists, and the UI-driven disable flow (POST /workspace/tools/:name/enable, only offered for listed tools) has no row to toggle — though the route itself accepts any name, so a direct-API disable still works. The diff's rationale argues only the non-daemon pollution case; this daemon-surface state is unargued, and the PR claims "no behavior change under qwen serve".

Failure scenario: an operator opens the daemon workspace tools panel (WebUI tools dialog / SDK status) on any qwen serve workspace → create_sub_session is not listed; it cannot be inspected or disabled from the UI even though every daemon session still declares it to the model.

Witness: not run — every link is a static, deterministic fact in the worktree (status builder, filter set, route validation and consumer wiring read in full); a live A/B would add no discriminating power.

Suggested fix: keep the workspace-tools status aware of the daemon-only tool — e.g. union getAllToolNames() with ToolNames.CREATE_SUB_SESSION in buildWorkspaceToolsStatus when the ACP channel is live, or source that cell from a live session registry; alternatively decide deliberately and document that the panel lists only workspace-registry tools.

中文说明

[Suggestion] daemon 的 workspace-tools 清单(acpAgent.ts 中的 buildWorkspaceToolsStatus,经 SERVE_STATUS_EXT_METHODS.workspaceTools 提供,消费方包括 SDK DaemonClient.workspaceTools()、WebUI 工具对话框、serve/routes/workspace-status.tsdaemon-status.ts)通过 getAllToolNames() 读取 workspace/引导 Config 的注册表——该方法包含待执行的工厂,且只应用 MCP 的 serverName 过滤(没有 shouldDefer 过滤)。改动前引导注册表携带该工具的懒加载工厂,因此工具会出现在运维面板中,带 displayName/description 和启用开关。改动后只有每个会话各自的注册表携带该工具,于是它在所有 qwen serve 工作区的清单中无条件消失:运维人员看不到这个可派生会话的特权工具的存在,UI 侧的禁用入口(POST /workspace/tools/:name/enable 只对列出的工具提供)也没有可切换的行——不过路由本身接受任意名称,直接走 API 禁用仍然有效。diff 的理由只论证了非 daemon 污染这一种状态;该 daemon 面板状态未被论证,而 PR 声称"qwen serve 下无行为变化"。

失败场景:运维人员在任意 qwen serve 工作区打开 daemon 工具面板(WebUI 工具对话框 / SDK 状态)→ create_sub_session 不再列出;即使每个 daemon 会话仍向模型声明它,也无法在 UI 中查看或禁用。

证据:未实际运行——每一环都是 worktree 中静态且确定的事实(状态构建器、过滤集、路由校验与消费方接线均已完整阅读);实际跑 A/B 不会提供更多区分力。

建议修复:让 workspace-tools 状态感知该 daemon-only 工具——例如在 ACP 通道存活时在 buildWorkspaceToolsStatus 中将 getAllToolNames()ToolNames.CREATE_SUB_SESSION 取并集,或从某个存活会话的注册表取数;或者有意地决策并记录"面板只列出 workspace 注册表中的工具"。

— qwen3.8-max via Qwen Code /review (v0.21.13)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants