From f4f1500af959b8b85a32a8d632b95b04046108f3 Mon Sep 17 00:00:00 2001 From: Gucc1 <1006490933@qq.com> Date: Mon, 17 Aug 2026 19:01:21 +0800 Subject: [PATCH 1/4] fix subagent default model config --- src/agent/runtime/AgentRuntimeConfig.ts | 8 ++ src/agent/sub/SubAgentSession.ts | 17 +++ src/cli/createLocalGateway.ts | 35 ++++++ src/pilot/config/loadPilotConfig.ts | 19 +++- src/pilot/config/types.ts | 1 + tests/agent/sub/SubAgentSession.spec.ts | 138 ++++++++++++++++++++++++ 6 files changed, 216 insertions(+), 2 deletions(-) diff --git a/src/agent/runtime/AgentRuntimeConfig.ts b/src/agent/runtime/AgentRuntimeConfig.ts index 0eb87c7a3..781e5154b 100644 --- a/src/agent/runtime/AgentRuntimeConfig.ts +++ b/src/agent/runtime/AgentRuntimeConfig.ts @@ -42,6 +42,14 @@ export type AgentRuntimeConfig = { * but no nested forks). Increase only when intentional. */ maxSubagentDepth?: number; + /** Optional default model/caps for forked subagents. Omitted means inherit this agent's model. */ + subagentModel?: { + provider: string; + model: string; + modelMultimodal?: MultimodalConstraints; + maxContextTokens?: number; + maxOutputTokens?: number; + }; /** Optional timeout budget for forked subagents spawned by the `agent` tool. */ subagentTimeoutMs?: number; /** Enable automatic JSON self-correction retry on invalid_tool_arguments. Default false. */ diff --git a/src/agent/sub/SubAgentSession.ts b/src/agent/sub/SubAgentSession.ts index 3f26d18e1..096e1d41e 100644 --- a/src/agent/sub/SubAgentSession.ts +++ b/src/agent/sub/SubAgentSession.ts @@ -278,6 +278,7 @@ export class SubAgentSession { private buildConfig(): AgentRuntimeConfig { const parent = this.options.parentConfig; + const subagentModel = parent.subagentModel; const subagentSystem = buildSubagentSystemPrompt(this.options.definition); const filteredParentSystem = applySystemPromptFilters( parent.systemPrompt ?? "", @@ -288,10 +289,26 @@ export class SubAgentSession { : subagentSystem; return { ...parent, + ...(subagentModel + ? { + provider: subagentModel.provider, + model: subagentModel.model, + ...(subagentModel.modelMultimodal + ? { modelMultimodal: subagentModel.modelMultimodal } + : {}), + ...(subagentModel.maxContextTokens !== undefined + ? { maxContextTokens: subagentModel.maxContextTokens } + : {}), + ...(subagentModel.maxOutputTokens !== undefined + ? { maxOutputTokens: subagentModel.maxOutputTokens } + : {}), + } + : {}), // Ask mode performs read-only checks against each tool call's real // input. Do not probe dynamic isReadOnly implementations with a dummy // object while constructing the registry. runMode: this.isReadOnlySession() ? "ask" : parent.runMode, + isSubagent: true, permissionContext: { ...parent.permissionContext, rules: { diff --git a/src/cli/createLocalGateway.ts b/src/cli/createLocalGateway.ts index 8c91d39dd..d9b42f0c4 100644 --- a/src/cli/createLocalGateway.ts +++ b/src/cli/createLocalGateway.ts @@ -1416,6 +1416,40 @@ class ProjectRuntimeRegistry { maxOutputTokens = readPositiveIntegerEnv(this.options.env.PILOTDECK_MAX_OUTPUT_TOKENS) ?? agent.maxOutputTokens ?? maxOutputTokens; + const subagentModel = agent.subagents?.default; + let subagentRuntimeModel: CreateAgentSessionOptions["config"]["subagentModel"]; + if (subagentModel) { + let subagentModelMultimodal: import("../model/index.js").MultimodalConstraints | undefined; + try { + subagentModelMultimodal = runtime.model.getMultimodal( + subagentModel.provider, + subagentModel.model, + ); + } catch { + // Model or provider not found — keep the override but fall back to inherited caps. + } + let subagentMaxContextTokens: number | undefined; + let subagentMaxOutputTokens: number | undefined; + try { + const caps = runtime.model.getCapabilities(subagentModel.provider, subagentModel.model); + subagentMaxContextTokens = caps.maxContextTokens; + subagentMaxOutputTokens = caps.maxOutputTokens; + } catch { + // Keep the override even if capability lookup fails. + } + subagentRuntimeModel = { + provider: subagentModel.provider, + model: subagentModel.model, + ...(subagentModelMultimodal ? { modelMultimodal: subagentModelMultimodal } : {}), + ...(subagentMaxContextTokens !== undefined ? { maxContextTokens: subagentMaxContextTokens } : {}), + ...(subagentMaxOutputTokens !== undefined + ? { + maxOutputTokens: readPositiveIntegerEnv(this.options.env.PILOTDECK_MAX_OUTPUT_TOKENS) + ?? subagentMaxOutputTokens, + } + : {}), + }; + } return { provider: agent.model.provider, model: agent.model.model, @@ -1423,6 +1457,7 @@ class ProjectRuntimeRegistry { cwd, permissionMode, jsonSelfCorrect: true, + ...(subagentRuntimeModel ? { subagentModel: subagentRuntimeModel } : {}), subagentTimeoutMs: agent.subagents?.timeoutMs, maxContextTokens, maxOutputTokens, diff --git a/src/pilot/config/loadPilotConfig.ts b/src/pilot/config/loadPilotConfig.ts index a07c57c43..83929c9f6 100644 --- a/src/pilot/config/loadPilotConfig.ts +++ b/src/pilot/config/loadPilotConfig.ts @@ -346,7 +346,7 @@ function parseAgent( } const model = parseAgentModelSelection(rawAgent.model, "agent.model", modelConfig, diagnostics); - const subagents = parseAgentSubagents(rawAgent.subagents, diagnostics); + const subagents = parseAgentSubagents(rawAgent.subagents, modelConfig, diagnostics); const maxContextTokens = readOptionalPositiveInteger(rawAgent.maxContextTokens, "agent.maxContextTokens"); const maxOutputTokens = readOptionalPositiveInteger(rawAgent.maxOutputTokens, "agent.maxOutputTokens"); const thinking = parseAgentThinking(rawAgent.thinking); @@ -383,6 +383,7 @@ function parseAgentThinking(value: unknown): PilotAgentConfig["thinking"] | unde function parseAgentSubagents( value: unknown, + modelConfig: ReturnType, diagnostics: PilotConfigDiagnostic[], ): PilotAgentConfig["subagents"] | undefined { if (value === undefined) { @@ -392,7 +393,7 @@ function parseAgentSubagents( throw new PilotConfigError("CONFIG_AGENT_SUBAGENTS_INVALID", "agent.subagents must be an object."); } for (const key of Object.keys(value)) { - if (key !== "timeoutMs") { + if (key !== "timeoutMs" && key !== "default" && key !== "params") { diagnostics.push({ code: "CONFIG_AGENT_UNKNOWN_FIELD", severity: "warning", @@ -402,7 +403,21 @@ function parseAgentSubagents( }); } } + let defaultModel: PilotAgentModelSelection | undefined; + if (value.default !== undefined && value.default !== null) { + if (typeof value.default === "string" && value.default.trim() === "inherit") { + defaultModel = undefined; + } else { + defaultModel = parseAgentModelSelection( + value.default, + "agent.subagents.default", + modelConfig, + diagnostics, + ); + } + } return { + ...(defaultModel ? { default: defaultModel } : {}), timeoutMs: readOptionalPositiveInteger(value.timeoutMs, "agent.subagents.timeoutMs"), }; } diff --git a/src/pilot/config/types.ts b/src/pilot/config/types.ts index 150a8c756..be8de2a26 100644 --- a/src/pilot/config/types.ts +++ b/src/pilot/config/types.ts @@ -74,6 +74,7 @@ export type PilotAgentConfig = { maxOutputTokens?: number; thinking?: { enabled: boolean; budgetTokens?: number }; subagents?: { + default?: PilotAgentModelSelection; timeoutMs?: number; }; }; diff --git a/tests/agent/sub/SubAgentSession.spec.ts b/tests/agent/sub/SubAgentSession.spec.ts index b6e9133d2..89c068935 100644 --- a/tests/agent/sub/SubAgentSession.spec.ts +++ b/tests/agent/sub/SubAgentSession.spec.ts @@ -306,6 +306,144 @@ test("explore registry ignores an unallowed dynamic execute_code tool without pr assert.equal(session.buildConfig().runMode, "ask"); }); +test("subagent config uses configured default model and caps", () => { + const registry = new ToolRegistry(); + const session = new SubAgentSession({ + definition: SUBAGENT_DEFINITIONS["general-purpose"], + directive: "Inspect the provided files.", + parentConfig: { + ...parentConfig(), + provider: "main", + model: "main-model", + modelMultimodal: { input: ["text"] }, + maxContextTokens: 100000, + maxOutputTokens: 20000, + subagentModel: { + provider: "child", + model: "child-model", + modelMultimodal: { input: ["text", "image"] }, + maxContextTokens: 32000, + maxOutputTokens: 4096, + }, + }, + parentDependencies: { + router: createRouter(), + tools: { + registry, + scheduler: {} as never, + }, + }, + parentSessionId: "parent-session", + parentTurnId: "parent-turn", + subagentSessionId: "subagent-session", + subagentId: "subagent-1", + }) as unknown as TestableSubAgentSession; + + const config = session.buildConfig(); + + assert.equal(config.provider, "child"); + assert.equal(config.model, "child-model"); + assert.deepEqual(config.modelMultimodal, { input: ["text", "image"] }); + assert.equal(config.maxContextTokens, 32000); + assert.equal(config.maxOutputTokens, 4096); + assert.equal(config.isSubagent, true); +}); + +test("subagent config inherits parent model when no default is configured", () => { + const registry = new ToolRegistry(); + const session = sessionFor(SUBAGENT_DEFINITIONS["general-purpose"], registry); + + const config = session.buildConfig(); + + assert.equal(config.provider, "test"); + assert.equal(config.model, "test-model"); + assert.equal(config.isSubagent, true); +}); + +test("configured subagent default remains a router baseline, not a router override", async () => { + const seen: Array<{ stage: "decide" | "execute"; provider: string; model: string; isMainAgent?: boolean }> = []; + const router: AgentRouterRuntime = { + decide: async ({ request, isMainAgent }) => { + seen.push({ + stage: "decide", + provider: request.provider, + model: request.model, + isMainAgent, + }); + return { + provider: "routed", + model: "tier-model", + scenarioType: "default", + isSubagent: true, + orchestrating: false, + resolvedFrom: "tokenSaver", + mutations: {}, + }; + }, + execute: async function* (_decision, request) { + seen.push({ + stage: "execute", + provider: request.provider, + model: request.model, + }); + yield { type: "text_delta", text: FINAL_REPORT }; + yield { + type: "usage", + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }; + }, + stream: async function* () { + yield { type: "text_delta", text: FINAL_REPORT }; + }, + } as AgentRouterRuntime; + const events: AgentEvent[] = []; + const loop = new AgentLoop({ + ...parentConfig(), + provider: "main", + model: "main-model", + subagentModel: { + provider: "child", + model: "child-model", + }, + }, { + router, + tools: { + registry: new ToolRegistry(), + scheduler: {} as never, + }, + eventEmitter: (event) => { + events.push(event); + }, + }) as unknown as TestableAgentLoop; + const fork = loop.buildSubagentForkApi({ + sessionId: "parent-session", + turnId: "parent-turn", + messages: [], + }, []); + + await fork.fork({ + definitionId: "explore", + directive: "Inspect routing.", + subagentId: "subagent-routed", + timeoutMs: 60_000, + }); + + assert.deepEqual(seen, [ + { + stage: "decide", + provider: "child", + model: "child-model", + isMainAgent: false, + }, + { + stage: "execute", + provider: "routed", + model: "tier-model", + }, + ]); + assert.ok(events.some((event) => event.type === "subagent_completed")); +}); + test("read-only subagent evaluates bash safety from the real command", async () => { const commands: string[] = []; const runner: PilotDeckCommandRunner = { From 170e5a15178af582bd6ddc67e4de29dc2f7184ea Mon Sep 17 00:00:00 2001 From: Gucc1 <1006490933@qq.com> Date: Tue, 18 Aug 2026 11:48:16 +0800 Subject: [PATCH 2/4] handle stale subagent default model refs --- src/pilot/config/loadPilotConfig.ts | 65 ++++++++- tests/agent/sub/SubAgentSession.spec.ts | 127 +++++++++++++++++- ui/server/services/pilotdeckConfig.js | 22 ++- ui/server/services/pilotdeckConfig.test.js | 107 +++++++++++++++ .../view/agentModel/utils/modelRefs.spec.ts | 69 ++++++++++ .../modelPool/components/ModelsSection.tsx | 24 +++- .../view/modelPool/utils/providerRefs.ts | 27 ++++ 7 files changed, 434 insertions(+), 7 deletions(-) diff --git a/src/pilot/config/loadPilotConfig.ts b/src/pilot/config/loadPilotConfig.ts index 83929c9f6..0eeed0b25 100644 --- a/src/pilot/config/loadPilotConfig.ts +++ b/src/pilot/config/loadPilotConfig.ts @@ -408,9 +408,8 @@ function parseAgentSubagents( if (typeof value.default === "string" && value.default.trim() === "inherit") { defaultModel = undefined; } else { - defaultModel = parseAgentModelSelection( + defaultModel = parseSubagentDefaultModelSelection( value.default, - "agent.subagents.default", modelConfig, diagnostics, ); @@ -422,6 +421,68 @@ function parseAgentSubagents( }; } +function parseSubagentDefaultModelSelection( + value: unknown, + modelConfig: ReturnType, + diagnostics: PilotConfigDiagnostic[], +): PilotAgentModelSelection | undefined { + const path = "agent.subagents.default"; + if (typeof value !== "string" || value.trim().length === 0) { + diagnostics.push({ + code: "CONFIG_AGENT_SUBAGENT_MODEL_INVALID", + severity: "warning", + message: `${path} must be inherit or a provider/model string. Inheriting agent.model instead.`, + path, + recoverable: true, + }); + return undefined; + } + + const trimmed = value.trim(); + const separatorIndex = trimmed.indexOf("/"); + const providerId = separatorIndex >= 0 ? trimmed.slice(0, separatorIndex) : ""; + const modelId = separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : ""; + if (!providerId || !modelId) { + diagnostics.push({ + code: "CONFIG_AGENT_SUBAGENT_MODEL_INVALID", + severity: "warning", + message: `${path} must use provider/model format. Inheriting agent.model instead.`, + path, + recoverable: true, + }); + return undefined; + } + + const provider = modelConfig.providers[providerId]; + if (!provider) { + diagnostics.push({ + code: "CONFIG_AGENT_SUBAGENT_PROVIDER_NOT_FOUND", + severity: "warning", + message: `${path} references unknown provider ${providerId}. Inheriting agent.model instead.`, + path, + recoverable: true, + }); + return undefined; + } + + if (!provider.models[modelId]) { + diagnostics.push({ + code: "CONFIG_AGENT_SUBAGENT_MODEL_NOT_FOUND", + severity: "warning", + message: `${path} references unknown model ${modelId} for provider ${providerId}. Inheriting agent.model instead.`, + path, + recoverable: true, + }); + return undefined; + } + + return { + id: trimmed, + provider: providerId, + model: modelId, + }; +} + function parseAgentModelSelection( value: unknown, path: string, diff --git a/tests/agent/sub/SubAgentSession.spec.ts b/tests/agent/sub/SubAgentSession.spec.ts index 89c068935..e5709d406 100644 --- a/tests/agent/sub/SubAgentSession.spec.ts +++ b/tests/agent/sub/SubAgentSession.spec.ts @@ -1,5 +1,8 @@ import assert from "node:assert/strict"; -import test from "node:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { afterEach } from "node:test"; import type { AgentRuntimeConfig } from "../../../src/agent/runtime/AgentRuntimeConfig.js"; import { @@ -34,6 +37,8 @@ import { type PilotDeckToolRuntimeContext, } from "../../../src/tool/index.js"; import type { CanonicalMessage } from "../../../src/model/index.js"; +import { loadPilotConfig } from "../../../src/pilot/index.js"; +import { PilotConfigError } from "../../../src/pilot/config/types.js"; const FINAL_REPORT = [ "Scope: inspected inputs", @@ -43,6 +48,14 @@ const FINAL_REPORT = [ "Issues: none", ].join("\n"); +const tempPilotHomes: string[] = []; + +afterEach(() => { + for (const dir of tempPilotHomes.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + type TestableSubAgentSession = { buildScopedRegistry(): ToolRegistry; buildConfig(): AgentRuntimeConfig; @@ -198,6 +211,118 @@ function runtimeContext(config: AgentRuntimeConfig): PilotDeckToolRuntimeContext }; } +function writePilotConfig(raw: string): string { + const pilotHome = mkdtempSync(join(tmpdir(), "pilotdeck-subagent-config-")); + tempPilotHomes.push(pilotHome); + writeFileSync(join(pilotHome, "pilotdeck.yaml"), raw, "utf8"); + return pilotHome; +} + +function loadInlinePilotConfig(raw: string) { + const pilotHome = writePilotConfig(raw); + return loadPilotConfig({ env: { PILOT_HOME: pilotHome } }); +} + +function pilotConfigWithSubagentDefault(defaultValue: string): string { + return ` +schemaVersion: 1 +agent: + model: main/main-model + subagents: + default: ${defaultValue} +model: + providers: + main: + protocol: openai + url: https://example.invalid/v1 + apiKey: test + models: + main-model: {} + child: + protocol: openai + url: https://example.invalid/v1 + apiKey: test + models: + child-model: {} +`; +} + +test("agent.subagents.default inherit keeps subagent model unset", () => { + const snapshot = loadInlinePilotConfig(pilotConfigWithSubagentDefault("inherit")); + + assert.equal(snapshot.config.agent.subagents?.default, undefined); + assert.equal(snapshot.diagnostics.some((diagnostic) => diagnostic.path === "agent.subagents.default"), false); +}); + +test("agent.subagents.default resolves a configured model", () => { + const snapshot = loadInlinePilotConfig(pilotConfigWithSubagentDefault("child/child-model")); + + assert.deepEqual(snapshot.config.agent.subagents?.default, { + id: "child/child-model", + provider: "child", + model: "child-model", + }); +}); + +test("agent.subagents.default inherits with a warning when provider is missing", () => { + const snapshot = loadInlinePilotConfig(pilotConfigWithSubagentDefault("missing/child-model")); + + assert.equal(snapshot.config.agent.subagents?.default, undefined); + assert.deepEqual( + snapshot.diagnostics.find((diagnostic) => diagnostic.path === "agent.subagents.default"), + { + code: "CONFIG_AGENT_SUBAGENT_PROVIDER_NOT_FOUND", + severity: "warning", + message: "agent.subagents.default references unknown provider missing. Inheriting agent.model instead.", + path: "agent.subagents.default", + recoverable: true, + }, + ); +}); + +test("agent.subagents.default inherits with a warning when model is missing", () => { + const snapshot = loadInlinePilotConfig(pilotConfigWithSubagentDefault("child/missing-model")); + + assert.equal(snapshot.config.agent.subagents?.default, undefined); + assert.equal( + snapshot.diagnostics.find((diagnostic) => diagnostic.path === "agent.subagents.default")?.code, + "CONFIG_AGENT_SUBAGENT_MODEL_NOT_FOUND", + ); +}); + +test("agent.subagents.default inherits with a warning when malformed", () => { + const snapshot = loadInlinePilotConfig(pilotConfigWithSubagentDefault("missing-format")); + + assert.equal(snapshot.config.agent.subagents?.default, undefined); + assert.equal( + snapshot.diagnostics.find((diagnostic) => diagnostic.path === "agent.subagents.default")?.code, + "CONFIG_AGENT_SUBAGENT_MODEL_INVALID", + ); +}); + +test("agent.model still fails fast when provider is missing", () => { + assert.throws( + () => loadInlinePilotConfig(` +schemaVersion: 1 +agent: + model: missing/main-model + subagents: + default: child/child-model +model: + providers: + child: + protocol: openai + url: https://example.invalid/v1 + apiKey: test + models: + child-model: {} +`), + (error) => + error instanceof PilotConfigError && + error.diagnostics.some((diagnostic) => diagnostic.code === "CONFIG_AGENT_PROVIDER_NOT_FOUND"), + ); +}); + test("explore subagent does not probe tool safety before execution", async () => { const readOnlyChecks: string[] = []; const registry = new ToolRegistry(); diff --git a/ui/server/services/pilotdeckConfig.js b/ui/server/services/pilotdeckConfig.js index b3a493c68..eae1551b3 100644 --- a/ui/server/services/pilotdeckConfig.js +++ b/ui/server/services/pilotdeckConfig.js @@ -191,12 +191,16 @@ export function resolveModel(config, ref, options = {}) { throw new Error(`Provider not found for model "${effective}": ${parts.providerId}`); } const def = isRecord(provider.models) ? provider.models[parts.modelId] : null; + if (!isRecord(def)) { + if (options.allowMissing) return null; + throw new Error(`Model not found for provider "${parts.providerId}": ${parts.modelId}`); + } return { id: effective, providerId: parts.providerId, provider, model: parts.modelId, - def: isRecord(def) ? def : {}, + def, }; } @@ -230,6 +234,16 @@ function validateModelRef(config, ref, label, errors) { } } +function validateOptionalSubagentDefault(config, warnings) { + const modelRef = normalizeString(config.agent?.subagents?.default); + if (!modelRef || modelRef === 'inherit') return; + if (!resolveModel(config, modelRef, { allowMissing: true })) { + warnings.push( + `agent.subagents.default="${modelRef}" doesn't resolve to a configured provider/model; subagents will inherit agent.model`, + ); + } +} + function validateRouterModelRefs(config, errors) { const router = config.router; if (!isRecord(router)) return; @@ -301,6 +315,7 @@ export function validatePilotDeckConfig(config) { } } + validateOptionalSubagentDefault(normalized, warnings); validateRouterModelRefs(normalized, errors); validateGatewayConfig(normalized, errors, warnings); @@ -603,6 +618,11 @@ function purgeBootstrapPlaceholder(config) { } } + const subagentDefault = normalizeString(config?.agent?.subagents?.default); + if (subagentDefault && subagentDefault !== 'inherit' && !resolveModel(config, subagentDefault, { allowMissing: true })) { + config.agent.subagents.default = 'inherit'; + } + const router = config?.router; if (!isRecord(router)) return config; diff --git a/ui/server/services/pilotdeckConfig.test.js b/ui/server/services/pilotdeckConfig.test.js index 899aef44c..319fbf1c8 100644 --- a/ui/server/services/pilotdeckConfig.test.js +++ b/ui/server/services/pilotdeckConfig.test.js @@ -7,6 +7,7 @@ import { readPilotDeckConfigFile, sanitizeProviderCredentials, validatePilotDeckConfig, + writePilotDeckConfig, } from './pilotdeckConfig.js'; const tempDirs = []; @@ -203,6 +204,78 @@ describe('validatePilotDeckConfig gateway validation', () => { expect(validation.errors).toEqual([]); }); + it('warns instead of failing when agent.subagents.default references a missing provider', () => { + const validation = validatePilotDeckConfig({ + agent: { + model: 'ollama/qwen3:0.6b', + subagents: { default: 'missing/qwen3:0.6b' }, + }, + model: { + providers: { + ollama: { + protocol: 'openai', + url: 'http://localhost:11434/v1', + models: { + 'qwen3:0.6b': {}, + }, + }, + }, + }, + }); + + expect(validation.valid).toBe(true); + expect(validation.warnings).toContain( + 'agent.subagents.default="missing/qwen3:0.6b" doesn\'t resolve to a configured provider/model; subagents will inherit agent.model', + ); + }); + + it('warns instead of failing when agent.subagents.default references a missing model', () => { + const validation = validatePilotDeckConfig({ + agent: { + model: 'ollama/qwen3:0.6b', + subagents: { default: 'ollama/missing-model' }, + }, + model: { + providers: { + ollama: { + protocol: 'openai', + url: 'http://localhost:11434/v1', + models: { + 'qwen3:0.6b': {}, + }, + }, + }, + }, + }); + + expect(validation.valid).toBe(true); + expect(validation.warnings).toContain( + 'agent.subagents.default="ollama/missing-model" doesn\'t resolve to a configured provider/model; subagents will inherit agent.model', + ); + }); + + it('rejects agent.model when the configured model is missing', () => { + const validation = validatePilotDeckConfig({ + agent: { model: 'ollama/missing-model' }, + model: { + providers: { + ollama: { + protocol: 'openai', + url: 'http://localhost:11434/v1', + models: { + 'qwen3:0.6b': {}, + }, + }, + }, + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.errors).toContain( + 'agent.model="ollama/missing-model" doesn\'t resolve to a configured provider/model', + ); + }); + it('removes blank Ollama apiKeys during sanitization', () => { const config = sanitizeProviderCredentials({ model: { @@ -222,4 +295,38 @@ describe('validatePilotDeckConfig gateway validation', () => { expect(config.model.providers.ollama).not.toHaveProperty('apiKey'); expect(config.model.providers.ollama.url).toBe('http://localhost:11434/v1'); }); + + it('resets a placeholder subagent default when writing config', async () => { + const configPath = useTempConfig(null); + + const result = await writePilotDeckConfig({ + agent: { + model: 'ollama/qwen3:0.6b', + subagents: { default: '_placeholder/_placeholder' }, + }, + model: { + providers: { + _placeholder: { + protocol: 'openai', + url: 'https://example.invalid/v1', + apiKey: 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE', + models: { + _placeholder: {}, + }, + }, + ollama: { + protocol: 'openai', + url: 'http://localhost:11434/v1', + models: { + 'qwen3:0.6b': {}, + }, + }, + }, + }, + }); + + expect(result.config.agent.subagents.default).toBe('inherit'); + expect(result.config.model.providers).not.toHaveProperty('_placeholder'); + expect(result.configPath).toBe(configPath); + }); }); diff --git a/ui/src/components/settings/view/agentModel/utils/modelRefs.spec.ts b/ui/src/components/settings/view/agentModel/utils/modelRefs.spec.ts index ade19afd5..1b72c8d28 100644 --- a/ui/src/components/settings/view/agentModel/utils/modelRefs.spec.ts +++ b/ui/src/components/settings/view/agentModel/utils/modelRefs.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; import type { PilotDeckConfig } from "../../modelPool/types"; import { activeModelCapabilities, setModelImageInput } from "./modelRefs"; +import { + clearSubagentDefaultForRemovedModel, + clearSubagentDefaultForRemovedProvider, +} from "../../modelPool/utils/providerRefs"; describe("setModelImageInput", () => { it("persists an explicit text-only capability when image input is disabled", () => { @@ -128,3 +132,68 @@ describe("activeModelCapabilities token defaults", () => { expect(capabilities?.defaultMaxOutputTokens).toBe(128_000); }); }); + +describe("subagent default model reference cleanup", () => { + it("resets agent.subagents.default when its provider is removed", () => { + const config: PilotDeckConfig = { + agent: { + model: "main/main-model", + subagents: { default: "child/child-model" }, + }, + model: { + providers: { + main: { models: { "main-model": {} } }, + }, + }, + }; + + const updated = clearSubagentDefaultForRemovedProvider(config, "child"); + + expect(updated.agent?.subagents?.default).toBe("inherit"); + expect(config.agent?.subagents?.default).toBe("child/child-model"); + }); + + it("keeps agent.subagents.default when a different provider is removed", () => { + const config: PilotDeckConfig = { + agent: { + model: "main/main-model", + subagents: { default: "child/child-model" }, + }, + }; + + const updated = clearSubagentDefaultForRemovedProvider(config, "main"); + + expect(updated).toBe(config); + }); + + it("resets agent.subagents.default when its model is removed", () => { + const config: PilotDeckConfig = { + agent: { + model: "main/main-model", + subagents: { default: "child/child-model" }, + }, + model: { + providers: { + child: { models: {} }, + }, + }, + }; + + const updated = clearSubagentDefaultForRemovedModel(config, "child", "child-model"); + + expect(updated.agent?.subagents?.default).toBe("inherit"); + }); + + it("keeps agent.subagents.default when a different model is removed", () => { + const config: PilotDeckConfig = { + agent: { + model: "main/main-model", + subagents: { default: "child/child-model" }, + }, + }; + + const updated = clearSubagentDefaultForRemovedModel(config, "child", "other-model"); + + expect(updated).toBe(config); + }); +}); diff --git a/ui/src/components/settings/view/modelPool/components/ModelsSection.tsx b/ui/src/components/settings/view/modelPool/components/ModelsSection.tsx index 49d024643..87d02aab6 100644 --- a/ui/src/components/settings/view/modelPool/components/ModelsSection.tsx +++ b/ui/src/components/settings/view/modelPool/components/ModelsSection.tsx @@ -9,7 +9,11 @@ import type { } from "../../../../../hooks/usePilotDeckConfig"; import { patch } from "../utils/patch"; import type { PilotDeckConfig, V2Provider } from "../types"; -import { rewriteProviderRefs } from "../utils/providerRefs"; +import { + clearSubagentDefaultForRemovedModel, + clearSubagentDefaultForRemovedProvider, + rewriteProviderRefs, +} from "../utils/providerRefs"; import { PageSectionHeader } from "../../../shared/view"; import CatalogPicker from "./CatalogPicker"; import ProviderCard from "./ProviderCard"; @@ -39,7 +43,12 @@ export default function ModelsSection({ config, onChange }: ModelsSectionProps) const removeProvider = async (id: string) => { const next = { ...providers }; delete next[id]; - await applyChange(patch(config, ["model", "providers"], next)); + await applyChange( + clearSubagentDefaultForRemovedProvider( + patch(config, ["model", "providers"], next), + id, + ), + ); }; const buildRenamedConfig = (oldId: string, newId: string) => { @@ -67,7 +76,16 @@ export default function ModelsSection({ config, onChange }: ModelsSectionProps) return { ok: false, error: t("pilotDeckConfig.panels.models.providerIdDuplicate") }; } const targetId = trimmed || oldId; - const nextConfig = patch(renamed.config, ["model", "providers", targetId], provider); + let nextConfig = patch(renamed.config, ["model", "providers", targetId], provider); + if (targetId === oldId) { + const previousModels = providers[oldId]?.models ?? {}; + const nextModels = provider.models ?? {}; + for (const modelId of Object.keys(previousModels)) { + if (!(modelId in nextModels)) { + nextConfig = clearSubagentDefaultForRemovedModel(nextConfig, targetId, modelId); + } + } + } return applyChange( nextConfig, targetId !== oldId diff --git a/ui/src/components/settings/view/modelPool/utils/providerRefs.ts b/ui/src/components/settings/view/modelPool/utils/providerRefs.ts index 73bf15655..ca510509c 100644 --- a/ui/src/components/settings/view/modelPool/utils/providerRefs.ts +++ b/ui/src/components/settings/view/modelPool/utils/providerRefs.ts @@ -17,6 +17,33 @@ function rewriteProviderRef( return `${newProviderId}/${value.slice(oldPrefix.length)}`; } +function splitModelRef(value: unknown): { providerId: string; modelId: string } | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0 || slash === trimmed.length - 1) return null; + return { providerId: trimmed.slice(0, slash), modelId: trimmed.slice(slash + 1) }; +} + +export function clearSubagentDefaultForRemovedProvider( + config: PilotDeckConfig, + providerId: string, +): PilotDeckConfig { + const parsed = splitModelRef(config.agent?.subagents?.default); + if (!parsed || parsed.providerId !== providerId) return config; + return patch(config, ["agent", "subagents", "default"], "inherit"); +} + +export function clearSubagentDefaultForRemovedModel( + config: PilotDeckConfig, + providerId: string, + modelId: string, +): PilotDeckConfig { + const parsed = splitModelRef(config.agent?.subagents?.default); + if (!parsed || parsed.providerId !== providerId || parsed.modelId !== modelId) return config; + return patch(config, ["agent", "subagents", "default"], "inherit"); +} + export function rewriteProviderRefs( config: PilotDeckConfig, oldProviderId: string, From 7d1664fa3ede3f74ff9878db523510e7757326bf Mon Sep 17 00:00:00 2001 From: Gucc1 <1006490933@qq.com> Date: Tue, 18 Aug 2026 14:10:03 +0800 Subject: [PATCH 3/4] fix subagent routed model token limits --- src/agent/loop/AgentLoop.ts | 42 +++- src/agent/sub/SubAgentSession.ts | 13 +- tests/agent/loop/context-cap.spec.ts | 321 ++++++++++++++++++++++++ tests/agent/sub/SubAgentSession.spec.ts | 13 +- 4 files changed, 376 insertions(+), 13 deletions(-) diff --git a/src/agent/loop/AgentLoop.ts b/src/agent/loop/AgentLoop.ts index d36aeae91..b93197249 100644 --- a/src/agent/loop/AgentLoop.ts +++ b/src/agent/loop/AgentLoop.ts @@ -406,7 +406,7 @@ export class AgentLoop { let pendingContextBudget: TokenBudgetSnapshot | undefined; const ctx = this.dependencies.context; - const preRoutingMaxContextTokens = this.currentMaxContextTokens(this.config.provider, this.config.model); + const preRoutingMaxContextTokens = this.preRoutingMaxContextTokens(); if (ctx?.tryAutoCompact) { try { const reservedOutputTokens = this.getReservedOutputTokens(); @@ -2012,16 +2012,27 @@ export class AgentLoop { private currentMaxContextTokens(provider: string, model: string): number { const transient = this.transientTokenCaps.get(this.tokenCapKey(provider, model))?.maxContextTokens; return transient - ?? this.config.maxContextTokens + ?? this.getBaselineSubagentTokenLimits(provider, model)?.maxContextTokens + ?? this.currentConfigMaxContextTokens() ?? this.dependencies.getModelMaxContextTokens?.(provider, model) ?? this.getModelTokenLimits(provider, model)?.maxContextTokens ?? 1_000_000; } + private preRoutingMaxContextTokens(): number { + if (this.config.isSubagent && this.config.subagentModel) { + return 1_000_000; + } + return this.currentMaxContextTokens(this.config.provider, this.config.model); + } + private currentMaxOutputTokens(provider: string, model: string): number | undefined { const transient = this.transientTokenCaps.get(this.tokenCapKey(provider, model)); const modelMaxOutputTokens = this.getModelTokenLimits(provider, model)?.maxOutputTokens; - const requested = transient?.attemptMaxOutputTokens ?? transient?.requestedMaxOutputTokens ?? this.config.maxOutputTokens; + const requested = transient?.attemptMaxOutputTokens + ?? transient?.requestedMaxOutputTokens + ?? this.getBaselineSubagentTokenLimits(provider, model)?.maxOutputTokens + ?? this.currentConfigMaxOutputTokens(); const candidates = [requested, transient?.hardMaxOutputTokens] .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0); if (candidates.length > 0 && typeof modelMaxOutputTokens === "number" && Number.isFinite(modelMaxOutputTokens) && modelMaxOutputTokens > 0) { @@ -2030,6 +2041,31 @@ export class AgentLoop { return candidates.length > 0 ? Math.min(...candidates.map((value) => Math.floor(value))) : undefined; } + private getBaselineSubagentTokenLimits(provider: string, model: string): { maxContextTokens?: number; maxOutputTokens?: number } | undefined { + const baseline = this.config.subagentModel; + if (!baseline || baseline.provider !== provider || baseline.model !== model) { + return undefined; + } + return { + maxContextTokens: baseline.maxContextTokens, + maxOutputTokens: baseline.maxOutputTokens, + }; + } + + private currentConfigMaxContextTokens(): number | undefined { + if (this.config.isSubagent && this.config.subagentModel) { + return undefined; + } + return this.config.maxContextTokens; + } + + private currentConfigMaxOutputTokens(): number | undefined { + if (this.config.isSubagent && this.config.subagentModel) { + return undefined; + } + return this.config.maxOutputTokens; + } + private setTransientTokenCap(provider: string, model: string, cap: { maxContextTokens?: number; requestedMaxOutputTokens?: number; diff --git a/src/agent/sub/SubAgentSession.ts b/src/agent/sub/SubAgentSession.ts index 096e1d41e..dded64bba 100644 --- a/src/agent/sub/SubAgentSession.ts +++ b/src/agent/sub/SubAgentSession.ts @@ -279,6 +279,11 @@ export class SubAgentSession { private buildConfig(): AgentRuntimeConfig { const parent = this.options.parentConfig; const subagentModel = parent.subagentModel; + const { + maxContextTokens: _parentMaxContextTokens, + maxOutputTokens: _parentMaxOutputTokens, + ...parentWithoutTokenCaps + } = parent; const subagentSystem = buildSubagentSystemPrompt(this.options.definition); const filteredParentSystem = applySystemPromptFilters( parent.systemPrompt ?? "", @@ -288,7 +293,7 @@ export class SubAgentSession { ? `${subagentSystem}\n\n${filteredParentSystem}` : subagentSystem; return { - ...parent, + ...(subagentModel ? parentWithoutTokenCaps : parent), ...(subagentModel ? { provider: subagentModel.provider, @@ -296,12 +301,6 @@ export class SubAgentSession { ...(subagentModel.modelMultimodal ? { modelMultimodal: subagentModel.modelMultimodal } : {}), - ...(subagentModel.maxContextTokens !== undefined - ? { maxContextTokens: subagentModel.maxContextTokens } - : {}), - ...(subagentModel.maxOutputTokens !== undefined - ? { maxOutputTokens: subagentModel.maxOutputTokens } - : {}), } : {}), // Ask mode performs read-only checks against each tool call's real diff --git a/tests/agent/loop/context-cap.spec.ts b/tests/agent/loop/context-cap.spec.ts index 431ede171..f9b09d0c9 100644 --- a/tests/agent/loop/context-cap.spec.ts +++ b/tests/agent/loop/context-cap.spec.ts @@ -145,6 +145,327 @@ test("agent loop respects agent maxContextTokens before and after routing", asyn assert.ok(events.some((event) => event.type === "context_budget")); }); +test("subagent loop applies baseline caps after router keeps the baseline model", async () => { + const tokenBudget = new TokenBudgetManager(); + const budgetEvaluations: Array<{ maxContextTokens?: number; reservedOutputTokens?: number }> = []; + + const context: AgentRuntimeDependencies["context"] = { + prepareForModel: async (input) => ({ + messages: input.messages, + systemPrompt: undefined, + systemPromptParts: [], + tools: input.tools, + diagnostics: [], + boundaries: [], + }), + applyToolResults: async (input) => ({ messages: input.messages, diagnostics: [] }), + recoverFromModelError: async () => ({ type: "give_up", reason: "test" }), + captureTurn: async () => undefined, + tryAutoCompact: async (input) => { + await input.budgetEvaluator?.(input.messages); + return { + type: "skipped", + snapshot: tokenBudget.snapshotFromTokens(1_000, input.maxContextTokens ?? 1_000_000, { + reservedOutputTokens: input.reservedOutputTokens, + }), + }; + }, + }; + + const router: AgentRouterRuntime = { + invalidateSticky: () => ({ orchestrating: false }), + decide: async ({ request }) => ({ + provider: request.provider, + model: request.model, + scenarioType: "default", + isSubagent: true, + orchestrating: false, + resolvedFrom: "explicit", + mutations: {}, + }), + execute: async function* (): AsyncIterable { + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: "done" }; + yield { type: "message_end", finishReason: "stop" }; + }, + stream: async function* (): AsyncIterable {}, + materializeRequest: (decision, request) => ({ + ...request, + provider: decision.provider, + model: decision.model, + }), + observeUsage: () => undefined, + }; + + const loop = new AgentLoop({ + provider: "child", + model: "baseline", + cwd: "/workspace/project", + isSubagent: true, + subagentModel: { + provider: "child", + model: "baseline", + maxContextTokens: 200_000, + maxOutputTokens: 12_345, + }, + permissionMode: "bypassPermissions", + permissionContext: createDefaultPermissionContext({ + cwd: "/workspace/project", + mode: "bypassPermissions", + canPrompt: false, + bypassAvailable: true, + }), + }, { + router, + tools: { registry: new ToolRegistry(), scheduler: { async executeAll() { return []; } } }, + context, + tokenAccounting: { + evaluateRequestBudget: async (_request: unknown, options: { maxContextTokens: number; reservedOutputTokens?: number }) => { + budgetEvaluations.push({ + maxContextTokens: options.maxContextTokens, + reservedOutputTokens: options.reservedOutputTokens, + }); + return tokenBudget.snapshotFromTokens(1_000, options.maxContextTokens, { + reservedOutputTokens: options.reservedOutputTokens, + }); + }, + } as unknown as AgentRuntimeDependencies["tokenAccounting"], + getModelTokenLimits(provider, model) { + if (provider === "child" && model === "baseline") { + return { maxContextTokens: 200_000, maxOutputTokens: 12_345 }; + } + return undefined; + }, + }); + + for await (const _event of loop.run({ + sessionId: "subagent-baseline-caps", + turnId: "turn-baseline-caps", + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + })) { + // Drain the turn. + } + + assert.deepEqual(budgetEvaluations, [ + { maxContextTokens: 1_000_000, reservedOutputTokens: 12_345 }, + { maxContextTokens: 200_000, reservedOutputTokens: 12_345 }, + ]); +}); + +test("subagent loop uses routed model caps when router picks a smaller model than the baseline", async () => { + const tokenBudget = new TokenBudgetManager(); + const budgetEvaluations: Array<{ maxContextTokens?: number; reservedOutputTokens?: number }> = []; + + const context: AgentRuntimeDependencies["context"] = { + prepareForModel: async (input) => ({ + messages: input.messages, + systemPrompt: undefined, + systemPromptParts: [], + tools: input.tools, + diagnostics: [], + boundaries: [], + }), + applyToolResults: async (input) => ({ messages: input.messages, diagnostics: [] }), + recoverFromModelError: async () => ({ type: "give_up", reason: "test" }), + captureTurn: async () => undefined, + tryAutoCompact: async (input) => { + await input.budgetEvaluator?.(input.messages); + return { + type: "skipped", + snapshot: tokenBudget.snapshotFromTokens(1_000, input.maxContextTokens ?? 1_000_000, { + reservedOutputTokens: input.reservedOutputTokens, + }), + }; + }, + }; + + const router: AgentRouterRuntime = { + invalidateSticky: () => ({ orchestrating: false }), + decide: async () => ({ + provider: "child", + model: "small-routed", + scenarioType: "default", + isSubagent: true, + orchestrating: false, + resolvedFrom: "tokenSaver", + mutations: {}, + }), + execute: async function* (): AsyncIterable { + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: "done" }; + yield { type: "message_end", finishReason: "stop" }; + }, + stream: async function* (): AsyncIterable {}, + materializeRequest: (decision, request) => ({ ...request, provider: decision.provider, model: decision.model }), + observeUsage: () => undefined, + }; + + const loop = new AgentLoop({ + provider: "child", + model: "large-baseline", + cwd: "/workspace/project", + isSubagent: true, + subagentModel: { + provider: "child", + model: "large-baseline", + maxContextTokens: 200_000, + maxOutputTokens: 32_768, + }, + permissionMode: "bypassPermissions", + permissionContext: createDefaultPermissionContext({ + cwd: "/workspace/project", + mode: "bypassPermissions", + canPrompt: false, + bypassAvailable: true, + }), + }, { + router, + tools: { registry: new ToolRegistry(), scheduler: { async executeAll() { return []; } } }, + context, + tokenAccounting: { + evaluateRequestBudget: async (_request: unknown, options: { maxContextTokens: number; reservedOutputTokens?: number }) => { + budgetEvaluations.push({ + maxContextTokens: options.maxContextTokens, + reservedOutputTokens: options.reservedOutputTokens, + }); + return tokenBudget.snapshotFromTokens(1_000, options.maxContextTokens, { + reservedOutputTokens: options.reservedOutputTokens, + }); + }, + } as unknown as AgentRuntimeDependencies["tokenAccounting"], + getModelTokenLimits(provider, model) { + if (provider !== "child") return undefined; + if (model === "large-baseline") { + return { maxContextTokens: 200_000, maxOutputTokens: 32_768 }; + } + if (model === "small-routed") { + return { maxContextTokens: 32_000, maxOutputTokens: 4_096 }; + } + return undefined; + }, + }); + + for await (const _event of loop.run({ + sessionId: "subagent-routed-smaller-caps", + turnId: "turn-routed-smaller-caps", + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + })) { + // Drain the turn. + } + + assert.deepEqual(budgetEvaluations, [ + { maxContextTokens: 1_000_000, reservedOutputTokens: 32_768 }, + { maxContextTokens: 32_000, reservedOutputTokens: 0 }, + ]); +}); + +test("subagent loop does not precompress to a smaller baseline when router picks a larger model", async () => { + const tokenBudget = new TokenBudgetManager(); + const budgetEvaluations: Array<{ maxContextTokens?: number; reservedOutputTokens?: number }> = []; + + const context: AgentRuntimeDependencies["context"] = { + prepareForModel: async (input) => ({ + messages: input.messages, + systemPrompt: undefined, + systemPromptParts: [], + tools: input.tools, + diagnostics: [], + boundaries: [], + }), + applyToolResults: async (input) => ({ messages: input.messages, diagnostics: [] }), + recoverFromModelError: async () => ({ type: "give_up", reason: "test" }), + captureTurn: async () => undefined, + tryAutoCompact: async (input) => { + await input.budgetEvaluator?.(input.messages); + return { + type: "skipped", + snapshot: tokenBudget.snapshotFromTokens(1_000, input.maxContextTokens ?? 1_000_000, { + reservedOutputTokens: input.reservedOutputTokens, + }), + }; + }, + }; + + const router: AgentRouterRuntime = { + invalidateSticky: () => ({ orchestrating: false }), + decide: async () => ({ + provider: "child", + model: "large-routed", + scenarioType: "default", + isSubagent: true, + orchestrating: false, + resolvedFrom: "tokenSaver", + mutations: {}, + }), + execute: async function* (): AsyncIterable { + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: "done" }; + yield { type: "message_end", finishReason: "stop" }; + }, + stream: async function* (): AsyncIterable {}, + materializeRequest: (decision, request) => ({ ...request, provider: decision.provider, model: decision.model }), + observeUsage: () => undefined, + }; + + const loop = new AgentLoop({ + provider: "child", + model: "small-baseline", + cwd: "/workspace/project", + isSubagent: true, + subagentModel: { + provider: "child", + model: "small-baseline", + maxContextTokens: 32_000, + maxOutputTokens: 4_096, + }, + permissionMode: "bypassPermissions", + permissionContext: createDefaultPermissionContext({ + cwd: "/workspace/project", + mode: "bypassPermissions", + canPrompt: false, + bypassAvailable: true, + }), + }, { + router, + tools: { registry: new ToolRegistry(), scheduler: { async executeAll() { return []; } } }, + context, + tokenAccounting: { + evaluateRequestBudget: async (_request: unknown, options: { maxContextTokens: number; reservedOutputTokens?: number }) => { + budgetEvaluations.push({ + maxContextTokens: options.maxContextTokens, + reservedOutputTokens: options.reservedOutputTokens, + }); + return tokenBudget.snapshotFromTokens(1_000, options.maxContextTokens, { + reservedOutputTokens: options.reservedOutputTokens, + }); + }, + } as unknown as AgentRuntimeDependencies["tokenAccounting"], + getModelTokenLimits(provider, model) { + if (provider !== "child") return undefined; + if (model === "small-baseline") { + return { maxContextTokens: 32_000, maxOutputTokens: 4_096 }; + } + if (model === "large-routed") { + return { maxContextTokens: 200_000, maxOutputTokens: 32_768 }; + } + return undefined; + }, + }); + + for await (const _event of loop.run({ + sessionId: "subagent-routed-larger-caps", + turnId: "turn-routed-larger-caps", + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + })) { + // Drain the turn. + } + + assert.deepEqual(budgetEvaluations, [ + { maxContextTokens: 1_000_000, reservedOutputTokens: 4_096 }, + { maxContextTokens: 200_000, reservedOutputTokens: 0 }, + ]); +}); + test("agent loop does not reserve catalog max output for compaction unless requested", async () => { const tokenBudget = new TokenBudgetManager(); const budgetEvaluations: Array<{ maxContextTokens?: number; reservedOutputTokens?: number }> = []; diff --git a/tests/agent/sub/SubAgentSession.spec.ts b/tests/agent/sub/SubAgentSession.spec.ts index e5709d406..c09f8f330 100644 --- a/tests/agent/sub/SubAgentSession.spec.ts +++ b/tests/agent/sub/SubAgentSession.spec.ts @@ -431,7 +431,7 @@ test("explore registry ignores an unallowed dynamic execute_code tool without pr assert.equal(session.buildConfig().runMode, "ask"); }); -test("subagent config uses configured default model and caps", () => { +test("subagent config uses configured default model without copying caps to top-level overrides", () => { const registry = new ToolRegistry(); const session = new SubAgentSession({ definition: SUBAGENT_DEFINITIONS["general-purpose"], @@ -469,8 +469,15 @@ test("subagent config uses configured default model and caps", () => { assert.equal(config.provider, "child"); assert.equal(config.model, "child-model"); assert.deepEqual(config.modelMultimodal, { input: ["text", "image"] }); - assert.equal(config.maxContextTokens, 32000); - assert.equal(config.maxOutputTokens, 4096); + assert.equal(config.maxContextTokens, undefined); + assert.equal(config.maxOutputTokens, undefined); + assert.deepEqual(config.subagentModel, { + provider: "child", + model: "child-model", + modelMultimodal: { input: ["text", "image"] }, + maxContextTokens: 32000, + maxOutputTokens: 4096, + }); assert.equal(config.isSubagent, true); }); From d4627e716586cfb916817cf8adc806a63f14bc39 Mon Sep 17 00:00:00 2001 From: Gucc1 <1006490933@qq.com> Date: Tue, 18 Aug 2026 15:16:16 +0800 Subject: [PATCH 4/4] fix subagent caps scope and null model refs --- src/agent/loop/AgentLoop.ts | 3 + tests/agent/loop/context-cap.spec.ts | 107 +++++++++++++++++++++ ui/server/services/pilotdeckConfig.js | 11 ++- ui/server/services/pilotdeckConfig.test.js | 64 ++++++++++++ 4 files changed, 182 insertions(+), 3 deletions(-) diff --git a/src/agent/loop/AgentLoop.ts b/src/agent/loop/AgentLoop.ts index b93197249..8df79adcd 100644 --- a/src/agent/loop/AgentLoop.ts +++ b/src/agent/loop/AgentLoop.ts @@ -2042,6 +2042,9 @@ export class AgentLoop { } private getBaselineSubagentTokenLimits(provider: string, model: string): { maxContextTokens?: number; maxOutputTokens?: number } | undefined { + if (this.config.isSubagent !== true) { + return undefined; + } const baseline = this.config.subagentModel; if (!baseline || baseline.provider !== provider || baseline.model !== model) { return undefined; diff --git a/tests/agent/loop/context-cap.spec.ts b/tests/agent/loop/context-cap.spec.ts index f9b09d0c9..f514a4425 100644 --- a/tests/agent/loop/context-cap.spec.ts +++ b/tests/agent/loop/context-cap.spec.ts @@ -145,6 +145,113 @@ test("agent loop respects agent maxContextTokens before and after routing", asyn assert.ok(events.some((event) => event.type === "context_budget")); }); +test("main agent loop ignores matching subagent baseline caps", async () => { + const tokenBudget = new TokenBudgetManager(); + const budgetEvaluations: Array<{ maxContextTokens?: number; reservedOutputTokens?: number }> = []; + + const context: AgentRuntimeDependencies["context"] = { + prepareForModel: async (input) => ({ + messages: input.messages, + systemPrompt: undefined, + systemPromptParts: [], + tools: input.tools, + diagnostics: [], + boundaries: [], + }), + applyToolResults: async (input) => ({ messages: input.messages, diagnostics: [] }), + recoverFromModelError: async () => ({ type: "give_up", reason: "test" }), + captureTurn: async () => undefined, + tryAutoCompact: async (input) => { + await input.budgetEvaluator?.(input.messages); + return { + type: "skipped", + snapshot: tokenBudget.snapshotFromTokens(1_000, input.maxContextTokens ?? 1_000_000, { + reservedOutputTokens: input.reservedOutputTokens, + }), + }; + }, + }; + + const router: AgentRouterRuntime = { + invalidateSticky: () => ({ orchestrating: false }), + decide: async ({ request }) => ({ + provider: request.provider, + model: request.model, + scenarioType: "default", + isSubagent: false, + orchestrating: false, + resolvedFrom: "explicit", + mutations: {}, + }), + execute: async function* (): AsyncIterable { + yield { type: "message_start", role: "assistant" }; + yield { type: "text_delta", text: "done" }; + yield { type: "message_end", finishReason: "stop" }; + }, + stream: async function* (): AsyncIterable {}, + materializeRequest: (decision, request) => ({ + ...request, + provider: decision.provider, + model: decision.model, + }), + observeUsage: () => undefined, + }; + + const loop = new AgentLoop({ + provider: "openai", + model: "same-model", + cwd: "/workspace/project", + maxContextTokens: 8_000, + maxOutputTokens: 1_000, + subagentModel: { + provider: "openai", + model: "same-model", + maxContextTokens: 128_000, + maxOutputTokens: 32_768, + }, + permissionMode: "bypassPermissions", + permissionContext: createDefaultPermissionContext({ + cwd: "/workspace/project", + mode: "bypassPermissions", + canPrompt: false, + bypassAvailable: true, + }), + }, { + router, + tools: { registry: new ToolRegistry(), scheduler: { async executeAll() { return []; } } }, + context, + tokenAccounting: { + evaluateRequestBudget: async (_request: unknown, options: { maxContextTokens: number; reservedOutputTokens?: number }) => { + budgetEvaluations.push({ + maxContextTokens: options.maxContextTokens, + reservedOutputTokens: options.reservedOutputTokens, + }); + return tokenBudget.snapshotFromTokens(1_000, options.maxContextTokens, { + reservedOutputTokens: options.reservedOutputTokens, + }); + }, + } as unknown as AgentRuntimeDependencies["tokenAccounting"], + getModelTokenLimits(provider, model) { + if (provider === "openai" && model === "same-model") { + return { maxContextTokens: 128_000, maxOutputTokens: 32_768 }; + } + return undefined; + }, + }); + + for await (const _event of loop.run({ + sessionId: "main-agent-matching-subagent-baseline", + turnId: "turn-main-agent-matching-subagent-baseline", + messages: [{ role: "user", content: [{ type: "text", text: "hello" }] }], + })) { + // Drain the turn. + } + + assert.deepEqual(budgetEvaluations, [ + { maxContextTokens: 8_000, reservedOutputTokens: 1_000 }, + ]); +}); + test("subagent loop applies baseline caps after router keeps the baseline model", async () => { const tokenBudget = new TokenBudgetManager(); const budgetEvaluations: Array<{ maxContextTokens?: number; reservedOutputTokens?: number }> = []; diff --git a/ui/server/services/pilotdeckConfig.js b/ui/server/services/pilotdeckConfig.js index eae1551b3..e095804c3 100644 --- a/ui/server/services/pilotdeckConfig.js +++ b/ui/server/services/pilotdeckConfig.js @@ -190,17 +190,22 @@ export function resolveModel(config, ref, options = {}) { if (options.allowMissing) return null; throw new Error(`Provider not found for model "${effective}": ${parts.providerId}`); } - const def = isRecord(provider.models) ? provider.models[parts.modelId] : null; - if (!isRecord(def)) { + const models = isRecord(provider.models) ? provider.models : {}; + if (!Object.prototype.hasOwnProperty.call(models, parts.modelId)) { if (options.allowMissing) return null; throw new Error(`Model not found for provider "${parts.providerId}": ${parts.modelId}`); } + const rawDef = models[parts.modelId]; + if (rawDef !== null && rawDef !== undefined && !isRecord(rawDef)) { + if (options.allowMissing) return null; + throw new Error(`Model definition for provider "${parts.providerId}" must be an object: ${parts.modelId}`); + } return { id: effective, providerId: parts.providerId, provider, model: parts.modelId, - def, + def: isRecord(rawDef) ? rawDef : {}, }; } diff --git a/ui/server/services/pilotdeckConfig.test.js b/ui/server/services/pilotdeckConfig.test.js index 319fbf1c8..6f8272f98 100644 --- a/ui/server/services/pilotdeckConfig.test.js +++ b/ui/server/services/pilotdeckConfig.test.js @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { buildDefaultPilotDeckConfig, readPilotDeckConfigFile, + resolveModel, sanitizeProviderCredentials, validatePilotDeckConfig, writePilotDeckConfig, @@ -204,6 +205,69 @@ describe('validatePilotDeckConfig gateway validation', () => { expect(validation.errors).toEqual([]); }); + it('accepts null model definitions as empty objects', () => { + const validation = validatePilotDeckConfig({ + agent: { model: 'ollama/qwen3:0.6b' }, + model: { + providers: { + ollama: { + protocol: 'openai', + url: 'http://localhost:11434/v1', + models: { + 'qwen3:0.6b': null, + }, + }, + }, + }, + }); + + expect(validation.valid).toBe(true); + expect(validation.errors).toEqual([]); + expect(validation.config.model.providers.ollama.models['qwen3:0.6b']).toBeNull(); + expect(resolveModel(validation.config, 'ollama/qwen3:0.6b').def).toEqual({}); + }); + + it('still treats absent model keys as missing', () => { + const validation = validatePilotDeckConfig({ + agent: { model: 'ollama/missing-model' }, + model: { + providers: { + ollama: { + protocol: 'openai', + url: 'http://localhost:11434/v1', + models: { + 'qwen3:0.6b': null, + }, + }, + }, + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.errors).toContain( + 'agent.model="ollama/missing-model" doesn\'t resolve to a configured provider/model', + ); + }); + + it('still rejects non-object model definitions', () => { + expect(() => resolveModel({ + agent: { model: 'ollama/qwen3:0.6b' }, + model: { + providers: { + ollama: { + protocol: 'openai', + url: 'http://localhost:11434/v1', + models: { + 'qwen3:0.6b': 'invalid', + }, + }, + }, + }, + }, 'ollama/qwen3:0.6b')).toThrow( + 'Model definition for provider "ollama" must be an object: qwen3:0.6b', + ); + }); + it('warns instead of failing when agent.subagents.default references a missing provider', () => { const validation = validatePilotDeckConfig({ agent: {