Merge PR #6200: fix(session): retry past synthetic tool results after mid-tool-call stall (@roboomp)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user