diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index 8fb12c2ec34..46f5e8aafc5 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -9,7 +9,6 @@ import { LanguageModelSchema, ProviderOptions, } from "./options.js" -import { isRecord } from "../utils/record.js" export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"]) export type MessageRole = Schema.Schema.Type @@ -56,11 +55,6 @@ export const MediaPart = Schema.Struct({ }).annotate({ identifier: "LLM.Content.Media" }) export type MediaPart = Schema.Schema.Type -const isToolResultValue = (value: unknown): value is ToolResultValue => - isRecord(value) && - (value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") && - "value" in value - const toolResultValueSchema = Schema.Union([ Schema.Struct({ type: Schema.Literal("json"), @@ -80,6 +74,7 @@ const toolResultValueSchema = Schema.Union([ }), ]).annotate({ identifier: "LLM.ToolResult" }) export type ToolResultValue = Schema.Schema.Type +const isToolResultValue = Schema.is(toolResultValueSchema) export const ToolResultValue = Object.assign(toolResultValueSchema, { is: isToolResultValue, diff --git a/packages/ai/test/schema.test.ts b/packages/ai/test/schema.test.ts index 18e6bc466c6..28325eda5fb 100644 --- a/packages/ai/test/schema.test.ts +++ b/packages/ai/test/schema.test.ts @@ -21,6 +21,7 @@ import { QuotaExceededError, RateLimitError, RouteID, + ToolResultValue, TransportError, UnknownProviderError, Usage, @@ -83,6 +84,37 @@ describe("llm schema", () => { }) }) +describe("ToolResultValue", () => { + test("uses the canonical schema guard", () => { + const cases: ReadonlyArray<{ readonly value: unknown; readonly expected: boolean }> = [ + { value: { type: "json", value: { ok: true } }, expected: true }, + { value: { type: "text", value: "done" }, expected: true }, + { value: { type: "error", value: "failed" }, expected: true }, + { value: { type: "content", value: [{ type: "text", text: "done" }] }, expected: true }, + { value: { type: "content", value: [{ type: "text" }] }, expected: false }, + { value: { type: "content", value: "done" }, expected: false }, + { value: { type: "json" }, expected: false }, + { value: { type: "unknown", value: "done" }, expected: false }, + ] + + for (const item of cases) { + expect(Schema.is(ToolResultValue)(item.value)).toBe(item.expected) + expect(ToolResultValue.is(item.value)).toBe(item.expected) + } + }) + + test("accepts canonical results with extra fields", () => { + expect(ToolResultValue.is({ type: "json", value: { ok: true }, metadata: { source: "tool" } })).toBe(true) + expect( + ToolResultValue.is({ + type: "content", + value: [{ type: "file", uri: "https://example.test/result.txt", mime: "text/plain", checksum: "abc" }], + metadata: { source: "tool" }, + }), + ).toBe(true) + }) +}) + describe("AI.Usage", () => { test("subtractTokens clamps non-sensical breakdowns to zero", () => { // Defense against a provider reporting cached_tokens > prompt_tokens or diff --git a/packages/ai/test/tool-runtime.test.ts b/packages/ai/test/tool-runtime.test.ts index f45455b5d35..8930c486afa 100644 --- a/packages/ai/test/tool-runtime.test.ts +++ b/packages/ai/test/tool-runtime.test.ts @@ -442,6 +442,33 @@ describe("LLMClient tools", () => { }), ) + it.effect("projects malformed tagged dynamic output as opaque JSON", () => + Effect.gen(function* () { + const malformed = { type: "content", value: [{ type: "text" }] } + const dynamic = Tool.make({ + description: "Return caller-defined JSON.", + jsonSchema: { type: "object", properties: {} }, + execute: () => Effect.succeed(malformed), + }) + + const dispatched = yield* ToolRuntime.dispatch( + { dynamic }, + LLMEvent.toolCall({ id: "call_1", name: "dynamic", input: {} }), + ) + + expect(dispatched.result).toEqual({ type: "json", value: malformed }) + expect(dispatched.output).toEqual({ structured: malformed, content: [] }) + expect(dispatched.events).toEqual([ + LLMEvent.toolResult({ + id: "call_1", + name: "dynamic", + result: { type: "json", value: malformed }, + output: { structured: malformed, content: [] }, + }), + ]) + }), + ) + it.effect("executes tool calls for one step without looping by default", () => Effect.gen(function* () { const layer = scriptedResponses([