fix(minions): verify the finally-resolved model at the subagent gate — the models.subagent config path bypassed capability checks (#3919)

Wave-assembled from PR #3919 by @Masashi-Ono0611.

Co-Authored-By: masashiono0611 <masashi.ono.0611@gmail.com>
This commit is contained in:
Garry Tan
2026-08-12 14:38:36 -07:00
committed by Sina Matian
co-authored by masashiono0611
parent 3fa0a5acb5
commit 94ec7e31e0
2 changed files with 200 additions and 19 deletions
+68 -19
View File
@@ -48,6 +48,7 @@ import {
logSubagentHeartbeat,
} from './subagent-audit.ts';
import { resolveModel, isAnthropicProvider, TIER_DEFAULTS } from '../../model-config.ts';
import { splitProviderModelId, normalizeModelId } from '../../model-id.ts';
import { resolveAnthropicKey } from '../../ai/anthropic-key.ts';
import { buildSystemPrompt, DEFAULT_SUBAGENT_SYSTEM } from '../system-prompt.ts';
import { toolLoop as gatewayToolLoop } from '../../ai/gateway.ts';
@@ -211,31 +212,79 @@ export function makeSubagentHandler(deps: SubagentDeps) {
// 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, {
tier: 'subagent',
configKey: 'models.subagent',
fallback: TIER_DEFAULTS.subagent,
});
// Refuse-at-handler-entry on the FINAL resolved model, not just an
// explicit data.model: `models.subagent` config resolves through
// resolveModel's explicit-key branch, which does NOT run
// enforceSubagentCapable's silent fallback (only the inherited
// models.default / tier / env branches do). An explicitly chosen
// tool-incapable model must be refused loudly here rather than silently
// run a loop that has no way to dispatch tools.
// The queue.ts gate already catches explicit data.model at submit; this
// check additionally covers config-resolved models, direct `gbrain agent
// run` invocations, and any path that bypasses the queue's check.
//
// Exception (#1151 precedent): a replayed job whose last persisted
// message is a terminal assistant turn (no tool_use blocks) needs NO
// further provider call — the path-specific replay logic below returns
// the already-committed result. Don't let a capability refusal (e.g.
// config repointed to a tool-incapable model between submit and replay)
// dead-letter completed work.
{
const lastRows = await engine.executeRaw<{ role: string; content_blocks: unknown }>(
`SELECT role, content_blocks FROM subagent_messages
WHERE job_id = $1 ORDER BY message_idx DESC LIMIT 1`,
[ctx.id],
);
const lastRow = lastRows[0];
const lastBlocks = lastRow
? (typeof lastRow.content_blocks === 'string'
? JSON.parse(lastRow.content_blocks)
: lastRow.content_blocks) as Array<{ type?: string; text?: unknown }>
: null;
// Terminal = assistant turn with NO pending tool dispatch in EITHER
// block vocabulary (legacy Anthropic `tool_use`, gateway `tool-call`)
// AND actual text (the gateway terminal rule) — a pending-tool replay
// must still pass the gate before the loop resumes on the provider.
const alreadyTerminal = lastRow?.role === 'assistant'
&& Array.isArray(lastBlocks)
&& !lastBlocks.some(b => b?.type === 'tool_use' || b?.type === 'tool-call')
&& lastBlocks.some(b => b?.type === 'text' && typeof b.text === 'string' && b.text.trim() !== '');
const modelSource = data.model ? 'data.model' : 'the resolved subagent model config';
// A BARE Anthropic id (`claude-sonnet-4-6`, no `provider:` prefix) is a
// supported value everywhere else: isAnthropicProvider() has an explicit
// bare-`claude-` branch, the legacy path strips the prefix before calling
// the Messages API, and this handler's own DEFAULT_MODEL is bare.
// classifyCapabilities() resolves through the recipe registry, which
// requires an explicit provider, so classifying the raw value would
// report `unknown` and refuse a working config. Normalize first — the
// dream cycle does the same before queueing children
// (src/core/cycle/synthesize.ts). Narrow on purpose: only bare ids that
// isAnthropicProvider() recognizes are normalized, so a bare
// non-Anthropic id still classifies as `unknown` and is still refused.
const modelForVerdict = splitProviderModelId(model).provider === null && isAnthropicProvider(model)
? normalizeModelId(model)
: model;
const verdict = alreadyTerminal ? 'ok' : classifyCapabilities(modelForVerdict);
if (verdict === 'unusable:no_tools') {
throw new Error(
`subagent job rejected: ${modelSource} "${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: ${modelSource} "${model}" references an unknown provider. ` +
`Use format provider:model where provider matches a recipe in src/core/ai/recipes/.`,
);
}
}
const maxTurns = data.max_turns ?? DEFAULT_MAX_TURNS;
// #2778: per-turn output cap — data.max_tokens → config → 8192 default.
const maxOutputTokens = resolveMaxOutputTokens(
+132
View File
@@ -709,3 +709,135 @@ describe('subagent handler output-token cap (#2778)', () => {
expect(texts.some(t => t.includes('truncated') && t.includes('DROPPED'))).toBe(true);
});
});
describe('handler-entry capability gate on the config-resolved model', () => {
test('config-resolved models.subagent lacking tool calling is refused at dispatch', async () => {
// The queue submit gate only sees explicit data.model. A job that omits
// data.model resolves `models.subagent` inside the handler — pre-fix that
// path bypassed the capability check entirely and a tool-incapable model
// (declared supports_tools: false) ran the loop anyway.
await engine.setConfig('models.subagent', 'minimax:MiniMax-M2');
try {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'should never run' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' }); // no data.model → queue gate passes
await expect(handler(ctx)).rejects.toThrow(/lacks native tool calling/);
expect(client.calls.length).toBe(0); // refused before any provider call
} finally {
await engine.unsetConfig('models.subagent');
}
});
test('already-terminal replay is NOT refused when config points at a tool-incapable model', async () => {
// #1151 precedent: a job whose last persisted message is a terminal
// assistant turn needs no provider call — the replay short-circuit
// returns the committed result. A capability refusal here (config
// repointed between submit and replay) would dead-letter completed work.
// Gateway loop ON: that's the only routing where the capability gate is
// the deciding check (the legacy path's Anthropic pin refuses
// non-Anthropic models regardless). The gateway path's terminal
// early-return runs before any provider client is constructed, so no
// API key is needed.
await engine.setConfig('models.subagent', 'minimax:MiniMax-M2');
await engine.setConfig('agent.use_gateway_loop', 'true');
try {
const client = new FakeMessagesClient([]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
// Persist a completed transcript: seed user + terminal assistant.
await engine.executeRaw(
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
VALUES ($1, 0, 'user', $2::text::jsonb), ($1, 1, 'assistant', $3::text::jsonb)`,
[
ctx.id,
JSON.stringify([{ type: 'text', text: 'hi' }]),
JSON.stringify([{ type: 'text', text: 'committed answer' }]),
],
);
const result = await handler(ctx);
expect(result.result).toBe('committed answer');
// Replay short-circuit: no new turns persisted beyond the 2 seeded rows
// (a provider call would have appended an assistant row — and would have
// failed anyway, since no gateway credentials are configured here).
const rows = await engine.executeRaw<{ count: string }>(
`SELECT count(*)::text AS count FROM subagent_messages WHERE job_id = $1`,
[ctx.id],
);
expect(parseInt(rows[0]!.count, 10)).toBe(2);
} finally {
await engine.unsetConfig('models.subagent');
await engine.unsetConfig('agent.use_gateway_loop');
}
});
test('non-terminal gateway replay (pending tool-call) IS still refused on a tool-incapable model', async () => {
// A gateway transcript persists pending dispatch as `tool-call` blocks.
// A replay that still needs the loop to resume on the provider must NOT
// slip past the capability gate via the terminal exception.
await engine.setConfig('models.subagent', 'minimax:MiniMax-M2');
await engine.setConfig('agent.use_gateway_loop', 'true');
try {
const client = new FakeMessagesClient([]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
await engine.executeRaw(
`INSERT INTO subagent_messages (job_id, message_idx, role, content_blocks)
VALUES ($1, 0, 'user', $2::text::jsonb), ($1, 1, 'assistant', $3::text::jsonb)`,
[
ctx.id,
JSON.stringify([{ type: 'text', text: 'hi' }]),
JSON.stringify([{ type: 'tool-call', toolCallId: 'tc_1', toolName: 'echo', input: {} }]),
],
);
await expect(handler(ctx)).rejects.toThrow(/lacks native tool calling/);
} finally {
await engine.unsetConfig('models.subagent');
await engine.unsetConfig('agent.use_gateway_loop');
}
});
test('a BARE Anthropic id in models.subagent still runs (not refused as unknown provider)', async () => {
// A bare `claude-*` id (no `provider:` prefix) is a supported config value
// everywhere else: isAnthropicProvider() has an explicit bare-`claude-`
// branch and the legacy path strips the prefix before calling the Messages
// API. classifyCapabilities() resolves through the recipe registry, which
// requires an explicit provider, so classifying the raw value would report
// `unknown` and this gate would refuse a config that works.
await engine.setConfig('models.subagent', 'claude-sonnet-4-6');
try {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'ran on the bare id' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' }); // no data.model → config resolves
const result = await handler(ctx);
expect(result.result).toBe('ran on the bare id');
// The provider call carries the bare id (the legacy path strips any prefix).
expect(client.calls.length).toBe(1);
expect(client.calls[0]!.model).toBe('claude-sonnet-4-6');
} finally {
await engine.unsetConfig('models.subagent');
}
});
test('a bare NON-Anthropic id in models.subagent is still refused', async () => {
// The normalization above is deliberately narrow: only bare ids that
// isAnthropicProvider() recognizes get an `anthropic:` prefix for the
// verdict. A bare `gpt-5` is not a recipe-resolvable model, so the gate
// must keep reporting `unknown` rather than classifying it as Anthropic.
await engine.setConfig('models.subagent', 'gpt-5');
try {
const client = new FakeMessagesClient([
{ content: [{ type: 'text', text: 'should never run' }] as any, stop_reason: 'end_turn' },
]);
const handler = makeSubagentHandler({ engine, client, toolRegistry: [] });
const ctx = await makeCtx({ prompt: 'hi' });
await expect(handler(ctx)).rejects.toThrow(/references an unknown provider/);
expect(client.calls.length).toBe(0);
} finally {
await engine.unsetConfig('models.subagent');
}
});
});