From f4ec1532f0815bdff560d6f8df0bfcf1564acb98 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:50:39 -0500 Subject: [PATCH] refactor(ai): split the tool loop into small pieces (#61005) --- packages/ai/README.md | 7 +- packages/ai/src/llm.ts | 294 ++++++++----------------- packages/ai/src/loop.ts | 211 ++++++++++++++++++ packages/ai/src/tool-runtime.ts | 17 ++ packages/ai/test/error-partial.test.ts | 12 + packages/ai/test/loop-steps.test.ts | 77 +++++++ packages/ai/test/loop.test.ts | 115 ++++++++++ packages/ai/test/recorded-scenarios.ts | 39 +--- 8 files changed, 532 insertions(+), 240 deletions(-) create mode 100644 packages/ai/src/loop.ts create mode 100644 packages/ai/test/loop-steps.test.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index f07b40b7c50..a6573d11689 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -59,8 +59,11 @@ it fails). Each step ends with its own `step-finish`; one `finish` ends the loop and the usage summed across steps. `until` is checked after each step that has tool calls, with every step's `LLMResponse`; returning `true` ends the loop without running them. Tools are an array, sent in the order given so the provider can reuse its cached prompt prefix. Each tool has a -`name`, and two tools with the same name fail the request. `tool` takes a plain `async` execute; a throw or rejection -goes back to the model as an error result, and `signal` aborts when the loop is interrupted. +`name`, and two tools with the same name fail the request. Tool arguments that are not valid JSON go back to the model +as an error result so it can retry. A step that hits the output token limit ends the loop without running its tool +calls, which may be cut off. A forced `toolChoice` applies to the first step only; later steps use `auto`. `tool` takes +a plain `async` execute; a throw or rejection goes back to the model as an error result, and `signal` aborts when the +loop is interrupted. `input` is an Effect Schema that decodes and types the arguments, or a raw JSON Schema object, such as an MCP tool's, whose arguments arrive as `unknown` (pair it with an optional JSON Schema `outputSchema`). `tool` from `@opencode/ai/effect` takes the same options (including an optional `success` schema) with an Effect execute: diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index 3c009015389..1fd7bc0b7e9 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -26,7 +26,6 @@ import { type CompactionCheckpointResponse, type CompactionResponse, type Finish, - type FinishReasonDetails, ToolChoice, ToolEntry, Usage, @@ -37,7 +36,8 @@ import { withPartial, } from "./schema/index.js" import { tool, type ExecutableTool, type Tool, type ToolSchema } from "./tool.js" -import { ToolRuntime } from "./tool-runtime.js" +import { runTools } from "./tool-runtime.js" +import { addUsage, nextRequest, pendingCalls } from "./loop.js" /** Input accepted by `ai.request`, `ai.generate`, `ai.stream` and `ai.compact`. An `LLMRequest` is also a valid input. */ export type LLMRequestInput = TextRequestFields & PromptOrMessages @@ -117,18 +117,9 @@ export function stream(input: Input, options?: StreamOptions) { return Stream.unwrap( prepare(input).pipe( Effect.map(({ request, output }) => - Stream.suspend(() => { - let state = LLMResponse.empty() - return LLMClient.stream(request, options).pipe( - Stream.mapEffect((event) => { - state = LLMResponse.reduce(state, event) - const response = event.type === "finish" ? LLMResponse.complete(state) : undefined - if (!response || event.type !== "finish") return Effect.succeed(event) - const finish = { ...event, usage: response.usage, message: response.message } - return output === undefined ? Effect.succeed(finish) : withObject(output, finish, state) - }), - ) - }), + stepStream(request, undefined, options, (step) => + Stream.fromEffect(finishStep(step, step.response.usage, output)), + ), ), ), ) @@ -142,8 +133,9 @@ export type LLMLoopInput = Omit