diff --git a/packages/workflow-agent-builtin/__tests__/loop.test.ts b/packages/workflow-agent-builtin/__tests__/loop.test.ts index f2c58dd..c1ea654 100644 --- a/packages/workflow-agent-builtin/__tests__/loop.test.ts +++ b/packages/workflow-agent-builtin/__tests__/loop.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, mock, beforeEach } from "bun:test"; +import { beforeEach, describe, expect, mock, test } from "bun:test"; const mockChatCompletionWithTools = mock(async () => ({ content: "---\nstatus: done\n---", @@ -19,7 +19,7 @@ mock.module("../src/tools/index.js", () => ({ getBuiltinTools: () => [], })); -import { shouldNudge, executeTurnTools, runBuiltinLoop } from "../src/loop.js"; +import { executeTurnTools, runBuiltinLoop, shouldNudge } from "../src/loop.js"; const fakeProvider = {} as any; const fakeToolCtx = {} as any; @@ -51,7 +51,9 @@ describe("shouldNudge", () => { expect(shouldNudge({ noTools: true, text: "some text", turn: 0, maxTurns: 5 })).toBe(false); }); test("2.3 returns false when text starts with ---", () => { - expect(shouldNudge({ noTools: false, text: "---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(false); + expect(shouldNudge({ noTools: false, text: "---\nstatus: done", turn: 0, maxTurns: 5 })).toBe( + false, + ); }); test("2.4 returns false on last turn", () => { expect(shouldNudge({ noTools: false, text: "some text", turn: 4, maxTurns: 5 })).toBe(false); @@ -60,7 +62,9 @@ describe("shouldNudge", () => { expect(shouldNudge({ noTools: false, text: "some text", turn: 3, maxTurns: 5 })).toBe(true); }); test("2.6 leading whitespace before --- suppresses nudge", () => { - expect(shouldNudge({ noTools: false, text: " ---\nstatus: done", turn: 0, maxTurns: 5 })).toBe(false); + expect(shouldNudge({ noTools: false, text: " ---\nstatus: done", turn: 0, maxTurns: 5 })).toBe( + false, + ); }); }); @@ -81,27 +85,42 @@ describe("executeTurnTools", () => { test("4.2 tool result content matches executeBuiltinTool return value", async () => { mockExecuteBuiltinTool.mockResolvedValue("result-A"); const messages: any[] = []; - await executeTurnTools([{ id: "c1", name: "read_file", arguments: "{}" }], fakeToolCtx, messages, "/tmp", "sess"); + await executeTurnTools( + [{ id: "c1", name: "read_file", arguments: "{}" }], + fakeToolCtx, + messages, + "/tmp", + "sess", + ); expect(messages[0].content).toBe("result-A"); }); }); describe("runBuiltinLoop integration", () => { test("3.1 single text-only response returns finalText immediately", async () => { - mockChatCompletionWithTools.mockResolvedValue({ content: "---\nstatus: done\n---", toolCalls: [] }); + mockChatCompletionWithTools.mockResolvedValue({ + content: "---\nstatus: done\n---", + toolCalls: [], + }); const result = await runBuiltinLoop(makeOptions()); expect(result.finalText).toBe("---\nstatus: done\n---"); expect(result.turnCount).toBe(1); }); test("3.2 noTools=true suppresses tool calls", async () => { - mockChatCompletionWithTools.mockResolvedValue({ content: "ok", toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }] }); + mockChatCompletionWithTools.mockResolvedValue({ + content: "ok", + toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }], + }); const result = await runBuiltinLoop(makeOptions({ noTools: true })); expect(result.finalText).toBe("ok"); expect(result.turnCount).toBe(1); }); test("3.3 tool call followed by text response", async () => { mockChatCompletionWithTools - .mockResolvedValueOnce({ content: null, toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }] }) + .mockResolvedValueOnce({ + content: null, + toolCalls: [{ id: "c1", name: "read_file", arguments: "{}" }], + }) .mockResolvedValueOnce({ content: "---\nstatus: done\n---", toolCalls: [] }); mockExecuteBuiltinTool.mockResolvedValue("file contents"); const result = await runBuiltinLoop(makeOptions()); @@ -115,7 +134,8 @@ describe("runBuiltinLoop integration", () => { const result = await runBuiltinLoop(makeOptions()); expect(result.finalText).toBe("---\nstatus: done\n---"); const nudgeMsg = result.messages.find( - (m) => m.role === "user" && typeof m.content === "string" && m.content.includes("frontmatter"), + (m) => + m.role === "user" && typeof m.content === "string" && m.content.includes("frontmatter"), ); expect(nudgeMsg).toBeDefined(); }); @@ -125,7 +145,10 @@ describe("runBuiltinLoop integration", () => { expect(result.finalText).toBe("still thinking"); }); test("3.6 original messages array is not mutated", async () => { - mockChatCompletionWithTools.mockResolvedValue({ content: "---\nstatus: done\n---", toolCalls: [] }); + mockChatCompletionWithTools.mockResolvedValue({ + content: "---\nstatus: done\n---", + toolCalls: [], + }); const original = [{ role: "system" as const, content: "sys" }]; await runBuiltinLoop(makeOptions({ messages: original })); expect(original.length).toBe(1); diff --git a/packages/workflow-agent-builtin/src/loop.ts b/packages/workflow-agent-builtin/src/loop.ts index df0f411..b4996b0 100644 --- a/packages/workflow-agent-builtin/src/loop.ts +++ b/packages/workflow-agent-builtin/src/loop.ts @@ -81,6 +81,109 @@ export function shouldNudge({ noTools, text, turn, maxTurns }: ShouldNudgeOption return !noTools && !text.trimStart().startsWith("---") && turn < maxTurns - 1; } +async function handleTextTurn( + text: string, + turn: number, + noTools: boolean, + maxTurns: number, + storageRoot: string, + sessionId: string, + messages: ChatMessage[], +): Promise<{ done: boolean; finalText: string }> { + await appendTurn(storageRoot, sessionId, { + role: "assistant", + content: text, + toolCalls: null, + reasoning: null, + }); + + if (shouldNudge({ noTools, text, turn, maxTurns })) { + log("7FXQM2KN", "text-only turn without frontmatter, nudging LLM to continue"); + const nudge = + "You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " + + "Either continue using tools to complete your work, or output your final response starting with `---`."; + messages.push({ role: "user", content: nudge }); + return { done: false, finalText: "" }; + } + + return { done: true, finalText: text }; +} + +async function handleToolTurn( + content: string, + toolCalls: LlmToolCall[], + toolCtx: ToolContext, + messages: ChatMessage[], + storageRoot: string, + sessionId: string, +): Promise { + await appendTurn(storageRoot, sessionId, { + role: "assistant", + content, + toolCalls: mapToolCallsForPayload(toolCalls), + reasoning: null, + }); + return executeTurnTools(toolCalls, toolCtx, messages, storageRoot, sessionId); +} + +export function extractFinalText(messages: ChatMessage[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if ( + msg !== undefined && + msg.role === "assistant" && + msg.content !== null && + msg.content.trim() !== "" + ) { + return msg.content; + } + } + return ""; +} + +type LoopTurnResult = { done: boolean; finalText: string; extraTurns: number }; + +async function runLoopTurn( + turn: number, + options: RunBuiltinLoopOptions, + messages: ChatMessage[], + openAiTools: ReturnType, +): Promise { + log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`); + const response = await chatCompletionWithTools( + options.provider, + messages, + openAiTools.length > 0 ? openAiTools : null, + ); + + const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null); + messages.push({ role: "assistant", content: response.content, tool_calls: effectiveToolCalls }); + + if (effectiveToolCalls === null || effectiveToolCalls.length === 0) { + const text = response.content ?? ""; + const result = await handleTextTurn( + text, + turn, + options.noTools, + options.maxTurns, + options.storageRoot, + options.sessionId, + messages, + ); + return { done: result.done, finalText: result.finalText, extraTurns: 0 }; + } + + const extra = await handleToolTurn( + response.content ?? "", + effectiveToolCalls, + options.toolCtx, + messages, + options.storageRoot, + options.sessionId, + ); + return { done: false, finalText: "", extraTurns: extra }; +} + /** Agent run loop: LLM ↔ tools until no tool_calls or maxTurns. */ export async function runBuiltinLoop( options: RunBuiltinLoopOptions, @@ -91,78 +194,16 @@ export async function runBuiltinLoop( let turnCount = 0; for (let turn = 0; turn < options.maxTurns; turn++) { - log("8K2M4N7P", `builtin loop turn ${turn + 1}/${options.maxTurns}`); - const response = await chatCompletionWithTools( - options.provider, - messages, - openAiTools.length > 0 ? openAiTools : null, - ); - - // When noTools is set, ignore any tool_calls the LLM might still return - const effectiveToolCalls = options.noTools ? null : (response.toolCalls ?? null); - - const assistantMessage: ChatMessage = { - role: "assistant", - content: response.content, - tool_calls: effectiveToolCalls, - }; - messages.push(assistantMessage); - - if (effectiveToolCalls === null || effectiveToolCalls.length === 0) { - const text = response.content ?? ""; - await appendTurn(options.storageRoot, options.sessionId, { - role: "assistant", - content: text, - toolCalls: null, - reasoning: null, - }); - turnCount += 1; - - if (shouldNudge({ noTools: options.noTools, text, turn, maxTurns: options.maxTurns })) { - log("7FXQM2KN", "text-only turn without frontmatter, nudging LLM to continue"); - const nudge = - "You stopped calling tools but your response does not start with the required `---` YAML frontmatter. " + - "Either continue using tools to complete your work, or output your final response starting with `---`."; - messages.push({ role: "user", content: nudge }); - continue; - } - - finalText = text; + const result = await runLoopTurn(turn, options, messages, openAiTools); + turnCount += 1 + result.extraTurns; + if (result.done) { + finalText = result.finalText; break; } - - // Assistant turn with tool calls - await appendTurn(options.storageRoot, options.sessionId, { - role: "assistant", - content: response.content ?? "", - toolCalls: mapToolCallsForPayload(effectiveToolCalls), - reasoning: null, - }); - turnCount += 1; - - // Execute tools - turnCount += await executeTurnTools( - effectiveToolCalls, - options.toolCtx, - messages, - options.storageRoot, - options.sessionId, - ); } if (finalText === "" && messages.length > 0) { - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if ( - msg !== undefined && - msg.role === "assistant" && - msg.content !== null && - msg.content.trim() !== "" - ) { - finalText = msg.content; - break; - } - } + finalText = extractFinalText(messages); } return { finalText, messages, turnCount };