Skip to content
23 changes: 21 additions & 2 deletions packages/desktop/src/main/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Comment thread
sasicodes marked this conversation as resolved.
};
const subagentSessionTools = (): ReturnType<typeof createStartCustomTools> =>
createStartCustomTools({ ...base, includeSubagents: false, customTools: subagentSessionTools });
Expand Down Expand Up @@ -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);
}
Expand Down
21 changes: 16 additions & 5 deletions packages/desktop/src/main/models.ts
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
34 changes: 22 additions & 12 deletions packages/desktop/src/main/providers/tools/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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.'
}
}
}
Expand All @@ -40,47 +51,46 @@ interface CreateSubagentToolsOptions {
cwd: () => string;
authStorage: AuthStorage;
modelRegistry: ModelRegistry;
thinkingLevel: () => EffortLevel;
settingsManager: SettingsManager;
customTools: () => ToolDefinition[];
nameAllocator: () => SubagentNameAllocator;
model: () => ModelRegistry['getAvailable'] extends () => Array<infer ModelItem> ? 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({
label: 'sub-agents',
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))
});

Expand Down
50 changes: 30 additions & 20 deletions packages/desktop/src/main/subagents/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<infer ModelItem> ? ModelItem : never;
resolveModel: (key: string) => ResolvedModel | null;
}

interface AgentJob {
task: SubagentTaskInput;
agent: SubagentActivity;
}

const maxConcurrentAgents = 4;
Expand Down Expand Up @@ -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<SubagentRunResult> => {
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<void> => {
const runAgent = async ({ task, agent }: AgentJob): Promise<void> => {
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();

Expand All @@ -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));
Expand Down Expand Up @@ -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)}`,
Expand Down
16 changes: 15 additions & 1 deletion packages/desktop/src/main/subagents/types.ts
Original file line number Diff line number Diff line change
@@ -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<infer ModelItem> ? 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 {
Expand Down
14 changes: 14 additions & 0 deletions packages/desktop/src/main/subagents/utils/catalog.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
};
18 changes: 6 additions & 12 deletions packages/desktop/src/main/subagents/utils/input.ts
Original file line number Diff line number Diff line change
@@ -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<unknown, SubagentTaskInput>;

export const maxSubagentTasks = 8;

Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/main/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading