From a94490eda202a79bb524e9822bb6c8bf6f3f04ea Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Fri, 18 Sep 2026 00:07:24 +0300 Subject: [PATCH] coding-agent: answer the tool call a replaced session left unresolved --- .../.changes/interrupted-tool-call-result.md | 1 + .../coding-agent/src/core/agent-session.ts | 18 ++++++++ packages/coding-agent/src/core/messages.ts | 43 ++++++++++++++++++- .../test/suite/agent-session-prompt.test.ts | 29 ++++++++++++- 4 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 packages/coding-agent/.changes/interrupted-tool-call-result.md diff --git a/packages/coding-agent/.changes/interrupted-tool-call-result.md b/packages/coding-agent/.changes/interrupted-tool-call-result.md new file mode 100644 index 000000000..998a865a9 --- /dev/null +++ b/packages/coding-agent/.changes/interrupted-tool-call-result.md @@ -0,0 +1 @@ +- Fixed a resumed session leaving a tool call unanswered when the previous process ended mid-call; the call now carries a result saying it did not complete. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 4b5a2acab..6284569f2 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -191,6 +191,7 @@ import { createCompactionOutcomeMessage, createHarnessDigestMessage, createHeartbeatPromptMessage, + createInterruptedToolResultMessage, createLanguageDriftNoticeMessage, createRefinementNoticeMessage, createRefinementOutcomeMessage, @@ -209,6 +210,7 @@ import { RLM_CHILD_FAILURE_CUSTOM_TYPE, RLM_CHILD_TERMINAL_NOTICE_CUSTOM_TYPE, type RlmChildTerminalNoticeDetails, + unansweredToolCalls, } from "./messages.js"; import type { ModelRegistry } from "./model-registry.js"; import { findExactModelReferenceMatch } from "./model-resolver.js"; @@ -8647,6 +8649,19 @@ export class AgentSession { this._emit({ type: "message_end", message }); } + /** + * Answer a tool call whose result died with the previous process. A transcript can only + * carry one when the process ended mid-call, and left alone the model reads a turn that + * ends mid-call, guesses why, and re-derives work the notice would have spared it. + */ + private _repairInterruptedToolCalls(): void { + for (const toolCall of unansweredToolCalls(this.agent.state.messages)) { + const message = createInterruptedToolResultMessage(toolCall); + this.agent.state.messages.push(message); + this.sessionManager.appendMessage(message); + } + } + private _onIpythonStateRestored(result: RestoreResult): void { const lines = ["[python-state-restored]", ""]; if (result.restored.length > 0) { @@ -10639,6 +10654,9 @@ export class AgentSession { flagValues?: Map; includeAllExtensionTools?: boolean; }): void { + // The first build in this process is a genuine resume: the transcript came from disk, + // so a tool call whose result died with the previous process is still unanswered here. + if (!this._ipythonRuntimeBuilt) this._repairInterruptedToolCalls(); const pythonSkills = getPythonSkillRuntimeInfo(this._modelVisibleSkills()); let configuredBaseToolDefinitions: Record; if (this._baseToolsOverride) { diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts index 0ecc5e63c..ee6a0d42d 100644 --- a/packages/coding-agent/src/core/messages.ts +++ b/packages/coding-agent/src/core/messages.ts @@ -5,8 +5,8 @@ * and provides a transformer to convert them to LLM-compatible messages. */ -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai"; +import type { AgentMessage, AgentToolCall } from "@earendil-works/pi-agent-core"; +import type { ImageContent, Message, TextContent, ToolResultMessage } from "@earendil-works/pi-ai"; import type { AgentCronJob } from "./cron-jobs.js"; import { type AppliedRefinementEdit, @@ -232,6 +232,45 @@ export function createManualContinuationMessage(timestamp = Date.now()): CustomM }; } +export const INTERRUPTED_TOOL_CALL_PREFIX = ""; + +/** + * Tool calls in the tail message that no result ever answered. + * + * Only the tail is considered. A result is persisted as soon as the loop produces one, so a + * call left unanswered there means the process ended mid-call; and appending its repair is + * only order-correct when the call itself is last, which keeps tool_use/tool_result adjacent + * for providers that require it. + */ +export function unansweredToolCalls(messages: readonly AgentMessage[]): AgentToolCall[] { + const tail = messages.at(-1); + if (tail?.role !== "assistant") { + return []; + } + return tail.content.filter((part): part is AgentToolCall => part.type === "toolCall"); +} + +/** The result a call never received, so a resumed session reads the loss at the call site. */ +export function createInterruptedToolResultMessage(toolCall: AgentToolCall, timestamp = Date.now()): ToolResultMessage { + const reason = + toolCall.name === "ipython" + ? "This cell did not complete: the process running the session ended while it ran, so no result was recorded and nothing it did was applied. Its kernel was revived from its snapshot, and output it had already written is kept." + : `This ${toolCall.name} call did not complete: the process running the session ended while it ran, so no result was recorded.`; + return { + role: "toolResult", + toolCallId: toolCall.id, + toolName: toolCall.name, + content: [ + { + type: "text", + text: `${INTERRUPTED_TOOL_CALL_PREFIX}\n${reason}\nRe-run it if you still need it.\n`, + }, + ], + isError: true, + timestamp, + }; +} + export const LANGUAGE_DRIFT_NOTICE_CUSTOM_TYPE = "language_drift_notice"; /** diff --git a/packages/coding-agent/test/suite/agent-session-prompt.test.ts b/packages/coding-agent/test/suite/agent-session-prompt.test.ts index 42d2eaac9..a89c822ce 100644 --- a/packages/coding-agent/test/suite/agent-session-prompt.test.ts +++ b/packages/coding-agent/test/suite/agent-session-prompt.test.ts @@ -6,11 +6,12 @@ import { fauxAssistantMessage, fauxToolCall } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import type { BashResult } from "../../src/core/bash-executor.js"; -import { convertToLlm, HARNESS_DIGEST_CUSTOM_TYPE } from "../../src/core/messages.js"; +import { convertToLlm, HARNESS_DIGEST_CUSTOM_TYPE, INTERRUPTED_TOOL_CALL_PREFIX } from "../../src/core/messages.js"; import type { PromptTemplate } from "../../src/core/prompt-templates.js"; import { getLocalHarnessStateDir, loadHarnessState, saveHarnessState } from "../../src/core/refinement/index.js"; +import { SessionManager } from "../../src/core/session-manager.js"; import { createSyntheticSourceInfo } from "../../src/core/source-info.js"; -import { createTestResourceLoader } from "../utilities.js"; +import { assistantMsg, createTestResourceLoader } from "../utilities.js"; import { createHarness, getAssistantTexts, getMessageText, getUserTexts, type Harness } from "./harness.js"; import { createDeferred, createWaitingHarness } from "./scheduling.js"; @@ -1055,4 +1056,28 @@ describe("Harness digest at cold boundaries", () => { expect(resumedStale.session.messages.at(-1)).toBe(digests.at(-1)); expect(getMessageText(digests.at(-1))).toContain("[local:resume_test_memory] Resume test memory"); }); + + it("resume answers the tool call whose result died with the previous process", async () => { + const tempDir = join(tmpdir(), `pi-interrupted-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(tempDir, { recursive: true }); + tempDirs.push(tempDir); + const seeded = SessionManager.create(tempDir, join(tempDir, "sessions")); + seeded.appendMessage({ + ...assistantMsg(""), + content: [{ type: "toolCall" as const, id: "dangling", name: "ipython", arguments: { code: "1+1" } }], + stopReason: "toolUse" as const, + }); + + const resumed = await createHarness({ existingSessionFile: seeded.getSessionFile() }); + harnesses.push(resumed); + const repair = resumed.session.messages.find((m) => m.role === "toolResult" && m.toolCallId === "dangling"); + expect(repair).toMatchObject({ toolName: "ipython", isError: true }); + expect(getMessageText(repair)).toContain(INTERRUPTED_TOOL_CALL_PREFIX); + // Adjacent, so replaying the transcript to a provider shows tool_use then tool_result. + const repairedAt = resumed.session.messages.findIndex((m) => m.role === "toolResult"); + expect(resumed.session.messages[repairedAt - 1]).toMatchObject({ role: "assistant" }); + // Durable: the transcript itself now answers the call, so a later resume repairs nothing. + const persisted = SessionManager.open(seeded.getSessionFile()!).buildSessionContext().messages; + expect(persisted.filter((m) => m.role === "toolResult")).toHaveLength(1); + }); }); -- 2.51.2