@yeaft/webchat-agent 0.1.1104 → 0.1.1107

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/yeaft/engine.js CHANGED
@@ -2294,18 +2294,139 @@ export class Engine {
2294
2294
  stopReason = event.stopReason;
2295
2295
  yield event;
2296
2296
  break;
2297
- case 'error':
2298
- yield event;
2299
- break;
2297
+ case 'error': {
2298
+ const adapterError = event.error instanceof Error
2299
+ ? event.error
2300
+ : new Error(String(event.error?.message || event.error || 'LLM stream error'));
2301
+ adapterError.retryable = Boolean(event.retryable);
2302
+ if (event.retryable) {
2303
+ if (adapterError instanceof LLMRateLimitError || adapterError instanceof LLMServerError) {
2304
+ throw adapterError;
2305
+ }
2306
+ throw new LLMServerError(adapterError.message, adapterError.statusCode ?? 0);
2307
+ }
2308
+ if (adapterError instanceof LLMRateLimitError || adapterError instanceof LLMServerError) {
2309
+ const nonRetryableError = new Error(adapterError.message);
2310
+ nonRetryableError.name = adapterError.name || 'LLMStreamError';
2311
+ nonRetryableError.code = adapterError.code;
2312
+ nonRetryableError.statusCode = adapterError.statusCode;
2313
+ nonRetryableError.retryable = false;
2314
+ throw nonRetryableError;
2315
+ }
2316
+ throw adapterError;
2317
+ }
2300
2318
  }
2301
2319
  }
2302
2320
  // Stream completed without throwing — reset the retry counter so
2303
- // the next turn starts with a clean budget. Note: this includes
2304
- // stream() that emitted an in-band `error` event (server didn't
2305
- // throw), since those are policy-specific and not transport-level.
2321
+ // the next turn starts with a clean budget. In-band adapter errors
2322
+ // are converted to throws above so they share the real error path.
2306
2323
  consecutiveRetryableErrors = 0;
