From 09739b19fb30c96771f7a67ee71b0804f90f68de Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 8 Oct 2026 16:48:55 -0700 Subject: [PATCH] fix(core): return bad tool params to the model instead of ending the run With tool resolution disabled, streamText decodes each tool call's params against the tool's schema while it reads the reply, so one bad call failed the reply and ended the run. The tools handed to streamText now accept any params there; Fold's toolkit.handle checks them when it runs the tool and returns a failed tool result the model can fix. Providers still build the same JSON schema, since only setParameters changes. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a1196a-ea93-79e6-9500-a2651678f1ea --- .../src/AgentRuntime/AgentRuntimeLayer.ts | 20 ++++++-- .../AgentRuntimeToolParams.vi.test.ts | 50 +++++++++++++++++++ .../test/TestLayers/ScriptedLanguageModel.ts | 5 +- 3 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 packages/fold-core/test/AgentRuntime/AgentRuntimeToolParams.vi.test.ts diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index ae89b36..961f4f1 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -79,6 +79,20 @@ const assistantResultText = (message: Prompt.AssistantMessage): string | null => return text.length > 0 ? text : null } +/** + * The tool as handed to `streamText`, which accepts any params while it reads the model's reply. + * + * With tool resolution disabled, `streamText` decodes each tool call's params against + * `tool.setParameters(Schema.toEncoded(tool.parametersSchema))`, so one bad call fails the whole reply and + * ends the run. Fold decodes params when it runs the tool (`toolkit.handle`), which turns a bad call into a + * failed tool result the model can fix. Only `setParameters` changes: providers build the tool's JSON + * schema from the unchanged `parametersSchema`, so the model sees the same schema. + */ +const acceptAnyParamsInReply = (tool: AnyTool): AnyTool => + Object.assign(Object.create(Object.getPrototypeOf(tool)), tool, { + setParameters: () => tool.setParameters(Schema.Unknown), + }) + /** Live AgentRuntime layer wiring the EventLog, hooks, model, and tool settlement into the loop. */ export const liveAgentRuntimeLayer: Layer.Layer< AgentRuntime, @@ -356,9 +370,9 @@ export const liveAgentRuntimeLayer: Layer.Layer< // toolkit is filtered to the projected active names, so a tools-change entry binds the very // next request. Handlers stay untouched - settlement still executes against the full Toolset. const withHandler = yield* toolset.withHandler - const activeToolEntries = Object.entries(withHandler.tools).filter(([name]) => - runtimeState.activeTools.includes(name), - ) + const activeToolEntries = Object.entries(withHandler.tools) + .filter(([name]) => runtimeState.activeTools.includes(name)) + .map(([name, tool]) => [name, acceptAnyParamsInReply(tool)] as const) const toolkit: Toolkit.WithHandler> = { tools: Object.fromEntries(activeToolEntries), handle: withHandler.handle, diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeToolParams.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolParams.vi.test.ts new file mode 100644 index 0000000..0377617 --- /dev/null +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeToolParams.vi.test.ts @@ -0,0 +1,50 @@ +import { expect, it } from '@effect/vitest' +import { Predicate, Effect, Ref } from 'effect' +import { Tool } from 'effect/ai' + +import { AgentRuntime, type ToolResultLogEntry } from '../../src/index' +import { makeScriptedLanguageModel, textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' +import { layerEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' +import { collectEntries } from '../ToolRuntime/ToolRuntimeTestHelpers' +import { agentRuntimeBaseLayer, runInput, startInput } from './AgentRuntimeTestHelpers' + +it.effect('returns a tool call with bad params to the model as a failed tool result', () => + Effect.gen(function* () { + const recorder = yield* makeEchoRecorder + const scripted = yield* makeScriptedLanguageModel([ + toolCallTurn([{ id: 'provider-call-1', name: 'echo', params: { txet: 'hi' } }]), + textTurn('Fixed my call'), + ]) + const layer = agentRuntimeBaseLayer(scripted.layer, layerEchoTool(recorder)) + + const result = yield* Effect.gen(function* () { + const runtime = yield* AgentRuntime + + yield* runtime.start(startInput()) + const finished = yield* runtime.run(runInput('use the echo tool')) + const entries = yield* collectEntries + const calls = yield* Ref.get(recorder.calls) + + return { finished, entries, calls } + }).pipe(Effect.provide(layer)) + + expect(result.calls).toEqual([]) + expect(result.finished.outcome).toBe('completed') + expect(result.finished.resultText).toBe('Fixed my call') + + const toolResult = result.entries.find((entry): entry is ToolResultLogEntry => + Predicate.isTagged(entry, 'tool-result'), + ) + const part = toolResult?.message.content.find((content) => content.type === 'tool-result') + expect(part).toMatchObject({ + isFailure: true, + result: { reason: { _tag: 'ToolParameterValidationError', description: 'Missing key\n at ["text"]' } }, + }) + + // The model is still offered the tool's real params schema. + const requests = yield* scripted.requests + const echo = requests[0]?.tools.find((tool) => tool.name === 'echo') + if (echo === undefined) throw new Error('expected the echo tool on the first request') + expect(Tool.getJsonSchema(echo)).toMatchObject({ properties: { text: { type: 'string' } }, required: ['text'] }) + }), +) diff --git a/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts b/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts index c64fdfa..489433b 100644 --- a/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts +++ b/packages/fold-core/test/TestLayers/ScriptedLanguageModel.ts @@ -12,7 +12,7 @@ import { AnthropicLanguageModel } from '@humanlayer/effect-ai-anthropic' import { OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' import { Predicate, Effect, Layer, Ref, Stream } from 'effect' -import { AiError, LanguageModel, type Prompt, type Response } from 'effect/ai' +import { AiError, LanguageModel, type Prompt, type Response, type Tool } from 'effect/ai' /** Optional shaping for a scripted turn's finish part. */ export type ScriptedFinishOptions = { @@ -89,6 +89,8 @@ export type ScriptedRequest = { readonly prompt: Prompt.Prompt /** Tool names advertised to the model on this request. */ readonly toolNames: ReadonlyArray + /** The tools as the provider received them, to build the schemas it advertises. */ + readonly tools: ReadonlyArray /** The OpenAI per-request Config in context at request time, or null when none was provided. */ readonly openAiConfig: typeof OpenAiLanguageModel.Config.Service | null /** The Anthropic per-request Config in context at request time, or null when none was provided. */ @@ -136,6 +138,7 @@ export const makeScriptedLanguageModel = (turns: ReadonlyArray): E { prompt: options.prompt, toolNames: options.tools.map((tool) => tool.name), + tools: options.tools, openAiConfig: Predicate.isTagged(openAiConfig, 'Some') ? openAiConfig.value : null, anthropicConfig: Predicate.isTagged(anthropicConfig, 'Some') ? anthropicConfig.value : null, },