@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.
@@ -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<boolean>;
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.2",
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.2",
54
- "@oh-my-pi/omp-stats": "17.3.2",
55
- "@oh-my-pi/omptype": "17.3.2",
56
- "@oh-my-pi/pi-agent-core": "17.3.2",
57
- "@oh-my-pi/pi-ai": "17.3.2",
58
- "@oh-my-pi/pi-catalog": "17.3.2",
59
- "@oh-my-pi/pi-mnemopi": "17.3.2",
60
- "@oh-my-pi/pi-natives": "17.3.2",
61
- "@oh-my-pi/pi-tui": "17.3.2",
62
- "@oh-my-pi/pi-utils": "17.3.2",
63
- "@oh-my-pi/pi-wire": "17.3.2",
64
- "@oh-my-pi/snapcompact": "17.3.2",
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 the previous turn's inline error in the transcript before dropping
747
- // the banner, so the error stays in history once the banner is gone.
748
- this.#pinnedErrorComponent?.setErrorPinned(false);
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 (e.g. Anthropic content-filter block)
1324
- // above the editor so it survives transcript scroll. Cleared at the next
1325
- // turn's agent_start. Suppress the transcript's inline `Error: …` line for
1326
- // the same message while pinned so the error isn't rendered twice.
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.ctx.showPinnedError(event.message.errorMessage);
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) this.#pinnedErrorComponent = undefined;
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 (appliedRetryUpdate || (event.retryErrors?.length ?? 0) > 0) {
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
- this.ctx.showError(`Retry failed after ${event.attempt} attempts: ${event.finalError || "Unknown error"}`);
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();
@@ -1,4 +1,4 @@
1
1
  <system-injection>
2
- Stopped; task incomplete. Continue.
2
+ Stopped without actionable output; task incomplete. Continue with a user-visible final answer or the next required tool call.
3
3
  Attempt #{{retryCount}}/{{maxRetries}}
4
4
  </system-injection>
@@ -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
- if (await this.#recovery.handleEmptyAssistantStop(msg)) {
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<boolean> {
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
- async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
652
- if (!isEmptyAssistantStop(assistantMessage)) {
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 false;
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 false;
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 empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
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 zero-content turn carries no transcript value, while its provider usage
684
- // can anchor the next prompt at the full failed-request size and re-trigger
685
- // compaction at the same boundary. Remove every capped empty stop; toolUse
686
- // orphans still need this for Anthropic message-history validity.
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 false;
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 true;
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. The one exception is a refusal whose ONLY
1029
- // replay-unsafe output is tool calls the agent loop proved never ran
1030
- // (`#refusalReplaySafe`): nothing reached the user and no side effect
1031
- // happened, so discarding the turn duplicates nothing and the fallback
1032
- // chain gets its chance.
1033
- if (this.#hasReplayUnsafeOutput(message) && !this.#refusalReplaySafe(message)) return false;
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 a classifier refusal is replay-safe *despite* having emitted tool
1040
- * calls, because every emitted call provably never executed.
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
- * Anthropic's request classifier can fire after the model has already streamed
1043
- * a tool call, which used to strand the turn: `#hasReplayUnsafeOutput` sees the
1044
- * `toolCall` block and vetoes retry one line before the refusal could reach the
1045
- * fallback-chain consult, so a refusal that a different model family would very
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
- * That veto exists to protect against duplicating work or visible output. Neither
1049
- * risk is present here: the agent loop pairs each emitted-but-unrun call with a
1050
- * synthetic `executed: false` result (see {@link isSyntheticToolResultMessage}),
1051
- * which is a positive record that `tool.execute()` never ran. So the veto is
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
- #refusalReplaySafe(message: AssistantMessage): boolean {
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 refused assistant message is NOT the tail of state: the agent loop
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 have already emitted results. Keep that failed
2017
- // turn intact so continuation cannot repeat their side effects.
2018
- if (!options?.preserveFailedTurn) {
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 in
2062
- // preserveFailedTurn mode — the kept turn ends in synthetic tool
2063
- // results that continue() accepts — and never once a newer prompt owns
2064
- // the session.
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