From 5c63f17cff0afd1bb18784828ac7854a3f629a16 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 20:36:38 +0100 Subject: [PATCH] feat(ai): structured tool calls on ChatMessage (contract request 3) ChatMessage gains toolCalls and an opaque providerData; providers may yield providerData once per turn. The assistant loop appends one assistant message per model turn. Anthropic replays its own raw blocks from providerData (no in-memory map) or maps text + tool_use blocks; OpenAI maps toolCalls to tool_calls. Co-Authored-By: Claude Opus 5.5 --- packages/contracts/src/ai.ts | 20 ++++++++- packages/contracts/test/types.test.ts | 8 ++-- packages/feature-ai/src/assistant/loop.ts | 15 ++++--- .../feature-ai/src/providers/anthropic.ts | 35 +++++++--------- packages/feature-ai/src/providers/openai.ts | 8 ++-- packages/feature-ai/test/anthropic.test.ts | 42 +++++++++++-------- packages/feature-ai/test/assistant.test.ts | 20 ++++++++- packages/feature-ai/test/helpers.ts | 13 +++--- packages/feature-ai/test/openai.test.ts | 24 ++++++++++- 9 files changed, 126 insertions(+), 59 deletions(-) diff --git a/packages/contracts/src/ai.ts b/packages/contracts/src/ai.ts index e0815c1..44c591c 100644 --- a/packages/contracts/src/ai.ts +++ b/packages/contracts/src/ai.ts @@ -1,3 +1,19 @@ -export interface ChatMessage { role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string; name?: string } +/** One tool call the model asked for in an assistant turn. */ +export interface ToolCall { id: string; name: string; input: unknown } +/** + * A chat message. An assistant turn that calls tools carries them in `toolCalls` (v1.1, contract request 3), with + * its text in `content`; each result follows as a `tool` message with `toolCallId` (and `name`). + * `providerData` is opaque: the provider that produced the turn yields it and gets it back unchanged (for example + * Anthropic's raw content blocks with thinking signatures). Other providers ignore it. + */ +export interface ChatMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string; + toolCallId?: string; + name?: string; + toolCalls?: ToolCall[]; + providerData?: unknown; +} export interface ToolSpec { name: string; description: string; inputSchema: Record } -export interface AiProvider { id: string; chat(messages: ChatMessage[], opts: { tools?: ToolSpec[]; signal?: AbortSignal }): AsyncIterable<{ text?: string; toolCall?: { id: string; name: string; input: unknown } }> } +/** `chat` streams text and tool calls, and may yield `providerData` once per turn for the assistant message. */ +export interface AiProvider { id: string; chat(messages: ChatMessage[], opts: { tools?: ToolSpec[]; signal?: AbortSignal }): AsyncIterable<{ text?: string; toolCall?: ToolCall; providerData?: unknown }> } diff --git a/packages/contracts/test/types.test.ts b/packages/contracts/test/types.test.ts index a49a1fa..88fc793 100644 --- a/packages/contracts/test/types.test.ts +++ b/packages/contracts/test/types.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import type { - AiProvider, AppConfig, AuthService, AuthUser, Assert, ChatMessage, ExerciseRunner, ExerciseTest, + AiProvider, AppConfig, AuthService, AuthUser, Assert, ChatMessage, ToolCall, ExerciseRunner, ExerciseTest, Feature, ImportResult, ImportSource, JobContext, JobHandler, JobQueue, JobRecord, RequestContext, RouteHandler, ServerContext, TestResult, ToolSpec, WebFeature, WebRoute, } from "../src/index"; @@ -38,10 +38,12 @@ test("contract interfaces accept well-formed literals", async () => { run: async () => [{ name: "t", passed: true } satisfies TestResult], }; const msg: ChatMessage = { role: "user", content: "hi" }; + const call: ToolCall = { id: "1", name: "x", input: {} }; + const turn: ChatMessage = { role: "assistant", content: "", toolCalls: [call], providerData: { any: "thing" } }; const tool: ToolSpec = { name: "x", description: "d", inputSchema: {} }; const provider: AiProvider = { id: "p", - async *chat() { yield { text: "hi" }; yield { toolCall: { id: "1", name: "x", input: {} } }; }, + async *chat() { yield { text: "hi" }; yield { toolCall: { id: "1", name: "x", input: {} } }; yield { providerData: [] }; }, }; const source: ImportSource = { kind: "pdf-deck", path: "/tmp/a.pdf", options: {} }; const result: ImportResult = { courseId: "c", manifestPath: "/tmp/stcourse.yaml", warnings: [] }; @@ -53,5 +55,5 @@ test("contract interfaces accept well-formed literals", async () => { expect(typeof feature.routes).toBe("object"); expect(await queue.enqueue("noop", {})).toBe("j1"); expect((await runner.run("", "", 1000))[0]!.passed).toBe(true); - void [rc, jc, ctx, assert, t, msg, tool, provider, source, result, web]; + void [rc, jc, ctx, assert, t, msg, turn, tool, provider, source, result, web]; }); diff --git a/packages/feature-ai/src/assistant/loop.ts b/packages/feature-ai/src/assistant/loop.ts index 0a3a0e0..3683d5c 100644 --- a/packages/feature-ai/src/assistant/loop.ts +++ b/packages/feature-ai/src/assistant/loop.ts @@ -1,4 +1,4 @@ -import type { AiProvider, ChatMessage } from "@stc/contracts"; +import type { AiProvider, ChatMessage, ToolCall } from "@stc/contracts"; import type { ToolSet } from "./tools"; export type AssistantEvent = @@ -9,7 +9,8 @@ export type AssistantEvent = /** * Runs the model with tools until it answers without calling a tool, or until maxSteps model calls. - * Mutates `messages` by appending the tool round trips, so callers can inspect them. + * Mutates `messages` by appending the tool round trips (an assistant message with `toolCalls`, then one `tool` + * message per call), so callers can inspect them. */ export async function* runAssistant(o: { provider: AiProvider; @@ -22,11 +23,13 @@ export async function* runAssistant(o: { let answer = ""; for (let step = 0; step < maxSteps; step++) { let text = ""; - const calls: { id: string; name: string; input: unknown }[] = []; + const calls: ToolCall[] = []; + let providerData: unknown; try { for await (const ev of o.provider.chat(o.messages, { tools: o.tools.specs, signal: o.signal })) { if (ev.text) { text += ev.text; yield { type: "text", text: ev.text }; } if (ev.toolCall) calls.push(ev.toolCall); + if (ev.providerData !== undefined) providerData = ev.providerData; } } catch (e) { yield { type: "error", message: (e as Error).message }; @@ -37,8 +40,10 @@ export async function* runAssistant(o: { yield { type: "done", text: answer }; return; } - if (text) o.messages.push({ role: "assistant", content: text }); - for (const c of calls) o.messages.push({ role: "assistant", content: JSON.stringify(c.input ?? {}), toolCallId: c.id, name: c.name }); + // One assistant message per model turn: its text, every tool call, and the provider's opaque data. + const turn: ChatMessage = { role: "assistant", content: text, toolCalls: calls }; + if (providerData !== undefined) turn.providerData = providerData; + o.messages.push(turn); for (const c of calls) { yield { type: "tool", name: c.name }; let result: string; diff --git a/packages/feature-ai/src/providers/anthropic.ts b/packages/feature-ai/src/providers/anthropic.ts index 1576bf3..724f583 100644 --- a/packages/feature-ai/src/providers/anthropic.ts +++ b/packages/feature-ai/src/providers/anthropic.ts @@ -17,19 +17,24 @@ export interface AnthropicOptions { type Block = Record & { type: string }; type ApiMessage = { role: "user" | "assistant"; content: Block[] }; +/** This provider's own `providerData` (`{ anthropic: Block[] }`); anything else is ignored. */ +const rawBlocks = (data: unknown): Block[] | null => { + const blocks = data && typeof data === "object" ? (data as { anthropic?: unknown }).anthropic : undefined; + return Array.isArray(blocks) && blocks.length && blocks.every((b) => b && typeof b === "object" && typeof (b as Block).type === "string") ? (blocks as Block[]) : null; +}; + /** * Anthropic Messages API over plain fetch with streaming and tool use. * - Thinking is left to the model default (adaptive on current models); no temperature or forced tool_choice. - * - The frozen ChatMessage contract has no slot for thinking blocks, so the provider remembers every - * response's raw content blocks by tool_use id and replays them unchanged when the caller sends back - * the matching assistant tool-call message (required for thinking + tool use). + * - Each turn ends with `providerData = { anthropic: }` (thinking with signature, text, + * tool_use). An assistant message carrying it is replayed verbatim, as thinking + tool use requires; one + * without it maps to text plus tool_use blocks from `toolCalls`. The provider keeps no conversation state. * - On the first-party endpoint, server-side refusal fallbacks are on ("default" routing). */ export function createAnthropicProvider(o: AnthropicOptions): AiProvider { const doFetch = o.fetch ?? fetch; const base = (o.baseUrl || FIRST_PARTY).replace(/\/+$/, ""); const firstParty = base === FIRST_PARTY; - const rawByToolId = new Map(); const toApi = (messages: ChatMessage[]): { system: string; messages: ApiMessage[] } => { const system = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n"); @@ -42,18 +47,12 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider { for (const m of messages) { if (m.role === "system") continue; if (m.role === "tool") push("user", { type: "tool_result", tool_use_id: m.toolCallId ?? "", content: m.content }); - else if (m.role === "assistant" && m.toolCallId) { - let input: unknown = {}; - try { input = JSON.parse(m.content || "{}"); } catch { input = { value: m.content }; } - push("assistant", { type: "tool_use", id: m.toolCallId, name: m.name ?? "", input }); - } else if (m.content) push(m.role === "assistant" ? "assistant" : "user", { type: "text", text: m.content }); - } - // Replace assistant turns we produced with their exact original blocks (thinking signatures intact). - for (const msg of out) { - if (msg.role !== "assistant") continue; - const id = msg.content.find((b) => b.type === "tool_use")?.id as string | undefined; - const raw = id ? rawByToolId.get(id) : undefined; - if (raw) msg.content = structuredClone(raw); + else if (m.role === "assistant") { + const raw = rawBlocks(m.providerData); + if (raw) { for (const b of structuredClone(raw)) push("assistant", b); continue; } + if (m.content) push("assistant", { type: "text", text: m.content }); + for (const c of m.toolCalls ?? []) push("assistant", { type: "tool_use", id: c.id, name: c.name, input: c.input ?? {} }); + } else if (m.content) push("user", { type: "text", text: m.content }); } return { system, messages: out }; }; @@ -85,7 +84,6 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider { const blocks: Block[] = []; const partialJson = new Map(); - const toolIds: string[] = []; let stopReason: string | null = null; for await (const { data } of readSse(res.body)) { let ev: any; @@ -117,7 +115,6 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider { let input: unknown = {}; try { input = json ? JSON.parse(json) : (b.input ?? {}); } catch { input = { _invalidJson: json }; } b.input = input; - toolIds.push(b.id as string); yield { toolCall: { id: b.id as string, name: b.name as string, input } }; } break; @@ -130,7 +127,7 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider { } } const content = blocks.filter(Boolean); - for (const id of toolIds) rawByToolId.set(id, content); + if (content.length) yield { providerData: { anthropic: content } }; if (stopReason === "refusal" && !content.some((b) => b.type === "text" && b.text)) { yield { text: "The model declined to answer this request." }; } diff --git a/packages/feature-ai/src/providers/openai.ts b/packages/feature-ai/src/providers/openai.ts index f1c477d..8af5dbd 100644 --- a/packages/feature-ai/src/providers/openai.ts +++ b/packages/feature-ai/src/providers/openai.ts @@ -11,11 +11,9 @@ type OaMessage = function toOpenAi(messages: ChatMessage[]): OaMessage[] { const out: OaMessage[] = []; for (const m of messages) { - if (m.role === "assistant" && m.toolCallId) { - const call = { id: m.toolCallId, type: "function" as const, function: { name: m.name ?? "", arguments: m.content || "{}" } }; - const last = out[out.length - 1]; - if (last && last.role === "assistant") (last.tool_calls ??= []).push(call); - else out.push({ role: "assistant", content: null, tool_calls: [call] }); + if (m.role === "assistant" && m.toolCalls?.length) { + const tool_calls = m.toolCalls.map((c) => ({ id: c.id, type: "function" as const, function: { name: c.name, arguments: JSON.stringify(c.input ?? {}) } })); + out.push({ role: "assistant", content: m.content || null, tool_calls }); } else if (m.role === "tool") out.push({ role: "tool", tool_call_id: m.toolCallId ?? "", content: m.content }); else out.push({ role: m.role, content: m.content } as OaMessage); } diff --git a/packages/feature-ai/test/anthropic.test.ts b/packages/feature-ai/test/anthropic.test.ts index 7477b07..a962c38 100644 --- a/packages/feature-ai/test/anthropic.test.ts +++ b/packages/feature-ai/test/anthropic.test.ts @@ -56,40 +56,48 @@ test("streams text and sends a well-formed request", async () => { expect(h.get("anthropic-beta")).toBe("server-side-fallback-2026-07-01"); }); -test("assembles tool input and replays thinking blocks unchanged on the next call", async () => { +test("assembles tool input and replays thinking blocks unchanged from providerData", async () => { const ff = fakeFetch([ok(toolTurn), ok(textTurn)]); - const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }); const msgs: ChatMessage[] = [{ role: "user", content: "How do I add?" }]; - const first = await collect(p.chat(msgs, {})); + const first = await collect(createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }).chat(msgs, {})); expect(first.text).toBe("Let me look."); expect(first.toolCalls).toEqual([{ id: "toolu_1", name: "search_course", input: { query: "add" } }]); + const raw = [ + { type: "thinking", thinking: "", signature: "SIG-abc" }, + { type: "text", text: "Let me look." }, + { type: "tool_use", id: "toolu_1", name: "search_course", input: { query: "add" } }, + ]; + expect(first.providerData).toEqual({ anthropic: raw }); - msgs.push({ role: "assistant", content: "Let me look." }); - msgs.push({ role: "assistant", content: JSON.stringify({ query: "add" }), toolCallId: "toolu_1", name: "search_course" }); + msgs.push({ role: "assistant", content: first.text, toolCalls: first.toolCalls, providerData: first.providerData }); msgs.push({ role: "tool", content: "lessons/04-exercise.md: ...", toolCallId: "toolu_1", name: "search_course" }); - await collect(p.chat(msgs, {})); + // A new provider instance: the replay comes from the message, not from memory. + await collect(createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }).chat(msgs, {})); const sent = ff.calls[1]!.body.messages; expect(sent).toHaveLength(3); - expect(sent[1]).toEqual({ - role: "assistant", - content: [ - { type: "thinking", thinking: "", signature: "SIG-abc" }, - { type: "text", text: "Let me look." }, - { type: "tool_use", id: "toolu_1", name: "search_course", input: { query: "add" } }, - ], - }); + expect(sent[1]).toEqual({ role: "assistant", content: raw }); expect(sent[2]).toEqual({ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "lessons/04-exercise.md: ..." }] }); }); -test("tool calls it did not produce are mapped from the message fields", async () => { +test("tool calls without Anthropic providerData are mapped from the message fields", async () => { const ff = fakeFetch([ok(textTurn)]); const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }); await collect(p.chat([ { role: "user", content: "q" }, - { role: "assistant", content: "{\"x\":1}", toolCallId: "t9", name: "list_notes" }, + { role: "assistant", content: "Checking.", toolCalls: [{ id: "t9", name: "list_notes", input: { x: 1 } }, { id: "t10", name: "my_progress", input: {} }], providerData: { other: true } }, { role: "tool", content: "none", toolCallId: "t9" }, + { role: "tool", content: "50%", toolCallId: "t10" }, ], {})); - expect(ff.calls[0]!.body.messages[1]).toEqual({ role: "assistant", content: [{ type: "tool_use", id: "t9", name: "list_notes", input: { x: 1 } }] }); + const sent = ff.calls[0]!.body.messages; + expect(sent[1]).toEqual({ role: "assistant", content: [ + { type: "text", text: "Checking." }, + { type: "tool_use", id: "t9", name: "list_notes", input: { x: 1 } }, + { type: "tool_use", id: "t10", name: "my_progress", input: {} }, + ] }); + expect(sent[2]).toEqual({ role: "user", content: [ + { type: "tool_result", tool_use_id: "t9", content: "none" }, + { type: "tool_result", tool_use_id: "t10", content: "50%" }, + ] }); }); test("errors carry the API message", async () => { diff --git a/packages/feature-ai/test/assistant.test.ts b/packages/feature-ai/test/assistant.test.ts index c4e6307..ef91ed1 100644 --- a/packages/feature-ai/test/assistant.test.ts +++ b/packages/feature-ai/test/assistant.test.ts @@ -69,7 +69,7 @@ test("the loop runs tools and feeds results back until the model answers", async const done = events.find((e) => e.type === "done") as { type: "done"; text: string }; expect(done.text).toBe("Searching.Found it in lessons/04-exercise.md. (1 tool result)"); const second = seen[1]!.messages; - expect(second.at(-2)).toMatchObject({ role: "assistant", toolCallId: "c1", name: "search_course" }); + expect(second.at(-2)).toEqual({ role: "assistant", content: "Searching.", toolCalls: [{ id: "c1", name: "search_course", input: { query: "add arguments" } }] }); expect(second.at(-1)!.role).toBe("tool"); expect(second.at(-1)!.content).toContain("lessons/04-exercise.md"); expect(seen[0]!.tools!.map((x) => x.name)).toContain("search_course"); @@ -169,3 +169,21 @@ test("read_file refuses solutions however the path is written, including through rmSync(dir, { recursive: true, force: true }); } }); + +test("one assistant message per model turn carries every tool call and the provider's data", async () => { + const t = courseTools({ repo, course, index, listNotes: async () => [] }); + const calls = [ + { id: "c1", name: "search_course", input: { query: "add" } }, + { id: "c2", name: "list_notes", input: {} }, + ]; + const { provider, seen } = fakeProvider([ + () => ({ toolCalls: calls, providerData: { fake: ["opaque", 1] } }), + () => ({ text: "Done." }), + ]); + const messages: ChatMessage[] = [{ role: "user", content: "q" }]; + for await (const _ of runAssistant({ provider, messages, tools: combineTools([t]) })) { /* drain */ } + const sent = seen[1]!.messages; + expect(sent.slice(1).map((m) => [m.role, m.toolCallId ?? null])).toEqual([["assistant", null], ["tool", "c1"], ["tool", "c2"]]); + expect(sent[1]).toEqual({ role: "assistant", content: "", toolCalls: calls, providerData: { fake: ["opaque", 1] } }); + expect(sent[2]).toMatchObject({ role: "tool", toolCallId: "c1", name: "search_course" }); +}); diff --git a/packages/feature-ai/test/helpers.ts b/packages/feature-ai/test/helpers.ts index cc3846a..2fb3382 100644 --- a/packages/feature-ai/test/helpers.ts +++ b/packages/feature-ai/test/helpers.ts @@ -1,4 +1,4 @@ -import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts"; +import type { AiProvider, ChatMessage, ToolCall, ToolSpec } from "@stc/contracts"; import { migrate, type Db } from "@stc/db"; import { forEachDb } from "../../db/test/helpers"; import { migrationsDirs } from "../src/migrations"; @@ -30,18 +30,20 @@ export function fakeFetch(responses: (() => Response)[]) { return { fetch: f, calls }; } -export async function collect(it: AsyncIterable<{ text?: string; toolCall?: { id: string; name: string; input: unknown } }>) { +export async function collect(it: AsyncIterable<{ text?: string; toolCall?: ToolCall; providerData?: unknown }>) { let text = ""; - const toolCalls: { id: string; name: string; input: unknown }[] = []; + const toolCalls: ToolCall[] = []; + let providerData: unknown; for await (const ev of it) { if (ev.text) text += ev.text; if (ev.toolCall) toolCalls.push(ev.toolCall); + if (ev.providerData !== undefined) providerData = ev.providerData; } - return { text, toolCalls }; + return { text, toolCalls, providerData }; } /** Scripted fake provider: each call to chat() plays the next turn and records the messages it got. */ -export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[]) => { text?: string; toolCalls?: { id: string; name: string; input: unknown }[] })[]) { +export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[]) => { text?: string; toolCalls?: ToolCall[]; providerData?: unknown })[]) { const seen: { messages: ChatMessage[]; tools?: ToolSpec[] }[] = []; const provider: AiProvider = { id: "fake", @@ -50,6 +52,7 @@ export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[ const turn = turns[Math.min(seen.length - 1, turns.length - 1)]!(messages, opts.tools); if (turn.text) for (const part of turn.text.match(/.{1,8}/gs) ?? []) yield { text: part }; for (const tc of turn.toolCalls ?? []) yield { toolCall: tc }; + if (turn.providerData !== undefined) yield { providerData: turn.providerData }; }, }; return { provider, seen }; diff --git a/packages/feature-ai/test/openai.test.ts b/packages/feature-ai/test/openai.test.ts index e1efa87..ff02197 100644 --- a/packages/feature-ai/test/openai.test.ts +++ b/packages/feature-ai/test/openai.test.ts @@ -37,8 +37,7 @@ test("assembles fragmented tool calls and maps a tool round back", async () => { await collect(p.chat([ { role: "user", content: "q" }, - { role: "assistant", content: "Reading." }, - { role: "assistant", content: "{\"lessonId\":\"m1-intro\"}", toolCallId: "call_1", name: "read_lesson" }, + { role: "assistant", content: "Reading.", toolCalls: [{ id: "call_1", name: "read_lesson", input: { lessonId: "m1-intro" } }] }, { role: "tool", content: "# Introduction", toolCallId: "call_1", name: "read_lesson" }, ], { tools })); expect(ff.calls[1]!.body.messages).toEqual([ @@ -59,3 +58,24 @@ test("createProvider picks the implementation and defaults", () => { expect(() => createProvider({ kind: "anthropic" }, null)).toThrow(/key/); expect(() => createProvider({ kind: "openai" }, null)).toThrow(/base URL|model/); }); + +test("several tool calls in one assistant turn map to one message with tool_calls", async () => { + const ff = fakeFetch([ok([chunk({ content: "ok" }), [null, "[DONE]"]])]); + const p = createOpenAiProvider({ baseUrl: "http://x/v1", model: "m", fetch: ff.fetch }); + const r = await collect(p.chat([ + { role: "user", content: "q" }, + { role: "assistant", content: "", toolCalls: [{ id: "a", name: "search_course", input: { query: "x" } }, { id: "b", name: "list_notes", input: {} }], providerData: { anthropic: [] } }, + { role: "tool", content: "r1", toolCallId: "a", name: "search_course" }, + { role: "tool", content: "r2", toolCallId: "b", name: "list_notes" }, + ], {})); + expect(r.providerData).toBeUndefined(); + expect(ff.calls[0]!.body.messages).toEqual([ + { role: "user", content: "q" }, + { role: "assistant", content: null, tool_calls: [ + { id: "a", type: "function", function: { name: "search_course", arguments: "{\"query\":\"x\"}" } }, + { id: "b", type: "function", function: { name: "list_notes", arguments: "{}" } }, + ] }, + { role: "tool", tool_call_id: "a", content: "r1" }, + { role: "tool", tool_call_id: "b", content: "r2" }, + ]); +});