@yeaft/webchat-agent 1.0.261 → 1.0.262

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.261",
3
+ "version": "1.0.262",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -69,12 +69,14 @@ const DEFAULTS = {
69
69
  // • jitterRatio: ± random fraction applied to backoff; 0 disables.
70
70
  // • streamIdleTimeoutMs: per-SSE-chunk silence budget. 0 disables the
71
71
  // stalled-stream guard; every received chunk refreshes the budget.
72
+ // Keep the default below the normal 120s Session silence watchdog so
73
+ // the engine can cancel the stale response and issue a fresh request.
72
74
  llmRetry: {
73
75
  maxRetries: 3,
74
76
  baseDelayMs: 1_000,
75
77
  maxDelayMs: 30_000,
76
78
  jitterRatio: 0.25,
77
- streamIdleTimeoutMs: 20_000,
79
+ streamIdleTimeoutMs: 90_000,
78
80
  },
79
81
  };
80
82
 
package/yeaft/engine.js CHANGED
@@ -100,6 +100,9 @@ const RETRY_DEFAULTS = Object.freeze({
100
100
  jitterRatio: 0.25,
101
101
  });
102
102
 
103
+ const RETRY_CONTINUATION_PROMPT =
104
+ 'Continue from the exact point where the previous response stopped. Do not repeat text already produced.';
105
+
103
106
  // Accept legacy namespaced commands and Claude Code-style bare skill commands.
104
107
  // Project-tier skills are shown as /<skill-name>; /yeaft-skills:<name> and
105
108
  // /skill:<name> stay supported for globals, older clients, and typed history.
