mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.19.0 fix(skillopt): close the last gap in the AI SDK v6 tool-loop fix (write-capture mapper + regression test) (#1809)
* fix(ai/gateway): AI SDK v6 tool loop on non-Anthropic providers (#1782, #1764) v6's asSchema() rejected the bare { jsonSchema } object and called it as a function ("schema is not a function"), killing every tool-using agent run on non-Anthropic providers and skillopt on all providers. Wrap tool inputSchema with the SDK jsonSchema() helper, and add a pure toModelMessages() boundary adapter in chat() that converts tool results to the v6 ModelMessage shape (role:'tool' + typed output:{type:'json'|'error-text',value}, isError dropped, non-JSON-safe output normalized). toolLoop stays provider-neutral and unchanged. Both skillopt tool builders (rollout.ts, write-capture.ts) switch to the shared paramDefToSchema mapper so enum/default/items survive. Tests run the produced shapes through real generateText + MockLanguageModelV3. Co-Authored-By: michaeladair44 <michaeladair44@users.noreply.github.com> Co-Authored-By: justemu <justemu@users.noreply.github.com> Co-Authored-By: JE4NVRG <JE4NVRG@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.42.19.0) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: michaeladair44 <michaeladair44@users.noreply.github.com> Co-authored-by: justemu <justemu@users.noreply.github.com> Co-authored-by: JE4NVRG <JE4NVRG@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
michaeladair44
justemu
JE4NVRG
Claude Opus 4.8
parent
bde11bb18f
commit
3d2add15d9
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { generateText, jsonSchema } from 'ai';
|
||||
import { MockLanguageModelV3 } from 'ai/test';
|
||||
import { toModelMessages, type ChatMessage } from '../../src/core/ai/gateway.ts';
|
||||
|
||||
// v0.42 AI SDK v6 fix — the regression guard that the original bug evaded.
|
||||
// Every gateway/toolLoop test stubs the chat transport, which short-circuits
|
||||
// BEFORE generateText runs, so neither asSchema() (tool schema normalization)
|
||||
// nor ModelMessage conversion was ever exercised in CI. This test drives the
|
||||
// REAL generateText with a MockLanguageModelV3 (no network/keys) so both
|
||||
// failure points are validated against the actual installed AI SDK v6.
|
||||
|
||||
function mockModel(): MockLanguageModelV3 {
|
||||
return new MockLanguageModelV3({
|
||||
doGenerate: async () => ({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
finishReason: 'stop',
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
warnings: [],
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
const internalMessages: ChatMessage[] = [
|
||||
{ role: 'user', content: 'find foo' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'foo' } }],
|
||||
},
|
||||
{
|
||||
role: 'user', // toolLoop's internal feedback role; adapter must promote to 'tool'
|
||||
content: [{ type: 'tool-result', toolCallId: 'tc1', toolName: 'search', output: { hits: 3 } }],
|
||||
},
|
||||
];
|
||||
|
||||
describe('gateway tool schema + message shape (real AI SDK v6)', () => {
|
||||
it('jsonSchema()-wrapped tools + adapted messages pass generateText without throwing', async () => {
|
||||
const model = mockModel();
|
||||
// Built exactly as gateway.chat() builds it (the primary fix).
|
||||
const tools = {
|
||||
search: {
|
||||
description: 'search the brain',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: { q: { type: 'string' } } } as any),
|
||||
},
|
||||
};
|
||||
|
||||
const result = await generateText({
|
||||
model: model as any,
|
||||
tools: tools as any,
|
||||
messages: toModelMessages(internalMessages) as any,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('ok');
|
||||
// The tool result was promoted to a role:'tool' message in the prompt the
|
||||
// model actually received — proving ModelMessage conversion accepted it.
|
||||
const prompt = model.doGenerateCalls[0]!.prompt as any[];
|
||||
expect(prompt.some((m) => m.role === 'tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('REGRESSION: the bare { jsonSchema } object (pre-fix shape) is rejected by v6', async () => {
|
||||
const model = mockModel();
|
||||
const badTools = {
|
||||
search: {
|
||||
description: 'search the brain',
|
||||
// The exact pre-v0.42 bug shape: a plain object, not a Schema.
|
||||
inputSchema: { jsonSchema: { type: 'object' } } as any,
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
generateText({
|
||||
model: model as any,
|
||||
tools: badTools as any,
|
||||
messages: toModelMessages(internalMessages) as any,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('REGRESSION: raw tool-result in a role:user message (pre-fix shape) is rejected by v6', async () => {
|
||||
const model = mockModel();
|
||||
const tools = {
|
||||
search: {
|
||||
description: 'search',
|
||||
inputSchema: jsonSchema({ type: 'object', properties: { q: { type: 'string' } } } as any),
|
||||
},
|
||||
};
|
||||
// Pre-fix: toolLoop pushed { role:'user', content:[{type:'tool-result', output: <raw> }] }.
|
||||
const preFixMessages = [
|
||||
{ role: 'user', content: 'find foo' },
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'tc1', toolName: 'search', input: { q: 'foo' } }] },
|
||||
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'tc1', toolName: 'search', output: { hits: 3 } }] },
|
||||
];
|
||||
await expect(
|
||||
generateText({ model: model as any, tools: tools as any, messages: preFixMessages as any }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { runRollout } from '../../src/core/skillopt/rollout.ts';
|
||||
import { buildWriteCaptureRegistry } from '../../src/core/skillopt/write-capture.ts';
|
||||
import type { ChatToolDef, ToolLoopOpts, ToolLoopResult } from '../../src/core/ai/gateway.ts';
|
||||
|
||||
// v0.42 AI SDK v6 fix: both skillopt tool-schema mappers (rollout.ts AND
|
||||
// write-capture.ts) previously mapped only {type,description}, silently
|
||||
// dropping enum/default/items. Both now route through the canonical
|
||||
// paramDefToSchema(). This pins that the dropped metadata survives in BOTH
|
||||
// sites (the "fix every path or one silently diverges" class).
|
||||
|
||||
function findTool(defs: ChatToolDef[], name: string): ChatToolDef {
|
||||
const t = defs.find((d) => d.name === name);
|
||||
expect(t).toBeDefined();
|
||||
return t!;
|
||||
}
|
||||
|
||||
describe('skillopt rollout tool schemas preserve ParamDef metadata', () => {
|
||||
test('rollout.ts: enum metadata survives for list_pages.sort + traverse_graph.direction', async () => {
|
||||
let captured: ChatToolDef[] = [];
|
||||
await runRollout({
|
||||
engine: {} as never,
|
||||
skillText: 'Answer the task using read-only brain tools when useful.',
|
||||
task: { task_id: 'schema-capture', task: 'noop', judge: { kind: 'rule', checks: [] } },
|
||||
targetModel: 'anthropic:claude-sonnet-4-6',
|
||||
toolLoopFn: async (opts: ToolLoopOpts): Promise<ToolLoopResult> => {
|
||||
captured = opts.tools;
|
||||
return {
|
||||
finalText: 'done',
|
||||
totalTurns: 0,
|
||||
totalUsage: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 },
|
||||
stopReason: 'end',
|
||||
messages: [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const listPages = findTool(captured, 'brain_list_pages');
|
||||
const sort = (listPages.inputSchema.properties as Record<string, any>).sort;
|
||||
expect(sort.enum).toContain('updated_desc');
|
||||
|
||||
const traverse = findTool(captured, 'brain_traverse_graph');
|
||||
const direction = (traverse.inputSchema.properties as Record<string, any>).direction;
|
||||
expect(direction.enum).toEqual(['in', 'out', 'both']);
|
||||
});
|
||||
|
||||
test('write-capture.ts: the second mapper preserves the same enum metadata', () => {
|
||||
const { defs } = buildWriteCaptureRegistry({} as never);
|
||||
|
||||
const traverse = findTool(defs, 'brain_traverse_graph');
|
||||
const direction = (traverse.inputSchema.properties as Record<string, any>).direction;
|
||||
expect(direction.enum).toEqual(['in', 'out', 'both']);
|
||||
|
||||
const listPages = findTool(defs, 'brain_list_pages');
|
||||
const sort = (listPages.inputSchema.properties as Record<string, any>).sort;
|
||||
expect(sort.enum).toContain('updated_desc');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user