Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/kind-tools-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/think": patch
---

Expose `TurnConfig.repairToolCall` so callers can repair malformed tool calls before tool execution.
24 changes: 24 additions & 0 deletions docs/think/lifecycle-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ All fields are optional. Return only what you want to change.
| `chatStreamStallTimeoutMs` | `number` | Override the stream-stall watchdog for this turn (`0` disables it); auto-resets after the turn. Useful for a turn with a known-slow tool — see [Think configuration](./index.md). |
| `headers` | `Record<string, string>` | Additional provider request headers |
| `providerOptions` | `Record<string, unknown>` | Provider-specific options |
| `repairToolCall` | `ToolCallRepairFunction` | Repair a tool call that the AI SDK cannot parse or validate. The returned call is revalidated before execution. |
| `experimental_transform` | `StreamTextTransform \| StreamTextTransform[]` | AI SDK stream transform(s) for this turn — inspect or rewrite stream parts (for example, emit `source` parts derived from tool results). Applied in order. |

### Examples
Expand Down Expand Up @@ -199,6 +200,29 @@ beforeTurn() {

`stopWhen` is additive: Think sends both `stepCountIs(maxSteps)` and your condition(s) to the AI SDK, so the loop ends when either one matches. Stop conditions are functions, so they can be returned from a Think subclass's `beforeTurn`, but not from sandboxed extension `beforeTurn` hooks over RPC.

Repair fenced JSON tool input before execution:

````typescript
import { InvalidToolInputError } from "ai";

beforeTurn() {
return {
repairToolCall: async ({ toolCall, error }) => {
if (!InvalidToolInputError.isInstance(error)) return null;

const fencedJson = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(
toolCall.input
);
if (!fencedJson?.[1]) return null;

return { ...toolCall, input: fencedJson[1] };
}
};
}
````

The AI SDK invokes `repairToolCall` only for `NoSuchToolError` or `InvalidToolInputError`. Return a complete raw tool call, usually by preserving the original call and replacing its `input` JSON string, or return `null` when the call cannot be repaired. The SDK parses and schema-validates a returned call once before `beforeToolCall` and tool execution. Because the callback is a function, configure it from a Think subclass; sandboxed extensions cannot send it over RPC.

Prune older tool calls from the model context with the AI SDK's [`pruneMessages`](https://ai-sdk.dev/):

```typescript
Expand Down
3 changes: 3 additions & 0 deletions packages/think/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,8 @@ The AI SDK-derived contexts spread the SDK's own types at the top level — no i

`TurnConfig.stopWhen` accepts AI SDK stop conditions such as `hasToolCall("finalAnswer")` for ending a turn early. Think composes these with its own `maxSteps` bound, so a custom condition can stop before the cap without removing the safety limit. Because stop conditions are functions, return `stopWhen` from a Think subclass's `beforeTurn`; sandboxed extension hooks cannot provide it over RPC.

`TurnConfig.repairToolCall` repairs a complete tool call that the AI SDK cannot parse or validate. Return the original raw call with a corrected `input` JSON string, or `null` when it cannot be repaired. The AI SDK revalidates the returned call before Think's `beforeToolCall` hook and tool execution. Configure this function from a Think subclass; sandboxed extension hooks cannot provide it over RPC.

`TurnConfig` also accepts an `output` field that is forwarded to `streamText` as the AI SDK's structured-output spec. Combine with `activeTools: []` for providers (e.g. `workers-ai-provider`) that strip tools when `responseFormat: "json"` is active. Use `telemetry` to pass the AI SDK's per-call telemetry settings through to `streamText`; the previous `experimental_telemetry` name remains as a deprecated alias. Trace payload storage is separately controlled by the agent fields `storeMessages` (chat messages) and `storeTools` (tool arguments/results); both default to `false`. Stored messages follow the OpenTelemetry GenAI schemas: `{ role, parts }`, `{ type, content }` for text/reasoning, `tool_call` / `tool_call_response` for tools, and `finish_reason` on model output.

Per-tool hooks are wired so `beforeToolCall` fires _before_ `execute` (Think wraps every tool's `execute`) and `afterToolCall` fires _after_ (via the AI SDK's `onToolExecutionEnd`) with `toolExecutionMs` and `toolOutput`. Deprecated `durationMs` and `success`/`output`/`error` aliases remain for compatibility. `beforeToolCall` can return a `ToolCallDecision` to:
Expand Down Expand Up @@ -708,6 +710,7 @@ interface TurnConfig {
timeout?: TimeoutConfiguration;
headers?: Record<string, string | undefined>;
providerOptions?: Record<string, unknown>;
repairToolCall?: ToolCallRepairFunction;
telemetry?: TelemetrySettings;
/** @deprecated Prefer telemetry. */
experimental_telemetry?: TelemetrySettings;
Expand Down
26 changes: 23 additions & 3 deletions packages/think/src/tests/agents/think-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3509,7 +3509,9 @@ export class ThinkConfigInSessionAgent extends Think<Cloudflare.Env> {
// Extends Think with tools configured for tool integration testing.
// Uses a mock model that calls the "echo" tool on first invocation.

function createToolCallingMockModel(): LanguageModel {
function createToolCallingMockModel(
toolInput = JSON.stringify({ message: "hello" })
): LanguageModel {
let callCount = 0;
return {
specificationVersion: "v3",
Expand Down Expand Up @@ -3540,7 +3542,7 @@ function createToolCallingMockModel(): LanguageModel {
controller.enqueue({
type: "tool-input-delta",
id: "tc1",
delta: JSON.stringify({ message: "hello" })
delta: toolInput
});
controller.enqueue({ type: "tool-input-end", id: "tc1" });
// v3 spec also requires an explicit `tool-call` chunk so the
Expand All @@ -3549,7 +3551,7 @@ function createToolCallingMockModel(): LanguageModel {
type: "tool-call",
toolCallId: "tc1",
toolName: "echo",
input: JSON.stringify({ message: "hello" })
input: toolInput
});
controller.enqueue({
type: "finish",
Expand Down Expand Up @@ -3828,6 +3830,9 @@ export class ThinkToolsTestAgent extends Think {
override getModel(): LanguageModel {
if (this._useAttachReplyAction) return createAttachReplyMockModel();
if (this._useDurablePauseAction) return createDurablePauseMockModel();
if (this._repairToolCalls) {
return createToolCallingMockModel('```json\n{"message":"repaired"}\n```');
}
return createToolCallingMockModel();
}

Expand Down Expand Up @@ -4588,8 +4593,23 @@ export class ThinkToolsTestAgent extends Think {
}

private _turnStopCondition: TurnConfig["stopWhen"];
private _repairToolCalls = false;

/** Enables deterministic malformed tool-call repair inside the test agent. */
async enableToolCallRepairForTest(): Promise<void> {
this._repairToolCalls = true;
}

override beforeTurn(): TurnConfig | void {
if (this._repairToolCalls) {
return {
stopWhen: this._turnStopCondition,
repairToolCall: async ({ toolCall }) => ({
...toolCall,
input: JSON.stringify({ message: "repaired" })
})
};
}
if (this._turnStopCondition) {
return { stopWhen: this._turnStopCondition };
}
Expand Down
20 changes: 20 additions & 0 deletions packages/think/src/tests/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1870,6 +1870,26 @@ describe("Think — beforeTurn config overrides", () => {
expect(text).toBe("HELLO FROM THE ASSISTANT");
});

it("repairToolCall repairs invalid tool input before execution", async () => {
const agent = await freshToolAgent("bt-tool-call-repair");
await agent.enableToolCallRepairForTest();

const result = await agent.testChat("Repair the tool call");

expect(result).toMatchObject({ done: true, error: undefined });
expect(await agent.getEchoExecuteCount()).toBe(1);
expect(await agent.getBeforeToolCallLog()).toEqual([
{ toolName: "echo", inputJson: '{"message":"repaired"}' }
]);
expect(await agent.getAfterToolCallLog()).toEqual([
{
toolName: "echo",
inputJson: '{"message":"repaired"}',
outputJson: '"echo: repaired"'
}
]);
});

it("sends reasoning chunks by default on the chat() path", async () => {
const agent = await freshAgent("bt-reasoning-default");
await agent.setReasoningResponse("Final answer", "Visible thinking");
Expand Down
17 changes: 17 additions & 0 deletions packages/think/src/think.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2173,6 +2173,19 @@ export interface TurnConfig {
experimental_transform?: Parameters<
typeof streamText
>[0]["experimental_transform"];
/**
* Repairs tool calls that the AI SDK cannot parse or validate before tool
* execution. The returned tool call is parsed and validated again. Configure
* this function from a Think subclass; sandboxed extensions cannot send
* functions over RPC.
*
* Typed via the `experimental_repairToolCall` key, which exists in both AI
* SDK v6 and v7 (`repairToolCall` is v7-only), so this type resolves under
* either supported major.
*/
repairToolCall?: Parameters<
typeof streamText
>[0]["experimental_repairToolCall"];
/**
* Optional structured-output specification (AI SDK `output`).
* Forwarded to `streamText` so the model's final response is parsed
Expand Down Expand Up @@ -5843,6 +5856,10 @@ export class Think<
// can inspect/rewrite the stream (e.g. emit `source` parts derived from
// tool results) without owning the stream pipeline themselves.
experimental_transform: config.experimental_transform,
// `experimental_repairToolCall` is the common option name across AI SDK
// v6 and v7. TurnConfig exposes the stable v7 name while this boundary
// keeps both supported majors working.
experimental_repairToolCall: config.repairToolCall,
// Forward the per-turn structured-output spec from TurnConfig so
// callers can use AI SDK `Output.object({ schema })` / `Output.text()`
// on the terminal turn without dropping tools at model construction.
Expand Down
Loading