@@ -353,8 +356,8 @@ export function shouldAllowGroupReflection({
353
356
  * @typedef {{ type: 'consolidate', archivedCount: number, extractedCount: number }} ConsolidateEvent
354
357
  * @typedef {{ type: 'recall', entryCount: number, cached: boolean }} RecallEvent
355
358
  * @typedef {{ type: 'fallback', from: string, to: string, reason: string }} FallbackEvent
356
- * @typedef {{ type: 'llm_retry', attempt: number, maxRetries: number, delayMs: number, reason: 'rate_limit_retry_after'|'rate_limit_backoff'|'transient_backoff'|'stream_idle_timeout', errorName: string, statusCode: number|null, message: string }} LlmRetryEvent
357
- * @typedef {{ type: 'error', error: Error, retryable: boolean, reason?: 'stream_idle_timeout', retryExhausted?: boolean }} ErrorEvent
359
+ * @typedef {{ type: 'llm_retry', attempt: number, maxRetries: number, delayMs: number, reason: 'rate_limit_retry_after'|'rate_limit_backoff'|'transient_backoff'|'stream_idle_timeout', recoveryMode: 'restart'|'continue', errorName: string, statusCode: number|null, message: string }} LlmRetryEvent
360
+ * @typedef {{ type: 'error', error: Error, retryable: boolean, reason?: 'stream_idle_timeout', retryExhausted?: boolean, retryAttempts?: number, maxRetries?: number }} ErrorEvent
358
361
  *
359
362
  * @typedef {import('./llm/adapter.js').StreamEvent | TurnStartEvent | TurnEndEvent | ToolStartEvent | ToolEndEvent | ConsolidateEvent | RecallEvent | FallbackEvent | LlmRetryEvent | ErrorEvent} EngineEvent
360
363
  */
@@ -1860,10 +1863,27 @@ export class Engine {
1860
1863
  // The signal passed down to adapter.stream() + tool execution.
1861
1864
  const runSignal = abortCtrl.signal;
1862
1865
 
1866
+ const retryLifecycle = {
1867
+ pendingContinuation: null,
1868
+ lastPersistedPartial: null,
1869
+ };
1863
1870
  try {
1864
1871
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
1865
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName });
1872
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
1866
1873
  } finally {
1874
+ // Closing the async generator at a visible retry boundary means the
1875
+ // continuation never reached a provider. Keep it out of history and
1876
+ // terminate the accepted assistant prefix instead of leaving `retry`.
1877
+ if (retryLifecycle.pendingContinuation
1878
+ && retryLifecycle.lastPersistedPartial
1879
+ && typeof this.#conversationStore?.update === 'function') {
1880
+ const abortedPartial = this.#conversationStore.update(
1881
+ retryLifecycle.lastPersistedPartial,
1882
+ { stopReason: 'aborted' },
1883
+ );
1884
+ if (abortedPartial) retryLifecycle.lastPersistedPartial = abortedPartial;
1885
+ }
1886
+ retryLifecycle.pendingContinuation = null;
1867
1887
  if (signal) {
1868
1888
  try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
1869
1889
  }
@@ -1897,7 +1917,7 @@ export class Engine {
1897
1917
  * in a try/finally without indenting the whole loop.
1898
1918
  * @private
1899
1919
  */
1900
- async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null }) {
1920
+ async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, sessionTopics = null, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
1901
1921
 
1902
1922
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
1903
1923
  ? collabToolPolicy
@@ -2329,6 +2349,15 @@ export class Engine {
2329
2349
  // the previous iteration) cleanly ends the loop instead of
2330
2350
  // launching another adapter stream.
2331
2351
  if (signal?.aborted) {
2352
+ if (retryLifecycle.lastPersistedPartial
2353
+ && typeof this.#conversationStore?.update === 'function') {
2354
+ const abortedPartial = this.#conversationStore.update(
2355
+ retryLifecycle.lastPersistedPartial,
2356
+ { stopReason: 'aborted' },
2357
+ );
2358
+ if (abortedPartial) retryLifecycle.lastPersistedPartial = abortedPartial;
2359
+ }
2360
+ retryLifecycle.pendingContinuation = null;
2332
2361
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2333
2362
  yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2334
2363
  break;
@@ -2484,7 +2513,10 @@ export class Engine {
2484
2513
  // <yeaftDir>/memory/<scopeDir>/archive/tool-results/<id>.md so
2485
2514
  // message_trace can fetch it on demand. The stub keeps the
2486
2515
  // OpenAI/Anthropic toolCallId pairing intact.
2487
- let wireMessages = stripMetaForWire([...conversationMessages]);
2516
+ const pendingContinuationForRequest = retryLifecycle.pendingContinuation;
2517
+ let wireMessages = stripMetaForWire(pendingContinuationForRequest
2518
+ ? [...conversationMessages, pendingContinuationForRequest]
2519
+ : [...conversationMessages]);
2488
2520
 
2489
2521
  if (scenario !== 'work-item' && this.#yeaftDir && (this.#config?.archive?.toolResults !== false)) {
2490
2522
  try {
@@ -2553,6 +2585,18 @@ export class Engine {
2553
2585
  }
2554
2586
  }
2555
2587
 
2588
+ // Do not durably publish a model-only continuation until every
2589
+ // pre-request await is complete and the fresh provider request is about
2590
+ // to start. Stop at the visible loop boundary must leave no
2591
+ // continuation that the provider never received.
2592
+ if (signal?.aborted) throw new LLMAbortError();
2593
+ if (pendingContinuationForRequest
2594
+ && retryLifecycle.pendingContinuation === pendingContinuationForRequest) {
2595
+ this.#persistConversationMessage(pendingContinuationForRequest, { sessionId: runtimeSessionId });
2596
+ conversationMessages.push(pendingContinuationForRequest);
2597
+ retryLifecycle.pendingContinuation = null;
2598
+ }
2599
+
2556
2600
  // Snapshot task results carried by this exact request. Request start
2557
2601
  // is not delivery: fetch may remain pending and then be aborted before
2558
2602
  // the provider processes anything. Ack only after a normal stream end
@@ -2721,6 +2765,30 @@ export class Engine {
2721
2765
  });
2722
2766
  };
2723
2767
 
2768
+ const prepareRetryContinuation = () => {
2769
+ if (!responseText || toolCalls.length > 0) return null;
2770
+ const partialAssistant = {
2771
+ role: 'assistant',
2772
+ content: responseText,
2773
+ incomplete: true,
2774
+ responseKind: 'progress',
2775
+ };
2776
+ const persistedPartial = persistIncompleteAssistantOnce('retry');
2777
+ if (persistedPartial) {
2778
+ partialAssistant._persistedMessageId = persistedPartial.id;
2779
+ lastPersistedAssistantTextMessage = persistedPartial;
2780
+ retryLifecycle.lastPersistedPartial = persistedPartial;
2781
+ }
2782
+ conversationMessages.push(partialAssistant);
2783
+ fullResponseText += responseText;
2784
+ retryLifecycle.pendingContinuation = {
2785
+ role: 'user',
2786
+ content: RETRY_CONTINUATION_PROMPT,
2787
+ userAuthored: false,
2788
+ };
2789
+ return persistedPartial;
2790
+ };
2791
+
2724
2792
  // Abort/retry/fallback are not final assistant responses. Handle them
2725
2793
  // before writing debug loop rows; otherwise transient DeepSeek stream
2726
2794
  // cuts or user stops show up as bogus `Error: Request aborted` replies.
@@ -2729,7 +2797,18 @@ export class Engine {
2729
2797
  || err?.name === 'LLMAbortError'
2730
2798
  || (signal?.aborted && /abort/i.test(err?.message || ''));
2731
2799
  if (earlyIsAbort || signal?.aborted) {
2732
- persistIncompleteAssistantOnce('aborted');
2800
+ const abortedPartial = persistIncompleteAssistantOnce('aborted');
2801
+ if (abortedPartial) {
2802
+ retryLifecycle.lastPersistedPartial = abortedPartial;
2803
+ } else if (retryLifecycle.lastPersistedPartial
2804
+ && typeof this.#conversationStore?.update === 'function') {
2805
+ const updatedPartial = this.#conversationStore.update(
2806
+ retryLifecycle.lastPersistedPartial,
2807
+ { stopReason: 'aborted' },
2808
+ );
2809
+ if (updatedPartial) retryLifecycle.lastPersistedPartial = updatedPartial;
2810
+ }
2811
+ retryLifecycle.pendingContinuation = null;
2733
2812
  traceRequest('llm.request_abort', {
2734
2813
  durationMs: perfNowMs() - requestPerfStart,
2735
2814
  ok: false,
@@ -2770,7 +2849,12 @@ export class Engine {
2770
2849
 
2771
2850
  const earlyIsRateLimit = err instanceof LLMRateLimitError;
2772
2851
  const earlyIsTransient = err instanceof LLMServerError;
2773
- if (earlyIsRateLimit || earlyIsTransient) {
2852
+ // A completed tool_call has already crossed the streaming boundary to
2853
+ // the caller. Replaying that request would publish a duplicate call and
2854
+ // leave ambiguous execution ownership, so only pre-tool failures are
2855
+ // eligible for transparent retry or model fallback.
2856
+ const canReplayProviderRequest = toolCalls.length === 0;
2857
+ if ((earlyIsRateLimit || earlyIsTransient) && canReplayProviderRequest) {
2774
2858
  if (consecutiveRetryableErrors < retryPolicy.maxRetries) {
2775
2859
  consecutiveRetryableErrors += 1;
2776
2860
  let delayMs;
@@ -2787,20 +2871,37 @@ export class Engine {
2787
2871
  ? 'stream_idle_timeout'
2788
2872
  : 'transient_backoff';
2789
2873
  }
2874
+ // A retry is a brand-new provider request. Replaying the original
2875
+ // request after forwarding partial text duplicates that text in
2876
+ // the UI and can make the model restart its answer. Preserve the
2877
+ // accepted prefix as an incomplete assistant boundary, then ask
2878
+ // the next request to continue from it.
2879
+ const recoveryMode = responseText && toolCalls.length === 0 ? 'continue' : 'restart';
2790
2880
  endAttemptTrace('llm_retry');
2881
+ if (recoveryMode === 'continue') prepareRetryContinuation();
2791
2882
  yield {
2792
2883
  type: 'llm_retry',
2793
2884
  attempt: consecutiveRetryableErrors,
2794
2885
  maxRetries: retryPolicy.maxRetries,
2795
2886
  delayMs,
2796
2887
  reason,
2888
+ recoveryMode,
2797
2889
  errorName: err.name,
2798
2890
  statusCode: err.statusCode ?? null,
2799
2891
  message: String(err.message || '').slice(0, 300),
2800
2892
  };
2801
2893
  const slept = await sleepWithAbort(delayMs, signal);
2802
2894
  if (!slept || signal?.aborted) {
2803
- persistIncompleteAssistantOnce('aborted');
2895
+ const partial = persistIncompleteAssistantOnce('aborted');
2896
+ if (!partial && retryLifecycle.lastPersistedPartial
2897
+ && typeof this.#conversationStore?.update === 'function') {
2898
+ const abortedPartial = this.#conversationStore.update(
2899
+ retryLifecycle.lastPersistedPartial,
2900
+ { stopReason: 'aborted' },
2901
+ );
2902
+ if (abortedPartial) retryLifecycle.lastPersistedPartial = abortedPartial;
2903
+ }
2904
+ retryLifecycle.pendingContinuation = null;
2804
2905
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2805
2906
  yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2806
2907
  break;
@@ -2812,8 +2913,9 @@ export class Engine {
2812
2913
 
2813
2914
  const earlyFallbackModel = this.#config.fallbackModel;
2814
2915
  if (earlyFallbackModel && earlyFallbackModel !== currentModel
2815
- && (earlyIsRateLimit || earlyIsTransient)) {
2916
+ && (earlyIsRateLimit || earlyIsTransient) && canReplayProviderRequest) {
2816
2917
  endAttemptTrace('fallback_retry');
2918
+ prepareRetryContinuation();
2817
2919
  yield { type: 'fallback', from: currentModel, to: earlyFallbackModel, reason: err.message };
2818
2920
  currentModel = earlyFallbackModel;
2819
2921
  consecutiveRetryableErrors = 0;
@@ -2821,7 +2923,21 @@ export class Engine {
2821
2923
  continue;
2822
2924
  }
2823
2925
 
2824
- persistIncompleteAssistantOnce('error');
2926
+ // A later fresh request can fail before producing text. In that case the
2927
+ // durable assistant prefix belongs to this same query and must leave the
2928
+ // transient `retry` state even though the current attempt persisted none.
2929
+ const persistedErrorPartial = persistIncompleteAssistantOnce('error');
2930
+ if (persistedErrorPartial) {
2931
+ retryLifecycle.lastPersistedPartial = persistedErrorPartial;
2932
+ } else if (retryLifecycle.lastPersistedPartial
2933
+ && typeof this.#conversationStore?.update === 'function') {
2934
+ const errorPartial = this.#conversationStore.update(
2935
+ retryLifecycle.lastPersistedPartial,
2936
+ { stopReason: 'error' },
2937
+ );
2938
+ if (errorPartial) retryLifecycle.lastPersistedPartial = errorPartial;
2939
+ }
2940
+ retryLifecycle.pendingContinuation = null;
2825
2941
 
2826
2942
  this.#trace.endTurn(turnId, {
2827
2943
  model: currentModel,
@@ -2893,7 +3009,10 @@ export class Engine {
2893
3009
  };
2894
3010
  if (err instanceof LLMStreamIdleTimeoutError) {
2895
3011
  errorEvent.reason = 'stream_idle_timeout';
2896
- errorEvent.retryExhausted = consecutiveRetryableErrors >= retryPolicy.maxRetries;
3012
+ errorEvent.retryExhausted = canReplayProviderRequest
3013
+ && consecutiveRetryableErrors >= retryPolicy.maxRetries;
3014
+ errorEvent.retryAttempts = consecutiveRetryableErrors;
3015
+ errorEvent.maxRetries = retryPolicy.maxRetries;
2897
3016
  }
2898
3017
  yield errorEvent;
2899
3018
  yield { type: 'turn_end', turnNumber, stopReason: 'error', threadId };
@@ -11,18 +11,19 @@ export const VALID_STATES = new Set([
11
11
  'idle',
12
12
  'typing',
13
13
  'thinking',
14
+ 'retrying',
14
15
  'streaming',
15
16
  'tool',
16
17
  'error',
17
18
  ]);
18
19
 
19
- const RUNNING_STATES = new Set(['typing', 'thinking', 'streaming', 'tool']);
20
+ const RUNNING_STATES = new Set(['typing', 'thinking', 'retrying', 'streaming', 'tool']);
20
21
 
21
22
  export function isVpStatusRunning(state) {
22
23
  return RUNNING_STATES.has(state || 'idle');
23
24
  }
24
25
 
25
- const STATE_PRIORITY = ['tool', 'streaming', 'thinking', 'typing', 'error', 'idle'];
26
+ const STATE_PRIORITY = ['tool', 'streaming', 'retrying', 'thinking', 'typing', 'error', 'idle'];
26
27
  const MAX_RETAINED_THREADS_PER_VP = 20;
27
28
  const COMPLETED_TTL_MS = 30 * 60 * 1000;
28
29
 
@@ -52,7 +53,7 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
52
53
  }
53
54
  }
54
55
  if (rows.some(r => RUNNING_STATES.has(r.state))) {
55
- for (const candidate of ['tool', 'streaming', 'thinking', 'typing']) {
56
+ for (const candidate of ['tool', 'streaming', 'retrying', 'thinking', 'typing']) {
56
57
  if (rows.some(r => r.state === candidate)) { state = candidate; break; }
57
58
  }
58
59
  }
@@ -593,7 +593,7 @@ function createThreadId() {
593
593
  return `thr_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
594
594
  }
595
595
 
596
- const RUNNING_THREAD_STATES = new Set(['queued', 'typing', 'thinking', 'streaming', 'tool']);
596
+ const RUNNING_THREAD_STATES = new Set(['queued', 'typing', 'thinking', 'retrying', 'streaming', 'tool']);
597
597
  /** @type {Map<string, Map<string, object>>} */
598
598
  const vpThreads = new Map();
599
599
  /** @type {Map<string, Set<Promise<string|null>>>} */
@@ -3868,6 +3868,7 @@ function handleEngineEvent(event, hctx) {
3868
3868
  break;
3869
3869
 
3870
3870
  case 'llm_retry':
3871
+ maybeTransitionVpStatus(hctx, 'retrying');
3871
3872
  // Engine paused before re-issuing the same turn because the LLM
3872
3873
  // returned a retryable error (rate limit / 5xx / transient network /
3873
3874
  // stream idle timeout). Surface to the client so the UI can show
@@ -3879,6 +3880,7 @@ function handleEngineEvent(event, hctx) {
3879
3880
  maxRetries: event.maxRetries,
3880
3881
  delayMs: event.delayMs,
3881
3882
  reason: event.reason,
3883
+ recoveryMode: event.recoveryMode || 'restart',
3882
3884
  errorName: event.errorName,
3883
3885
  statusCode: event.statusCode,
3884
3886
  message: event.message,
@@ -4045,15 +4047,32 @@ function handleEngineEvent(event, hctx) {
4045
4047
  break;
4046
4048
 
4047
4049
  case 'error': {
4050
+ // Engine retry exhaustion is an event terminal, not a thrown exception.
4051
+ // Move the broker out of its running-only `retrying` state here so
4052
+ // reconnect snapshots cannot resurrect a finished Session as active.
4053
+ maybeTransitionVpStatus(hctx, 'error');
4048
4054
  const errMsg = event.error?.message || 'Unknown error';
4049
- hctx.lastEngineErrorDetail = { message: errMsg };
4055
+ const retryAttempts = Number.isFinite(event.retryAttempts) ? event.retryAttempts : 0;
4056
+ const exhaustedIdle = event.reason === 'stream_idle_timeout' && event.retryExhausted;
4057
+ const visibleErrMsg = exhaustedIdle && retryAttempts > 0
4058
+ ? `${errMsg} after ${retryAttempts} fresh request retries`
4059
+ : errMsg;
4060
+ hctx.lastEngineErrorDetail = {
4061
+ message: visibleErrMsg,
4062
+ ...(event.reason ? { reason: event.reason } : {}),
4063
+ ...(event.retryExhausted !== undefined ? { retryExhausted: !!event.retryExhausted } : {}),
4064
+ ...(Number.isFinite(event.retryAttempts) ? { retryAttempts: event.retryAttempts } : {}),
4065
+ ...(Number.isFinite(event.maxRetries) ? { maxRetries: event.maxRetries } : {}),
4066
+ };
4050
4067
  sendSessionEvent({
4051
4068
  type: 'error',
4052
- message: errMsg,
4069
+ message: visibleErrMsg,
4053
4070
  errorName: event.error?.name || null,
4054
4071
  retryable: !!event.retryable,
4055
4072
  ...(event.reason ? { reason: event.reason } : {}),
4056
4073
  ...(event.retryExhausted !== undefined ? { retryExhausted: !!event.retryExhausted } : {}),
4074
+ ...(Number.isFinite(event.retryAttempts) ? { retryAttempts: event.retryAttempts } : {}),
4075
+ ...(Number.isFinite(event.maxRetries) ? { maxRetries: event.maxRetries } : {}),
4057
4076
  }, envelope);
4058
4077
  if (isPermissionErrorMsg(errMsg)) {
4059
4078
  if (!_permissionDiagnosticSent) {
@@ -4072,7 +4091,7 @@ function handleEngineEvent(event, hctx) {
4072
4091
  sendSessionOutputFrame({
4073
4092
  type: 'assistant',
4074
4093
  message: {
4075
- content: [{ type: 'text', text: `⚠️ Error: ${errMsg}` }],
4094
+ content: [{ type: 'text', text: `⚠️ Error: ${visibleErrMsg}` }],
4076
4095
  },
4077
4096
  }, envelope);
4078
4097
  }