@oh-my-pi/pi-coding-agent 16.2.1 → 16.2.2
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 +23 -0
- package/dist/cli.js +2648 -2662
- package/dist/types/advisor/runtime.d.ts +15 -1
- package/dist/types/config/model-roles.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +32 -12
- package/dist/types/discovery/omp-extension-roots.d.ts +3 -3
- package/dist/types/edit/index.d.ts +18 -0
- package/dist/types/edit/streaming.d.ts +30 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -0
- package/dist/types/mcp/oauth-discovery.d.ts +0 -11
- package/dist/types/modes/components/status-line/component.d.ts +0 -2
- package/dist/types/sdk.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +26 -2
- package/dist/types/session/messages.d.ts +6 -7
- package/dist/types/session/settings-stream-fn.d.ts +21 -0
- package/dist/types/session/turn-persistence.d.ts +88 -0
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +196 -0
- package/src/advisor/runtime.ts +65 -2
- package/src/auto-thinking/classifier.ts +2 -2
- package/src/config/model-resolver.ts +5 -1
- package/src/config/model-roles.ts +3 -3
- package/src/config/settings-schema.ts +30 -8
- package/src/discovery/omp-extension-roots.ts +38 -13
- package/src/edit/index.ts +21 -0
- package/src/edit/streaming.ts +170 -0
- package/src/extensibility/custom-tools/types.ts +1 -0
- package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +18 -1
- package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +59 -2
- package/src/extensibility/plugins/manager.ts +74 -4
- package/src/extensibility/shared-events.ts +1 -0
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/mcp/oauth-discovery.ts +5 -29
- package/src/mcp/transports/http.ts +3 -1
- package/src/mnemopi/backend.ts +2 -2
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/components/assistant-message.ts +5 -5
- package/src/modes/components/status-line/component.ts +1 -9
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/controllers/event-controller.ts +8 -11
- package/src/modes/interactive-mode.ts +0 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/modes/utils/ui-helpers.ts +5 -4
- package/src/sdk.ts +12 -26
- package/src/session/agent-session.ts +319 -219
- package/src/session/messages.ts +20 -12
- package/src/session/session-persistence.ts +1 -2
- package/src/session/settings-stream-fn.ts +49 -0
- package/src/session/turn-persistence.ts +142 -0
- package/src/session/unexpected-stop-classifier.ts +2 -2
- package/src/slash-commands/helpers/mcp.ts +2 -1
- package/src/tiny/models.ts +8 -6
- package/src/tiny/text.ts +14 -7
- package/src/tools/image-gen.ts +2 -1
- package/src/tools/tts.ts +2 -1
- package/src/utils/title-generator.ts +1 -1
- package/src/web/search/providers/tavily.ts +36 -19
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
type CompactionSummaryMessage,
|
|
37
37
|
countTokens,
|
|
38
38
|
resolveTelemetry,
|
|
39
|
+
type StreamFn,
|
|
39
40
|
ThinkingLevel,
|
|
40
41
|
type ToolChoiceDirective,
|
|
41
42
|
} from "@oh-my-pi/pi-agent-core";
|
|
@@ -80,6 +81,7 @@ import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/to
|
|
|
80
81
|
import type {
|
|
81
82
|
AssistantMessage,
|
|
82
83
|
AssistantMessageEvent,
|
|
84
|
+
Context,
|
|
83
85
|
ImageContent,
|
|
84
86
|
Message,
|
|
85
87
|
MessageAttribution,
|
|
@@ -102,18 +104,13 @@ import {
|
|
|
102
104
|
clearAnthropicFastModeFallback,
|
|
103
105
|
deriveClaudeDeviceId,
|
|
104
106
|
Effort,
|
|
105
|
-
isContextOverflow,
|
|
106
|
-
isUsageLimitError,
|
|
107
107
|
parseRateLimitReason,
|
|
108
108
|
resolveServiceTier,
|
|
109
109
|
streamSimple,
|
|
110
110
|
} from "@oh-my-pi/pi-ai";
|
|
111
|
+
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
111
112
|
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
112
|
-
import {
|
|
113
|
-
GeminiHeaderRunDetector,
|
|
114
|
-
isGeminiThinkingModel,
|
|
115
|
-
THINKING_LOOP_ERROR_MARKER,
|
|
116
|
-
} from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
|
113
|
+
import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
|
117
114
|
import { isFireworksFastModelId, toFireworksBaseModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
|
|
118
115
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
119
116
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
@@ -125,7 +122,6 @@ import {
|
|
|
125
122
|
getInstallId,
|
|
126
123
|
isBunTestRuntime,
|
|
127
124
|
isEnoent,
|
|
128
|
-
isUnexpectedSocketCloseMessage,
|
|
129
125
|
logger,
|
|
130
126
|
prompt,
|
|
131
127
|
relativePathWithinRoot,
|
|
@@ -307,7 +303,6 @@ import {
|
|
|
307
303
|
type BashExecutionMessage,
|
|
308
304
|
type CustomMessage,
|
|
309
305
|
convertToLlm,
|
|
310
|
-
GENERIC_ABORT_SENTINEL,
|
|
311
306
|
type PythonExecutionMessage,
|
|
312
307
|
readQueueChipText,
|
|
313
308
|
SILENT_ABORT_MARKER,
|
|
@@ -324,6 +319,7 @@ import { formatSessionHistoryMarkdown } from "./session-history-format";
|
|
|
324
319
|
import { cleanupEmptyMoveSession, type SessionManager } from "./session-manager";
|
|
325
320
|
import type { ShakeMode, ShakeResult } from "./shake-types";
|
|
326
321
|
import { ToolChoiceQueue } from "./tool-choice-queue";
|
|
322
|
+
import { planTurnPersistence, sameMessageContent, sessionMessagePersistenceKey } from "./turn-persistence";
|
|
327
323
|
import { classifyUnexpectedStop, isUnexpectedStopCandidate } from "./unexpected-stop-classifier";
|
|
328
324
|
import { YieldQueue } from "./yield-queue";
|
|
329
325
|
|
|
@@ -369,7 +365,14 @@ export type AgentSessionEvent =
|
|
|
369
365
|
/** True when compaction was skipped for a benign reason (no model, no candidates, nothing to compact). */
|
|
370
366
|
skipped?: boolean;
|
|
371
367
|
}
|
|
372
|
-
| {
|
|
368
|
+
| {
|
|
369
|
+
type: "auto_retry_start";
|
|
370
|
+
attempt: number;
|
|
371
|
+
maxAttempts: number;
|
|
372
|
+
delayMs: number;
|
|
373
|
+
errorMessage: string;
|
|
374
|
+
errorId?: number;
|
|
375
|
+
}
|
|
373
376
|
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string }
|
|
374
377
|
| { type: "retry_fallback_applied"; from: string; to: string; role: string }
|
|
375
378
|
| { type: "retry_fallback_succeeded"; model: string; role: string }
|
|
@@ -512,6 +515,21 @@ export interface AgentSessionConfig {
|
|
|
512
515
|
builtInToolNames?: Iterable<string>;
|
|
513
516
|
/** Current session pre-LLM message transform pipeline */
|
|
514
517
|
transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => AgentMessage[] | Promise<AgentMessage[]>;
|
|
518
|
+
/**
|
|
519
|
+
* Per-request transform applied after `convertToLlm` and before the
|
|
520
|
+
* provider call. Used for snapcompact, secret obfuscation, and image
|
|
521
|
+
* clamping. When supplied via {@link createAgentSession}, the advisor agent
|
|
522
|
+
* inherits this so its requests undergo the same shaping as the main turn.
|
|
523
|
+
*/
|
|
524
|
+
transformProviderContext?: (context: Context, model: Model) => Context | Promise<Context>;
|
|
525
|
+
/**
|
|
526
|
+
* Stream wrapper passed to the advisor agent so its requests apply the
|
|
527
|
+
* session's `providers.openrouterVariant`, `providers.antigravityEndpoint`,
|
|
528
|
+
* `providers.maxInFlightRequests`, and `model.loopGuard.*` settings —
|
|
529
|
+
* keeping OpenRouter sticky-routing / response caching consistent with the
|
|
530
|
+
* main agent. Defaults to plain `streamSimple` when omitted.
|
|
531
|
+
*/
|
|
532
|
+
advisorStreamFn?: StreamFn;
|
|
515
533
|
/** Provider payload hook used by the active session request path */
|
|
516
534
|
onPayload?: SimpleStreamOptions["onPayload"];
|
|
517
535
|
/** Provider response hook used by the active session request path */
|
|
@@ -1333,6 +1351,8 @@ export class AgentSession {
|
|
|
1333
1351
|
#onPayload: SimpleStreamOptions["onPayload"] | undefined;
|
|
1334
1352
|
#onResponse: SimpleStreamOptions["onResponse"] | undefined;
|
|
1335
1353
|
#onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
|
|
1354
|
+
#transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
|
|
1355
|
+
#advisorStreamFn: StreamFn | undefined;
|
|
1336
1356
|
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
|
1337
1357
|
#rebuildSystemPrompt:
|
|
1338
1358
|
| ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
|
|
@@ -1387,6 +1407,7 @@ export class AgentSession {
|
|
|
1387
1407
|
* `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so
|
|
1388
1408
|
* it cannot leak into later unrelated aborts. */
|
|
1389
1409
|
#planInternalAbortPending = false;
|
|
1410
|
+
#pendingAbortErrorId?: number;
|
|
1390
1411
|
|
|
1391
1412
|
#postPromptTasks = new Set<Promise<unknown>>();
|
|
1392
1413
|
#postPromptTasksPromise: Promise<void> | undefined = undefined;
|
|
@@ -1657,6 +1678,8 @@ export class AgentSession {
|
|
|
1657
1678
|
this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
|
|
1658
1679
|
this.#requestedToolNames = config.requestedToolNames;
|
|
1659
1680
|
this.#transformContext = config.transformContext ?? (messages => messages);
|
|
1681
|
+
this.#transformProviderContext = config.transformProviderContext;
|
|
1682
|
+
this.#advisorStreamFn = config.advisorStreamFn;
|
|
1660
1683
|
this.#onPayload = config.onPayload;
|
|
1661
1684
|
this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
|
|
1662
1685
|
// Avoid wrapping in an `async` closure when no user callback is configured: the
|
|
@@ -2003,6 +2026,25 @@ export class AgentSession {
|
|
|
2003
2026
|
conversationId: undefined,
|
|
2004
2027
|
}
|
|
2005
2028
|
: undefined;
|
|
2029
|
+
// Mirror the provider-shaping options the SDK installs on the main agent
|
|
2030
|
+
// so the advisor's requests cache, route, and obfuscate identically:
|
|
2031
|
+
//
|
|
2032
|
+
// - `streamFn`/`advisorStreamFn` carries the session's OpenRouter sticky
|
|
2033
|
+
// variant, antigravity endpoint mode, in-flight cap, and loop guard.
|
|
2034
|
+
// - `onPayload`/`onResponse`/`onSseEvent` keep extension hooks plus the
|
|
2035
|
+
// per-session `RawSseDebugBuffer` recording advisor traffic too.
|
|
2036
|
+
// - `providerSessionState` shares Codex websockets / Anthropic fast-mode
|
|
2037
|
+
// fallback state with the main agent so both turns reuse the same
|
|
2038
|
+
// transport caches.
|
|
2039
|
+
// - `promptCacheKey` pins OpenAI Responses (and any provider that reads
|
|
2040
|
+
// `prompt_cache_key`) to the advisor session id so consecutive advisor
|
|
2041
|
+
// turns land on the same cache shard.
|
|
2042
|
+
// - `transformProviderContext` applies snapcompact, secret obfuscation,
|
|
2043
|
+
// and image clamping to advisor requests like the main turn.
|
|
2044
|
+
//
|
|
2045
|
+
// Without this parity, OpenRouter advisor calls bypassed the variant
|
|
2046
|
+
// suffix and prompt-cache key and produced inconsistent cache hits
|
|
2047
|
+
// (see can1357/oh-my-pi#3639).
|
|
2006
2048
|
const advisorAgent = new Agent({
|
|
2007
2049
|
initialState: {
|
|
2008
2050
|
systemPrompt,
|
|
@@ -2012,7 +2054,14 @@ export class AgentSession {
|
|
|
2012
2054
|
},
|
|
2013
2055
|
appendOnlyContext,
|
|
2014
2056
|
sessionId: advisorSessionId,
|
|
2057
|
+
promptCacheKey: advisorSessionId,
|
|
2058
|
+
providerSessionState: this.#providerSessionState,
|
|
2015
2059
|
getApiKey: requestModel => this.#modelRegistry.resolver(requestModel, advisorSessionId),
|
|
2060
|
+
streamFn: this.#advisorStreamFn,
|
|
2061
|
+
onPayload: this.#onPayload,
|
|
2062
|
+
onResponse: this.#onResponse,
|
|
2063
|
+
onSseEvent: this.#onSseEvent,
|
|
2064
|
+
transformProviderContext: this.#transformProviderContext,
|
|
2016
2065
|
intentTracing: false,
|
|
2017
2066
|
telemetry: advisorTelemetry,
|
|
2018
2067
|
serviceTier: advisorServiceTier,
|
|
@@ -2027,6 +2076,19 @@ export class AgentSession {
|
|
|
2027
2076
|
advisorAgent.reset();
|
|
2028
2077
|
appendOnlyContext.log.clear();
|
|
2029
2078
|
},
|
|
2079
|
+
rollbackTo: count => {
|
|
2080
|
+
// Drop the failed user batch + synthetic assistant-error turn
|
|
2081
|
+
// `Agent.#runLoop` appended for a turn ending in `stopReason: "error"`.
|
|
2082
|
+
// The append-only context auto-resyncs on the next prompt's
|
|
2083
|
+
// `syncMessages` shrink path, but reset the sync cursor so the log
|
|
2084
|
+
// can't carry the dropped tail forward if no further turn ever runs.
|
|
2085
|
+
const messages = advisorAgent.state.messages;
|
|
2086
|
+
if (count < messages.length) {
|
|
2087
|
+
messages.length = count;
|
|
2088
|
+
}
|
|
2089
|
+
appendOnlyContext.resetSyncCursor();
|
|
2090
|
+
advisorAgent.state.error = undefined;
|
|
2091
|
+
},
|
|
2030
2092
|
state: advisorAgent.state,
|
|
2031
2093
|
};
|
|
2032
2094
|
|
|
@@ -2049,6 +2111,14 @@ export class AgentSession {
|
|
|
2049
2111
|
maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
|
|
2050
2112
|
obfuscator: this.#obfuscator,
|
|
2051
2113
|
beginAdvisorUpdate: () => this.#advisorEmissionGuard.beginUpdate(),
|
|
2114
|
+
notifyFailure: error => {
|
|
2115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2116
|
+
this.emitNotice(
|
|
2117
|
+
"warning",
|
|
2118
|
+
`Advisor unavailable for ${formatModelString(advisorSel.model)}: ${message}`,
|
|
2119
|
+
"advisor",
|
|
2120
|
+
);
|
|
2121
|
+
},
|
|
2052
2122
|
});
|
|
2053
2123
|
if (seedToCurrent) {
|
|
2054
2124
|
this.#advisorRuntime.seedTo(this.agent.state.messages.length);
|
|
@@ -2586,82 +2656,8 @@ export class AgentSession {
|
|
|
2586
2656
|
}
|
|
2587
2657
|
};
|
|
2588
2658
|
|
|
2589
|
-
#messageValueSignature(value: unknown): string {
|
|
2590
|
-
return JSON.stringify(value) ?? "undefined";
|
|
2591
|
-
}
|
|
2592
|
-
|
|
2593
|
-
#sessionMessagesReferToSameTurn(left: AgentMessage, right: AgentMessage): boolean {
|
|
2594
|
-
if (left === right) return true;
|
|
2595
|
-
if (left.role !== right.role) return false;
|
|
2596
|
-
switch (left.role) {
|
|
2597
|
-
case "assistant":
|
|
2598
|
-
if (right.role !== "assistant") return false;
|
|
2599
|
-
return (
|
|
2600
|
-
left.timestamp === right.timestamp &&
|
|
2601
|
-
left.provider === right.provider &&
|
|
2602
|
-
left.model === right.model &&
|
|
2603
|
-
left.responseId === right.responseId &&
|
|
2604
|
-
left.stopReason === right.stopReason &&
|
|
2605
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2606
|
-
);
|
|
2607
|
-
case "toolResult":
|
|
2608
|
-
if (right.role !== "toolResult") return false;
|
|
2609
|
-
return (
|
|
2610
|
-
left.timestamp === right.timestamp &&
|
|
2611
|
-
left.toolCallId === right.toolCallId &&
|
|
2612
|
-
left.toolName === right.toolName &&
|
|
2613
|
-
left.isError === right.isError &&
|
|
2614
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2615
|
-
);
|
|
2616
|
-
case "user":
|
|
2617
|
-
if (right.role !== "user") return false;
|
|
2618
|
-
return (
|
|
2619
|
-
left.timestamp === right.timestamp &&
|
|
2620
|
-
left.attribution === right.attribution &&
|
|
2621
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2622
|
-
);
|
|
2623
|
-
case "developer":
|
|
2624
|
-
if (right.role !== "developer") return false;
|
|
2625
|
-
return (
|
|
2626
|
-
left.timestamp === right.timestamp &&
|
|
2627
|
-
left.attribution === right.attribution &&
|
|
2628
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2629
|
-
);
|
|
2630
|
-
case "fileMention":
|
|
2631
|
-
if (right.role !== "fileMention") return false;
|
|
2632
|
-
return (
|
|
2633
|
-
left.timestamp === right.timestamp &&
|
|
2634
|
-
this.#messageValueSignature(left.files) === this.#messageValueSignature(right.files)
|
|
2635
|
-
);
|
|
2636
|
-
default:
|
|
2637
|
-
return false;
|
|
2638
|
-
}
|
|
2639
|
-
}
|
|
2640
|
-
|
|
2641
|
-
#sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
|
|
2642
|
-
switch (message.role) {
|
|
2643
|
-
case "assistant":
|
|
2644
|
-
return [
|
|
2645
|
-
"assistant",
|
|
2646
|
-
message.timestamp,
|
|
2647
|
-
message.provider,
|
|
2648
|
-
message.model,
|
|
2649
|
-
message.responseId ?? "",
|
|
2650
|
-
message.stopReason,
|
|
2651
|
-
].join(":");
|
|
2652
|
-
case "toolResult":
|
|
2653
|
-
return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
|
|
2654
|
-
case "user":
|
|
2655
|
-
case "developer":
|
|
2656
|
-
case "fileMention":
|
|
2657
|
-
return `${message.role}:${message.timestamp}`;
|
|
2658
|
-
default:
|
|
2659
|
-
return undefined;
|
|
2660
|
-
}
|
|
2661
|
-
}
|
|
2662
|
-
|
|
2663
2659
|
#createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined {
|
|
2664
|
-
const key =
|
|
2660
|
+
const key = sessionMessagePersistenceKey(message);
|
|
2665
2661
|
if (!key) return undefined;
|
|
2666
2662
|
const previous = this.#messageEndPersistenceTail;
|
|
2667
2663
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
@@ -2691,31 +2687,61 @@ export class AgentSession {
|
|
|
2691
2687
|
}
|
|
2692
2688
|
|
|
2693
2689
|
async #waitForSessionMessagePersistence(message: AgentMessage): Promise<void> {
|
|
2694
|
-
const key =
|
|
2690
|
+
const key = sessionMessagePersistenceKey(message);
|
|
2695
2691
|
if (!key) return;
|
|
2696
2692
|
await this.#pendingMessageEndPersistence.get(key);
|
|
2697
2693
|
}
|
|
2698
2694
|
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2695
|
+
/**
|
|
2696
|
+
* Index every message entry on the current branch by persistence key, so
|
|
2697
|
+
* the mid-run-compaction planner can ask "is this turn message already on
|
|
2698
|
+
* the branch?" in O(1) instead of re-walking the branch per check.
|
|
2699
|
+
*
|
|
2700
|
+
* The Map's value is the list of branch messages that share a key — almost
|
|
2701
|
+
* always one. We only need the LIST when content equality matters (rare
|
|
2702
|
+
* collision tiebreaker via {@link sameMessageContent}); the empty/single-
|
|
2703
|
+
* entry common case lets the caller's lookup short-circuit at presence.
|
|
2704
|
+
*
|
|
2705
|
+
* Pre-#3629 the equivalent was `sessionManager.getBranch()` called twice
|
|
2706
|
+
* per turn message, each call rebuilding the path via O(n²) `unshift` and
|
|
2707
|
+
* structurally JSON-comparing every entry — seconds of synchronous work
|
|
2708
|
+
* per `onTurnEnd` on a long session and the load-bearing source of the
|
|
2709
|
+
* `ui.loop-blocked` warnings in the bug report.
|
|
2710
|
+
*/
|
|
2711
|
+
#indexPersistedMessagesByKey(): Map<string, AgentMessage[]> {
|
|
2712
|
+
const index = new Map<string, AgentMessage[]>();
|
|
2713
|
+
for (const entry of this.sessionManager.getBranch()) {
|
|
2714
|
+
if (entry.type !== "message") continue;
|
|
2715
|
+
const key = sessionMessagePersistenceKey(entry.message);
|
|
2716
|
+
if (key === undefined) continue;
|
|
2717
|
+
const existing = index.get(key);
|
|
2718
|
+
if (existing) existing.push(entry.message);
|
|
2719
|
+
else index.set(key, [entry.message]);
|
|
2704
2720
|
}
|
|
2705
|
-
return
|
|
2721
|
+
return index;
|
|
2706
2722
|
}
|
|
2707
2723
|
|
|
2708
|
-
|
|
2724
|
+
/**
|
|
2725
|
+
* True when {@link message} is structurally identical to a message already
|
|
2726
|
+
* appended to the current branch. Pairs a fast persistence-key lookup with
|
|
2727
|
+
* a content-equality fallback so two logically distinct messages that
|
|
2728
|
+
* happen to collide on the cheap key (e.g. two assistant turns at the same
|
|
2729
|
+
* millisecond with `undefined` responseId) still count as DISTINCT.
|
|
2730
|
+
*/
|
|
2731
|
+
#sessionMessageAlreadyPersisted(message: AgentMessage): boolean {
|
|
2732
|
+
const key = sessionMessagePersistenceKey(message);
|
|
2733
|
+
if (key === undefined) return false;
|
|
2709
2734
|
const branch = this.sessionManager.getBranch();
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2735
|
+
// Reverse walk: recently-appended entries are at the tail, so the common
|
|
2736
|
+
// "is the message I just emitted already in the branch?" lookup short-
|
|
2737
|
+
// circuits in O(1) hot, O(branch) cold. Cheap-key compare for every
|
|
2738
|
+
// entry; content compare only when the cheap check matches, so the
|
|
2739
|
+
// expensive `JSON.stringify(content)` path stays off the hot loop.
|
|
2740
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
2741
|
+
const entry = branch[index];
|
|
2742
|
+
if (entry.type !== "message") continue;
|
|
2743
|
+
if (sessionMessagePersistenceKey(entry.message) !== key) continue;
|
|
2744
|
+
if (sameMessageContent(entry.message, message)) return true;
|
|
2719
2745
|
}
|
|
2720
2746
|
return false;
|
|
2721
2747
|
}
|
|
@@ -2756,17 +2782,36 @@ export class AgentSession {
|
|
|
2756
2782
|
for (const message of turnMessages) {
|
|
2757
2783
|
await this.#waitForSessionMessagePersistence(message);
|
|
2758
2784
|
}
|
|
2785
|
+
// One branch snapshot + one persistence-key index drives the entire
|
|
2786
|
+
// planning pass. Pre-#3629 this re-walked the branch and structurally
|
|
2787
|
+
// JSON-compared every entry per turn message, which on long sessions
|
|
2788
|
+
// turned each `onTurnEnd` into a seconds-long sync block (the
|
|
2789
|
+
// `ui.loop-blocked` warnings tagged `subagent:*` in the bug report).
|
|
2790
|
+
const branchIndex = this.#indexPersistedMessagesByKey();
|
|
2791
|
+
const turnKeys = turnMessages.map(sessionMessagePersistenceKey);
|
|
2792
|
+
const persistedKeys = new Set<string>();
|
|
2759
2793
|
for (let index = 0; index < turnMessages.length; index++) {
|
|
2760
|
-
const
|
|
2761
|
-
if (
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
}
|
|
2769
|
-
|
|
2794
|
+
const key = turnKeys[index];
|
|
2795
|
+
if (key === undefined) continue;
|
|
2796
|
+
const candidates = branchIndex.get(key);
|
|
2797
|
+
if (!candidates) continue;
|
|
2798
|
+
// Key match only counts when content also matches — two distinct
|
|
2799
|
+
// messages that collided on the cheap key must STILL be persisted.
|
|
2800
|
+
if (candidates.some(persisted => sameMessageContent(persisted, turnMessages[index]))) {
|
|
2801
|
+
persistedKeys.add(key);
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
const plan = planTurnPersistence(turnKeys, persistedKeys);
|
|
2805
|
+
if (plan.kind === "out-of-order") {
|
|
2806
|
+
const message = turnMessages[plan.messageIndex];
|
|
2807
|
+
logger.debug("Skipping mid-run compaction because turn persistence is out of order", {
|
|
2808
|
+
role: message.role,
|
|
2809
|
+
timestamp: message.timestamp,
|
|
2810
|
+
});
|
|
2811
|
+
return false;
|
|
2812
|
+
}
|
|
2813
|
+
for (const index of plan.toPersist) {
|
|
2814
|
+
this.#persistSessionMessageIfMissing(turnMessages[index]);
|
|
2770
2815
|
}
|
|
2771
2816
|
return true;
|
|
2772
2817
|
}
|
|
@@ -2785,11 +2830,17 @@ export class AgentSession {
|
|
|
2785
2830
|
if (
|
|
2786
2831
|
event.type === "message_end" &&
|
|
2787
2832
|
event.message.role === "assistant" &&
|
|
2788
|
-
event.message.stopReason === "aborted"
|
|
2789
|
-
this.#planInternalAbortPending
|
|
2833
|
+
event.message.stopReason === "aborted"
|
|
2790
2834
|
) {
|
|
2791
|
-
|
|
2792
|
-
this.#planInternalAbortPending
|
|
2835
|
+
const message = event.message as AssistantMessage;
|
|
2836
|
+
if (this.#planInternalAbortPending) {
|
|
2837
|
+
message.errorMessage = SILENT_ABORT_MARKER;
|
|
2838
|
+
message.errorId = AIError.create(AIError.Flag.SilentAbort);
|
|
2839
|
+
this.#planInternalAbortPending = false;
|
|
2840
|
+
} else if (this.#pendingAbortErrorId) {
|
|
2841
|
+
message.errorId = this.#pendingAbortErrorId;
|
|
2842
|
+
this.#pendingAbortErrorId = undefined;
|
|
2843
|
+
}
|
|
2793
2844
|
}
|
|
2794
2845
|
|
|
2795
2846
|
const messageEndPersistence =
|
|
@@ -3102,7 +3153,7 @@ export class AgentSession {
|
|
|
3102
3153
|
if (
|
|
3103
3154
|
msg.stopReason === "error" &&
|
|
3104
3155
|
msg.provider === "github-copilot" &&
|
|
3105
|
-
|
|
3156
|
+
AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed)
|
|
3106
3157
|
) {
|
|
3107
3158
|
await this.#modelRegistry.authStorage.remove("github-copilot");
|
|
3108
3159
|
}
|
|
@@ -3716,10 +3767,34 @@ export class AgentSession {
|
|
|
3716
3767
|
|
|
3717
3768
|
context.toolName = toolCall.name;
|
|
3718
3769
|
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
|
|
3719
|
-
context.filePaths = this.#
|
|
3770
|
+
context.filePaths = this.#extractTtsrToolFilePaths(toolCall);
|
|
3720
3771
|
return context;
|
|
3721
3772
|
}
|
|
3722
3773
|
|
|
3774
|
+
/**
|
|
3775
|
+
* Resolve the file paths a tool call would touch for TTSR path-glob matching.
|
|
3776
|
+
*
|
|
3777
|
+
* Prefer the tool's own `matcherPaths` hook — it understands the wire format
|
|
3778
|
+
* (hashline `[path#TAG]` section headers, apply_patch envelope markers) and
|
|
3779
|
+
* surfaces paths the generic top-level argument scan never sees. Fall back
|
|
3780
|
+
* to {@link #extractTtsrFilePathsFromArgs} for tools that pass paths as
|
|
3781
|
+
* `path`/`paths` arguments and for tool calls whose payload has not yet
|
|
3782
|
+
* streamed a header.
|
|
3783
|
+
*/
|
|
3784
|
+
#extractTtsrToolFilePaths(toolCall: ToolCall): string[] | undefined {
|
|
3785
|
+
const args = toolCall.arguments ?? {};
|
|
3786
|
+
const tools = this.agent.state.tools;
|
|
3787
|
+
const tool =
|
|
3788
|
+
tools.find(t => t.name === toolCall.name) ??
|
|
3789
|
+
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
|
|
3790
|
+
const toolPaths = tool?.matcherPaths?.(args);
|
|
3791
|
+
if (toolPaths && toolPaths.length > 0) {
|
|
3792
|
+
const normalized = toolPaths.flatMap(p => this.#normalizeTtsrPathCandidates(p));
|
|
3793
|
+
if (normalized.length > 0) return Array.from(new Set(normalized));
|
|
3794
|
+
}
|
|
3795
|
+
return this.#extractTtsrFilePathsFromArgs(args);
|
|
3796
|
+
}
|
|
3797
|
+
|
|
3723
3798
|
/**
|
|
3724
3799
|
* Match a stream delta against TTSR rules.
|
|
3725
3800
|
*
|
|
@@ -3733,6 +3808,14 @@ export class AgentSession {
|
|
|
3733
3808
|
if (!manager) {
|
|
3734
3809
|
return [];
|
|
3735
3810
|
}
|
|
3811
|
+
const entries = this.#resolveTtsrMatcherEntries(toolCall);
|
|
3812
|
+
if (entries) {
|
|
3813
|
+
const matches: Rule[] = [];
|
|
3814
|
+
for (const entry of entries) {
|
|
3815
|
+
matches.push(...manager.checkSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path)));
|
|
3816
|
+
}
|
|
3817
|
+
return matches;
|
|
3818
|
+
}
|
|
3736
3819
|
const digest = this.#resolveTtsrMatcherDigest(toolCall);
|
|
3737
3820
|
if (digest !== undefined) {
|
|
3738
3821
|
return manager.checkSnapshot(digest, matchContext);
|
|
@@ -3742,14 +3825,44 @@ export class AgentSession {
|
|
|
3742
3825
|
|
|
3743
3826
|
/** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
|
|
3744
3827
|
#resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3828
|
+
const tool = this.#resolveTtsrTool(toolCall);
|
|
3829
|
+
return tool?.matcherDigest?.(toolCall?.arguments ?? {});
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
/**
|
|
3833
|
+
* Per-file split of a streamed call (one entry per touched file paired with
|
|
3834
|
+
* the digest of only that file's added lines). Lets {@link #checkTtsrStream}
|
|
3835
|
+
* and {@link #checkTtsrAstStream} evaluate each file in isolation so a
|
|
3836
|
+
* path-scoped rule like `tool:edit(*.ts)` never fires on text that belongs
|
|
3837
|
+
* to a sibling Markdown hunk in a multi-file payload.
|
|
3838
|
+
*/
|
|
3839
|
+
#resolveTtsrMatcherEntries(toolCall: ToolCall | undefined): readonly { path: string; digest: string }[] | undefined {
|
|
3840
|
+
const tool = this.#resolveTtsrTool(toolCall);
|
|
3841
|
+
const entries = tool?.matcherEntries?.(toolCall?.arguments ?? {});
|
|
3842
|
+
return entries && entries.length > 0 ? entries : undefined;
|
|
3843
|
+
}
|
|
3844
|
+
|
|
3845
|
+
#resolveTtsrTool(toolCall: ToolCall | undefined) {
|
|
3846
|
+
if (!toolCall) return undefined;
|
|
3748
3847
|
const tools = this.agent.state.tools;
|
|
3749
|
-
|
|
3848
|
+
return (
|
|
3750
3849
|
tools.find(t => t.name === toolCall.name) ??
|
|
3751
|
-
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name)
|
|
3752
|
-
|
|
3850
|
+
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name)
|
|
3851
|
+
);
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
/**
|
|
3855
|
+
* Replace `matchContext`'s `filePaths` + `streamKey` so a per-file entry
|
|
3856
|
+
* gets its own glob-eligible path and its own TTSR buffer/repeat tracking
|
|
3857
|
+
* (each file's stream is independent inside the same tool call).
|
|
3858
|
+
*/
|
|
3859
|
+
#perFileTtsrContext(base: TtsrMatchContext, filePath: string): TtsrMatchContext {
|
|
3860
|
+
const filePaths = this.#normalizeTtsrPathCandidates(filePath);
|
|
3861
|
+
return {
|
|
3862
|
+
...base,
|
|
3863
|
+
filePaths: filePaths.length > 0 ? filePaths : [filePath],
|
|
3864
|
+
streamKey: base.streamKey ? `${base.streamKey}#${filePath}` : undefined,
|
|
3865
|
+
};
|
|
3753
3866
|
}
|
|
3754
3867
|
|
|
3755
3868
|
/**
|
|
@@ -3764,6 +3877,16 @@ export class AgentSession {
|
|
|
3764
3877
|
if (!manager) {
|
|
3765
3878
|
return [];
|
|
3766
3879
|
}
|
|
3880
|
+
const entries = this.#resolveTtsrMatcherEntries(toolCall);
|
|
3881
|
+
if (entries) {
|
|
3882
|
+
const matches: Rule[] = [];
|
|
3883
|
+
for (const entry of entries) {
|
|
3884
|
+
matches.push(
|
|
3885
|
+
...(await manager.checkAstSnapshot(entry.digest, this.#perFileTtsrContext(matchContext, entry.path))),
|
|
3886
|
+
);
|
|
3887
|
+
}
|
|
3888
|
+
return matches;
|
|
3889
|
+
}
|
|
3767
3890
|
const digest = this.#resolveTtsrMatcherDigest(toolCall);
|
|
3768
3891
|
if (digest === undefined) {
|
|
3769
3892
|
return [];
|
|
@@ -4493,6 +4616,7 @@ export class AgentSession {
|
|
|
4493
4616
|
maxAttempts: event.maxAttempts,
|
|
4494
4617
|
delayMs: event.delayMs,
|
|
4495
4618
|
errorMessage: event.errorMessage,
|
|
4619
|
+
errorId: event.errorId,
|
|
4496
4620
|
});
|
|
4497
4621
|
} else if (event.type === "auto_retry_end") {
|
|
4498
4622
|
await this.#extensionRunner.emit({
|
|
@@ -7267,6 +7391,7 @@ export class AgentSession {
|
|
|
7267
7391
|
preserveCompaction?: boolean;
|
|
7268
7392
|
}): Promise<void> {
|
|
7269
7393
|
const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
|
|
7394
|
+
this.#pendingAbortErrorId = userInterrupt ? AIError.create(AIError.Flag.UserInterrupt) : undefined;
|
|
7270
7395
|
if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
|
|
7271
7396
|
// Pull advisor concerns out of the steer/follow-up queues before any await so
|
|
7272
7397
|
// the post-abort stranded-message drain can't auto-resume the run on them.
|
|
@@ -8973,7 +9098,7 @@ export class AgentSession {
|
|
|
8973
9098
|
const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
|
|
8974
9099
|
const errorIsFromBeforeCompaction =
|
|
8975
9100
|
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
|
|
8976
|
-
if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
|
|
9101
|
+
if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
|
|
8977
9102
|
// Remove the error message from agent state (it IS saved to session for history,
|
|
8978
9103
|
// but we don't want it in context for the retry)
|
|
8979
9104
|
const messages = this.agent.state.messages;
|
|
@@ -10775,11 +10900,12 @@ export class AgentSession {
|
|
|
10775
10900
|
}
|
|
10776
10901
|
|
|
10777
10902
|
const message = error instanceof Error ? error.message : String(error);
|
|
10903
|
+
const id = AIError.classify(error, candidate.api);
|
|
10778
10904
|
if (this.#isCompactionAuthFailure(error)) {
|
|
10779
10905
|
lastError = this.#buildCompactionAuthError();
|
|
10780
10906
|
break;
|
|
10781
10907
|
}
|
|
10782
|
-
if (
|
|
10908
|
+
if (AIError.is(id, AIError.Flag.Timeout)) {
|
|
10783
10909
|
logger.warn(
|
|
10784
10910
|
hasMoreCandidates
|
|
10785
10911
|
? "Auto-compaction summarization timed out, trying next model"
|
|
@@ -10798,8 +10924,8 @@ export class AgentSession {
|
|
|
10798
10924
|
retrySettings.enabled &&
|
|
10799
10925
|
attempt < retrySettings.maxRetries &&
|
|
10800
10926
|
(retryAfterMs !== undefined ||
|
|
10801
|
-
|
|
10802
|
-
|
|
10927
|
+
AIError.is(id, AIError.Flag.Transient) ||
|
|
10928
|
+
AIError.is(id, AIError.Flag.UsageLimit));
|
|
10803
10929
|
if (!shouldRetry) {
|
|
10804
10930
|
lastError = error;
|
|
10805
10931
|
break;
|
|
@@ -11198,6 +11324,31 @@ export class AgentSession {
|
|
|
11198
11324
|
// Auto-Retry
|
|
11199
11325
|
// =========================================================================
|
|
11200
11326
|
|
|
11327
|
+
/**
|
|
11328
|
+
* Classify retry decisions against the active session model. Test stream
|
|
11329
|
+
* shims and provider adapters can emit generic assistant metadata, but retry
|
|
11330
|
+
* policy belongs to the model that was actually requested for this turn.
|
|
11331
|
+
*/
|
|
11332
|
+
#classifyRetryMessage(message: AssistantMessage): number {
|
|
11333
|
+
const activeModel = this.model;
|
|
11334
|
+
if (!activeModel || message.api === activeModel.api) {
|
|
11335
|
+
return AIError.classifyMessage(message);
|
|
11336
|
+
}
|
|
11337
|
+
|
|
11338
|
+
const id = AIError.classifyMessage({
|
|
11339
|
+
api: activeModel.api,
|
|
11340
|
+
errorId: message.errorId,
|
|
11341
|
+
errorMessage: message.errorMessage,
|
|
11342
|
+
errorStatus: message.errorStatus,
|
|
11343
|
+
});
|
|
11344
|
+
message.errorId = id;
|
|
11345
|
+
return id;
|
|
11346
|
+
}
|
|
11347
|
+
|
|
11348
|
+
#isGenericAbortSentinel(message: AssistantMessage): boolean {
|
|
11349
|
+
return message.errorMessage === "Request was aborted" || message.errorMessage === "Request was aborted.";
|
|
11350
|
+
}
|
|
11351
|
+
|
|
11201
11352
|
/**
|
|
11202
11353
|
* Retry an empty, reason-less provider abort: a turn that ended `aborted`
|
|
11203
11354
|
* with no content and the generic sentinel (bare `abort()`), but only while
|
|
@@ -11210,14 +11361,22 @@ export class AgentSession {
|
|
|
11210
11361
|
* `prompt()`) or silently undo the guard's intended abort.
|
|
11211
11362
|
*/
|
|
11212
11363
|
#isRetryableReasonlessAbort(message: AssistantMessage): boolean {
|
|
11213
|
-
|
|
11214
|
-
message.stopReason
|
|
11215
|
-
message.content.length
|
|
11216
|
-
|
|
11217
|
-
|
|
11218
|
-
|
|
11219
|
-
|
|
11220
|
-
|
|
11364
|
+
if (
|
|
11365
|
+
message.stopReason !== "aborted" ||
|
|
11366
|
+
message.content.length !== 0 ||
|
|
11367
|
+
this.#abortInProgress ||
|
|
11368
|
+
this.#isDisposed ||
|
|
11369
|
+
this.#streamingEditAbortTriggered
|
|
11370
|
+
) {
|
|
11371
|
+
return false;
|
|
11372
|
+
}
|
|
11373
|
+
|
|
11374
|
+
const id = this.#classifyRetryMessage(message);
|
|
11375
|
+
if (AIError.is(id, AIError.Flag.Abort)) return true;
|
|
11376
|
+
if (!this.#isGenericAbortSentinel(message)) return false;
|
|
11377
|
+
|
|
11378
|
+
message.errorId = AIError.create(AIError.Flag.Abort);
|
|
11379
|
+
return true;
|
|
11221
11380
|
}
|
|
11222
11381
|
|
|
11223
11382
|
/**
|
|
@@ -11226,21 +11385,15 @@ export class AgentSession {
|
|
|
11226
11385
|
* Usage-limit errors are retryable because the retry handler performs credential switching.
|
|
11227
11386
|
*/
|
|
11228
11387
|
#isRetryableError(message: AssistantMessage): boolean {
|
|
11229
|
-
if (message.stopReason !== "error"
|
|
11388
|
+
if (message.stopReason !== "error") return false;
|
|
11230
11389
|
|
|
11390
|
+
const id = this.#classifyRetryMessage(message);
|
|
11231
11391
|
// Context overflow is handled by compaction, not retry
|
|
11232
11392
|
const contextWindow = this.model?.contextWindow ?? 0;
|
|
11233
|
-
if (isContextOverflow(message, contextWindow)) return false;
|
|
11393
|
+
if (AIError.isContextOverflow(message, contextWindow)) return false;
|
|
11234
11394
|
|
|
11235
11395
|
if (this.#isClassifierRefusal(message)) return true;
|
|
11236
|
-
|
|
11237
|
-
if (this.#isMalformedFunctionCallError(message)) return true;
|
|
11238
|
-
if (this.#hasReplayUnsafeToolOutput(message)) return false;
|
|
11239
|
-
if (message.errorMessage.includes(THINKING_LOOP_ERROR_MARKER)) return true;
|
|
11240
|
-
if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
|
|
11241
|
-
|
|
11242
|
-
const err = message.errorMessage;
|
|
11243
|
-
return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
|
|
11396
|
+
return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
|
|
11244
11397
|
}
|
|
11245
11398
|
/**
|
|
11246
11399
|
* Retried turns remove the failed assistant message from active context.
|
|
@@ -11252,73 +11405,12 @@ export class AgentSession {
|
|
|
11252
11405
|
return message.content.some(block => block.type === "toolCall");
|
|
11253
11406
|
}
|
|
11254
11407
|
|
|
11255
|
-
#isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
|
|
11256
|
-
const currentApi = this.model?.api;
|
|
11257
|
-
if (
|
|
11258
|
-
message.api !== "openai-responses" &&
|
|
11259
|
-
message.api !== "openai-codex-responses" &&
|
|
11260
|
-
currentApi !== "openai-responses" &&
|
|
11261
|
-
currentApi !== "openai-codex-responses"
|
|
11262
|
-
) {
|
|
11263
|
-
return false;
|
|
11264
|
-
}
|
|
11265
|
-
|
|
11266
|
-
const errorMessage = message.errorMessage;
|
|
11267
|
-
if (!errorMessage) return false;
|
|
11268
|
-
|
|
11269
|
-
return (
|
|
11270
|
-
/\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
|
|
11271
|
-
(/previous[ _]?response/i.test(errorMessage) &&
|
|
11272
|
-
/not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage))
|
|
11273
|
-
);
|
|
11274
|
-
}
|
|
11275
|
-
|
|
11276
11408
|
#isClassifierRefusal(message: AssistantMessage): boolean {
|
|
11277
11409
|
if (message.stopReason !== "error") return false;
|
|
11278
11410
|
const stopType = message.stopDetails?.type;
|
|
11279
11411
|
return stopType === "refusal" || stopType === "sensitive";
|
|
11280
11412
|
}
|
|
11281
11413
|
|
|
11282
|
-
#isProviderErrorFinishReasonBeforeToolUse(message: AssistantMessage): boolean {
|
|
11283
|
-
if (!message.errorMessage) return false;
|
|
11284
|
-
if (message.content.some(block => block.type === "toolCall")) return false;
|
|
11285
|
-
return /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i.test(message.errorMessage);
|
|
11286
|
-
}
|
|
11287
|
-
|
|
11288
|
-
#isMalformedFunctionCallError(message: AssistantMessage): boolean {
|
|
11289
|
-
if (!message.errorMessage) return false;
|
|
11290
|
-
return /\bmalformed.?function.?call\b/i.test(message.errorMessage);
|
|
11291
|
-
}
|
|
11292
|
-
|
|
11293
|
-
#isTransientErrorMessage(errorMessage: string): boolean {
|
|
11294
|
-
return (
|
|
11295
|
-
this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
|
|
11296
|
-
);
|
|
11297
|
-
}
|
|
11298
|
-
|
|
11299
|
-
#isTransientEnvelopeErrorMessage(errorMessage: string): boolean {
|
|
11300
|
-
// Match Anthropic stream-envelope failures that indicate a broken stream before any content starts.
|
|
11301
|
-
return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage);
|
|
11302
|
-
}
|
|
11303
|
-
|
|
11304
|
-
#isCompactionSummarizationTimeoutMessage(errorMessage: string): boolean {
|
|
11305
|
-
return /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i.test(errorMessage);
|
|
11306
|
-
}
|
|
11307
|
-
|
|
11308
|
-
#isTransientTransportErrorMessage(errorMessage: string): boolean {
|
|
11309
|
-
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
|
|
11310
|
-
// service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
|
|
11311
|
-
// gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets
|
|
11312
|
-
// (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from
|
|
11313
|
-
// src/http/h2_client/dispatch.zig)
|
|
11314
|
-
return (
|
|
11315
|
-
isUnexpectedSocketCloseMessage(errorMessage) ||
|
|
11316
|
-
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i.test(
|
|
11317
|
-
errorMessage,
|
|
11318
|
-
)
|
|
11319
|
-
);
|
|
11320
|
-
}
|
|
11321
|
-
|
|
11322
11414
|
#getRetryFallbackChains(): RetryFallbackChains {
|
|
11323
11415
|
const configuredChains = this.settings.get("retry.fallbackChains");
|
|
11324
11416
|
if (!configuredChains || typeof configuredChains !== "object") return {};
|
|
@@ -11551,20 +11643,15 @@ export class AgentSession {
|
|
|
11551
11643
|
#isFireworksFastFallbackEligible(message: AssistantMessage): boolean {
|
|
11552
11644
|
const model = this.#activeFireworksFastModel();
|
|
11553
11645
|
if (!model) return false;
|
|
11554
|
-
if (message.stopReason !== "error"
|
|
11646
|
+
if (message.stopReason !== "error") return false;
|
|
11555
11647
|
if (message.content.some(block => block.type === "toolCall")) return false;
|
|
11556
11648
|
// A content refusal/sensitivity stop is the model's decision, not a route
|
|
11557
11649
|
// failure — switching to the base model would just re-trigger it.
|
|
11558
11650
|
if (this.#isClassifierRefusal(message)) return false;
|
|
11559
|
-
|
|
11560
|
-
|
|
11561
|
-
if (
|
|
11562
|
-
if (
|
|
11563
|
-
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i.test(
|
|
11564
|
-
err,
|
|
11565
|
-
)
|
|
11566
|
-
)
|
|
11567
|
-
return false;
|
|
11651
|
+
const id = this.#classifyRetryMessage(message);
|
|
11652
|
+
if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
|
|
11653
|
+
if (AIError.is(id, AIError.Flag.UsageLimit)) return false;
|
|
11654
|
+
if (AIError.is(id, AIError.Flag.AuthFailed)) return false;
|
|
11568
11655
|
return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
|
|
11569
11656
|
}
|
|
11570
11657
|
|
|
@@ -11730,7 +11817,8 @@ export class AgentSession {
|
|
|
11730
11817
|
}
|
|
11731
11818
|
|
|
11732
11819
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
11733
|
-
const
|
|
11820
|
+
const id = this.#classifyRetryMessage(message);
|
|
11821
|
+
const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
|
|
11734
11822
|
const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
|
|
11735
11823
|
let delayMs = staleOpenAIResponsesReplayError
|
|
11736
11824
|
? 0
|
|
@@ -11745,7 +11833,7 @@ export class AgentSession {
|
|
|
11745
11833
|
this.#resetCurrentResponsesProviderSession("stale replay error");
|
|
11746
11834
|
}
|
|
11747
11835
|
|
|
11748
|
-
if (this.model && !staleOpenAIResponsesReplayError &&
|
|
11836
|
+
if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) {
|
|
11749
11837
|
const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
|
11750
11838
|
const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
|
|
11751
11839
|
this.model.provider,
|
|
@@ -11852,6 +11940,7 @@ export class AgentSession {
|
|
|
11852
11940
|
maxAttempts: retrySettings.maxRetries,
|
|
11853
11941
|
delayMs,
|
|
11854
11942
|
errorMessage,
|
|
11943
|
+
errorId: message.errorId,
|
|
11855
11944
|
});
|
|
11856
11945
|
|
|
11857
11946
|
// Remove the failed assistant message from active context before retrying.
|
|
@@ -13763,6 +13852,17 @@ export class AgentSession {
|
|
|
13763
13852
|
return this.#advisorAgent !== undefined;
|
|
13764
13853
|
}
|
|
13765
13854
|
|
|
13855
|
+
/**
|
|
13856
|
+
* The live advisor `Agent`, or `undefined` when no advisor runtime is
|
|
13857
|
+
* attached. Surfaced for diagnostics (`/dump advisor` already serializes
|
|
13858
|
+
* its transcript via {@link formatAdvisorHistoryAsText}) and so callers can
|
|
13859
|
+
* verify the advisor inherits the session's provider-shaping options
|
|
13860
|
+
* (`streamFn`, `promptCacheKey`, `providerSessionState`, ...).
|
|
13861
|
+
*/
|
|
13862
|
+
getAdvisorAgent(): Agent | undefined {
|
|
13863
|
+
return this.#advisorAgent;
|
|
13864
|
+
}
|
|
13865
|
+
|
|
13766
13866
|
/**
|
|
13767
13867
|
* Return structured advisor stats for the status command and TUI panel.
|
|
13768
13868
|
*/
|