diff --git a/src/core/ai/capabilities.ts b/src/core/ai/capabilities.ts new file mode 100644 index 000000000..5d860a0ab --- /dev/null +++ b/src/core/ai/capabilities.ts @@ -0,0 +1,148 @@ +/** + * Provider capability detection for the gateway-native subagent tool loop. + * + * Pre-v0.38 the subagent loop was Anthropic-direct (`new Anthropic()` instantiated + * in `src/core/minions/handlers/subagent.ts`). The three-layer pin + * (`queue.ts:87-106` + `subagent.ts:149-167` + `doctor.ts:1190-1225` enforced + * Anthropic-only because crash-replay relied on Anthropic's stable `tool_use_id`s + * for reconciliation. v0.38 (D11) moves the stable-ID generation gbrain-side + * (ordinal + uuid v7 persisted in `subagent_tool_executions` at first observation), + * which decouples the loop from any specific provider's response format. + * + * This module reads capabilities from the recipe (`src/core/ai/recipes/*.ts`) + * and surfaces them via a normalized `ProviderCapabilities` shape that the + * gateway's `toolLoop()` consumes to decide: + * - REFUSE at submit when tool-calling is unsupported (D6 — useless loop) + * - WARN at submit when prompt caching is unavailable (D6 — cost regression) + * - INFO at submit when parallel tools unsupported (D6 — just slower) + * + * The capability shape is intentionally narrow. Per-call cost is already in + * `ChatTouchpoint.cost_per_1m_*`; we don't re-export it here because routing + * decisions don't depend on it. + */ + +import { resolveRecipe } from './model-resolver.ts'; +import { AIConfigError } from './errors.ts'; + +export interface ProviderCapabilities { + /** Provider returns native function/tool calling. Required for the subagent loop. */ + supportsToolCalling: boolean; + + /** + * Anthropic-style ephemeral prompt cache markers honored. When false, the + * loop runs hot (no cache_control injection) and per-turn costs scale + * linearly with conversation length. Doesn't break the loop; just costs more. + */ + supportsPromptCaching: boolean; + + /** + * Provider can return multiple `tool_use` blocks in a single assistant turn + * and accepts a single follow-up `user` message with matching `tool_result` + * blocks. When false, the loop falls back to serial tool dispatch (one tool + * per turn), which matches the v0.15 default and is a perf hit, not a + * correctness issue. + * + * NOTE: this currently reads from `recipe.touchpoints.chat.supports_tools` + * because no recipe exposes a separate parallel-tools field today. Treat as + * "best-effort capability hint" — when the gateway tool loop lands in v0.38 + * Slice 4 it will add parallel dispatch with a per-recipe gate. + */ + supportsParallelTools: boolean; + + /** + * Provider supports an extended-thinking / reasoning block in responses. + * Not load-bearing for the loop; surfaced so callers (e.g. `gbrain agent run`) + * can decide whether to surface the reasoning trace in `--follow` output. + */ + supportsThinking: boolean; + + /** + * Max input+output tokens the provider/model accepts per turn. Drives the + * gateway's pre-flight context check; the loop refuses to send a prompt + * that exceeds this (with a paste-ready truncation hint). + */ + maxContext: number; +} + +/** + * Resolve a `provider:model` string and return its capability set. + * + * Throws `AIConfigError` when the provider/model is unknown OR when the + * provider lacks a `chat` touchpoint (e.g., embedding-only providers like + * Voyage). Callers that want a soft check can wrap in try/catch and degrade. + */ +export function getProviderCapabilities(modelString: string): ProviderCapabilities { + const { recipe, parsed } = resolveRecipe(modelString); + const chat = recipe.touchpoints.chat; + if (!chat) { + throw new AIConfigError( + `Provider "${recipe.id}" does not offer a chat touchpoint.`, + `Known providers with chat: openai, anthropic, google, openrouter, litellm-proxy, deepseek, groq, together, azure-openai, dashscope, minimax, zhipu, ollama, llama-server. Pick one for models.tier.subagent.`, + ); + } + + // For native providers, the model must be in the recipe's allow-list. For + // openai-compatible recipes (litellm, ollama, llama-server), arbitrary model + // ids are accepted because the gateway behind the proxy decides what's real. + // We don't error here — `assertTouchpoint` already enforces this at gateway + // boundary; this function returns capabilities for whatever the user asked + // for, on the assumption it'll be validated elsewhere. + + return { + supportsToolCalling: chat.supports_tools === true, + supportsPromptCaching: chat.supports_prompt_cache === true, + // No recipe exposes parallel-tools-specifically yet; gate on supports_tools. + // Subsequent waves can split this into its own recipe field if a provider + // ever supports tools without parallel dispatch. + supportsParallelTools: chat.supports_tools === true, + // Not exposed by ChatTouchpoint today — defaults to false. Recipes can add + // a `supports_thinking` field later without breaking this helper (it'll + // just keep returning false until a recipe sets it). + supportsThinking: false, + maxContext: chat.max_context_tokens ?? 128_000, + }; + + // The `parsed` binding is intentionally unused — `resolveRecipe` is called + // here for its validation side-effects (throws on unknown provider). Keeping + // the destructure makes future per-model capability overrides cheap. + void parsed; +} + +/** + * Tier-1 gate consumed by `enforceSubagentCapable()` in src/core/model-config.ts + * (D6 + D7). Returns: + * + * - `'ok'` — provider has tool-calling, prompt caching, and parallel tools. + * Loop runs at full speed. + * - `'degraded:no_caching'` — provider supports tools but lacks prompt + * caching. Loop runs but per-turn cost is higher. Warn once per + * (source, model) pair. + * - `'degraded:no_parallel'` — provider supports tools and caching but the + * loop will dispatch serially. Info-log; no warn. + * - `'unusable:no_tools'` — provider lacks tool calling entirely. Refuse at + * submit; the loop has no way to execute brain ops. + * - `'unknown'` — the provider/model isn't in any recipe. Refuse at submit + * (defensive: don't spend money on an unrecognized provider). + * + * Pure function; no side effects. The caller decides what to do with each + * verdict (warn / info / throw) based on its surface. + */ +export type CapabilityVerdict = + | 'ok' + | 'degraded:no_caching' + | 'degraded:no_parallel' + | 'unusable:no_tools' + | 'unknown'; + +export function classifyCapabilities(modelString: string): CapabilityVerdict { + let caps: ProviderCapabilities; + try { + caps = getProviderCapabilities(modelString); + } catch { + return 'unknown'; + } + if (!caps.supportsToolCalling) return 'unusable:no_tools'; + if (!caps.supportsPromptCaching) return 'degraded:no_caching'; + if (!caps.supportsParallelTools) return 'degraded:no_parallel'; + return 'ok'; +} diff --git a/src/core/migrate.ts b/src/core/migrate.ts index ad6371a35..3b82bc432 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -3766,6 +3766,66 @@ export const MIGRATIONS: Migration[] = [ ); `, }, + { + version: 81, + name: 'subagent_tool_executions_stable_id', + // v0.38 Slice 1 — D11 stable-ID columns. The pre-v81 reconciliation key + // for crash-replay was `(job_id, tool_use_id)` where tool_use_id came from + // the Anthropic Messages API. That was load-bearing for the v0.15 crash- + // replay guarantee but tied the loop to Anthropic-direct. The provider- + // agnostic loop assigns its own ordinal at FIRST observation of a tool + // call in a turn; that ordinal + gbrain_tool_use_id (uuid v7) become the + // canonical reconciliation key. The provider's own tool_use_id stays as a + // side channel for debugging. + // + // Migration shape: + // - ordinal INTEGER NULL: legacy rows have NULL; new writes always set + // a value. UNIQUE on (job_id, message_idx, ordinal) treats multiple + // NULL ordinals as distinct (Postgres semantics), so legacy rows + // don't collide with each other or with new writes. + // - gbrain_tool_use_id UUID NULL: same NULL semantics; populated at + // write time post-Slice-1, NULL on legacy rows. + // - Existing constraint uniq_subagent_tools_use_id (job_id, tool_use_id) + // is preserved — provider IDs stay deduplicated within a job. + // - The read-time D5 shim recomputes a stable key for legacy rows from + // (job_id, message_idx, content_blocks index, tool_name); no data + // migration needed. + // + // ADD COLUMN with no DEFAULT (NULL) is metadata-only on Postgres 11+ and + // PGLite 17.5 — instant on tables of any size. The UNIQUE constraint + // builds a partial index; on a typical brain with ~hundreds of subagent + // rows this is sub-millisecond. + idempotent: true, + sql: ` + ALTER TABLE subagent_tool_executions + ADD COLUMN IF NOT EXISTS ordinal INTEGER, + ADD COLUMN IF NOT EXISTS gbrain_tool_use_id UUID; + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'subagent_tool_executions_stable_id' + ) THEN + ALTER TABLE subagent_tool_executions + ADD CONSTRAINT subagent_tool_executions_stable_id + UNIQUE (job_id, message_idx, ordinal); + END IF; + END$$; + `, + sqlFor: { + // PGLite doesn't support DO blocks. Use a simpler conditional via + // information_schema with a separate idempotent guard. + 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 + ADD CONSTRAINT subagent_tool_executions_stable_id + UNIQUE (job_id, message_idx, ordinal); + `, + }, + }, ]; export const LATEST_VERSION = MIGRATIONS.length > 0 diff --git a/src/core/pglite-schema.ts b/src/core/pglite-schema.ts index 6a49ed42b..d85e54b2a 100644 --- a/src/core/pglite-schema.ts +++ b/src/core/pglite-schema.ts @@ -409,20 +409,27 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_job ON subagent_messages (job_i CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages (job_id, provider_id); CREATE TABLE IF NOT EXISTS subagent_tool_executions ( - id BIGSERIAL PRIMARY KEY, - job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE, - message_idx INTEGER NOT NULL, - tool_use_id TEXT NOT NULL, - tool_name TEXT NOT NULL, - input JSONB NOT NULL, - status TEXT NOT NULL, - output JSONB, - error TEXT, - schema_version INTEGER NOT NULL DEFAULT 1, - provider_id TEXT, - started_at TIMESTAMPTZ NOT NULL DEFAULT now(), - ended_at TIMESTAMPTZ, + id BIGSERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE, + message_idx INTEGER NOT NULL, + tool_use_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + input JSONB NOT NULL, + status TEXT NOT NULL, + output JSONB, + error TEXT, + schema_version INTEGER NOT NULL DEFAULT 1, + provider_id TEXT, + -- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first observation; + -- gbrain_tool_use_id is uuid v7). Reconciliation on crash-replay uses + -- (job_id, message_idx, ordinal) as the unique key. Legacy rows (pre-v81) + -- have ordinal=NULL and resolve via the read-time D5 shim. + ordinal INTEGER, + gbrain_tool_use_id UUID, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id), + CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal), CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed')) ); CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status); diff --git a/src/core/schema-embedded.ts b/src/core/schema-embedded.ts index a1cef61fe..ca2ce43fa 100644 --- a/src/core/schema-embedded.ts +++ b/src/core/schema-embedded.ts @@ -727,20 +727,29 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages ( -- After success: UPDATE to 'complete' + output. On failure: 'failed' + error. -- Replay re-runs 'pending' rows only if the tool is idempotent. CREATE TABLE IF NOT EXISTS subagent_tool_executions ( - id BIGSERIAL PRIMARY KEY, - job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE, - message_idx INTEGER NOT NULL, - tool_use_id TEXT NOT NULL, - tool_name TEXT NOT NULL, - input JSONB NOT NULL, - status TEXT NOT NULL, - output JSONB, - error TEXT, - schema_version INTEGER NOT NULL DEFAULT 1, - provider_id TEXT, - started_at TIMESTAMPTZ NOT NULL DEFAULT now(), - ended_at TIMESTAMPTZ, + id BIGSERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE, + message_idx INTEGER NOT NULL, + tool_use_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + input JSONB NOT NULL, + status TEXT NOT NULL, + output JSONB, + error TEXT, + schema_version INTEGER NOT NULL DEFAULT 1, + provider_id TEXT, + -- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first + -- observation of a tool call; gbrain_tool_use_id is uuid v7). Reconciliation + -- on crash-replay uses (job_id, message_idx, ordinal) as the unique key. + -- Legacy rows (pre-v81) have ordinal=NULL + gbrain_tool_use_id=NULL and + -- are resolved by the read-time D5 shim that recomputes the key from + -- (job_id, message_idx, content_blocks index, tool_name). + ordinal INTEGER, + gbrain_tool_use_id UUID, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id), + CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal), CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed')) ); CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status); diff --git a/src/schema.sql b/src/schema.sql index 859d4e894..d4c5f64e2 100644 --- a/src/schema.sql +++ b/src/schema.sql @@ -723,20 +723,29 @@ CREATE INDEX IF NOT EXISTS idx_subagent_messages_provider ON subagent_messages ( -- After success: UPDATE to 'complete' + output. On failure: 'failed' + error. -- Replay re-runs 'pending' rows only if the tool is idempotent. CREATE TABLE IF NOT EXISTS subagent_tool_executions ( - id BIGSERIAL PRIMARY KEY, - job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE, - message_idx INTEGER NOT NULL, - tool_use_id TEXT NOT NULL, - tool_name TEXT NOT NULL, - input JSONB NOT NULL, - status TEXT NOT NULL, - output JSONB, - error TEXT, - schema_version INTEGER NOT NULL DEFAULT 1, - provider_id TEXT, - started_at TIMESTAMPTZ NOT NULL DEFAULT now(), - ended_at TIMESTAMPTZ, + id BIGSERIAL PRIMARY KEY, + job_id BIGINT NOT NULL REFERENCES minion_jobs(id) ON DELETE CASCADE, + message_idx INTEGER NOT NULL, + tool_use_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + input JSONB NOT NULL, + status TEXT NOT NULL, + output JSONB, + error TEXT, + schema_version INTEGER NOT NULL DEFAULT 1, + provider_id TEXT, + -- v0.38 D11: gbrain-owned stable IDs (ordinal assigned at first + -- observation of a tool call; gbrain_tool_use_id is uuid v7). Reconciliation + -- on crash-replay uses (job_id, message_idx, ordinal) as the unique key. + -- Legacy rows (pre-v81) have ordinal=NULL + gbrain_tool_use_id=NULL and + -- are resolved by the read-time D5 shim that recomputes the key from + -- (job_id, message_idx, content_blocks index, tool_name). + ordinal INTEGER, + gbrain_tool_use_id UUID, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, CONSTRAINT uniq_subagent_tools_use_id UNIQUE (job_id, tool_use_id), + CONSTRAINT subagent_tool_executions_stable_id UNIQUE (job_id, message_idx, ordinal), CONSTRAINT chk_subagent_tools_status CHECK (status IN ('pending','complete','failed')) ); CREATE INDEX IF NOT EXISTS idx_subagent_tools_job ON subagent_tool_executions (job_id, status); diff --git a/test/ai/capabilities.test.ts b/test/ai/capabilities.test.ts new file mode 100644 index 000000000..3f6ed4e0f --- /dev/null +++ b/test/ai/capabilities.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'bun:test'; +import { getProviderCapabilities, classifyCapabilities } from '../../src/core/ai/capabilities.ts'; + +describe('getProviderCapabilities (v0.38 Slice 1 — D6/D7 recipe-driven capabilities)', () => { + it('returns full capabilities for Anthropic (canonical reference)', () => { + const caps = getProviderCapabilities('anthropic:claude-sonnet-4-6'); + expect(caps.supportsToolCalling).toBe(true); + expect(caps.supportsPromptCaching).toBe(true); + expect(caps.supportsParallelTools).toBe(true); + expect(caps.maxContext).toBe(200000); + }); + + it('returns capabilities for OpenAI (no prompt caching field set as true)', () => { + const caps = getProviderCapabilities('openai:gpt-5.2'); + expect(caps.supportsToolCalling).toBe(true); + expect(caps.supportsPromptCaching).toBe(false); // OpenAI implicit caching doesn't get marked + expect(caps.maxContext).toBe(200000); + }); + + it('returns capabilities for Google Gemini', () => { + const caps = getProviderCapabilities('google:gemini-1.5-pro'); + expect(caps.supportsToolCalling).toBe(true); + expect(caps.supportsPromptCaching).toBe(false); + expect(caps.maxContext).toBe(1000000); // Gemini 1.5 Pro + }); + + it('honors Anthropic alias (undated → dated)', () => { + const caps = getProviderCapabilities('anthropic:claude-haiku-4-5'); + expect(caps.supportsToolCalling).toBe(true); + }); + + it('throws for unknown provider', () => { + expect(() => getProviderCapabilities('madeup-provider:foo')).toThrow(); + }); + + it('throws for embedding-only provider (no chat touchpoint)', () => { + expect(() => getProviderCapabilities('voyage:voyage-3-large')).toThrow( + /does not offer a chat touchpoint/, + ); + }); + + it('throws for missing colon', () => { + expect(() => getProviderCapabilities('claude-sonnet-4-6')).toThrow(/missing a provider prefix/); + }); +}); + +describe('classifyCapabilities (D6 — three-tier capability verdict)', () => { + it('returns ok for fully-capable Anthropic models', () => { + expect(classifyCapabilities('anthropic:claude-sonnet-4-6')).toBe('ok'); + expect(classifyCapabilities('anthropic:claude-opus-4-7')).toBe('ok'); + }); + + it('returns degraded:no_caching for OpenAI (tools yes, caching no)', () => { + expect(classifyCapabilities('openai:gpt-5.2')).toBe('degraded:no_caching'); + }); + + it('returns degraded:no_caching for Google Gemini', () => { + expect(classifyCapabilities('google:gemini-1.5-pro')).toBe('degraded:no_caching'); + }); + + it('returns unknown for unrecognized providers', () => { + expect(classifyCapabilities('madeup:something')).toBe('unknown'); + }); + + it('returns unknown for embedding-only providers (chat touchpoint missing)', () => { + // Voyage has no chat touchpoint → throws inside getProviderCapabilities + // → classifyCapabilities catches → returns 'unknown'. + expect(classifyCapabilities('voyage:voyage-3-large')).toBe('unknown'); + }); +});