diff --git a/packages/desktop/src/main/chat.ts b/packages/desktop/src/main/chat.ts index d7e4f504..269aaec7 100644 --- a/packages/desktop/src/main/chat.ts +++ b/packages/desktop/src/main/chat.ts @@ -64,8 +64,10 @@ import { updateSessionTitle, upsertSessionOnStart } from '@main/sessions'; +import { modelScore } from '@main/models'; import { readStartState, type StartState, updateStartState } from '@main/storage'; import { SubagentNameAllocator } from '@main/subagents/allocator'; +import type { WorkflowModelOption } from '@main/subagents/types'; import { type AgentTab, type AgentTabStatus, @@ -2000,9 +2002,10 @@ export class ChatService { authStorage: this.authStorage, nameAllocator: () => allocator, modelRegistry: this.modelRegistry, - model: () => this.pickModel() ?? null, settingsManager: this.settingsManager, - thinkingLevel: () => this.selectedThinkingLevel + model: () => this.pickModel() ?? null, + availableModels: () => this.workflowModels(), + resolveModel: (key: string) => this.findModelByKey(key) ?? this.pickModel() ?? null }; const subagentSessionTools = (): ReturnType => createStartCustomTools({ ...base, includeSubagents: false, customTools: subagentSessionTools }); @@ -2139,6 +2142,22 @@ export class ChatService { return getVisibleModels(this.modelRegistry.getAvailable()); } + private workflowModels(): WorkflowModelOption[] { + return this.getPickerModels().flatMap((model) => { + const score = modelScore(model.id); + if (!score) return []; + return [ + { + score, + name: model.name, + key: modelKey(model), + provider: model.provider, + effortLevels: getSupportedEffortLevels(model) + } + ]; + }); + } + private isThinkingLevel(level: string): level is EffortLevel { return effortLevels.includes(level as EffortLevel); } diff --git a/packages/desktop/src/main/models.ts b/packages/desktop/src/main/models.ts index 00d76cc1..df5967ca 100644 --- a/packages/desktop/src/main/models.ts +++ b/packages/desktop/src/main/models.ts @@ -1,18 +1,29 @@ import type { ProviderKey } from '@main/types'; +export interface ModelScore { + taste: number; + intelligence: number; + affordability: number; +} + export interface AllowedModel { id: string; + score: ModelScore; provider: ProviderKey; } export const models: readonly AllowedModel[] = [ - { provider: 'openai', id: 'gpt-5.5' }, - { provider: 'openai', id: 'gpt-5.4' }, - { provider: 'anthropic', id: 'claude-fable-5' }, - { provider: 'anthropic', id: 'claude-opus-4-8' }, - { provider: 'anthropic', id: 'claude-sonnet-5' } + { provider: 'openai', id: 'gpt-5.5', score: { taste: 5, intelligence: 8, affordability: 9 } }, + { provider: 'openai', id: 'gpt-5.4', score: { taste: 4, intelligence: 6, affordability: 8 } }, + { provider: 'anthropic', id: 'claude-fable-5', score: { taste: 9, intelligence: 9, affordability: 2 } }, + { provider: 'anthropic', id: 'claude-opus-4-8', score: { taste: 8, intelligence: 7, affordability: 4 } }, + { provider: 'anthropic', id: 'claude-sonnet-5', score: { taste: 7, intelligence: 5, affordability: 5 } } ] as const; +const scoresById = new Map(models.map((model) => [model.id, model.score])); + +export const modelScore = (id: string): ModelScore | undefined => scoresById.get(id); + const idsByProvider = (provider: ProviderKey) => models.filter((model) => model.provider === provider).map((model) => model.id); diff --git a/packages/desktop/src/main/providers/tools/subagents.ts b/packages/desktop/src/main/providers/tools/subagents.ts index d6e99beb..6bfc7479 100644 --- a/packages/desktop/src/main/providers/tools/subagents.ts +++ b/packages/desktop/src/main/providers/tools/subagents.ts @@ -8,8 +8,10 @@ import { import { toolResult } from '@main/providers/tools/result'; import type { SubagentNameAllocator } from '@main/subagents/allocator'; import { runSubagents } from '@main/subagents/runtime'; +import type { ResolvedModel, WorkflowModelOption } from '@main/subagents/types'; +import { workflowToolDescription } from '@main/subagents/utils/catalog'; import { maxSubagentTasks, normalizeSubagentTasks } from '@main/subagents/utils/input'; -import type { EffortLevel } from '@main/types'; +import { effortLevels } from '@main/types'; const spawnToolParameters = { properties: { @@ -20,12 +22,21 @@ const spawnToolParameters = { description: 'Focused tasks to run in parallel sub-agents.', items: { type: 'object', - required: ['prompt'], + required: ['prompt', 'model', 'effort'], additionalProperties: false, properties: { prompt: { type: 'string', description: 'Focused task for one sub-agent.' + }, + model: { + type: 'string', + description: 'Model key for this task, chosen from the models listed in the tool description.' + }, + effort: { + type: 'string', + enum: effortLevels, + description: 'Reasoning effort for this task.' } } } @@ -40,21 +51,21 @@ interface CreateSubagentToolsOptions { cwd: () => string; authStorage: AuthStorage; modelRegistry: ModelRegistry; - thinkingLevel: () => EffortLevel; settingsManager: SettingsManager; customTools: () => ToolDefinition[]; nameAllocator: () => SubagentNameAllocator; - model: () => ModelRegistry['getAvailable'] extends () => Array ? ModelItem | null : never; + availableModels: () => WorkflowModelOption[]; + resolveModel: (key: string) => ResolvedModel | null; } export const createSubagentTools = ({ cwd, - model, authStorage, customTools, + resolveModel, modelRegistry, nameAllocator, - thinkingLevel, + availableModels, settingsManager }: CreateSubagentToolsOptions) => [ defineTool({ @@ -62,25 +73,24 @@ export const createSubagentTools = ({ name: 'run_workflow', executionMode: 'sequential', parameters: spawnToolParameters, - description: 'Run a workflow of focused sub-agents in parallel.', + get description() { + return workflowToolDescription(availableModels()); + }, promptSnippet: 'Use for independent research, review, or mapping work.', prepareArguments: (args) => ({ tasks: normalizeSubagentTasks(args) }), async execute(_toolCallId, { tasks }, signal, onUpdate) { - const selectedModel = model(); - if (!selectedModel) throw new Error('No configured model is available for sub-agents.'); - if (tasks.length === 0) throw new Error('Enter at least one sub-agent task.'); + if (tasks.length === 0) throw new Error('Each workflow task needs a prompt, a model, and an effort level.'); const result = await runSubagents({ tasks, cwd: cwd(), authStorage, customTools, + resolveModel, modelRegistry, settingsManager, - model: selectedModel, ...(signal ? { signal } : {}), nameAllocator: nameAllocator(), - thinkingLevel: thinkingLevel(), onUpdate: (snapshot) => onUpdate?.(toolResult('Sub-agents are working.', snapshot)) }); diff --git a/packages/desktop/src/main/subagents/runtime.ts b/packages/desktop/src/main/subagents/runtime.ts index d6346688..22076c8b 100644 --- a/packages/desktop/src/main/subagents/runtime.ts +++ b/packages/desktop/src/main/subagents/runtime.ts @@ -9,25 +9,29 @@ import { type ToolDefinition } from '@earendil-works/pi-coding-agent'; import { countLabel } from '@main/details'; -import { agentEndError } from '@main/helpers'; +import { agentEndError, clampThinkingLevel } from '@main/helpers'; import { createStartResourceLoader } from '@main/prompt/loader'; import type { SubagentNameAllocator } from '@main/subagents/allocator'; import { subagentAccentColor, subagentAvatar } from '@main/subagents/avatar'; -import type { SubagentRunResult, SubagentRunSnapshot, SubagentTaskInput } from '@main/subagents/types'; -import type { EffortLevel, SubagentActivity } from '@main/types'; +import type { ResolvedModel, SubagentRunResult, SubagentRunSnapshot, SubagentTaskInput } from '@main/subagents/types'; +import type { SubagentActivity } from '@main/types'; interface RunSubagentsOptions { cwd: string; signal?: AbortSignal; authStorage: AuthStorage; tasks: SubagentTaskInput[]; - thinkingLevel: EffortLevel; modelRegistry: ModelRegistry; settingsManager: SettingsManager; customTools: () => ToolDefinition[]; nameAllocator: SubagentNameAllocator; onUpdate: (snapshot: SubagentRunSnapshot) => void; - model: ModelRegistry['getAvailable'] extends () => Array ? ModelItem : never; + resolveModel: (key: string) => ResolvedModel | null; +} + +interface AgentJob { + task: SubagentTaskInput; + agent: SubagentActivity; } const maxConcurrentAgents = 4; @@ -88,35 +92,41 @@ const runWithAbort = async (session: AgentSession, signal: AbortSignal | null, t export const runSubagents = async ({ cwd, - model, tasks, signal, onUpdate, authStorage, customTools, + resolveModel, modelRegistry, nameAllocator, - thinkingLevel, settingsManager }: RunSubagentsOptions): Promise => { - const agents: SubagentActivity[] = tasks.map((task, index) => { + const jobs: AgentJob[] = tasks.map((task, index) => { const name = nameAllocator.next(`${task.prompt}:${index}:${randomUUID()}`); return { - name, - id: randomUUID(), - status: 'queued', - task: task.prompt, - avatar: subagentAvatar(name), - accentColor: subagentAccentColor(name) + task, + agent: { + name, + id: randomUUID(), + status: 'queued', + task: task.prompt, + avatar: subagentAvatar(name), + accentColor: subagentAccentColor(name) + } }; }); + const agents = jobs.map((job) => job.agent); const update = () => onUpdate({ agents: agents.map((agent) => ({ ...agent })) }); update(); - const runAgent = async (agent: SubagentActivity): Promise => { + const runAgent = async ({ task, agent }: AgentJob): Promise => { let session: AgentSession | null = null; try { + const model = resolveModel(task.model); + if (!model) throw new Error('No configured model is available. Set up a provider in settings.'); + agent.status = 'running'; update(); @@ -127,11 +137,11 @@ export const runSubagents = async ({ model, authStorage, modelRegistry, - thinkingLevel, sessionManager, resourceLoader, customTools: customTools(), - settingsManager + settingsManager, + thinkingLevel: clampThinkingLevel(model, task.effort) }); session = result.session; session.setActiveToolsByName(session.getAllTools().map(({ name }) => name)); @@ -162,11 +172,11 @@ export const runSubagents = async ({ } }; - const pending = [...agents]; + const pending = [...jobs]; const worker = async () => { - for (let agent = pending.shift(); agent; agent = pending.shift()) await runAgent(agent); + for (let job = pending.shift(); job; job = pending.shift()) await runAgent(job); }; - await Promise.all(Array.from({ length: Math.min(maxConcurrentAgents, agents.length) }, worker)); + await Promise.all(Array.from({ length: Math.min(maxConcurrentAgents, jobs.length) }, worker)); return { text: `${countLabel(agents.length, 'sub-agent')} finished.\n\n${resultText(agents)}`, diff --git a/packages/desktop/src/main/subagents/types.ts b/packages/desktop/src/main/subagents/types.ts index c0065de2..30bdc2fb 100644 --- a/packages/desktop/src/main/subagents/types.ts +++ b/packages/desktop/src/main/subagents/types.ts @@ -1,7 +1,21 @@ -import type { SubagentActivity } from '@main/types'; +import type { ModelRegistry } from '@earendil-works/pi-coding-agent'; +import type { ModelScore } from '@main/models'; +import type { EffortLevel, SubagentActivity } from '@main/types'; + +export type ResolvedModel = ModelRegistry['getAvailable'] extends () => Array ? ModelItem : never; export interface SubagentTaskInput { + model: string; prompt: string; + effort: EffortLevel; +} + +export interface WorkflowModelOption { + key: string; + name: string; + provider: string; + score: ModelScore; + effortLevels: EffortLevel[]; } export interface SubagentRunSnapshot { diff --git a/packages/desktop/src/main/subagents/utils/catalog.ts b/packages/desktop/src/main/subagents/utils/catalog.ts new file mode 100644 index 00000000..cc336be7 --- /dev/null +++ b/packages/desktop/src/main/subagents/utils/catalog.ts @@ -0,0 +1,14 @@ +import type { WorkflowModelOption } from '@main/subagents/types'; + +const scoreLine = (option: WorkflowModelOption) => + `- ${option.key} (${option.name}): affordability ${option.score.affordability}, intelligence ${option.score.intelligence}, taste ${option.score.taste}; effort ${option.effortLevels.join('/')}`; + +export const workflowModelMenu = (options: WorkflowModelOption[]) => options.map(scoreLine).join('\n'); + +export const workflowToolDescription = (options: WorkflowModelOption[]) => { + if (options.length === 0) { + return 'Run a workflow of focused sub-agents in parallel. No models are configured; set up a provider before spawning sub-agents.'; + } + + return `Run a workflow of focused sub-agents in parallel. For each task choose one of the model keys listed below exactly (only these models are configured) and an effort, using the scores (0-10, higher is better; affordability higher means cheaper). Do not default to the highest scores, that wastes tokens. Pick the cheapest model and lowest effort that can still do the task well, and only move up when the task truly needs it: reading, lookups, and mechanical edits belong on a cheap model at low effort; design, UX, and architecture want higher taste; genuinely hard reasoning wants higher intelligence at high or xhigh effort. Match each task to the minimum capability it needs.\n\nModels:\n${workflowModelMenu(options)}`; +}; diff --git a/packages/desktop/src/main/subagents/utils/input.ts b/packages/desktop/src/main/subagents/utils/input.ts index 9535f273..70ae9f28 100644 --- a/packages/desktop/src/main/subagents/utils/input.ts +++ b/packages/desktop/src/main/subagents/utils/input.ts @@ -1,19 +1,13 @@ import { isRecord } from '@main/details'; import type { SubagentTaskInput } from '@main/subagents/types'; +import { effortLevels } from '@main/types'; import * as v from 'valibot'; -const taskSchema = v.pipe( - v.union([ - v.string(), - v.pipe( - v.object({ prompt: v.string() }), - v.transform((task) => task.prompt) - ) - ]), - v.trim(), - v.minLength(1), - v.transform((prompt) => ({ prompt }) satisfies SubagentTaskInput) -); +const taskSchema = v.object({ + effort: v.picklist(effortLevels), + model: v.pipe(v.string(), v.trim(), v.minLength(1)), + prompt: v.pipe(v.string(), v.trim(), v.minLength(1)) +}) satisfies v.GenericSchema; export const maxSubagentTasks = 8; diff --git a/packages/desktop/src/main/types.ts b/packages/desktop/src/main/types.ts index 99860f15..df4ba4e4 100644 --- a/packages/desktop/src/main/types.ts +++ b/packages/desktop/src/main/types.ts @@ -2,7 +2,7 @@ export type EffortLevel = 'high' | 'low' | 'xhigh' | 'medium'; export type ThinkingLevel = 'off' | 'minimal' | EffortLevel; export type ProviderKey = 'openai' | 'anthropic'; -export const effortLevels: EffortLevel[] = ['low', 'medium', 'high', 'xhigh']; +export const effortLevels = ['low', 'medium', 'high', 'xhigh'] as const satisfies readonly EffortLevel[]; export type ChatStatus = { ready: boolean; diff --git a/packages/desktop/tests/units/subagents-runtime.test.ts b/packages/desktop/tests/units/subagents-runtime.test.ts index 64ec1428..cefb172f 100644 --- a/packages/desktop/tests/units/subagents-runtime.test.ts +++ b/packages/desktop/tests/units/subagents-runtime.test.ts @@ -30,14 +30,13 @@ describe('sub-agent runtime', () => { it('keeps full sub-agent tools enabled', async () => { const snapshots: SubagentRunSnapshot[] = []; const run = runSubagents({ - model, authStorage, modelRegistry, settingsManager, + resolveModel: () => model, cwd: '/workspace/project', - tasks: [{ prompt: 'Inspect the project.' }], + tasks: [{ prompt: 'Inspect the project.', model: 'anthropic:claude-opus-4-7', effort: 'medium' }], nameAllocator: new SubagentNameAllocator(), - thinkingLevel: 'medium', customTools: () => [tool('browser_open'), tool('browser_snapshot')], onUpdate: (snapshot) => snapshots.push(snapshot) }); @@ -59,16 +58,19 @@ describe('sub-agent runtime', () => { it('runs at most four sub-agents at once', async () => { const run = runSubagents({ - model, authStorage, modelRegistry, settingsManager, onUpdate: () => {}, customTools: () => [], - thinkingLevel: 'medium', + resolveModel: () => model, cwd: '/workspace/project', nameAllocator: new SubagentNameAllocator(), - tasks: Array.from({ length: 6 }, (_, index) => ({ prompt: `Task ${index}.` })) + tasks: Array.from({ length: 6 }, (_, index) => ({ + prompt: `Task ${index}.`, + model: 'anthropic:claude-opus-4-7', + effort: 'medium' as const + })) }); await vi.waitFor(() => expect(listFakeSessions()).toHaveLength(4)); diff --git a/packages/desktop/tests/units/tools/subagents.test.ts b/packages/desktop/tests/units/tools/subagents.test.ts index c0200244..f0b8ebd1 100644 --- a/packages/desktop/tests/units/tools/subagents.test.ts +++ b/packages/desktop/tests/units/tools/subagents.test.ts @@ -22,7 +22,12 @@ describe('sub-agent tool details', () => { agents: [{ id: 'agent-1', name: 'Arul', status: 'unknown' }] } }, - args: { tasks: [{ prompt: 'Review UI.' }, { prompt: 'Review tests.' }] }, + args: { + tasks: [ + { prompt: 'Review UI.', model: 'anthropic:claude-opus-4-7', effort: 'low' }, + { prompt: 'Review tests.', model: 'anthropic:claude-opus-4-7', effort: 'low' } + ] + }, key: 'tool:subagents', toolName: 'run_workflow' }); @@ -37,7 +42,7 @@ describe('sub-agent tool details', () => { state: 'active', toolName: 'run_workflow', key: 'tool:subagents', - args: { tasks: [{ prompt: 'Review renderer activity UI.' }] }, + args: { tasks: [{ prompt: 'Review renderer activity UI.', model: 'anthropic:claude-opus-4-7', effort: 'high' }] }, result: { content: [{ text: 'Sub-agents are working.', type: 'text' }], details: { @@ -62,41 +67,47 @@ describe('sub-agent tool details', () => { expect(detail.subagents?.[0]?.task).toBe('Review renderer activity UI.'); }); - it('normalizes Claude single-task sub-agent arguments', () => { - expect(normalizeSubagentTasks({ prompt: 'Review renderer activity UI.' })).toEqual([ - { prompt: 'Review renderer activity UI.' } - ]); - expect(normalizeSubagentTasks({ tasks: { prompt: 'Review main process.' } })).toEqual([ - { prompt: 'Review main process.' } - ]); - expect(normalizeSubagentTasks({ tasks: [{ prompt: 'Review UI.' }, { prompt: 'Review tests.' }] })).toEqual([ - { prompt: 'Review UI.' }, - { prompt: 'Review tests.' } - ]); - expect(normalizeSubagentTasks(['Review UI.', 'Review tests.'])).toEqual([ - { prompt: 'Review UI.' }, - { prompt: 'Review tests.' } + it('normalizes single-task workflow arguments and requires model and effort', () => { + const single = { prompt: 'Review renderer activity UI.', model: 'anthropic:claude-opus-4-7', effort: 'low' }; + expect(normalizeSubagentTasks(single)).toEqual([single]); + expect(normalizeSubagentTasks({ tasks: single })).toEqual([single]); + expect( + normalizeSubagentTasks({ + tasks: [ + { prompt: 'Review UI.', model: 'openai:gpt-5.5', effort: 'high' }, + { prompt: 'Review tests.', model: 'anthropic:claude-sonnet-5', effort: 'low' } + ] + }) + ).toEqual([ + { prompt: 'Review UI.', model: 'openai:gpt-5.5', effort: 'high' }, + { prompt: 'Review tests.', model: 'anthropic:claude-sonnet-5', effort: 'low' } ]); - expect(normalizeSubagentTasks({ tasks: [{ prompt: ' ' }, { name: 'no prompt' }] })).toEqual([]); + expect(normalizeSubagentTasks(['Review UI.', 'Review tests.'])).toEqual([]); + expect(normalizeSubagentTasks({ tasks: [{ prompt: 'No model or effort.' }] })).toEqual([]); + expect(normalizeSubagentTasks({ tasks: [{ prompt: ' ', model: 'openai:gpt-5.5', effort: 'low' }] })).toEqual([]); expect(normalizeSubagentTasks({})).toEqual([]); const detail = toolEventDetail({ state: 'active', toolName: 'run_workflow', key: 'tool:subagents', - args: { prompt: 'Review renderer activity UI.' } + args: single }); expect(detail.title).toBe('Spawning 1 agent'); }); it('caps normalized tasks at the spawn limit', () => { - const tasks = Array.from({ length: 12 }, (_, index) => ({ prompt: `Task ${index}` })); + const tasks = Array.from({ length: 12 }, (_, index) => ({ + prompt: `Task ${index}`, + model: 'anthropic:claude-opus-4-7', + effort: 'low' + })); const normalized = normalizeSubagentTasks({ tasks }); expect(maxSubagentTasks).toBe(8); expect(normalized).toHaveLength(maxSubagentTasks); - expect(normalized.at(-1)).toEqual({ prompt: 'Task 7' }); + expect(normalized.at(-1)).toEqual({ prompt: 'Task 7', model: 'anthropic:claude-opus-4-7', effort: 'low' }); }); it('expands completed summaries', () => { diff --git a/packages/desktop/tests/units/tools/workflow-catalog.test.ts b/packages/desktop/tests/units/tools/workflow-catalog.test.ts new file mode 100644 index 00000000..2796e3c1 --- /dev/null +++ b/packages/desktop/tests/units/tools/workflow-catalog.test.ts @@ -0,0 +1,45 @@ +import type { WorkflowModelOption } from '@main/subagents/types'; +import { workflowModelMenu, workflowToolDescription } from '@main/subagents/utils/catalog'; +import { describe, expect, it } from 'vitest'; + +const option = (overrides: Partial): WorkflowModelOption => ({ + key: 'anthropic:claude-opus-4-8', + name: 'Claude Opus 4 8', + provider: 'anthropic', + effortLevels: ['low', 'medium', 'high'], + score: { affordability: 4, intelligence: 7, taste: 8 }, + ...overrides +}); + +describe('workflow catalog', () => { + it('renders one scored line per available model', () => { + const menu = workflowModelMenu([ + option({ key: 'openai:gpt-5.5', name: 'GPT 5.5', score: { affordability: 9, intelligence: 8, taste: 5 } }), + option({}) + ]); + + expect(menu).toBe( + [ + '- openai:gpt-5.5 (GPT 5.5): affordability 9, intelligence 8, taste 5; effort low/medium/high', + '- anthropic:claude-opus-4-8 (Claude Opus 4 8): affordability 4, intelligence 7, taste 8; effort low/medium/high' + ].join('\n') + ); + }); + + it('explains scoring, tells the agent to conserve tokens, and lists models when some are available', () => { + const description = workflowToolDescription([option({})]); + + expect(description).toContain('higher is better'); + expect(description).toContain('affordability higher means cheaper'); + expect(description).toContain('Do not default to the highest scores'); + expect(description).toContain('minimum capability it needs'); + expect(description).toContain('anthropic:claude-opus-4-8'); + }); + + it('tells the agent to set up a provider when nothing is available', () => { + const description = workflowToolDescription([]); + + expect(description).toContain('No models are configured'); + expect(description).not.toContain('affordability higher means cheaper'); + }); +});