diff --git a/crates/agent-gui/src/lib/subagents/agentTool.ts b/crates/agent-gui/src/lib/subagents/agentTool.ts index a34533cf5..e9863a781 100644 --- a/crates/agent-gui/src/lib/subagents/agentTool.ts +++ b/crates/agent-gui/src/lib/subagents/agentTool.ts @@ -40,12 +40,28 @@ import type { SubagentConversationStore } from "./store"; import { AGENT_TOOL_NAME, MAX_AGENTS, + type SubagentModelSelection, type SubagentTemplate, type SubagentToolRegistry, } from "./types"; import { createSequentialQueue, normalizeErrorMessage, runWithConcurrency } from "./utils"; import { parseSubagentBatch, type ResolvedSubagentSpec } from "./validate"; +const MODEL_SELECTION_PARAMETER = Type.Object( + { + custom_provider_id: Type.String({ + minLength: 1, + description: "Configured provider id from LiveAgent settings.", + }), + model: Type.String({ minLength: 1, description: "Enabled model id for that provider." }), + }, + { + additionalProperties: false, + description: + "Optional model override for this run. Omit it to inherit the parent conversation model.", + }, +); + const AGENT_PARAMETERS = Type.Object( { agents: Type.Array( @@ -80,6 +96,7 @@ const AGENT_PARAMETERS = Type.Object( "Enabled AGENTS template id or name. Only valid when the id is first created.", }), ), + model: Type.Optional(MODEL_SELECTION_PARAMETER), mode: Type.Optional( Type.Union([Type.Literal("readonly"), Type.Literal("worktree")], { description: @@ -185,16 +202,28 @@ export type SubagentRuntimeConfig = { providerId: ProviderId; model: string; runtime: SubagentProviderRuntime; + resolveModel?: ResolveSubagentModel; sessionId?: string; templates: SubagentTemplate[]; store: SubagentConversationStore; scheduler: SubagentScheduler; }; +export type ResolvedSubagentModel = { + providerId: ProviderId; + model: string; + runtime: SubagentProviderRuntime; +}; + +export type ResolveSubagentModel = ( + selectedModel: SubagentModelSelection, +) => ResolvedSubagentModel | undefined; + export function createSubagentTools(params: { providerId: ProviderId; model: string; runtime: SubagentProviderRuntime; + resolveModel?: ResolveSubagentModel; runtimePlatform?: RuntimePlatform; workdir: string; resolveHomeDir?: () => Promise; @@ -292,6 +321,41 @@ export function createSubagentTools(params: { if (!parsed.ok) { return rejectBatch(parsed.issues); } + const modelByAgentId = new Map(); + const modelIssues: SubagentIssue[] = []; + for (const agent of parsed.batch.agents) { + const selectedModel = agent.spec.selectedModel; + if (!selectedModel) continue; + + let resolvedModel: ResolvedSubagentModel | undefined; + try { + resolvedModel = params.resolveModel?.(selectedModel); + } catch (error) { + modelIssues.push( + issue( + "model_unavailable", + `Configured model ${selectedModel.customProviderId}/${selectedModel.model} could not be resolved: ${normalizeErrorMessage(error, "unknown model configuration error")}`, + agent.spec.id, + ), + ); + continue; + } + if (!resolvedModel) { + modelIssues.push( + issue( + "model_unavailable", + `Configured model ${selectedModel.customProviderId}/${selectedModel.model} is missing or disabled. Select an active model or omit the override to inherit the conversation model.`, + agent.spec.id, + ), + ); + continue; + } + modelByAgentId.set(agent.spec.id, resolvedModel); + } + if (modelIssues.length > 0) { + return rejectBatch(modelIssues); + } + const { agents, issues: pathIssues } = await resolveOutputPaths({ agents: parsed.batch.agents, workdir: params.workdir, @@ -380,10 +444,19 @@ export function createSubagentTools(params: { }; try { + const resolvedModel = modelByAgentId.get(resolved.spec.id); + const runEnvironment = resolvedModel + ? { + ...env, + providerId: resolvedModel.providerId, + model: resolvedModel.model, + runtime: resolvedModel.runtime, + } + : env; const report = await enqueueAgentRun(resolved.spec.id, () => scheduler.runSubagent( () => - executeSubagentRun(env, { + executeSubagentRun(runEnvironment, { spec: resolved.spec, existingIdentity: resolved.existingIdentity, template: resolved.template, diff --git a/crates/agent-gui/src/lib/subagents/errors.ts b/crates/agent-gui/src/lib/subagents/errors.ts index 974b218ff..7382a07a1 100644 --- a/crates/agent-gui/src/lib/subagents/errors.ts +++ b/crates/agent-gui/src/lib/subagents/errors.ts @@ -13,6 +13,7 @@ export type SubagentIssueCode = | "unknown_agent_id" | "unknown_template" | "identity_conflict" + | "model_unavailable" | "worktree_unavailable" | "output_path_outside_workspace" | "unknown_recipient" diff --git a/crates/agent-gui/src/lib/subagents/index.ts b/crates/agent-gui/src/lib/subagents/index.ts index 0313fed56..e1b53037b 100644 --- a/crates/agent-gui/src/lib/subagents/index.ts +++ b/crates/agent-gui/src/lib/subagents/index.ts @@ -1,4 +1,9 @@ -export { createSubagentTools, type SubagentRuntimeConfig } from "./agentTool"; +export { + createSubagentTools, + type ResolvedSubagentModel, + type ResolveSubagentModel, + type SubagentRuntimeConfig, +} from "./agentTool"; export { renderMessageBusSnapshot } from "./bus"; export { isSubagentCardToolCall } from "./card"; export type { SubagentStoreIpc } from "./ipc/store"; @@ -33,6 +38,7 @@ export { SUBAGENT_BROADCAST_RECIPIENT, SUBAGENT_PARENT_ID, type SubagentIdentity, + type SubagentModelSelection, type SubagentRunSummary, type SubagentTemplate, type SubagentToolRegistry, diff --git a/crates/agent-gui/src/lib/subagents/policy.ts b/crates/agent-gui/src/lib/subagents/policy.ts index 978eebc47..0f6d67691 100644 --- a/crates/agent-gui/src/lib/subagents/policy.ts +++ b/crates/agent-gui/src/lib/subagents/policy.ts @@ -236,7 +236,7 @@ export function decideWorktreeCleanup(params: { return { shouldCleanup: false, reason: "unapplied_changes" }; } -/** Readonly children: read-only builtin tools + MCP business tools. */ +/** Readonly children: only tools whose metadata explicitly declares read-only behavior. */ export function selectReadOnlyTools(params: { tools: Tool[]; metadataByName: Map; @@ -245,9 +245,7 @@ export function selectReadOnlyTools(params: { if (tool.name === AGENT_TOOL_NAME) return false; if (tool.name === SEND_MESSAGE_TOOL_NAME) return true; const metadata = params.metadataByName.get(tool.name); - return ( - metadata?.isReadOnly === true || (metadata?.groupId === "mcp" && metadata.kind === "mcp") - ); + return metadata?.isReadOnly === true; }); } diff --git a/crates/agent-gui/src/lib/subagents/types.ts b/crates/agent-gui/src/lib/subagents/types.ts index 8cb96317c..76098dba8 100644 --- a/crates/agent-gui/src/lib/subagents/types.ts +++ b/crates/agent-gui/src/lib/subagents/types.ts @@ -24,6 +24,11 @@ export type SubagentApplyPolicy = "none" | "explicit" | "auto"; export type SubagentRunStatus = "running" | "completed" | "failed" | "cancelled"; export type SubagentMessageChannel = "direct" | "shared" | "decision" | "question"; +export type SubagentModelSelection = { + customProviderId: string; + model: string; +}; + /** One fully validated agent request inside an Agent tool call. */ export type SubagentSpec = { id: string; @@ -32,6 +37,7 @@ export type SubagentSpec = { role?: string; identity?: string; templateId?: string; + selectedModel?: SubagentModelSelection; mode: SubagentMode; applyPolicy: SubagentApplyPolicy; allowedOutputPaths: string[]; diff --git a/crates/agent-gui/src/lib/subagents/validate.ts b/crates/agent-gui/src/lib/subagents/validate.ts index 60556dbef..fbe286f2e 100644 --- a/crates/agent-gui/src/lib/subagents/validate.ts +++ b/crates/agent-gui/src/lib/subagents/validate.ts @@ -8,6 +8,7 @@ import { type SubagentApplyPolicy, type SubagentIdentity, type SubagentMode, + type SubagentModelSelection, type SubagentSpec, type SubagentTemplate, } from "./types"; @@ -48,6 +49,7 @@ const KNOWN_AGENT_KEYS = new Set([ "role", "identity", "template", + "model", "mode", "apply_policy", "allowed_output_paths", @@ -58,6 +60,51 @@ const KNOWN_AGENT_KEYS = new Set([ const MODES = new Set(["readonly", "worktree"]); const APPLY_POLICIES = new Set(["none", "explicit", "auto"]); +function parseModelSelection( + value: unknown, + agentId: string | undefined, + issues: SubagentIssue[], +): SubagentModelSelection | undefined { + if (typeof value === "undefined") return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + issues.push( + issue( + "invalid_arguments", + "model must be an object with custom_provider_id and model strings.", + agentId, + ), + ); + return undefined; + } + + const obj = asObject(value); + for (const key of Object.keys(obj)) { + if (key !== "custom_provider_id" && key !== "model") { + issues.push( + issue( + "invalid_arguments", + `Unknown model field "${key}". Allowed fields: custom_provider_id, model.`, + agentId, + ), + ); + } + } + + const customProviderId = optionalString(obj.custom_provider_id); + const model = optionalString(obj.model); + if (!customProviderId || !model) { + issues.push( + issue( + "invalid_arguments", + "model.custom_provider_id and model.model must both be non-empty strings.", + agentId, + ), + ); + return undefined; + } + return { customProviderId, model }; +} + function parseOptionalBoolean( value: unknown, field: string, @@ -233,6 +280,7 @@ export function parseSubagentBatch( const role = optionalString(entry.role); const identityText = optionalString(entry.identity); const templateRef = optionalString(entry.template); + const selectedModel = parseModelSelection(entry.model, agentRef, issues); let template: SubagentTemplate | undefined; if (templateRef) { @@ -343,6 +391,7 @@ export function parseSubagentBatch( role, identity: identityText, templateId: template?.id ?? existingIdentity?.templateId, + selectedModel, mode, applyPolicy, allowedOutputPaths, diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 4a58f277e..a96b195c1 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -290,6 +290,7 @@ export async function buildBuiltinToolRegistry( providerId: subagentRuntime.providerId, model: subagentRuntime.model, runtime: subagentRuntime.runtime, + resolveModel: subagentRuntime.resolveModel, runtimePlatform: params.runtimePlatform, workdir: params.workdir, resolveHomeDir, diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 84ceecb31..5c23efcf0 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -173,6 +173,7 @@ import { collectRetainedSubagentParentToolCallIds, createSubagentStoreManager, pruneSubagentRunsForConversation, + type ResolveSubagentModel, } from "../lib/subagents"; import { applyTerminalEventToSessions, @@ -572,6 +573,23 @@ function buildProviderRuntimeConfig( }; } +function createSubagentModelResolver( + settings: AppSettings, + runtimeControls: ChatRuntimeControls, +): ResolveSubagentModel { + return (selectedModel) => { + const provider = settings.customProviders.find( + (item) => item.id === selectedModel.customProviderId, + ); + if (!provider?.activeModels.includes(selectedModel.model)) return undefined; + return { + providerId: provider.type, + model: selectedModel.model, + runtime: buildProviderRuntimeConfig(provider, selectedModel.model, runtimeControls), + }; + }; +} + function selectedModelsMatch(left: SelectedModel | undefined, right: SelectedModel | undefined) { return ( Boolean(left) && @@ -3780,6 +3798,7 @@ export function ChatPage(props: ChatPageProps) { overrides?.runtimeControlsOverride ?? settings.chatRuntimeControls; const providerConfig = buildProviderRuntimeConfig(provider, model, runtimeControls); + const resolveSubagentModel = createSubagentModelResolver(settings, runtimeControls); const memorySummaryModelSelection = resolveMemorySummaryModelSelection(settings); const memoryExtractionModel = memorySummaryModelSelection ? { @@ -4508,6 +4527,7 @@ export function ChatPage(props: ChatPageProps) { }, runtimeModel, selectedModel, + resolveSubagentModel, memoryExtractionModel, onMemoryExtractionModelFailure: handleMemoryExtractionModelFailure, memoryExtractionStatusText, diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index a59c66e76..668a72660 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -58,6 +58,7 @@ import { buildRosterReminder, createSubagentScheduler, isSubagentCardToolCall, + type ResolveSubagentModel, renderMessageBusSnapshot, SUBAGENT_PARENT_ID, type SubagentConversationStore, @@ -206,6 +207,7 @@ export type RunAgentConversationTurnParams = { customProviderId: string; model: string; }; + resolveSubagentModel: ResolveSubagentModel; effectiveWorkdir: string; effectiveSkillsEnabled: boolean; showSilentMemoryExtraction: boolean; @@ -275,6 +277,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP runtime, runtimeModel, selectedModel, + resolveSubagentModel, effectiveWorkdir, effectiveSkillsEnabled, showSilentMemoryExtraction, @@ -422,6 +425,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP providerId, model, runtime, + resolveModel: resolveSubagentModel, sessionId, templates: enabledSubagentTemplates(agentTemplates), store: subagentStore, diff --git a/crates/agent-gui/test/subagents/agent-tool.test.mjs b/crates/agent-gui/test/subagents/agent-tool.test.mjs index 49aa9416c..eebd552bf 100644 --- a/crates/agent-gui/test/subagents/agent-tool.test.mjs +++ b/crates/agent-gui/test/subagents/agent-tool.test.mjs @@ -48,7 +48,7 @@ test("readonly happy path: identity prompts, filtered child tools, SendMessage a const call = harness.runnerCalls[0]; assert.deepEqual( call.tools.map((tool) => tool.name), - ["Read", "Grep", "mcp_docs_search", "SendMessage"], + ["Read", "Grep", "SendMessage"], ); assert.equal(call.sessionId, "parent-session:subagent:reviewer-a"); assert.equal(call.workdir, "/tmp/liveagent-subagent-test"); @@ -523,6 +523,118 @@ test("unknown template rejects the batch before any agent starts", async () => { assert.equal(harness.runnerCalls.length, 0); }); +test("Agent resolves a different configured model for each delegated job", async () => { + const harness = await createSubagentHarness({ + resolveModel(selectedModel) { + if (selectedModel.customProviderId === "provider-a" && selectedModel.model === "model-a") { + return { + providerId: "codex", + model: "model-a", + runtime: { baseUrl: "https://a.example.test", apiKey: "key-a" }, + }; + } + if (selectedModel.customProviderId === "provider-b" && selectedModel.model === "model-b") { + return { + providerId: "gemini", + model: "model-b", + runtime: { baseUrl: "https://b.example.test", apiKey: "key-b" }, + }; + } + return undefined; + }, + }); + const result = await harness.bundle.executeToolCall( + createAgentToolCall({ + agents: [ + { + id: "specialist-a", + prompt: "Review A", + model: { custom_provider_id: "provider-a", model: "model-a" }, + }, + { + id: "specialist-b", + prompt: "Review B", + model: { custom_provider_id: "provider-b", model: "model-b" }, + }, + { id: "inherited", prompt: "Review C" }, + ], + concurrency: 3, + }), + ); + + assert.equal(result.isError, false); + assert.deepEqual( + harness.runnerCalls + .map((call) => [call.providerId, call.model, call.runtime.apiKey]) + .sort((left, right) => left[1].localeCompare(right[1])), + [ + ["codex", "gpt-5", "test-key"], + ["codex", "model-a", "key-a"], + ["gemini", "model-b", "key-b"], + ], + ); + assert.deepEqual( + harness.storeIpc.appliedSaves + .filter((save) => save.run.status === "completed") + .map((save) => [save.run.agentId, save.run.model]) + .sort((left, right) => left[0].localeCompare(right[0])), + [ + ["inherited", "gpt-5"], + ["specialist-a", "model-a"], + ["specialist-b", "model-b"], + ], + ); +}); + +test("an unavailable explicit model rejects the whole Agent batch", async () => { + const harness = await createSubagentHarness({ resolveModel: () => undefined }); + const result = await harness.bundle.executeToolCall( + createAgentToolCall({ + agents: [ + { id: "inherited", prompt: "Would otherwise run" }, + { + id: "missing-model", + prompt: "Cannot run", + model: { custom_provider_id: "disabled-provider", model: "disabled-model" }, + }, + ], + }), + ); + + assert.equal(result.isError, true); + assert.equal(result.details.status, "rejected"); + assert.equal(result.details.issues[0].code, "model_unavailable"); + assert.match(result.content[0].text, /missing or disabled/); + assert.equal(harness.runnerCalls.length, 0); + assert.equal(harness.storeIpc.upsertIdentityCount, 0); + assert.equal(harness.worktreeIpc.creates.length, 0); +}); + +test("a model resolver failure rejects the whole Agent batch", async () => { + const harness = await createSubagentHarness({ + resolveModel() { + throw new Error("provider settings could not be decoded"); + }, + }); + const result = await harness.bundle.executeToolCall( + createAgentToolCall({ + agents: [ + { + id: "broken-model", + prompt: "Cannot run", + model: { custom_provider_id: "provider-a", model: "model-a" }, + }, + ], + }), + ); + + assert.equal(result.isError, true); + assert.equal(result.details.issues[0].code, "model_unavailable"); + assert.match(result.content[0].text, /provider settings could not be decoded/); + assert.equal(harness.runnerCalls.length, 0); + assert.equal(harness.storeIpc.upsertIdentityCount, 0); +}); + test("abort persists a cancelled run and retains the worktree", async () => { const harness = await createSubagentHarness({ runner: (params) => diff --git a/crates/agent-gui/test/subagents/harness.mjs b/crates/agent-gui/test/subagents/harness.mjs index 5f74f944b..65f3b0ff5 100644 --- a/crates/agent-gui/test/subagents/harness.mjs +++ b/crates/agent-gui/test/subagents/harness.mjs @@ -446,6 +446,7 @@ export async function createSubagentHarness(options = {}) { apiKey: "test-key", reasoning: "medium", }, + resolveModel: options.resolveModel, workdir: options.workdir ?? "/tmp/liveagent-subagent-test", sessionId: options.sessionId === null ? undefined : (options.sessionId ?? "parent-session"), templates: options.templates ?? [ diff --git a/crates/agent-gui/test/subagents/validate.test.mjs b/crates/agent-gui/test/subagents/validate.test.mjs index af0b9bad7..c80bcd5b6 100644 --- a/crates/agent-gui/test/subagents/validate.test.mjs +++ b/crates/agent-gui/test/subagents/validate.test.mjs @@ -56,6 +56,48 @@ test("minimal valid agent gets mechanical defaults", () => { assert.equal(result.batch.concurrency, 1); }); +test("model overrides require a complete configured provider reference", () => { + const valid = parse({ + agents: [ + { + id: "expert-a", + prompt: "Inspect the code.", + model: { custom_provider_id: "provider-a", model: "model-a" }, + }, + ], + }); + assert.equal(valid.ok, true); + assert.deepEqual(valid.batch.agents[0].spec.selectedModel, { + customProviderId: "provider-a", + model: "model-a", + }); + + const incomplete = parse({ + agents: [{ id: "expert-a", prompt: "Inspect the code.", model: { model: "model-a" } }], + }); + assert.deepEqual(issueCodes(incomplete), ["invalid_arguments"]); + assert.match(incomplete.issues[0].message, /custom_provider_id and model\.model/); +}); + +test("model overrides reject unknown fields", () => { + const result = parse({ + agents: [ + { + id: "expert-a", + prompt: "Inspect the code.", + model: { + custom_provider_id: "provider-a", + model: "model-a", + base_url: "https://untrusted.example.test", + }, + }, + ], + }); + + assert.deepEqual(issueCodes(result), ["invalid_arguments"]); + assert.match(result.issues[0].message, /Unknown model field "base_url"/); +}); + test("unknown top-level parameter rejects the whole call", () => { const result = parse({ agents: [{ id: "a", prompt: "ok" }], diff --git a/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs b/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs index 6453f1df8..819550a04 100644 --- a/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs +++ b/crates/agent-gui/test/tools/builtin-registry-subagent-mcp.test.mjs @@ -276,7 +276,7 @@ test("subagent registries list MCP servers from live settings, not turn-start sn assert.deepEqual(harness.listedServerCommands, [["mock-mcp-server"], ["mock-mcp-server-v2"]]); }); -test("read-only children inherit MCP business tools but no write, shell, or manager tools", async () => { +test("read-only children reject unclassified MCP, write, shell, and manager tools", async () => { const harness = createRegistryHarness(); const { registry } = await buildRegistry(harness, { withSubagentRuntime: true }); @@ -290,7 +290,7 @@ test("read-only children inherit MCP business tools but no write, shell, or mana const names = harness.runnerCalls[0].tools.map((tool) => tool.name); assert.ok(names.includes("Read")); - assert.ok(names.includes("mcp_docs_search")); + assert.ok(!names.includes("mcp_docs_search")); assert.ok(names.includes("SendMessage")); assert.ok(!names.includes("Write"));