2307
2324
  } catch (err) {
2308
2325
  const latencyMs = Date.now() - startTime;
2326
+
2327
+ const endAttemptTrace = (attemptStopReason) => {
2328
+ this.#trace.endTurn(turnId, {
2329
+ model: currentModel,
2330
+ inputTokens: totalUsage.inputTokens,
2331
+ outputTokens: totalUsage.outputTokens,
2332
+ cacheReadTokens: totalUsage.cacheReadTokens,
2333
+ cacheWriteTokens: totalUsage.cacheWriteTokens,
2334
+ stopReason: attemptStopReason,
2335
+ latencyMs,
2336
+ responseText,
2337
+ systemPrompt,
2338
+ messages: conversationMessages.map(mapDebugMessage),
2339
+ toolCalls: toolCalls.map(tc => ({ id: tc.id, name: tc.name, input: tc.input })),
2340
+ usage: {
2341
+ inputTokens: totalUsage.inputTokens || 0,
2342
+ outputTokens: totalUsage.outputTokens || 0,
2343
+ cacheReadTokens: totalUsage.cacheReadTokens || 0,
2344
+ cacheWriteTokens: totalUsage.cacheWriteTokens || 0,
2345
+ totalInputTokens: (totalUsage.inputTokens || 0) + (totalUsage.cacheInputDeltaTokens || 0),
2346
+ totalTokens: (totalUsage.inputTokens || 0) + (totalUsage.cacheInputDeltaTokens || 0) + (totalUsage.outputTokens || 0),
2347
+ },
2348
+ ttfbMs,
2349
+ rawRequest,
2350
+ rawResponse,
2351
+ });
2352
+ };
2353
+
2354
+ // Abort/retry/fallback are not final assistant responses. Handle them
2355
+ // before writing debug loop rows; otherwise transient DeepSeek stream
2356
+ // cuts or user stops show up as bogus `Error: Request aborted` replies.
2357
+ const earlyIsAbort = err instanceof LLMAbortError
2358
+ || err?.name === 'AbortError'
2359
+ || err?.name === 'LLMAbortError'
2360
+ || (signal?.aborted && /abort/i.test(err?.message || ''));
2361
+ if (earlyIsAbort || signal?.aborted) {
2362
+ endAttemptTrace('aborted');
2363
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2364
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2365
+ break;
2366
+ }
2367
+
2368
+ if (err instanceof LLMContextError && this.#conversationStore) {
2369
+ const retryConsolidated = await this.#maybeConsolidate();
2370
+ if (retryConsolidated && retryConsolidated.archivedCount > 0) {
2371
+ endAttemptTrace('context_overflow_retry');
2372
+ yield { type: 'consolidate', archivedCount: retryConsolidated.archivedCount, extractedCount: retryConsolidated.extractedCount };
2373
+ yield { type: 'turn_end', turnNumber, stopReason: 'context_overflow_retry', threadId };
2374
+ continue;
2375
+ }
2376
+ }
2377
+
2378
+ const earlyIsRateLimit = err instanceof LLMRateLimitError;
2379
+ const earlyIsTransient = err instanceof LLMServerError;
2380
+ if (earlyIsRateLimit || earlyIsTransient) {
2381
+ if (consecutiveRetryableErrors < retryPolicy.maxRetries) {
2382
+ consecutiveRetryableErrors += 1;
2383
+ let delayMs;
2384
+ let reason;
2385
+ if (earlyIsRateLimit && Number.isFinite(err.retryAfterMs) && err.retryAfterMs > 0) {
2386
+ delayMs = Math.min(retryPolicy.maxDelayMs, err.retryAfterMs);
2387
+ reason = 'rate_limit_retry_after';
2388
+ } else if (earlyIsRateLimit) {
2389
+ delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors);
2390
+ reason = 'rate_limit_backoff';
2391
+ } else {
2392
+ delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors - 1);
2393
+ reason = err instanceof LLMStreamIdleTimeoutError
2394
+ ? 'stream_idle_timeout'
2395
+ : 'transient_backoff';
2396
+ }
2397
+ endAttemptTrace('llm_retry');
2398
+ yield {
2399
+ type: 'llm_retry',
2400
+ attempt: consecutiveRetryableErrors,
2401
+ maxRetries: retryPolicy.maxRetries,
2402
+ delayMs,
2403
+ reason,
2404
+ errorName: err.name,
2405
+ statusCode: err.statusCode ?? null,
2406
+ message: String(err.message || '').slice(0, 300),
2407
+ };
2408
+ const slept = await sleepWithAbort(delayMs, signal);
2409
+ if (!slept || signal?.aborted) {
2410
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2411
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2412
+ break;
2413
+ }
2414
+ yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
2415
+ continue;
2416
+ }
2417
+ }
2418
+
2419
+ const earlyFallbackModel = this.#config.fallbackModel;
2420
+ if (earlyFallbackModel && earlyFallbackModel !== currentModel
2421
+ && (earlyIsRateLimit || earlyIsTransient)) {
2422
+ endAttemptTrace('fallback_retry');
2423
+ yield { type: 'fallback', from: currentModel, to: earlyFallbackModel, reason: err.message };
2424
+ currentModel = earlyFallbackModel;
2425
+ consecutiveRetryableErrors = 0;
2426
+ yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry', threadId };
2427
+ continue;
2428
+ }
2429
+
2309
2430
  this.#trace.endTurn(turnId, {
2310
2431
  model: currentModel,
2311
2432
  inputTokens: totalUsage.inputTokens,
@@ -2367,96 +2488,6 @@ export class Engine {
2367
2488
  rawResponse,
2368
2489
  };
2369
2490
 
