From a3244a5774b91b7224a86d8373aa403cdca4fac3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:33:18 +0800 Subject: [PATCH] fix(tool-subagent): an omitted agentOptions must not materialize an empty object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The partial-toolFilter materialization fix (da6c6d58) stopped one field short: the adjacent agentOptions key in the SAME Config has the same schemastery trap. An omitted agentOptions materializes {}, which is truthy — so every yml-configured load put a dishonest agentOptions: {} on every start request and the presence check in execute() could never be false through config (only unit tests bypassing schemastery ever exercised that branch). Harmless downstream today (the driver only spreads it), but the request shape lied and the check was production-dead. Same discipline as its toolFilter sibling: the omitted key now defaults to undefined, the presence check is spelled !== undefined like its neighbors, and a regression test (fails against the unfixed schema) pins that an omitted agentOptions stays absent from the request. Swept every other Config in the repo for the class: no further instances — omitted primitives inside a materialized object stay ABSENT (verified empirically), so subagent-mock's capabilities spread is safe, and the remaining object/array fields all carry explicit defaults or the forced-undefined discipline already. --- packages/subagent/tool-subagent/src/index.ts | 8 +++-- .../tool-subagent/tests/tool-subagent.spec.ts | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f677076d3f..31d6dce113 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -93,9 +93,13 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), + // Omitted-object discipline (see the toolFilter note below): without the + // forced default an omitted `agentOptions` materializes `{}`, which reads as + // present — the request would carry `agentOptions: {}` and the presence + // check in execute() could never be false through config. agentOptions: z.object({ model: z.string(), - }), + }).default(undefined as unknown as { model: string }), persona: z.string(), // A schemastery object materializes {} (with [] for nested arrays) when the // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. @@ -229,7 +233,7 @@ export function apply(ctx: Context, config: Config): void { prompt: [{ type: 'text', text: args.prompt }], parent, ...exec.signal ? { signal: exec.signal } : {}, - ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + ...config.agentOptions !== undefined ? { agentOptions: config.agentOptions } : {}, ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolFilter !== undefined ? { toolFilter: config.toolFilter } : {}, ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {}, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index da560350e4..9d9941fdff 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -511,6 +511,36 @@ describe('dsh-tool-subagent', () => { expect(seen?.toolFilter).not.toHaveProperty('allow') }) + it('an omitted agentOptions does not materialize an empty object onto the request', async () => { + // Same schemastery trap as toolFilter, adjacent field: an omitted + // `agentOptions` config key materializes `{}` without the forced default, + // which reads as present and puts a dishonest `agentOptions: {}` on every + // start request. + let seen: { agentOptions?: unknown } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture4', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: (request) => { + seen = request + return { + id: AgentId('capture4-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'capture4' }) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen).toBeDefined() + expect(seen).not.toHaveProperty('agentOptions') + }) + it('an explicit empty toolFilter fails at plugin load, not at first delegation', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt)