@oh-my-pi/pi-coding-agent 16.2.0 → 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 +35 -0
- package/dist/cli.js +2709 -2715
- package/dist/types/advisor/runtime.d.ts +15 -1
- package/dist/types/advisor/watchdog.d.ts +11 -0
- 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/acp/acp-event-mapper.d.ts +1 -0
- 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 +32 -2
- package/dist/types/session/blob-store.d.ts +4 -0
- 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 +217 -0
- package/src/advisor/runtime.ts +65 -2
- package/src/advisor/watchdog.ts +15 -0
- 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/claude-plugins.ts +3 -2
- 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 +15 -2
- package/src/modes/acp/acp-event-mapper.ts +89 -25
- 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/prompts/advisor/context-files.md +8 -0
- package/src/sdk.ts +23 -27
- package/src/session/agent-session.ts +330 -219
- package/src/session/blob-store.ts +24 -0
- 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 */
|
|
@@ -589,6 +607,12 @@ export interface AgentSessionConfig {
|
|
|
589
607
|
advisorReadOnlyTools?: AgentTool[];
|
|
590
608
|
/** Preloaded watchdog prompt content for the advisor. */
|
|
591
609
|
advisorWatchdogPrompt?: string;
|
|
610
|
+
/**
|
|
611
|
+
* Preloaded project context files (AGENTS.md, etc.) rendered as a system-prompt
|
|
612
|
+
* block for the advisor — the same standing instructions the primary agent
|
|
613
|
+
* receives, so the reviewer holds the agent to them.
|
|
614
|
+
*/
|
|
615
|
+
advisorContextPrompt?: string;
|
|
592
616
|
/**
|
|
593
617
|
* Strip tool descriptions from provider-bound tool specs on side requests
|
|
594
618
|
* (handoff). Must match the session-start value used to build the system
|
|
@@ -1220,6 +1244,7 @@ export class AgentSession {
|
|
|
1220
1244
|
#advisorAdviseTool?: AdviseTool;
|
|
1221
1245
|
#advisorReadOnlyTools?: AgentTool[];
|
|
1222
1246
|
#advisorWatchdogPrompt?: string;
|
|
1247
|
+
#advisorContextPrompt?: string;
|
|
1223
1248
|
#advisorYieldQueueUnsubscribe?: () => void;
|
|
1224
1249
|
/** Persists the advisor agent's turns to `<session>/__advisor.jsonl` for stats
|
|
1225
1250
|
* attribution and Agent Hub observability. Undefined when no advisor is active. */
|
|
@@ -1326,6 +1351,8 @@ export class AgentSession {
|
|
|
1326
1351
|
#onPayload: SimpleStreamOptions["onPayload"] | undefined;
|
|
1327
1352
|
#onResponse: SimpleStreamOptions["onResponse"] | undefined;
|
|
1328
1353
|
#onSseEvent: SimpleStreamOptions["onSseEvent"] | undefined;
|
|
1354
|
+
#transformProviderContext: ((context: Context, model: Model) => Context | Promise<Context>) | undefined;
|
|
1355
|
+
#advisorStreamFn: StreamFn | undefined;
|
|
1329
1356
|
#convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
|
|
1330
1357
|
#rebuildSystemPrompt:
|
|
1331
1358
|
| ((toolNames: string[], tools: Map<string, AgentTool>) => Promise<{ systemPrompt: string[] }>)
|
|
@@ -1380,6 +1407,7 @@ export class AgentSession {
|
|
|
1380
1407
|
* `message_end` + `stopReason: "aborted"`; callers clear it in `finally` so
|
|
1381
1408
|
* it cannot leak into later unrelated aborts. */
|
|
1382
1409
|
#planInternalAbortPending = false;
|
|
1410
|
+
#pendingAbortErrorId?: number;
|
|
1383
1411
|
|
|
1384
1412
|
#postPromptTasks = new Set<Promise<unknown>>();
|
|
1385
1413
|
#postPromptTasksPromise: Promise<void> | undefined = undefined;
|
|
@@ -1643,12 +1671,15 @@ export class AgentSession {
|
|
|
1643
1671
|
this.agent.serviceTierResolver = model => this.#effectiveServiceTier(model);
|
|
1644
1672
|
this.#advisorReadOnlyTools = config.advisorReadOnlyTools;
|
|
1645
1673
|
this.#advisorWatchdogPrompt = config.advisorWatchdogPrompt;
|
|
1674
|
+
this.#advisorContextPrompt = config.advisorContextPrompt;
|
|
1646
1675
|
this.#pruneToolDescriptions = config.pruneToolDescriptions === true;
|
|
1647
1676
|
this.#validateRetryFallbackChains();
|
|
1648
1677
|
this.#toolRegistry = config.toolRegistry ?? new Map();
|
|
1649
1678
|
this.#builtInToolNames = new Set(config.builtInToolNames ?? []);
|
|
1650
1679
|
this.#requestedToolNames = config.requestedToolNames;
|
|
1651
1680
|
this.#transformContext = config.transformContext ?? (messages => messages);
|
|
1681
|
+
this.#transformProviderContext = config.transformProviderContext;
|
|
1682
|
+
this.#advisorStreamFn = config.advisorStreamFn;
|
|
1652
1683
|
this.#onPayload = config.onPayload;
|
|
1653
1684
|
this.rawSseDebugBuffer = config.rawSseDebugBuffer ?? new RawSseDebugBuffer();
|
|
1654
1685
|
// Avoid wrapping in an `async` closure when no user callback is configured: the
|
|
@@ -1960,6 +1991,9 @@ export class AgentSession {
|
|
|
1960
1991
|
const appendOnlyContext = new AppendOnlyContextManager();
|
|
1961
1992
|
const advisorThinkingLevel = advisorSel.thinkingLevel ?? ThinkingLevel.Medium;
|
|
1962
1993
|
const systemPrompt = [advisorSystemPrompt];
|
|
1994
|
+
if (this.#advisorContextPrompt) {
|
|
1995
|
+
systemPrompt.push(this.#advisorContextPrompt);
|
|
1996
|
+
}
|
|
1963
1997
|
if (this.#advisorWatchdogPrompt) {
|
|
1964
1998
|
systemPrompt.push(this.#advisorWatchdogPrompt);
|
|
1965
1999
|
}
|
|
@@ -1992,6 +2026,25 @@ export class AgentSession {
|
|
|
1992
2026
|
conversationId: undefined,
|
|
1993
2027
|
}
|
|
1994
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).
|
|
1995
2048
|
const advisorAgent = new Agent({
|
|
1996
2049
|
initialState: {
|
|
1997
2050
|
systemPrompt,
|
|
@@ -2001,7 +2054,14 @@ export class AgentSession {
|
|
|
2001
2054
|
},
|
|
2002
2055
|
appendOnlyContext,
|
|
2003
2056
|
sessionId: advisorSessionId,
|
|
2057
|
+
promptCacheKey: advisorSessionId,
|
|
2058
|
+
providerSessionState: this.#providerSessionState,
|
|
2004
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,
|
|
2005
2065
|
intentTracing: false,
|
|
2006
2066
|
telemetry: advisorTelemetry,
|
|
2007
2067
|
serviceTier: advisorServiceTier,
|
|
@@ -2016,6 +2076,19 @@ export class AgentSession {
|
|
|
2016
2076
|
advisorAgent.reset();
|
|
2017
2077
|
appendOnlyContext.log.clear();
|
|
2018
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
|
+
},
|
|
2019
2092
|
state: advisorAgent.state,
|
|
2020
2093
|
};
|
|
2021
2094
|
|
|
@@ -2038,6 +2111,14 @@ export class AgentSession {
|
|
|
2038
2111
|
maintainContext: incomingTokens => this.#maintainAdvisorContext(incomingTokens),
|
|
2039
2112
|
obfuscator: this.#obfuscator,
|
|
2040
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
|
+
},
|
|
2041
2122
|
});
|
|
2042
2123
|
if (seedToCurrent) {
|
|
2043
2124
|
this.#advisorRuntime.seedTo(this.agent.state.messages.length);
|
|
@@ -2575,82 +2656,8 @@ export class AgentSession {
|
|
|
2575
2656
|
}
|
|
2576
2657
|
};
|
|
2577
2658
|
|
|
2578
|
-
#messageValueSignature(value: unknown): string {
|
|
2579
|
-
return JSON.stringify(value) ?? "undefined";
|
|
2580
|
-
}
|
|
2581
|
-
|
|
2582
|
-
#sessionMessagesReferToSameTurn(left: AgentMessage, right: AgentMessage): boolean {
|
|
2583
|
-
if (left === right) return true;
|
|
2584
|
-
if (left.role !== right.role) return false;
|
|
2585
|
-
switch (left.role) {
|
|
2586
|
-
case "assistant":
|
|
2587
|
-
if (right.role !== "assistant") return false;
|
|
2588
|
-
return (
|
|
2589
|
-
left.timestamp === right.timestamp &&
|
|
2590
|
-
left.provider === right.provider &&
|
|
2591
|
-
left.model === right.model &&
|
|
2592
|
-
left.responseId === right.responseId &&
|
|
2593
|
-
left.stopReason === right.stopReason &&
|
|
2594
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2595
|
-
);
|
|
2596
|
-
case "toolResult":
|
|
2597
|
-
if (right.role !== "toolResult") return false;
|
|
2598
|
-
return (
|
|
2599
|
-
left.timestamp === right.timestamp &&
|
|
2600
|
-
left.toolCallId === right.toolCallId &&
|
|
2601
|
-
left.toolName === right.toolName &&
|
|
2602
|
-
left.isError === right.isError &&
|
|
2603
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2604
|
-
);
|
|
2605
|
-
case "user":
|
|
2606
|
-
if (right.role !== "user") return false;
|
|
2607
|
-
return (
|
|
2608
|
-
left.timestamp === right.timestamp &&
|
|
2609
|
-
left.attribution === right.attribution &&
|
|
2610
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2611
|
-
);
|
|
2612
|
-
case "developer":
|
|
2613
|
-
if (right.role !== "developer") return false;
|
|
2614
|
-
return (
|
|
2615
|
-
left.timestamp === right.timestamp &&
|
|
2616
|
-
left.attribution === right.attribution &&
|
|
2617
|
-
this.#messageValueSignature(left.content) === this.#messageValueSignature(right.content)
|
|
2618
|
-
);
|
|
2619
|
-
case "fileMention":
|
|
2620
|
-
if (right.role !== "fileMention") return false;
|
|
2621
|
-
return (
|
|
2622
|
-
left.timestamp === right.timestamp &&
|
|
2623
|
-
this.#messageValueSignature(left.files) === this.#messageValueSignature(right.files)
|
|
2624
|
-
);
|
|
2625
|
-
default:
|
|
2626
|
-
return false;
|
|
2627
|
-
}
|
|
2628
|
-
}
|
|
2629
|
-
|
|
2630
|
-
#sessionMessagePersistenceKey(message: AgentMessage): string | undefined {
|
|
2631
|
-
switch (message.role) {
|
|
2632
|
-
case "assistant":
|
|
2633
|
-
return [
|
|
2634
|
-
"assistant",
|
|
2635
|
-
message.timestamp,
|
|
2636
|
-
message.provider,
|
|
2637
|
-
message.model,
|
|
2638
|
-
message.responseId ?? "",
|
|
2639
|
-
message.stopReason,
|
|
2640
|
-
].join(":");
|
|
2641
|
-
case "toolResult":
|
|
2642
|
-
return `toolResult:${message.timestamp}:${message.toolCallId}:${message.toolName}`;
|
|
2643
|
-
case "user":
|
|
2644
|
-
case "developer":
|
|
2645
|
-
case "fileMention":
|
|
2646
|
-
return `${message.role}:${message.timestamp}`;
|
|
2647
|
-
default:
|
|
2648
|
-
return undefined;
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
|
|
2652
2659
|
#createMessageEndPersistenceSlot(message: AgentMessage): MessageEndPersistenceSlot | undefined {
|
|
2653
|
-
const key =
|
|
2660
|
+
const key = sessionMessagePersistenceKey(message);
|
|
2654
2661
|
if (!key) return undefined;
|
|
2655
2662
|
const previous = this.#messageEndPersistenceTail;
|
|
2656
2663
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
@@ -2680,31 +2687,61 @@ export class AgentSession {
|
|
|
2680
2687
|
}
|
|
2681
2688
|
|
|
2682
2689
|
async #waitForSessionMessagePersistence(message: AgentMessage): Promise<void> {
|
|
2683
|
-
const key =
|
|
2690
|
+
const key = sessionMessagePersistenceKey(message);
|
|
2684
2691
|
if (!key) return;
|
|
2685
2692
|
await this.#pendingMessageEndPersistence.get(key);
|
|
2686
2693
|
}
|
|
2687
2694
|
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
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]);
|
|
2693
2720
|
}
|
|
2694
|
-
return
|
|
2721
|
+
return index;
|
|
2695
2722
|
}
|
|
2696
2723
|
|
|
2697
|
-
|
|
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;
|
|
2698
2734
|
const branch = this.sessionManager.getBranch();
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
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;
|
|
2708
2745
|
}
|
|
2709
2746
|
return false;
|
|
2710
2747
|
}
|
|
@@ -2745,17 +2782,36 @@ export class AgentSession {
|
|
|
2745
2782
|
for (const message of turnMessages) {
|
|
2746
2783
|
await this.#waitForSessionMessagePersistence(message);
|
|
2747
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>();
|
|
2748
2793
|
for (let index = 0; index < turnMessages.length; index++) {
|
|
2749
|
-
const
|
|
2750
|
-
if (
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
}
|
|
2758
|
-
|
|
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]);
|
|
2759
2815
|
}
|
|
2760
2816
|
return true;
|
|
2761
2817
|
}
|
|
@@ -2774,11 +2830,17 @@ export class AgentSession {
|
|
|
2774
2830
|
if (
|
|
2775
2831
|
event.type === "message_end" &&
|
|
2776
2832
|
event.message.role === "assistant" &&
|
|
2777
|
-
event.message.stopReason === "aborted"
|
|
2778
|
-
this.#planInternalAbortPending
|
|
2833
|
+
event.message.stopReason === "aborted"
|
|
2779
2834
|
) {
|
|
2780
|
-
|
|
2781
|
-
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
|
+
}
|
|
2782
2844
|
}
|
|
2783
2845
|
|
|
2784
2846
|
const messageEndPersistence =
|
|
@@ -3091,7 +3153,7 @@ export class AgentSession {
|
|
|
3091
3153
|
if (
|
|
3092
3154
|
msg.stopReason === "error" &&
|
|
3093
3155
|
msg.provider === "github-copilot" &&
|
|
3094
|
-
|
|
3156
|
+
AIError.is(AIError.classifyMessage(msg), AIError.Flag.AuthFailed)
|
|
3095
3157
|
) {
|
|
3096
3158
|
await this.#modelRegistry.authStorage.remove("github-copilot");
|
|
3097
3159
|
}
|
|
@@ -3705,10 +3767,34 @@ export class AgentSession {
|
|
|
3705
3767
|
|
|
3706
3768
|
context.toolName = toolCall.name;
|
|
3707
3769
|
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
|
|
3708
|
-
context.filePaths = this.#
|
|
3770
|
+
context.filePaths = this.#extractTtsrToolFilePaths(toolCall);
|
|
3709
3771
|
return context;
|
|
3710
3772
|
}
|
|
3711
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
|
+
|
|
3712
3798
|
/**
|
|
3713
3799
|
* Match a stream delta against TTSR rules.
|
|
3714
3800
|
*
|
|
@@ -3722,6 +3808,14 @@ export class AgentSession {
|
|
|
3722
3808
|
if (!manager) {
|
|
3723
3809
|
return [];
|
|
3724
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
|
+
}
|
|
3725
3819
|
const digest = this.#resolveTtsrMatcherDigest(toolCall);
|
|
3726
3820
|
if (digest !== undefined) {
|
|
3727
3821
|
return manager.checkSnapshot(digest, matchContext);
|
|
@@ -3731,14 +3825,44 @@ export class AgentSession {
|
|
|
3731
3825
|
|
|
3732
3826
|
/** Reconstruct the tool's normalized source snapshot via its `matcherDigest`, if any. */
|
|
3733
3827
|
#resolveTtsrMatcherDigest(toolCall: ToolCall | undefined): string | undefined {
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
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;
|
|
3737
3847
|
const tools = this.agent.state.tools;
|
|
3738
|
-
|
|
3848
|
+
return (
|
|
3739
3849
|
tools.find(t => t.name === toolCall.name) ??
|
|
3740
|
-
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name)
|
|
3741
|
-
|
|
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
|
+
};
|
|
3742
3866
|
}
|
|
3743
3867
|
|
|
3744
3868
|
/**
|
|
@@ -3753,6 +3877,16 @@ export class AgentSession {
|
|
|
3753
3877
|
if (!manager) {
|
|
3754
3878
|
return [];
|
|
3755
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
|
+
}
|
|
3756
3890
|
const digest = this.#resolveTtsrMatcherDigest(toolCall);
|
|
3757
3891
|
if (digest === undefined) {
|
|
3758
3892
|
return [];
|
|
@@ -4482,6 +4616,7 @@ export class AgentSession {
|
|
|
4482
4616
|
maxAttempts: event.maxAttempts,
|
|
4483
4617
|
delayMs: event.delayMs,
|
|
4484
4618
|
errorMessage: event.errorMessage,
|
|
4619
|
+
errorId: event.errorId,
|
|
4485
4620
|
});
|
|
4486
4621
|
} else if (event.type === "auto_retry_end") {
|
|
4487
4622
|
await this.#extensionRunner.emit({
|
|
@@ -7256,6 +7391,7 @@ export class AgentSession {
|
|
|
7256
7391
|
preserveCompaction?: boolean;
|
|
7257
7392
|
}): Promise<void> {
|
|
7258
7393
|
const userInterrupt = options?.reason === USER_INTERRUPT_LABEL;
|
|
7394
|
+
this.#pendingAbortErrorId = userInterrupt ? AIError.create(AIError.Flag.UserInterrupt) : undefined;
|
|
7259
7395
|
if (userInterrupt) this.#advisorAutoResumeSuppressed = true;
|
|
7260
7396
|
// Pull advisor concerns out of the steer/follow-up queues before any await so
|
|
7261
7397
|
// the post-abort stranded-message drain can't auto-resume the run on them.
|
|
@@ -8962,7 +9098,7 @@ export class AgentSession {
|
|
|
8962
9098
|
const compactionEntry = getLatestCompactionEntry(this.sessionManager.getBranch());
|
|
8963
9099
|
const errorIsFromBeforeCompaction =
|
|
8964
9100
|
compactionEntry !== null && assistantMessage.timestamp < new Date(compactionEntry.timestamp).getTime();
|
|
8965
|
-
if (sameModel && !errorIsFromBeforeCompaction && isContextOverflow(assistantMessage, contextWindow)) {
|
|
9101
|
+
if (sameModel && !errorIsFromBeforeCompaction && AIError.isContextOverflow(assistantMessage, contextWindow)) {
|
|
8966
9102
|
// Remove the error message from agent state (it IS saved to session for history,
|
|
8967
9103
|
// but we don't want it in context for the retry)
|
|
8968
9104
|
const messages = this.agent.state.messages;
|
|
@@ -10764,11 +10900,12 @@ export class AgentSession {
|
|
|
10764
10900
|
}
|
|
10765
10901
|
|
|
10766
10902
|
const message = error instanceof Error ? error.message : String(error);
|
|
10903
|
+
const id = AIError.classify(error, candidate.api);
|
|
10767
10904
|
if (this.#isCompactionAuthFailure(error)) {
|
|
10768
10905
|
lastError = this.#buildCompactionAuthError();
|
|
10769
10906
|
break;
|
|
10770
10907
|
}
|
|
10771
|
-
if (
|
|
10908
|
+
if (AIError.is(id, AIError.Flag.Timeout)) {
|
|
10772
10909
|
logger.warn(
|
|
10773
10910
|
hasMoreCandidates
|
|
10774
10911
|
? "Auto-compaction summarization timed out, trying next model"
|
|
@@ -10787,8 +10924,8 @@ export class AgentSession {
|
|
|
10787
10924
|
retrySettings.enabled &&
|
|
10788
10925
|
attempt < retrySettings.maxRetries &&
|
|
10789
10926
|
(retryAfterMs !== undefined ||
|
|
10790
|
-
|
|
10791
|
-
|
|
10927
|
+
AIError.is(id, AIError.Flag.Transient) ||
|
|
10928
|
+
AIError.is(id, AIError.Flag.UsageLimit));
|
|
10792
10929
|
if (!shouldRetry) {
|
|
10793
10930
|
lastError = error;
|
|
10794
10931
|
break;
|
|
@@ -11187,6 +11324,31 @@ export class AgentSession {
|
|
|
11187
11324
|
// Auto-Retry
|
|
11188
11325
|
// =========================================================================
|
|
11189
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
|
+
|
|
11190
11352
|
/**
|
|
11191
11353
|
* Retry an empty, reason-less provider abort: a turn that ended `aborted`
|
|
11192
11354
|
* with no content and the generic sentinel (bare `abort()`), but only while
|
|
@@ -11199,14 +11361,22 @@ export class AgentSession {
|
|
|
11199
11361
|
* `prompt()`) or silently undo the guard's intended abort.
|
|
11200
11362
|
*/
|
|
11201
11363
|
#isRetryableReasonlessAbort(message: AssistantMessage): boolean {
|
|
11202
|
-
|
|
11203
|
-
message.stopReason
|
|
11204
|
-
message.content.length
|
|
11205
|
-
|
|
11206
|
-
|
|
11207
|
-
|
|
11208
|
-
|
|
11209
|
-
|
|
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;
|
|
11210
11380
|
}
|
|
11211
11381
|
|
|
11212
11382
|
/**
|
|
@@ -11215,21 +11385,15 @@ export class AgentSession {
|
|
|
11215
11385
|
* Usage-limit errors are retryable because the retry handler performs credential switching.
|
|
11216
11386
|
*/
|
|
11217
11387
|
#isRetryableError(message: AssistantMessage): boolean {
|
|
11218
|
-
if (message.stopReason !== "error"
|
|
11388
|
+
if (message.stopReason !== "error") return false;
|
|
11219
11389
|
|
|
11390
|
+
const id = this.#classifyRetryMessage(message);
|
|
11220
11391
|
// Context overflow is handled by compaction, not retry
|
|
11221
11392
|
const contextWindow = this.model?.contextWindow ?? 0;
|
|
11222
|
-
if (isContextOverflow(message, contextWindow)) return false;
|
|
11393
|
+
if (AIError.isContextOverflow(message, contextWindow)) return false;
|
|
11223
11394
|
|
|
11224
11395
|
if (this.#isClassifierRefusal(message)) return true;
|
|
11225
|
-
|
|
11226
|
-
if (this.#isMalformedFunctionCallError(message)) return true;
|
|
11227
|
-
if (this.#hasReplayUnsafeToolOutput(message)) return false;
|
|
11228
|
-
if (message.errorMessage.includes(THINKING_LOOP_ERROR_MARKER)) return true;
|
|
11229
|
-
if (this.#isStaleOpenAIResponsesReplayError(message)) return true;
|
|
11230
|
-
|
|
11231
|
-
const err = message.errorMessage;
|
|
11232
|
-
return this.#isTransientErrorMessage(err) || isUsageLimitError(err);
|
|
11396
|
+
return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
|
|
11233
11397
|
}
|
|
11234
11398
|
/**
|
|
11235
11399
|
* Retried turns remove the failed assistant message from active context.
|
|
@@ -11241,73 +11405,12 @@ export class AgentSession {
|
|
|
11241
11405
|
return message.content.some(block => block.type === "toolCall");
|
|
11242
11406
|
}
|
|
11243
11407
|
|
|
11244
|
-
#isStaleOpenAIResponsesReplayError(message: AssistantMessage): boolean {
|
|
11245
|
-
const currentApi = this.model?.api;
|
|
11246
|
-
if (
|
|
11247
|
-
message.api !== "openai-responses" &&
|
|
11248
|
-
message.api !== "openai-codex-responses" &&
|
|
11249
|
-
currentApi !== "openai-responses" &&
|
|
11250
|
-
currentApi !== "openai-codex-responses"
|
|
11251
|
-
) {
|
|
11252
|
-
return false;
|
|
11253
|
-
}
|
|
11254
|
-
|
|
11255
|
-
const errorMessage = message.errorMessage;
|
|
11256
|
-
if (!errorMessage) return false;
|
|
11257
|
-
|
|
11258
|
-
return (
|
|
11259
|
-
/\bItem with id ['"][^'"]+['"] not found\.?/i.test(errorMessage) ||
|
|
11260
|
-
(/previous[ _]?response/i.test(errorMessage) &&
|
|
11261
|
-
/not[ _]?found|invalid|expired|stale|zero[ _-]?data[ _-]?retention/i.test(errorMessage))
|
|
11262
|
-
);
|
|
11263
|
-
}
|
|
11264
|
-
|
|
11265
11408
|
#isClassifierRefusal(message: AssistantMessage): boolean {
|
|
11266
11409
|
if (message.stopReason !== "error") return false;
|
|
11267
11410
|
const stopType = message.stopDetails?.type;
|
|
11268
11411
|
return stopType === "refusal" || stopType === "sensitive";
|
|
11269
11412
|
}
|
|
11270
11413
|
|
|
11271
|
-
#isProviderErrorFinishReasonBeforeToolUse(message: AssistantMessage): boolean {
|
|
11272
|
-
if (!message.errorMessage) return false;
|
|
11273
|
-
if (message.content.some(block => block.type === "toolCall")) return false;
|
|
11274
|
-
return /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i.test(message.errorMessage);
|
|
11275
|
-
}
|
|
11276
|
-
|
|
11277
|
-
#isMalformedFunctionCallError(message: AssistantMessage): boolean {
|
|
11278
|
-
if (!message.errorMessage) return false;
|
|
11279
|
-
return /\bmalformed.?function.?call\b/i.test(message.errorMessage);
|
|
11280
|
-
}
|
|
11281
|
-
|
|
11282
|
-
#isTransientErrorMessage(errorMessage: string): boolean {
|
|
11283
|
-
return (
|
|
11284
|
-
this.#isTransientEnvelopeErrorMessage(errorMessage) || this.#isTransientTransportErrorMessage(errorMessage)
|
|
11285
|
-
);
|
|
11286
|
-
}
|
|
11287
|
-
|
|
11288
|
-
#isTransientEnvelopeErrorMessage(errorMessage: string): boolean {
|
|
11289
|
-
// Match Anthropic stream-envelope failures that indicate a broken stream before any content starts.
|
|
11290
|
-
return /anthropic stream envelope error:/i.test(errorMessage) && /before message_start/i.test(errorMessage);
|
|
11291
|
-
}
|
|
11292
|
-
|
|
11293
|
-
#isCompactionSummarizationTimeoutMessage(errorMessage: string): boolean {
|
|
11294
|
-
return /\b(?:operation\s+)?timed?\s*out\b|\btimeout\b|\bstream stall\b/i.test(errorMessage);
|
|
11295
|
-
}
|
|
11296
|
-
|
|
11297
|
-
#isTransientTransportErrorMessage(errorMessage: string): boolean {
|
|
11298
|
-
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
|
|
11299
|
-
// service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
|
|
11300
|
-
// gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets
|
|
11301
|
-
// (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from
|
|
11302
|
-
// src/http/h2_client/dispatch.zig)
|
|
11303
|
-
return (
|
|
11304
|
-
isUnexpectedSocketCloseMessage(errorMessage) ||
|
|
11305
|
-
/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(
|
|
11306
|
-
errorMessage,
|
|
11307
|
-
)
|
|
11308
|
-
);
|
|
11309
|
-
}
|
|
11310
|
-
|
|
11311
11414
|
#getRetryFallbackChains(): RetryFallbackChains {
|
|
11312
11415
|
const configuredChains = this.settings.get("retry.fallbackChains");
|
|
11313
11416
|
if (!configuredChains || typeof configuredChains !== "object") return {};
|
|
@@ -11540,20 +11643,15 @@ export class AgentSession {
|
|
|
11540
11643
|
#isFireworksFastFallbackEligible(message: AssistantMessage): boolean {
|
|
11541
11644
|
const model = this.#activeFireworksFastModel();
|
|
11542
11645
|
if (!model) return false;
|
|
11543
|
-
if (message.stopReason !== "error"
|
|
11646
|
+
if (message.stopReason !== "error") return false;
|
|
11544
11647
|
if (message.content.some(block => block.type === "toolCall")) return false;
|
|
11545
11648
|
// A content refusal/sensitivity stop is the model's decision, not a route
|
|
11546
11649
|
// failure — switching to the base model would just re-trigger it.
|
|
11547
11650
|
if (this.#isClassifierRefusal(message)) return false;
|
|
11548
|
-
|
|
11549
|
-
|
|
11550
|
-
if (
|
|
11551
|
-
if (
|
|
11552
|
-
/\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i.test(
|
|
11553
|
-
err,
|
|
11554
|
-
)
|
|
11555
|
-
)
|
|
11556
|
-
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;
|
|
11557
11655
|
return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
|
|
11558
11656
|
}
|
|
11559
11657
|
|
|
@@ -11719,7 +11817,8 @@ export class AgentSession {
|
|
|
11719
11817
|
}
|
|
11720
11818
|
|
|
11721
11819
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
11722
|
-
const
|
|
11820
|
+
const id = this.#classifyRetryMessage(message);
|
|
11821
|
+
const staleOpenAIResponsesReplayError = AIError.is(id, AIError.Flag.StaleResponsesItem);
|
|
11723
11822
|
const parsedRetryAfterMs = this.#parseRetryAfterMsFromError(errorMessage);
|
|
11724
11823
|
let delayMs = staleOpenAIResponsesReplayError
|
|
11725
11824
|
? 0
|
|
@@ -11734,7 +11833,7 @@ export class AgentSession {
|
|
|
11734
11833
|
this.#resetCurrentResponsesProviderSession("stale replay error");
|
|
11735
11834
|
}
|
|
11736
11835
|
|
|
11737
|
-
if (this.model && !staleOpenAIResponsesReplayError &&
|
|
11836
|
+
if (this.model && !staleOpenAIResponsesReplayError && AIError.is(id, AIError.Flag.UsageLimit)) {
|
|
11738
11837
|
const retryAfterMs = parsedRetryAfterMs ?? calculateRateLimitBackoffMs(parseRateLimitReason(errorMessage));
|
|
11739
11838
|
const outcome = await this.#modelRegistry.authStorage.markUsageLimitReached(
|
|
11740
11839
|
this.model.provider,
|
|
@@ -11841,6 +11940,7 @@ export class AgentSession {
|
|
|
11841
11940
|
maxAttempts: retrySettings.maxRetries,
|
|
11842
11941
|
delayMs,
|
|
11843
11942
|
errorMessage,
|
|
11943
|
+
errorId: message.errorId,
|
|
11844
11944
|
});
|
|
11845
11945
|
|
|
11846
11946
|
// Remove the failed assistant message from active context before retrying.
|
|
@@ -13752,6 +13852,17 @@ export class AgentSession {
|
|
|
13752
13852
|
return this.#advisorAgent !== undefined;
|
|
13753
13853
|
}
|
|
13754
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
|
+
|
|
13755
13866
|
/**
|
|
13756
13867
|
* Return structured advisor stats for the status command and TUI panel.
|
|
13757
13868
|
*/
|