@pentoshi/clai 3.9.1 → 3.9.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.
@@ -1,6 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { join } from "node:path";
3
- import { streamWithProvider, completeWithProvider, isEmptyCompletionError } from "../llm/router.js";
3
+ import { streamWithProvider, completeWithProvider } from "../llm/router.js";
4
+ import { classifyStreamFailure, planStreamRecovery, recordRecoveryAttempt, createStreamRecoveryState, resetStreamRecoveryState, } from "./stream-recovery.js";
4
5
  import { resolveToolDialect } from "../llm/capabilities.js";
5
6
  import { syntheticToolCallId, isTextOnlyModel, fromWireName, } from "../llm/tool-protocol.js";
6
7
  import { sanitizeAssistantText } from "../ui/ansi-box.js";
@@ -102,6 +103,28 @@ export function shouldYieldForResponderBeforeReport(plan, runningJobs, notificat
102
103
  !notification.readAt &&
103
104
  !notification.analyzedAt));
104
105
  }
106
+ /**
107
+ * Cancellable backoff. Resolves after `ms`, or rejects immediately if the
108
+ * signal aborts (double-Esc) so recovery waits never trap a cancelled turn.
109
+ */
110
+ function delay(ms, signal) {
111
+ if (ms <= 0)
112
+ return Promise.resolve();
113
+ if (signal?.aborted) {
114
+ return Promise.reject(signal.reason ?? new Error("Aborted"));
115
+ }
116
+ return new Promise((resolve, reject) => {
117
+ const onAbort = () => {
118
+ clearTimeout(timer);
119
+ reject(signal?.reason ?? new Error("Aborted"));
120
+ };
121
+ const timer = setTimeout(() => {
122
+ signal?.removeEventListener("abort", onAbort);
123
+ resolve();
124
+ }, ms);
125
+ signal?.addEventListener("abort", onAbort, { once: true });
126
+ });
127
+ }
105
128
  export async function runAgentTurn(prompt, options = {}) {
106
129
  const agentMode = options.mode === "plan" || options.mode === "agent" || options.mode === "ask"
107
130
  ? options.mode
@@ -696,6 +719,13 @@ export async function runAgentTurn(prompt, options = {}) {
696
719
  // to actually act instead of silently returning an empty answer.
697
720
  let emptyVisibleRetries = 0;
698
721
  let retryWithoutThinking = false;
722
+ // Robust stream-failure recovery. When a provider stream/complete fails we
723
+ // try working approaches (backoff, compaction, thinking-off, provider
724
+ // fallback) before surrendering the turn — see ./stream-recovery. Both are
725
+ // reset on any successful stream so each failure episode gets a fresh
726
+ // budget and we only give up in the worst case.
727
+ let allowModelFallback = false;
728
+ const recoveryState = createStreamRecoveryState();
699
729
  // Track tool calls truncated by the token limit so we can ask the model
700
730
  // to retry in smaller pieces instead of leaking broken JSON as an answer.
701
731
  let truncatedToolRetries = 0;
@@ -2254,6 +2284,10 @@ export async function runAgentTurn(prompt, options = {}) {
2254
2284
  /** E4: consecutive free-tier stream failures this turn. */
2255
2285
  let freeTierConsecutiveFailures = 0;
2256
2286
  let freeTierLargeContextWarned = false;
2287
+ // Surface the free-tier "failed N times / switch provider" advisory at most
2288
+ // once per turn — the recovery planner already narrates each retry, so
2289
+ // repeating this on every failure just adds noise.
2290
+ let freeTierAdvisoryShown = false;
2257
2291
  const summarizeForCompaction = async (summaryPrompt) => {
2258
2292
  const response = await completeWithProvider({
2259
2293
  provider,
@@ -2522,7 +2556,7 @@ export async function runAgentTurn(prompt, options = {}) {
2522
2556
  completion = await streamWithProvider({
2523
2557
  provider,
2524
2558
  model,
2525
- allowModelFallback: false,
2559
+ allowModelFallback,
2526
2560
  messages,
2527
2561
  temperature: /minimax-m3/i.test(model) ? 1.0 : 0.2,
2528
2562
  maxTokens: stepMaxTokens,
@@ -2663,36 +2697,67 @@ export async function runAgentTurn(prompt, options = {}) {
2663
2697
  }
2664
2698
  });
2665
2699
  freeTierConsecutiveFailures = 0;
2700
+ // Stream succeeded → the failure episode is over. Reset the recovery
2701
+ // budget and the one-shot fallback flag so a later, unrelated failure
2702
+ // starts fresh (and we never give up while making progress).
2703
+ resetStreamRecoveryState(recoveryState);
2704
+ allowModelFallback = false;
2666
2705
  }
2667
2706
  catch (streamError) {
2707
+ // User cancelled (double-Esc) — never try to recover, just stop.
2708
+ if (options.signal?.aborted)
2709
+ throw streamError;
2668
2710
  // E4: track free-tier failures for advisory notices (never blocks).
2669
2711
  freeTierConsecutiveFailures += 1;
2670
- for (const notice of freeTierGuardNotices({
2671
- provider,
2672
- estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
2673
- consecutiveFailures: freeTierConsecutiveFailures,
2674
- })) {
2675
- if (notice.includes("Large context"))
2676
- continue; // already shown above
2677
- writeNotice("warn", notice, chalk.yellow(` ⚠ ${notice}\n`));
2678
- }
2679
- // A fully empty model completion (no text, no tool calls) must not
2680
- // kill the turn. It shows up most after auto-compaction, when the
2681
- // tail ends on re-injected system context and the model has nothing
2682
- // to answer. Append a trailing user nudge (so the turn no longer
2683
- // ends on system messages) and retry, bounded like the
2684
- // successful-but-empty path below.
2685
- if (!options.signal?.aborted &&
2686
- isEmptyCompletionError(streamError) &&
2687
- emptyVisibleRetries < 3) {
2688
- emptyVisibleRetries += 1;
2689
- writeNotice("warn", "model streamed an empty response — nudging it to continue", chalk.yellow(" ⚠ model streamed an empty response — nudging it to continue\n"));
2690
- messages.push(recoveryUserMessage("Your previous response was empty. Continue the task now: emit your next tool call, " +
2691
- "or give your final answer if every required step is already complete and verified. " +
2692
- "Do not reply with an empty message."));
2693
- continue;
2712
+ if (!freeTierAdvisoryShown) {
2713
+ for (const notice of freeTierGuardNotices({
2714
+ provider,
2715
+ estimatedInputTokens: contextBreakdown.estimatedTotalTokens,
2716
+ consecutiveFailures: freeTierConsecutiveFailures,
2717
+ })) {
2718
+ if (notice.includes("Large context"))
2719
+ continue; // already shown above
2720
+ writeNotice("warn", notice, chalk.yellow(` ⚠ ${notice}\n`));
2721
+ freeTierAdvisoryShown = true;
2722
+ }
2723
+ }
2724
+ // Robust recovery: a single flaky provider/model (empty admission,
2725
+ // connection glitch, capacity 5xx, rate limit, or an oversized
2726
+ // request) must not kill the turn. Classify the failure and take a
2727
+ // bounded, escalating recovery step — back off, compact, drop
2728
+ // thinking, or let the router fall back to another provider/model.
2729
+ // We only rethrow (stop the turn) in the worst case: every approach
2730
+ // for that failure class is exhausted or the total budget is spent.
2731
+ const failureKind = classifyStreamFailure(streamError);
2732
+ const plan = planStreamRecovery({
2733
+ kind: failureKind,
2734
+ state: recoveryState,
2735
+ });
2736
+ if (plan.action === "give-up") {
2737
+ throw streamError;
2738
+ }
2739
+ recordRecoveryAttempt(recoveryState, failureKind);
2740
+ if (plan.notice) {
2741
+ writeNotice("warn", plan.notice, chalk.yellow(` ⚠ ${plan.notice}\n`));
2694
2742
  }
2695
- throw streamError;
2743
+ if (plan.disableThinking)
2744
+ retryWithoutThinking = true;
2745
+ if (plan.allowModelFallback)
2746
+ allowModelFallback = true;
2747
+ if (plan.forceCompact) {
2748
+ await maybeAutoCompact(`stream-recovery:${failureKind}`, true);
2749
+ }
2750
+ if (plan.nudge) {
2751
+ messages.push(recoveryUserMessage(plan.nudge));
2752
+ }
2753
+ if (plan.delayMs > 0) {
2754
+ emit({
2755
+ type: "status",
2756
+ text: `retrying in ${Math.ceil(plan.delayMs / 1000)}s (${failureKind})`,
2757
+ });
2758
+ await delay(plan.delayMs, options.signal);
2759
+ }
2760
+ continue;
2696
2761
  }
2697
2762
  }
2698
2763
  finally {