mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-28 14:59:47 +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>
181 lines
7.0 KiB
TypeScript
181 lines
7.0 KiB
TypeScript
/**
|
|
* Pins `toModelMessages` — the gbrain ChatMessage[] → AI SDK v6 ModelMessage[]
|
|
* converter. v6 tightened ModelMessage validation: tool results must ride on a
|
|
* dedicated `role:'tool'` message with a structured `{type,value}` output part,
|
|
* not a `role:'user'` message with a bare-value tool-result block (which is how
|
|
* gbrain's toolLoop pushes them). Without this conversion every multi-turn tool
|
|
* loop — skillopt rollouts AND production subagent jobs — throws "messages do
|
|
* not match the ModelMessage[] schema" the moment the model calls a tool.
|
|
*
|
|
* Surfaced by the SkillOpt real-LLM eval (Track B). These cases pin the exact
|
|
* v6 shapes that `generateText` accepts (verified against AI SDK 6.0.174).
|
|
*/
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { toModelMessages, type ChatMessage } from '../src/core/ai/gateway.ts';
|
|
|
|
describe('toModelMessages — v6 ModelMessage shape', () => {
|
|
test('string content passes through unchanged', () => {
|
|
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
|
expect(toModelMessages(msgs)).toEqual([{ role: 'user', content: 'hello' }]);
|
|
});
|
|
|
|
test('assistant text block maps to {type:text,text}', () => {
|
|
const msgs: ChatMessage[] = [
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
|
|
];
|
|
expect(toModelMessages(msgs)).toEqual([
|
|
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
|
|
]);
|
|
});
|
|
|
|
test('assistant tool-call block keeps {toolCallId,toolName,input}', () => {
|
|
const msgs: ChatMessage[] = [
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: { query: 'x' } }],
|
|
},
|
|
];
|
|
expect(toModelMessages(msgs)).toEqual([
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: { query: 'x' } }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('tool-result on a user-role message becomes role:tool with json output', () => {
|
|
const msgs: ChatMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { hits: 0 } }],
|
|
},
|
|
];
|
|
expect(toModelMessages(msgs)).toEqual([
|
|
{
|
|
role: 'tool',
|
|
content: [
|
|
{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { type: 'json', value: { hits: 0 } } },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('string tool-result output becomes {type:text,value}', () => {
|
|
const msgs: ChatMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'echo', output: 'done' }],
|
|
},
|
|
];
|
|
expect(toModelMessages(msgs)).toEqual([
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'echo', output: { type: 'text', value: 'done' } }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('errored tool-result becomes {type:error-text,value}', () => {
|
|
const msgs: ChatMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { msg: 'boom' }, isError: true }],
|
|
},
|
|
];
|
|
expect(toModelMessages(msgs)).toEqual([
|
|
{
|
|
role: 'tool',
|
|
content: [
|
|
{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { type: 'error-text', value: '{"msg":"boom"}' } },
|
|
],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('null tool-result output is preserved as json null (not dropped)', () => {
|
|
const msgs: ChatMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'noop', output: null }],
|
|
},
|
|
];
|
|
expect(toModelMessages(msgs)).toEqual([
|
|
{
|
|
role: 'tool',
|
|
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'noop', output: { type: 'json', value: null } }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test('Date in tool-result json output serializes to ISO string (Postgres timestamptz)', () => {
|
|
// node-postgres returns timestamptz columns as JS Date; AI SDK v6's
|
|
// JSONValue schema rejects a raw Date, dead-lettering the tool loop.
|
|
const msgs: ChatMessage[] = [
|
|
{
|
|
role: 'user',
|
|
content: [{
|
|
type: 'tool-result',
|
|
toolCallId: 'c1',
|
|
toolName: 'brain_get_page',
|
|
output: { rows: [{ updated_at: new Date('2026-06-26T06:56:59.000Z'), nested: { created_at: new Date('2026-01-02T03:04:05.000Z') } }] },
|
|
}],
|
|
},
|
|
];
|
|
const out = toModelMessages(msgs) as any[];
|
|
const value = out[0].content[0].output.value;
|
|
expect(out[0].content[0].output.type).toBe('json');
|
|
expect(value.rows[0].updated_at).toBe('2026-06-26T06:56:59.000Z');
|
|
expect(value.rows[0].nested.created_at).toBe('2026-01-02T03:04:05.000Z');
|
|
// No Date instance survives (would throw in AI SDK v6).
|
|
expect(value.rows[0].updated_at instanceof Date).toBe(false);
|
|
});
|
|
|
|
test('non-string text block is dropped (reasoning-model null-text guard)', () => {
|
|
// DeepSeek v4 / reasoning models can emit text:null/undefined thinking
|
|
// parts; AI SDK v6 rejects them. Dropped here; tool-call sibling kept.
|
|
const msgs: ChatMessage[] = [
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'text', text: null as unknown as string },
|
|
{ type: 'text', text: undefined as unknown as string },
|
|
{ type: 'text', text: 'kept' },
|
|
{ type: 'text', text: '' }, // empty string is valid — kept
|
|
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
|
|
],
|
|
},
|
|
];
|
|
const out = toModelMessages(msgs) as any[];
|
|
expect(out[0].content).toEqual([
|
|
{ type: 'text', text: 'kept' },
|
|
{ type: 'text', text: '' },
|
|
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
|
|
]);
|
|
});
|
|
|
|
test('errored tool-result never throws on circular/bigint output (safeStringify)', () => {
|
|
const circular: any = {};
|
|
circular.self = circular;
|
|
const msgs: ChatMessage[] = [
|
|
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'x', output: circular, isError: true }] },
|
|
];
|
|
const out = toModelMessages(msgs) as any[];
|
|
expect(out[0].content[0].output.type).toBe('error-text');
|
|
expect(typeof out[0].content[0].output.value).toBe('string');
|
|
});
|
|
|
|
test('full multi-turn conversation: user → assistant(tool-call) → tool(result)', () => {
|
|
const msgs: ChatMessage[] = [
|
|
{ role: 'user', content: 'find widget' },
|
|
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: { query: 'widget' } }] },
|
|
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { hits: 0 } }] },
|
|
];
|
|
const out = toModelMessages(msgs);
|
|
expect(out).toHaveLength(3);
|
|
expect((out[0] as any).role).toBe('user');
|
|
expect((out[1] as any).role).toBe('assistant');
|
|
expect((out[2] as any).role).toBe('tool');
|
|
expect((out[2] as any).content[0].output).toEqual({ type: 'json', value: { hits: 0 } });
|
|
});
|
|
});
|