diff --git a/src/lib/agent/__tests__/agent-prompt-loader.test.ts b/src/lib/agent/__tests__/agent-prompt-loader.test.ts index 8b7d5bb05..88eb4cde0 100644 --- a/src/lib/agent/__tests__/agent-prompt-loader.test.ts +++ b/src/lib/agent/__tests__/agent-prompt-loader.test.ts @@ -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('#'); @@ -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'); @@ -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( @@ -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(); }); }); diff --git a/src/lib/agent/agent-prompt-loader.ts b/src/lib/agent/agent-prompt-loader.ts index 65123deab..239880ae2 100644 --- a/src/lib/agent/agent-prompt-loader.ts +++ b/src/lib/agent/agent-prompt-loader.ts @@ -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/` @@ -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__'; @@ -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[]; @@ -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, @@ -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] : ''; @@ -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), @@ -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); }), ); @@ -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, }; } diff --git a/src/lib/agent/runner/harness/pi/gateway.ts b/src/lib/agent/runner/harness/pi/gateway.ts index 4328d83f7..5a90e5334 100644 --- a/src/lib/agent/runner/harness/pi/gateway.ts +++ b/src/lib/agent/runner/harness/pi/gateway.ts @@ -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; } /** @@ -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; diff --git a/src/lib/agent/runner/harness/types.ts b/src/lib/agent/runner/harness/types.ts index 2687a57e6..5efaa7733 100644 --- a/src/lib/agent/runner/harness/types.ts +++ b/src/lib/agent/runner/harness/types.ts @@ -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, @@ -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[]; diff --git a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts index 0f2106f1a..2aee5044f 100644 --- a/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts +++ b/src/lib/agent/runner/sequence/orchestrator/__tests__/variant-resolution.test.ts @@ -1,68 +1,146 @@ import { describe, it, expect } from 'vitest'; import { resolveSkillVariantId } from '../orchestrator-runner'; import { Integration } from '@lib/constants'; +import type { SkillEntry } from '@lib/wizard-tools'; -// A representative slice of the real install-step menu ids, including the -// families whose variant id differs from the framework enum. -const MENU = [ - 'posthog-integration-install-django', - 'posthog-integration-install-laravel', - 'posthog-integration-install-nextjs-app-router', - 'posthog-integration-install-nextjs-pages-router', - 'posthog-integration-install-nuxt-3-6', - 'posthog-integration-install-vue-3', - 'posthog-integration-install-astro-hybrid', - 'posthog-integration-install-ruby-on-rails', - 'posthog-integration-install-react-react-router-6', - 'posthog-integration-install-react-react-router-7-framework', - 'posthog-integration-install-react-tanstack-router-code-based', - 'posthog-integration-install-swift', - 'posthog-integration-install-javascript_web', +// Pinned from the real built skill-menu.json, so this suite tests the actual cross-repo contract. +const INTEGRATION_ENTRIES = [ + { id: 'integration-nextjs-app-router', framework: 'nextjs', default: true }, + { id: 'integration-nextjs-pages-router', framework: 'nextjs' }, + { + id: 'integration-react-react-router-6', + framework: 'react-router', + default: true, + }, + { + id: 'integration-react-react-router-7-framework', + framework: 'react-router', + }, + { id: 'integration-react-react-router-7-data', framework: 'react-router' }, + { + id: 'integration-react-react-router-7-declarative', + framework: 'react-router', + }, + { id: 'integration-react-vite' }, + { id: 'integration-nuxt-3-6', framework: 'nuxt', default: true }, + { id: 'integration-nuxt-4', framework: 'nuxt' }, + { id: 'integration-vue-3', framework: 'vue' }, + { id: 'integration-django', framework: 'django' }, + { id: 'integration-flask', framework: 'flask' }, + { id: 'integration-fastapi', framework: 'fastapi' }, + { + id: 'integration-react-tanstack-router-file-based', + framework: 'tanstack-router', + }, + { + id: 'integration-react-tanstack-router-code-based', + framework: 'tanstack-router', + default: true, + }, + { id: 'integration-tanstack-start', framework: 'tanstack-start' }, + { id: 'integration-laravel', framework: 'laravel' }, + { id: 'integration-php' }, + { id: 'integration-ruby-on-rails', framework: 'rails' }, + { id: 'integration-android', framework: 'android' }, + { id: 'integration-sveltekit', framework: 'sveltekit' }, + { id: 'integration-python', framework: 'python' }, + { id: 'integration-javascript_node', framework: 'javascript_node' }, + { id: 'integration-javascript_web', framework: 'javascript_web' }, + { id: 'integration-ruby', framework: 'ruby' }, + { id: 'integration-elixir' }, + { id: 'integration-go' }, + { id: 'integration-swift', framework: 'swift' }, + { id: 'integration-flutter' }, + { id: 'integration-react-native', framework: 'react-native', default: true }, + { id: 'integration-expo', framework: 'react-native' }, + { id: 'integration-astro-static', framework: 'astro' }, + { id: 'integration-astro-view-transitions', framework: 'astro' }, + { id: 'integration-astro-ssr', framework: 'astro' }, + { id: 'integration-astro-hybrid', framework: 'astro', default: true }, + { id: 'integration-angular', framework: 'angular' }, +].map( + (e): SkillEntry => ({ + ...e, + group: 'integration', + name: e.id, + downloadUrl: `https://example.test/${e.id}.zip`, + }), +); + +// A single-variant skill collapses to the bare group id in the menu. +const MENU: SkillEntry[] = [ + ...INTEGRATION_ENTRIES, + { + id: 'posthog-integration-build', + group: 'posthog-integration-build', + name: 'build', + downloadUrl: 'https://example.test/posthog-integration-build.zip', + }, ]; -const SKILL = 'posthog-integration-install'; +describe('resolveSkillVariantId — menu-declared framework resolution', () => { + it('resolves a bare single-variant skill id to itself', () => { + expect( + resolveSkillVariantId(MENU, 'posthog-integration-build', 'django'), + ).toBe('posthog-integration-build'); + }); + + it('resolves a full menu id to itself, regardless of framework', () => { + expect( + resolveSkillVariantId(MENU, 'integration-nextjs-pages-router', 'nextjs'), + ).toBe('integration-nextjs-pages-router'); + }); -describe('resolveSkillVariantId — framework/variant parity', () => { - it('resolves the enums whose variant id differs from the enum value', () => { - expect(resolveSkillVariantId(MENU, SKILL, 'rails')).toBe( - 'posthog-integration-install-ruby-on-rails', + it('resolves the frameworks whose variant id differs from the detection id', () => { + expect(resolveSkillVariantId(MENU, 'integration', 'rails')).toBe( + 'integration-ruby-on-rails', ); - expect(resolveSkillVariantId(MENU, SKILL, 'react-router')).toBe( - 'posthog-integration-install-react-react-router-6', + expect(resolveSkillVariantId(MENU, 'integration', 'react-router')).toBe( + 'integration-react-react-router-6', ); - expect(resolveSkillVariantId(MENU, SKILL, 'tanstack-router')).toBe( - 'posthog-integration-install-react-tanstack-router-code-based', + expect(resolveSkillVariantId(MENU, 'integration', 'tanstack-router')).toBe( + 'integration-react-tanstack-router-code-based', ); }); - it('still resolves the frameworks that match by id or prefix', () => { - expect(resolveSkillVariantId(MENU, SKILL, 'django')).toBe( - 'posthog-integration-install-django', + it('picks the marked default when a family has several variants', () => { + expect(resolveSkillVariantId(MENU, 'integration', 'nextjs')).toBe( + 'integration-nextjs-app-router', + ); + expect(resolveSkillVariantId(MENU, 'integration', 'astro')).toBe( + 'integration-astro-hybrid', ); - expect(resolveSkillVariantId(MENU, SKILL, 'nextjs')).toBe( - 'posthog-integration-install-nextjs-app-router', + expect(resolveSkillVariantId(MENU, 'integration', 'react-native')).toBe( + 'integration-react-native', ); - expect(resolveSkillVariantId(MENU, SKILL, 'vue')).toBe( - 'posthog-integration-install-vue-3', + }); + + it('resolves a single-entry family without needing a default marker', () => { + expect(resolveSkillVariantId(MENU, 'integration', 'vue')).toBe( + 'integration-vue-3', + ); + expect(resolveSkillVariantId(MENU, 'integration', 'django')).toBe( + 'integration-django', + ); + }); + + it('returns undefined without a framework or without a matching entry', () => { + expect( + resolveSkillVariantId(MENU, 'integration', undefined), + ).toBeUndefined(); + expect(resolveSkillVariantId(MENU, 'integration', 'cobol')).toBeUndefined(); + // A variant with no framework field (react-vite) is only reachable by id. + expect(resolveSkillVariantId(MENU, 'integration-react-vite', 'vue')).toBe( + 'integration-react-vite', ); }); - it('every framework in a full menu resolves — no silent zero-diff', () => { - // A menu with one install variant per Integration enum (aliased where the - // id differs), so the whole enum must resolve. - const alias: Record = { - 'react-router': 'react-react-router-7-framework', - 'tanstack-router': 'react-tanstack-router-code-based', - rails: 'ruby-on-rails', - nextjs: 'nextjs-app-router', - nuxt: 'nuxt-3-6', - vue: 'vue-3', - astro: 'astro-hybrid', - }; - const enums = Object.values(Integration); - const menu = enums.map((e) => `${SKILL}-${alias[e] ?? e}`); - for (const e of enums) { - expect(resolveSkillVariantId(menu, SKILL, e)).toBeDefined(); + it('every framework in the Integration enum resolves — no silent zero-diff', () => { + for (const framework of Object.values(Integration)) { + expect( + resolveSkillVariantId(MENU, 'integration', framework), + `framework "${framework}" resolved nothing`, + ).toBeDefined(); } }); }); diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index 81a58b3e2..61b737113 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -13,8 +13,13 @@ import { randomUUID } from 'crypto'; import { existsSync, rmSync } from 'fs'; import * as path from 'path'; +import { IS_PRODUCTION_BUILD } from '@env'; import { OutroKind, type WizardSession } from '@lib/wizard-session'; -import { installSkillById, fetchSkillMenu } from '@lib/wizard-tools'; +import { + installSkillById, + fetchSkillMenu, + type SkillEntry, +} from '@lib/wizard-tools'; import { getUI } from '@ui'; import { analytics } from '@utils/analytics'; import { ciExcludedTaskTypes } from '@utils/ci-flag-overrides'; @@ -79,47 +84,27 @@ function requireTaskHarness(pick: HarnessPick): AgentHarness & { }; } -/** Every skill id the menu knows, across categories. */ -async function fetchSkillMenuIds(skillsBaseUrl: string): Promise { +/** Every skill entry the menu knows, across categories. */ +async function fetchSkillMenuEntries( + skillsBaseUrl: string, +): Promise { const menu = await fetchSkillMenu(skillsBaseUrl); if (!menu) return []; - return Object.values(menu.categories) - .flat() - .map((s) => s.id); + return Object.values(menu.categories).flat(); } -/** - * Resolve a bare skill id + the session's framework to the menu id: the bare - * id itself (single-variant skills collapse to it), else exact - * `-` (the 1:1 frameworks — django, python, flask, …), else the - * first granular variant under the framework (e.g. `-nextjs-app-router`). - * Undefined when nothing matches. - */ -/** - * Framework enums whose context-mill variant id differs from the enum value. - * The orchestrator resolves variants programmatically (the linear flow's agent - * picks from the menu by hand and self-corrects), so without these it silently - * resolves nothing and the tasks run skill-less — a zero-diff run. The value is - * the variant-id token (or its prefix, for a family the `startsWith` fallback - * then narrows). - */ -const FRAMEWORK_VARIANT_ALIASES: Record = { - rails: 'ruby-on-rails', - 'react-router': 'react-react-router', - 'tanstack-router': 'react-tanstack-router', -}; - +/** Menu id for a bare skill id + framework via the menu's declared group/framework/default fields; undefined when nothing matches. */ export function resolveSkillVariantId( - menuIds: readonly string[], + entries: readonly SkillEntry[], skillId: string, framework: string | undefined, ): string | undefined { - if (menuIds.includes(skillId)) return skillId; + if (entries.some((e) => e.id === skillId)) return skillId; if (!framework) return undefined; - const variant = FRAMEWORK_VARIANT_ALIASES[framework] ?? framework; - const exact = `${skillId}-${variant}`; - if (menuIds.includes(exact)) return exact; - return menuIds.find((id) => id.startsWith(`${exact}-`)); + const family = entries.filter( + (e) => e.group === skillId && e.framework === framework, + ); + return (family.find((e) => e.default) ?? family[0])?.id; } /** @@ -128,10 +113,10 @@ export function resolveSkillVariantId( * `integration-`. */ function resolveReferenceSkillId( - menuIds: readonly string[], + entries: readonly SkillEntry[], framework: string, ): string | undefined { - return resolveSkillVariantId(menuIds, 'integration', framework); + return resolveSkillVariantId(entries, 'integration', framework); } export async function runOrchestrator( @@ -178,13 +163,10 @@ export async function runOrchestrator( const store = new QueueStore(session.installDir, runId, { onTransition: (event, task) => { + const pick = resolveHarness(switchboardCtx, task.type); const base = { type: task.type, - model: taskModelSpec( - registry, - task, - resolveHarness(switchboardCtx, task.type).harness, - ).model, + model: taskModelSpec(registry, task, pick.harness).model ?? pick.model, attempts: task.attempts, }; switch (event) { @@ -235,9 +217,9 @@ export async function runOrchestrator( // skill — only the example file is read, when the agent's prompt points at it. let examplePath: string | undefined; let commandmentsPath: string | undefined; - const menuSkillIds = await fetchSkillMenuIds(boot.skillsBaseUrl); + const menuSkillEntries = await fetchSkillMenuEntries(boot.skillsBaseUrl); const referenceSkillId = session.skillId - ? resolveReferenceSkillId(menuSkillIds, session.skillId) + ? resolveReferenceSkillId(menuSkillEntries, session.skillId) : undefined; if (referenceSkillId) { const ref = await installSkillById( @@ -266,6 +248,41 @@ export async function runOrchestrator( ); } + // Preflight every task's mini-skills. A missing variant means the task runs + // skill-less — a silent zero-diff — so log + capture it on every build. In + // dev and CI the run then crashes so the gap can't slip through a test pass; + // the throw sits behind !IS_PRODUCTION_BUILD, which tsdown inlines to a + // literal, so it is stripped from the published bundle (real users get the + // degraded run, never a crash). + const missingVariants: string[] = []; + for (const type of registry.types) { + for (const skillId of registry.get(type)?.skills ?? []) { + if (resolveSkillVariantId(menuSkillEntries, skillId, session.skillId)) { + continue; + } + missingVariants.push(`${type}/${skillId}`); + logToFile( + `[orchestrator] no skill variant type=${type} skill=${skillId} framework=${ + session.skillId ?? 'none' + }`, + ); + analytics.wizardCapture('orchestrator skill variant missing', { + task_type: type, + skill: skillId, + framework: session.skillId, + }); + } + } + if (!IS_PRODUCTION_BUILD && missingVariants.length > 0) { + throw new Error( + `Orchestrator preflight: no skill variant for ${missingVariants.join( + ', ', + )} (framework=${ + session.skillId ?? 'none' + }) — fix the context-mill menu or the framework mapping.`, + ); + } + // The client injects the basics (project context + the I/O contract) around // every authored agent-prompt body. const promptContext: OrchestratorPromptContext = { @@ -368,7 +385,7 @@ export async function runOrchestrator( // SDK-divergent steps ship per-framework variants, so resolve against // the menu with the session's framework before installing. const variantId = resolveSkillVariantId( - menuSkillIds, + menuSkillEntries, skillId, session.skillId, ); diff --git a/src/lib/agent/runner/switchboard/models.ts b/src/lib/agent/runner/switchboard/models.ts index b1a7383b4..494baab69 100644 --- a/src/lib/agent/runner/switchboard/models.ts +++ b/src/lib/agent/runner/switchboard/models.ts @@ -27,13 +27,20 @@ import { import { RUN_SURFACE } from '@env'; /** Reasoning effort. pi maps it to `reasoning_effort` for openai-completions. */ -export type ThinkingLevel = - | 'off' - | 'minimal' - | 'low' - | 'medium' - | 'high' - | 'xhigh'; +const THINKING_LEVELS = [ + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +] as const; +export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; + +/** Whether a value (e.g. remote prompt frontmatter) names a valid effort. */ +export function isThinkingLevel(value: unknown): value is ThinkingLevel { + return (THINKING_LEVELS as readonly unknown[]).includes(value); +} export interface ModelCapabilities { /** Model supports reasoning; safe to request reasoning effort. */ diff --git a/src/lib/wizard-tools.ts b/src/lib/wizard-tools.ts index b1efc0717..f77679bf2 100644 --- a/src/lib/wizard-tools.ts +++ b/src/lib/wizard-tools.ts @@ -46,7 +46,17 @@ async function getSDKModule(): Promise { // Skill types // --------------------------------------------------------------------------- -export type SkillEntry = { id: string; name: string; downloadUrl: string }; +export type SkillEntry = { + id: string; + name: string; + downloadUrl: string; + /** The hyphenated skill-group prefix of `id` (e.g. `posthog-integration-install`). */ + group?: string; + /** The detection id this variant serves (e.g. `rails`, `react-router`). */ + framework?: string; + /** The variant a bare framework id resolves to when its family has several. */ + default?: boolean; +}; /** * Entry in the wizard's runtime CLI registry. Mirrors the shape context-mill