Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 74 additions & 1 deletion crates/agent-gui/src/lib/subagents/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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<string>;
Expand Down Expand Up @@ -292,6 +321,41 @@ export function createSubagentTools(params: {
if (!parsed.ok) {
return rejectBatch(parsed.issues);
}
const modelByAgentId = new Map<string, ResolvedSubagentModel>();
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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/agent-gui/src/lib/subagents/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type SubagentIssueCode =
| "unknown_agent_id"
| "unknown_template"
| "identity_conflict"
| "model_unavailable"
| "worktree_unavailable"
| "output_path_outside_workspace"
| "unknown_recipient"
Expand Down
8 changes: 7 additions & 1 deletion crates/agent-gui/src/lib/subagents/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -33,6 +38,7 @@ export {
SUBAGENT_BROADCAST_RECIPIENT,
SUBAGENT_PARENT_ID,
type SubagentIdentity,
type SubagentModelSelection,
type SubagentRunSummary,
type SubagentTemplate,
type SubagentToolRegistry,
Expand Down
6 changes: 2 additions & 4 deletions crates/agent-gui/src/lib/subagents/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolMetadataLike>;
Expand All @@ -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;
});
}

Expand Down
6 changes: 6 additions & 0 deletions crates/agent-gui/src/lib/subagents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +37,7 @@ export type SubagentSpec = {
role?: string;
identity?: string;
templateId?: string;
selectedModel?: SubagentModelSelection;
mode: SubagentMode;
applyPolicy: SubagentApplyPolicy;
allowedOutputPaths: string[];
Expand Down
49 changes: 49 additions & 0 deletions crates/agent-gui/src/lib/subagents/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type SubagentApplyPolicy,
type SubagentIdentity,
type SubagentMode,
type SubagentModelSelection,
type SubagentSpec,
type SubagentTemplate,
} from "./types";
Expand Down Expand Up @@ -48,6 +49,7 @@ const KNOWN_AGENT_KEYS = new Set([
"role",
"identity",
"template",
"model",
"mode",
"apply_policy",
"allowed_output_paths",
Expand All @@ -58,6 +60,51 @@ const KNOWN_AGENT_KEYS = new Set([
const MODES = new Set<SubagentMode>(["readonly", "worktree"]);
const APPLY_POLICIES = new Set<SubagentApplyPolicy>(["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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -343,6 +391,7 @@ export function parseSubagentBatch(
role,
identity: identityText,
templateId: template?.id ?? existingIdentity?.templateId,
selectedModel,
mode,
applyPolicy,
allowedOutputPaths,
Expand Down
1 change: 1 addition & 0 deletions crates/agent-gui/src/lib/tools/builtinRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions crates/agent-gui/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ import {
collectRetainedSubagentParentToolCallIds,
createSubagentStoreManager,
pruneSubagentRunsForConversation,
type ResolveSubagentModel,
} from "../lib/subagents";
import {
applyTerminalEventToSessions,
Expand Down Expand Up @@ -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) &&
Expand Down Expand Up @@ -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
? {
Expand Down Expand Up @@ -4508,6 +4527,7 @@ export function ChatPage(props: ChatPageProps) {
},
runtimeModel,
selectedModel,
resolveSubagentModel,
memoryExtractionModel,
onMemoryExtractionModelFailure: handleMemoryExtractionModelFailure,
memoryExtractionStatusText,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
buildRosterReminder,
createSubagentScheduler,
isSubagentCardToolCall,
type ResolveSubagentModel,
renderMessageBusSnapshot,
SUBAGENT_PARENT_ID,
type SubagentConversationStore,
Expand Down Expand Up @@ -206,6 +207,7 @@ export type RunAgentConversationTurnParams = {
customProviderId: string;
model: string;
};
resolveSubagentModel: ResolveSubagentModel;
effectiveWorkdir: string;
effectiveSkillsEnabled: boolean;
showSilentMemoryExtraction: boolean;
Expand Down Expand Up @@ -275,6 +277,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
runtime,
runtimeModel,
selectedModel,
resolveSubagentModel,
effectiveWorkdir,
effectiveSkillsEnabled,
showSilentMemoryExtraction,
Expand Down Expand Up @@ -422,6 +425,7 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP
providerId,
model,
runtime,
resolveModel: resolveSubagentModel,
sessionId,
templates: enabledSubagentTemplates(agentTemplates),
store: subagentStore,
Expand Down
Loading
Loading