Merge PR #6200: fix(session): retry past synthetic tool results after mid-tool-call stall (@roboomp)

This commit is contained in:
can1357
2026-07-22 21:13:15 +02:00
4 changed files with 100 additions and 3 deletions
+17
View File
@@ -2298,6 +2298,23 @@ export interface SyntheticToolResultDetails {
upstreamError?: string;
}
/**
* Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} —
* a tool_result emitted for a tool call the assistant never invoked (see
* {@link SyntheticToolResultDetails}). Consumers use this to look past the
* placeholder pairing back to the assistant turn that produced it, e.g.
* `AgentSession.retry()` walking back over the synthetic results a
* stalled/aborted mid-tool-call turn leaves behind.
*/
export function isSyntheticToolResultMessage(
message: AgentMessage | undefined,
): message is ToolResultMessage<SyntheticToolResultDetails> {
return (
message?.role === "toolResult" &&
(message.details as SyntheticToolResultDetails | undefined)?.__synthetic === true
);
}
function syntheticDetailsFor(
reason: "aborted" | "error" | "skipped" | "length",
errorMessage: string | undefined,
+1
View File
@@ -147,6 +147,7 @@
- Fixed dynamic model discovery refreshes dropping provider-level compatibility overrides from `models.yml` ([#6041](https://github.com/can1357/oh-my-pi/issues/6041)).
- Fixed a startup crash that locked users out of the app when `prewalk.enabled` was set but the prewalk hand-off target (default `@smol`) had no configured API key; prewalk now stays unarmed with a warning instead of aborting startup ([#6064](https://github.com/can1357/oh-my-pi/issues/6064)).
- Fixed in-progress aborts awaiting `session_stop` extension handlers whose results would be discarded ([#6134](https://github.com/can1357/oh-my-pi/issues/6134)).
- Fixed `/retry` reporting "Nothing to retry" after a stream stalled or aborted mid-tool-call, where a synthetic tool result appended for the un-run tool call shadowed the failed assistant turn ([#6056](https://github.com/can1357/oh-my-pi/issues/6056)).
## [17.0.5] - 2026-07-18
@@ -37,6 +37,7 @@ import {
type CompactionSummaryMessage,
countTokens,
createToolScopedAbortReason,
isSyntheticToolResultMessage,
resolveTelemetry,
type StreamFn,
TERMINAL_TOOL_RESULT_ABORT_REASON,
@@ -15758,20 +15759,40 @@ export class AgentSession {
/**
* Manually retry the last failed assistant turn.
* Removes the error message from agent state and re-attempts with a fresh retry budget.
*
* A stream that stalls or aborts mid-tool-call ends the turn with
* `stopReason: "error" | "aborted"` and then appends one synthetic
* {@link isSyntheticToolResultMessage tool_result} per emitted tool call to
* preserve the provider's tool_use/tool_result pairing (see
* `createAbortedToolResult` in `agent-loop.ts`). Those placeholders trail the
* failed assistant turn, so the retry lookback walks back over them before
* checking the assistant message; it strips both the placeholders and the
* failed turn before re-attempting.
*
* @returns true if retry was initiated, false if no failed turn to retry or agent is busy
*/
async retry(): Promise<boolean> {
if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
const messages = this.agent.state.messages;
const lastMsg = messages[messages.length - 1];
// Walk back past trailing synthetic tool_result placeholders emitted for
// tool calls that never ran because the turn stalled/aborted mid-tool-call.
// They shadow the failed assistant turn from the single-message lookback.
let turnEnd = messages.length;
while (turnEnd > 0 && isSyntheticToolResultMessage(messages[turnEnd - 1])) {
turnEnd--;
}
const lastMsg = messages[turnEnd - 1];
if (lastMsg?.role !== "assistant") return false;
const assistantMsg = lastMsg as AssistantMessage;
if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false;
// Remove the failed/aborted assistant message (same as auto-retry does before re-attempting)
this.agent.replaceMessages(messages.slice(0, -1));
// Remove the failed/aborted assistant message plus its synthetic tool
// results (same as auto-retry does before re-attempting).
this.agent.replaceMessages(messages.slice(0, turnEnd - 1));
// Reset retry budget for a fresh attempt
this.#retryAttempt = 0;
@@ -115,4 +115,62 @@ describe("AgentSession manual retry", () => {
expect(mock.calls.length).toBe(1);
expect(lastAgentMessage(session).content).toContainEqual({ type: "text", text: "already done" });
});
it("retries past synthetic tool results left by a mid-tool-call stream stall", async () => {
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
if (!model) {
throw new Error("Expected bundled Anthropic test model to exist");
}
// First turn stalls mid-tool-call: the assistant emits a `write` tool call
// but the stream ends with an error before it runs, so `stopReason: "error"`.
// The agent loop then appends a synthetic tool_result for the un-run call,
// which trails the failed assistant turn in agent state.
const mock = createMockModel({
responses: [
{
content: [{ type: "toolCall", name: "write", arguments: { path: "plan.md", content: "x" } }],
stopReason: "error",
errorMessage: "OpenAI completions stream stalled while waiting for the next event",
},
{ content: ["recovered after stalled tool call"], stopReason: "stop" },
],
});
const agent = new Agent({
getApiKey: model => `${model.provider}-test-key`,
initialState: {
model,
systemPrompt: ["Test"],
tools: [],
messages: [],
},
streamFn: mock.stream,
});
session = new AgentSession({
agent,
sessionManager: SessionManager.inMemory(),
settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }),
modelRegistry: new ModelRegistry(authStorage),
});
session.subscribe(() => {});
await session.prompt("write the plan");
await session.waitForIdle();
// The failed assistant turn is shadowed by a trailing synthetic tool_result.
const messages = session.agent.state.messages;
expect(messages.at(-1)?.role).toBe("toolResult");
const failedAssistant = messages.findLast(m => m.role === "assistant") as AssistantMessage;
expect(failedAssistant.stopReason).toBe("error");
await expect(session.retry()).resolves.toBe(true);
await session.waitForIdle();
expect(mock.calls.length).toBe(2);
expect(lastAgentMessage(session).stopReason).toBe("stop");
expect(lastAgentMessage(session).content).toContainEqual({
type: "text",
text: "recovered after stalled tool call",
});
});
});