Host setting subagent.maxDepth is not enforced on delegation paths that call SubagentRuntime directly (workflow / spawn_teammate / ralph) #7098
Replies: 5 comments
|
Confirmed from source, with the exact bypass call sites, the fix seam that already exists, and — more usefully — the two ways the obvious one-line fix breaks working configurations. The bypass, located.
One precision on the capacity half. Your table is right about The seam already exists and the runtime never calls it. // subagent/src/index.ts:248-251
resolveMaxDepth(configured?: number | 'provider-managed'): number | undefined {
if (configured === 'provider-managed') return undefined
return configured ?? (this.settingsSource() as Required<Config>).maxDepth
}Its only production callers are inside Which is also the argument for fixing it in the runtime rather than in each caller: defaulting at the two start paths covers all four delegation paths at once, and the three bypassing packages need no change at all. But
So the numeric default cannot be applied unconditionally. Two shapes work: (a) default only when the resolved provider declares the Not mountable as a plugin — the seam is closed on the read side too. Worth stating so it does not read as an unexplored option:
So the enforcement has to live in |
|
Verified all of it against the tree. Every line reference checks out — Both corrections accepted: Not uniform in which limit they miss. I scoped The fix seam. Your two objections to
One detail that falls out of shape (b) and I think is worth pinning down: widening the request field alone is not enough. If Combining (a) and (b) is what I would now propose, after const maxDepth = request.maxDepth === 'provider-managed'
? undefined
: request.maxDepth
?? (provider.capabilities.depthLimit
? (this.settingsSource() as Required<Config>).maxDepth
: undefined)The numeric default is only ever applied where the provider can enforce it; the sentinel stays distinguishable from "unspecified"; the capability gate keeps its meaning ("the caller asked for something this provider cannot do"); and the three bypassing packages need no change. One behaviour change worth stating plainly: an unspecified Also agreed that the plugin route is closed — worth having on record so it does not get re-opened: all four Thanks for reading it against the source rather than just the report. |
|
Correction to my previous comment. I checked the two claims against the tree instead of reasoning from the predicate alone, and I got the edit count wrong. I wrote that shape (b) is "two edits". It is at least three, and the one I missed is the load-bearing one on the continuable path. What holds: What I missed: and it guards both entry points — Shape (b) is therefore:
One further observation, flagged as un-chased rather than asserted: because Net effect on the proposal: this makes the capability-gated default the safer half. It needs no type widening and no assertion change — it only ever injects a number, and only where |
|
Correction #2 — this time over the report itself. Two independent verification passes went over this thread: one over the fix analysis, one over the report against the full master tree (commit The core finding stands. The bypass was independently reproduced from scratch in a fresh isolated What was wrong in the report.
Precisions.
Superseding the fix analysis in my previous comment. The edit list there was still incomplete:
One upstream doc bug found along the way. The 2026-07-12 note ( Thanks for the patience — and apologies for the churn. The first comment in this thread got the edit count wrong; this is the second pass over my own text, and both errors were mine rather than yours. |
|
Thanks — I re-derived each of your four claims at Where my analysis was incomplete (your point 1). I gave the assertion inventory as four request-path sites and concluded "fix it in the runtime and all four are covered at once". The conclusion survives; the inventory did not.
So the inventory is six, and one is outside The good news your finding implies. Because all six go through one function, there is exactly one home for the relaxation — and it is the same edit that resolves your mount-time-guard trap. Your compile break is real, and it has one choke point. The sentinel semantics (your point 4) — agreed, and it is worse than a string comparison. Your Your report corrections (the Agent Note citation, the sign-flipped design note, the |
Uh oh!
There was an error while loading. Please reload this page.
Summary
The host-level
subagent.maxDepthsetting (Settings → Plugins → Subagent; default1, described as "Only the main Agent can create Subagents") is applied only bydsh-tool-subagentinstances, which copy it into each request they create.SubagentRuntime.start()/startContinuable()never consult the host setting themselves, andresolveChildDepth(parent, maxDepth)passes the derived depth through unchanged whenevermaxDepth === undefined.As a result, first-party delegation paths that call the runtime directly bypass the cap completely:
maxDepth?dsh-tool-subagent(subagent,subagent_fork)resolveMaxDepth(config.maxDepth)→request.maxDepthdsh-workflow-ptc(workflow)lib/index.jsstartChild()→this.subagents.start(provider, {...})dsh-experimental-agent-team(spawn_teammate)startContinuable({ childId, provider, label, request: { prompt, parent } })dsh-tool-ralphctx.workflowEngine.start()maxActiveSubagentsdoes not cover them either:workflowuses the one-shotstart()path, and everyassertAdmitting/pool.reserve/holdOwnershipcall site sits inside the continuable machinery (dsh-subagent/lib/index.jsL856–1929) while the one-shotstart()is at L3216 — so those children are outside both limits.Reproduction (real dispatch, not static analysis)
Isolated
DSH_HOME(hard-linked.credentials.yaml+ junction toprofiles/node_modules) with a one-shot profile composed of@deepseek-ai/dsh-base+@deepseek-ai/dsh-headless, andsettings.yaml:The main agent (depth 0) delegates one child via
subagentwithrun_in_background: false. That child (depth 1) then attempts both channels in the same turn. Raw returns:subagent(depth 1 → 2)Error: subagent depth 2 exceeds maxDepth 1workflow(depth 1 → 2)workflow "reply-grandchild-alive" completed (1 agent).+Return value: "GRANDCHILD_ALIVE"The workflow script used:
So within one and the same child agent, the ordinary path is correctly refused (
depth 2 > maxDepth 1), while theworkflowpath creates and runs a depth-2 agent. The refusal message also confirms the depth check itself works and that the child truly is at depth 1 — the two results are directly comparable.Why this reads as unintended
1= "仅允许主 Agent 创建 Subagent / Only the main Agent can create Subagents".docs/subsystems/subagent.zh.mdtitles the section "深度是绝对的树上限" (depth is an absolute tree cap)..agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.mdstates thatdsh-tool-subagentcopies these controls into every request it creates, and that callers invokingSubagentRuntimedirectly choose them per request. So the per-request plumbing is by design — but that leaves a shipped first-party tool silently outside a setting the UI presents as global.Either behavior alone would be fine; the combination is the surprise. It also matters for cost: unbounded
workflowfan-out is precisely the expensive shape (in our own session logs, L2+L3 delegation accounted for ~72% of tokens).Possible fixes (any one would resolve the mismatch)
SubagentRuntime.start()/startContinuable(), fall back tosettingsSource().maxDepthwhenrequest.maxDepthisundefined(an explicit per-request value still wins). This makes the host setting a real floor for every delegation channel, including third-party plugins, and matches the "absolute tree cap" wording.dsh-workflow-ptc.startChild(),dsh-experimental-agent-team'sspawn_teammate, and the ralph path.workflowis intentionally exempt, say so in the settings help text and document it in the subagent subsystem page.Option 1 looks smallest and closest to the documented intent; option 3 at minimum removes the mismatch.
Environment
0.1.6-alpha.2, Windows (also installed on WSL at the same version), node v24.19.0subagentbranch was refused), so this is not a misconfiguration of the test.Happy to run further repros or test a patch.
中文版(同上内容)
概要
宿主级设置
subagent.maxDepth(设置 → 插件 → Subagent,默认1,文案为「仅允许主 Agent 创建 Subagent」)只由dsh-tool-subagent实例施加 —— 它把该值复制进自己创建的每个请求。SubagentRuntime.start()/startContinuable()自身从不读宿主设置,而resolveChildDepth(parent, maxDepth)在maxDepth === undefined时直接放行推导出的深度。因此,直接调用 runtime 的一等公民派发通道完全绕过该上限:
workflow(dsh-workflow-ptc.startChild()不传maxDepth)、spawn_teammate(dsh-experimental-agent-team不传)、ralph(走 workflowEngine)。maxActiveSubagents也管不到它们:workflow走一次性start(),而assertAdmitting/pool.reserve/holdOwnership全部位于 continuable 机制内(dsh-subagent/lib/index.jsL856–1929),一次性start()在 L3216 —— 两道上限都不覆盖。复现(真派发,非静态分析)
隔离
DSH_HOME+@deepseek-ai/dsh-base+@deepseek-ai/dsh-headless一次性 profile,settings.yaml写subagent.maxDepth: 1。主 agent(depth 0)派一个子 agent,该子 agent(depth 1)在同一轮里分别走两条通道:subagent(depth 1 → 2)Error: subagent depth 2 exceeds maxDepth 1workflow(depth 1 → 2)workflow "reply-grandchild-alive" completed (1 agent).+Return value: "GRANDCHILD_ALIVE"同一个子 agent 内,普通通道被正确拒绝,
workflow通道却建成并跑完了一个 depth-2 的 agent;而拒绝消息本身反证了深度校验有效、该子确在 depth 1,两个结果可直接对照。为什么像是无意的
docs/subsystems/subagent.zh.md该节标题为「深度是绝对的树上限」。dsh-tool-subagent把这些控制复制进它创建的每个请求、直调SubagentRuntime的调用方按请求选择 —— 逐请求机制是有意设计,但这让一个已交付的一等公民工具静默地落在用户读作"全局"的设置之外。可选修法(任一即可消除不一致)
start()/startContinuable()在request.maxDepth为undefined时回落到settingsSource().maxDepth(显式按请求给出的值仍优先)。最小改动,且与「绝对的树上限」表述一致,对第三方插件同样生效。dsh-workflow-ptc.startChild()、spawn_teammate、ralph 路径。workflow有意豁免,请在设置帮助文案与 subagent 子系统文档中写明。环境
dsh
0.1.6-alpha.2(Windows;WSL 同为该版本),node v24.19.0。同一次运行内普通通道被拒 ⇒ 排除"测试配置写错"。All reactions