From 1cbfb23fcc1d5ff0990b4e1cf2115bb9e8de51ee Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 25 Aug 2026 01:35:23 -0600 Subject: [PATCH 1/2] Make invalid tool calls model-visible --- .changeset/fair-tools-recover.md | 21 ++ .../anthropic/src/AnthropicLanguageModel.ts | 83 +--- .../test/AnthropicLanguageModel.test.ts | 63 ++++ .../openai-compat/src/OpenAiLanguageModel.ts | 97 +---- packages/ai/openai/src/OpenAiLanguageModel.ts | 239 +++--------- .../openai/test/OpenAiLanguageModel.test.ts | 39 ++ .../openrouter/src/OpenRouterLanguageModel.ts | 54 +-- packages/effect/src/unstable/ai/AiError.ts | 15 +- .../effect/src/unstable/ai/LanguageModel.ts | 355 +++++++++++++----- packages/effect/src/unstable/ai/Prompt.ts | 21 ++ packages/effect/src/unstable/ai/Response.ts | 313 +++++++++++---- packages/effect/src/unstable/ai/Tool.ts | 55 ++- packages/effect/src/unstable/ai/Toolkit.ts | 205 +++++++--- .../src/unstable/ai/internal/http-details.ts | 28 ++ .../effect/test/unstable/ai/AiError.test.ts | 23 ++ packages/effect/test/unstable/ai/Chat.test.ts | 48 ++- .../test/unstable/ai/LanguageModel.test.ts | 229 ++++++++++- .../effect/test/unstable/ai/Prompt.test.ts | 46 ++- .../effect/test/unstable/ai/Response.test.ts | 60 ++- packages/effect/test/unstable/ai/Tool.test.ts | 60 +-- .../typetest/unstable/ai/LanguageModel.tst.ts | 16 +- .../effect/typetest/unstable/ai/Tool.tst.ts | 41 +- 22 files changed, 1420 insertions(+), 691 deletions(-) create mode 100644 .changeset/fair-tools-recover.md create mode 100644 packages/effect/src/unstable/ai/internal/http-details.ts diff --git a/.changeset/fair-tools-recover.md b/.changeset/fair-tools-recover.md new file mode 100644 index 00000000000..db11c4cae70 --- /dev/null +++ b/.changeset/fair-tools-recover.md @@ -0,0 +1,21 @@ +--- +"@effect/ai-anthropic": patch +"@effect/ai-openai": patch +"@effect/ai-openai-compat": patch +"@effect/ai-openrouter": patch +"effect": patch +--- + +Return unknown tools, invalid tool parameters, and malformed provider tool-call +JSON as model-visible tool call errors instead of failing the language model +operation. Tool call errors retain the original call and become failed tool +results in chat history, allowing a model to correct and retry its request. + +Centralize provider parameter decoding in `LanguageModel`, add +`Response.ToolCallErrorPart`, expose errors through +`GenerateTextResponse.toolCallErrors`, and execute validated parameters through +a composable Toolkit boundary. `HandlerResult` is discriminated by `isFailure`, +so successful and failed results remain narrow after toolkit composition. +Parameter validation errors also accept arbitrary runtime values while retaining +a JSON-encoded representation, so reporting non-JSON invalid parameters cannot +become a defect. diff --git a/packages/ai/anthropic/src/AnthropicLanguageModel.ts b/packages/ai/anthropic/src/AnthropicLanguageModel.ts index 7ba02b588da..74ad53834f4 100644 --- a/packages/ai/anthropic/src/AnthropicLanguageModel.ts +++ b/packages/ai/anthropic/src/AnthropicLanguageModel.ts @@ -18,9 +18,8 @@ import * as Layer from "effect/Layer" import * as Option from "effect/Option" import * as Predicate from "effect/Predicate" import * as Redactable from "effect/Redactable" -import * as Schema from "effect/Schema" +import type * as Schema from "effect/Schema" import * as SchemaAST from "effect/SchemaAST" -import * as SchemaIssue from "effect/SchemaIssue" import * as Stream from "effect/Stream" import type { Span } from "effect/Tracer" import type { Mutable, Simplify } from "effect/Types" @@ -30,7 +29,7 @@ import * as IdGenerator from "effect/unstable/ai/IdGenerator" import * as LanguageModel from "effect/unstable/ai/LanguageModel" import * as AiModel from "effect/unstable/ai/Model" import type * as Prompt from "effect/unstable/ai/Prompt" -import type * as Response from "effect/unstable/ai/Response" +import * as Response from "effect/unstable/ai/Response" import * as Tool from "effect/unstable/ai/Tool" import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" @@ -40,8 +39,6 @@ import type { AnthropicTool } from "./AnthropicTool.ts" import type * as Generated from "./Generated.ts" import * as InternalUtilities from "./internal/utilities.ts" -const formatIssue = SchemaIssue.makeFormatterDefault() - /** * Known Anthropic Claude model identifiers exposed by the generated Anthropic schema. * @@ -1647,13 +1644,11 @@ const makeResponse = Effect.fnUntraced( // Map the provider wire name (e.g. "memory") back to the tool's // custom name (e.g. "AnthropicMemory") that the toolkit is keyed by const toolName = toolNameMapper.getCustomName(part.name) - const params = yield* transformToolCallParams(options.tools, toolName, part.input) - parts.push({ type: "tool-call", id: part.id, name: toolName, - params, + params: part.input, ...(Predicate.isNotUndefined(callerInfo) ? { metadata: { anthropic: { caller: callerInfo } } } : undefined) @@ -2087,13 +2082,11 @@ const makeStreamResponse = Effect.fnUntraced( id: part.id }) - const params = yield* transformToolCallParams(options.tools, toolName, part.input) - parts.push({ type: "tool-call", id: part.id, name: toolName, - params, + params: part.input, ...(Predicate.isNotUndefined(callerInfo) ? { metadata: { anthropic: { caller: callerInfo } } } : undefined) @@ -2675,26 +2668,15 @@ const makeStreamResponse = Effect.fnUntraced( } } - const params = contentBlock.providerExecuted === true - ? Tool.unsafeSecureJsonParse(finalParams) - : yield* transformToolCallParams( - options.tools, - contentBlock.name, - Tool.unsafeSecureJsonParse(finalParams) - ) - - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: contentBlock.id, name: contentBlock.name, - params, - ...(Predicate.isNotUndefined(contentBlock.providerExecuted) - ? { providerExecuted: contentBlock.providerExecuted } - : undefined), - ...(Predicate.isNotUndefined(contentBlock.caller) - ? { metadata: { anthropic: { caller: contentBlock.caller } } } - : undefined) - }) + params: finalParams, + metadata: Predicate.isNotUndefined(contentBlock.caller) + ? { anthropic: { caller: contentBlock.caller } } + : undefined, + providerExecuted: contentBlock.providerExecuted + })) } } @@ -3054,12 +3036,6 @@ const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError }) }) -const tryCodecTransform = (schema: S, method: string) => - Effect.try({ - try: () => toCodecAnthropic(schema), - catch: (error) => unsupportedSchemaError(error, method) - }) - const tryJsonSchema = (schema: S, method: string) => Effect.try({ try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecAnthropic }), @@ -3085,40 +3061,3 @@ const getOutputFormat = Effect.fnUntraced(function*({ capabilities, options }: { } return undefined }) - -const transformToolCallParams = Effect.fnUntraced(function*>( - tools: Tools, - toolName: string, - toolParams: unknown -): Effect.fn.Return { - const tool = tools.find((tool) => tool.name === toolName) - - if (Predicate.isUndefined(tool)) { - return yield* AiError.make({ - module: "AnthropicLanguageModel", - method: "makeResponse", - reason: new AiError.ToolNotFoundError({ - toolName, - availableTools: tools.map((tool) => tool.name) - }) - }) - } - - const { codec } = yield* tryCodecTransform(tool.parametersSchema, "makeResponse") - - const transform = Schema.decodeEffect(codec) - - return yield* ( - transform(toolParams) as Effect.Effect - ).pipe(Effect.mapError((error) => - AiError.make({ - module: "AnthropicLanguageModel", - method: "makeResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams, - description: formatIssue(error.issue) - }) - }) - )) -}) diff --git a/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts b/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts index bd8b1902329..69701ade47e 100644 --- a/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts +++ b/packages/ai/anthropic/test/AnthropicLanguageModel.test.ts @@ -329,6 +329,69 @@ describe("AnthropicLanguageModel", () => { }) describe("generateText", () => { + const TestTool = Tool.make("TestTool", { + parameters: Schema.Struct({ input: Schema.String }), + success: Schema.String + }) + const TestToolkit = Toolkit.make(TestTool) + const TestToolkitLayer = TestToolkit.toLayer({ + TestTool: ({ input }) => Effect.succeed(input) + }) + const toolCallLayer = (name: string, input: unknown) => + AnthropicClient.layer({ apiKey: Redacted.make("sk-test-key") }).pipe( + Layer.provide(Layer.succeed( + HttpClient.HttpClient, + makeHttpClient((request) => + Effect.succeed(jsonResponse(request, { + id: "msg_test_1", + type: "message", + role: "assistant", + model: "claude-sonnet-4-20250514", + content: [{ type: "tool_use", id: "toolu_test_1", name, input }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { + cache_creation: null, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + inference_geo: null, + input_tokens: 10, + output_tokens: 5, + service_tier: null + } + })) + ) + )) + ) + + it.effect("returns invalid tool parameters as a tool call error", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ + prompt: "Use the test tool", + toolkit: TestToolkit + }) + + assert.strictEqual(response.toolCallErrors[0]?.error._tag, "ToolParameterValidationError") + }).pipe( + Effect.provide(AnthropicLanguageModel.model("claude-sonnet-4-20250514")), + Effect.provide(TestToolkitLayer), + Effect.provide(toolCallLayer("TestTool", {})) + )) + + it.effect("returns unknown tool names as a tool call error", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ + prompt: "Use a tool", + toolkit: TestToolkit + }) + + assert.strictEqual(response.toolCallErrors[0]?.error._tag, "ToolNotFoundError") + }).pipe( + Effect.provide(AnthropicLanguageModel.model("claude-sonnet-4-20250514")), + Effect.provide(TestToolkitLayer), + Effect.provide(toolCallLayer("UnknownTool", { input: "test" })) + )) + it.effect("encodes dynamic tools", () => Effect.gen(function*() { let capturedRequest: HttpClientRequest.HttpClientRequest | undefined = undefined diff --git a/packages/ai/openai-compat/src/OpenAiLanguageModel.ts b/packages/ai/openai-compat/src/OpenAiLanguageModel.ts index 71e1e586aa9..411708002d6 100644 --- a/packages/ai/openai-compat/src/OpenAiLanguageModel.ts +++ b/packages/ai/openai-compat/src/OpenAiLanguageModel.ts @@ -18,9 +18,8 @@ import * as Option from "effect/Option" import * as Predicate from "effect/Predicate" import * as Rec from "effect/Record" import * as Redactable from "effect/Redactable" -import * as Schema from "effect/Schema" +import type * as Schema from "effect/Schema" import * as AST from "effect/SchemaAST" -import * as SchemaIssue from "effect/SchemaIssue" import * as Stream from "effect/Stream" import type { Span } from "effect/Tracer" import type { DeepMutable, Simplify } from "effect/Types" @@ -29,7 +28,7 @@ import * as LanguageModel from "effect/unstable/ai/LanguageModel" import * as AiModel from "effect/unstable/ai/Model" import { toCodecOpenAI } from "effect/unstable/ai/OpenAiStructuredOutput" import type * as Prompt from "effect/unstable/ai/Prompt" -import type * as Response from "effect/unstable/ai/Response" +import * as Response from "effect/unstable/ai/Response" import * as Tool from "effect/unstable/ai/Tool" import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" @@ -55,8 +54,6 @@ import { } from "./OpenAiClient.ts" import { addGenAIAnnotations } from "./OpenAiTelemetry.ts" -const formatIssue = SchemaIssue.makeFormatterDefault() - /** * Image detail level for vision requests. */ @@ -633,7 +630,6 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig const [rawResponse, response] = yield* client.createResponse(request) annotateResponse(options.span, rawResponse) return yield* makeResponse({ - options, rawResponse, response, toolNameMapper @@ -648,7 +644,6 @@ export const make = Effect.fnUntraced(function*({ model, config: providerConfig annotateRequest(options.span, request) const [response, stream] = yield* client.createResponseStream(request) return yield* makeStreamResponse({ - options, stream, response, toolNameMapper @@ -1046,12 +1041,10 @@ type ActiveToolCall = { const makeResponse = Effect.fnUntraced( function*>({ - options, rawResponse, response, toolNameMapper }: { - readonly options: LanguageModel.ProviderOptions readonly rawResponse: CreateResponse200 readonly response: HttpClientResponse.HttpClientResponse readonly toolNameMapper: Tool.NameMapper @@ -1091,28 +1084,13 @@ const makeResponse = Effect.fnUntraced( const toolId = toolCall.id ?? `${rawResponse.id}_tool_${index}` const toolName = toolNameMapper.getCustomName(toolCall.function?.name ?? "unknown_tool") const toolParamsJson = toolCall.function?.arguments ?? "{}" - const toolParams = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(toolParamsJson), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams: {}, - description: `Failed to securely JSON parse tool parameters: ${cause}` - }) - }) - }) - const params = yield* transformToolCallParams(options.tools, toolName, toolParams) hasToolCalls = true - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: toolId, name: toolName, - params, + params: toolParamsJson, metadata: { openai: { ...makeItemIdMetadata(toolCall.id) } } - }) + })) } } } @@ -1137,12 +1115,10 @@ const makeResponse = Effect.fnUntraced( const makeStreamResponse = Effect.fnUntraced( function*>({ - options, stream, response, toolNameMapper }: { - readonly options: LanguageModel.ProviderOptions readonly stream: Stream.Stream readonly response: HttpClientResponse.HttpClientResponse readonly toolNameMapper: Tool.NameMapper @@ -1184,28 +1160,13 @@ const makeStreamResponse = Effect.fnUntraced( for (const toolCall of Object.values(activeToolCalls)) { const toolParams = toolCall.arguments.length > 0 ? toolCall.arguments : "{}" - const parsedParams = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(toolParams), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeStreamResponse", - reason: new AiError.ToolParameterValidationError({ - toolName: toolCall.name, - toolParams: {}, - description: `Failed to securely JSON parse tool parameters: ${cause}` - }) - }) - }) - const params = yield* transformToolCallParams(options.tools, toolCall.name, parsedParams) parts.push({ type: "tool-params-end", id: toolCall.id }) - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: toolCall.id, name: toolCall.name, - params, + params: toolParams, metadata: { openai: { ...makeItemIdMetadata(toolCall.id) } } - }) + })) hasToolCalls = true } @@ -1415,12 +1376,6 @@ const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError }) }) -const tryCodecTransform = (schema: S, method: string) => - Effect.try({ - try: () => toCodecOpenAI(schema), - catch: (error) => unsupportedSchemaError(error, method) - }) - const tryJsonSchema = (schema: S, method: string) => Effect.try({ try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecOpenAI }), @@ -1433,42 +1388,6 @@ const tryToolJsonSchema = (tool: T, method: string) => catch: (error) => unsupportedSchemaError(error, method) }) -const transformToolCallParams = Effect.fnUntraced(function*>( - tools: Tools, - toolName: string, - toolParams: unknown -): Effect.fn.Return { - const tool = tools.find((tool) => tool.name === toolName) - - if (Predicate.isUndefined(tool)) { - return yield* AiError.make({ - module: "OpenAiLanguageModel", - method: "makeResponse", - reason: new AiError.ToolNotFoundError({ - toolName, - availableTools: tools.map((tool) => tool.name) - }) - }) - } - - const { codec } = yield* tryCodecTransform(tool.parametersSchema, "makeResponse") - const transform = Schema.decodeEffect(codec) - - return yield* ( - transform(toolParams) as Effect.Effect - ).pipe(Effect.mapError((error) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams, - description: formatIssue(error.issue) - }) - }) - )) -}) - const prepareTools = Effect.fnUntraced(function*>({ config, options, diff --git a/packages/ai/openai/src/OpenAiLanguageModel.ts b/packages/ai/openai/src/OpenAiLanguageModel.ts index 4addf9f5bfc..e67ee57575f 100644 --- a/packages/ai/openai/src/OpenAiLanguageModel.ts +++ b/packages/ai/openai/src/OpenAiLanguageModel.ts @@ -19,7 +19,6 @@ import * as Predicate from "effect/Predicate" import * as Redactable from "effect/Redactable" import * as Schema from "effect/Schema" import * as AST from "effect/SchemaAST" -import * as SchemaIssue from "effect/SchemaIssue" import * as Stream from "effect/Stream" import type { Span } from "effect/Tracer" import type { DeepMutable, Mutable, Simplify } from "effect/Types" @@ -29,7 +28,7 @@ import * as LanguageModel from "effect/unstable/ai/LanguageModel" import * as AiModel from "effect/unstable/ai/Model" import { toCodecOpenAI } from "effect/unstable/ai/OpenAiStructuredOutput" import type * as Prompt from "effect/unstable/ai/Prompt" -import type * as Response from "effect/unstable/ai/Response" +import * as Response from "effect/unstable/ai/Response" import * as Tool from "effect/unstable/ai/Tool" import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" import type * as HttpClientResponse from "effect/unstable/http/HttpClientResponse" @@ -40,8 +39,6 @@ import type * as OpenAiSchema from "./OpenAiSchema.ts" import { addGenAIAnnotations } from "./OpenAiTelemetry.ts" import type * as OpenAiTool from "./OpenAiTool.ts" -const formatIssue = SchemaIssue.makeFormatterDefault() - const ResponseModelIds = Generated.ModelIdsResponses.members[1] const SharedModelIds = Generated.ModelIdsShared.members[1] @@ -1387,32 +1384,12 @@ const makeResponse = Effect.fnUntraced( case "function_call": { hasToolCalls = true - - const toolName = part.name - - const toolParams = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(part.arguments), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams: {}, - description: `Faled to securely JSON parse tool parameters: ${cause}` - }) - }) - }) - - const params = yield* transformToolCallParams(options.tools, part.name, toolParams) - - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: part.call_id, - name: toolName, - params, + name: part.name, + params: part.arguments, metadata: { openai: makeItemIdMetadata(part.id) } - }) + })) break } @@ -1452,24 +1429,21 @@ const makeResponse = Effect.fnUntraced( ? (approvalRequests.get(part.approval_request_id) ?? part.id) : part.id - const { toolName, params } = yield* normalizeMcpToolCall({ + const toolCall = makeMcpToolCallPart({ + id: toolId, toolNameMapper, - toolParams: part.arguments, - method: "makeResponse" + params: part.arguments }) - parts.push({ - type: "tool-call", - id: toolId, - name: toolName, - params, - providerExecuted: true - }) + parts.push(toolCall) + if (toolCall.type === "tool-call-error") { + break + } parts.push({ type: "tool-result", id: toolId, - name: toolName, + name: toolCall.name, isFailure: false, providerExecuted: true, result: { @@ -1495,19 +1469,16 @@ const makeResponse = Effect.fnUntraced( const approvalRequestId = (part as any).approval_request_id ?? part.id const toolId = yield* idGenerator.generateId() - const { toolName, params } = yield* normalizeMcpToolCall({ + const toolCall = makeMcpToolCallPart({ + id: toolId, toolNameMapper, - toolParams: part.arguments, - method: "makeResponse" + params: part.arguments }) - parts.push({ - type: "tool-call", - id: toolId, - name: toolName, - params, - providerExecuted: true - }) + parts.push(toolCall) + if (toolCall.type === "tool-call-error") { + break + } parts.push({ type: "tool-approval-request", @@ -2136,37 +2107,17 @@ const makeStreamResponse = Effect.fnUntraced( hasToolCalls = true - const toolName = event.item.name - const toolArgs = event.item.arguments - - const toolParams = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(toolArgs), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeStreamResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams: {}, - description: `Failed securely JSON parse tool parameters: ${cause}` - }) - }) - }) - - const params = yield* transformToolCallParams(options.tools, toolName, toolParams) - parts.push({ type: "tool-params-end", id: event.item.call_id }) - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: event.item.call_id, - name: toolName, - params, + name: event.item.name, + params: event.item.arguments, metadata: { openai: makeItemIdMetadata(event.item.id) } - }) + })) break } @@ -2205,24 +2156,21 @@ const makeStreamResponse = Effect.fnUntraced( event.item.id) : event.item.id - const { toolName, params } = yield* normalizeMcpToolCall({ + const toolCall = makeMcpToolCallPart({ + id: toolId, toolNameMapper, - toolParams: event.item.arguments, - method: "makeStreamResponse" + params: event.item.arguments }) - parts.push({ - type: "tool-call", - id: toolId, - name: toolName, - params, - providerExecuted: true - }) + parts.push(toolCall) + if (toolCall.type === "tool-call-error") { + break + } parts.push({ type: "tool-result", id: toolId, - name: toolName, + name: toolCall.name, isFailure: false, providerExecuted: true, result: { @@ -2248,18 +2196,15 @@ const makeStreamResponse = Effect.fnUntraced( const toolId = yield* idGenerator.generateId() const approvalRequestId = (event.item as any).approval_request_id ?? event.item.id streamApprovalRequests.set(approvalRequestId, toolId) - const { toolName, params } = yield* normalizeMcpToolCall({ - toolNameMapper, - toolParams: event.item.arguments, - method: "makeStreamResponse" - }) - parts.push({ - type: "tool-call", + const toolCall = makeMcpToolCallPart({ id: toolId, - name: toolName, - params, - providerExecuted: true + toolNameMapper, + params: event.item.arguments }) + parts.push(toolCall) + if (toolCall.type === "tool-call-error") { + break + } parts.push({ type: "tool-approval-request", approvalId: approvalRequestId, @@ -2442,34 +2387,17 @@ const makeStreamResponse = Effect.fnUntraced( ) { hasToolCalls = true - const toolParams = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(event.arguments), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeStreamResponse", - reason: new AiError.ToolParameterValidationError({ - toolName: toolCall.name, - toolParams: {}, - description: `Failed securely JSON parse tool parameters: ${cause}` - }) - }) - }) - - const params = yield* transformToolCallParams(options.tools, toolCall.name, toolParams) - parts.push({ type: "tool-params-end", id: toolCall.id }) - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: toolCall.id, name: toolCall.name, - params, + params: event.arguments, metadata: { openai: makeItemIdMetadata(event.item_id) } - }) + })) toolCall.functionCall.emitted = true } @@ -2969,12 +2897,6 @@ const unsupportedSchemaError = (error: unknown, method: string): AiError.AiError }) }) -const tryCodecTransform = (schema: S, method: string) => - Effect.try({ - try: () => toCodecOpenAI(schema), - catch: (error) => unsupportedSchemaError(error, method) - }) - const tryJsonSchema = (schema: S, method: string) => Effect.try({ try: () => Tool.getJsonSchemaFromSchema(schema, { transformer: toCodecOpenAI }), @@ -3078,40 +3000,20 @@ const getApprovalRequestIdMapping = (prompt: Prompt.Prompt): ReadonlyMap>({ +const makeMcpToolCallPart = >({ + id, toolNameMapper, - toolParams, - method + params }: { + readonly id: string readonly toolNameMapper: Tool.NameMapper - readonly toolParams: unknown - readonly method: string -}): Effect.fn.Return<{ - readonly toolName: string readonly params: unknown -}, AiError.AiError> { - const toolName = toolNameMapper.getCustomName("mcp") - - if (typeof toolParams !== "string") { - return { toolName, params: toolParams } - } - - const params = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(toolParams), - catch: (cause) => - AiError.make({ - module: "OpenAiLanguageModel", - method, - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams, - description: `Failed to securely JSON parse tool parameters: ${cause}` - }) - }) - }) - - return { toolName, params } -}) +}): Response.ToolCallPartEncoded | Response.ToolCallErrorPartEncoded => { + const name = toolNameMapper.getCustomName("mcp") + return typeof params === "string" + ? Response.toolCallPartFromJson({ id, name, params, providerExecuted: true }) + : { type: "tool-call", id, name, params, providerExecuted: true } +} const getUsage = (usage: OpenAiSchema.ResponseUsage | null | undefined): Response.Usage => { if (Predicate.isNullish(usage)) { @@ -3174,40 +3076,3 @@ const toServiceTier = (value: string | undefined): { const getUsageTokenDetail = (details: unknown, key: string): number | undefined => Predicate.hasProperty(details, key) && typeof details[key] === "number" ? details[key] : undefined - -const transformToolCallParams = Effect.fnUntraced(function*>( - tools: Tools, - toolName: string, - toolParams: unknown -): Effect.fn.Return { - const tool = tools.find((tool) => tool.name === toolName) - - if (Predicate.isUndefined(tool)) { - return yield* AiError.make({ - module: "OpenAiLanguageModel", - method: "makeResponse", - reason: new AiError.ToolNotFoundError({ - toolName, - availableTools: tools.map((tool) => tool.name) - }) - }) - } - - const { codec } = yield* tryCodecTransform(tool.parametersSchema, "makeResponse") - - const transform = Schema.decodeEffect(codec) - - return yield* ( - transform(toolParams) as Effect.Effect - ).pipe(Effect.mapError((error) => - AiError.make({ - module: "OpenAiLanguageModel", - method: "makeResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams, - description: formatIssue(error.issue) - }) - }) - )) -}) diff --git a/packages/ai/openai/test/OpenAiLanguageModel.test.ts b/packages/ai/openai/test/OpenAiLanguageModel.test.ts index 19b1e452791..0b17e6cbabf 100644 --- a/packages/ai/openai/test/OpenAiLanguageModel.test.ts +++ b/packages/ai/openai/test/OpenAiLanguageModel.test.ts @@ -968,6 +968,28 @@ describe("OpenAiLanguageModel", () => { ]) )) + it.effect("returns malformed function call JSON as a tool call error", () => + Effect.gen(function*() { + const result = yield* LanguageModel.generateText({ + prompt: "Use the tool", + toolkit: TestToolkit + }).pipe(Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini"))) + + const error = result.toolCallErrors[0]?.error + assert.isDefined(error) + strictEqual(error._tag, "ToolParameterValidationError") + if (error._tag === "ToolParameterValidationError") { + strictEqual(error.toolParams, "{") + } + }).pipe( + Effect.provide([ + makeTestLayer({ + body: { output: [makeFunctionCall("TestTool", {}, { arguments: "{" })] } + }), + TestToolkitLayer + ]) + )) + it.effect("uses canonical OpenAiMcp name for mcp_call", () => Effect.gen(function*() { const result = yield* LanguageModel.generateText({ @@ -994,6 +1016,23 @@ describe("OpenAiLanguageModel", () => { } })))) + it.effect("returns malformed MCP arguments as a tool call error", () => + Effect.gen(function*() { + const result = yield* LanguageModel.generateText({ + prompt: "Use MCP", + toolkit: McpToolkit + }).pipe(Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini"))) + + const error = result.toolCallErrors[0]?.error + assert.isDefined(error) + strictEqual(error._tag, "ToolParameterValidationError") + strictEqual(result.toolResults.length, 0) + }).pipe(Effect.provide(makeTestLayer({ + body: { + output: [makeMcpCall("CheckPackage", {}, { arguments: "{" })] + } + })))) + it.each(["gpt-4.1", "gpt-5.6"] as const)( "maps stable web search action to tool call parameters with %s", (model) => diff --git a/packages/ai/openrouter/src/OpenRouterLanguageModel.ts b/packages/ai/openrouter/src/OpenRouterLanguageModel.ts index b32664cd9de..78ceb37a8ef 100644 --- a/packages/ai/openrouter/src/OpenRouterLanguageModel.ts +++ b/packages/ai/openrouter/src/OpenRouterLanguageModel.ts @@ -31,7 +31,7 @@ import * as LanguageModel from "effect/unstable/ai/LanguageModel" import * as AiModel from "effect/unstable/ai/Model" import { toCodecOpenAI } from "effect/unstable/ai/OpenAiStructuredOutput" import type * as Prompt from "effect/unstable/ai/Prompt" -import type * as Response from "effect/unstable/ai/Response" +import * as Response from "effect/unstable/ai/Response" import { addGenAIAnnotations } from "effect/unstable/ai/Telemetry" import * as Tool from "effect/unstable/ai/Tool" import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest" @@ -1038,30 +1038,14 @@ const makeResponse = Effect.fnUntraced( const toolCall = toolCalls[index] const toolName = toolCall.function.name const toolParams = toolCall.function.arguments ?? "{}" - const params = yield* Effect.try({ - try: () => Tool.unsafeSecureJsonParse(toolParams), - catch: (cause) => - AiError.make({ - module: "OpenRouterLanguageModel", - method: "makeResponse", - reason: new AiError.ToolParameterValidationError({ - toolName, - toolParams: {}, - description: `Failed to securely JSON parse tool parameters: ${cause}` - }) - }) - }) - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: toolCall.id, name: toolName, - params, - // Only attach reasoning_details to the first tool call to avoid - // duplicating thinking blocks for parallel tool calls (Claude) - ...(index === 0 && Predicate.isNotNullish(reasoningDetails) && reasoningDetails.length > 0 - ? { metadata: { openrouter: { reasoningDetails } } } - : undefined) - }) + params: toolParams, + metadata: index === 0 && Predicate.isNotNullish(reasoningDetails) && reasoningDetails.length > 0 + ? { openrouter: { reasoningDetails } } + : undefined + })) } } @@ -1501,26 +1485,14 @@ const makeStreamResponse = Effect.fnUntraced( // Forward any unsent tool calls if finish reason is 'tool-calls' if (finishReason === "tool-calls") { for (const toolCall of Object.values(activeToolCalls)) { - // Coerce invalid tool call parameters to an empty object - let params: unknown - // @effect-diagnostics-next-line tryCatchInEffectGen:off - try { - params = Tool.unsafeSecureJsonParse(toolCall.params) - } catch { - params = {} - } - - // Only attach reasoning_details to the first tool call to avoid - // duplicating thinking blocks for parallel tool calls (Claude) - parts.push({ - type: "tool-call", + parts.push(Response.toolCallPartFromJson({ id: toolCall.id, name: toolCall.name, - params, - metadata: reasoningDetailsAttachedToToolCall ? undefined : { - openrouter: { reasoningDetails: accumulatedReasoningDetails } - } - }) + params: toolCall.params, + metadata: reasoningDetailsAttachedToToolCall + ? undefined + : { openrouter: { reasoningDetails: accumulatedReasoningDetails } } + })) reasoningDetailsAttachedToToolCall = true } diff --git a/packages/effect/src/unstable/ai/AiError.ts b/packages/effect/src/unstable/ai/AiError.ts index 4c399b017f0..d9053cf004f 100644 --- a/packages/effect/src/unstable/ai/AiError.ts +++ b/packages/effect/src/unstable/ai/AiError.ts @@ -19,11 +19,22 @@ import * as Predicate from "../../Predicate.ts" import { redact } from "../../Redactable.ts" import * as Redacted from "../../Redacted.ts" import * as Schema from "../../Schema.ts" +import * as SchemaTransformation from "../../SchemaTransformation.ts" import type * as HttpClientError from "../http/HttpClientError.ts" -import { HttpRequestDetails, HttpResponseDetails } from "./Response.ts" +import { HttpRequestDetails, HttpResponseDetails } from "./internal/http-details.ts" const ReasonTypeId = "~effect/ai/AiError/Reason" as const +const DiagnosticPayload = Schema.Json.pipe( + Schema.decodeTo( + Schema.Unknown, + SchemaTransformation.transform({ + decode: (value) => value, + encode: Schema.encodeSync(Schema.Defect()) + }) + ) +) + const providerMetadataWithDefaults = () => (ProviderMetadata as unknown as typeof ProviderMetadata & Schema.Schema).pipe( Schema.withConstructorDefault(Effect.succeed({})), @@ -1070,7 +1081,7 @@ export class ToolParameterValidationError extends Schema.Error part.type === "tool-call") } + /** + * Returns all tool calls that could not be resolved. + */ + get toolCallErrors(): Array { + return this.content.filter((part) => part.type === "tool-call-error") + } + /** * Returns all tool result parts from the response. */ @@ -1181,17 +1189,15 @@ export const make: (params: { } } - // Construct the response schema with the tools from the toolkit, keeping - // tool call parameters encoded when tool call resolution is disabled - const ResponseSchema = Schema.mutable(Schema.Array(Response.Part( - options.disableToolCallResolution === true ? makeToolkitWithEncodedParameters(toolkit) : toolkit - ))) - // If tool call resolution is disabled, return the response without // resolving the tool calls that were generated if (options.disableToolCallResolution === true) { + const ResponseSchema = Schema.mutable( + Schema.Array(Response.PartWithToolParameters(toolkit, "preserveEncoded")) + ) const rawContent = yield* generateWithNonIncrementalFallback() - const content = yield* Schema.decodeEffect(ResponseSchema)(rawContent) + const normalized = yield* normalizeToolCallsEncoded(rawContent, toolkit, codecTransformer) + const content = yield* Schema.decodeEffect(ResponseSchema)(normalized.parts) if (tracker) { const responseMetadata = content.find((part) => part.type === "response-metadata") if (Predicate.isNotUndefined(responseMetadata) && Predicate.isNotUndefined(responseMetadata.id)) { @@ -1201,33 +1207,27 @@ export const make: (params: { return content as Array> } - // Validates the complete response before tool handlers can perform side - // effects. Tool parameters remain opaque here so their validation keeps - // using Toolkit's more specific ToolParameterValidationError; rawContent - // is decoded a second time with ResponseSchema below to produce the - // typed response. - const PrevalidationSchema = Schema.mutable( - Schema.Array(Response.Part(makeToolkitWithOpaqueParameters(toolkit))) + const ResponseSchema = Schema.mutable( + Schema.Array(Response.PartWithToolParameters(toolkit, "validateDecoded")) ) - const rawContent = yield* generateWithNonIncrementalFallback() - - yield* Schema.decodeEffect(PrevalidationSchema)(rawContent) + const normalizedContent = yield* normalizeToolCalls(rawContent, toolkit, codecTransformer) + // Validate the complete response before tool handlers can perform side + // effects. Invalid tool calls have already been normalized into model- + // visible error parts, while all other response parts are still checked + // by the response schema. + const content = yield* Schema.decodeEffect(ResponseSchema)(normalizedContent.parts) // Resolve the generated tool calls. When the finish reason indicates an // incomplete response, handlers do not run and every executable tool call // gets a synthesized failure result instead. - const incompleteFinishReason = findIncompleteFinishReason(rawContent) + const incompleteFinishReason = findIncompleteFinishReason(content) const toolResults = incompleteFinishReason !== undefined - ? rawContent - .filter((part): part is Response.ToolCallPartEncoded => - part.type === "tool-call" && - part.providerExecuted !== true && - toolkit.tools[part.name] !== undefined - ) + ? normalizedContent.toolCalls + .filter((part) => part.providerExecuted !== true) .map((part) => makeInterruptedToolResult(part, incompleteFinishReason) as ToolResolutionResult) : yield* resolveToolCalls( - rawContent, + normalizedContent.toolCalls, toolkit, providerOptions.prompt.content, concurrency @@ -1240,8 +1240,6 @@ export const make: (params: { Stream.runCollect ) - const content = yield* Schema.decodeEffect(ResponseSchema)(rawContent) - if (tracker) { const responseMetadata = content.find((part) => part.type === "response-metadata") if (Predicate.isNotUndefined(responseMetadata) && Predicate.isNotUndefined(responseMetadata.id)) { @@ -1493,30 +1491,27 @@ export const make: (params: { } } - // Construct the response schema with the tools from the toolkit, keeping - // tool call parameters encoded when tool call resolution is disabled - const ResponseSchema = Schema.NonEmptyArray(Response.StreamPart( - options.disableToolCallResolution === true ? makeToolkitWithEncodedParameters(toolkit) : toolkit - )) - const decodeParts = Schema.decodeEffect(ResponseSchema) - // If tool call resolution is disabled, return the response without // resolving the tool calls that were generated if (options.disableToolCallResolution === true) { + const ResponseSchema = Schema.NonEmptyArray( + Response.StreamPartWithToolParameters(toolkit, "preserveEncoded") + ) + const decodeParts = Schema.decodeEffect(ResponseSchema) return streamWithNonIncrementalFallback().pipe( Stream.mapArrayEffect((parts) => - decodeParts(parts).pipe( - tracker ? - Effect.tap((decodedParts) => { - for (const part of decodedParts) { - if (part.type === "response-metadata" && Predicate.isNotUndefined(part.id)) { - tracker.markParts(providerOptions.prompt.content, part.id) - } + Effect.gen(function*() { + const normalized = yield* normalizeToolCallsEncoded(parts, toolkit, codecTransformer) + const decodedParts = yield* decodeParts([normalized.parts[0], ...normalized.parts.slice(1)]) + if (tracker) { + for (const part of decodedParts) { + if (part.type === "response-metadata" && Predicate.isNotUndefined(part.id)) { + tracker.markParts(providerOptions.prompt.content, part.id) } - return Effect.void - }) : - identity - ) + } + } + return decodedParts + }) ) ) as Stream.Stream< Response.StreamPart, @@ -1525,6 +1520,11 @@ export const make: (params: { > } + const ResponseSchema = Schema.NonEmptyArray( + Response.StreamPartWithToolParameters(toolkit, "validateDecoded") + ) + const decodeParts = Schema.decodeEffect(ResponseSchema) + // Queue for decoded parts and tool results const queue = yield* Queue.make< Response.StreamPart, @@ -1554,10 +1554,10 @@ export const make: (params: { // ends with a complete finish). Providers emit a truncating finish // back-to-back with the last tool call, so this window is what lets an // incomplete finish prevent handlers from starting at all. - const bufferedToolCalls: Array = [] + const bufferedToolCalls: Array> = [] // Helper function to handle tool calls with approval logic - const handleToolCall = Effect.fnUntraced(function*(part: Response.ToolCallPartEncoded) { + const handleToolCall = Effect.fnUntraced(function*(part: DecodedToolCallParts) { const tool = toolkit.tools[part.name] if (!tool) return @@ -1579,7 +1579,7 @@ export const make: (params: { return } - yield* toolkit.handle(part.name, part.params as any, part.id).pipe( + yield* toolkit[Toolkit.Execute](part.name, part.params, part.id).pipe( Stream.unwrap, Stream.runForEach((result) => { const toolResultPart = Response.makePart("tool-result", { @@ -1616,7 +1616,8 @@ export const make: (params: { yield* streamWithNonIncrementalFallback().pipe( Stream.runForEachArray( Effect.fnUntraced(function*(chunk) { - const parts = yield* decodeParts(chunk) + const normalized = yield* normalizeToolCalls(chunk, toolkit, codecTransformer) + const parts = yield* decodeParts([normalized.parts[0], ...normalized.parts.slice(1)]) if (tracker) { for (const part of parts) { if (part.type === "response-metadata" && part.id) { @@ -1646,12 +1647,11 @@ export const make: (params: { yield* forkBufferedToolCalls } // Buffer this chunk's tool calls until the next chunk or the end of - // the stream - use the raw chunk for encoded params - for (const part of chunk) { - if (part.type === "tool-call" && part.providerExecuted !== true) { - if (toolkit.tools[part.name] !== undefined) { - pendingToolCalls.set(part.id, part.name) - } + // the stream. Invalid calls were normalized into error parts above + // and therefore never enter the execution buffer. + for (const part of normalized.toolCalls) { + if (part.providerExecuted !== true) { + pendingToolCalls.set(part.id, part.name) bufferedToolCalls.push(part) } } @@ -2207,7 +2207,8 @@ const createDenialResults = ( // Finish reasons that indicate the provider completed the response. Anything // else (including "unknown", "other", and future reasons) fails safe and // prevents tool handlers from running. -const completeFinishReasons: ReadonlyArray = ["stop", "tool-calls", "pause"] +const isCompleteFinishReason = (reason: unknown): reason is Response.FinishReason => + reason === "stop" || reason === "tool-calls" || reason === "pause" const findIncompleteFinishReason = ( content: ReadonlyArray<{ readonly type: string; readonly reason?: unknown }> @@ -2215,7 +2216,7 @@ const findIncompleteFinishReason = ( for (const part of content) { if ( part.type === "finish" && - !completeFinishReasons.includes(part.reason as Response.FinishReason) + !isCompleteFinishReason(part.reason) ) { return typeof part.reason === "string" ? part.reason : "unknown" } @@ -2246,6 +2247,208 @@ const makeInterruptedToolResult = ( }) } +const decodeToolParameters = ( + schema: S, + params: unknown +): Effect.Effect< + S["Type"], + Schema.SchemaError, + S["DecodingServices"] +> => Schema.decodeUnknownEffect(schema)(params) + +const transformToolCodec = ( + schema: S, + codecTransformer: CodecTransformer +): Effect.Effect< + Schema.ConstraintCodec, + AiError.AiError +> => + Effect.try({ + try: () => codecTransformer(schema).codec, + catch: (error) => + AiError.make({ + module: "LanguageModel", + method: "decodeToolCall", + reason: new AiError.UnsupportedSchemaError({ + description: error instanceof Error ? error.message : String(error) + }) + }) + }) + +type ToolValues> = Tools[Extract] + +type DecodedToolCallParts> = Response.ToolCallPart< + Extract, + Tool.Parameters> +> + +type EncodedToolCallParts> = Response.ToolCallPart< + Extract, + Tool.ParametersEncoded> +> + +const encodeToolCallErrorPart = Schema.encodeSync(Response.ToolCallErrorPart) + +type NormalizedResponsePart< + Part extends Response.AllPartsEncoded, + Tools extends Record +> = Part | DecodedToolCallParts | Response.ToolCallErrorPartEncoded + +type NormalizedEncodedResponsePart< + Part extends Response.AllPartsEncoded, + Tools extends Record +> = Part | EncodedToolCallParts | Response.ToolCallErrorPartEncoded + +const isToolName = >( + tools: Tools, + name: string +): name is Extract => Object.hasOwn(tools, name) + +const normalizeToolCall = >( + toolCall: Response.ToolCallPartEncoded, + toolkit: Toolkit.WithHandler, + codecTransformer: CodecTransformer +): Effect.Effect< + | { + readonly _tag: "Success" + readonly decoded: DecodedToolCallParts + } + | { + readonly _tag: "Failure" + readonly part: Response.ToolCallErrorPartEncoded + }, + AiError.AiError, + Tool.ParametersSchema>["DecodingServices"] +> => + Effect.gen(function*() { + if (!isToolName(toolkit.tools, toolCall.name)) { + return { + _tag: "Failure", + part: encodeToolCallErrorPart(Response.makePart("tool-call-error", { + id: toolCall.id, + name: toolCall.name, + params: toolCall.params, + providerExecuted: toolCall.providerExecuted ?? false, + metadata: toolCall.metadata, + error: new AiError.ToolNotFoundError({ + toolName: toolCall.name, + availableTools: Object.keys(toolkit.tools) + }) + })) + } + } + + const tool = toolkit.tools[toolCall.name] + const parametersSchema = tool.parametersSchema + const codec = toolCall.providerExecuted === true || Tool.isDynamic(tool) + ? parametersSchema + : yield* transformToolCodec(parametersSchema, codecTransformer) + const decoded = yield* Effect.result(decodeToolParameters(codec, toolCall.params)) + + if (Result.isFailure(decoded)) { + return { + _tag: "Failure", + part: encodeToolCallErrorPart(Response.makePart("tool-call-error", { + id: toolCall.id, + name: toolCall.name, + params: toolCall.params, + providerExecuted: toolCall.providerExecuted ?? false, + metadata: toolCall.metadata, + error: new AiError.ToolParameterValidationError({ + toolName: toolCall.name, + toolParams: toolCall.params, + description: decoded.failure.message + }) + })) + } + } + + const common = { + id: toolCall.id, + name: toolCall.name, + providerExecuted: toolCall.providerExecuted ?? false, + metadata: toolCall.metadata + } + return { + _tag: "Success", + decoded: Response.toolCallPart({ ...common, params: decoded.success }) + } + }) + +const normalizeToolCalls = >( + content: ReadonlyArray, + toolkit: Toolkit.WithHandler, + codecTransformer: CodecTransformer +): Effect.Effect< + { + readonly parts: Array> + readonly toolCalls: Array> + }, + AiError.AiError, + Tool.ParametersSchema>["DecodingServices"] +> => + Effect.gen(function*() { + const parts: Array> = [] + const toolCalls: Array> = [] + + for (const part of content) { + if (part.type !== "tool-call") { + parts.push(part) + continue + } + const normalized = yield* normalizeToolCall(part, toolkit, codecTransformer) + if (normalized._tag === "Failure") { + parts.push(normalized.part) + } else { + parts.push(normalized.decoded) + toolCalls.push(normalized.decoded) + } + } + + return { parts, toolCalls } + }) + +const normalizeToolCallsEncoded = < + Part extends Response.AllPartsEncoded, + Tools extends Record +>( + content: ReadonlyArray, + toolkit: Toolkit.WithHandler, + codecTransformer: CodecTransformer +): Effect.Effect< + { readonly parts: Array> }, + AiError.AiError | Schema.SchemaError, + | Tool.ParametersSchema>["DecodingServices"] + | Tool.ParametersSchema>["EncodingServices"] +> => + Effect.gen(function*() { + const parts: Array> = [] + + for (const part of content) { + if (part.type !== "tool-call") { + parts.push(part) + continue + } + const normalized = yield* normalizeToolCall(part, toolkit, codecTransformer) + if (normalized._tag === "Failure") { + parts.push(normalized.part) + continue + } + + const tool = toolkit.tools[normalized.decoded.name] + const params = yield* Schema.encodeUnknownEffect(tool.parametersSchema)(normalized.decoded.params) + parts.push(Response.toolCallPart({ + id: normalized.decoded.id, + name: normalized.decoded.name, + params, + providerExecuted: normalized.decoded.providerExecuted, + metadata: normalized.decoded.metadata + })) + } + + return { parts } + }) + type ToolResolutionResult> = | Response.ToolResultPart< Tool.Name, @@ -2255,7 +2458,7 @@ type ToolResolutionResult> = | Response.ToolApprovalRequestPart const resolveToolCalls = >( - content: ReadonlyArray, + toolCalls: ReadonlyArray>, toolkit: Toolkit.WithHandler, messages: ReadonlyArray, concurrency: Concurrency @@ -2264,17 +2467,6 @@ const resolveToolCalls = >( Tool.HandlerError | AiError.AiError, Tool.HandlerServices | IdGenerator > => { - const toolCalls: Array = [] - - for (const part of content) { - if (part.type === "tool-call") { - if (part.providerExecuted === true) { - continue - } - toolCalls.push(part) - } - } - const { approved, denied } = collectToolApprovals(messages) const approvedToolCallIds = new Set( approved.map((approval) => approval.toolCallId) @@ -2283,12 +2475,9 @@ const resolveToolCalls = >( denied.map((denial) => [denial.toolCallId, denial]) ) - const streams = toolCalls.map((toolCall) => + const streams = toolCalls.filter((toolCall) => toolCall.providerExecuted !== true).map((toolCall) => Effect.gen(function*() { const tool = toolkit.tools[toolCall.name] - if (!tool) { - return Stream.empty - } if (deniedByToolCallId.has(toolCall.id)) { const denial = deniedByToolCallId.get(toolCall.id)! @@ -2306,7 +2495,7 @@ const resolveToolCalls = >( } if (approvedToolCallIds.has(toolCall.id)) { - return toolkit.handle(toolCall.name, toolCall.params as any, toolCall.id).pipe( + return toolkit[Toolkit.Execute](toolCall.name, toolCall.params, toolCall.id).pipe( Stream.unwrap, Stream.map( (result) => @@ -2332,7 +2521,7 @@ const resolveToolCalls = >( ) } - return toolkit.handle(toolCall.name, toolCall.params as any, toolCall.id).pipe( + return toolkit[Toolkit.Execute](toolCall.name, toolCall.params, toolCall.id).pipe( Stream.unwrap, Stream.map( (result) => @@ -2354,20 +2543,6 @@ const resolveToolCalls = >( // Utilities // ============================================================================= -const makeToolkitWithEncodedParameters = >( - toolkit: Toolkit.WithHandler -): Toolkit.Any => - Toolkit.make( - ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.toEncoded(tool.parametersSchema))) - ) - -const makeToolkitWithOpaqueParameters = >( - toolkit: Toolkit.WithHandler -): Toolkit.Any => - Toolkit.make( - ...Object.values(toolkit.tools).map((tool) => tool.setParameters(Schema.Unknown)) - ) - const resolveToolkit = , E, R>( toolkit: ToolkitInput ): Effect.Effect, E, R> => diff --git a/packages/effect/src/unstable/ai/Prompt.ts b/packages/effect/src/unstable/ai/Prompt.ts index e902ba692f0..c21cb9cf0f5 100644 --- a/packages/effect/src/unstable/ai/Prompt.ts +++ b/packages/effect/src/unstable/ai/Prompt.ts @@ -2137,6 +2137,27 @@ export const fromResponseParts = (parts: ReadonlyArray): Promp break } + // Tool Call Error Parts + case "tool-call-error": { + assistantParts.push(makePart("tool-call", { + id: part.id, + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, + options: part.metadata + })) + const target = part.providerExecuted === true ? assistantParts : toolParts + target.push(makePart("tool-result", { + id: part.id, + name: part.name, + isFailure: true, + result: part.error, + providerExecuted: part.providerExecuted, + options: part.metadata + })) + break + } + // Tool Result Parts (skip preliminary results) case "tool-result": { if (part.preliminary !== true) { diff --git a/packages/effect/src/unstable/ai/Response.ts b/packages/effect/src/unstable/ai/Response.ts index 26b50b3ebe0..0c09adb1921 100644 --- a/packages/effect/src/unstable/ai/Response.ts +++ b/packages/effect/src/unstable/ai/Response.ts @@ -14,9 +14,12 @@ import type * as DateTime from "../../DateTime.ts" import * as Effect from "../../Effect.ts" import { identity } from "../../Function.ts" import * as Predicate from "../../Predicate.ts" +import * as Result from "../../Result.ts" import * as Schema from "../../Schema.ts" import * as SchemaTransformation from "../../SchemaTransformation.ts" -import type * as Tool from "./Tool.ts" +import * as AiError from "./AiError.ts" +import * as HttpDetails from "./internal/http-details.ts" +import * as Tool from "./Tool.ts" import type * as Toolkit from "./Toolkit.ts" const PartTypeId = "~effect/ai/Response/Part" as const @@ -52,6 +55,7 @@ export type AnyPart = | ToolParamsDeltaPart | ToolParamsEndPart | ToolCallPart + | ToolCallErrorPart | ToolResultPart | ToolApprovalRequestPart | FilePart @@ -80,6 +84,7 @@ export type AnyPartEncoded = | ToolParamsDeltaPartEncoded | ToolParamsEndPartEncoded | ToolCallPartEncoded + | ToolCallErrorPartEncoded | ToolResultPartEncoded | ToolApprovalRequestPartEncoded | FilePartEncoded @@ -108,6 +113,7 @@ export type AllParts> = | ToolParamsDeltaPart | ToolParamsEndPart | ToolCallParts + | ToolCallErrorPart | ToolResultParts | ToolApprovalRequestPart | FilePart @@ -136,6 +142,7 @@ export type AllPartsEncoded = | ToolParamsDeltaPartEncoded | ToolParamsEndPartEncoded | ToolCallPartEncoded + | ToolCallErrorPartEncoded | ToolResultPartEncoded | ToolApprovalRequestPartEncoded | FilePartEncoded @@ -145,6 +152,33 @@ export type AllPartsEncoded = | FinishPartEncoded | ErrorPartEncoded +type ToolkitTools = T extends Toolkit.Toolkit ? Tools + : T extends Toolkit.WithHandler ? Tools + : never + +type ToolParameterMode = "decode" | "preserveEncoded" | "validateDecoded" + +const toolParameterSchema = (tool: Tool.Any, mode: ToolParameterMode): Schema.Top => + mode === "preserveEncoded" ? + Schema.toEncoded(tool.parametersSchema) + : mode === "validateDecoded" ? + Schema.toType(tool.parametersSchema) + : tool.parametersSchema + +const makeToolSchemas = >( + toolkit: T, + toolParameters: ToolParameterMode +) => { + const toolCalls: Array = [] + const toolResults: Array = [] + for (const name in toolkit.tools) { + const tool: Tool.Any = toolkit.tools[name] + toolCalls.push(ToolCallPart(tool.name, toolParameterSchema(tool, toolParameters))) + toolResults.push(ToolResultPart(tool.name, tool.successSchema, tool.failureSchema)) + } + return { toolCalls, toolResults } +} + /** * Creates a Schema for all response parts based on a toolkit. * @@ -176,19 +210,12 @@ export type AllPartsEncoded = export const AllParts = >( toolkit: T ): Schema.Codec< - AllParts : Toolkit.WithHandlerTools>, + AllParts>, AllPartsEncoded, - Tool.ResultDecodingServices[keyof Toolkit.Tools]>, - Tool.ResultEncodingServices[keyof Toolkit.Tools]> + Tool.ResultDecodingServices[keyof ToolkitTools]>, + Tool.ResultEncodingServices[keyof ToolkitTools]> > => { - const toolCalls: Array = [] - const toolResults: Array = [] - for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) - const toolResult = ToolResultPart(tool.name, tool.successSchema, tool.failureSchema) - toolCalls.push(toolCall) - toolResults.push(toolResult) - } + const { toolCalls, toolResults } = makeToolSchemas(toolkit, "decode") return Schema.Union([ TextPart, TextStartPart, @@ -208,6 +235,7 @@ export const AllParts = >( ResponseMetadataPart, FinishPart, ErrorPart, + ToolCallErrorPart, ...toolCalls, ...toolResults ]) as any @@ -231,6 +259,7 @@ export type Part< | TextPart | ReasoningPart | ToolCallParts + | ToolCallErrorPart | ToolResultParts | ToolApprovalRequestPart | FilePart @@ -251,6 +280,7 @@ export type PartEncoded = | ReasoningDeltaPartEncoded | ReasoningEndPartEncoded | ToolCallPartEncoded + | ToolCallErrorPartEncoded | ToolResultPartEncoded | ToolApprovalRequestPartEncoded | FilePartEncoded @@ -259,28 +289,20 @@ export type PartEncoded = | ResponseMetadataPartEncoded | FinishPartEncoded -/** - * Creates a Schema for non-streaming response parts based on a toolkit. - * - * @category schemas - * @since 4.0.0 - */ -export const Part = >( - toolkit: T +/** @internal */ +export const PartWithToolParameters = < + T extends Toolkit.Any | Toolkit.WithHandler, + Mode extends ToolParameterMode +>( + toolkit: T, + toolParameters: Mode ): Schema.Codec< - Part : Toolkit.WithHandlerTools>, + Part, Mode extends "preserveEncoded" ? true : false>, PartEncoded, - Tool.ResultDecodingServices[keyof Toolkit.Tools]>, - Tool.ResultEncodingServices[keyof Toolkit.Tools]> + Tool.ResultDecodingServices[keyof ToolkitTools]>, + Tool.ResultEncodingServices[keyof ToolkitTools]> > => { - const toolCalls: Array = [] - const toolResults: Array = [] - for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) - const toolResult = ToolResultPart(tool.name, tool.successSchema, tool.failureSchema) - toolCalls.push(toolCall) - toolResults.push(toolResult) - } + const { toolCalls, toolResults } = makeToolSchemas(toolkit, toolParameters) return Schema.Union([ TextPart, ReasoningPart, @@ -290,11 +312,27 @@ export const Part = >( UrlSourcePart, ResponseMetadataPart, FinishPart, + ToolCallErrorPart, ...toolCalls, ...toolResults ]) as any } +/** + * Creates a Schema for non-streaming response parts based on a toolkit. + * + * @category schemas + * @since 4.0.0 + */ +export const Part = >( + toolkit: T +): Schema.Codec< + Part>, + PartEncoded, + Tool.ResultDecodingServices[keyof ToolkitTools]>, + Tool.ResultEncodingServices[keyof ToolkitTools]> +> => PartWithToolParameters(toolkit, "decode") + // ============================================================================= // Stream Parts // ============================================================================= @@ -319,6 +357,7 @@ export type StreamPart< | ToolParamsDeltaPart | ToolParamsEndPart | ToolCallParts + | ToolCallErrorPart | ToolResultParts | ToolApprovalRequestPart | FilePart @@ -345,6 +384,7 @@ export type StreamPartEncoded = | ToolParamsDeltaPartEncoded | ToolParamsEndPartEncoded | ToolCallPartEncoded + | ToolCallErrorPartEncoded | ToolResultPartEncoded | ToolApprovalRequestPartEncoded | FilePartEncoded @@ -354,28 +394,20 @@ export type StreamPartEncoded = | FinishPartEncoded | ErrorPartEncoded -/** - * Creates a Schema for streaming response parts based on a toolkit. - * - * @category schemas - * @since 4.0.0 - */ -export const StreamPart = >( - toolkit: T +/** @internal */ +export const StreamPartWithToolParameters = < + T extends Toolkit.Any | Toolkit.WithHandler, + Mode extends ToolParameterMode +>( + toolkit: T, + toolParameters: Mode ): Schema.Codec< - StreamPart : Toolkit.WithHandlerTools>, + StreamPart, Mode extends "preserveEncoded" ? true : false>, StreamPartEncoded, - Tool.ResultDecodingServices[keyof Toolkit.Tools]>, - Tool.ResultEncodingServices[keyof Toolkit.Tools]> + Tool.ResultDecodingServices[keyof ToolkitTools]>, + Tool.ResultEncodingServices[keyof ToolkitTools]> > => { - const toolCalls: Array = [] - const toolResults: Array = [] - for (const tool of Object.values(toolkit.tools as Record)) { - const toolCall = ToolCallPart(tool.name, tool.parametersSchema) - const toolResult = ToolResultPart(tool.name, tool.successSchema, tool.failureSchema) - toolCalls.push(toolCall) - toolResults.push(toolResult) - } + const { toolCalls, toolResults } = makeToolSchemas(toolkit, toolParameters) return Schema.Union([ TextStartPart, TextDeltaPart, @@ -393,11 +425,27 @@ export const StreamPart = >( ResponseMetadataPart, FinishPart, ErrorPart, + ToolCallErrorPart, ...toolCalls, ...toolResults ]) as any } +/** + * Creates a Schema for streaming response parts based on a toolkit. + * + * @category schemas + * @since 4.0.0 + */ +export const StreamPart = >( + toolkit: T +): Schema.Codec< + StreamPart>, + StreamPartEncoded, + Tool.ResultDecodingServices[keyof ToolkitTools]>, + Tool.ResultEncodingServices[keyof ToolkitTools]> +> => StreamPartWithToolParameters(toolkit, "decode") + // ============================================================================= // utility types // ============================================================================= @@ -1443,6 +1491,146 @@ export const toolCallPart = ( params: ConstructorParams> ): ToolCallPart => makePart("tool-call", params) +// ============================================================================= +// Tool Call Error Part +// ============================================================================= + +/** + * An error caused by a model-generated tool call that could not be resolved. + * + * @category errors + * @since 4.0.0 + */ +export type ToolCallError = AiError.ToolNotFoundError | AiError.ToolParameterValidationError + +/** + * Encoded representation of a model-generated tool call error. + * + * @category errors + * @since 4.0.0 + */ +export type ToolCallErrorEncoded = + | typeof AiError.ToolNotFoundError.Encoded + | typeof AiError.ToolParameterValidationError.Encoded + +/** + * Schema for errors caused by model-generated tool calls. + * + * @category schemas + * @since 4.0.0 + */ +export const ToolCallError = Schema.Union([ + AiError.ToolNotFoundError, + AiError.ToolParameterValidationError +]) satisfies Schema.Codec + +const encodeToolCallError = Schema.encodeSync(ToolCallError) + +/** + * Response part representing a model-generated tool call that could not be + * resolved. The original call is preserved so it can be added to model history + * together with the error as a failed tool result. + * + * @category models + * @since 4.0.0 + */ +export interface ToolCallErrorPart extends BasePart<"tool-call-error", ToolCallPartMetadata> { + /** Unique identifier for this tool call. */ + readonly id: string + /** Name requested by the model. */ + readonly name: string + /** Original parameters generated by the model. */ + readonly params: unknown + /** The reason the tool call could not be resolved. */ + readonly error: ToolCallError + /** Whether the tool was executed by the provider. */ + readonly providerExecuted: boolean +} + +/** + * Encoded representation of a tool call error part. + * + * @category models + * @since 4.0.0 + */ +export interface ToolCallErrorPartEncoded extends BasePartEncoded<"tool-call-error", ToolCallPartMetadata> { + readonly id: string + readonly name: string + readonly params: unknown + readonly error: ToolCallErrorEncoded + readonly providerExecuted?: boolean | undefined +} + +/** + * Schema for tool call error parts. + * + * @category schemas + * @since 4.0.0 + */ +export const ToolCallErrorPart = Schema.Struct({ + ...BasePart.fields, + type: Schema.Literal("tool-call-error"), + id: Schema.String, + name: Schema.String, + params: Schema.Unknown, + error: ToolCallError, + providerExecuted: Schema.Boolean.pipe(Schema.withDecodingDefaultKey(Effect.succeed(false))) +}).annotate({ identifier: "ToolCallErrorPart" }) satisfies Schema.Codec< + ToolCallErrorPart, + ToolCallErrorPartEncoded +> + +/** + * Constructs a tool call response part from JSON parameters returned by a + * provider. Malformed or unsafe JSON is preserved as a tool call error so the + * model can receive the failure in subsequent conversation history. + * + * @category constructors + * @since 4.0.0 + */ +export const toolCallPartFromJson = (options: { + readonly id: string + readonly name: string + readonly params: string + readonly providerExecuted?: boolean | undefined + readonly metadata?: ToolCallPartMetadata | undefined +}): ToolCallPartEncoded | ToolCallErrorPartEncoded => { + const parsed = Result.try({ + try: () => Tool.unsafeSecureJsonParse(options.params), + catch: (error) => error instanceof Error ? error.message : String(error) + }) + + const common = { + id: options.id, + name: options.name, + ...(Predicate.isNotUndefined(options.providerExecuted) + ? { providerExecuted: options.providerExecuted } + : undefined), + ...(Predicate.isNotUndefined(options.metadata) ? { metadata: options.metadata } : undefined) + } + + if (Result.isFailure(parsed)) { + return { + type: "tool-call-error", + ...common, + params: options.params, + error: encodeToolCallError( + new AiError.ToolParameterValidationError({ + toolName: options.name, + toolParams: options.params, + description: `Failed to securely JSON parse tool parameters: ${parsed.failure}` + }) + ) + } + } + + return { + type: "tool-call", + ...common, + params: parsed.success + } +} + // ============================================================================= // Tool Call Result Part // ============================================================================= @@ -2158,19 +2346,7 @@ export const UrlSourcePart: Schema.Struct<{ * @category schemas * @since 4.0.0 */ -export const HttpRequestDetails = Schema.Struct({ - method: Schema.Literals(["GET", "POST", "PATCH", "PUT", "DELETE", "HEAD", "OPTIONS", "TRACE"]), - url: Schema.String, - urlParams: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), - hash: Schema.optional(Schema.String), - headers: Schema.Record( - Schema.String, - Schema.Union([ - Schema.String, - Schema.Redacted(Schema.String) - ]) - ) -}).annotate({ identifier: "HttpRequestDetails" }) +export const HttpRequestDetails = HttpDetails.HttpRequestDetails /** * Schema for HTTP response details associated with an AI response. @@ -2199,16 +2375,7 @@ export const HttpRequestDetails = Schema.Struct({ * @category schemas * @since 4.0.0 */ -export const HttpResponseDetails = Schema.Struct({ - status: Schema.Int, - headers: Schema.Record( - Schema.String, - Schema.Union([ - Schema.String, - Schema.Redacted(Schema.String) - ]) - ) -}).annotate({ identifier: "HttpResponseDetails" }) +export const HttpResponseDetails = HttpDetails.HttpResponseDetails // ============================================================================= // Response Metadata Part diff --git a/packages/effect/src/unstable/ai/Tool.ts b/packages/effect/src/unstable/ai/Tool.ts index 39aa8f824b5..362f3f9e6d1 100644 --- a/packages/effect/src/unstable/ai/Tool.ts +++ b/packages/effect/src/unstable/ai/Tool.ts @@ -715,12 +715,7 @@ export type Name = T extends Tool< * @category utility types * @since 4.0.0 */ -export type Parameters = T extends Tool< - infer _Name, - infer _Config, - infer _Requirements -> ? _Config["parameters"]["Type"] - : never +export type Parameters = ParametersSchema["Type"] /** * A utility type to extract the encoded type of the tool call parameters. @@ -728,12 +723,7 @@ export type Parameters = T extends Tool< * @category utility types * @since 4.0.0 */ -export type ParametersEncoded = T extends Tool< - infer _Name, - infer _Config, - infer _Requirements -> ? _Config["parameters"]["Encoded"] - : never +export type ParametersEncoded = ParametersSchema["Encoded"] /** * A utility type to extract the schema for the parameters which an `Tool` @@ -951,28 +941,25 @@ export interface Handler { * @category models * @since 4.0.0 */ -export interface HandlerResult { - /** - * The result of executing the handler for a particular tool. - */ - readonly result: Result - /** - * The pre-encoded tool call result of executing the handler for a particular - * tool as a JSON-serializable value. The encoded result can be incorporated - * into subsequent requests to the large language model. - */ - readonly encodedResult: unknown - /** - * Whether the result of executing the tool call handler was an error or not. - */ - readonly isFailure: boolean - /** - * Whether this is a preliminary (intermediate) result or the final result. - * Preliminary results represent progress updates; only the final result - * should be used as the authoritative output. - */ - readonly preliminary: boolean -} +export type HandlerResult = + | { + /** The successful result of executing the handler. */ + readonly result: Success + /** The pre-encoded tool result that can be sent to a language model. */ + readonly encodedResult: unknown + readonly isFailure: false + /** Whether this is an intermediate result rather than the final result. */ + readonly preliminary: boolean + } + | { + /** The declared or framework-generated failure from executing the handler. */ + readonly result: FailureResult + /** The pre-encoded tool failure that can be sent to a language model. */ + readonly encodedResult: unknown + readonly isFailure: true + /** Failures are always terminal. */ + readonly preliminary: false + } /** * Tagged union for incremental handler output. diff --git a/packages/effect/src/unstable/ai/Toolkit.ts b/packages/effect/src/unstable/ai/Toolkit.ts index 39e01beb790..d2f154cafda 100644 --- a/packages/effect/src/unstable/ai/Toolkit.ts +++ b/packages/effect/src/unstable/ai/Toolkit.ts @@ -26,6 +26,20 @@ import * as AiError from "./AiError.ts" import type * as Tool from "./Tool.ts" const TypeId = "~effect/ai/Toolkit" as const +const WithHandlerTypeId = "~effect/ai/Toolkit/WithHandler" as const + +/** + * Property used to execute a tool whose parameters have already been decoded + * and validated against its parameter schema. + * + * This lower-level boundary is useful when composing resolved toolkits. Most + * callers should use {@link WithHandler.handle}, which accepts encoded model + * output and performs parameter decoding first. + * + * @category symbols + * @since 4.0.0 + */ +export const Execute = "~effect/ai/Toolkit/execute" as const /** * Represents a collection of tools which can be used to enhance the @@ -179,6 +193,32 @@ export type HandlersFrom> = { > } +type HandlerExecutionServices = T extends Tool.Tool< + infer _Name, + infer _Config, + infer Requirements +> ? Tool.ResultEncodingServices | Requirements + : never + +/** + * Handler for tool parameters which have already been decoded and validated. + * + * @category models + * @since 4.0.0 + */ +export type DecodedHandle> = ( + name: Name, + params: Tool.Parameters, + toolCallId?: string +) => Effect.Effect< + Stream.Stream< + Tool.HandlerResult, + Tool.HandlerError, + HandlerExecutionServices + >, + AiError.AiError +> + /** * A toolkit instance with registered handlers ready for tool execution. * @@ -186,6 +226,8 @@ export type HandlersFrom> = { * @since 4.0.0 */ export interface WithHandler> { + readonly [WithHandlerTypeId]: typeof WithHandlerTypeId + /** * The tools available in this toolkit instance. */ @@ -221,6 +263,9 @@ export interface WithHandler> { >, AiError.AiError > + + /** Executes a tool call whose parameters have already been decoded. */ + readonly [Execute]: DecodedHandle } /** @@ -232,6 +277,41 @@ export interface WithHandler> { */ export type WithHandlerTools = T extends WithHandler ? Tools : never +type ErasedHandle = ( + name: string, + params: unknown, + toolCallId?: string +) => Effect.Effect, AiError.AiError, unknown> + +const makeWithHandlerErased = >( + tools: Tools, + handle: ErasedHandle, + execute: ErasedHandle +): WithHandler => ({ + [WithHandlerTypeId]: WithHandlerTypeId, + tools, + handle, + [Execute]: execute +} as WithHandler) + +/** + * Creates a resolved toolkit from encoded and decoded handler boundaries. + * + * This is intended for integrations which compose, route, or decorate an + * existing resolved toolkit. Application toolkits should normally be created + * with {@link make} and provided with handlers through {@link Toolkit.toLayer}. + * + * @category constructors + * @since 4.0.0 + */ +export const makeWithHandler = >( + tools: Tools, + handle: WithHandler["handle"], + execute: DecodedHandle +): WithHandler => { + return makeWithHandlerErased(tools, handle as ErasedHandle, execute as ErasedHandle) +} + const Proto = { ...Effectable.Prototype({ label: "Toolkit", @@ -270,50 +350,27 @@ const Proto = { return schemas } - const handle = Effect.fnUntraced(function*(name: string, params: unknown, toolCallId?: string) { - const tool = Object.hasOwn(tools, name) ? tools[name] : undefined - - yield* Effect.annotateCurrentSpan({ - tool: name, - parameters: params - }) - - // If the tool is not found, return an error - if (Predicate.isUndefined(tool)) { - return yield* AiError.make({ - module: "Toolkit", - method: `${name}.handle`, - reason: new AiError.ToolNotFoundError({ - toolName: name, - availableTools: Object.keys(tools) - }) - }) - } - - // Fetch cached schemas / handlers for the tool - const schemas = getSchemas(tool) - - // Decode the tool call parameters which will be passed to the handler - const decodedParams = yield* schemas.decodeParameters(params).pipe( - Effect.mapError((cause) => - AiError.make({ - module: "Toolkit", - method: `${name}.handle`, - reason: new AiError.ToolParameterValidationError({ - toolName: name, - toolParams: params, - description: cause.message - }) - }) - ) - ) - + const execute = Effect.fnUntraced(function*( + tool: Tool.Any, + schemas: ReturnType, + name: string, + decodedParams: unknown, + toolCallId?: string + ) { // Setup the handler context - const queue = yield* Queue.make<{ - readonly result: any - readonly isFailure: boolean - readonly preliminary: boolean - }, Cause.Done>() + const queue = yield* Queue.make< + | { + readonly result: any + readonly isFailure: false + readonly preliminary: boolean + } + | { + readonly result: any + readonly isFailure: true + readonly preliminary: false + }, + Cause.Done + >() const context: HandlerContext = { toolCallId, preliminary: (result) => @@ -377,7 +434,11 @@ const Proto = { const normalizedError = normalizeError(error) return tool.failureMode === "error" ? Stream.fail(normalizedError) - : Stream.succeed({ result: normalizedError, isFailure: true, preliminary: false }) + : Stream.succeed({ + result: normalizedError, + isFailure: true as const, + preliminary: false as const + }) }), Stream.mapEffect(Effect.fnUntraced(function*(output) { const encodedResult = yield* encodeResult(output.result) @@ -387,10 +448,58 @@ const Proto = { ) satisfies Stream.Stream, any> }) - return { - tools, - handle: handle as any - } satisfies WithHandler> + const getTool = (name: string) => Object.hasOwn(tools, name) ? tools[name] : undefined + + const toolNotFound = (name: string) => + AiError.make({ + module: "Toolkit", + method: `${name}.handle`, + reason: new AiError.ToolNotFoundError({ + toolName: name, + availableTools: Object.keys(tools) + }) + }) + + const handle = Effect.fnUntraced(function*(name: string, params: unknown, toolCallId?: string) { + const tool = getTool(name) + + yield* Effect.annotateCurrentSpan({ tool: name, parameters: params }) + + if (Predicate.isUndefined(tool)) { + return yield* toolNotFound(name) + } + + const schemas = getSchemas(tool) + const decodedParams = yield* schemas.decodeParameters(params).pipe( + Effect.mapError((cause) => + AiError.make({ + module: "Toolkit", + method: `${name}.handle`, + reason: new AiError.ToolParameterValidationError({ + toolName: name, + toolParams: params, + description: cause.message + }) + }) + ) + ) + + return yield* execute(tool, schemas, name, decodedParams, toolCallId) + }) + + const executeDecoded = Effect.fnUntraced(function*(name: string, params: unknown, toolCallId?: string) { + const tool = getTool(name) + + yield* Effect.annotateCurrentSpan({ tool: name, parameters: params }) + + if (Predicate.isUndefined(tool)) { + return yield* toolNotFound(name) + } + + return yield* execute(tool, getSchemas(tool), name, params, toolCallId) + }) + + return makeWithHandlerErased(tools, handle, executeDecoded) }) }), [TypeId]: TypeId, diff --git a/packages/effect/src/unstable/ai/internal/http-details.ts b/packages/effect/src/unstable/ai/internal/http-details.ts new file mode 100644 index 00000000000..e2e8a72e065 --- /dev/null +++ b/packages/effect/src/unstable/ai/internal/http-details.ts @@ -0,0 +1,28 @@ +import * as Schema from "../../../Schema.ts" + +// Kept in a leaf module because both AiError and Response expose these schemas. + +export const HttpRequestDetails = Schema.Struct({ + method: Schema.Literals(["GET", "POST", "PATCH", "PUT", "DELETE", "HEAD", "OPTIONS", "TRACE"]), + url: Schema.String, + urlParams: Schema.Array(Schema.Tuple([Schema.String, Schema.String])), + hash: Schema.optional(Schema.String), + headers: Schema.Record( + Schema.String, + Schema.Union([ + Schema.String, + Schema.Redacted(Schema.String) + ]) + ) +}).annotate({ identifier: "HttpRequestDetails" }) + +export const HttpResponseDetails = Schema.Struct({ + status: Schema.Int, + headers: Schema.Record( + Schema.String, + Schema.Union([ + Schema.String, + Schema.Redacted(Schema.String) + ]) + ) +}).annotate({ identifier: "HttpResponseDetails" }) diff --git a/packages/effect/test/unstable/ai/AiError.test.ts b/packages/effect/test/unstable/ai/AiError.test.ts index 4e0e367ad4d..3ae633b6a9e 100644 --- a/packages/effect/test/unstable/ai/AiError.test.ts +++ b/packages/effect/test/unstable/ai/AiError.test.ts @@ -304,6 +304,16 @@ describe("AiError", () => { assert.deepStrictEqual(error.toolParams, params) }) + it("should accept non-JSON tool params", () => { + const params = { location: Number.NaN } + const error = new AiError.ToolParameterValidationError({ + toolName: "GetWeather", + toolParams: params, + description: "Expected string" + }) + assert.deepStrictEqual(error.toolParams, params) + }) + it("should have _tag set correctly", () => { const error = new AiError.ToolParameterValidationError({ toolName: "Test", @@ -778,6 +788,19 @@ describe("AiError", () => { assert.strictEqual(decoded.description, "Expected string") })) + it.effect("ToolParameterValidationError encodes non-JSON params", () => + Effect.gen(function*() { + const error = new AiError.ToolParameterValidationError({ + toolName: "SendMessage", + toolParams: { message: "hello", priority: Number.NaN }, + description: "Missing recipient" + }) + const encoded = yield* Schema.encodeEffect(AiError.ToolParameterValidationError)(error) + const decoded = yield* Schema.decodeEffect(AiError.ToolParameterValidationError)(encoded) + assert.deepStrictEqual(encoded.toolParams, { message: "hello", priority: null }) + assert.deepStrictEqual(decoded.toolParams, { message: "hello", priority: null }) + })) + it.effect("InvalidToolResultError roundtrip", () => Effect.gen(function*() { const error = new AiError.InvalidToolResultError({ diff --git a/packages/effect/test/unstable/ai/Chat.test.ts b/packages/effect/test/unstable/ai/Chat.test.ts index e3f89c0462e..e6b7ee72c96 100644 --- a/packages/effect/test/unstable/ai/Chat.test.ts +++ b/packages/effect/test/unstable/ai/Chat.test.ts @@ -1,7 +1,7 @@ import { assert, describe, it } from "@effect/vitest" import { Effect, Layer, Predicate, Ref, Schema } from "effect" import { TestClock } from "effect/testing" -import { Chat, IdGenerator, Prompt } from "effect/unstable/ai" +import { Chat, IdGenerator, Prompt, Tool, Toolkit } from "effect/unstable/ai" import { Persistence } from "effect/unstable/persistence" import * as TestUtils from "./utils.ts" @@ -16,6 +16,52 @@ const PersistenceLayer = Layer.provideMerge( ) describe("Chat", () => { + it.effect("stores invalid tool calls as model-visible history", () => + Effect.gen(function*() { + const TestTool = Tool.make("TestTool", { + parameters: Schema.Struct({ input: Schema.String }), + success: Schema.String + }) + const toolkit = Toolkit.make(TestTool) + const handlers = toolkit.toLayer({ + TestTool: ({ input }) => Effect.succeed(input) + }) + const chat = yield* Chat.empty + + yield* chat.generateText({ + prompt: "Use the test tool", + toolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "call-1", + name: "TestTool", + params: {} + }] + }), + Effect.provide(handlers) + ) + + const history = yield* Ref.get(chat.history) + const assistant = history.content[1] + const tool = history.content[2] + assert.strictEqual(assistant?.role, "assistant") + assert.strictEqual(tool?.role, "tool") + if (assistant?.role !== "assistant" || tool?.role !== "tool") { + return + } + assert.strictEqual(assistant.content[0]?.type, "tool-call") + assert.strictEqual(tool.content[0]?.type, "tool-result") + if (tool.content[0]?.type === "tool-result") { + assert.strictEqual(tool.content[0].isFailure, true) + assert.isTrue(Predicate.hasProperty(tool.content[0].result, "_tag")) + if (Predicate.hasProperty(tool.content[0].result, "_tag")) { + assert.strictEqual(tool.content[0].result._tag, "ToolParameterValidationError") + } + } + })) + it.effect("should persist chat history to the backing persistence store", () => Effect.gen(function*() { const storeId = "chat" diff --git a/packages/effect/test/unstable/ai/LanguageModel.test.ts b/packages/effect/test/unstable/ai/LanguageModel.test.ts index 7cbd2966e3f..a3cb3f6d959 100644 --- a/packages/effect/test/unstable/ai/LanguageModel.test.ts +++ b/packages/effect/test/unstable/ai/LanguageModel.test.ts @@ -138,7 +138,7 @@ describe("LanguageModel", () => { strictEqual(yield* Ref.get(calls), 0) })) - it.effect("fails cleanly for a tool call with a missing params field", () => + it.effect("returns a missing tool params field as a model-visible error", () => Effect.gen(function*() { const calls = yield* Ref.make(0) const handlers = MyToolkit.toLayer({ @@ -148,7 +148,7 @@ describe("LanguageModel", () => { ) }) - const error = yield* LanguageModel.generateText({ + const response = yield* LanguageModel.generateText({ prompt: [], toolkit: MyToolkit }).pipe( @@ -158,17 +158,102 @@ describe("LanguageModel", () => { finishPart ] }), - Effect.provide(handlers), - Effect.flip + Effect.provide(handlers) ) - strictEqual(error.reason._tag, "InvalidOutputError") + strictEqual(response.toolCallErrors[0]?.error._tag, "ToolParameterValidationError") strictEqual(yield* Ref.get(calls), 0) })) - it.effect("validates encoded tool parameters when tool call resolution is disabled", () => + it.effect("returns invalid tool calls as model-visible errors", () => Effect.gen(function*() { - const error = yield* LanguageModel.generateText({ + const response = yield* LanguageModel.generateText({ + prompt: [], + toolkit: MyToolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "tool-invalid-params", + name: "MyTool", + params: {} + }] + }), + Effect.provide(MyToolkitLayer) + ) + + const part = response.toolCallErrors[0]! + + strictEqual(part.type, "tool-call-error") + strictEqual(part.id, "tool-invalid-params") + strictEqual(part.name, "MyTool") + deepStrictEqual(part.params, {}) + strictEqual(part.error._tag, "ToolParameterValidationError") + })) + + it.effect("resolves valid tool calls alongside model-visible errors", () => + Effect.gen(function*() { + const calls = yield* Ref.make(0) + const handlers = MyToolkit.toLayer({ + MyTool: () => + Ref.update(calls, (n) => n + 1).pipe( + Effect.as({ testSuccess: "test-success" }) + ) + }) + + const response = yield* LanguageModel.generateText({ + prompt: [], + toolkit: MyToolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [ + { type: "tool-call", id: "tool-invalid", name: "MyTool", params: {} }, + { + type: "tool-call", + id: "tool-valid", + name: "MyTool", + params: { testParam: "test-param" } + } + ] + }), + Effect.provide(handlers) + ) + + strictEqual(response.toolCallErrors.length, 1) + strictEqual(response.toolCalls.length, 1) + strictEqual(response.toolResults.length, 1) + strictEqual(yield* Ref.get(calls), 1) + })) + + it.effect("returns unknown tool calls as model-visible errors", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ + prompt: [], + toolkit: MyToolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "tool-unknown", + name: "UnknownTool", + params: { query: "effect" } + }] + }), + Effect.provide(MyToolkitLayer) + ) + + const part = response.toolCallErrors[0]! + + strictEqual(part.type, "tool-call-error") + strictEqual(part.id, "tool-unknown") + strictEqual(part.name, "UnknownTool") + deepStrictEqual(part.params, { query: "effect" }) + strictEqual(part.error._tag, "ToolNotFoundError") + })) + + it.effect("returns invalid encoded tool parameters when tool call resolution is disabled", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ prompt: [], toolkit: TransformToolkit, disableToolCallResolution: true @@ -181,11 +266,10 @@ describe("LanguageModel", () => { params: { invalid: true } }] }), - Effect.provide(TransformToolkitLayer), - Effect.flip + Effect.provide(TransformToolkitLayer) ) - strictEqual(error.reason._tag, "InvalidOutputError") + strictEqual(response.toolCallErrors[0]?.error._tag, "ToolParameterValidationError") })) it.effect("preserves encoded tool parameters when tool call resolution is disabled", () => @@ -216,6 +300,27 @@ describe("LanguageModel", () => { }), Effect.provide(TransformToolkitLayer) )) + + it.effect("decodes transformed tool parameters once before execution", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ + prompt: [], + toolkit: TransformToolkit + }) + + strictEqual(response.toolCalls[0]?.params, 21) + strictEqual(response.toolResults[0]?.result, 42) + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "tool-transform", + name: "TransformTool", + params: "21" + }] + }), + Effect.provide(TransformToolkitLayer) + )) }) describe("streamText", () => { @@ -480,14 +585,83 @@ describe("LanguageModel", () => { ) })) - it.effect("validates encoded tool parameters when tool call resolution is disabled", () => + it.effect("streams invalid tool calls as model-visible errors", () => Effect.gen(function*() { - const error = yield* LanguageModel.streamText({ + const parts = yield* LanguageModel.streamText({ + prompt: [], + toolkit: MyToolkit + }).pipe( + Stream.runCollect, + TestUtils.withLanguageModel({ + streamText: [{ + type: "tool-call", + id: "tool-invalid-params", + name: "MyTool", + params: {} + }] + }), + Effect.provide(MyToolkitLayer) + ) + + const part = parts.find((part) => part.type === "tool-call-error")! + strictEqual(part.id, "tool-invalid-params") + strictEqual(part.name, "MyTool") + deepStrictEqual(part.params, {}) + strictEqual(part.error._tag, "ToolParameterValidationError") + })) + + it.effect("does not synthesize an interrupted result for an invalid tool call", () => + Effect.gen(function*() { + const parts = yield* LanguageModel.streamText({ + prompt: [], + toolkit: MyToolkit + }).pipe( + Stream.runCollect, + TestUtils.withLanguageModel({ + streamText: [ + { type: "tool-call", id: "tool-invalid", name: "MyTool", params: {} }, + { ...finishPart, reason: "length" } + ] + }), + Effect.provide(MyToolkitLayer) + ) + + deepStrictEqual(parts.map((part) => part.type), ["tool-call-error", "finish"]) + })) + + it.effect("streams unknown tool calls as model-visible errors", () => + Effect.gen(function*() { + const parts = yield* LanguageModel.streamText({ + prompt: [], + toolkit: MyToolkit + }).pipe( + Stream.runCollect, + TestUtils.withLanguageModel({ + streamText: [{ + type: "tool-call", + id: "tool-unknown", + name: "UnknownTool", + params: { query: "effect" } + }] + }), + Effect.provide(MyToolkitLayer) + ) + + const part = parts.find((part) => part.type === "tool-call-error")! + strictEqual(part.id, "tool-unknown") + strictEqual(part.name, "UnknownTool") + deepStrictEqual(part.params, { query: "effect" }) + strictEqual(part.error._tag, "ToolNotFoundError") + })) + + it.effect("streams invalid encoded tool parameters when tool call resolution is disabled", () => + Effect.gen(function*() { + const parts = yield* LanguageModel.streamText({ prompt: [], toolkit: TransformToolkit, disableToolCallResolution: true }).pipe( - Stream.runDrain, + Stream.runCollect, TestUtils.withLanguageModel({ streamText: [{ type: "tool-call", @@ -496,13 +670,36 @@ describe("LanguageModel", () => { params: { invalid: true } }] }), - Effect.provide(TransformToolkitLayer), - Effect.flip + Effect.provide(TransformToolkitLayer) ) - strictEqual(error.reason._tag, "InvalidOutputError") + const part = parts.find((part) => part.type === "tool-call-error") + strictEqual(part?.error._tag, "ToolParameterValidationError") })) + it.effect("decodes transformed tool parameters once before streamed execution", () => + Effect.gen(function*() { + const parts = yield* LanguageModel.streamText({ + prompt: [], + toolkit: TransformToolkit + }).pipe(Stream.runCollect) + + const toolCall = parts.find((part) => part.type === "tool-call") + const toolResult = parts.find((part) => part.type === "tool-result") + strictEqual(toolCall?.params, 21) + strictEqual(toolResult?.result, 42) + }).pipe( + TestUtils.withLanguageModel({ + streamText: [{ + type: "tool-call", + id: "tool-transform", + name: "TransformTool", + params: "21" + }] + }), + Effect.provide(TransformToolkitLayer) + )) + it.effect("preserves encoded tool parameters when tool call resolution is disabled", () => Effect.gen(function*() { const parts = yield* LanguageModel.streamText({ diff --git a/packages/effect/test/unstable/ai/Prompt.test.ts b/packages/effect/test/unstable/ai/Prompt.test.ts index a246f86e5e9..21e4bdb774f 100644 --- a/packages/effect/test/unstable/ai/Prompt.test.ts +++ b/packages/effect/test/unstable/ai/Prompt.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest" import { Schema } from "effect" -import { Prompt, Response } from "effect/unstable/ai" +import { AiError, Prompt, Response } from "effect/unstable/ai" describe("Prompt", () => { describe("part schemas", () => { @@ -256,6 +256,50 @@ describe("Prompt", () => { assert.strictEqual(typeof toolContent[0] === "object" && toolContent[0].type, "tool-result") }) + it("expands tool call errors into model-visible calls and failed results", () => { + const error = new AiError.ToolParameterValidationError({ + toolName: "get_weather", + toolParams: { city: 42 }, + description: "Expected a string at city" + }) + const prompt = Prompt.fromResponseParts([ + Response.makePart("tool-call-error", { + id: "call-1", + name: "get_weather", + params: { city: 42 }, + error, + providerExecuted: false + }) + ]) + + assert.deepStrictEqual( + prompt, + Prompt.make([ + { + role: "assistant", + content: [{ + type: "tool-call", + id: "call-1", + name: "get_weather", + params: { city: 42 }, + providerExecuted: false + }] + }, + { + role: "tool", + content: [{ + type: "tool-result", + id: "call-1", + name: "get_weather", + isFailure: true, + result: error, + providerExecuted: false + }] + } + ]) + ) + }) + it("places provider-executed tool results in the assistant message", () => { const parts = [ Response.makePart("tool-call", { diff --git a/packages/effect/test/unstable/ai/Response.test.ts b/packages/effect/test/unstable/ai/Response.test.ts index 46641349688..90735854b0c 100644 --- a/packages/effect/test/unstable/ai/Response.test.ts +++ b/packages/effect/test/unstable/ai/Response.test.ts @@ -1,9 +1,67 @@ import { describe, it } from "@effect/vitest" import { deepStrictEqual } from "@effect/vitest/utils" import { Effect, Schema } from "effect" -import { Response } from "effect/unstable/ai" +import * as Response from "effect/unstable/ai/Response" describe("Response", () => { + it("constructs tool call parts from provider JSON", () => { + deepStrictEqual( + Response.toolCallPartFromJson({ + id: "call-1", + name: "get_weather", + params: "{\"city\":\"Denver\"}" + }), + { + type: "tool-call", + id: "call-1", + name: "get_weather", + params: { city: "Denver" } + } + ) + }) + + it("preserves malformed provider JSON as a tool call error", () => { + const part = Response.toolCallPartFromJson({ + id: "call-1", + name: "get_weather", + params: "{" + }) + + deepStrictEqual(part.type, "tool-call-error") + if (part.type === "tool-call-error") { + deepStrictEqual(part.params, "{") + deepStrictEqual(part.error._tag, "ToolParameterValidationError") + if (part.error._tag === "ToolParameterValidationError") { + deepStrictEqual(part.error.toolParams, "{") + } + } + }) + + it.effect("round trips tool call errors", () => + Effect.gen(function*() { + const malformed = Response.toolCallPartFromJson({ + id: "call-1", + name: "get_weather", + params: "{" + }) + if (malformed.type !== "tool-call-error") { + throw new Error("Expected malformed tool call parameters") + } + const error = yield* Schema.decodeEffect(Response.ToolCallError)(malformed.error) + const part = Response.makePart("tool-call-error", { + id: "call-1", + name: "get_weather", + params: { city: 42 }, + error, + providerExecuted: false + }) + + const encoded = yield* Schema.encodeEffect(Response.ToolCallErrorPart)(part) + const decoded = yield* Schema.decodeEffect(Response.ToolCallErrorPart)(encoded) + + deepStrictEqual(decoded, part) + })) + it.effect("decodes response metadata with omitted optional fields", () => Effect.gen(function*() { const encoded: Response.ResponseMetadataPartEncoded = { diff --git a/packages/effect/test/unstable/ai/Tool.test.ts b/packages/effect/test/unstable/ai/Tool.test.ts index b92d2d5c34c..cfe97a28519 100644 --- a/packages/effect/test/unstable/ai/Tool.test.ts +++ b/packages/effect/test/unstable/ai/Tool.test.ts @@ -158,7 +158,7 @@ describe("Tool", () => { deepStrictEqual(response, toolResult) })) - it.effect("should raise an error when tool call parameters are invalid", () => + it.effect("should return invalid tool call parameters to the model", () => Effect.gen(function*() { const toolkit = Toolkit.make(FailureModeReturn) @@ -182,24 +182,41 @@ describe("Tool", () => { params: {} }] }), - Effect.provide(handlers), - Effect.flip + Effect.provide(handlers) ) deepStrictEqual( - response, - AiError.make({ - module: "Toolkit", - method: "FailureModeReturn.handle", - reason: new AiError.ToolParameterValidationError({ - toolName: "FailureModeReturn", - toolParams: {}, - description: `Missing key\n at ["testParam"]` - }) + response.toolCallErrors[0]?.error, + new AiError.ToolParameterValidationError({ + toolName: "FailureModeReturn", + toolParams: {}, + description: `Missing key\n at ["testParam"]` }) ) })) + it.effect("should fail safely for non-JSON tool parameters", () => { + const FiniteNumber = Tool.make("FiniteNumber", { + parameters: Schema.Struct({ value: Schema.Finite }), + success: Schema.Void + }) + const toolkit = Toolkit.make(FiniteNumber) + const handlers = toolkit.toLayer({ + FiniteNumber: () => Effect.void + }) + + return Effect.gen(function*() { + const withHandlers = yield* toolkit + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + const error = yield* withHandlers.handle("FiniteNumber", { value }).pipe(Effect.flip) + strictEqual(error.reason._tag, "ToolParameterValidationError") + if (error.reason._tag === "ToolParameterValidationError") { + deepStrictEqual(error.reason.toolParams, { value }) + } + } + }).pipe(Effect.provide(handlers)) + }) + it.effect("should return AiError when user returns an AiErrorReason when failure mode is return", () => Effect.gen(function*() { const toolkit = Toolkit.make(FailureModeReturn) @@ -870,7 +887,7 @@ describe("Tool", () => { deepStrictEqual(response, toolResult) })) - it.effect("should raise an error when tool call parameters are invalid", () => + it.effect("should return invalid tool call parameters to the model", () => Effect.gen(function*() { const tool = HandlerRequired({ failureMode: "return", @@ -902,20 +919,15 @@ describe("Tool", () => { } ] }), - Effect.provide(handlers), - Effect.flip + Effect.provide(handlers) ) deepStrictEqual( - response, - AiError.make({ - module: "Toolkit", - method: "HandlerRequired.handle", - reason: new AiError.ToolParameterValidationError({ - toolName: "HandlerRequired", - toolParams: {}, - description: `Missing key\n at ["testParam"]` - }) + response.toolCallErrors[0]?.error, + new AiError.ToolParameterValidationError({ + toolName: "HandlerRequired", + toolParams: {}, + description: `Missing key\n at ["testParam"]` }) ) })) diff --git a/packages/effect/typetest/unstable/ai/LanguageModel.tst.ts b/packages/effect/typetest/unstable/ai/LanguageModel.tst.ts index fad424926ce..1f5849f81dd 100644 --- a/packages/effect/typetest/unstable/ai/LanguageModel.tst.ts +++ b/packages/effect/typetest/unstable/ai/LanguageModel.tst.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Schema, type Stream } from "effect" +import { Context, type Effect, Schema, type Stream } from "effect" import { type AiError, type Chat, LanguageModel, Tool, Toolkit } from "effect/unstable/ai" import type * as Response from "effect/unstable/ai/Response" import { describe, expect, it } from "tstyche" @@ -36,6 +36,10 @@ const TransformTool = Tool.make("TransformTool", { success: Schema.Finite }) +declare const toolkitWithRequestContext: Toolkit.WithHandler<{ + readonly ToolWithRequestContext: typeof ToolWithRequestContext +}> + describe("LanguageModel", () => { describe("generateText", () => { it("uses encoded tool parameters when tool call resolution is disabled", () => { @@ -119,17 +123,9 @@ describe("LanguageModel", () => { }) it("includes tool request dependencies for resolved toolkits with handlers", () => { - const toolkit: Toolkit.WithHandler<{ - readonly ToolWithRequestContext: typeof ToolWithRequestContext - }> = { - tools: { - ToolWithRequestContext - }, - handle: () => Effect.die("not implemented") - } const program = LanguageModel.generateText({ prompt: "hello", - toolkit + toolkit: toolkitWithRequestContext }) type ProgramRequirements = typeof program extends Effect.Effect ? R : never diff --git a/packages/effect/typetest/unstable/ai/Tool.tst.ts b/packages/effect/typetest/unstable/ai/Tool.tst.ts index 830941ed80a..9960772babc 100644 --- a/packages/effect/typetest/unstable/ai/Tool.tst.ts +++ b/packages/effect/typetest/unstable/ai/Tool.tst.ts @@ -1,5 +1,5 @@ -import type { Schema } from "effect" -import { Tool } from "effect/unstable/ai" +import { Schema } from "effect" +import { type AiError, Tool, Toolkit } from "effect/unstable/ai" import { describe, expect, it } from "tstyche" describe("Tool", () => { @@ -30,4 +30,41 @@ describe("Tool", () => { >() }) }) + + describe("HandlerResult", () => { + it("narrows successful and failed results by isFailure", () => { + const tool = Tool.make("A", { + success: Schema.String, + failure: Schema.Number, + failureMode: "return" + }) + const check = (result: Tool.HandlerResult) => { + if (result.isFailure) { + expect(result.result).type.toBe() + expect(result.preliminary).type.toBe() + } else { + expect(result.result).type.toBe() + expect(result.preliminary).type.toBe() + } + } + void check + }) + }) + + describe("makeWithHandler", () => { + it("preserves the toolkit across encoded and decoded handlers", () => { + const tool = Tool.make("A", { + parameters: Schema.Struct({ value: Schema.Number }), + success: Schema.String + }) + type Tools = { readonly A: typeof tool } + const compose = ( + tools: Tools, + handle: Toolkit.WithHandler["handle"], + execute: Toolkit.DecodedHandle + ) => Toolkit.makeWithHandler(tools, handle, execute) + + expect>().type.toBe>() + }) + }) }) From e16a01f5eede5f002dc8e894a63e7c9738c19b94 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Tue, 1 Sep 2026 10:04:01 -0600 Subject: [PATCH 2/2] Fix approval decoding and the dynamic tool codec bypass Approval was decided by decoding tool parameters a second time, after `normalizeToolCalls` had already decoded them. For any schema with a transformation the second decode fails, and the surrounding `orElseSucceed(constFalse)` turned that failure into "no approval needed", so a tool that required approval ran without it. Approval now reads the decoded parameters, which is what `NeedsApprovalFunction` already declares it receives, and the function can no longer fail. Dynamic tools bypassed the provider codec whenever they were dynamic. Only a dynamic tool that declares raw JSON Schema should bypass it; one whose parameters are an Effect `Schema` still needs it. The guard now matches the one `Tool.getJsonSchema` already uses. Co-Authored-By: Claude Opus 5 (1M context) --- .../effect/src/unstable/ai/LanguageModel.ts | 22 +-- .../test/unstable/ai/LanguageModel.test.ts | 161 ++++++++++++++++++ packages/effect/test/unstable/ai/utils.ts | 4 + 3 files changed, 177 insertions(+), 10 deletions(-) diff --git a/packages/effect/src/unstable/ai/LanguageModel.ts b/packages/effect/src/unstable/ai/LanguageModel.ts index 214c2ad228b..0db6b7e3169 100644 --- a/packages/effect/src/unstable/ai/LanguageModel.ts +++ b/packages/effect/src/unstable/ai/LanguageModel.ts @@ -15,7 +15,7 @@ import type * as Cause from "../../Cause.ts" import * as Context from "../../Context.ts" import * as Effect from "../../Effect.ts" import * as FiberSet from "../../FiberSet.ts" -import { constFalse, identity, pipe } from "../../Function.ts" +import { identity, pipe } from "../../Function.ts" import type * as JsonSchema from "../../JsonSchema.ts" import * as Option from "../../Option.ts" import * as Predicate from "../../Predicate.ts" @@ -2097,19 +2097,18 @@ const stripResolvedApprovals = ( const isApprovalNeeded = Effect.fnUntraced(function*( tool: T, - toolCall: Response.ToolCallPartEncoded, + // Parameters have already been decoded by the time approval is decided + toolCall: { readonly id: string; readonly params: unknown }, messages: ReadonlyArray -): Effect.fn.Return> { +): Effect.fn.Return { if (Predicate.isUndefined(tool.needsApproval)) { return false } if (typeof tool.needsApproval === "function") { - const params = yield* Schema.decodeUnknownEffect(tool.parametersSchema)( - toolCall.params - ) as any - - const result = tool.needsApproval(params, { + // Decoding `params` again here would fail for any schema that transforms + // its input, and a failure would have read as "no approval needed" + const result = tool.needsApproval(toolCall.params, { toolCallId: toolCall.id, messages }) @@ -2118,7 +2117,7 @@ const isApprovalNeeded = Effect.fnUntraced(function*( } return tool.needsApproval -}, Effect.orElseSucceed(constFalse)) +}) const executeApprovedToolCalls = >( approvals: ReadonlyArray, @@ -2340,7 +2339,10 @@ const normalizeToolCall = >( const tool = toolkit.tools[toolCall.name] const parametersSchema = tool.parametersSchema - const codec = toolCall.providerExecuted === true || Tool.isDynamic(tool) + // Only a dynamic tool that declares raw JSON Schema bypasses the provider + // codec; one whose parameters are an Effect `Schema` still needs it + const declaresJsonSchema = Tool.isDynamic(tool) && Predicate.isNotUndefined(tool.jsonSchema) + const codec = toolCall.providerExecuted === true || declaresJsonSchema ? parametersSchema : yield* transformToolCodec(parametersSchema, codecTransformer) const decoded = yield* Effect.result(decodeToolParameters(codec, toolCall.params)) diff --git a/packages/effect/test/unstable/ai/LanguageModel.test.ts b/packages/effect/test/unstable/ai/LanguageModel.test.ts index a3cb3f6d959..f4681382e5e 100644 --- a/packages/effect/test/unstable/ai/LanguageModel.test.ts +++ b/packages/effect/test/unstable/ai/LanguageModel.test.ts @@ -30,6 +30,37 @@ const TransformToolkitLayer = TransformToolkit.toLayer({ TransformTool: (value) => Effect.succeed(value * 2) }) +const DynamicSchemaTool = Tool.dynamic("DynamicSchemaTool", { + parameters: Schema.Struct({ value: Schema.String }) +}) + +const DynamicJsonSchemaTool = Tool.dynamic("DynamicJsonSchemaTool", { + parameters: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"] + } +}) + +const DynamicToolkit = Toolkit.make(DynamicSchemaTool, DynamicJsonSchemaTool) + +const DynamicToolkitLayer = DynamicToolkit.toLayer({ + DynamicSchemaTool: (params) => Effect.succeed(params), + DynamicJsonSchemaTool: (params) => Effect.succeed(params) +}) + +const TransformApprovalTool = Tool.make("TransformApprovalTool", { + parameters: Schema.FiniteFromString, + success: Schema.Finite, + needsApproval: (value) => value > 10 +}) + +const TransformApprovalToolkit = Toolkit.make(TransformApprovalTool) + +const TransformApprovalToolkitLayer = TransformApprovalToolkit.toLayer({ + TransformApprovalTool: (value) => Effect.succeed(value) +}) + const ApprovalTool = Tool.make("ApprovalTool", { parameters: Schema.Struct({ action: Schema.String }), success: Schema.Struct({ result: Schema.String }), @@ -1959,6 +1990,136 @@ describe("LanguageModel", () => { }) describe("tool approval", () => { + it.effect("applies the provider codec to a dynamic tool defined with a schema", () => + Effect.gen(function*() { + const transformed: Array = [] + const codecTransformer: LanguageModel.CodecTransformer = (schema) => { + transformed.push("DynamicSchemaTool") + return LanguageModel.defaultCodecTransformer(schema) + } + + yield* LanguageModel.generateText({ + prompt: [], + toolkit: DynamicToolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "call-dynamic-schema", + name: "DynamicSchemaTool", + params: { value: "hello" } + }], + codecTransformer + }), + Effect.provide(DynamicToolkitLayer) + ) + + deepStrictEqual(transformed, ["DynamicSchemaTool"]) + })) + + it.effect("bypasses the provider codec for a dynamic tool defined with raw JSON Schema", () => + Effect.gen(function*() { + const transformed: Array = [] + const codecTransformer: LanguageModel.CodecTransformer = (schema) => { + transformed.push("DynamicJsonSchemaTool") + return LanguageModel.defaultCodecTransformer(schema) + } + + yield* LanguageModel.generateText({ + prompt: [], + toolkit: DynamicToolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "call-dynamic-json-schema", + name: "DynamicJsonSchemaTool", + params: { value: "hello" } + }], + codecTransformer + }), + Effect.provide(DynamicToolkitLayer) + ) + + deepStrictEqual(transformed, []) + })) + + it.effect("evaluates needsApproval against decoded parameters when streaming", () => + Effect.gen(function*() { + const parts: Array>> = [] + + yield* LanguageModel.streamText({ + prompt: [], + toolkit: TransformApprovalToolkit + }).pipe( + Stream.runForEach((part) => + Effect.sync(() => { + parts.push(part) + }) + ), + TestUtils.withLanguageModel({ + streamText: [{ + type: "tool-call", + id: "call-approval-transform", + name: "TransformApprovalTool", + params: "42" + }] + }), + Effect.provide(TransformApprovalToolkitLayer) + ) + + const approval = parts.find((part) => part.type === "tool-approval-request") + assertDefined(approval) + strictEqual(parts.some((part) => part.type === "tool-result"), false) + })) + + it.effect("evaluates needsApproval against decoded parameters when generating", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ + prompt: [], + toolkit: TransformApprovalToolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "call-approval-transform", + name: "TransformApprovalTool", + params: "42" + }] + }), + Effect.provide(TransformApprovalToolkitLayer) + ) + + strictEqual( + response.content.some((part) => part.type === "tool-approval-request"), + true + ) + })) + + it.effect("runs the handler when the decoded parameters do not need approval", () => + Effect.gen(function*() { + const response = yield* LanguageModel.generateText({ + prompt: [], + toolkit: TransformApprovalToolkit + }).pipe( + TestUtils.withLanguageModel({ + generateText: [{ + type: "tool-call", + id: "call-approval-transform", + name: "TransformApprovalTool", + params: "7" + }] + }), + Effect.provide(TransformApprovalToolkitLayer) + ) + + strictEqual( + response.content.some((part) => part.type === "tool-approval-request"), + false + ) + deepStrictEqual(response.toolResults[0]?.result, 7) + })) + it.effect("emits tool-approval-request when tool has needsApproval: true", () => Effect.gen(function*() { const parts: Array>> = [] diff --git a/packages/effect/test/unstable/ai/utils.ts b/packages/effect/test/unstable/ai/utils.ts index 40a104f9631..a721f5da088 100644 --- a/packages/effect/test/unstable/ai/utils.ts +++ b/packages/effect/test/unstable/ai/utils.ts @@ -14,6 +14,7 @@ interface WithLanguageModelOptions { | ((options: LanguageModel.ProviderOptions) => | Array | Stream.Stream) + readonly codecTransformer?: LanguageModel.CodecTransformer | undefined } export const withLanguageModel: { @@ -42,6 +43,9 @@ export const withLanguageModel: { const result = options.generateText(opts) return Effect.isEffect(result) ? result : Effect.succeed(result) }, + ...(Predicate.isNotUndefined(options.codecTransformer) + ? { codecTransformer: options.codecTransformer } + : {}), streamText: (opts) => { if (Predicate.isUndefined(options.streamText)) { return Stream.empty