@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.
- package/CHANGELOG.md +16 -0
- package/dist/{CHANGELOG-2swgsx9f.md → CHANGELOG-s5t1xdmy.md} +16 -0
- package/dist/cli.js +3357 -3355
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/update-cli.d.ts +35 -1
- package/dist/types/session/turn-recovery.d.ts +1 -1
- package/package.json +13 -13
- package/src/cli/update-cli.ts +121 -25
- 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 +31 -10
- package/src/session/turn-recovery.ts +59 -55
- package/src/task/executor.ts +2 -1
- package/src/tools/read-format.ts +11 -10
- package/src/tools/run-scope.ts +4 -2
- package/src/utils/external-editor.ts +10 -11
- package/src/web/search/providers/gemini.ts +4 -9
|
@@ -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
|
|
package/src/task/executor.ts
CHANGED
|
@@ -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;
|
package/src/tools/read-format.ts
CHANGED
|
@@ -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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
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
|
|
package/src/tools/run-scope.ts
CHANGED
|
@@ -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 [
|
|
55
|
-
const
|
|
56
|
-
const child =
|
|
53
|
+
const [stdin, stdout, stderr] = options?.stdio ?? ["inherit", "inherit", "inherit"];
|
|
54
|
+
const cmd =
|
|
57
55
|
process.platform === "win32"
|
|
58
|
-
?
|
|
59
|
-
:
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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,
|