Compare commits

...

3 Commits

2 changed files with 141 additions and 77 deletions
@@ -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);
+108 -67
View File
@@ -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<number> {
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<typeof builtinToolsToOpenAi>,
): Promise<LoopTurnResult> {
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 };