@oh-my-pi/pi-coding-agent 17.3.1 → 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.
@@ -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
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  import path from "node:path";
8
8
  import type { AgentEvent, AgentIdentity, AgentMessage, AgentTelemetryConfig } from "@oh-my-pi/pi-agent-core";
9
- import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
9
+ import { EventLoopKeepalive, recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
10
10
  import type { Api, Model, ServiceTierByFamily, Usage } from "@oh-my-pi/pi-ai";
11
11
  import { logger, popLoopPhase, prompt, pushLoopPhase, untilAborted } from "@oh-my-pi/pi-utils";
12
12
  import { ASYNC_JOB_MANAGER_SHUTDOWN_REASON, AsyncJobManager } from "../async";
@@ -1868,6 +1868,7 @@ async function driveSessionToYield(
1868
1868
  monitor: SubagentRunMonitor,
1869
1869
  task: string,
1870
1870
  ): Promise<DriveOutcome> {
1871
+ using _keepalive = new EventLoopKeepalive();
1871
1872
  const abortSignal = monitor.abortSignal;
1872
1873
  let exitCode = 0;
1873
1874
  let error: string | undefined;
@@ -37,16 +37,17 @@ export interface HashlineHeaderContext {
37
37
  }
38
38
 
39
39
  export function formatReadHashlineHeader(displayPath: string, tag: string): string {
40
- // In-workspace reads collapse to the bare filename for brevity: the edit
41
- // tool's snapshot-tag recovery rebinds a bare `[name#tag]` onto the in-tree
42
- // file it uniquely names. Out-of-workspace reads can't lean on that
43
- // recovery refuses to redirect a write outside the cwd/sandbox
44
- // (HashlineFilesystem.allowTagPathRecovery) so an absolute displayPath
45
- // must stay directly resolvable, otherwise the basename resolves against
46
- // cwd, misses, and the edit fails with "File not found" (e.g. ~/.claude/*).
47
- // `shortenPath` keeps `~/.claude/...` (round-trips through resolveToCwd's ~
48
- // expansion) instead of leaking the full home path into the read output.
49
- const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : path.basename(displayPath);
40
+ // In-workspace reads keep their workspace-relative path (e.g.
41
+ // `src/settings.json`), not just the basename: collapsing to the bare name
42
+ // made a header ambiguous whenever another same-named file exists at cwd
43
+ // the edit tool would resolve the bare name against cwd, hit the wrong
44
+ // file, and reject the valid edit via the snapshot-tag guard (the authored
45
+ // path exists, so Patcher's tag-path recovery never runs). The relative
46
+ // path stays directly resolvable against cwd and names the file uniquely.
47
+ // Out-of-workspace reads use an absolute displayPath; `shortenPath` keeps
48
+ // `~/.claude/...` (round-trips through resolveToCwd's ~ expansion) instead
49
+ // of leaking the full home path into the read output.
50
+ const anchor = path.isAbsolute(displayPath) ? shortenPath(displayPath) : displayPath;
50
51
  return formatHashlineHeader(anchor, tag);
51
52
  }
52
53
 
@@ -38,7 +38,7 @@ interface ObservedPromiseState {
38
38
  const observedBrowserPromises = new WeakMap<Promise<unknown>, ObservedPromiseState>();
39
39
  const observedPromiseConstructor = { [Symbol.species]: Promise };
40
40
 
41
- type PromiseCombinatorName = "all" | "race";
41
+ type PromiseCombinatorName = "all" | "race" | "allSettled" | "any";
42
42
  type PromiseCombinator = (this: PromiseConstructor, values: Iterable<unknown>) => Promise<unknown>;
43
43
 
44
44
  interface PromiseCombinatorTrackingContext {
@@ -46,11 +46,13 @@ interface PromiseCombinatorTrackingContext {
46
46
  onFloatingRejection: FloatingRejectionHandler;
47
47
  }
48
48
 
49
- const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race"];
49
+ const PROMISE_COMBINATORS: readonly PromiseCombinatorName[] = ["all", "race", "allSettled", "any"];
50
50
  const NativePromise = Promise;
51
51
  const nativePromiseCombinators: Record<PromiseCombinatorName, PromiseCombinator> = {
52
52
  all: Promise.all,
53
53
  race: Promise.race,
54
+ allSettled: Promise.allSettled,
55
+ any: Promise.any,
54
56
  };
55
57
  const promiseCombinatorTracking = new AsyncLocalStorage<PromiseCombinatorTrackingContext>();
56
58
  let previousPromiseDescriptor: PropertyDescriptor | undefined;
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Utilities for launching an external text editor ($VISUAL / $EDITOR).
3
3
  */
4
- import { spawn } from "node:child_process";
5
4
  import * as fs from "node:fs/promises";
6
5
  import * as os from "node:os";
7
6
  import * as path from "node:path";
@@ -51,17 +50,17 @@ export async function openInEditor(
51
50
  try {
52
51
  await Bun.write(tmpFile, content);
53
52
 
54
- const [editor, ...editorArgs] = editorCmd.split(" ");
55
- const stdio = options?.stdio ?? ["inherit", "inherit", "inherit"];
56
- const child =
53
+ const [stdin, stdout, stderr] = options?.stdio ?? ["inherit", "inherit", "inherit"];
54
+ const cmd =
57
55
  process.platform === "win32"
58
- ? spawn(editor, [...editorArgs, tmpFile], { stdio, shell: true })
59
- : spawn($which("sh") ?? "sh", ["-c", `${editorCmd} "$1"`, "sh", tmpFile], { stdio });
60
- const { promise, reject, resolve } = Promise.withResolvers<number>();
61
- child.once("exit", (code, signal) => resolve(code ?? (signal ? -1 : 0)));
62
- child.once("error", error => reject(error));
63
- const exitCode = await promise;
64
-
56
+ ? ["cmd", "/c", `${editorCmd} "${tmpFile}"`]
57
+ : [$which("sh") ?? "sh", "-c", `${editorCmd} "$1"`, "sh", tmpFile];
58
+ const child = Bun.spawn(cmd, {
59
+ stdin,
60
+ stdout,
61
+ stderr,
62
+ });
63
+ const exitCode = await child.exited;
65
64
  if (exitCode === 0) {
66
65
  const text = await Bun.file(tmpFile).text();
67
66
  if (options?.trimTrailingNewline === false) {
@@ -9,11 +9,7 @@
9
9
  * endpoint.
10
10
  */
11
11
  import { type AuthStorage, type FetchImpl, type OAuthAccess, withOAuthAccess } from "@oh-my-pi/pi-ai";
12
- import {
13
- ANTIGRAVITY_SYSTEM_INSTRUCTION,
14
- getAntigravityUserAgent,
15
- getGeminiCliHeaders,
16
- } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
12
+ import { getAntigravityUserAgent, getGeminiCliHeaders } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
17
13
  import { fetchWithRetry, USER_AGENT } from "@oh-my-pi/pi-utils";
18
14
 
19
15
  import type { SearchCitation, SearchResponse, SearchSource } from "../../../web/search/types";
@@ -441,10 +437,9 @@ async function callGeminiSearch(
441
437
  };
442
438
 
443
439
  const normalizedSystemPrompt = systemPrompt?.toWellFormed();
444
- const systemInstructionParts: Array<{ text: string }> = [
445
- ...(auth.isAntigravity ? [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION }] : []),
446
- ...(normalizedSystemPrompt ? [{ text: normalizedSystemPrompt }] : []),
447
- ];
440
+ const systemInstructionParts: Array<{ text: string }> = normalizedSystemPrompt
441
+ ? [{ text: normalizedSystemPrompt }]
442
+ : [];
448
443
 
449
444
  const requestBody: Record<string, unknown> = {
450
445
  project: auth.projectId,