diff --git a/src/agents/pi-embedded-runner.limithistoryturns.test.ts b/src/agents/pi-embedded-runner.limithistoryturns.test.ts index e0340a69e..1052a76ff 100644 --- a/src/agents/pi-embedded-runner.limithistoryturns.test.ts +++ b/src/agents/pi-embedded-runner.limithistoryturns.test.ts @@ -154,7 +154,27 @@ describe("limitHistoryTurns", () => { { role: "assistant", content: [{ type: "text", text: "response" }] }, ]; const limited = limitHistoryTurns(messages, 1); - expect(limited[0].content).toEqual([{ type: "text", text: "second" }]); expect(limited[1].content).toEqual([{ type: "text", text: "response" }]); }); + + it("does not slice between tool use and tool result when limit cuts off tool use", () => { + const messages: AgentMessage[] = [ + { role: "user", content: [{ type: "text", text: "start" }] }, + { role: "assistant", content: [{ type: "text", text: "ack" }] }, + { role: "user", content: [{ type: "text", text: "do tool" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "foo", input: {} }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "res" }] }, + ]; + + // If we limit to 1 turn, we should get the full tool interaction chain (User -> Asst(Call) -> User(Result)) + const limited = limitHistoryTurns(messages, 1); + + expect(limited.length).toBe(3); + expect(limited[0].role).toBe("user"); + expect((limited[0].content as any)[0].text).toBe("do tool"); + expect(limited[1].role).toBe("assistant"); + expect((limited[1].content as any)[0].type).toBe("tool_use"); + expect(limited[2].role).toBe("user"); + expect((limited[2].content as any)[0].type).toBe("tool_result"); + }); }); diff --git a/src/agents/pi-embedded-runner/history.ts b/src/agents/pi-embedded-runner/history.ts index 8abdc7d1e..b43f628e7 100644 --- a/src/agents/pi-embedded-runner/history.ts +++ b/src/agents/pi-embedded-runner/history.ts @@ -9,9 +9,28 @@ function stripThreadSuffix(value: string): string { return match?.[1] ?? value; } +/** + * Check if a user message is purely a tool result (not a new user turn). + */ +function isToolResultMessage(msg: AgentMessage): boolean { + if (msg.role !== "user") return false; + const content = msg.content; + if (!Array.isArray(content)) return false; + // A tool result message contains only tool_result blocks + return ( + content.length > 0 && + content.every((block) => { + if (!block || typeof block !== "object") return false; + const type = (block as { type?: unknown }).type; + return type === "tool_result"; + }) + ); +} + /** * Limits conversation history to the last N user turns (and their associated * assistant responses). This reduces token usage for long-running DM sessions. + * Tool result messages are not counted as new user turns. */ export function limitHistoryTurns( messages: AgentMessage[], @@ -23,7 +42,9 @@ export function limitHistoryTurns( let lastUserIndex = messages.length; for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") { + const msg = messages[i]; + // Only count genuine user messages, not tool results + if (msg.role === "user" && !isToolResultMessage(msg)) { userCount++; if (userCount > limit) { return messages.slice(lastUserIndex);