@oh-my-pi/pi-coding-agent 17.2.12 → 17.2.13
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 +57 -0
- package/dist/{CHANGELOG-k9ghy5sn.md → CHANGELOG-d8xh7keh.md} +57 -0
- package/dist/cli.js +3069 -3047
- package/dist/types/advisor/delta-split.d.ts +24 -0
- package/dist/types/advisor/runtime.d.ts +2 -2
- package/dist/types/async/job-manager.d.ts +8 -1
- package/dist/types/cli/update-cli.d.ts +53 -1
- package/dist/types/config/keybindings.d.ts +10 -0
- package/dist/types/config/model-resolver.d.ts +15 -2
- package/dist/types/config/settings-schema.d.ts +4 -0
- package/dist/types/discovery/agents-md.d.ts +10 -1
- package/dist/types/eval/runner-cache.d.ts +12 -0
- package/dist/types/extensibility/extensions/runner.d.ts +12 -3
- package/dist/types/extensibility/extensions/types.d.ts +15 -4
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +4 -1
- package/dist/types/lib/xai-http.d.ts +0 -1
- package/dist/types/mcp/tool-bridge.d.ts +8 -5
- package/dist/types/modes/components/agent-hub-renderer.d.ts +6 -1
- package/dist/types/modes/components/status-line/types.d.ts +4 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -4
- package/dist/types/modes/interactive-mode.d.ts +17 -8
- package/dist/types/modes/types.d.ts +7 -10
- package/dist/types/modes/utils/hotkeys-markdown.d.ts +1 -1
- package/dist/types/session/agent-session-types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +22 -3
- package/dist/types/session/messages.d.ts +20 -0
- package/dist/types/session/retry-fallback-chains.d.ts +13 -0
- package/dist/types/session/session-advisors.d.ts +1 -1
- package/dist/types/session/session-history-format.d.ts +10 -0
- package/dist/types/session/session-maintenance.d.ts +1 -1
- package/dist/types/session/session-tools.d.ts +24 -5
- package/dist/types/session/turn-recovery.d.ts +34 -5
- package/dist/types/slash-commands/types.d.ts +5 -1
- package/dist/types/task/executor.d.ts +1 -1
- package/dist/types/tools/approval.d.ts +7 -0
- package/dist/types/tools/todo.d.ts +14 -15
- package/dist/types/tools/write.d.ts +2 -2
- package/dist/types/utils/local-date.d.ts +2 -0
- package/dist/types/vibe/runtime.d.ts +1 -1
- package/dist/types/web/parallel.d.ts +1 -0
- package/dist/types/web/search/providers/brave.d.ts +8 -3
- package/dist/types/web/search/providers/codex.d.ts +6 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +3 -2
- package/dist/types/web/search/providers/jina.d.ts +3 -3
- package/dist/types/web/search/providers/parallel.d.ts +1 -0
- package/dist/types/web/search/providers/perplexity.d.ts +4 -0
- package/dist/types/web/search/providers/tinyfish.d.ts +2 -0
- package/package.json +13 -13
- package/src/advisor/delta-split.ts +98 -0
- package/src/advisor/runtime.ts +321 -69
- package/src/async/job-manager.ts +14 -3
- package/src/cli/plugin-cli.ts +30 -2
- package/src/cli/update-cli.ts +259 -24
- package/src/config/keybindings.ts +52 -9
- package/src/config/model-resolver.ts +19 -3
- package/src/config/settings-schema.ts +5 -0
- package/src/cursor.ts +10 -5
- package/src/discovery/agents-md.ts +61 -23
- package/src/eval/jl/kernel.ts +2 -20
- package/src/eval/py/kernel.ts +2 -20
- package/src/eval/rb/kernel.ts +2 -20
- package/src/eval/runner-cache.ts +41 -0
- package/src/exec/non-interactive-env.ts +14 -3
- package/src/extensibility/extensions/loader.ts +5 -2
- package/src/extensibility/extensions/runner.ts +184 -66
- package/src/extensibility/extensions/types.ts +26 -2
- package/src/extensibility/extensions/wrapper.ts +13 -7
- package/src/extensibility/plugins/marketplace/manager.ts +6 -2
- package/src/hindsight/client.ts +1 -1
- package/src/lib/xai-http.ts +0 -4
- package/src/lsp/client.ts +2 -0
- package/src/lsp/servers.ts +1 -1
- package/src/mcp/tool-bridge.ts +15 -6
- package/src/modes/components/agent-hub-renderer.ts +9 -3
- package/src/modes/components/agent-hub.ts +2 -1
- package/src/modes/components/status-line/component.ts +58 -6
- package/src/modes/components/status-line/segments.ts +12 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/user-message.ts +20 -5
- package/src/modes/controllers/event-controller.ts +48 -0
- package/src/modes/controllers/extension-ui-controller.ts +14 -7
- package/src/modes/controllers/input-controller.ts +25 -6
- package/src/modes/interactive-mode.ts +315 -129
- package/src/modes/rpc/rpc-frame.ts +13 -5
- package/src/modes/theme/tui-adapters.ts +4 -5
- package/src/modes/types.ts +13 -7
- package/src/modes/utils/hotkeys-markdown.ts +10 -6
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/registry/persisted-agents.ts +43 -8
- package/src/sdk.ts +139 -8
- package/src/session/agent-session-types.ts +2 -0
- package/src/session/agent-session.ts +66 -10
- package/src/session/messages.ts +98 -28
- package/src/session/retry-fallback-chains.ts +14 -0
- package/src/session/session-advisors.ts +32 -15
- package/src/session/session-history-format.ts +15 -1
- package/src/session/session-maintenance.ts +8 -8
- package/src/session/session-manager.ts +6 -2
- package/src/session/session-tools.ts +321 -184
- package/src/session/turn-recovery.ts +225 -47
- package/src/slash-commands/builtin-modes.ts +41 -12
- package/src/slash-commands/types.ts +5 -1
- package/src/task/executor.ts +92 -45
- package/src/task/structured-subagent.ts +5 -5
- package/src/tools/approval.ts +44 -10
- package/src/tools/fetch.ts +21 -2
- package/src/tools/image-gen.ts +6 -8
- package/src/tools/todo.ts +70 -26
- package/src/tools/tts.ts +3 -2
- package/src/tools/write.ts +7 -3
- package/src/utils/local-date.ts +13 -0
- package/src/utils/tools-manager.ts +2 -2
- package/src/vibe/runtime.ts +22 -14
- package/src/web/kagi.ts +91 -34
- package/src/web/parallel.ts +11 -2
- package/src/web/scrapers/crates-io.ts +2 -2
- package/src/web/scrapers/discogs.ts +2 -2
- package/src/web/scrapers/docs-rs.ts +2 -2
- package/src/web/scrapers/github.ts +2 -2
- package/src/web/scrapers/musicbrainz.ts +1 -2
- package/src/web/scrapers/pubmed.ts +2 -2
- package/src/web/scrapers/sec-edgar.ts +2 -2
- package/src/web/search/providers/brave.ts +121 -46
- package/src/web/search/providers/codex.ts +88 -12
- package/src/web/search/providers/exa.ts +45 -10
- package/src/web/search/providers/firecrawl.ts +53 -11
- package/src/web/search/providers/gemini.ts +139 -27
- package/src/web/search/providers/jina.ts +48 -25
- package/src/web/search/providers/parallel.ts +23 -9
- package/src/web/search/providers/perplexity.ts +24 -7
- package/src/web/search/providers/searxng.ts +77 -1
- package/src/web/search/providers/tavily.ts +23 -22
- package/src/web/search/providers/tinyfish.ts +44 -10
- package/src/web/search/providers/xai.ts +85 -14
|
@@ -44,7 +44,7 @@ import type {
|
|
|
44
44
|
UsageFallbackConfirmation,
|
|
45
45
|
UsageFallbackConfirmer,
|
|
46
46
|
} from "./agent-session-types";
|
|
47
|
-
import { isEmptyErrorTurn } from "./messages";
|
|
47
|
+
import { assistantTurnProducedOutput, isEmptyAssistantStop, isEmptyErrorTurn } from "./messages";
|
|
48
48
|
import {
|
|
49
49
|
type ActiveRetryFallbackState,
|
|
50
50
|
calculateRetryBackoffDelayMs,
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
type RetryFallbackRevertPolicy,
|
|
59
59
|
type RetryFallbackSelector,
|
|
60
60
|
resolveRetryFallbackChainKey,
|
|
61
|
+
type ServingModel,
|
|
61
62
|
validateRetryFallbackChains,
|
|
62
63
|
} from "./retry-fallback-chains";
|
|
63
64
|
import { getLatestCompactionEntry } from "./session-context";
|
|
@@ -189,6 +190,33 @@ export class TurnRecovery {
|
|
|
189
190
|
#emptyStopRetryCount = 0;
|
|
190
191
|
#unexpectedStopRetryCount = 0;
|
|
191
192
|
#acceptTerminalEmptyStopForPrompt = false;
|
|
193
|
+
// Three fields sit near the word "serve" and are deliberately distinct:
|
|
194
|
+
// `#activeRetryFallback.served` gates the one-shot `retry_fallback_succeeded`
|
|
195
|
+
// event for the current arm, `#fallbackRouted` says how the CURRENT model was
|
|
196
|
+
// reached, and `#lastServed` is the session's attribution. A fallback flipping
|
|
197
|
+
// to served does not by itself move attribution — only a settled turn does.
|
|
198
|
+
/**
|
|
199
|
+
* Attribution of the newest turn that produced output, tagged with the
|
|
200
|
+
* session it belongs to. Anchoring rather than resetting follows
|
|
201
|
+
* `#ensurePersistedMessageKeys`: every real switch mints a new session id, so
|
|
202
|
+
* stale attribution drops itself and no mutation call site has to remember to
|
|
203
|
+
* clear it. The id — not the file — is the anchor because an unpersisted
|
|
204
|
+
* session has no file, and comparing two `undefined`s would never invalidate.
|
|
205
|
+
*/
|
|
206
|
+
#lastServed: { attribution: ServingModel; sessionId: string } | undefined;
|
|
207
|
+
/**
|
|
208
|
+
* Session whose current model was reached by fallback routing rather than by
|
|
209
|
+
* the configured primary, or `undefined` when it was not. Tracked separately
|
|
210
|
+
* from {@link #activeRetryFallback} because the Fireworks Fast degrade swaps
|
|
211
|
+
* models without arming a chain, and anchored like {@link #lastServed}:
|
|
212
|
+
* switching transcripts in place must not describe a fresh session's model
|
|
213
|
+
* with how the previous one was routed.
|
|
214
|
+
*/
|
|
215
|
+
#fallbackRoutedFor: string | undefined;
|
|
216
|
+
/** Memoized bootstrap answer, for the window before anything has served. */
|
|
217
|
+
#bootstrapCache:
|
|
218
|
+
| { model: Model; level: ThinkingLevel | undefined; routed: boolean; value: ServingModel }
|
|
219
|
+
| undefined;
|
|
192
220
|
|
|
193
221
|
constructor(host: TurnRecoveryHost, options: TurnRecoveryOptions = {}) {
|
|
194
222
|
this.#host = host;
|
|
@@ -198,6 +226,7 @@ export class TurnRecovery {
|
|
|
198
226
|
lastAppliedFallbackThinkingLevel: host.configuredThinkingLevel(),
|
|
199
227
|
pinned: options.initialRetryFallback.pinned ?? false,
|
|
200
228
|
};
|
|
229
|
+
this.#markFallbackRouted();
|
|
201
230
|
}
|
|
202
231
|
this.#validateRetryFallbackChains();
|
|
203
232
|
}
|
|
@@ -212,12 +241,70 @@ export class TurnRecovery {
|
|
|
212
241
|
return this.#retryPromise;
|
|
213
242
|
}
|
|
214
243
|
|
|
215
|
-
/**
|
|
216
|
-
get
|
|
244
|
+
/** Whether the CURRENT session's model was reached by fallback routing. */
|
|
245
|
+
get #fallbackRouted(): boolean {
|
|
246
|
+
return (
|
|
247
|
+
this.#fallbackRoutedFor !== undefined && this.#fallbackRoutedFor === this.#host.sessionManager.getSessionId()
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#markFallbackRouted(): void {
|
|
252
|
+
this.#fallbackRoutedFor = this.#host.sessionManager.getSessionId();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Model this session's produced work is attributed to.
|
|
257
|
+
*
|
|
258
|
+
* A model switch is a routing decision, not evidence the target can produce
|
|
259
|
+
* anything: a candidate that errors on its first request produced none of the
|
|
260
|
+
* turns already in this session. So attribution only ever names a model that
|
|
261
|
+
* has settled a turn here, and a switch — into a fallback, back to a restored
|
|
262
|
+
* primary, or anywhere else — moves it only once the new model answers.
|
|
263
|
+
*
|
|
264
|
+
* Before anything has served there is no earlier work to miscredit, so the
|
|
265
|
+
* configured model is both the only available answer and a safe one.
|
|
266
|
+
*/
|
|
267
|
+
get servingModel(): ServingModel | undefined {
|
|
268
|
+
const served = this.#lastServed;
|
|
269
|
+
if (served && served.sessionId === this.#host.sessionManager.getSessionId()) return served.attribution;
|
|
217
270
|
const model = this.#host.model();
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
271
|
+
if (!model) return undefined;
|
|
272
|
+
// Polled per streaming event and per render, so the pre-first-turn window
|
|
273
|
+
// must not format a selector on every call.
|
|
274
|
+
const level = this.#host.thinkingLevel();
|
|
275
|
+
const cached = this.#bootstrapCache;
|
|
276
|
+
if (cached && cached.model === model && cached.level === level && cached.routed === this.#fallbackRouted) {
|
|
277
|
+
return cached.value;
|
|
278
|
+
}
|
|
279
|
+
const value: ServingModel = {
|
|
280
|
+
selector: formatRetryFallbackSelector(model, level),
|
|
281
|
+
isFallback: this.#fallbackRouted,
|
|
282
|
+
};
|
|
283
|
+
this.#bootstrapCache = { model, level, routed: this.#fallbackRouted, value };
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Carries attribution onto a new session id that continues this conversation.
|
|
289
|
+
*
|
|
290
|
+
* The session-id anchor assumes a new id means an unrelated transcript, which
|
|
291
|
+
* holds for `/new` and for resuming something else. A fork breaks that
|
|
292
|
+
* assumption on purpose: it clones the transcript and keeps running the same
|
|
293
|
+
* recovery state under a fresh id. Dropping attribution there would bootstrap
|
|
294
|
+
* an unproven fallback as the primary and re-credit it with the work the
|
|
295
|
+
* previous model did — the very bug the anchor exists to prevent.
|
|
296
|
+
*
|
|
297
|
+
* Only state belonging to `previousSessionId` moves, so an id left behind by
|
|
298
|
+
* an earlier switch stays expired.
|
|
299
|
+
*/
|
|
300
|
+
reanchorServedAttribution(previousSessionId: string): void {
|
|
301
|
+
const sessionId = this.#host.sessionManager.getSessionId();
|
|
302
|
+
if (this.#lastServed?.sessionId === previousSessionId) {
|
|
303
|
+
this.#lastServed = { ...this.#lastServed, sessionId };
|
|
304
|
+
}
|
|
305
|
+
if (this.#fallbackRoutedFor === previousSessionId) {
|
|
306
|
+
this.#fallbackRoutedFor = sessionId;
|
|
307
|
+
}
|
|
221
308
|
}
|
|
222
309
|
|
|
223
310
|
/** Resets per-prompt recovery counters and terminal-stop acceptance. */
|
|
@@ -232,24 +319,41 @@ export class TurnRecovery {
|
|
|
232
319
|
this.#acceptTerminalEmptyStopForPrompt = accept;
|
|
233
320
|
}
|
|
234
321
|
|
|
235
|
-
/**
|
|
322
|
+
/**
|
|
323
|
+
* Records which model produced this turn, marks an active fallback as having
|
|
324
|
+
* served, then closes a successful retry saga and annotates recovered
|
|
325
|
+
* persisted errors.
|
|
326
|
+
*/
|
|
236
327
|
async onAssistantSettledSuccessfully(message: AssistantMessage): Promise<void> {
|
|
237
|
-
if (
|
|
238
|
-
message.stopReason === "error" ||
|
|
239
|
-
message.stopReason === "aborted" ||
|
|
240
|
-
this.#isEmptyAssistantStop(message) ||
|
|
241
|
-
this.#retryAttempt === 0
|
|
242
|
-
) {
|
|
328
|
+
if (!assistantTurnProducedOutput(message)) {
|
|
243
329
|
return;
|
|
244
330
|
}
|
|
245
331
|
const model = this.#host.model();
|
|
246
|
-
if (
|
|
332
|
+
if (model) {
|
|
333
|
+
this.#lastServed = {
|
|
334
|
+
attribution: {
|
|
335
|
+
selector: formatRetryFallbackSelector(model, this.#host.thinkingLevel()),
|
|
336
|
+
isFallback: this.#fallbackRouted,
|
|
337
|
+
},
|
|
338
|
+
sessionId: this.#host.sessionManager.getSessionId(),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
// Independent of the retry saga below: a usage-aware fallback is applied
|
|
342
|
+
// before a request without ever incrementing `#retryAttempt`, and it still
|
|
343
|
+
// owns every turn it serves. Gating this on the saga left such a fallback
|
|
344
|
+
// permanently unproven, hiding it from observers for the whole session.
|
|
345
|
+
if (this.#activeRetryFallback && !this.#activeRetryFallback.served && model) {
|
|
346
|
+
this.#activeRetryFallback.served = true;
|
|
247
347
|
await this.#host.emitSessionEvent({
|
|
248
348
|
type: "retry_fallback_succeeded",
|
|
249
|
-
model:
|
|
349
|
+
model:
|
|
350
|
+
this.#lastServed?.attribution.selector ?? formatRetryFallbackSelector(model, this.#host.thinkingLevel()),
|
|
250
351
|
role: this.#activeRetryFallback.role,
|
|
251
352
|
});
|
|
252
353
|
}
|
|
354
|
+
if (this.#retryAttempt === 0) {
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
253
357
|
const retryErrors = await this.#markPendingRetryErrors({
|
|
254
358
|
status: "recovered",
|
|
255
359
|
supersedingMessage: message,
|
|
@@ -538,7 +642,7 @@ export class TurnRecovery {
|
|
|
538
642
|
}
|
|
539
643
|
|
|
540
644
|
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
|
|
541
|
-
if (!
|
|
645
|
+
if (!isEmptyAssistantStop(assistantMessage)) {
|
|
542
646
|
this.#emptyStopRetryCount = 0;
|
|
543
647
|
return false;
|
|
544
648
|
}
|
|
@@ -587,31 +691,6 @@ export class TurnRecovery {
|
|
|
587
691
|
return true;
|
|
588
692
|
}
|
|
589
693
|
|
|
590
|
-
#isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
|
|
591
|
-
switch (assistantMessage.stopReason) {
|
|
592
|
-
case "stop":
|
|
593
|
-
// Unsigned thinking alone is not actionable, but a signature is
|
|
594
|
-
// provider-authenticated content and makes the stop terminal.
|
|
595
|
-
for (const content of assistantMessage.content) {
|
|
596
|
-
if (content.type === "toolCall") return false;
|
|
597
|
-
if (content.type === "text" && hasNonWhitespace(content.text)) return false;
|
|
598
|
-
if (content.type === "thinking" && hasNonWhitespace(content.thinkingSignature ?? "")) return false;
|
|
599
|
-
}
|
|
600
|
-
return true;
|
|
601
|
-
case "toolUse":
|
|
602
|
-
// An orphaned toolUse stop (no tool_use block) corrupts Anthropic history:
|
|
603
|
-
// a later tool_result has nothing to anchor to. Thinking alone cannot anchor
|
|
604
|
-
// a tool_result, so it does not rescue a toolUse stop here.
|
|
605
|
-
for (const content of assistantMessage.content) {
|
|
606
|
-
if (content.type === "toolCall") return false;
|
|
607
|
-
if (content.type === "text" && hasNonWhitespace(content.text)) return false;
|
|
608
|
-
}
|
|
609
|
-
return true;
|
|
610
|
-
default:
|
|
611
|
-
return false;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
694
|
#emptyStopRetryReminder(): string {
|
|
616
695
|
return prompt.render(emptyStopRetryTemplate, {
|
|
617
696
|
retryCount: this.#emptyStopRetryCount,
|
|
@@ -939,12 +1018,83 @@ export class TurnRecovery {
|
|
|
939
1018
|
|
|
940
1019
|
// Credential rotation and classifier fallbacks are safe only before
|
|
941
1020
|
// committed text, images, tool calls, or server tools. Thinking-only
|
|
942
|
-
// output remains replay-safe.
|
|
943
|
-
|
|
1021
|
+
// output remains replay-safe. The one exception is a refusal whose ONLY
|
|
1022
|
+
// replay-unsafe output is tool calls the agent loop proved never ran
|
|
1023
|
+
// (`#refusalReplaySafe`): nothing reached the user and no side effect
|
|
1024
|
+
// happened, so discarding the turn duplicates nothing and the fallback
|
|
1025
|
+
// chain gets its chance.
|
|
1026
|
+
if (this.#hasReplayUnsafeOutput(message) && !this.#refusalReplaySafe(message)) return false;
|
|
944
1027
|
if (AIError.is(id, AIError.Flag.AccountPolicy) || this.isClassifierRefusal(message)) return true;
|
|
945
1028
|
return AIError.retriable(id);
|
|
946
1029
|
}
|
|
947
1030
|
|
|
1031
|
+
/**
|
|
1032
|
+
* True when a classifier refusal is replay-safe *despite* having emitted tool
|
|
1033
|
+
* calls, because every emitted call provably never executed.
|
|
1034
|
+
*
|
|
1035
|
+
* Anthropic's request classifier can fire after the model has already streamed
|
|
1036
|
+
* a tool call, which used to strand the turn: `#hasReplayUnsafeOutput` sees the
|
|
1037
|
+
* `toolCall` block and vetoes retry one line before the refusal could reach the
|
|
1038
|
+
* fallback-chain consult, so a refusal that a different model family would very
|
|
1039
|
+
* likely have served just ended the turn.
|
|
1040
|
+
*
|
|
1041
|
+
* That veto exists to protect against duplicating work or visible output. Neither
|
|
1042
|
+
* risk is present here: the agent loop pairs each emitted-but-unrun call with a
|
|
1043
|
+
* synthetic `executed: false` result (see {@link isSyntheticToolResultMessage}),
|
|
1044
|
+
* which is a positive record that `tool.execute()` never ran. So the veto is
|
|
1045
|
+
* lifted only when ALL of the following hold, and any uncertainty (assistant
|
|
1046
|
+
* message missing from state, a call with no result, a non-synthetic result, an
|
|
1047
|
+
* `executed` that is not exactly `false`) keeps it in place:
|
|
1048
|
+
*
|
|
1049
|
+
* - the stop is a classifier refusal/sensitivity stop;
|
|
1050
|
+
* - the only replay-unsafe blocks are tool calls — an `image`, an
|
|
1051
|
+
* `anthropicServerTool`, or committed non-whitespace text has already rendered
|
|
1052
|
+
* or has side effects, so replaying would duplicate it;
|
|
1053
|
+
* - at least one tool call was emitted (otherwise the plain refusal path already
|
|
1054
|
+
* handles it);
|
|
1055
|
+
* - every emitted call id has a result after the assistant message in state, and
|
|
1056
|
+
* every such result is synthetic with `executed === false`.
|
|
1057
|
+
*/
|
|
1058
|
+
#refusalReplaySafe(message: AssistantMessage): boolean {
|
|
1059
|
+
if (!this.isClassifierRefusal(message)) return false;
|
|
1060
|
+
|
|
1061
|
+
const emittedToolCallIds = new Set<string>();
|
|
1062
|
+
for (const block of message.content) {
|
|
1063
|
+
if (block.type === "toolCall") {
|
|
1064
|
+
emittedToolCallIds.add(block.id);
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (block.type === "image" || block.type === "anthropicServerTool") return false;
|
|
1068
|
+
if (block.type === "text" && this.#host.textOutputCommitted() && hasNonWhitespace(block.text)) return false;
|
|
1069
|
+
}
|
|
1070
|
+
if (emittedToolCallIds.size === 0) return false;
|
|
1071
|
+
|
|
1072
|
+
// The refused assistant message is NOT the tail of state: the agent loop
|
|
1073
|
+
// appends the synthetic results after it before the turn ends, so locate it
|
|
1074
|
+
// by walking backwards exactly as `classifyResolvedInterruptedToolTurn` does.
|
|
1075
|
+
const messages = this.#host.agent.state.messages;
|
|
1076
|
+
let assistantIndex = -1;
|
|
1077
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1078
|
+
const candidate = messages[i];
|
|
1079
|
+
if (candidate.role === "assistant" && this.#isSameAssistantMessage(candidate, message)) {
|
|
1080
|
+
assistantIndex = i;
|
|
1081
|
+
break;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (assistantIndex < 0) return false;
|
|
1085
|
+
|
|
1086
|
+
const unexecutedToolCallIds = new Set<string>();
|
|
1087
|
+
for (let i = assistantIndex + 1; i < messages.length; i++) {
|
|
1088
|
+
const candidate = messages[i];
|
|
1089
|
+
if (candidate.role !== "toolResult" || !emittedToolCallIds.has(candidate.toolCallId)) continue;
|
|
1090
|
+
// Every result for an emitted call is inspected, not just the first: a
|
|
1091
|
+
// real result anywhere in the tail means the tool ran.
|
|
1092
|
+
if (!isSyntheticToolResultMessage(candidate) || candidate.details?.executed !== false) return false;
|
|
1093
|
+
unexecutedToolCallIds.add(candidate.toolCallId);
|
|
1094
|
+
}
|
|
1095
|
+
return unexecutedToolCallIds.size === emittedToolCallIds.size;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
948
1098
|
/**
|
|
949
1099
|
* Classify a reasonless abort or stream stall whose emitted tool calls all
|
|
950
1100
|
* have results. The failed assistant/tool-result pair stays in context so
|
|
@@ -1069,9 +1219,10 @@ export class TurnRecovery {
|
|
|
1069
1219
|
return getRetryFallbackRevertPolicy(this.#host.settings);
|
|
1070
1220
|
}
|
|
1071
1221
|
|
|
1072
|
-
/** Clears fallback ownership after an explicit model change. */
|
|
1222
|
+
/** Clears fallback ownership after an explicit model change or a restore. */
|
|
1073
1223
|
clearActiveRetryFallback(): void {
|
|
1074
1224
|
this.#activeRetryFallback = undefined;
|
|
1225
|
+
this.#fallbackRoutedFor = undefined;
|
|
1075
1226
|
}
|
|
1076
1227
|
|
|
1077
1228
|
/** Checks whether a fallback selector remains in cooldown. */
|
|
@@ -1312,14 +1463,29 @@ export class TurnRecovery {
|
|
|
1312
1463
|
: clampThinkingLevelToCeiling(candidate, requestedThinkingLevel, this.#host.thinkingLevelCeiling());
|
|
1313
1464
|
const candidateSelector = formatModelStringWithRouting(candidate);
|
|
1314
1465
|
const previousModel = this.#host.model();
|
|
1466
|
+
// Mark routing BEFORE the swap: `setModelWithProviderSessionReset` moves the
|
|
1467
|
+
// model and fans `model_changed` out to subscribers synchronously, and a
|
|
1468
|
+
// listener reading attribution in that window must already see the incoming
|
|
1469
|
+
// candidate as fallback-routed. Attribution itself is safe regardless — it
|
|
1470
|
+
// names the last model that served, which this swap has not changed.
|
|
1471
|
+
const routedBeforeSwap = this.#fallbackRoutedFor;
|
|
1472
|
+
const servedBeforeSwap = this.#activeRetryFallback?.served;
|
|
1473
|
+
this.#markFallbackRouted();
|
|
1474
|
+
if (this.#activeRetryFallback) this.#activeRetryFallback.served = false;
|
|
1315
1475
|
await this.#host.setModelWithProviderSessionReset(candidate);
|
|
1316
1476
|
if (options?.signal?.aborted) {
|
|
1477
|
+
this.#fallbackRoutedFor = routedBeforeSwap;
|
|
1478
|
+
if (this.#activeRetryFallback) this.#activeRetryFallback.served = servedBeforeSwap;
|
|
1317
1479
|
if (previousModel && this.#host.model() === candidate) {
|
|
1318
1480
|
await this.#host.setModelWithProviderSessionReset(previousModel);
|
|
1319
1481
|
}
|
|
1320
1482
|
return false;
|
|
1321
1483
|
}
|
|
1322
|
-
if (this.#host.model() !== candidate)
|
|
1484
|
+
if (this.#host.model() !== candidate) {
|
|
1485
|
+
this.#fallbackRoutedFor = routedBeforeSwap;
|
|
1486
|
+
if (this.#activeRetryFallback) this.#activeRetryFallback.served = servedBeforeSwap;
|
|
1487
|
+
return false;
|
|
1488
|
+
}
|
|
1323
1489
|
this.#host.sessionManager.appendModelChange(candidateSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true);
|
|
1324
1490
|
this.#host.settings.getStorage()?.recordModelUsage(candidateSelector);
|
|
1325
1491
|
this.#host.setThinkingLevel(nextThinkingLevel);
|
|
@@ -1439,6 +1605,9 @@ export class TurnRecovery {
|
|
|
1439
1605
|
const apiKey = await this.#host.modelRegistry.getApiKey(baseModel, this.#host.sessionId());
|
|
1440
1606
|
if (!apiKey) return false;
|
|
1441
1607
|
const baseSelector = formatModelStringWithRouting(baseModel);
|
|
1608
|
+
// A capability degrade is fallback routing too, even though it arms no
|
|
1609
|
+
// chain: the base model must not be reported as the configured primary.
|
|
1610
|
+
this.#markFallbackRouted();
|
|
1442
1611
|
await this.#host.setModelWithProviderSessionReset(baseModel);
|
|
1443
1612
|
this.#host.sessionManager.appendModelChange(baseSelector, EPHEMERAL_MODEL_CHANGE_ROLE, true);
|
|
1444
1613
|
this.#host.settings.getStorage()?.recordModelUsage(baseSelector);
|
|
@@ -1463,7 +1632,12 @@ export class TurnRecovery {
|
|
|
1463
1632
|
} = this.#activeRetryFallback;
|
|
1464
1633
|
const originalSelector = parseRetryFallbackSelector(originalSelectorRaw, this.#host.modelRegistry);
|
|
1465
1634
|
if (!originalSelector) {
|
|
1466
|
-
|
|
1635
|
+
// Defensive: the stored selector is always produced by
|
|
1636
|
+
// `formatRetryFallbackSelector`, so it should never fail to parse. If it
|
|
1637
|
+
// somehow does, nothing is restored and the session keeps running on the
|
|
1638
|
+
// fallback — so drop the chain record but NOT `#fallbackRouted`, whose
|
|
1639
|
+
// clearing would report the fallback's remaining turns as the primary.
|
|
1640
|
+
this.#activeRetryFallback = undefined;
|
|
1467
1641
|
return false;
|
|
1468
1642
|
}
|
|
1469
1643
|
|
|
@@ -1493,11 +1667,15 @@ export class TurnRecovery {
|
|
|
1493
1667
|
const thinkingToApply =
|
|
1494
1668
|
currentThinkingLevel === lastAppliedFallbackThinkingLevel ? originalThinkingLevel : currentThinkingLevel;
|
|
1495
1669
|
const primarySelector = formatModelStringWithRouting(primaryModel);
|
|
1670
|
+
// Clear before the swap: `setModelWithProviderSessionReset` and
|
|
1671
|
+
// `setThinkingLevel` both notify subscribers, and an observer reading
|
|
1672
|
+
// attribution in that window would see the restored primary still tagged
|
|
1673
|
+
// as fallback-served.
|
|
1674
|
+
this.clearActiveRetryFallback();
|
|
1496
1675
|
await this.#host.setModelWithProviderSessionReset(primaryModel);
|
|
1497
1676
|
this.#host.sessionManager.appendModelChange(primarySelector, EPHEMERAL_MODEL_CHANGE_ROLE);
|
|
1498
1677
|
this.#host.settings.getStorage()?.recordModelUsage(primarySelector);
|
|
1499
1678
|
this.#host.setThinkingLevel(thinkingToApply);
|
|
1500
|
-
this.clearActiveRetryFallback();
|
|
1501
1679
|
return true;
|
|
1502
1680
|
}
|
|
1503
1681
|
|
|
@@ -14,13 +14,41 @@ import { computerExposureMode } from "../tools/computer/exposure";
|
|
|
14
14
|
import type { InspectImageMode } from "../utils/inspect-image-mode";
|
|
15
15
|
import { commandConsumed, errorMessage, usage } from "./helpers/parse";
|
|
16
16
|
import { handleSecurityCommand } from "./helpers/security";
|
|
17
|
-
import type { SlashCommandSpec } from "./types";
|
|
17
|
+
import type { ParsedSlashCommand, SlashCommandSpec, TuiSlashCommandRuntime } from "./types";
|
|
18
18
|
|
|
19
19
|
export function refreshStatusLine(ctx: InteractiveModeContext): void {
|
|
20
20
|
ctx.statusLine.invalidate();
|
|
21
21
|
ctx.ui.requestRender();
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
async function runWithDetachedModeDraft(
|
|
25
|
+
command: ParsedSlashCommand,
|
|
26
|
+
runtime: TuiSlashCommandRuntime,
|
|
27
|
+
run: () => Promise<boolean>,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const { editor } = runtime.ctx;
|
|
30
|
+
if (!runtime.draftDetached) editor.clearDraft();
|
|
31
|
+
try {
|
|
32
|
+
const submitted = await run();
|
|
33
|
+
if (!submitted && ((runtime.input?.images?.length ?? 0) > 0 || (runtime.input?.imageLinks?.length ?? 0) > 0)) {
|
|
34
|
+
editor.pendingImages = [...(runtime.input?.images ?? []), ...editor.pendingImages];
|
|
35
|
+
editor.pendingImageLinks = [
|
|
36
|
+
...(runtime.input?.imageLinks ?? runtime.input?.images?.map(() => undefined) ?? []),
|
|
37
|
+
...editor.pendingImageLinks,
|
|
38
|
+
];
|
|
39
|
+
editor.imageLinks = editor.pendingImageLinks.length > 0 ? editor.pendingImageLinks : undefined;
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!editor.getText() && editor.pendingImages.length === 0) {
|
|
43
|
+
editor.setText(command.text);
|
|
44
|
+
editor.pendingImages = runtime.input?.images ? [...runtime.input.images] : [];
|
|
45
|
+
editor.pendingImageLinks = runtime.input?.imageLinks ? [...runtime.input.imageLinks] : [];
|
|
46
|
+
editor.imageLinks = editor.pendingImageLinks.length > 0 ? editor.pendingImageLinks : undefined;
|
|
47
|
+
}
|
|
48
|
+
runtime.ctx.showError(error instanceof Error ? error.message : String(error));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
/** `/fast status` label for the active model: "on" when its family is priority, else "off". */
|
|
25
53
|
function formatFastModeStatus(session: AgentSession): string {
|
|
26
54
|
return session.isFastModeEnabled() ? "on" : "off";
|
|
@@ -182,8 +210,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
|
|
|
182
210
|
return "Plan: off";
|
|
183
211
|
},
|
|
184
212
|
handleTui: async (command, runtime) => {
|
|
185
|
-
await
|
|
186
|
-
|
|
213
|
+
await runWithDetachedModeDraft(command, runtime, () =>
|
|
214
|
+
runtime.ctx.handlePlanModeCommand(command.args || undefined, runtime.input),
|
|
215
|
+
);
|
|
187
216
|
},
|
|
188
217
|
},
|
|
189
218
|
{
|
|
@@ -208,8 +237,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
|
|
|
208
237
|
return "Vibe: off";
|
|
209
238
|
},
|
|
210
239
|
handleTui: async (command, runtime) => {
|
|
211
|
-
await
|
|
212
|
-
|
|
240
|
+
await runWithDetachedModeDraft(command, runtime, () =>
|
|
241
|
+
runtime.ctx.handleVibeModeCommand(command.args || undefined, runtime.input),
|
|
242
|
+
);
|
|
213
243
|
},
|
|
214
244
|
},
|
|
215
245
|
{
|
|
@@ -232,8 +262,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
|
|
|
232
262
|
return state ? `Goal: ${state.goal.status} (${shortDetail(state.goal.objective)})` : "Goal: off";
|
|
233
263
|
},
|
|
234
264
|
handleTui: async (command, runtime) => {
|
|
235
|
-
await
|
|
236
|
-
|
|
265
|
+
await runWithDetachedModeDraft(command, runtime, () =>
|
|
266
|
+
runtime.ctx.handleGoalModeCommand(command.args || undefined, runtime.input),
|
|
267
|
+
);
|
|
237
268
|
},
|
|
238
269
|
},
|
|
239
270
|
{
|
|
@@ -242,11 +273,9 @@ export const BUILTIN_MODE_SLASH_COMMANDS: ReadonlyArray<SlashCommandSpec> = [
|
|
|
242
273
|
inlineHint: "[rough objective]",
|
|
243
274
|
allowArgs: true,
|
|
244
275
|
handleTui: async (command, runtime) => {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
runtime.ctx.editor.setText("");
|
|
249
|
-
await runtime.ctx.handleGuidedGoalCommand(command.args || undefined);
|
|
276
|
+
await runWithDetachedModeDraft(command, runtime, () =>
|
|
277
|
+
runtime.ctx.handleGuidedGoalCommand(command.args || undefined, runtime.input),
|
|
278
|
+
);
|
|
250
279
|
},
|
|
251
280
|
},
|
|
252
281
|
{
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Settings } from "../config/settings";
|
|
2
|
-
import type { InteractiveModeContext } from "../modes/types";
|
|
2
|
+
import type { InteractiveModeContext, SubmittedUserInput } from "../modes/types";
|
|
3
3
|
import type { AgentSession } from "../session/agent-session";
|
|
4
4
|
import type { SessionManager } from "../session/session-manager";
|
|
5
5
|
|
|
@@ -83,6 +83,10 @@ export interface SlashCommandRuntime {
|
|
|
83
83
|
*/
|
|
84
84
|
export interface TuiSlashCommandRuntime {
|
|
85
85
|
ctx: InteractiveModeContext;
|
|
86
|
+
/** Post-extension-hook attachments belonging to the submitted slash draft. */
|
|
87
|
+
input?: Pick<SubmittedUserInput, "images" | "imageLinks">;
|
|
88
|
+
/** The editor snapshot was cleared before asynchronous input hooks ran. */
|
|
89
|
+
draftDetached?: boolean;
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
/** Unified slash-command spec consumed by both TUI and ACP dispatchers. */
|