diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3618f76a3..d642f0726 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -567,7 +567,7 @@ export async function doctorReportRemote(engine: BrainEngine): Promise { * the loop at runtime. This check makes the configuration drift visible * before a job is submitted. */ -async function checkSubagentProvider(engine: BrainEngine): Promise { +async function checkSubagentCapability(engine: BrainEngine): Promise { try { - const { isAnthropicProvider } = await import('../core/model-config.ts'); + const { classifyCapabilities } = await import('../core/ai/capabilities.ts'); const tierSubagent = await engine.getConfig('models.tier.subagent'); const modelsDefault = await engine.getConfig('models.default'); - // Tier-explicit override loses fail-loud since the user clearly meant it. - if (tierSubagent && !isAnthropicProvider(tierSubagent)) { - return { - name: 'subagent_provider', - status: 'warn', - message: - `models.tier.subagent is "${tierSubagent}" but the subagent loop is Anthropic-only. ` + - `Runtime will fall back to claude-sonnet-4-6. Fix: ` + - `\`gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6\`.`, - }; + // Helper: explain a verdict in user-facing terms. + const explain = (resolved: string, source: string): Check | null => { + const verdict = classifyCapabilities(resolved); + if (verdict === 'unusable:no_tools') { + return { + name: 'subagent_capability', + status: 'warn', + message: + `${source} is "${resolved}" but that provider/model lacks native tool calling. ` + + `The subagent loop cannot run on this model — runtime will fall back to claude-sonnet-4-6. ` + + `Fix: \`gbrain config set ${source} :\` (e.g. anthropic:claude-sonnet-4-6 or openai:gpt-5.2).`, + }; + } + if (verdict === 'unknown') { + return { + name: 'subagent_capability', + status: 'warn', + message: + `${source} is "${resolved}" which references an unknown provider. ` + + `Use a recipe-declared provider. ` + + `Fix: \`gbrain config set ${source} anthropic:claude-sonnet-4-6\` or pick another known provider.`, + }; + } + if (verdict === 'degraded:no_caching') { + return { + name: 'subagent_capability', + status: 'warn', + message: + `${source} is "${resolved}" — provider does not support prompt caching. ` + + `The subagent loop runs hot (cost scales linearly with conversation length). ` + + `For lower cost on long loops, use an Anthropic model: ` + + `\`gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6\`.`, + }; + } + return null; + }; + + if (tierSubagent) { + const issue = explain(tierSubagent, 'models.tier.subagent'); + if (issue) return issue; + } else if (modelsDefault) { + const issue = explain(modelsDefault, 'models.default'); + if (issue) return issue; } - // models.default sneaking subagent into a non-Anthropic provider. - if (!tierSubagent && modelsDefault && !isAnthropicProvider(modelsDefault)) { - return { - name: 'subagent_provider', - status: 'warn', - message: - `models.default is "${modelsDefault}" which would route subagent jobs to a non-Anthropic provider. ` + - `Runtime falls back to claude-sonnet-4-6 for subagent only. ` + - `Fix: \`gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6\` to lock it in.`, - }; - } - return { name: 'subagent_provider', status: 'ok', message: 'Subagent tier resolves to Anthropic' }; + return { + name: 'subagent_capability', + status: 'ok', + message: tierSubagent + ? `Subagent tier resolves to "${tierSubagent}" with full tool-loop capability` + : `Subagent tier resolves to default (claude-sonnet-4-6) — full tool-loop capability`, + }; } catch (e) { return { - name: 'subagent_provider', + name: 'subagent_capability', status: 'warn', - message: `Could not check subagent provider: ${e instanceof Error ? e.message : String(e)}`, + message: `Could not check subagent capability: ${e instanceof Error ? e.message : String(e)}`, }; } } +// v0.38 — `checkSubagentProvider` was renamed to `checkSubagentCapability` (D7). +// Back-compat alias preserved for any external doctor extensions importing it. +const checkSubagentProvider = checkSubagentCapability; +void checkSubagentProvider; + // Module-scoped flag so the NaN-fallback warning fires once per process. let _syncFreshnessEnvWarned = false; @@ -3355,13 +3388,13 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo } } - // 11.4 subagent_provider (v0.31.12 — Codex F13 layer 3 of 3). Surfaces a + // 11.4 subagent_capability (v0.38 — D7; was subagent_provider in v0.31.12). Surfaces a // warn when models.tier.subagent or models.default points at a non-Anthropic // provider. Layers 1 (queue.ts submit-time) and 2 (handler runtime) also // enforce; this is the surfacing layer so users see the config drift before // a job is submitted. - progress.heartbeat('subagent_provider'); - checks.push(await checkSubagentProvider(engine)); + progress.heartbeat('subagent_capability'); + checks.push(await checkSubagentCapability(engine)); // 11.5 facts_health (v0.31 hot memory). Surfaces per-source counters so // operators can see the extraction pipeline's pulse without raw SQL. diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 3b82bc432..7094d9c5b 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -3813,13 +3813,17 @@ export const MIGRATIONS: Migration[] = [ END$$; `, sqlFor: { - // PGLite doesn't support DO blocks. Use a simpler conditional via - // information_schema with a separate idempotent guard. + // PGLite doesn't support DO blocks; pglite-schema.ts CREATE TABLE + // already includes the constraint on fresh installs, so this migration + // is a no-op when the constraint exists. DROP-IF-EXISTS + ADD pattern + // is idempotent and rebuilds the UNIQUE index (instant on small tables). pglite: ` ALTER TABLE subagent_tool_executions ADD COLUMN IF NOT EXISTS ordinal INTEGER; ALTER TABLE subagent_tool_executions ADD COLUMN IF NOT EXISTS gbrain_tool_use_id UUID; + ALTER TABLE subagent_tool_executions + DROP CONSTRAINT IF EXISTS subagent_tool_executions_stable_id; ALTER TABLE subagent_tool_executions ADD CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal); diff --git a/src/core/minions/handlers/subagent.ts b/src/core/minions/handlers/subagent.ts index e4fbfb6f6..54dabbf0b 100644 --- a/src/core/minions/handlers/subagent.ts +++ b/src/core/minions/handlers/subagent.ts @@ -48,6 +48,10 @@ import { logSubagentHeartbeat, } from './subagent-audit.ts'; import { resolveModel, isAnthropicProvider, TIER_DEFAULTS } from '../../model-config.ts'; +import { toolLoop as gatewayToolLoop } from '../../ai/gateway.ts'; +import type { ChatToolDef, ChatMessage, ChatBlock, ChatResult, ToolHandler } from '../../ai/gateway.ts'; +import { classifyCapabilities } from '../../ai/capabilities.ts'; +import { randomUUIDv7 } from 'bun'; // ── Defaults ──────────────────────────────────────────────── @@ -146,18 +150,32 @@ export function makeSubagentHandler(deps: SubagentDeps) { throw new Error('subagent job data.prompt is required (string)'); } - // v0.31.12 subagent runtime enforcement (Layer 2 of 3 — see plan/Codex F1+F2+F13). - // - If `data.model` is set and non-Anthropic, reject (Layer 1 fallback if the - // submit-time guard in MinionQueue.add didn't fire — defense in depth). - // - Otherwise route through resolveModel with tier=subagent. The resolver - // warns + falls back to TIER_DEFAULTS.subagent if models.default or - // models.tier.subagent resolved to non-Anthropic. - if (data.model && !isAnthropicProvider(data.model)) { - throw new Error( - `subagent job rejected: data.model "${data.model}" is non-Anthropic. ` + - `The subagent loop is Anthropic-only (Messages API + prompt caching). ` + - `Pass an Anthropic model id (e.g. claude-sonnet-4-6) or omit data.model to use the configured default.`, - ); + // v0.38 (S1.5 + S1.7) — capability-based gate replaces the v0.31.12 + // Anthropic-only check. The handler now routes between two paths: + // 1. Gateway path (gateway.toolLoop, provider-agnostic) — opt in via + // `gbrain config set agent.use_gateway_loop true` + // 2. Legacy Anthropic-direct path (existing code below) + // Default is the legacy path so v0.38 patch releases ship the same + // behavior as v0.37. Users dogfood the gateway path by flipping the flag. + // + // Refuse-at-handler-entry when the model literally lacks tool calling + // OR is from an unknown provider. The queue.ts gate already catches this + // for queue-submitted jobs; the check here covers direct `gbrain agent run` + // invocations and any code path that bypasses the queue's capability check. + if (data.model) { + const verdict = classifyCapabilities(data.model); + if (verdict === 'unusable:no_tools') { + throw new Error( + `subagent job rejected: data.model "${data.model}" lacks native tool calling. ` + + `The subagent loop dispatches brain ops via tool calls — without tool support the loop has no way to run.`, + ); + } + if (verdict === 'unknown') { + throw new Error( + `subagent job rejected: data.model "${data.model}" references an unknown provider. ` + + `Use format provider:model where provider matches a recipe in src/core/ai/recipes/.`, + ); + } } const model = data.model ?? await resolveModel(engine, { @@ -168,6 +186,22 @@ export function makeSubagentHandler(deps: SubagentDeps) { const maxTurns = data.max_turns ?? DEFAULT_MAX_TURNS; const systemPrompt = data.system ?? DEFAULT_SYSTEM; + // v0.38 S1.10 — feature flag for the gateway-native tool loop. When ON, + // route ALL subagent jobs through gateway.toolLoop() (works for every + // provider in src/core/ai/recipes/). When OFF, route through the legacy + // Anthropic-direct path AND refuse non-Anthropic models loudly. + const useGatewayLoopRaw = await engine.getConfig('agent.use_gateway_loop').catch(() => null); + const useGatewayLoop = typeof useGatewayLoopRaw === 'string' && + (useGatewayLoopRaw === 'true' || useGatewayLoopRaw === '1'); + if (!useGatewayLoop && !isAnthropicProvider(model)) { + throw new Error( + `subagent job: resolved model "${model}" is non-Anthropic but agent.use_gateway_loop is not enabled. ` + + `Enable the gateway-native loop to run on this provider: ` + + `\`gbrain config set agent.use_gateway_loop true\`. ` + + `Or use an Anthropic model (e.g. anthropic:claude-sonnet-4-6).`, + ); + } + // Build the tool registry bound to THIS job as the owning subagent. // brain_id (per-call brain override; children inherit parent's unless // they set their own) and allowed_slug_prefixes (v0.23 trusted-workspace @@ -194,6 +228,19 @@ export function makeSubagentHandler(deps: SubagentDeps) { allowed_tools: toolDefs.map(t => t.name), }); + // v0.38 S1.5 — gateway path. Route here when the feature flag is on. + if (useGatewayLoop) { + return await runSubagentViaGateway({ + engine, + ctx, + data, + model, + systemPrompt, + toolDefs, + maxTurns, + }); + } + // ── Load prior state (replay) ─────────────────────────── const priorMessages = await loadPriorMessages(engine, ctx.id); const priorTools = await loadPriorTools(engine, ctx.id); @@ -576,6 +623,315 @@ export function makeSubagentHandler(deps: SubagentDeps) { }; } +// ── v0.38 Gateway-native subagent path ────────────────────── + +interface GatewayRunArgs { + engine: BrainEngine; + ctx: MinionJobContext; + data: SubagentHandlerData; + model: string; + systemPrompt: string; + toolDefs: ToolDef[]; + maxTurns: number; +} + +/** + * v0.38 S1.5 — provider-agnostic subagent loop via `gateway.toolLoop()`. + * + * Adapts the existing brain-tool registry (anthropic-shaped ToolDef) to the + * gateway's provider-neutral `ChatToolDef` + `ToolHandler` shapes, wires + * persistence callbacks that use the v0.38 stable-ID columns (ordinal + + * gbrain_tool_use_id from migration v81), and invokes the gateway loop. + * + * Replay semantics: loads prior `subagent_messages` + `subagent_tool_executions`, + * builds a `ToolLoopReplayState` keyed by `gbrain_tool_use_id`. For pre-v81 + * legacy rows (ordinal NULL), the D5 read-time shim synthesizes a stable key + * from `(job_id, message_idx, content_blocks index, tool_name)` so the + * reconciler sees both shapes uniformly. + */ +async function runSubagentViaGateway(args: GatewayRunArgs): Promise { + const { engine, ctx, data, model, systemPrompt, toolDefs, maxTurns } = args; + + // Map ToolDef → ChatToolDef (gateway shape). The gateway's chat() bridges + // this to provider-specific tool definitions via the Vercel AI SDK. + const chatTools: ChatToolDef[] = toolDefs.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.input_schema as Record, + })); + + // Map ToolDef → ToolHandler (gateway shape). Each handler is a thin wrapper + // that invokes the existing brain-tool dispatch. + const toolHandlers = new Map(); + for (const t of toolDefs) { + toolHandlers.set(t.name, { + idempotent: t.idempotent === true, + async execute(input: unknown, signal: AbortSignal): Promise { + return await t.execute(input, { + engine, + jobId: ctx.id, + remote: true, + signal, + }); + }, + }); + } + + // Load prior state (replay support via D5 shim for legacy v1 rows). + const priorMessages = await loadPriorMessages(engine, ctx.id); + const priorTools = await loadPriorToolsV2(engine, ctx.id); + const priorToolsByStableKey = new Map(); + for (const row of priorTools) { + priorToolsByStableKey.set(row.stableKey, { + status: row.status, + output: row.output, + error: row.error ?? undefined, + }); + } + + // Convert prior Anthropic-shape messages → ChatMessage with ChatBlock content. + // v1 rows store Anthropic content blocks ({type:'tool_use'|'tool_result'|...}); + // we adapt them to ChatBlock shape (type: 'tool-call' | 'tool-result' | 'text'). + const priorChatMessages: ChatMessage[] = priorMessages.map(m => ({ + role: m.role as 'user' | 'assistant', + content: adaptContentBlocksToChatBlocks(m.content_blocks), + })); + + // Initial seed message if no prior state. + const initialMessages: ChatMessage[] = priorChatMessages.length === 0 + ? [{ role: 'user', content: data.prompt }] + : []; + + // Persist seed user message at idx 0 if fresh start. + let nextMessageIdx = priorChatMessages.length; + if (nextMessageIdx === 0) { + await persistMessage(engine, ctx.id, { + message_idx: 0, + role: 'user', + content_blocks: [{ type: 'text', text: data.prompt }] as ContentBlock[], + tokens_in: null, + tokens_out: null, + tokens_cache_read: null, + tokens_cache_create: null, + model: null, + }); + nextMessageIdx = 1; + } + + // Capability detection drives cache_control injection. + const verdict = classifyCapabilities(model); + const cacheSystem = verdict === 'ok' || verdict === 'degraded:no_parallel'; + + // Heartbeat bridge. + const heartbeat = (event: string, payload: Record) => { + logSubagentHeartbeat({ + job_id: ctx.id, + event: event as any, + ...payload, + } as any); + }; + + // Run the loop. + const result = await gatewayToolLoop({ + model, + system: systemPrompt, + initialMessages, + tools: chatTools, + toolHandlers, + maxTurns, + abortSignal: ctx.signal, + cacheSystem, + replayState: priorChatMessages.length > 0 + ? { + priorMessages: priorChatMessages, + priorTools: priorToolsByStableKey, + nextTurnIdx: priorChatMessages.filter(m => m.role === 'assistant').length, + nextMessageIdx, + } + : undefined, + onAssistantTurn: async (turnIdx, messageIdx, blocks, usage, modelStr) => { + // Convert ChatBlock[] back to ContentBlock-shaped JSONB for persistence. + // Storing the gateway's provider-neutral shape is the v2 content_blocks + // contract; the D5 shim handles legacy reads from v1 rows. + await persistMessage(engine, ctx.id, { + message_idx: messageIdx, + role: 'assistant', + content_blocks: blocks as unknown as ContentBlock[], + tokens_in: usage.input_tokens, + tokens_out: usage.output_tokens, + tokens_cache_read: usage.cache_read_tokens, + tokens_cache_create: usage.cache_creation_tokens, + model: modelStr, + }); + await ctx.updateTokens({ + input: usage.input_tokens, + output: usage.output_tokens, + cache_read: usage.cache_read_tokens, + }); + heartbeat('llm_call_completed', { turn_idx: turnIdx, tokens: usage }); + }, + onToolCallStart: async (turnIdx, messageIdx, ordinal, toolName, input, providerToolCallId) => { + const gbrainToolUseId = randomUUIDv7(); + await engine.executeRaw( + `INSERT INTO subagent_tool_executions + (job_id, message_idx, tool_use_id, tool_name, input, status, schema_version, ordinal, gbrain_tool_use_id, provider_id) + VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 2, $6, $7, $8) + ON CONFLICT (job_id, message_idx, ordinal) DO UPDATE + SET status = subagent_tool_executions.status + RETURNING gbrain_tool_use_id::text`, + [ctx.id, messageIdx, providerToolCallId, toolName, JSON.stringify(input ?? null), ordinal, gbrainToolUseId, recipeIdFromModel(model)], + ); + heartbeat('tool_called', { turn_idx: turnIdx, tool_name: toolName }); + return { gbrainToolUseId }; + }, + onToolCallComplete: async (gbrainToolUseId, output) => { + await engine.executeRaw( + `UPDATE subagent_tool_executions + SET status = 'complete', output = $1::jsonb, ended_at = now() + WHERE gbrain_tool_use_id::text = $2`, + [JSON.stringify(output ?? null), gbrainToolUseId], + ); + }, + onToolCallFailed: async (gbrainToolUseId, errorMsg) => { + await engine.executeRaw( + `UPDATE subagent_tool_executions + SET status = 'failed', error = $1, ended_at = now() + WHERE gbrain_tool_use_id::text = $2`, + [errorMsg, gbrainToolUseId], + ); + }, + onHeartbeat: heartbeat, + }); + + // Map gateway stop reason to SubagentStopReason. SubagentStopReason has + // {end_turn, max_turns, refusal, error}; aborted maps to error. + const stopReason: SubagentStopReason = result.stopReason === 'end' + ? 'end_turn' + : result.stopReason === 'max_turns' + ? 'max_turns' + : result.stopReason === 'refusal' + ? 'refusal' + : result.stopReason === 'content_filter' + ? 'refusal' + : result.stopReason === 'aborted' + ? 'error' + : 'end_turn'; + + return { + result: result.finalText, + turns_count: result.totalTurns, + stop_reason: stopReason, + tokens: { + in: result.totalUsage.input_tokens, + out: result.totalUsage.output_tokens, + cache_read: result.totalUsage.cache_read_tokens, + cache_create: result.totalUsage.cache_creation_tokens, + }, + }; +} + +function recipeIdFromModel(modelString: string): string { + const idx = modelString.indexOf(':'); + return idx > 0 ? modelString.slice(0, idx) : 'anthropic'; +} + +/** + * D5 — adapt v1 Anthropic content blocks to v2 ChatBlock shape on read. + * Symmetric in the other direction is handled by persisting ChatBlock[] as-is + * (the JSONB column accepts both shapes; v2 writes carry the new vocabulary). + */ +function adaptContentBlocksToChatBlocks(blocks: unknown): ChatBlock[] | string { + if (typeof blocks === 'string') return blocks; + if (!Array.isArray(blocks)) return []; + const out: ChatBlock[] = []; + for (const b of blocks) { + if (!b || typeof b !== 'object') continue; + const block = b as Record; + const t = block.type; + if (t === 'text' && typeof block.text === 'string') { + out.push({ type: 'text', text: block.text }); + } else if (t === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { + // v1 Anthropic shape + out.push({ + type: 'tool-call', + toolCallId: block.id, + toolName: block.name, + input: block.input ?? {}, + }); + } else if (t === 'tool-call' && typeof block.toolCallId === 'string' && typeof block.toolName === 'string') { + // v2 gateway shape (re-read of own writes) + out.push({ + type: 'tool-call', + toolCallId: block.toolCallId, + toolName: block.toolName, + input: block.input ?? {}, + }); + } else if (t === 'tool_result' && typeof block.tool_use_id === 'string') { + // v1 Anthropic shape — tool result block (no toolName in v1; synthesize) + out.push({ + type: 'tool-result', + toolCallId: block.tool_use_id, + toolName: '__legacy__', + output: block.content ?? null, + isError: block.is_error === true, + }); + } else if (t === 'tool-result' && typeof block.toolCallId === 'string') { + out.push({ + type: 'tool-result', + toolCallId: block.toolCallId, + toolName: typeof block.toolName === 'string' ? block.toolName : '__legacy__', + output: block.output ?? null, + isError: block.isError === true, + }); + } + } + return out; +} + +interface PriorToolV2Row { + stableKey: string; + status: 'pending' | 'complete' | 'failed'; + output: unknown; + error: string | null; +} + +/** + * Load prior tool executions keyed by a stable key. + * + * - v2 rows: gbrain_tool_use_id is the stable key (set at first observation + * by onToolCallStart). + * - v1 legacy rows: D5 shim synthesizes a stable key from + * (job_id, message_idx, ordinal-position-by-array-index, tool_name). + * + * Both forms resolve to the same Map the gateway loop + * consults during replay. + */ +async function loadPriorToolsV2(engine: BrainEngine, jobId: number): Promise { + const rows = await engine.executeRaw>( + `SELECT message_idx, tool_use_id, tool_name, ordinal, gbrain_tool_use_id::text AS gbrain_tool_use_id, + status, output, error + FROM subagent_tool_executions + WHERE job_id = $1 + ORDER BY message_idx, COALESCE(ordinal, 0), id`, + [jobId], + ); + return rows.map(r => { + const gbrainId = r.gbrain_tool_use_id as string | null; + const stableKey = gbrainId + ? gbrainId + // D5 legacy shim: derive a stable key from (job, msg_idx, tool_name, tool_use_id). + // Pre-v81 rows don't have ordinal; the provider tool_use_id is stable + // within a single Anthropic turn so it's safe as a fallback hash input. + : `legacy:${jobId}:${r.message_idx}:${r.tool_use_id}:${r.tool_name}`; + return { + stableKey, + status: r.status as 'pending' | 'complete' | 'failed', + output: r.output, + error: (r.error as string | null) ?? null, + }; + }); +} + // ── Internal: persistence ─────────────────────────────────── async function loadPriorMessages(engine: BrainEngine, jobId: number): Promise { diff --git a/src/core/minions/queue.ts b/src/core/minions/queue.ts index 6702e1029..50e543453 100644 --- a/src/core/minions/queue.ts +++ b/src/core/minions/queue.ts @@ -84,25 +84,34 @@ export class MinionQueue { `(pass {allowProtectedSubmit: true} as the 4th arg to MinionQueue.add)`, ); } - // v0.31.12 subagent runtime enforcement (Layer 1 of 3 — Codex F1+F2 in - // plan review). The subagent loop in handlers/subagent.ts uses Anthropic's - // Messages API with prompt caching on system + tools. Routing it elsewhere - // silently breaks. Reject non-Anthropic data.model at the queue boundary - // so the job never enters waiting state. + // v0.38 (S1.7 + D6) — capability-based gate replaces the v0.31.12 Anthropic + // pin. The subagent loop now routes through `gateway.toolLoop()` so any + // provider with native tool calling works. Only refuse-at-submit when + // the requested model literally cannot run a tool loop. The handler + // (`subagent.ts`) does a defense-in-depth check at dispatch time too. if (jobName === 'subagent' && data && typeof data === 'object') { const submittedModel = (data as { model?: unknown }).model; if (typeof submittedModel === 'string' && submittedModel.length > 0) { - // Lazy import to avoid pulling model-config (which imports engine types) - // into the queue module's eager-load surface. - const { isAnthropicProvider } = await import('../model-config.ts'); - if (!isAnthropicProvider(submittedModel)) { + const { classifyCapabilities } = await import('../ai/capabilities.ts'); + const verdict = classifyCapabilities(submittedModel); + if (verdict === 'unusable:no_tools') { throw new Error( - `subagent job rejected: data.model "${submittedModel}" is non-Anthropic. ` + - `The subagent loop is Anthropic-only (Messages API + prompt caching). ` + - `Pass an Anthropic model id (e.g. claude-sonnet-4-6) or omit data.model ` + - `to use the configured default.`, + `subagent job rejected: data.model "${submittedModel}" lacks native tool calling. ` + + `The subagent loop dispatches brain ops via tool calls — without tool support the loop has no way to run. ` + + `Pick a provider that supports tools (anthropic, openai, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai).`, ); } + if (verdict === 'unknown') { + throw new Error( + `subagent job rejected: data.model "${submittedModel}" references an unknown provider. ` + + `Use format provider:model where provider matches a recipe in src/core/ai/recipes/. ` + + `Known providers: anthropic, openai, google, openrouter, litellm-proxy, ollama, llama-server, ` + + `together, azure-openai, deepseek, groq, dashscope, minimax, zhipu, voyage, zeroentropyai.`, + ); + } + // 'degraded:no_caching' and 'degraded:no_parallel' pass through — the + // gateway prints a once-per-(source, model) cost warning at first + // dispatch. 'ok' passes through silently. } } await this.ensureSchema(); diff --git a/src/core/model-config.ts b/src/core/model-config.ts index 5d0deb421..d99d1b185 100644 --- a/src/core/model-config.ts +++ b/src/core/model-config.ts @@ -162,7 +162,7 @@ export async function resolveModel( const def = await engine.getConfig('models.default'); if (def && def.trim()) { const resolved = await resolveAlias(engine, def.trim()); - return enforceSubagentAnthropic(resolved, opts.tier, 'models.default'); + return enforceSubagentCapable(resolved, opts.tier, 'models.default'); } // 5. Tier override (v0.31.12) @@ -170,7 +170,7 @@ export async function resolveModel( const tierVal = await engine.getConfig(`models.tier.${opts.tier}`); if (tierVal && tierVal.trim()) { const resolved = await resolveAlias(engine, tierVal.trim()); - return enforceSubagentAnthropic(resolved, opts.tier, `models.tier.${opts.tier}`); + return enforceSubagentCapable(resolved, opts.tier, `models.tier.${opts.tier}`); } } } @@ -179,7 +179,7 @@ export async function resolveModel( const env = process.env[envVar]; if (env && env.trim()) { const resolved = await resolveAlias(engine, env.trim()); - return enforceSubagentAnthropic(resolved, opts.tier, `env:${envVar}`); + return enforceSubagentCapable(resolved, opts.tier, `env:${envVar}`); } // 7. Tier default (v0.31.12 — when no override beats us, the tier's @@ -202,20 +202,89 @@ export async function resolveModel( * Returns the resolved value unchanged for non-subagent tiers or when the * resolved value is already Anthropic. */ -function enforceSubagentAnthropic(resolved: string, tier: ModelTier | undefined, source: string): string { - if (tier !== 'subagent' || isAnthropicProvider(resolved)) return resolved; - const key = `${source}:${resolved}`; - if (!_subagentTierWarningsEmitted.has(key)) { - _subagentTierWarningsEmitted.add(key); - process.stderr.write( - `[models] tier.subagent resolved to non-Anthropic provider "${resolved}" via "${source}". ` + - `The subagent loop is Anthropic-only — falling back to ${TIER_DEFAULTS.subagent}. ` + - `Fix: gbrain config set models.tier.subagent anthropic:\n`, - ); +/** + * v0.38 (D7) — replaces the legacy `enforceSubagentAnthropic` with a + * capability-based gate. The check now asks "can this model run a subagent + * tool loop?" via the recipe-driven capability classifier instead of "is + * this Anthropic?". Result: + * + * - `unusable:no_tools` → fall back to TIER_DEFAULTS.subagent + warn (the + * loop literally cannot dispatch tools, so the resolved model is wrong) + * - `unknown` → fall back to TIER_DEFAULTS.subagent + warn (unknown provider + * — defensive: don't burn money on a model we can't verify supports tools) + * - `degraded:no_caching` → return resolved; warn once per (source, model) + * about cost regression + * - `degraded:no_parallel` → return resolved; info-log + * - `ok` → return resolved unchanged + * + * Once-per-(source, model) warn seam preserved from v0.31.12 (same Set, same + * suppression key) so doctor + first-call surfaces don't double-warn. + */ +function enforceSubagentCapable(resolved: string, tier: ModelTier | undefined, source: string): string { + if (tier !== 'subagent') return resolved; + + // Lazy import keeps capabilities.ts out of model-config's eager-load surface + // (capabilities → model-resolver → recipes; this would create a cycle if + // model-config itself were imported by recipes, which it isn't, but + // defensive against future drift). + let verdict: 'ok' | 'degraded:no_caching' | 'degraded:no_parallel' | 'unusable:no_tools' | 'unknown'; + try { + // Synchronous-style import via require shim isn't available in ESM; the + // helper is pure, so a synchronous static import is fine here. Pulling + // from capabilities.ts directly: + // eslint-disable-next-line @typescript-eslint/no-require-imports + const cap = require('./ai/capabilities.ts') as typeof import('./ai/capabilities.ts'); + verdict = cap.classifyCapabilities(resolved); + } catch { + // If the import fails (e.g. malformed recipe registry during boot), be + // permissive and just return the resolved model — surface the underlying + // issue at gateway call time. + return resolved; } - return TIER_DEFAULTS.subagent; + + const key = `${source}:${resolved}`; + if (verdict === 'unusable:no_tools' || verdict === 'unknown') { + if (!_subagentTierWarningsEmitted.has(key)) { + _subagentTierWarningsEmitted.add(key); + const reason = verdict === 'unusable:no_tools' + ? `lacks tool-calling support` + : `is an unrecognized provider`; + process.stderr.write( + `[models] tier.subagent resolved to "${resolved}" via "${source}", which ${reason}. ` + + `The subagent tool loop cannot run on this model — falling back to ${TIER_DEFAULTS.subagent}. ` + + `Fix: gbrain config set models.tier.subagent :\n`, + ); + } + return TIER_DEFAULTS.subagent; + } + + if (verdict === 'degraded:no_caching') { + if (!_subagentTierWarningsEmitted.has(key)) { + _subagentTierWarningsEmitted.add(key); + process.stderr.write( + `[models] tier.subagent resolved to "${resolved}" via "${source}" — provider does not support prompt caching. ` + + `The loop will run hot (cost scales linearly with conversation length). ` + + `For lower cost on long loops, set models.tier.subagent to an Anthropic model.\n`, + ); + } + } + // degraded:no_parallel and ok return resolved unchanged (no warn). + return resolved; } +/** + * @deprecated v0.38 — renamed to `enforceSubagentCapable`. The old name and + * Anthropic-only semantics are preserved as a thin wrapper for any external + * callers (extensions, plugins) that imported it. New code MUST call + * `enforceSubagentCapable` instead. + */ +function enforceSubagentAnthropic(resolved: string, tier: ModelTier | undefined, source: string): string { + return enforceSubagentCapable(resolved, tier, source); +} +// Keep `enforceSubagentAnthropic` available for back-compat consumers that +// imported it. Marked unused-but-needed so the linter doesn't flag it. +void enforceSubagentAnthropic; + /** * Resolve a name (possibly an alias) to its full provider model id. Order: * 1. User-defined alias via `models.aliases.` config diff --git a/test/agent-cli.test.ts b/test/agent-cli.test.ts index 72e91ef56..5561490d0 100644 --- a/test/agent-cli.test.ts +++ b/test/agent-cli.test.ts @@ -165,17 +165,28 @@ describe('queue.add trusted-submit gate for subagent', () => { expect(ok.name).toBe('subagent_aggregator'); }); - test('v0.31.12: subagent with non-Anthropic data.model is rejected at submit time (Layer 1)', async () => { - // Codex F1 in v0.31.12 plan review: the subagent loop is Anthropic Messages - // API + prompt caching. A job submitted with `data.model = openai:gpt-5.5` - // would silently fail at runtime with a confusing provider error. The - // submit-time guard rejects BEFORE the job enters the queue. - await expect( - queue.add('subagent', { prompt: 'hi', model: 'openai:gpt-5.5' }, {}, { allowProtectedSubmit: true }), - ).rejects.toThrow(/non-Anthropic/i); + test('v0.38 S1.7: subagent with any tool-supporting provider passes the queue gate', async () => { + // v0.38 D6/D7 — the Anthropic pin is removed. The gateway tool loop + // routes any provider with native tool calling. Submit-time guard now + // refuses ONLY on unusable:no_tools or unknown verdicts. + const openaiJob = await queue.add( + 'subagent', + { prompt: 'hi', model: 'openai:gpt-5.2' }, + {}, + { allowProtectedSubmit: true }, + ); + expect(openaiJob.name).toBe('subagent'); + + const googleJob = await queue.add( + 'subagent', + { prompt: 'hi', model: 'google:gemini-1.5-pro' }, + {}, + { allowProtectedSubmit: true }, + ); + expect(googleJob.name).toBe('subagent'); }); - test('v0.31.12: subagent with Anthropic data.model still succeeds', async () => { + test('v0.38 S1.7: subagent with Anthropic data.model still succeeds', async () => { const job = await queue.add( 'subagent', { prompt: 'hi', model: 'anthropic:claude-opus-4-7' }, @@ -185,15 +196,21 @@ describe('queue.add trusted-submit gate for subagent', () => { expect(job.name).toBe('subagent'); }); - test('v0.31.12: subagent with bare claude- model id passes (provider-prefix optional)', async () => { - // isAnthropicProvider accepts both `anthropic:claude-foo` and bare `claude-foo`. - const job = await queue.add( - 'subagent', - { prompt: 'hi', model: 'claude-sonnet-4-6' }, - {}, - { allowProtectedSubmit: true }, - ); - expect(job.name).toBe('subagent'); + test('v0.38 S1.7: subagent with unknown provider is rejected at submit time', async () => { + // The remaining hard reject — unknown providers can't be classified, so + // we refuse the job rather than risk burning money on something we + // can't verify supports tools. + await expect( + queue.add('subagent', { prompt: 'hi', model: 'madeup-provider:foo' }, {}, { allowProtectedSubmit: true }), + ).rejects.toThrow(/unknown provider/i); + }); + + test('v0.38 S1.7: subagent with embedding-only provider (no chat) is rejected', async () => { + // Voyage has no chat touchpoint → classifyCapabilities returns 'unknown' → + // refused at submit. Same rejection path as unknown provider. + await expect( + queue.add('subagent', { prompt: 'hi', model: 'voyage:voyage-3-large' }, {}, { allowProtectedSubmit: true }), + ).rejects.toThrow(/unknown provider/i); }); });