Skip to content
41 changes: 35 additions & 6 deletions src/lib/agent/__tests__/agent-prompt-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,30 @@ Add at least one capture call.
});
});

it('drops an effort that is not a ThinkingLevel — remote typos never reach a session', () => {
const p = parseAgentPrompt(
'---\nmodel_pi: m\neffort_pi: mediun\neffort_sdk: high\n---\nx',
'capture',
);
expect(p.effortPi).toBeUndefined();
expect(p.effortSdk).toBe('high');
});

it('falls back to the menu entry flow when frontmatter omits it', () => {
const p = parseAgentPrompt(
'---\ntype: install\n---\nx',
'install',
'my-flow',
);
expect(p.flow).toBe('my-flow');
const declared = parseAgentPrompt(
'---\nflow: audit\n---\nx',
'install',
'my-flow',
);
expect(declared.flow).toBe('audit');
});

it('strips inline comments and keeps the body', () => {
const p = parseAgentPrompt(sample, 'fallback');
expect(p.modelPi).not.toContain('#');
Expand All @@ -93,9 +117,10 @@ Add at least one capture call.
);
});

it('defaults missing array fields to empty and model to undefined', () => {
it('defaults missing array fields to empty and models to undefined', () => {
const p = parseAgentPrompt('no frontmatter at all', 'stub');
expect(p.model).toBeUndefined();
expect(p.modelPi).toBeUndefined();
expect(p.modelSdk).toBeUndefined();
expect(p.skills).toEqual([]);
expect(p.dependsOn).toEqual([]);
expect(p.body).toBe('no frontmatter at all');
Expand Down Expand Up @@ -296,7 +321,7 @@ describe('taskModelSpec', () => {
'capture',
);

it('prefers the enqueue override, then the prompt, then the default', () => {
it('prefers the enqueue override, then the prompt; the switchboard pick is the caller fallback', () => {
const registry = registryOf([prompt]);
const task = { type: 'capture' };
expect(
Expand All @@ -306,9 +331,13 @@ describe('taskModelSpec', () => {
expect(taskModelSpec(registry, task as never, 'pi').model).toBe(
'prompt-model',
);
expect(taskModelSpec(registryOf([]), task as never, 'pi').model).toBe(
'claude-sonnet-4-6',
);
// An empty column stays undefined — the caller falls back to its switchboard pick.
expect(
taskModelSpec(registry, task as never, 'anthropic').model,
).toBeUndefined();
expect(
taskModelSpec(registryOf([]), task as never, 'pi').model,
).toBeUndefined();
});
});

Expand Down
49 changes: 33 additions & 16 deletions src/lib/agent/agent-prompt-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import type {
} from './runner/sequence/orchestrator/queue';
import type { ResolvedTask } from './runner/sequence/orchestrator/executor';
import type { HostResolution } from '@lib/host-resolution';
import { DEFAULT_AGENT_MODEL } from '@lib/constants';
import {
isThinkingLevel,
type ThinkingLevel,
} from './runner/switchboard/models';
import { logToFile } from '@utils/debug';
import { analytics } from '@utils/analytics';

/**
* The basics the client injects around every agent-prompt body. The `/agents/`
Expand Down Expand Up @@ -101,9 +106,6 @@ export function assembleSeedPrompt(
return [projectContext(ctx), SEED_BASICS, body].join('\n\n');
}

/** Used when neither the enqueue call nor the prompt frontmatter names a model. */
const DEFAULT_TASK_MODEL = DEFAULT_AGENT_MODEL;

/** Orchestrator tools are MCP tools under the `posthog-wizard` server. Frontmatter
* names them short (e.g. `enqueue_task`); the SDK gates on the full name. */
const ORCHESTRATOR_TOOL_PREFIX = 'mcp__posthog-wizard__';
Expand All @@ -125,9 +127,9 @@ export interface AgentPrompt {
/** Per-profile model + effort. `pi` = the gpt/pi harness, `sdk` = the anthropic
* harness. The mapping is not 1:1 across providers, so each agent names both. */
modelPi?: string;
effortPi?: string;
effortPi?: ThinkingLevel;
modelSdk?: string;
effortSdk?: string;
effortSdk?: ThinkingLevel;
skills: string[];
allowedTools: string[];
disallowedTools: string[];
Expand All @@ -140,7 +142,7 @@ export interface AgentPrompt {
export function promptModelFor(
prompt: AgentPrompt,
harness: string,
): { model?: string; effort?: string } {
): { model?: string; effort?: ThinkingLevel } {
const pi = harness === 'pi';
return {
model: pi ? prompt.modelPi : prompt.modelSdk,
Expand Down Expand Up @@ -209,12 +211,13 @@ function toStringArray(value: unknown): string[] {
* Parse the leading `---` frontmatter block and the markdown body. The
* frontmatter is a small, known schema (scalars and inline `[a, b]` arrays), so
* a tiny parser covers it without a YAML dependency. Inline `# comments` after a
* value are stripped. `fallbackType` is the menu id, used when the body omits
* `type:`.
* value are stripped. `fallbackType` (the menu id) and `fallbackFlow` (the
* menu entry's flow) apply when the frontmatter omits `type:`/`flow:`.
*/
export function parseAgentPrompt(
text: string,
fallbackType: string,
fallbackFlow?: string,
): AgentPrompt {
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
const frontmatter = match ? match[1] : '';
Expand All @@ -239,15 +242,29 @@ export function parseAgentPrompt(
}

const str = (v: unknown) => (typeof v === 'string' ? v : undefined);
// Effort is remote data — reject typos here so downstream carries ThinkingLevel.
const effort = (v: unknown, key: string): ThinkingLevel | undefined => {
if (v === undefined) return undefined;
if (isThinkingLevel(v)) return v;
logToFile(
`[agent-prompt] ${fallbackType}: ignoring invalid ${key} "${String(v)}"`,
);
analytics.wizardCapture('agent prompt invalid effort', {
task_type: fallbackType,
key,
value: String(v),
});
return undefined;
};
return {
type: typeof fields.type === 'string' ? fields.type : fallbackType,
label: typeof fields.label === 'string' ? fields.label : undefined,
flow: typeof fields.flow === 'string' ? fields.flow : undefined,
flow: typeof fields.flow === 'string' ? fields.flow : fallbackFlow,
seed: fields.seed === 'true',
modelPi: str(fields.model_pi),
effortPi: str(fields.effort_pi),
effortPi: effort(fields.effort_pi, 'effort_pi'),
modelSdk: str(fields.model_sdk),
effortSdk: str(fields.effort_sdk),
effortSdk: effort(fields.effort_sdk, 'effort_sdk'),
skills: toStringArray(fields.skills),
allowedTools: toStringArray(fields.allowedTools),
disallowedTools: toStringArray(fields.disallowedTools),
Expand Down Expand Up @@ -286,7 +303,7 @@ export async function loadAgentRegistry(
const prompts = await Promise.all(
entries.map(async (entry) => {
const text = await fetchText(entry.downloadUrl);
return parseAgentPrompt(text, entry.id);
return parseAgentPrompt(text, entry.id, entry.flow);
}),
);

Expand Down Expand Up @@ -386,18 +403,18 @@ export function resolveTask(
}

/** The model + effort a task runs on for a harness: enqueue override, then the
* prompt's per-profile frontmatter, then the default model. */
* prompt's per-profile frontmatter; the caller's switchboard pick is the fallback. */
export function taskModelSpec(
registry: AgentRegistry,
task: QueuedTask,
harness: string,
): { model: string; effort?: string } {
): { model?: string; effort?: ThinkingLevel } {
const picked = promptModelFor(
registry.get(task.type) ?? EMPTY_PROMPT,
harness,
);
return {
model: task.model ?? picked.model ?? DEFAULT_TASK_MODEL,
model: task.model ?? picked.model,
effort: picked.effort,
};
}
Expand Down
9 changes: 5 additions & 4 deletions src/lib/agent/runner/harness/pi/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ export interface GatewayProviderInputs {
// Linear runs honour the wizard-pi-effort flag; orchestrator tasks pass false
// so each per-agent model keeps its own tuned effort from the table.
applyEffortFlag?: boolean;
// Explicit per-agent effort from the prompt frontmatter — overrides the table
// default for a reasoning model when set.
effort?: string;
// Explicit per-agent effort from the prompt frontmatter — validated to a
// ThinkingLevel at the parse boundary; overrides the table default for a
// reasoning model when set.
effort?: ThinkingLevel;
}

/**
Expand Down Expand Up @@ -103,7 +104,7 @@ export function buildGatewayProvider(inputs: GatewayProviderInputs): {
// An explicit frontmatter effort wins over the table for a reasoning model.
const caps =
effort && tableCaps.reasoning
? { ...tableCaps, thinkingLevel: effort as ThinkingLevel }
? { ...tableCaps, thinkingLevel: effort }
: tableCaps;
const baseUrl =
api === 'openai-completions' ? `${gatewayUrl}/v1` : gatewayUrl;
Expand Down
3 changes: 2 additions & 1 deletion src/lib/agent/runner/harness/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type { SpinnerHandle } from '@ui';
import type { WizardAskBridge } from '@lib/wizard-ask-bridge';
import type { AgentErrorType } from '@lib/agent/agent-interface';
import type { OrchestratorToolsContext } from '@lib/agent/runner/sequence/orchestrator/queue-tools';
import type { ThinkingLevel } from '@lib/agent/runner/switchboard/models';
import type {
ProgramRun,
BootstrapResult,
Expand Down Expand Up @@ -79,7 +80,7 @@ export interface TaskRunInputs {
model: string;
/** Reasoning effort from the agent prompt's per-profile frontmatter; overrides
* the model's table default when set. */
effort?: string;
effort?: ThinkingLevel;
/** Per-task tool overrides from the agent prompt's frontmatter. */
allowedTools?: readonly string[];
disallowedTools?: readonly string[];
Expand Down
Loading
Loading