feat(agents): v0.38 Slice 1 foundation — migration v81 + capabilities module

Adds the storage substrate for the gateway-native subagent tool loop:

  - migration v81 adds subagent_tool_executions.ordinal + .gbrain_tool_use_id
    + UNIQUE(job_id, message_idx, ordinal). NULL-tolerant so legacy rows
    survive untouched; the v0.38 read-time D5 shim recomputes the stable
    key for pre-v81 rows from (job_id, message_idx, content_blocks index,
    tool_name) without a data migration. Engine-aware via sqlFor.pglite.
  - src/core/ai/capabilities.ts reads ChatTouchpoint fields from each
    recipe and exposes getProviderCapabilities() + classifyCapabilities()
    with a 5-state verdict (ok / degraded:no_caching / degraded:no_parallel
    / unusable:no_tools / unknown). This is what enforceSubagentCapable
    (D7, S1.8) will gate on once the queue.ts pin removal (S1.7) lands.
  - 12 unit cases in test/ai/capabilities.test.ts pin the verdict matrix
    across Anthropic, OpenAI, Google, voyage (no chat → unknown), unknown
    provider, missing-colon malformed input.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md
Wave: v0.38 (Agents+Minions cathedral; CEO + Eng + 2x Codex cleared).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-21 16:09:33 -07:00
co-authored by Claude Opus 4.7
parent 1580c6d1ca
commit 14d3f987e9
6 changed files with 342 additions and 39 deletions
+148
View File
@@ -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';
}
+60
View File
@@ -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
+20 -13
View File
@@ -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);
+22 -13
View File
@@ -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);
+22 -13
View File
@@ -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);
+70
View File
@@ -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');
});
});