mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-29 17:20:29 +00:00
+5








86962b242b
Collector branch superseding the gateway tool-loop duplicate cluster and adjacent provider fixes. Re-implemented from the best of each PR (deduped by content, not file-overlap); every fix carries test coverage. Gateway tool-loop resume (supersedes #1934 #2062 #2065 #2112 #2274 #2487 #2336 #2257 #2499 #2491, test #2063): - toolLoop now persists the tool-result user turn per round (onToolResultTurn), so a resumed subagent job reloads a balanced transcript instead of dangling assistant tool-calls that non-Anthropic providers reject with AI_MissingToolResultsError. - runSubagentViaGateway reconciles an already-corrupted transcript on resume: it heals every dangling assistant tool-call turn (not just the tail) from the settled subagent_tool_executions rows, re-dispatching idempotent-pending tools and throwing on non-idempotent, mirroring the legacy Anthropic path. Terminal early-return for a transcript that already reached end_turn. - repairToolPairing() is a last-resort normalization at the chat() boundary (from #2336): back-fills error stubs for any assistant tool-call still unanswered (partial turns, provider-duplicated/dropped IDs on local models, length-truncated batches). No-op on balanced input. - toModelMessages is Date-safe (Postgres timestamptz -> ISO via a JSON round trip at the SDK boundary, never a ::jsonb cast; degrades bigint/circular to a string instead of throwing) and drops non-string text blocks reasoning models emit that AI SDK v6 rejects (#2488). Adjacent provider fixes: - Model-aware default max output tokens: thinking-by-default Claude 5 models get headroom (gateway 32000, think 16000) while everything else stays 4096/4000, so DeepSeek/OpenAI subagents don't exceed provider caps (#2614 #2806). - DeepSeek: promote reasoning_content into content when content is empty, via a fail-open recipe fetch shim (#2617). - OpenRouter: map openrouter_api_key (config + env) into OPENROUTER_API_KEY through buildGatewayConfig; register agent.use_gateway_loop, zeroentropy and openrouter keys in KNOWN_CONFIG_KEYS so `config set` accepts them (#2572, config key from #2112). Preserves JSONB (no JSON.stringify into ::jsonb), engine parity, source isolation, and trust-boundary invariants. Verified with the gbrain-pr-test-env consumer matrix (clawlancer/Postgres, gstack + hivemindos/PGLite) baseline-FAIL -> candidate-PASS on the same repro, plus bun run verify (31/31) and 439 targeted unit + e2e tests. Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local> Co-authored-by: thomaskong119 <thomaskong119@hotmail.com> Co-authored-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com> Co-authored-by: brettdavies <brettdavies@users.noreply.github.com> Co-authored-by: ivandebot <187176982+ivandebot@users.noreply.github.com> Co-authored-by: Rafael Reis <rafael.reis@contabilizei.com.br> Co-authored-by: fbal23 <fbal.public@gmail.com> Co-authored-by: javieraldape <javieraldape@users.noreply.github.com> Co-authored-by: David Carolan <david@joyrestart.com> Co-authored-by: Masashi-Ono0611 <masashi.ono.0611@gmail.com> Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com> Co-authored-by: psam-717 <mphilannorbah@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
toolLoop,
|
|
__setChatTransportForTests,
|
|
configureGateway,
|
|
resetGateway,
|
|
type ChatBlock,
|
|
type ToolHandler,
|
|
} from '../../src/core/ai/gateway.ts';
|
|
|
|
describe('gateway.toolLoop (v0.38 D11 — provider-agnostic loop control)', () => {
|
|
beforeEach(() => {
|
|
configureGateway({
|
|
chat_model: 'anthropic:claude-sonnet-4-6',
|
|
embedding_model: 'openai:text-embedding-3-large',
|
|
embedding_dimensions: 1536,
|
|
expansion_model: 'anthropic:claude-haiku-4-5',
|
|
env: { ANTHROPIC_API_KEY: 'stub', OPENAI_API_KEY: 'stub' },
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
__setChatTransportForTests(null);
|
|
resetGateway();
|
|
});
|
|
|
|
it('exits cleanly on end stop_reason with no tools', async () => {
|
|
__setChatTransportForTests(async () => ({
|
|
text: 'hello world',
|
|
blocks: [{ type: 'text', text: 'hello world' }] as ChatBlock[],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 5, output_tokens: 2, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
}));
|
|
|
|
const result = await toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'hi' }],
|
|
tools: [],
|
|
toolHandlers: new Map(),
|
|
});
|
|
|
|
expect(result.stopReason).toBe('end');
|
|
expect(result.finalText).toBe('hello world');
|
|
expect(result.totalTurns).toBe(0); // First turn ended cleanly without tool dispatch
|
|
expect(result.totalUsage.input_tokens).toBe(5);
|
|
});
|
|
|
|
it('dispatches a single tool call and feeds the result back to the next turn', async () => {
|
|
let turn = 0;
|
|
__setChatTransportForTests(async () => {
|
|
turn++;
|
|
if (turn === 1) {
|
|
return {
|
|
text: '',
|
|
blocks: [
|
|
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'foo' } },
|
|
] as ChatBlock[],
|
|
stopReason: 'tool_calls',
|
|
usage: { input_tokens: 10, output_tokens: 4, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
}
|
|
return {
|
|
text: 'final answer',
|
|
blocks: [{ type: 'text', text: 'final answer' }] as ChatBlock[],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 15, output_tokens: 5, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
});
|
|
|
|
let toolWasCalled = false;
|
|
const handler: ToolHandler = {
|
|
idempotent: true,
|
|
async execute(input) {
|
|
toolWasCalled = true;
|
|
expect(input).toEqual({ q: 'foo' });
|
|
return { ok: true, results: [{ slug: 'foo/bar' }] };
|
|
},
|
|
};
|
|
|
|
const result = await toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'find foo' }],
|
|
tools: [{ name: 'search', description: 'search the brain', inputSchema: { type: 'object' } }],
|
|
toolHandlers: new Map([['search', handler]]),
|
|
});
|
|
|
|
expect(toolWasCalled).toBe(true);
|
|
expect(result.stopReason).toBe('end');
|
|
expect(result.finalText).toBe('final answer');
|
|
expect(result.totalUsage.input_tokens).toBe(25); // 10 + 15
|
|
expect(result.totalUsage.output_tokens).toBe(9); // 4 + 5
|
|
});
|
|
|
|
it('captures persistence callbacks in order: assistant → tool start → tool complete', async () => {
|
|
let turn = 0;
|
|
__setChatTransportForTests(async () => {
|
|
turn++;
|
|
if (turn === 1) {
|
|
return {
|
|
text: '',
|
|
blocks: [
|
|
{ type: 'tool-call', toolCallId: 'tc1', toolName: 'echo', input: { msg: 'hi' } },
|
|
] as ChatBlock[],
|
|
stopReason: 'tool_calls',
|
|
usage: { input_tokens: 5, output_tokens: 3, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
}
|
|
return {
|
|
text: 'done',
|
|
blocks: [{ type: 'text', text: 'done' }] as ChatBlock[],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 5, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
});
|
|
|
|
const events: string[] = [];
|
|
|
|
await toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'echo hi' }],
|
|
tools: [{ name: 'echo', description: 'echo', inputSchema: { type: 'object' } }],
|
|
toolHandlers: new Map([['echo', {
|
|
idempotent: true,
|
|
async execute(input) { events.push(`execute(${JSON.stringify(input)})`); return input; },
|
|
}]]),
|
|
onAssistantTurn: async (turnIdx, _msgIdx, _blocks, _usage, _model) => {
|
|
events.push(`onAssistantTurn(${turnIdx})`);
|
|
},
|
|
onToolCallStart: async (turnIdx, _msgIdx, ordinal, toolName, _input, providerToolCallId) => {
|
|
events.push(`onToolCallStart(turn=${turnIdx}, ordinal=${ordinal}, name=${toolName}, providerCallId=${providerToolCallId})`);
|
|
return { gbrainToolUseId: `gb-${turnIdx}-${ordinal}` };
|
|
},
|
|
onToolCallComplete: async (gbrainToolUseId, _output) => {
|
|
events.push(`onToolCallComplete(${gbrainToolUseId})`);
|
|
},
|
|
});
|
|
|
|
// Write-ordering invariant: assistant persisted BEFORE pending tool row;
|
|
// pending row persisted BEFORE execute; execute BEFORE complete.
|
|
expect(events[0]).toBe('onAssistantTurn(0)');
|
|
expect(events[1]).toMatch(/onToolCallStart\(turn=0, ordinal=0, name=echo/);
|
|
expect(events[2]).toMatch(/execute/);
|
|
expect(events[3]).toMatch(/onToolCallComplete\(gb-0-0\)/);
|
|
expect(events[4]).toBe('onAssistantTurn(1)'); // final assistant turn
|
|
});
|
|
|
|
it('persists the tool-result user turn via onToolResultTurn before the next chat', async () => {
|
|
let turn = 0;
|
|
__setChatTransportForTests(async () => {
|
|
turn++;
|
|
if (turn === 1) {
|
|
return {
|
|
text: '',
|
|
blocks: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'x' } }] as ChatBlock[],
|
|
stopReason: 'tool_calls',
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
}
|
|
return {
|
|
text: 'done',
|
|
blocks: [{ type: 'text', text: 'done' }] as ChatBlock[],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
});
|
|
|
|
const resultTurns: Array<{ turnIdx: number; messageIdx: number; blocks: ChatBlock[] }> = [];
|
|
await toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'go' }],
|
|
tools: [{ name: 'search', description: 's', inputSchema: { type: 'object' } }],
|
|
toolHandlers: new Map([['search', { idempotent: true, async execute() { return { hits: 1 }; } }]]),
|
|
onToolResultTurn: async (turnIdx, messageIdx, blocks) => {
|
|
resultTurns.push({ turnIdx, messageIdx, blocks });
|
|
},
|
|
});
|
|
|
|
// Fired exactly once, for the single tool round, carrying the tool-result.
|
|
expect(resultTurns).toHaveLength(1);
|
|
expect(resultTurns[0].turnIdx).toBe(0);
|
|
expect(resultTurns[0].blocks[0].type).toBe('tool-result');
|
|
expect((resultTurns[0].blocks[0] as Extract<ChatBlock, { type: 'tool-result' }>).toolCallId).toBe('tc1');
|
|
});
|
|
|
|
it('replay short-circuits a complete prior tool execution', async () => {
|
|
let chatCalls = 0;
|
|
__setChatTransportForTests(async () => {
|
|
chatCalls++;
|
|
// Turn 1 emits a tool call. Turn 2 finishes.
|
|
if (chatCalls === 1) {
|
|
return {
|
|
text: '',
|
|
blocks: [
|
|
{ type: 'tool-call', toolCallId: 'provider-id-1', toolName: 'work', input: { x: 1 } },
|
|
] as ChatBlock[],
|
|
stopReason: 'tool_calls',
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
}
|
|
return {
|
|
text: 'fin',
|
|
blocks: [{ type: 'text', text: 'fin' }] as ChatBlock[],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
});
|
|
|
|
let executed = false;
|
|
const result = await toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'go' }],
|
|
tools: [{ name: 'work', description: 'w', inputSchema: { type: 'object' } }],
|
|
toolHandlers: new Map([['work', {
|
|
idempotent: false,
|
|
async execute() { executed = true; return 'fresh'; },
|
|
}]]),
|
|
onToolCallStart: async () => ({ gbrainToolUseId: 'gb-replay-key' }),
|
|
replayState: {
|
|
priorMessages: [],
|
|
priorTools: new Map([['gb-replay-key', {
|
|
status: 'complete' as const,
|
|
output: 'from-prior-run',
|
|
}]]),
|
|
nextTurnIdx: 0,
|
|
nextMessageIdx: 0,
|
|
},
|
|
});
|
|
|
|
expect(executed).toBe(false); // replay short-circuit
|
|
expect(result.stopReason).toBe('end');
|
|
expect(result.finalText).toBe('fin');
|
|
});
|
|
|
|
it('refuses replay of non-idempotent pending tool with unrecoverable error', async () => {
|
|
__setChatTransportForTests(async () => ({
|
|
text: '',
|
|
blocks: [
|
|
{ type: 'tool-call', toolCallId: 'tc-non-idem', toolName: 'mutate', input: {} },
|
|
] as ChatBlock[],
|
|
stopReason: 'tool_calls',
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
}));
|
|
|
|
await expect(
|
|
toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'go' }],
|
|
tools: [{ name: 'mutate', description: 'm', inputSchema: { type: 'object' } }],
|
|
toolHandlers: new Map([['mutate', { idempotent: false, async execute() { return null; } }]]),
|
|
onToolCallStart: async () => ({ gbrainToolUseId: 'gb-pending-key' }),
|
|
replayState: {
|
|
priorMessages: [],
|
|
priorTools: new Map([['gb-pending-key', { status: 'pending' as const }]]),
|
|
nextTurnIdx: 0,
|
|
nextMessageIdx: 0,
|
|
},
|
|
}),
|
|
).rejects.toThrow(/non-idempotent.*pending/i);
|
|
});
|
|
|
|
it('defaults max output tokens per model: 4096 for non-thinking, 32000 for Claude 5', async () => {
|
|
const seen: Array<number | undefined> = [];
|
|
__setChatTransportForTests(async (opts) => {
|
|
seen.push(opts.maxTokens);
|
|
return {
|
|
text: 'ok',
|
|
blocks: [{ type: 'text', text: 'ok' }] as ChatBlock[],
|
|
stopReason: 'end',
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: opts.model ?? 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
};
|
|
});
|
|
|
|
await toolLoop({ model: 'openai:gpt-4o', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
|
await toolLoop({ model: 'anthropic:claude-sonnet-4-6', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
|
await toolLoop({ model: 'anthropic:claude-sonnet-5', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
|
await toolLoop({ model: 'anthropic:claude-fable-5', initialMessages: [{ role: 'user', content: 'hi' }], tools: [], toolHandlers: new Map() });
|
|
|
|
// Non-thinking / non-Claude-5 stay 4096 (safe under openai-compat caps);
|
|
// thinking-by-default Claude 5 models get 32000 headroom.
|
|
expect(seen).toEqual([4096, 4096, 32000, 32000]);
|
|
});
|
|
|
|
it('hits max_turns when the model keeps calling tools', async () => {
|
|
__setChatTransportForTests(async () => ({
|
|
text: '',
|
|
blocks: [
|
|
{ type: 'tool-call', toolCallId: `tc-${Math.random()}`, toolName: 'loop', input: {} },
|
|
] as ChatBlock[],
|
|
stopReason: 'tool_calls',
|
|
usage: { input_tokens: 1, output_tokens: 1, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
}));
|
|
|
|
const result = await toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'loop' }],
|
|
tools: [{ name: 'loop', description: 'l', inputSchema: { type: 'object' } }],
|
|
toolHandlers: new Map([['loop', { idempotent: true, async execute() { return null; } }]]),
|
|
maxTurns: 3,
|
|
});
|
|
|
|
expect(result.stopReason).toBe('max_turns');
|
|
expect(result.totalTurns).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('returns refusal reason without dispatching tools when stopReason=refusal', async () => {
|
|
__setChatTransportForTests(async () => ({
|
|
text: 'I cannot help with that',
|
|
blocks: [{ type: 'text', text: 'I cannot help with that' }] as ChatBlock[],
|
|
stopReason: 'refusal',
|
|
usage: { input_tokens: 1, output_tokens: 5, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
|
model: 'anthropic:claude-sonnet-4-6',
|
|
providerId: 'anthropic',
|
|
}));
|
|
|
|
let toolWasCalled = false;
|
|
const result = await toolLoop({
|
|
initialMessages: [{ role: 'user', content: 'bad request' }],
|
|
tools: [{ name: 'work', description: 'w', inputSchema: { type: 'object' } }],
|
|
toolHandlers: new Map([['work', { idempotent: true, async execute() { toolWasCalled = true; return null; } }]]),
|
|
});
|
|
|
|
expect(toolWasCalled).toBe(false);
|
|
expect(result.stopReason).toBe('refusal');
|
|
expect(result.finalText).toBe('I cannot help with that');
|
|
});
|
|
});
|