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 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 20:36:38 +01:00
co-authored by Claude Opus 5.5
parent ddc66f5be5
commit 5c63f17cff
9 changed files with 126 additions and 59 deletions
+18 -2
View File
@@ -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<string, unknown> }
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 }> }
+5 -3
View File
@@ -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];
});
+10 -5
View File
@@ -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;
+16 -19
View File
@@ -17,19 +17,24 @@ export interface AnthropicOptions {
type Block = Record<string, unknown> & { 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: <raw content blocks> }` (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<string, Block[]>();
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<number, string>();
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." };
}
+3 -5
View File
@@ -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);
}
+25 -17
View File
@@ -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 () => {
+19 -1
View File
@@ -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" });
});
+8 -5
View File
@@ -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 };
+22 -2
View File
@@ -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" },
]);
});