@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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +54 -46
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +2 -2
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/package.json +1 -1
- package/yeaft/config.js +3 -1
- package/yeaft/engine.js +130 -11
- package/yeaft/vp-status-broker.js +4 -3
- package/yeaft/web-bridge.js +23 -4
|
Binary file
|
package/package.json
CHANGED
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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: ${
|
|
4094
|
+
content: [{ type: 'text', text: `⚠️ Error: ${visibleErrMsg}` }],
|
|
4076
4095
|
},
|
|
4077
4096
|
}, envelope);
|
|
4078
4097
|
}
|