@oh-my-pi/pi-coding-agent 17.3.2 → 17.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/{CHANGELOG-fr2awajz.md → CHANGELOG-s5t1xdmy.md} +8 -0
- package/dist/cli.js +2511 -2509
- package/dist/types/session/turn-recovery.d.ts +1 -1
- package/package.json +13 -13
- package/src/modes/controllers/event-controller.ts +57 -11
- package/src/prompts/system/empty-stop-retry.md +1 -1
- package/src/session/agent-session.ts +8 -1
- package/src/session/turn-recovery.ts +59 -55
|
@@ -126,7 +126,7 @@ export declare class TurnRecovery {
|
|
|
126
126
|
/** Persists an otherwise skipped terminal empty error turn. */
|
|
127
127
|
persistTerminalEmptyErrorTurn(message: AssistantMessage): Promise<void>;
|
|
128
128
|
/** Handles empty terminal assistant turns and schedules bounded recovery. */
|
|
129
|
-
handleEmptyAssistantStop(message: AssistantMessage): Promise<
|
|
129
|
+
handleEmptyAssistantStop(message: AssistantMessage): Promise<"continue" | "terminal" | undefined>;
|
|
130
130
|
/** Classifies suspicious terminal stops and schedules bounded recovery. */
|
|
131
131
|
handleUnexpectedAssistantStop(message: AssistantMessage): Promise<boolean>;
|
|
132
132
|
/** Removes a persisted failed assistant turn after its persistence slot settles. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "17.3.
|
|
4
|
+
"version": "17.3.3",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -50,18 +50,18 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@babel/parser": "^7.29.7",
|
|
53
|
-
"@oh-my-pi/hashline": "17.3.
|
|
54
|
-
"@oh-my-pi/omp-stats": "17.3.
|
|
55
|
-
"@oh-my-pi/omptype": "17.3.
|
|
56
|
-
"@oh-my-pi/pi-agent-core": "17.3.
|
|
57
|
-
"@oh-my-pi/pi-ai": "17.3.
|
|
58
|
-
"@oh-my-pi/pi-catalog": "17.3.
|
|
59
|
-
"@oh-my-pi/pi-mnemopi": "17.3.
|
|
60
|
-
"@oh-my-pi/pi-natives": "17.3.
|
|
61
|
-
"@oh-my-pi/pi-tui": "17.3.
|
|
62
|
-
"@oh-my-pi/pi-utils": "17.3.
|
|
63
|
-
"@oh-my-pi/pi-wire": "17.3.
|
|
64
|
-
"@oh-my-pi/snapcompact": "17.3.
|
|
53
|
+
"@oh-my-pi/hashline": "17.3.3",
|
|
54
|
+
"@oh-my-pi/omp-stats": "17.3.3",
|
|
55
|
+
"@oh-my-pi/omptype": "17.3.3",
|
|
56
|
+
"@oh-my-pi/pi-agent-core": "17.3.3",
|
|
57
|
+
"@oh-my-pi/pi-ai": "17.3.3",
|
|
58
|
+
"@oh-my-pi/pi-catalog": "17.3.3",
|
|
59
|
+
"@oh-my-pi/pi-mnemopi": "17.3.3",
|
|
60
|
+
"@oh-my-pi/pi-natives": "17.3.3",
|
|
61
|
+
"@oh-my-pi/pi-tui": "17.3.3",
|
|
62
|
+
"@oh-my-pi/pi-utils": "17.3.3",
|
|
63
|
+
"@oh-my-pi/pi-wire": "17.3.3",
|
|
64
|
+
"@oh-my-pi/snapcompact": "17.3.3",
|
|
65
65
|
"@opentelemetry/api": "^1.9.1",
|
|
66
66
|
"@opentelemetry/api-logs": "^0.220.0",
|
|
67
67
|
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
@@ -141,6 +141,8 @@ export class EventController {
|
|
|
141
141
|
// restored when the banner clears at the next `agent_start` (see
|
|
142
142
|
// #handleMessageEnd / #handleAgentStart).
|
|
143
143
|
#pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
|
|
144
|
+
#pinnedErrorMessage: AssistantMessage | undefined = undefined;
|
|
145
|
+
#restorePinnedErrorInline = true;
|
|
144
146
|
#retrySupersededAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
145
147
|
#retrySupersededAssistantQueue: AssistantMessageComponent[] = [];
|
|
146
148
|
// Set when `auto_retry_start` fires and cleared by `auto_retry_end` (both
|
|
@@ -650,6 +652,8 @@ export class EventController {
|
|
|
650
652
|
this.#readToolCallAssistantComponents.clear();
|
|
651
653
|
this.#lastAssistantComponent = undefined;
|
|
652
654
|
this.#pinnedErrorComponent = undefined;
|
|
655
|
+
this.#pinnedErrorMessage = undefined;
|
|
656
|
+
this.#restorePinnedErrorInline = true;
|
|
653
657
|
this.#retryPending = this.ctx.viewSession.isRetrying;
|
|
654
658
|
this.#cancelIdleCompaction();
|
|
655
659
|
this.#cancelIdleRecap();
|
|
@@ -743,10 +747,13 @@ export class EventController {
|
|
|
743
747
|
this.#resetReadGroup();
|
|
744
748
|
this.#resolveDisplaceableTodo();
|
|
745
749
|
this.#lastAssistantComponent = undefined;
|
|
746
|
-
// Restore
|
|
747
|
-
//
|
|
748
|
-
|
|
750
|
+
// Restore terminal errors in transcript history when their banner clears.
|
|
751
|
+
// Recoverable empty-output attempts are discarded by session recovery and
|
|
752
|
+
// must stay hidden rather than resurfacing as a stale inline error.
|
|
753
|
+
if (this.#restorePinnedErrorInline) this.#pinnedErrorComponent?.setErrorPinned(false);
|
|
749
754
|
this.#pinnedErrorComponent = undefined;
|
|
755
|
+
this.#pinnedErrorMessage = undefined;
|
|
756
|
+
this.#restorePinnedErrorInline = true;
|
|
750
757
|
this.ctx.clearPinnedError();
|
|
751
758
|
if (this.ctx.retryLoader) {
|
|
752
759
|
this.ctx.retryLoader.stop();
|
|
@@ -1320,14 +1327,20 @@ export class EventController {
|
|
|
1320
1327
|
}
|
|
1321
1328
|
this.ctx.streamingComponent = undefined;
|
|
1322
1329
|
this.ctx.streamingMessage = undefined;
|
|
1323
|
-
// Pin a turn-ending provider error
|
|
1324
|
-
//
|
|
1325
|
-
//
|
|
1326
|
-
//
|
|
1330
|
+
// Pin a turn-ending provider error above the editor so it survives
|
|
1331
|
+
// transcript scroll and suppress its duplicate inline row. Empty-output
|
|
1332
|
+
// errors are known intermediate attempts: hide them entirely while
|
|
1333
|
+
// session recovery continues, but retain the component so a terminal
|
|
1334
|
+
// retry-cap event can promote its final error into the one banner.
|
|
1327
1335
|
if (event.message.stopReason === "error" && event.message.errorMessage && !isSilentAbort(event.message)) {
|
|
1336
|
+
const recoverableEmptyOutput =
|
|
1337
|
+
!event.message.errorMessage.startsWith("Retry budget exhausted") &&
|
|
1338
|
+
AIError.is(AIError.classifyMessage(event.message), AIError.Flag.EmptyResponse);
|
|
1328
1339
|
this.#lastAssistantComponent?.setErrorPinned(true);
|
|
1329
1340
|
this.#pinnedErrorComponent = this.#lastAssistantComponent;
|
|
1330
|
-
this
|
|
1341
|
+
this.#pinnedErrorMessage = event.message;
|
|
1342
|
+
this.#restorePinnedErrorInline = !recoverableEmptyOutput;
|
|
1343
|
+
if (!recoverableEmptyOutput) this.ctx.showPinnedError(event.message.errorMessage);
|
|
1331
1344
|
}
|
|
1332
1345
|
this.ctx.statusLine.invalidate();
|
|
1333
1346
|
this.ctx.ui.requestRender();
|
|
@@ -1947,6 +1960,8 @@ export class EventController {
|
|
|
1947
1960
|
// restore its inline Error row; just unpin the fixed-region banner so the
|
|
1948
1961
|
// retry UI is the visible state.
|
|
1949
1962
|
this.#pinnedErrorComponent = undefined;
|
|
1963
|
+
this.#pinnedErrorMessage = undefined;
|
|
1964
|
+
this.#restorePinnedErrorInline = true;
|
|
1950
1965
|
this.ctx.clearPinnedError();
|
|
1951
1966
|
}
|
|
1952
1967
|
const delaySeconds = Math.round(event.delayMs / 1000);
|
|
@@ -1968,20 +1983,51 @@ export class EventController {
|
|
|
1968
1983
|
this.ctx.retryLoader = undefined;
|
|
1969
1984
|
this.ctx.statusContainer.disposeChildren();
|
|
1970
1985
|
}
|
|
1986
|
+
const pinnedError = this.#pinnedErrorMessage?.errorMessage;
|
|
1987
|
+
const terminalFailurePinned =
|
|
1988
|
+
!event.success &&
|
|
1989
|
+
this.#pinnedErrorComponent !== undefined &&
|
|
1990
|
+
pinnedError !== undefined &&
|
|
1991
|
+
pinnedError === event.finalError;
|
|
1992
|
+
let stalePinnedErrorCleared = false;
|
|
1993
|
+
if (!event.success && this.#pinnedErrorComponent && !terminalFailurePinned) {
|
|
1994
|
+
this.#pinnedErrorComponent.setErrorPinned(false);
|
|
1995
|
+
this.#pinnedErrorComponent = undefined;
|
|
1996
|
+
this.#pinnedErrorMessage = undefined;
|
|
1997
|
+
this.#restorePinnedErrorInline = true;
|
|
1998
|
+
this.ctx.clearPinnedError();
|
|
1999
|
+
stalePinnedErrorCleared = true;
|
|
2000
|
+
}
|
|
1971
2001
|
let appliedRetryUpdate = false;
|
|
1972
2002
|
for (const retryError of event.retryErrors ?? []) {
|
|
1973
2003
|
const component = this.#takeRetrySupersededAssistantComponent(retryError.persistenceKey);
|
|
1974
2004
|
if (!component) continue;
|
|
1975
2005
|
component.applyRetryRecovery(retryError.retryRecovery);
|
|
1976
|
-
if (this.#pinnedErrorComponent === component)
|
|
2006
|
+
if (!terminalFailurePinned && this.#pinnedErrorComponent === component) {
|
|
2007
|
+
this.#pinnedErrorComponent = undefined;
|
|
2008
|
+
this.#pinnedErrorMessage = undefined;
|
|
2009
|
+
this.#restorePinnedErrorInline = true;
|
|
2010
|
+
}
|
|
1977
2011
|
appliedRetryUpdate = true;
|
|
1978
2012
|
}
|
|
1979
|
-
if (
|
|
2013
|
+
if (
|
|
2014
|
+
!terminalFailurePinned &&
|
|
2015
|
+
!stalePinnedErrorCleared &&
|
|
2016
|
+
(appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0)
|
|
2017
|
+
) {
|
|
1980
2018
|
this.ctx.clearPinnedError();
|
|
1981
2019
|
}
|
|
1982
2020
|
this.#clearRetrySupersededAssistantComponents();
|
|
1983
2021
|
if (!event.success) {
|
|
1984
|
-
|
|
2022
|
+
if (terminalFailurePinned) {
|
|
2023
|
+
const terminalError = this.#restorePinnedErrorInline
|
|
2024
|
+
? `Retry failed after ${event.attempt} attempts: ${event.finalError || pinnedError || "Unknown error"}`
|
|
2025
|
+
: (pinnedError ?? event.finalError);
|
|
2026
|
+
if (terminalError) this.ctx.showPinnedError(terminalError);
|
|
2027
|
+
this.#restorePinnedErrorInline = true;
|
|
2028
|
+
} else {
|
|
2029
|
+
this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
|
|
2030
|
+
}
|
|
1985
2031
|
}
|
|
1986
2032
|
this.#ensureWorkingLoaderWhileStreaming();
|
|
1987
2033
|
this.ctx.ui.requestRender();
|
|
@@ -2854,11 +2854,18 @@ export class AgentSession {
|
|
|
2854
2854
|
// tool_result and corrupts message history. The handler also
|
|
2855
2855
|
// schedules its own retry, so a real empty stop never needs the
|
|
2856
2856
|
// active-goal threshold pre-empt below.
|
|
2857
|
-
|
|
2857
|
+
const emptyOutputRecovery = await this.#recovery.handleEmptyAssistantStop(msg);
|
|
2858
|
+
if (emptyOutputRecovery === "continue") {
|
|
2858
2859
|
maintenanceRoute("empty-stop-handled");
|
|
2859
2860
|
await emitAgentEndNotification({ willContinue: true });
|
|
2860
2861
|
return;
|
|
2861
2862
|
}
|
|
2863
|
+
if (emptyOutputRecovery === "terminal") {
|
|
2864
|
+
// The cap already closed retry state and made provider-empty errors
|
|
2865
|
+
// non-retryable. Continue through terminal maintenance so session_stop
|
|
2866
|
+
// hooks and queued follow-up handling retain their normal contract.
|
|
2867
|
+
maintenanceRoute("empty-stop-retry-cap");
|
|
2868
|
+
}
|
|
2862
2869
|
|
|
2863
2870
|
// Record quota exhaustion before deciding whether this failed turn may be
|
|
2864
2871
|
// replayed. Visible/side-effecting output then remains terminal while its
|
|
@@ -395,7 +395,7 @@ export class TurnRecovery {
|
|
|
395
395
|
}
|
|
396
396
|
|
|
397
397
|
/** Handles empty terminal assistant turns and schedules bounded recovery. */
|
|
398
|
-
handleEmptyAssistantStop(message: AssistantMessage): Promise<
|
|
398
|
+
handleEmptyAssistantStop(message: AssistantMessage): Promise<"continue" | "terminal" | undefined> {
|
|
399
399
|
return this.#handleEmptyAssistantStop(message);
|
|
400
400
|
}
|
|
401
401
|
|
|
@@ -648,24 +648,37 @@ export class TurnRecovery {
|
|
|
648
648
|
return retryErrors;
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
-
|
|
652
|
-
if (
|
|
651
|
+
#isRecoverableProviderEmptyOutput(message: AssistantMessage): boolean {
|
|
652
|
+
if (message.stopReason !== "error") return false;
|
|
653
|
+
const id = this.#classifyRetryMessage(message);
|
|
654
|
+
if (!AIError.is(id, AIError.Flag.EmptyResponse)) return false;
|
|
655
|
+
return message.content.every(
|
|
656
|
+
block => block.type === "thinking" || (block.type === "text" && !hasNonWhitespace(block.text)),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<"continue" | "terminal" | undefined> {
|
|
661
|
+
const providerEmptyOutput = this.#isRecoverableProviderEmptyOutput(assistantMessage);
|
|
662
|
+
if (!isEmptyAssistantStop(assistantMessage) && !providerEmptyOutput) {
|
|
653
663
|
this.#emptyStopRetryCount = 0;
|
|
654
|
-
return
|
|
664
|
+
return undefined;
|
|
655
665
|
}
|
|
656
666
|
|
|
657
667
|
if (this.#acceptTerminalEmptyStopForPrompt && assistantMessage.stopReason === "stop") {
|
|
658
668
|
this.#acceptTerminalEmptyStopForPrompt = false;
|
|
659
669
|
this.#discardAcceptedTerminalEmptyStop(assistantMessage);
|
|
660
670
|
this.#emptyStopRetryCount = 0;
|
|
661
|
-
return
|
|
671
|
+
return undefined;
|
|
662
672
|
}
|
|
663
673
|
|
|
664
674
|
this.#emptyStopRetryCount++;
|
|
665
675
|
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
|
|
666
676
|
const attempts = this.#emptyStopRetryCount - 1;
|
|
667
|
-
const finalError =
|
|
668
|
-
"Assistant returned
|
|
677
|
+
const finalError = providerEmptyOutput
|
|
678
|
+
? "Assistant returned no final output after retry cap; try switching models"
|
|
679
|
+
: "Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
|
|
680
|
+
assistantMessage.errorMessage = finalError;
|
|
681
|
+
if (providerEmptyOutput) assistantMessage.errorId = AIError.create();
|
|
669
682
|
logger.warn(finalError, {
|
|
670
683
|
attempts,
|
|
671
684
|
model: assistantMessage.model,
|
|
@@ -680,12 +693,12 @@ export class TurnRecovery {
|
|
|
680
693
|
this.#clearPendingRetryErrors();
|
|
681
694
|
this.#retryAttempt = 0;
|
|
682
695
|
this.resolveRetry();
|
|
683
|
-
// A
|
|
684
|
-
// can anchor the next prompt at the full failed-request size
|
|
685
|
-
// compaction at the same boundary. Remove every capped
|
|
686
|
-
// orphans still need this for Anthropic
|
|
696
|
+
// A turn with no actionable output carries no transcript value, while its
|
|
697
|
+
// provider usage can anchor the next prompt at the full failed-request size
|
|
698
|
+
// and re-trigger compaction at the same boundary. Remove every capped
|
|
699
|
+
// empty output; toolUse orphans still need this for Anthropic history.
|
|
687
700
|
await this.dropPersistedAssistantTurn(assistantMessage);
|
|
688
|
-
return
|
|
701
|
+
return "terminal";
|
|
689
702
|
}
|
|
690
703
|
this.discardAssistantTurn(assistantMessage);
|
|
691
704
|
this.#host.agent.appendMessage({
|
|
@@ -695,7 +708,7 @@ export class TurnRecovery {
|
|
|
695
708
|
timestamp: Date.now(),
|
|
696
709
|
});
|
|
697
710
|
this.#host.scheduleAgentContinue({ generation: this.#host.promptGeneration() });
|
|
698
|
-
return
|
|
711
|
+
return "continue";
|
|
699
712
|
}
|
|
700
713
|
|
|
701
714
|
#emptyStopRetryReminder(): string {
|
|
@@ -1019,52 +1032,39 @@ export class TurnRecovery {
|
|
|
1019
1032
|
if (this.#isUsagePreflightBlocked(message)) return false;
|
|
1020
1033
|
|
|
1021
1034
|
const id = this.#classifyRetryMessage(message);
|
|
1022
|
-
// Context overflow is handled by compaction, not retry
|
|
1035
|
+
// Context overflow is handled by compaction, not retry.
|
|
1023
1036
|
const contextWindow = this.#host.model()?.contextWindow ?? 0;
|
|
1024
1037
|
if (AIError.isContextOverflow(message, contextWindow)) return false;
|
|
1025
1038
|
|
|
1026
1039
|
// Credential rotation and classifier fallbacks are safe only before
|
|
1027
1040
|
// committed text, images, tool calls, or server tools. Thinking-only
|
|
1028
|
-
// output remains replay-safe.
|
|
1029
|
-
//
|
|
1030
|
-
//
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1041
|
+
// output remains replay-safe. A classifier refusal or malformed-function
|
|
1042
|
+
// response may also be replayed when every emitted tool call is paired
|
|
1043
|
+
// with positive proof that it never executed.
|
|
1044
|
+
const replaySafeUnexecutedTools =
|
|
1045
|
+
(this.isClassifierRefusal(message) || AIError.is(id, AIError.Flag.MalformedFunctionCall)) &&
|
|
1046
|
+
this.#unexecutedToolCallsReplaySafe(message);
|
|
1047
|
+
if (this.#hasReplayUnsafeOutput(message) && !replaySafeUnexecutedTools) return false;
|
|
1034
1048
|
if (AIError.is(id, AIError.Flag.AccountPolicy) || this.isClassifierRefusal(message)) return true;
|
|
1035
1049
|
return AIError.retriable(id);
|
|
1036
1050
|
}
|
|
1037
1051
|
|
|
1038
1052
|
/**
|
|
1039
|
-
* True when
|
|
1040
|
-
*
|
|
1053
|
+
* True when every emitted tool call provably never executed and no other
|
|
1054
|
+
* replay-unsafe output exists. The caller restricts this exception to
|
|
1055
|
+
* classifier refusals and malformed-function responses.
|
|
1041
1056
|
*
|
|
1042
|
-
*
|
|
1043
|
-
*
|
|
1044
|
-
*
|
|
1045
|
-
*
|
|
1046
|
-
* likely have served just ended the turn.
|
|
1057
|
+
* Gemini can report `MALFORMED_FUNCTION_CALL` after streaming an earlier,
|
|
1058
|
+
* well-formed call. Anthropic classifiers can likewise refuse after a call.
|
|
1059
|
+
* The agent loop pairs each emitted-but-unrun call with a synthetic
|
|
1060
|
+
* `executed: false` result, which proves `tool.execute()` never ran.
|
|
1047
1061
|
*
|
|
1048
|
-
*
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1052
|
-
* lifted only when ALL of the following hold, and any uncertainty (assistant
|
|
1053
|
-
* message missing from state, a call with no result, a non-synthetic result, an
|
|
1054
|
-
* `executed` that is not exactly `false`) keeps it in place:
|
|
1055
|
-
*
|
|
1056
|
-
* - the stop is a classifier refusal/sensitivity stop;
|
|
1057
|
-
* - the only replay-unsafe blocks are tool calls — an `image`, an
|
|
1058
|
-
* `anthropicServerTool`, or committed non-whitespace text has already rendered
|
|
1059
|
-
* or has side effects, so replaying would duplicate it;
|
|
1060
|
-
* - at least one tool call was emitted (otherwise the plain refusal path already
|
|
1061
|
-
* handles it);
|
|
1062
|
-
* - every emitted call id has a result after the assistant message in state, and
|
|
1063
|
-
* every such result is synthetic with `executed === false`.
|
|
1062
|
+
* Any uncertainty keeps the replay veto in place: the assistant must exist
|
|
1063
|
+
* in state, every call must have a later synthetic result, every result must
|
|
1064
|
+
* say `executed === false`, and the turn must contain no image, server tool,
|
|
1065
|
+
* or committed non-whitespace text.
|
|
1064
1066
|
*/
|
|
1065
|
-
#
|
|
1066
|
-
if (!this.isClassifierRefusal(message)) return false;
|
|
1067
|
-
|
|
1067
|
+
#unexecutedToolCallsReplaySafe(message: AssistantMessage): boolean {
|
|
1068
1068
|
const emittedToolCallIds = new Set<string>();
|
|
1069
1069
|
for (const block of message.content) {
|
|
1070
1070
|
if (block.type === "toolCall") {
|
|
@@ -1076,7 +1076,7 @@ export class TurnRecovery {
|
|
|
1076
1076
|
}
|
|
1077
1077
|
if (emittedToolCallIds.size === 0) return false;
|
|
1078
1078
|
|
|
1079
|
-
// The
|
|
1079
|
+
// The errored assistant message is NOT the tail of state: the agent loop
|
|
1080
1080
|
// appends the synthetic results after it before the turn ends, so locate it
|
|
1081
1081
|
// by walking backwards exactly as `classifyResolvedInterruptedToolTurn` does.
|
|
1082
1082
|
const messages = this.#host.agent.state.messages;
|
|
@@ -1803,6 +1803,10 @@ export class TurnRecovery {
|
|
|
1803
1803
|
|
|
1804
1804
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
1805
1805
|
const id = this.#classifyRetryMessage(message);
|
|
1806
|
+
const preserveFailedTurn =
|
|
1807
|
+
options?.preserveFailedTurn === true ||
|
|
1808
|
+
((classifierRefusal || AIError.is(id, AIError.Flag.MalformedFunctionCall)) &&
|
|
1809
|
+
this.#unexecutedToolCallsReplaySafe(message));
|
|
1806
1810
|
const rateLimitReason = parseRateLimitReason(errorMessage);
|
|
1807
1811
|
const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
|
|
1808
1812
|
const accountPolicyDenial = AIError.is(id, AIError.Flag.AccountPolicy);
|
|
@@ -2013,9 +2017,10 @@ export class TurnRecovery {
|
|
|
2013
2017
|
errorId: message.errorId,
|
|
2014
2018
|
});
|
|
2015
2019
|
|
|
2016
|
-
// Resolved stream-stall tools
|
|
2017
|
-
//
|
|
2018
|
-
|
|
2020
|
+
// Resolved stream-stall tools and proven-unexecuted malformed/refused
|
|
2021
|
+
// calls keep their assistant/result pair. Continuation then sees explicit
|
|
2022
|
+
// synthetic results and cannot repeat a side effect.
|
|
2023
|
+
if (!preserveFailedTurn) {
|
|
2019
2024
|
this.removeAssistantMessageFromActiveContext(message, "auto-retry");
|
|
2020
2025
|
}
|
|
2021
2026
|
|
|
@@ -2058,11 +2063,10 @@ export class TurnRecovery {
|
|
|
2058
2063
|
// rejects any assistant tail, so a missed removal fails the scheduled
|
|
2059
2064
|
// retry locally before a provider request is ever made. Re-check the
|
|
2060
2065
|
// tail after the backoff (covering rebuilds during the sleep too) and
|
|
2061
|
-
// strip a still-failed assistant tail by position. Never
|
|
2062
|
-
//
|
|
2063
|
-
//
|
|
2064
|
-
|
|
2065
|
-
if (!options?.preserveFailedTurn && this.#host.promptGeneration() === generation) {
|
|
2066
|
+
// strip a still-failed assistant tail by position. Never when preserving
|
|
2067
|
+
// the failed turn — the kept turn ends in synthetic tool results that
|
|
2068
|
+
// continue() accepts — and never once a newer prompt owns the session.
|
|
2069
|
+
if (!preserveFailedTurn && this.#host.promptGeneration() === generation) {
|
|
2066
2070
|
this.#stripFailedAssistantTail();
|
|
2067
2071
|
}
|
|
2068
2072
|
|