2370
- // ─── task-325a: abort short-circuit ────────────────
2371
- // If the adapter threw LLMAbortError, or the signal fired during
2372
- // stream() (fetch throws AbortError / DOMException), we converge
2373
- // the state machine on the 'aborted' terminal state — no retry,
2374
- // no fallback, no persistence. One `aborted` event + one
2375
- // `turn_end` with stopReason='aborted' and we're done.
2376
- const isAbort = err instanceof LLMAbortError
2377
- || err?.name === 'AbortError'
2378
- || err?.name === 'LLMAbortError'
2379
- || (signal?.aborted && /abort/i.test(err?.message || ''));
2380
- if (isAbort || signal?.aborted) {
2381
- yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2382
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2383
- break;
2384
- }
2385
-
2386
- // ─── LLMContextError → force compact → retry ──────
2387
- if (err instanceof LLMContextError && this.#conversationStore) {
2388
- const consolidated = await this.#maybeConsolidate();
2389
- if (consolidated && consolidated.archivedCount > 0) {
2390
- yield { type: 'consolidate', archivedCount: consolidated.archivedCount, extractedCount: consolidated.extractedCount };
2391
- yield { type: 'turn_end', turnNumber, stopReason: 'context_overflow_retry', threadId };
2392
- continue; // retry with fewer messages
2393
- }
2394
- }
2395
-
2396
- // ─── Rate-limit / transient / stream-idle retry ───
2397
- // Honour server-supplied Retry-After for 429/529; fall back to
2398
- // exponential backoff for 5xx, transport failures, and stream-idle
2399
- // timeouts wrapped as LLMServerError. Counts against
2400
- // retryPolicy.maxRetries; on exhaustion we fall through to the
2401
- // fallback-model path (and ultimately the error event) without
2402
- // further waiting.
2403
- const isRateLimit = err instanceof LLMRateLimitError;
2404
- const isTransient = err instanceof LLMServerError;
2405
- if (isRateLimit || isTransient) {
2406
- if (consecutiveRetryableErrors < retryPolicy.maxRetries) {
2407
- consecutiveRetryableErrors += 1;
2408
- let delayMs;
2409
- let reason;
2410
- if (isRateLimit && Number.isFinite(err.retryAfterMs) && err.retryAfterMs > 0) {
2411
- // Server told us exactly when to come back. Respect it,
2412
- // but still cap to maxDelayMs so a misconfigured upstream
2413
- // can't make us hang forever on one turn.
2414
- delayMs = Math.min(retryPolicy.maxDelayMs, err.retryAfterMs);
2415
- reason = 'rate_limit_retry_after';
2416
- } else if (isRateLimit) {
2417
- // No header — use backoff but start one step higher so the
2418
- // first retry isn't immediate (rate-limit windows are
2419
- // usually >= 1s wide).
2420
- delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors);
2421
- reason = 'rate_limit_backoff';
2422
- } else {
2423
- delayMs = computeBackoffDelay(retryPolicy, consecutiveRetryableErrors - 1);
2424
- reason = err instanceof LLMStreamIdleTimeoutError
2425
- ? 'stream_idle_timeout'
2426
- : 'transient_backoff';
2427
- }
2428
- yield {
2429
- type: 'llm_retry',
2430
- attempt: consecutiveRetryableErrors,
2431
- maxRetries: retryPolicy.maxRetries,
2432
- delayMs,
2433
- reason,
2434
- errorName: err.name,
2435
- statusCode: err.statusCode ?? null,
2436
- message: String(err.message || '').slice(0, 300),
2437
- };
2438
- const slept = await sleepWithAbort(delayMs, signal);
2439
- if (!slept || signal?.aborted) {
2440
- yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2441
- yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2442
- break;
2443
- }
2444
- yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
2445
- continue; // retry the same turn with the same model
2446
- }
2447
- // Exhausted — fall through to fallback-model / error paths.
2448
- }
2449
-
2450
- // ─── Fallback model ──────────────────────────────
2451
- const fallbackModel = this.#config.fallbackModel;
2452
- if (fallbackModel && fallbackModel !== currentModel &&
2453
- (err instanceof LLMRateLimitError || err instanceof LLMServerError)) {
2454
- yield { type: 'fallback', from: currentModel, to: fallbackModel, reason: err.message };
2455
- currentModel = fallbackModel;
2456
- consecutiveRetryableErrors = 0; // new model, fresh retry budget
2457
- yield { type: 'turn_end', turnNumber, stopReason: 'fallback_retry', threadId };
2458
- continue; // retry with fallback model
2459
- }
2460
2491
 
