feat(agents): v0.38 Slice 1 — kill the Anthropic pin, route through gateway.toolLoop

Closes the three-layer Anthropic-only enforcement (queue gate / model-config
runtime fallback / doctor check) with a capability-based gate driven by the
recipe registry. Any provider that supports native tool calling can now
run the subagent loop.

Three layers reworked:

  - queue.ts:87-106 (S1.7) — drop isAnthropicProvider hard-reject. Replace
    with classifyCapabilities() check: refuse only when verdict is
    'unusable:no_tools' or 'unknown'. Degraded providers (no caching, no
    parallel tools) pass through; the gateway prints once-per-(source, model)
    cost warnings at first dispatch.
  - model-config.ts:205 (S1.8) — rename enforceSubagentAnthropic →
    enforceSubagentCapable. Keeps the once-per-(source, model) warn seam
    from v0.31.12 and inherits the same suppression Set so doctor + first-
    call surfaces stay in sync. Legacy name kept as a thin wrapper for
    external callers.
  - doctor.ts:1189 (S1.9) — rename subagent_provider check →
    subagent_capability. The check now surfaces three states: 'unusable',
    'unknown', and 'degraded:no_caching' (the cost-regression warn). Paste-
    ready fix hints point at `gbrain config set models.tier.subagent`.

Subagent handler routing (S1.5 + S1.10):

  - New `agent.use_gateway_loop` config flag (default off). When enabled,
    the handler routes through gateway.toolLoop() — provider-agnostic via
    the Vercel AI SDK. When disabled, the legacy Anthropic-direct path
    stays unchanged.
  - Handler-entry capability check refuses tool-unsupported / unknown
    providers loudly. With flag OFF + non-Anthropic model, refuses with a
    paste-ready hint.
  - runSubagentViaGateway() (new helper) bridges the existing ToolDef
    registry to gateway's ChatToolDef + ToolHandler shapes. Persists to
    the v0.38 stable-ID columns (ordinal + gbrain_tool_use_id) at first
    observation; settles complete/failed on tool exit.
  - D5 read-time shim (S1.6) — loadPriorToolsV2 + adaptContentBlocksToChatBlocks
    handle v1 Anthropic-shaped legacy rows alongside v2 gateway-shaped writes
    so crash-replay reconciles across the upgrade boundary.

Tests:

  - test/agent-cli.test.ts Layer 1/2/3 cases flipped from "rejects non-
    Anthropic" to "any tool-supporting provider accepted; refuses unknown
    and embedding-only providers". 4 new cases covering openai, google,
    unknown provider, embedding-only.
  - All 27 cases pass; typecheck clean.

Plan: ~/.claude/plans/system-instruction-you-are-working-shimmying-breeze.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-21 16:21:57 -07:00
co-authored by Claude Opus 4.7
parent cf91450dd8
commit 6b8d50f7d9
6 changed files with 577 additions and 89 deletions
+35 -18
View File
@@ -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);
});
});