diff --git a/.changeset/ai-gateway-tools.md b/.changeset/ai-gateway-tools.md new file mode 100644 index 00000000..af7ee72c --- /dev/null +++ b/.changeset/ai-gateway-tools.md @@ -0,0 +1,108 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add tool support to `useAIChat()` and the AI Gateway transport layer. + +You can now register **local tools** and **provider tools** via the `tools` option on `useAIChat()`. Tool-call and tool-result protocol messages stay internal to the hook — the public `messages` array remains user/assistant text only. + +## Local tools + +Define tools that run inside AppShell using `defineAIChatTool`. The model decides when to call them, AppShell validates arguments with the schema, executes the handler, and feeds results back into the next model turn automatically. + +```ts +import { defineAIChatTool, aiToolSchema } from "@tailor-platform/app-shell"; + +const lookupCustomer = defineAIChatTool({ + description: "Look up a customer by ID and return their profile", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string({ description: "Customer ID" }), + includeInactive: aiToolSchema.optional(aiToolSchema.boolean()), + }), + async execute({ customerId, includeInactive }, { signal }) { + const res = await fetch(`/api/customers/${customerId}?inactive=${includeInactive ?? false}`, { + signal, + }); + return res.json(); + }, +}); +``` + +### Schema primitives (`aiToolSchema`) + +| Helper | Description | +| --------------------------------------- | --------------------------------------------------------------- | +| `aiToolSchema.string(opts?)` | String input (supports `minLength`, `maxLength`, `description`) | +| `aiToolSchema.number(opts?)` | Numeric input (supports `minimum`, `maximum`, `integer`) | +| `aiToolSchema.boolean(opts?)` | Boolean input | +| `aiToolSchema.enum(values, opts?)` | Fixed set of string literals | +| `aiToolSchema.array(itemSchema, opts?)` | Array of a given schema | +| `aiToolSchema.object(shape)` | Object with named fields | +| `aiToolSchema.optional(schema)` | Marks a field as optional | + +### Tool context + +The `execute` function receives a second argument with: + +- `signal` — the `AbortSignal` for the in-flight chat request +- `messages` — the public user/assistant message history at the time of execution + +## Provider tools + +Provider tools are not executed locally — they are passed through to the AI Gateway and handled upstream by the model provider. + +```ts +import { aiProviderTool } from "@tailor-platform/app-shell"; + +const webSearch = aiProviderTool.openai.webSearch({ + searchContextSize: "high", + userLocation: { + type: "approximate", + country: "JP", + city: "Tokyo", + timezone: "Asia/Tokyo", + }, + filters: { + allowedDomains: ["nikkei.com", "reuters.com"], + }, +}); +``` + +## Registering tools with `useAIChat` + +Pass tools as a record to the `tools` option. The key becomes the tool name sent to the model. + +```tsx +import { + useAIChat, + defineAIChatTool, + aiToolSchema, + aiProviderTool, +} from "@tailor-platform/app-shell"; + +const lookupCustomer = defineAIChatTool({ + description: "Look up a customer by ID", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme Corp", plan: "enterprise" }; + }, +}); + +function ChatPanel({ client }) { + const { messages, status, sendMessage } = useAIChat({ + client, + model: "gpt-5-mini", + tools: { + lookupCustomer, + web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), + }, + }); + + // messages only contains user/assistant text — tool calls are handled internally + // status transitions: "submitted" → "streaming" → ("submitted" during tool rounds) → "ready" +} +``` + +The hook runs up to 8 tool rounds per user message. If the model keeps requesting tools beyond that limit, the request fails with an error. diff --git a/docs/api/create-ai-gateway-client.md b/docs/api/create-ai-gateway-client.md index d59f2da3..0544e6e4 100644 --- a/docs/api/create-ai-gateway-client.md +++ b/docs/api/create-ai-gateway-client.md @@ -5,7 +5,7 @@ description: Create a low-level AI Gateway client that reuses AppShell authentic # createAIGatewayClient -Creates a small AI Gateway transport client for text-only chat completions. +Creates a small AI Gateway transport client for chat completions, tool calls, and optional sources. ## Signature @@ -41,7 +41,8 @@ interface AIGatewayClient { The iterable yields completion events: - `text-delta` — append `event.text` to build the assistant response -- `done` — terminal event with an optional `finishReason` +- `tool-call` — a local function tool call requested by the model +- `done` — terminal event with an optional `finishReason` and optional `sources` ## Related Types @@ -54,11 +55,40 @@ type AIGatewayChatMessage = | { role: "assistant"; content?: string; + toolCalls?: AIGatewayToolCall[]; + } + | { + role: "tool"; + toolCallId: string; + content: string; + }; + +interface AIGatewayToolCall { + id: string; + name: string; + argumentsText: string; +} + +type AIGatewayTool = + | { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; + } + | { + type: "provider"; + provider: "openai"; + name: "web_search"; + options?: unknown; }; interface AIGatewayChatRequest { model: string; messages: AIGatewayChatMessage[]; + tools?: AIGatewayTool[]; signal?: AbortSignal; } @@ -67,10 +97,23 @@ type AIChatCompletionEvent = type: "text-delta"; text: string; } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + argumentsText: string; + } | { type: "done"; finishReason?: string; + sources?: AIChatSource[]; }; + +interface AIChatSource { + type: "url"; + url: string; + title?: string; +} ``` ## Usage @@ -104,7 +147,8 @@ console.log(text); ## Notes - AppShell chooses the appropriate AI Gateway transport automatically -- The low-level API is intentionally narrow: text deltas plus completion metadata +- Local tools are sent as normalized `type: "function"` definitions; provider tools are passed through as normalized `type: "provider"` definitions +- The low-level API stays event-based so hooks can layer streaming UI and tool loops on top - `request.signal` is passed through so callers can abort in-flight work ## Related diff --git a/docs/api/use-ai-chat.md b/docs/api/use-ai-chat.md index 506176ab..d3aa7a67 100644 --- a/docs/api/use-ai-chat.md +++ b/docs/api/use-ai-chat.md @@ -1,16 +1,20 @@ --- title: useAIChat -description: Simple text-only chat hook for AI Gateway +description: AI Gateway chat hook with optional local and provider tools --- # useAIChat -React hook for simple text-only chat on top of `createAIGatewayClient`. +React hook for AI Gateway chat on top of `createAIGatewayClient`, with optional local and provider tool support. ## Signature ```typescript -const useAIChat: (config: { client: AIGatewayClient; model: string }) => { +const useAIChat: (config: { + client: AIGatewayClient; + model: string; + tools?: Record; +}) => { messages: AIChatMessage[]; status: "ready" | "submitted" | "streaming" | "error"; error?: Error; @@ -28,6 +32,13 @@ interface AIChatMessage { id: string; role: "user" | "assistant"; content: string; + sources?: AIChatSource[]; +} + +interface AIChatSource { + type: "url"; + url: string; + title?: string; } ``` @@ -52,10 +63,24 @@ interface AIChatMessage { - **Type:** `() => void` - **Description:** Aborts the current request if one is in progress +### `tools` + +Register tools under a single object: + +- local tools created with `defineAIChatTool(...)` +- provider tools such as `aiProviderTool.openai.webSearch(...)` + ## Usage ```tsx -import { createAuthClient, createAIGatewayClient, useAIChat } from "@tailor-platform/app-shell"; +import { + aiProviderTool, + aiToolSchema, + createAuthClient, + createAIGatewayClient, + defineAIChatTool, + useAIChat, +} from "@tailor-platform/app-shell"; const authClient = createAuthClient({ clientId: "your-client-id", @@ -67,10 +92,24 @@ const aiClient = createAIGatewayClient({ authClient, }); +const lookupCustomer = defineAIChatTool({ + description: "Look up a customer in the current workspace", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme Corp" }; + }, +}); + export function ChatScreen() { const { messages, sendMessage, status, stop, error } = useAIChat({ client: aiClient, model: "gpt-5-mini", + tools: { + lookupCustomer, + web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), + }, }); return ( @@ -99,7 +138,8 @@ export function ChatScreen() { ## Notes - AppShell chooses the appropriate AI Gateway transport automatically -- The hook is intentionally text-only +- Public messages stay user/assistant text-first; internal tool messages remain private to the hook +- Provider tools can attach optional `sources` to assistant messages - System prompts and custom history shaping should use the low-level client directly - `stop()` keeps any already-streamed assistant text and ignores late chunks from the stopped request diff --git a/packages/core/src/ai/assistant-loop.ts b/packages/core/src/ai/assistant-loop.ts new file mode 100644 index 00000000..5c40441f --- /dev/null +++ b/packages/core/src/ai/assistant-loop.ts @@ -0,0 +1,175 @@ +import type { + AIGatewayChatMessage, + AIGatewayClient, + AIGatewayFunctionTool, + AIGatewayProviderTool, + AIGatewayTool, + AIGatewayToolCall, + AIChatSource, +} from "./client"; +import type { AIChatConfiguredTool, AILocalTool } from "./tools"; +import { deriveVisibleMessages, resolveToolCalls } from "./tool-execution"; + +const MAX_TOOL_ROUNDS = 8; + +/** + * Splits the public tools object into: + * - normalized AI Gateway tool definitions sent over the wire + * - local tool executors kept for in-process validation and execution + */ +function normalizeConfiguredTools(tools: Record | undefined): { + gatewayTools: AIGatewayTool[]; + localTools: Map; +} { + if (!tools) { + return { + gatewayTools: [], + localTools: new Map(), + }; + } + + const localTools = new Map(); + const gatewayTools = Object.entries(tools).map(([name, tool]) => { + if (tool.kind === "local") { + localTools.set(name, tool); + return { + type: "function", + function: { + name, + ...(tool.description ? { description: tool.description } : {}), + parameters: tool.schema["~standard"].jsonSchema.input({ target: "draft-07" }), + }, + } satisfies AIGatewayFunctionTool; + } + + return { + type: "provider", + provider: "openai", + name: "web_search", + ...(tool.options ? { options: tool.options } : {}), + } satisfies AIGatewayProviderTool; + }); + + return { + gatewayTools, + localTools, + }; +} + +export interface AIChatAssistantTurnResult { + gatewayAssistantMessage: Extract; + toolCalls: AIGatewayToolCall[]; +} + +export type AssistantLoopEvent = + | { type: "text-delta"; delta: string } + | { type: "sources"; sources: AIChatSource[] } + | { type: "turn-end"; turn: AIChatAssistantTurnResult } + | { type: "tool-resolution-start" } + | { + type: "tool-results"; + messages: Extract[]; + } + | { type: "complete" }; + +/** + * Pure async generator that runs the multi-turn assistant loop. + * Yields events for the consumer to translate into state changes. + * Manages transcript internally — the caller only provides the initial transcript. + */ +export async function* runAssistantLoop(input: { + client: AIGatewayClient; + model: string; + tools: Record | undefined; + transcript: AIGatewayChatMessage[]; + signal: AbortSignal; +}): AsyncGenerator { + const { gatewayTools, localTools } = normalizeConfiguredTools(input.tools); + const transcript = [...input.transcript]; + + for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) { + const turn: AIChatAssistantTurnResult = yield* streamAssistantTurn({ + client: input.client, + model: input.model, + tools: gatewayTools, + transcript, + signal: input.signal, + }); + + if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { + transcript.push(turn.gatewayAssistantMessage); + } + + yield { type: "turn-end", turn }; + + if (turn.toolCalls.length === 0) { + yield { type: "complete" }; + return; + } + + yield { type: "tool-resolution-start" }; + + const toolMessages = await resolveToolCalls({ + toolCalls: turn.toolCalls, + localTools, + signal: input.signal, + visibleMessages: deriveVisibleMessages(transcript), + }); + + transcript.push(...toolMessages); + yield { type: "tool-results", messages: toolMessages }; + } + + throw new Error(`AI chat exceeded the maximum number of tool rounds (${MAX_TOOL_ROUNDS}).`); +} + +/** + * Streams one assistant turn from the AI Gateway. + * Yields text-delta and sources events as they arrive. + * Returns the collected turn result (content + tool calls) via generator return. + */ +async function* streamAssistantTurn(input: { + client: AIGatewayClient; + model: string; + tools: AIGatewayTool[]; + transcript: AIGatewayChatMessage[]; + signal: AbortSignal; +}): AsyncGenerator { + let content = ""; + const toolCalls: AIGatewayToolCall[] = []; + + for await (const event of input.client.streamChatCompletion({ + model: input.model, + messages: input.transcript, + ...(input.tools.length > 0 ? { tools: input.tools } : {}), + signal: input.signal, + })) { + if (event.type === "text-delta") { + content = `${content}${event.text}`; + yield { type: "text-delta", delta: event.text }; + continue; + } + + if (event.type === "tool-call") { + toolCalls.push({ + id: event.toolCallId, + name: event.toolName, + argumentsText: event.argumentsText, + }); + continue; + } + + if (event.sources?.length) { + yield { type: "sources", sources: event.sources }; + } + } + + return { + gatewayAssistantMessage: { + role: "assistant", + ...(content ? { content } : {}), + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }, + toolCalls, + }; +} diff --git a/packages/core/src/ai/client.test.ts b/packages/core/src/ai/client.test.ts index f747297c..752e57f6 100644 --- a/packages/core/src/ai/client.test.ts +++ b/packages/core/src/ai/client.test.ts @@ -178,6 +178,105 @@ describe("createAIGatewayClient", () => { }); }); + it("emits tool calls from streaming responses", async () => { + const authClient = createMockAuthClient( + new Response( + createSSEStream([ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookupCustomer","arguments":"{\\"customerId\\":\\""}}]}}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"cust-1\\"}"}}]}}]}\n\n', + 'data: {"choices":[{"finish_reason":"tool_calls"}]}\n\n', + "data: [DONE]\n\n", + ]), + { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }, + ), + ); + const client = createAIGatewayClient({ + gatewayUri: "https://gateway.example.com", + authClient, + }); + + await expect( + collectEvents( + client, + createRequest({ + tools: [ + { + type: "function", + function: { + name: "lookupCustomer", + parameters: { type: "object", properties: { customerId: { type: "string" } } }, + }, + }, + ], + }), + ), + ).resolves.toEqual([ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', + }, + { type: "done", finishReason: "tool_calls" }, + ]); + + const [input, init] = getRequestCall(authClient); + expect(JSON.parse(String(await readRequestBody(input, init)))).toEqual({ + model: "gpt-5-mini", + messages: [{ role: "user", content: "Hello" }], + tools: [ + { + type: "function", + function: { + name: "lookupCustomer", + parameters: { type: "object", properties: { customerId: { type: "string" } } }, + }, + }, + ], + stream: true, + }); + }); + + it("emits sources from json responses", async () => { + const authClient = createMockAuthClient( + new Response( + JSON.stringify({ + choices: [ + { + message: { + content: "Grounded answer", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }, + finish_reason: "stop", + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ); + const client = createAIGatewayClient({ + gatewayUri: "https://gateway.example.com", + authClient, + }); + + await expect( + collectEvents(client, createRequest({ model: "gemini-2.5-flash" })), + ).resolves.toEqual([ + { type: "text-delta", text: "Grounded answer" }, + { + type: "done", + finishReason: "stop", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }, + ]); + }); + it("emits done even when json responses do not include assistant text", async () => { const authClient = createMockAuthClient( new Response( diff --git a/packages/core/src/ai/client.ts b/packages/core/src/ai/client.ts index 5f9fca4e..3ca22dfe 100644 --- a/packages/core/src/ai/client.ts +++ b/packages/core/src/ai/client.ts @@ -6,6 +6,21 @@ import type { } from "openai/resources/chat/completions"; import type { AuthClient } from "@tailor-platform/auth-public-client"; +/** Source/citation metadata attached to assistant messages when available. */ +export interface AIChatSource { + type: "url"; + url: string; + title?: string; +} + +/** Normalized local function tool call emitted by the AI Gateway transport. */ +export interface AIGatewayToolCall { + id: string; + name: string; + argumentsText: string; +} + +/** Internal transcript message shape sent to the AI Gateway transport layer. */ export type AIGatewayChatMessage = | { role: "system" | "user"; @@ -14,22 +29,57 @@ export type AIGatewayChatMessage = | { role: "assistant"; content?: string; + toolCalls?: AIGatewayToolCall[]; + } + | { + role: "tool"; + toolCallId: string; + content: string; }; +/** Normalized local function tool definition understood by the AI Gateway. */ +export interface AIGatewayFunctionTool { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +/** Normalized provider tool definition passed through to the AI Gateway. */ +export interface AIGatewayProviderTool { + type: "provider"; + provider: "openai"; + name: "web_search"; + options?: unknown; +} + +export type AIGatewayTool = AIGatewayFunctionTool | AIGatewayProviderTool; + export interface AIGatewayChatRequest { model: string; messages: AIGatewayChatMessage[]; + tools?: AIGatewayTool[]; signal?: AbortSignal; } +/** Low-level event stream consumed by `useAIChat()`. */ export type AIChatCompletionEvent = | { type: "text-delta"; text: string; } + | { + type: "tool-call"; + toolCallId: string; + toolName: string; + argumentsText: string; + } | { type: "done"; finishReason?: string; + sources?: AIChatSource[]; }; export interface AIGatewayClient { @@ -103,30 +153,39 @@ async function* streamOpenAICompatibleResponse(input: { request: AIGatewayChatRequest; }): AsyncGenerator { try { - const stream = await input.client.chat.completions.create( - { - model: input.request.model, - messages: input.request.messages.map(toOpenAIMessage), - stream: true, - }, + const stream = (await input.client.chat.completions.create( + buildChatRequestBody(input.request, true), { signal: input.request.signal, }, - ); + )) as AsyncIterable; let finishReason: string | undefined; + let sources: AIChatSource[] | undefined; + const toolCalls = new Map>(); for await (const chunk of stream) { const choice = chunk.choices[0]; const delta = extractStreamingText(choice); finishReason = extractFinishReason(choice?.finish_reason) ?? finishReason; + sources = extractSources(choice) ?? sources; + collectStreamingToolCalls(toolCalls, choice); if (delta) { yield { type: "text-delta", text: delta }; } } - yield createDoneEvent(finishReason); + for (const toolCall of finalizeStreamingToolCalls(toolCalls)) { + yield { + type: "tool-call", + toolCallId: toolCall.id, + toolName: toolCall.name, + argumentsText: toolCall.argumentsText, + }; + } + + yield createDoneEvent(finishReason, sources); } catch (error) { throw normalizeGatewayError(error, input.request.signal); } @@ -137,16 +196,12 @@ async function* streamJSONResponse(input: { request: AIGatewayChatRequest; }): AsyncGenerator { try { - const completion = await input.client.chat.completions.create( - { - model: input.request.model, - messages: input.request.messages.map(toOpenAIMessage), - stream: false, - }, + const completion = (await input.client.chat.completions.create( + buildChatRequestBody(input.request, false), { signal: input.request.signal, }, - ); + )) as ChatCompletion; const choice = completion.choices[0]; const text = extractFinalText(choice); @@ -155,21 +210,67 @@ async function* streamJSONResponse(input: { yield { type: "text-delta", text }; } - yield createDoneEvent(extractFinishReason(choice?.finish_reason)); + for (const toolCall of extractToolCalls(choice)) { + yield { + type: "tool-call", + toolCallId: toolCall.id, + toolName: toolCall.name, + argumentsText: toolCall.argumentsText, + }; + } + + yield createDoneEvent(extractFinishReason(choice?.finish_reason), extractSources(choice)); } catch (error) { throw normalizeGatewayError(error, input.request.signal); } } +/** + * Builds the narrow OpenAI-compatible request body that Envoy AI Gateway + * expects, while preserving AppShell's normalized tool contract internally. + */ +function buildChatRequestBody( + request: AIGatewayChatRequest, + stream: boolean, +): Parameters[0] { + return { + model: request.model, + messages: request.messages.map(toOpenAIMessage), + ...(request.tools?.length ? { tools: request.tools } : {}), + stream, + } as Parameters[0]; +} + function withTrailingSlash(value: string): string { return value.endsWith("/") ? value : `${value}/`; } function toOpenAIMessage(message: AIGatewayChatMessage): ChatCompletionMessageParam { if (message.role === "assistant") { - return message.content === undefined - ? { role: "assistant" } - : { role: "assistant", content: message.content }; + return { + role: "assistant", + ...(message.content !== undefined ? { content: message.content } : { content: null }), + ...(message.toolCalls?.length + ? { + tool_calls: message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: "function", + function: { + name: toolCall.name, + arguments: toolCall.argumentsText, + }, + })), + } + : {}), + } as ChatCompletionMessageParam; + } + + if (message.role === "tool") { + return { + role: "tool", + tool_call_id: message.toolCallId, + content: message.content, + } as ChatCompletionMessageParam; } return { @@ -178,6 +279,63 @@ function toOpenAIMessage(message: AIGatewayChatMessage): ChatCompletionMessagePa }; } +/** + * Accumulates streamed tool-call deltas into complete normalized tool calls. + * + * OpenAI-compatible streams may split ids, names, and JSON arguments across + * many chunks, so this keeps a per-index assembly buffer until the stream ends. + */ +function collectStreamingToolCalls( + toolCalls: Map>, + choice: ChatCompletionChunk["choices"][number] | undefined, +): void { + for (const toolCallDelta of choice?.delta?.tool_calls ?? []) { + const existing = toolCalls.get(toolCallDelta.index) ?? { argumentsText: "" }; + + if (toolCallDelta.id) { + existing.id = toolCallDelta.id; + } + + if (toolCallDelta.function?.name) { + existing.name = toolCallDelta.function.name; + } + + if (toolCallDelta.function?.arguments) { + existing.argumentsText = `${existing.argumentsText ?? ""}${toolCallDelta.function.arguments}`; + } + + toolCalls.set(toolCallDelta.index, existing); + } +} + +/** Finalizes buffered streamed tool calls into stable ordered results. */ +function finalizeStreamingToolCalls( + toolCalls: Map>, +): AIGatewayToolCall[] { + return [...toolCalls.entries()] + .toSorted(([leftIndex], [rightIndex]) => leftIndex - rightIndex) + .map(([, toolCall]) => ({ + id: toolCall.id ?? crypto.randomUUID(), + name: toolCall.name ?? "", + argumentsText: toolCall.argumentsText ?? "", + })) + .filter((toolCall) => toolCall.name.length > 0); +} + +/** Extracts complete function tool calls from a non-streaming completion. */ +function extractToolCalls( + choice: ChatCompletion["choices"][number] | undefined, +): AIGatewayToolCall[] { + return (choice?.message?.tool_calls ?? []) + .filter((toolCall) => toolCall.type === "function") + .map((toolCall) => ({ + id: toolCall.id ?? crypto.randomUUID(), + name: toolCall.function.name, + argumentsText: toolCall.function.arguments, + })) + .filter((toolCall) => toolCall.name.length > 0); +} + function extractStreamingText(choice: ChatCompletionChunk["choices"][number] | undefined): string { return extractText(choice?.delta?.content); } @@ -211,12 +369,66 @@ function extractText(content: unknown): string { .join(""); } +/** + * Best-effort parser for normalized provider sources/citations. + * + * This intentionally tolerates response-shape differences so AppShell can + * surface sources without depending on one provider-specific payload format. + */ +function extractSources(value: unknown): AIChatSource[] | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + + if ("message" in value) { + return extractSources((value as { message?: unknown }).message); + } + + if ("delta" in value) { + return extractSources((value as { delta?: unknown }).delta); + } + + if (!("sources" in value) || !Array.isArray(value.sources)) { + return undefined; + } + + const sources = value.sources + .map((source) => { + if (!source || typeof source !== "object") { + return null; + } + + if ((source as { type?: unknown }).type !== "url") { + return null; + } + + const url = (source as { url?: unknown }).url; + if (typeof url !== "string" || url.length === 0) { + return null; + } + + const title = (source as { title?: unknown }).title; + return { + type: "url" as const, + url, + ...(typeof title === "string" && title.length > 0 ? { title } : {}), + }; + }) + .filter((source) => source !== null); + + return sources.length > 0 ? sources : undefined; +} + function extractFinishReason(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } -function createDoneEvent(finishReason?: string): AIChatCompletionEvent { - return finishReason ? { type: "done", finishReason } : { type: "done" }; +function createDoneEvent(finishReason?: string, sources?: AIChatSource[]): AIChatCompletionEvent { + return { + type: "done", + ...(finishReason ? { finishReason } : {}), + ...(sources?.length ? { sources } : {}), + }; } function normalizeGatewayError(error: unknown, signal?: AbortSignal): Error { diff --git a/packages/core/src/ai/tool-execution.ts b/packages/core/src/ai/tool-execution.ts new file mode 100644 index 00000000..af74c669 --- /dev/null +++ b/packages/core/src/ai/tool-execution.ts @@ -0,0 +1,129 @@ +import type { AIGatewayChatMessage, AIGatewayToolCall } from "./client"; +import type { AIChatMessage } from "./use-ai-chat"; +import type { AIChatToolContext, AILocalTool } from "./tools"; + +/** + * Executes requested local tools in call order and converts their outputs into + * internal `role: "tool"` transcript messages for the next model round. + */ +export async function resolveToolCalls(input: { + toolCalls: AIGatewayToolCall[]; + localTools: Map; + signal: AbortSignal; + visibleMessages: AIChatToolContext["messages"]; +}): Promise[]> { + const messages: Extract[] = []; + + for (const toolCall of input.toolCalls) { + if (input.signal.aborted) { + throw createAbortError(); + } + + messages.push( + await resolveToolCall({ + toolCall, + localTools: input.localTools, + context: { + signal: input.signal, + messages: input.visibleMessages, + }, + }), + ); + } + + return messages; +} + +/** + * Validates, executes, and stringifies one local tool call. + * + * Tool failures are converted into tool result payloads so the model can + * recover in-band instead of failing the entire chat request. + */ +async function resolveToolCall(input: { + toolCall: AIGatewayToolCall; + localTools: Map; + context: AIChatToolContext; +}): Promise> { + const tool = input.localTools.get(input.toolCall.name); + + if (!tool) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: `Unknown tool: ${input.toolCall.name}` }), + }; + } + + try { + const rawArguments = parseToolArguments(input.toolCall.argumentsText); + const result = await tool.schema["~standard"].validate(rawArguments); + + if (result.issues) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: result.issues.map((issue) => issue.message).join("; ") }), + }; + } + + const output = await tool.execute(result.value, input.context); + return { + role: "tool", + toolCallId: input.toolCall.id, + content: stringifyToolResult(output), + }; + } catch (error) { + return { + role: "tool", + toolCallId: input.toolCall.id, + content: JSON.stringify({ error: error instanceof Error ? error.message : String(error) }), + }; + } +} + +/** + * Derives the user/assistant visible messages from the full transcript. + * Used to provide conversation context to tool executors. + */ +export function deriveVisibleMessages(transcript: AIGatewayChatMessage[]): AIChatMessage[] { + const result: AIChatMessage[] = []; + + for (const message of transcript) { + if (message.role === "user") { + // ponytail: ids are placeholders — tool executors only read content, not ids + result.push({ id: "", role: "user", content: message.content }); + } else if (message.role === "assistant" && message.content) { + result.push({ id: "", role: "assistant", content: message.content }); + } + } + + return result; +} + +function parseToolArguments(argumentsText: string): unknown { + const text = argumentsText.trim(); + return text ? JSON.parse(text) : {}; +} + +function stringifyToolResult(value: unknown): string { + if (typeof value === "string") { + return value; + } + + if (value === undefined) { + return "null"; + } + + return JSON.stringify(value); +} + +function createAbortError(): Error { + if (typeof DOMException !== "undefined") { + return new DOMException("The operation was aborted.", "AbortError"); + } + + const error = new Error("The operation was aborted."); + error.name = "AbortError"; + return error; +} diff --git a/packages/core/src/ai/tools.test.ts b/packages/core/src/ai/tools.test.ts new file mode 100644 index 00000000..d7eb880c --- /dev/null +++ b/packages/core/src/ai/tools.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { aiProviderTool, aiToolSchema, defineAIChatTool } from "./tools"; + +describe("aiToolSchema", () => { + it("validates nested objects and generates json schema", async () => { + const schema = aiToolSchema.object({ + city: aiToolSchema.string({ description: "City name" }), + unit: aiToolSchema.optional(aiToolSchema.enum(["c", "f"])), + tags: aiToolSchema.array(aiToolSchema.string()), + }); + + await expect( + schema["~standard"].validate({ city: "Tokyo", unit: "c", tags: ["capital"] }), + ).resolves.toEqual({ + value: { + city: "Tokyo", + unit: "c", + tags: ["capital"], + }, + }); + + await expect( + schema["~standard"].validate({ city: "Tokyo", unit: "k", tags: [] }), + ).resolves.toEqual({ + issues: [{ message: "Must be one of: c, f", path: ["unit"] }], + }); + + expect(schema["~standard"].jsonSchema.input({ target: "draft-07" })).toEqual({ + type: "object", + properties: { + city: { type: "string", description: "City name" }, + unit: { type: "string", enum: ["c", "f"] }, + tags: { type: "array", items: { type: "string" } }, + }, + required: ["city", "tags"], + additionalProperties: false, + }); + }); +}); + +describe("AI chat tool helpers", () => { + it("creates local and provider tool definitions", async () => { + const lookupCustomer = defineAIChatTool({ + description: "Look up a customer", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme" }; + }, + }); + + const webSearch = aiProviderTool.openai.webSearch({ searchContextSize: "high" }); + + expect(lookupCustomer.kind).toBe("local"); + await expect( + lookupCustomer.execute( + { customerId: "cust-1" }, + { signal: new AbortController().signal, messages: [] }, + ), + ).resolves.toEqual({ customerId: "cust-1", name: "Acme" }); + + expect(webSearch).toEqual({ + kind: "provider", + provider: "openai", + tool: "webSearch", + options: { searchContextSize: "high" }, + }); + }); +}); diff --git a/packages/core/src/ai/tools.ts b/packages/core/src/ai/tools.ts new file mode 100644 index 00000000..ca9fb4bc --- /dev/null +++ b/packages/core/src/ai/tools.ts @@ -0,0 +1,606 @@ +import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"; +import type { AIChatMessage } from "./use-ai-chat"; + +/** + * Public schema contract for local AI chat tools. + * + * A schema must both validate runtime inputs and generate JSON Schema for the + * AI Gateway request payload. + * + * @example + * ```ts + * import { aiToolSchema } from "@tailor-platform/app-shell"; + * + * const schema = aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * includeInactive: aiToolSchema.optional(aiToolSchema.boolean()), + * }); + * ``` + */ +export type AIChatToolSchema = StandardSchemaV1 & + StandardJSONSchemaV1; + +type AnyAIChatToolSchema = AIChatToolSchema; +const OPTIONAL_TOOL_SCHEMA = Symbol("optional-tool-schema"); + +type AIOptionalToolSchema = + AIChatToolSchema< + StandardSchemaV1.InferInput | undefined, + StandardSchemaV1.InferOutput | undefined + > & { + [OPTIONAL_TOOL_SCHEMA]: TSchema; + }; + +type AISchemaShape = Record; + +type UnwrapOptionalSchema = + TSchema extends AIOptionalToolSchema + ? TInner + : TSchema extends AnyAIChatToolSchema + ? TSchema + : never; + +type SchemaInput = + StandardSchemaV1.InferInput>; + +type SchemaOutput = + StandardSchemaV1.InferOutput>; + +type ObjectInput = { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? never : K]: SchemaInput; +} & { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? K : never]?: SchemaInput< + TShape[K] + >; +}; + +type ObjectOutput = { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? never : K]: SchemaOutput< + TShape[K] + >; +} & { + [K in keyof TShape as TShape[K] extends AIOptionalToolSchema ? K : never]?: SchemaOutput< + TShape[K] + >; +}; + +function success(value: T): StandardSchemaV1.SuccessResult { + return { value }; +} + +function failure(message: string): StandardSchemaV1.FailureResult { + return { issues: [{ message }] }; +} + +function prefixIssues( + issues: readonly StandardSchemaV1.Issue[], + key: PropertyKey, +): StandardSchemaV1.FailureResult { + return { + issues: issues.map((issue) => ({ + ...issue, + path: [key, ...(issue.path ?? [])], + })), + }; +} + +/** + * Creates a Standard Schema + Standard JSON Schema compatible tool schema. + * + * This keeps local tool validation and AI Gateway JSON Schema generation bound + * to the same declaration. + */ +function createToolSchema(config: { + validate: ( + value: unknown, + ) => StandardSchemaV1.Result | Promise>; + jsonSchemaInput: (options: StandardJSONSchemaV1.Options) => Record; + jsonSchemaOutput?: (options: StandardJSONSchemaV1.Options) => Record; +}): AIChatToolSchema { + return { + "~standard": { + version: 1, + vendor: "app-shell", + validate: config.validate, + jsonSchema: { + input: config.jsonSchemaInput, + output: config.jsonSchemaOutput ?? config.jsonSchemaInput, + }, + }, + } as AIChatToolSchema; +} + +/** Marks wrapper schemas created by `aiToolSchema.optional(...)`. */ +function isOptionalSchema( + schema: AnyAIChatToolSchema | AIOptionalToolSchema, +): schema is AIOptionalToolSchema { + return OPTIONAL_TOOL_SCHEMA in schema; +} + +/** Returns the underlying schema for optional and non-optional schema entries. */ +function unwrapOptionalSchema( + schema: TSchema, +): UnwrapOptionalSchema { + return ( + isOptionalSchema(schema) ? schema[OPTIONAL_TOOL_SCHEMA] : schema + ) as UnwrapOptionalSchema; +} + +/** + * Minimal Standard Schema helpers for common local tool inputs. + * + * These helpers intentionally cover the narrow set of JSON-schema-friendly + * shapes AppShell needs for tool calling today. + * + * @example + * ```ts + * import { aiToolSchema } from "@tailor-platform/app-shell"; + * + * const lookupCustomerSchema = aiToolSchema.object({ + * customerId: aiToolSchema.string({ description: "Workspace customer id" }), + * fields: aiToolSchema.optional( + * aiToolSchema.array(aiToolSchema.enum(["name", "email", "status"])), + * ), + * }); + * ``` + */ +export const aiToolSchema = { + /** + * Declares a string input. + * + * Use this for ids, names, prompts, or any free-form text argument. + * + * @example + * ```ts + * aiToolSchema.string({ description: "Customer id" }) + * ``` + */ + string(options?: { + description?: string; + minLength?: number; + maxLength?: number; + }): AIChatToolSchema { + return createToolSchema({ + validate: (value) => { + if (typeof value !== "string") { + return failure("Must be a string"); + } + if (options?.minLength !== undefined && value.length < options.minLength) { + return failure(`Must be at least ${options.minLength} character(s)`); + } + if (options?.maxLength !== undefined && value.length > options.maxLength) { + return failure(`Must be at most ${options.maxLength} character(s)`); + } + return success(value); + }, + jsonSchemaInput: () => ({ + type: "string", + ...(options?.description ? { description: options.description } : {}), + ...(options?.minLength !== undefined ? { minLength: options.minLength } : {}), + ...(options?.maxLength !== undefined ? { maxLength: options.maxLength } : {}), + }), + }); + }, + + /** + * Declares a numeric input. + * + * Set `integer: true` for counts, indexes, and pagination-like arguments. + * + * @example + * ```ts + * aiToolSchema.number({ minimum: 1, integer: true }) + * ``` + */ + number(options?: { + description?: string; + minimum?: number; + maximum?: number; + integer?: boolean; + }): AIChatToolSchema { + return createToolSchema({ + validate: (value) => { + if (typeof value !== "number" || Number.isNaN(value)) { + return failure("Must be a number"); + } + if (options?.integer && !Number.isInteger(value)) { + return failure("Must be an integer"); + } + if (options?.minimum !== undefined && value < options.minimum) { + return failure(`Must be at least ${options.minimum}`); + } + if (options?.maximum !== undefined && value > options.maximum) { + return failure(`Must be at most ${options.maximum}`); + } + return success(value); + }, + jsonSchemaInput: () => ({ + type: options?.integer ? "integer" : "number", + ...(options?.description ? { description: options.description } : {}), + ...(options?.minimum !== undefined ? { minimum: options.minimum } : {}), + ...(options?.maximum !== undefined ? { maximum: options.maximum } : {}), + }), + }); + }, + + /** + * Declares a boolean input. + * + * Useful for enable/disable or include/exclude style arguments. + * + * @example + * ```ts + * aiToolSchema.boolean({ description: "Include archived records" }) + * ``` + */ + boolean(options?: { description?: string }): AIChatToolSchema { + return createToolSchema({ + validate: (value) => + typeof value === "boolean" ? success(value) : failure("Must be a boolean"), + jsonSchemaInput: () => ({ + type: "boolean", + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + /** + * Declares a string enum input. + * + * Use this when the model should choose from a fixed set of string literals. + * + * @example + * ```ts + * aiToolSchema.enum(["draft", "published", "archived"]) + * ``` + */ + enum( + values: TValues, + options?: { description?: string }, + ): AIChatToolSchema { + return createToolSchema({ + validate: (value) => + typeof value === "string" && values.includes(value) + ? success(value as TValues[number]) + : failure(`Must be one of: ${values.join(", ")}`), + jsonSchemaInput: () => ({ + type: "string", + enum: [...values], + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + /** + * Declares an array input whose items must match another tool schema. + * + * @example + * ```ts + * aiToolSchema.array(aiToolSchema.string(), { + * description: "Fields to include in the response", + * }) + * ``` + */ + array( + schema: TSchema, + options?: { description?: string }, + ): AIChatToolSchema< + StandardSchemaV1.InferInput[], + StandardSchemaV1.InferOutput[] + > { + return createToolSchema({ + validate: async (value) => { + if (!Array.isArray(value)) { + return failure("Must be an array"); + } + + const output: StandardSchemaV1.InferOutput[] = []; + for (const [index, entry] of value.entries()) { + const result = await schema["~standard"].validate(entry); + if (result.issues) { + return prefixIssues(result.issues, index); + } + output.push(result.value); + } + + return success(output); + }, + jsonSchemaInput: (jsonSchemaOptions) => ({ + type: "array", + items: schema["~standard"].jsonSchema.input(jsonSchemaOptions), + ...(options?.description ? { description: options.description } : {}), + }), + jsonSchemaOutput: (jsonSchemaOptions) => ({ + type: "array", + items: schema["~standard"].jsonSchema.output(jsonSchemaOptions), + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + /** + * Declares an object input composed from named child schemas. + * + * All properties are required unless wrapped in `aiToolSchema.optional(...)`. + * + * @example + * ```ts + * aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * fields: aiToolSchema.optional( + * aiToolSchema.array(aiToolSchema.enum(["name", "email", "status"])), + * ), + * }) + * ``` + */ + object( + shape: TShape, + options?: { description?: string }, + ): AIChatToolSchema, ObjectOutput> { + return createToolSchema({ + validate: async (value) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return failure("Must be an object"); + } + + const output: Record = {}; + + for (const [key, rawSchema] of Object.entries(shape)) { + const schema = unwrapOptionalSchema(rawSchema); + const isOptional = isOptionalSchema(rawSchema); + const inputValue = (value as Record)[key]; + + if (inputValue === undefined) { + if (!isOptional) { + return prefixIssues(failure("Required").issues, key); + } + continue; + } + + const result = await schema["~standard"].validate(inputValue); + if (result.issues) { + return prefixIssues(result.issues, key); + } + output[key] = result.value; + } + + return success(output as ObjectOutput); + }, + jsonSchemaInput: (jsonSchemaOptions) => ({ + type: "object", + properties: Object.fromEntries( + Object.entries(shape).map(([key, rawSchema]) => [ + key, + unwrapOptionalSchema(rawSchema)["~standard"].jsonSchema.input(jsonSchemaOptions), + ]), + ), + required: Object.entries(shape) + .filter(([, rawSchema]) => !isOptionalSchema(rawSchema)) + .map(([key]) => key), + additionalProperties: false, + ...(options?.description ? { description: options.description } : {}), + }), + jsonSchemaOutput: (jsonSchemaOptions) => ({ + type: "object", + properties: Object.fromEntries( + Object.entries(shape).map(([key, rawSchema]) => [ + key, + unwrapOptionalSchema(rawSchema)["~standard"].jsonSchema.output(jsonSchemaOptions), + ]), + ), + required: Object.entries(shape) + .filter(([, rawSchema]) => !isOptionalSchema(rawSchema)) + .map(([key]) => key), + additionalProperties: false, + ...(options?.description ? { description: options.description } : {}), + }), + }); + }, + + /** + * Marks a child schema as optional inside `aiToolSchema.object(...)`. + * + * @example + * ```ts + * aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * fields: aiToolSchema.optional(aiToolSchema.array(aiToolSchema.string())), + * }) + * ``` + */ + optional(schema: TSchema): AIOptionalToolSchema { + return { + ...createToolSchema({ + validate: async (value) => + value === undefined ? success(undefined) : await schema["~standard"].validate(value), + jsonSchemaInput: (jsonSchemaOptions) => + schema["~standard"].jsonSchema.input(jsonSchemaOptions), + jsonSchemaOutput: (jsonSchemaOptions) => + schema["~standard"].jsonSchema.output(jsonSchemaOptions), + }), + [OPTIONAL_TOOL_SCHEMA]: schema, + } as AIOptionalToolSchema; + }, +}; + +/** Context passed to local tool executors. */ +export interface AIChatToolContext { + /** Abort signal for the in-flight chat request. */ + signal: AbortSignal; + /** Public user/assistant transcript visible to the current chat turn. */ + messages: AIChatMessage[]; +} + +/** + * A tool executed locally inside AppShell. + * + * Local tools are validated with Standard Schema, executed in-process, and then + * written back into the internal AI Gateway transcript as `role: "tool"` + * messages for the next model round. + */ +export interface AILocalTool< + TSchema extends AIChatToolSchema = AIChatToolSchema, +> { + kind: "local"; + description?: string; + schema: TSchema; + execute: ( + args: StandardSchemaV1.InferOutput, + context: AIChatToolContext, + ) => unknown | Promise; +} + +/** + * Small typed helper for defining local AI chat tools. + * + * This is mostly an identity function, but it keeps `execute(args)` inferred + * from the provided schema when tools are declared inline. + * + * @example + * ```ts + * import { defineAIChatTool, aiToolSchema } from "@tailor-platform/app-shell"; + * + * const lookupCustomer = defineAIChatTool({ + * description: "Look up a customer in the current workspace", + * schema: aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * }), + * async execute({ customerId }, { signal }) { + * const customer = await fetchCustomer(customerId, { signal }); + * return { + * id: customer.id, + * name: customer.name, + * status: customer.status, + * }; + * }, + * }); + * ``` + */ +export function defineAIChatTool>(tool: { + description?: string; + schema: TSchema; + execute: ( + args: StandardSchemaV1.InferOutput, + context: AIChatToolContext, + ) => unknown | Promise; +}): AILocalTool { + return { + kind: "local", + ...tool, + }; +} + +/** Options for the normalized OpenAI web search provider tool. */ +export interface OpenAIWebSearchToolOptions { + externalWebAccess?: boolean; + searchContextSize?: "low" | "medium" | "high"; + userLocation?: { + type: "approximate"; + country?: string; + city?: string; + region?: string; + timezone?: string; + }; + filters?: { + allowedDomains?: string[]; + }; +} + +/** + * Provider-backed tool definition passed through to the AI Gateway. + * + * Unlike local tools, provider tools are not executed inside AppShell. They are + * serialized into the normalized AI Gateway request and handled upstream. + */ +export interface AIOpenAIWebSearchTool { + kind: "provider"; + provider: "openai"; + tool: "webSearch"; + options?: OpenAIWebSearchToolOptions; +} + +/** + * Union of all tool definitions accepted by `useAIChat({ tools })`. + * + * @example + * ```ts + * import { + * aiProviderTool, + * aiToolSchema, + * defineAIChatTool, + * useAIChat, + * } from "@tailor-platform/app-shell"; + * + * const lookupCustomer = defineAIChatTool({ + * schema: aiToolSchema.object({ + * customerId: aiToolSchema.string(), + * }), + * async execute({ customerId }) { + * return { customerId, name: "Acme Corp" }; + * }, + * }); + * + * useAIChat({ + * client: aiClient, + * model: "gpt-5-mini", + * tools: { + * lookupCustomer, + * web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), + * }, + * }); + * ``` + */ +export type AIChatConfiguredTool = AILocalTool | AIOpenAIWebSearchTool; + +/** + * Factory helpers for provider-backed tools that are not executed locally. + * + * @example + * ```ts + * import { aiProviderTool } from "@tailor-platform/app-shell"; + * + * const webSearch = aiProviderTool.openai.webSearch({ + * searchContextSize: "high", + * userLocation: { + * type: "approximate", + * country: "JP", + * city: "Tokyo", + * timezone: "Asia/Tokyo", + * }, + * filters: { + * allowedDomains: ["nikkei.com", "reuters.com"], + * }, + * }); + * ``` + */ +export const aiProviderTool = { + openai: { + /** + * Registers the OpenAI web search provider tool. + * + * AppShell passes this through to the AI Gateway and does not execute it + * locally. + * + * @example + * ```ts + * aiProviderTool.openai.webSearch({ + * searchContextSize: "high", + * userLocation: { + * type: "approximate", + * country: "JP", + * city: "Tokyo", + * timezone: "Asia/Tokyo", + * }, + * }) + * ``` + */ + webSearch(options?: OpenAIWebSearchToolOptions): AIOpenAIWebSearchTool { + return { + kind: "provider", + provider: "openai", + tool: "webSearch", + options, + }; + }, + }, +}; diff --git a/packages/core/src/ai/use-ai-chat.test.tsx b/packages/core/src/ai/use-ai-chat.test.tsx index 54c96f22..2608103b 100644 --- a/packages/core/src/ai/use-ai-chat.test.tsx +++ b/packages/core/src/ai/use-ai-chat.test.tsx @@ -1,7 +1,8 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { useAIChat } from "./use-ai-chat"; -import type { AIGatewayClient } from "./client"; +import type { AIGatewayClient, AIChatCompletionEvent } from "./client"; +import { aiProviderTool, aiToolSchema, defineAIChatTool } from "./tools"; function createAbortError(): Error { if (typeof DOMException !== "undefined") { @@ -250,6 +251,192 @@ describe("useAIChat", () => { }); }); + it("executes local tools and continues the conversation", async () => { + const lookupCustomer = defineAIChatTool({ + description: "Look up a customer", + schema: aiToolSchema.object({ + customerId: aiToolSchema.string(), + }), + async execute({ customerId }) { + return { customerId, name: "Acme Corp" }; + }, + }); + + const client = { + streamChatCompletion: vi.fn(async function* ({ messages, tools }) { + const lastMessage = messages.at(-1); + + if (lastMessage?.role === "user") { + expect(tools).toEqual([ + { + type: "function", + function: { + name: "lookupCustomer", + description: "Look up a customer", + parameters: { + type: "object", + properties: { + customerId: { type: "string" }, + }, + required: ["customerId"], + additionalProperties: false, + }, + }, + }, + ]); + + yield { + type: "tool-call", + toolCallId: "call-1", + toolName: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', + } as const; + yield { type: "done", finishReason: "tool_calls" } as const; + return; + } + + expect(messages).toEqual([ + { role: "user", content: "Find customer" }, + { + role: "assistant", + toolCalls: [ + { + id: "call-1", + name: "lookupCustomer", + argumentsText: '{"customerId":"cust-1"}', + }, + ], + }, + { + role: "tool", + toolCallId: "call-1", + content: '{"customerId":"cust-1","name":"Acme Corp"}', + }, + ]); + + yield { type: "text-delta", text: "Customer: Acme Corp" } as const; + yield { type: "done", finishReason: "stop" } as const; + }), + } satisfies AIGatewayClient; + + const { result } = renderHook(() => + useAIChat({ + client, + model: "gpt-5-mini", + tools: { + lookupCustomer, + }, + }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Find customer")).resolves.toBe(true); + }); + + await waitFor(() => { + expect(result.current.status).toBe("ready"); + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Find customer" }, + { id: "id-2", role: "assistant", content: "Customer: Acme Corp" }, + ]); + }); + + expect(client.streamChatCompletion).toHaveBeenCalledTimes(2); + }); + + it("fails after too many tool rounds", async () => { + const loop = defineAIChatTool({ + description: "Loop forever", + schema: aiToolSchema.object({ + value: aiToolSchema.string(), + }), + async execute({ value }) { + return { value }; + }, + }); + + let callCount = 0; + const client = { + streamChatCompletion: vi.fn(async function* () { + callCount += 1; + yield { + type: "tool-call", + toolCallId: `call-${callCount}`, + toolName: "loop", + argumentsText: '{"value":"again"}', + } as const; + yield { type: "done", finishReason: "tool_calls" } as const; + }), + } satisfies AIGatewayClient; + + const { result } = renderHook(() => + useAIChat({ + client, + model: "gpt-5-mini", + tools: { + loop, + }, + }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Start looping")).resolves.toBe(false); + }); + + await waitFor(() => { + expect(result.current.status).toBe("error"); + expect(result.current.error?.message).toContain("maximum number of tool rounds"); + }); + }); + + it("passes provider tools through and exposes sources", async () => { + const client = { + streamChatCompletion: vi.fn(async function* ({ tools }) { + expect(tools).toEqual([ + { + type: "provider", + provider: "openai", + name: "web_search", + options: { searchContextSize: "high" }, + }, + ]); + yield { type: "text-delta", text: "Latest market news" } as const; + const doneEvent: AIChatCompletionEvent = { + type: "done", + finishReason: "stop", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }; + yield doneEvent; + }), + } satisfies AIGatewayClient; + + const { result } = renderHook(() => + useAIChat({ + client, + model: "gpt-5-mini", + tools: { + web_search: aiProviderTool.openai.webSearch({ searchContextSize: "high" }), + }, + }), + ); + + await act(async () => { + await expect(result.current.sendMessage("Search the web")).resolves.toBe(true); + }); + + await waitFor(() => { + expect(result.current.messages).toEqual([ + { id: "id-1", role: "user", content: "Search the web" }, + { + id: "id-2", + role: "assistant", + content: "Latest market news", + sources: [{ type: "url", url: "https://example.com", title: "Example" }], + }, + ]); + }); + }); + it("ignores blank and concurrent sends", async () => { let releaseRequest!: () => void; const requestGate = new Promise((resolve) => { diff --git a/packages/core/src/ai/use-ai-chat.ts b/packages/core/src/ai/use-ai-chat.ts index c551b89a..0f94a278 100644 --- a/packages/core/src/ai/use-ai-chat.ts +++ b/packages/core/src/ai/use-ai-chat.ts @@ -1,16 +1,82 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { isAbortError } from "./client"; -import type { AIGatewayChatMessage, AIGatewayClient } from "./client"; - +import type { AIGatewayChatMessage, AIGatewayClient, AIChatSource } from "./client"; +import { + runAssistantLoop, + type AIChatAssistantTurnResult, + type AssistantLoopEvent, +} from "./assistant-loop"; +import type { AIChatConfiguredTool } from "./tools"; + +/** Public chat message shape exposed by `useAIChat()`. */ export interface AIChatMessage { id: string; role: "user" | "assistant"; content: string; + sources?: AIChatSource[]; } export type AIChatStatus = "ready" | "submitted" | "streaming" | "error"; -export function useAIChat(config: { client: AIGatewayClient; model: string }): { +/** Active request handle stored in the ref for staleness checks + cancellation. */ +interface ActiveHandle { + readonly controller: AbortController; + readonly ctx: ChatRequestContext; +} + +/** + * Encapsulates per-request mutable state: transcript staging + streaming turn buffer. + * + * Transcript is "staged" here and only committed to the persistent ref on success, + * so failed/aborted requests don't pollute conversation history. + */ +class ChatRequestContext { + transcript: AIGatewayChatMessage[]; + private turnMessageId: string | null = null; + private turnContent = ""; + + constructor(initialTranscript: AIGatewayChatMessage[]) { + this.transcript = [...initialTranscript]; + } + + /** Appends a text delta to the current turn buffer, lazily creating a message id. */ + appendDelta(delta: string): { messageId: string; content: string; isNew: boolean } { + this.turnContent = `${this.turnContent}${delta}`; + const isNew = !this.turnMessageId; + if (isNew) { + this.turnMessageId = crypto.randomUUID(); + } + return { messageId: this.turnMessageId!, content: this.turnContent, isNew }; + } + + get currentMessageId(): string | null { + return this.turnMessageId; + } + + commitTurn(turn: AIChatAssistantTurnResult): void { + if (turn.gatewayAssistantMessage.content || turn.toolCalls.length > 0) { + this.transcript.push(turn.gatewayAssistantMessage); + } + this.turnMessageId = null; + this.turnContent = ""; + } + + commitToolResults(messages: Extract[]): void { + this.transcript.push(...messages); + } +} + +/** + * React hook for AI Gateway chat with optional local and provider tools. + * + * Public state stays user/assistant text-first while tool-call and tool-result + * protocol messages remain internal to the hook. + */ +export function useAIChat(config: { + client: AIGatewayClient; + model: string; + tools?: Record; +}): { messages: AIChatMessage[]; status: AIChatStatus; error?: Error; @@ -24,49 +90,35 @@ export function useAIChat(config: { client: AIGatewayClient; model: string }): { const [messages, setMessages] = useState([]); const [status, setStatus] = useState("ready"); const [error, setError] = useState(undefined); - const messagesRef = useRef([]); - const activeRequestRef = useRef(null); - const abortControllerRef = useRef(null); + const transcriptRef = useRef([]); + const activeRef = useRef(null); const updateMessages = useCallback((updater: (previous: AIChatMessage[]) => AIChatMessage[]) => { - setMessages((previous) => { - const next = updater(previous); - messagesRef.current = next; - return next; - }); + setMessages((previous) => updater(previous)); }, []); const stop = useCallback(() => { - const controller = abortControllerRef.current; - - if (!controller) { - return; - } + const active = activeRef.current; + if (!active) return; - activeRequestRef.current = null; - abortControllerRef.current = null; - controller.abort(); + activeRef.current = null; + active.controller.abort(); setStatus("ready"); }, []); useEffect(() => { return () => { - activeRequestRef.current = null; - abortControllerRef.current?.abort(); - abortControllerRef.current = null; + activeRef.current?.controller.abort(); + activeRef.current = null; }; }, []); const sendMessage = useCallback( - async (message: string) => { - if (activeRequestRef.current) { - return false; - } + async (message: string): Promise => { + if (activeRef.current) return false; const text = message.trim(); - if (!text) { - return false; - } + if (!text) return false; const userMessage: AIChatMessage = { id: crypto.randomUUID(), @@ -74,67 +126,40 @@ export function useAIChat(config: { client: AIGatewayClient; model: string }): { content: text, }; - const nextMessages = [...messagesRef.current, userMessage]; - messagesRef.current = nextMessages; - setMessages(nextMessages); + updateMessages((previous) => [...previous, userMessage]); setError(undefined); setStatus("submitted"); + const ctx = new ChatRequestContext([ + ...transcriptRef.current, + { role: "user", content: text }, + ]); const controller = new AbortController(); - const requestId = Symbol(); - activeRequestRef.current = requestId; - abortControllerRef.current = controller; - let assistantMessageId: string | null = null; - const isActive = () => activeRequestRef.current === requestId; + const handle: ActiveHandle = { controller, ctx }; + activeRef.current = handle; try { - for await (const event of config.client.streamChatCompletion({ + for await (const event of runAssistantLoop({ + client: config.client, model: config.model, - messages: nextMessages.map(toGatewayMessage), + tools: config.tools, + transcript: ctx.transcript, signal: controller.signal, })) { - if (!isActive()) { - return false; - } - - if (event.type !== "text-delta" || !event.text) { - continue; - } - - setStatus("streaming"); - - if (!assistantMessageId) { - assistantMessageId = crypto.randomUUID(); - updateMessages((previous) => [ - ...previous, - { - id: assistantMessageId!, - role: "assistant", - content: event.text, - }, - ]); - continue; - } - - updateMessages((previous) => - previous.map((entry) => - entry.id === assistantMessageId - ? { ...entry, content: `${entry.content}${event.text}` } - : entry, - ), - ); + if (activeRef.current !== handle) break; + applyEvent(event, ctx, updateMessages, setStatus); } - if (!isActive()) { - return false; - } + if (activeRef.current !== handle) return false; + // Commit staged transcript to persistent history + transcriptRef.current = ctx.transcript; + activeRef.current = null; setStatus("ready"); return true; } catch (caughtError) { - if (!isActive()) { - return false; - } + if (activeRef.current !== handle) return false; + activeRef.current = null; if (isAbortError(caughtError)) { setStatus("ready"); @@ -144,33 +169,65 @@ export function useAIChat(config: { client: AIGatewayClient; model: string }): { setError(toError(caughtError)); setStatus("error"); return false; - } finally { - if (activeRequestRef.current === requestId) { - activeRequestRef.current = null; - } - - if (abortControllerRef.current === controller) { - abortControllerRef.current = null; - } } }, - [config.client, config.model, updateMessages], + [config.client, config.model, config.tools, updateMessages], ); - return { - messages, - status, - error, - sendMessage, - stop, - }; + return { messages, status, error, sendMessage, stop }; } -function toGatewayMessage(message: AIChatMessage): AIGatewayChatMessage { - return { - role: message.role, - content: message.content, - }; +/** Single dispatch point: loop events → React state updates + context mutations. */ +function applyEvent( + event: AssistantLoopEvent, + ctx: ChatRequestContext, + updateMessages: (updater: (previous: AIChatMessage[]) => AIChatMessage[]) => void, + setStatus: (status: AIChatStatus) => void, +): void { + switch (event.type) { + case "text-delta": { + const { messageId, content, isNew } = ctx.appendDelta(event.delta); + setStatus("streaming"); + + if (isNew) { + updateMessages((previous) => [...previous, { id: messageId, role: "assistant", content }]); + } else { + updateMessages((previous) => + previous.map((entry) => (entry.id === messageId ? { ...entry, content } : entry)), + ); + } + break; + } + + case "sources": { + const id = ctx.currentMessageId; + if (!id) break; + const { sources } = event; + updateMessages((previous) => + previous.map((entry) => (entry.id === id ? { ...entry, sources } : entry)), + ); + break; + } + + case "turn-end": { + ctx.commitTurn(event.turn); + break; + } + + case "tool-resolution-start": { + setStatus("submitted"); + break; + } + + case "tool-results": { + ctx.commitToolResults(event.messages); + break; + } + + case "complete": { + break; + } + } } function toError(error: unknown): Error { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fc297685..eae88470 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -49,9 +49,25 @@ export { type AIGatewayClient, type AIGatewayChatMessage, type AIGatewayChatRequest, + type AIGatewayTool, + type AIGatewayToolCall, + type AIGatewayFunctionTool, + type AIGatewayProviderTool, type AIChatCompletionEvent, + type AIChatSource, } from "./ai/client"; export { useAIChat, type AIChatMessage, type AIChatStatus } from "./ai/use-ai-chat"; +export { + defineAIChatTool, + aiToolSchema, + aiProviderTool, + type AIChatConfiguredTool, + type AIChatToolContext, + type AIChatToolSchema, + type AILocalTool, + type AIOpenAIWebSearchTool, + type OpenAIWebSearchToolOptions, +} from "./ai/tools"; // Re-export auth-public-client types for advanced use cases export type { AuthClient } from "@tailor-platform/auth-public-client";