2461
2492
  const isRetryableError = err instanceof LLMRateLimitError || err instanceof LLMServerError;
2462
2493
  const errorEvent = {
@@ -240,12 +240,17 @@ export function retryAfterFromResponse(response) {
240
240
  * prove from the error shape.
241
241
  *
242
242
  * @param {unknown} err
243
- * @param {{ providerLabel?: string }} [opts]
243
+ * @param {{ providerLabel?: string, signal?: AbortSignal }} [opts]
244
244
  * @returns {Error}
245
245
  */
246
246
  export function classifyFetchError(err, opts = {}) {
247
247
  if (!(err instanceof Error)) return err instanceof Object ? err : new Error(String(err));
248
- if (err.name === 'AbortError' || err.name === 'LLMAbortError') return new LLMAbortError();
248
+ if (err.name === 'LLMAbortError' || err instanceof LLMAbortError) return new LLMAbortError();
249
+ const label = opts.providerLabel ? `${opts.providerLabel}: ` : '';
250
+ if (err.name === 'AbortError') {
251
+ if (opts.signal?.aborted) return new LLMAbortError();
252
+ return new LLMServerError(`${label}stream aborted unexpectedly: ${err.message || 'AbortError'}`, 0);
253
+ }
249
254
  // Anything already classified: keep as-is.
250
255
  if (err instanceof LLMRateLimitError
251
256
  || err instanceof LLMAuthError
@@ -254,7 +259,6 @@ export function classifyFetchError(err, opts = {}) {
254
259
  || err instanceof LLMAbortError) {
255
260
  return err;
256
261
  }
257
- const label = opts.providerLabel ? `${opts.providerLabel}: ` : '';
258
262
  const code = err.cause?.code || err.code || null;
259
263
  const transientCodes = new Set([
260
264
  'ECONNRESET', 'ECONNREFUSED', 'ECONNABORTED', 'ETIMEDOUT',
@@ -34,10 +34,10 @@ function thinkingV1Enabled() {
34
34
  return process.env.YEAFT_THINKING_V1 === '1';
35
35
  }
36
36
 
37
- function applyAnthropicThinking(body, model, effort) {
38
- const cap = getThinkingCapability(model);
37
+ function applyAnthropicThinking(body, model, effort, effortContext = {}) {
38
+ const cap = getThinkingCapability(model, effortContext);
39
39
  if (!cap.supportsThinking) return;
40
- if (!getModelEffortOptions(model).includes(effort)) return;
40
+ if (!getModelEffortOptions(model, effortContext).includes(effort)) return;
41
41
 
42
42
  if (cap.thinkingProtocol === 'anthropic-adaptive') {
43
43
  body.thinking = { type: 'adaptive' };
@@ -59,6 +59,34 @@ function applyAnthropicThinking(body, model, effort) {
59
59
  const DEFAULT_BASE_URL = 'https://api.anthropic.com';
60
60
  const API_VERSION = '2023-06-01';
61
61
 
62
+ function hasNonEmptyText(value) {
63
+ return typeof value === 'string' && value.trim().length > 0;
64
+ }
65
+
66
+ function translateUserContent(content) {
67
+ if (hasNonEmptyText(content)) return content;
68
+
69
+ if (Array.isArray(content)) {
70
+ const parts = [];
71
+ for (const part of content) {
72
+ if (!part || typeof part !== 'object') {
73
+ if (hasNonEmptyText(part)) parts.push({ type: 'text', text: String(part) });
74
+ continue;
75
+ }
76
+ if (part.type === 'text') {
77
+ if (hasNonEmptyText(part.text)) parts.push(part);
78
+ continue;
79
+ }
80
+ // Non-text blocks (image/document/tool_result-like compatible payloads)
81
+ // are meaningful even without a text field. Preserve them verbatim.
82
+ parts.push(part);
83
+ }
84
+ return parts.length > 0 ? parts : null;
85
+ }
86
+
87
+ return null;
88
+ }
89
+
62
90
  /**
63
91
  * AnthropicAdapter — Talks to Anthropic Messages API.
64
92
  */
@@ -116,7 +144,8 @@ export class AnthropicAdapter extends LLMAdapter {
116
144
  for (const msg of messages) {
117
145
  if (msg.role === 'system') continue; // system goes separately
118
146
  if (msg.role === 'user') {
119
- result.push({ role: 'user', content: msg.content });
147
+ const content = translateUserContent(msg.content);
148
+ if (content) result.push({ role: 'user', content });
120
149
  } else if (msg.role === 'assistant') {
121
150
  const content = [];
122
151
  // task-327d: Anthropic requires thinking blocks to appear BEFORE
@@ -137,7 +166,7 @@ export class AnthropicAdapter extends LLMAdapter {
137
166
  }
138
167
  }
139
168
  }
140
- if (msg.content) {
169
+ if (hasNonEmptyText(msg.content)) {
141
170
  content.push({ type: 'text', text: msg.content });
142
171
  }
143
172
  if (msg.toolCalls) {
@@ -150,7 +179,7 @@ export class AnthropicAdapter extends LLMAdapter {
150
179
  });
151
180
  }
152
181
  }
153
- result.push({ role: 'assistant', content });
182
+ if (content.length > 0) result.push({ role: 'assistant', content });
154
183
  } else if (msg.role === 'tool') {
155
184
  // Anthropic requires all tool_results from the same turn in a single
156
185
  // user message. Merge consecutive tool messages into one.
@@ -207,10 +236,10 @@ export class AnthropicAdapter extends LLMAdapter {
207
236
  }
208
237
 
209
238
  /**
210
- * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', signal?: AbortSignal }} params
239
+ * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', effortContext?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
211
240
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
212
241
  */
213
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, signal, onRawExchange }) {
242
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext, signal, onRawExchange }) {
214
243
  if (signal?.aborted) throw new LLMAbortError();
215
244
 
216
245
  const body = {
@@ -226,7 +255,7 @@ export class AnthropicAdapter extends LLMAdapter {
226
255
  // models use budget_tokens. Unsupported combinations silently drop effort.
227
256
  const normEffort = normalizeEffort(effort);
228
257
  if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
229
- applyAnthropicThinking(body, model, normEffort);
258
+ applyAnthropicThinking(body, model, normEffort, effortContext);
230
259
  }
231
260
 
232
261
  const translatedTools = this.#translateTools(tools);
@@ -249,7 +278,7 @@ export class AnthropicAdapter extends LLMAdapter {
249
278
  signal,
250
279
  });
251
280
  } catch (err) {
252
- throw classifyFetchError(err, { providerLabel: 'Anthropic' });
281
+ throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
253
282
  }
254
283
 
255
284
  if (!response.ok) {
@@ -291,6 +320,8 @@ export class AnthropicAdapter extends LLMAdapter {
291
320
  const rawSseBodyChunks = [];
292
321
  const responseHeaders = safeHeaders(response);
293
322
  const responseStatus = response.status;
323
+ let sawStop = false;
324
+ let sawMessageStart = false;
294
325
 
295
326
  try {
296
327
  while (true) {
@@ -310,7 +341,10 @@ export class AnthropicAdapter extends LLMAdapter {
310
341
  for (const line of lines) {
311
342
  if (!line.startsWith('data: ')) continue;
312
343
  const data = line.slice(6).trim();
313
- if (data === '[DONE]') continue;
344
+ if (data === '[DONE]') {
345
+ sawStop = true;
346
+ continue;
347
+ }
314
348
 
315
349
  let event;
316
350
  try {
@@ -410,6 +444,7 @@ export class AnthropicAdapter extends LLMAdapter {
410
444
  } else if (type === 'message_delta') {
411
445
  const stopReason = event.delta?.stop_reason;
412
446
  if (stopReason) {
447
+ sawStop = true;
413
448
  yield {
414
449
  type: 'stop',
415
450
  stopReason: this.#mapStopReason(stopReason),
@@ -423,7 +458,10 @@ export class AnthropicAdapter extends LLMAdapter {
423
458
  outputTokens: event.usage.output_tokens || 0,
424
459
  };
425
460
  }
461
+ } else if (type === 'message_stop') {
462
+ sawStop = true;
426
463
  } else if (type === 'message_start') {
464
+ sawMessageStart = true;
427
465
  // Usage from message_start
428
466
  if (event.message?.usage) {
429
467
  yield {
@@ -443,8 +481,11 @@ export class AnthropicAdapter extends LLMAdapter {
443
481
  }
444
482
  }
445
483
  }
484
+ if (sawMessageStart && !sawStop) {
485
+ throw new LLMServerError('Anthropic stream ended before stop event', 0);
486
+ }
446
487
  } catch (err) {
447
- throw classifyFetchError(err, { providerLabel: 'Anthropic' });
488
+ throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
448
489
  } finally {
449
490
  reader.releaseLock();
450
491
  // Emit raw exchange after stream completes (or errors). Body is the
@@ -473,7 +514,7 @@ export class AnthropicAdapter extends LLMAdapter {
473
514
  * models silently drop the param. max_tokens auto-widens to budget+1024
474
515
  * when needed.
475
516
  */
476
- async call({ model, system, messages, maxTokens = 4096, effort, effortSource, signal }) {
517
+ async call({ model, system, messages, maxTokens = 4096, effort, effortSource, effortContext, signal }) {
477
518
  if (signal?.aborted) throw new LLMAbortError();
478
519
 
479
520
  const body = {
@@ -486,7 +527,7 @@ export class AnthropicAdapter extends LLMAdapter {
486
527
  // task-327c: mirror stream()'s thinking injection for side queries.
487
528
  const normEffort = normalizeEffort(effort);
488
529
  if ((thinkingV1Enabled() || effortSource === 'user') && normEffort) {
489
- applyAnthropicThinking(body, model, normEffort);
530
+ applyAnthropicThinking(body, model, normEffort, effortContext);
490
531
  }
491
532
 
492
533
  let response;
@@ -498,7 +539,7 @@ export class AnthropicAdapter extends LLMAdapter {
498
539
  signal,
499
540
  });
500
541
  } catch (err) {
501
- throw classifyFetchError(err, { providerLabel: 'Anthropic' });
542
+ throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
502
543
  }
503
544
 
504
545
  if (!response.ok) {
@@ -17,7 +17,7 @@
17
17
  * - response.function_call_arguments.delta / .done
18
18
  * - response.completed (contains final response.usage)
19
19
  * - response.incomplete (e.g. max_output_tokens)
20
- * - response.error
20
+ * - response.failed
21
21
  * - Usage: only in terminal completed/incomplete events
22
22
  *
23
23
  * Id contract (agreed with PM):
@@ -292,8 +292,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
292
292
  signal,
293
293
  });
294
294
  } catch (err) {
295
- if (err.name === 'AbortError') throw new LLMAbortError();
296
- throw classifyFetchError(err, { providerLabel: 'OpenAI' });
295
+ throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
297
296
  }
298
297
 
299
298
  if (!response.ok) {
@@ -332,6 +331,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
332
331
  const rawSseBodyChunks = [];
333
332
  const responseHeaders = safeHeaders(response);
334
333
  const responseStatus = response.status;
334
+ let sawTerminalEvent = false;
335
335
 
336
336
  try {
337
337
  while (true) {
@@ -353,7 +353,8 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
353
353
  const line = rawLine.trimEnd();
354
354
  if (!line.startsWith('data:')) continue;
355
355
  const data = line.slice(5).trim();
356
- if (!data || data === '[DONE]') continue;
356
+ if (!data) continue;
357
+ if (data === '[DONE]') continue;
357
358
 
358
359
  let event;
359
360
  try {
@@ -409,6 +410,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
409
410
  toolCallAccum.delete(idx);
410
411
  }
411
412
  } else if (type === 'response.completed' || type === 'response.incomplete') {
413
+ sawTerminalEvent = true;
412
414
  const respObj = event.response || {};
413
415
 
414
416
  // Fallback: flush any function_call items in the final output that we
@@ -449,17 +451,26 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
449
451
  type: 'stop',
450
452
  stopReason: this.#mapStopReason(respObj, sawToolCall),
451
453
  };
452
- } else if (type === 'response.error') {
453
- // Let the engine decide; emit error event
454
- const message = event.error?.message || event.message || 'response.error';
455
- yield { type: 'error', error: new Error(message), retryable: false };
454
+ } else if (type === 'response.failed' || type === 'response.error') {
455
+ sawTerminalEvent = true;
456
+ // Let the engine decide; emit error event. `response.failed` is
457
+ // the official terminal event; keep `response.error` as a legacy
458
+ // compatibility alias for older mocks/proxies.
459
+ const errObj = event.response?.error || event.error || {};
460
+ const code = errObj.code || event.response?.status || type;
461
+ const message = errObj.message || event.message || `${type}: ${code}`;
462
+ const failure = new Error(message);
463
+ failure.code = code;
464
+ yield { type: 'error', error: failure, retryable: false };
456
465
  }
457
466
  // Other semantic events (output_item.done, content_part.added, etc.) are ignored.
458
467
  }
459
468
  }
469
+ if (!sawTerminalEvent) {
470
+ throw new LLMServerError('OpenAI stream ended before terminal event', 0);
471
+ }
460
472
  } catch (err) {
461
- if (err?.name === 'AbortError') throw new LLMAbortError();
462
- throw classifyFetchError(err, { providerLabel: 'OpenAI' });
473
+ throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
463
474
  } finally {
464
475
  try { reader.releaseLock(); } catch { /* noop */ }
465
476
  // Emit raw exchange after stream completes (or errors). Body is the
@@ -523,8 +534,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
523
534
  signal,
524
535
  });
525
536
  } catch (err) {
526
- if (err.name === 'AbortError') throw new LLMAbortError();
527
- throw classifyFetchError(err, { providerLabel: 'OpenAI' });
537
+ throw classifyFetchError(err, { providerLabel: 'OpenAI', signal });
528
538
  }
529
539
 
530
540
  if (!response.ok) {
@@ -552,9 +552,16 @@ export class AdapterRouter extends LLMAdapter {
552
552
  */
553
553
  async *stream(params) {
554
554
  const resolved = await this.#resolveAdapter(params.model);
555
+ const effortContext = {
556
+ protocol: resolved.protocol,
557
+ supportsEffort: resolved.entry?.supportsEffort,
558
+ effortOptions: resolved.entry?.effortOptions,
559
+ thinkingProtocol: resolved.entry?.thinkingProtocol,
560
+ maxBudgetTokens: resolved.entry?.maxBudgetTokens,
561
+ };
555
562
  const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
556
563
  const sanitized = sanitizeMessagesForWire(filtered);
557
- yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId });
564
+ yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext });
558
565
  }
559
566
 
560
567
  /**
@@ -565,9 +572,16 @@ export class AdapterRouter extends LLMAdapter {
565
572
  */
566
573
  async call(params) {
567
574
  const resolved = await this.#resolveAdapter(params.model);
575
+ const effortContext = {
576
+ protocol: resolved.protocol,
577
+ supportsEffort: resolved.entry?.supportsEffort,
578
+ effortOptions: resolved.entry?.effortOptions,
579
+ thinkingProtocol: resolved.entry?.thinkingProtocol,
580
+ maxBudgetTokens: resolved.entry?.maxBudgetTokens,
581
+ };
568
582
  const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
569
583
  const sanitized = sanitizeMessagesForWire(filtered);
570
- return resolved.adapter.call({ ...sanitized, model: resolved.modelId });
584
+ return resolved.adapter.call({ ...sanitized, model: resolved.modelId, effortContext });
571
585
  }
572
586
 
573
587
  /**
package/yeaft/models.js CHANGED
@@ -537,8 +537,13 @@ export function thinkingBudgetForEffort(model, effort) {
537
537
  */
538
538
  export function getThinkingCapability(model, context = {}) {
539
539
  const info = MODEL_REGISTRY.get(model);
540
+ const modelId = parseModelRef(model).modelId;
540
541
  const overrideOptions = normalizeEffortOptions(context.effortOptions);
541
- const overrideProtocol = context.thinkingProtocol || (context.protocol === 'anthropic' ? 'anthropic' : context.protocol === 'openai-responses' ? 'openai-reasoning' : null);
542
+ const overrideProtocol = context.thinkingProtocol || (
543
+ context.protocol === 'anthropic'
544
+ ? (/^deepseek/i.test(modelId) ? 'anthropic-adaptive' : 'anthropic')
545
+ : context.protocol === 'openai-responses' ? 'openai-reasoning' : null
546
+ );
542
547
  if (context.supportsEffort === true || overrideOptions) {
543
548
  return {
544
549
  supportsThinking: true,
@@ -560,7 +565,15 @@ export function getThinkingCapability(model, context = {}) {
560
565
  };
561
566
  }
562
567
  if (context.protocol === 'anthropic' && inferred?.thinkingProtocol === 'openai-reasoning') {
563
- inferred = null;
568
+ if (/^deepseek/i.test(modelId)) {
569
+ inferred = {
570
+ ...inferred,
571
+ thinkingProtocol: 'anthropic-adaptive',
572
+ effortOptions: ANTHROPIC_ADAPTIVE_EFFORT_OPTIONS,
573
+ };
574
+ } else {
575
+ inferred = null;
576
+ }
564
577
  }
565
578
  if ((!info || !info.supportsThinking) && !inferred) {
566
579
  return {
package/yeaft/session.js CHANGED
@@ -218,26 +218,18 @@ export async function loadSession(options = {}) {
218
218
  }
219
219
 
220
220
  // ─── 3. Create trajectory trace ─────────────────────────
221
- // feat-always-on-trajectory-store: the trace is no longer gated on
222
- // config.debug. It is a TRAJECTORY STORE: every turn's full
223
- // (system_prompt, messages, tool_calls, tool_results, response, usage)
224
- // is persisted to ~/.yeaft/debug.db so it can serve two purposes:
225
- // 1. Debug panel hydration user opens "请求日志" and sees prior turns.
226
- // 2. SFT / RL training data scripts can later dump JSONL trajectories.
227
- // Cost is negligible (one insert per turn, WAL mode), and the data only
228
- // accumulates while the user actually uses the agent. The previous gate
229
- // silently discarded every turn unless the user had set debug:true in
230
- // ~/.yeaft/config.json, which nobody ever did — wasting the asset.
221
+ // Always-on request trace for the Debug panel. It is file-backed, not
222
+ // SQLite-backed: each request writes one bounded JSON file under
223
+ // `<yeaftDir>/debug/` (session traces are nested under
224
+ // `<yeaftDir>/sessions/<sessionId>/debug/requests/`). A request file stores one
225
+ // base request snapshot plus per-loop deltas, so 100-200 loop requests do
226
+ // not become hundreds of tiny files or repeated cumulative payloads.
231
227
  const trace = createTrace({
232
228
  enabled: true,
233
- dbPath: join(yeaftDir, 'debug.db'),
229
+ dirPath: yeaftDir,
234
230
  });
235
- // Bound disk growth: prune trajectories older than 10 days on session load.
236
- // Cheap (indexed DELETE + incremental_vacuum), runs once per process start,
237
- // not per turn. The always-on store stamps every turn with the *cumulative*
238
- // request/response (each long-session row is ~MB), so without a tight TTL the
239
- // file balloons — a real deployment hit 5GB in 15 days. 10 days keeps enough
240
- // history for debug-panel replay while capping the steady-state footprint.
231
+ // Hard cap debug history to the most recent 10 requests per Session. Trace
232
+ // failures are best-effort and must never stop the agent loop.
241
233
  try { trace.cleanup?.(10); } catch (err) {
242
234
  console.warn('[Yeaft] trace.cleanup failed:', err?.message || err);
243
235
  }