@steerable/agent-shell 0.7.4 → 0.8.1

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.
@@ -40,6 +40,23 @@ export interface CoreLoopToolAction {
40
40
  auto?: boolean;
41
41
  };
42
42
  }
43
+ /**
44
+ * What the sidecar knew about a failed model request, beyond its text.
45
+ *
46
+ * The host renders user-facing copy per `errorKind` and never parses
47
+ * `message` for it: the provider taxonomy is structured data, and message
48
+ * text is for the diagnostics panel.
49
+ */
50
+ export interface TurnFailureInfo {
51
+ /** Provider classification; `undefined` when the sidecar did not send one. */
52
+ errorKind?: string;
53
+ /** Proxy the request used, credentials stripped; `undefined` means direct. */
54
+ proxy?: string;
55
+ /** Provider attempts spent on the failing request, including the last. */
56
+ attempts?: number;
57
+ /** HTTP status when the provider answered with one. */
58
+ statusCode?: number;
59
+ }
43
60
  export interface CoreLoopTurnOutcome {
44
61
  status: string;
45
62
  reason?: string;
@@ -204,6 +221,13 @@ export interface StreamCoreLoopTurnOptions {
204
221
  }) => void;
205
222
  /** Fired when a tool finishes on the host (for tool-card rendering). */
206
223
  onToolAction?: (action: CoreLoopToolAction) => void;
224
+ /**
225
+ * Fired when the sidecar abandons the attempt it was streaming and starts
226
+ * the request again (a retry after partial output). Everything emitted via
227
+ * `onText` / `onReasoning` since the last restart belongs to a reply the
228
+ * user will never see in full and must be withdrawn from the display.
229
+ */
230
+ onRestart?: () => void;
207
231
  /** soft_timeout / budget_exhausted notices from the loop. */
208
232
  onNotice?: (kind: string, notice?: {
209
233
  kind: string;
@@ -307,6 +307,9 @@ export async function streamCoreLoopTurn(options) {
307
307
  // for the sidecar's cancelled done.
308
308
  if (cancelling)
309
309
  return;
310
+ if (chunk.restart) {
311
+ options.onRestart?.();
312
+ }
310
313
  if (chunk.delta) {
311
314
  options.onText(chunk.delta);
312
315
  }
@@ -373,8 +376,15 @@ export async function streamCoreLoopTurn(options) {
373
376
  // persist the partial trace — failed turns are the ones dogfooding
374
377
  // most needs to inspect.
375
378
  const failure = new Error(`coreloop stream failed: ${err.kind}: ${err.message}`);
379
+ const annotated = failure;
376
380
  if (err.traceId)
377
- failure.traceId = err.traceId;
381
+ annotated.traceId = err.traceId;
382
+ annotated.failureInfo = {
383
+ errorKind: err.errorKind,
384
+ proxy: err.proxy,
385
+ attempts: err.attempts,
386
+ statusCode: err.statusCode,
387
+ };
378
388
  reject(failure);
379
389
  },
380
390
  })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Which turns the `resume` channel may continue.
3
+ *
4
+ * `resume` replays the durable record's projection as the loop seed, so it can
5
+ * only continue a turn the record still ends mid-way through. Three settled
6
+ * states reach this decision, and they are not the same question:
7
+ *
8
+ * - **Interrupted** (W7-1): the process died, so the turn_active marker
9
+ * survived and no terminal assistant message was written. The user message
10
+ * is the record's tail; continuing finishes the request that never got an
11
+ * answer.
12
+ * - **Failed**: the turn's last model request exhausted its retries. The
13
+ * process is alive and settled the turn, so the marker is gone and the
14
+ * assistant message carries `completionStatus: 'failed'`. Everything
15
+ * completed before the failure is in the record — tools included — so
16
+ * continuing asks the model for the next step without repeating side
17
+ * effects.
18
+ * - **Cancelled**: the user stopped it. Continuing is allowed for the same
19
+ * reason as a failure, and deliberately: the usual cause is impatience with
20
+ * a retry, not a decision to abandon the task.
21
+ *
22
+ * A turn that completed normally is none of these. Resuming it would append a
23
+ * reply the user never asked for, which is why this is a whitelist: an
24
+ * unrecognised state refuses.
25
+ */
26
+ /** Why a turn can be continued, as far as the store can tell. */
27
+ export type ResumableReason = 'interrupted' | 'failed' | 'cancelled';
28
+ export interface ResumableTurnInput {
29
+ /** The `turn_active` marker survived: a turn began and never settled. */
30
+ turnActive: boolean;
31
+ /** A stream for this chat is live in THIS process right now. */
32
+ streamActive: boolean;
33
+ /** Role of the chat's newest persisted message, or null on an empty chat. */
34
+ lastMessageRole: string | null;
35
+ /**
36
+ * `completionStatus` of the last assistant message when the store ends on
37
+ * one. Absent when the newest message is not an assistant reply, or when the
38
+ * metadata predates the field.
39
+ */
40
+ lastAssistantStatus?: string | null;
41
+ }
42
+ /**
43
+ * Returns why the chat's last turn may be continued, or null when it may not.
44
+ *
45
+ * A live stream always refuses: the turn is running, and a second writer on
46
+ * the same record would interleave two tails.
47
+ */
48
+ export declare function resumableTurn(input: ResumableTurnInput): ResumableReason | null;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Which turns the `resume` channel may continue.
3
+ *
4
+ * `resume` replays the durable record's projection as the loop seed, so it can
5
+ * only continue a turn the record still ends mid-way through. Three settled
6
+ * states reach this decision, and they are not the same question:
7
+ *
8
+ * - **Interrupted** (W7-1): the process died, so the turn_active marker
9
+ * survived and no terminal assistant message was written. The user message
10
+ * is the record's tail; continuing finishes the request that never got an
11
+ * answer.
12
+ * - **Failed**: the turn's last model request exhausted its retries. The
13
+ * process is alive and settled the turn, so the marker is gone and the
14
+ * assistant message carries `completionStatus: 'failed'`. Everything
15
+ * completed before the failure is in the record — tools included — so
16
+ * continuing asks the model for the next step without repeating side
17
+ * effects.
18
+ * - **Cancelled**: the user stopped it. Continuing is allowed for the same
19
+ * reason as a failure, and deliberately: the usual cause is impatience with
20
+ * a retry, not a decision to abandon the task.
21
+ *
22
+ * A turn that completed normally is none of these. Resuming it would append a
23
+ * reply the user never asked for, which is why this is a whitelist: an
24
+ * unrecognised state refuses.
25
+ */
26
+ /**
27
+ * Returns why the chat's last turn may be continued, or null when it may not.
28
+ *
29
+ * A live stream always refuses: the turn is running, and a second writer on
30
+ * the same record would interleave two tails.
31
+ */
32
+ export function resumableTurn(input) {
33
+ if (input.streamActive)
34
+ return null;
35
+ if (input.turnActive) {
36
+ // A surviving marker with an assistant tail is the crash-between-write-
37
+ // and-clear window; the reply proves the turn settled, so it is not
38
+ // interrupted — but a failed reply may still be worth continuing.
39
+ return input.lastMessageRole === 'assistant'
40
+ ? statusReason(input.lastAssistantStatus)
41
+ : 'interrupted';
42
+ }
43
+ if (input.lastMessageRole !== 'assistant')
44
+ return null;
45
+ return statusReason(input.lastAssistantStatus);
46
+ }
47
+ function statusReason(status) {
48
+ if (status === 'failed')
49
+ return 'failed';
50
+ if (status === 'cancelled')
51
+ return 'cancelled';
52
+ return null;
53
+ }
@@ -1,3 +1,4 @@
1
+ import { type TurnFailureInfo } from './coreloop-stream.js';
1
2
  import type { ScopedStore } from '../storage/scoped-store.js';
2
3
  import { ToolRouter } from '../tool-router.js';
3
4
  import { type ResolvedAsk } from '../sidecar/reverse-ask-user.js';
@@ -35,10 +36,14 @@ export interface WakeResult {
35
36
  status?: number;
36
37
  }
37
38
  /**
38
- * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
39
- * 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
39
+ * Reads the structured failure detail off a rejected stream.
40
+ *
41
+ * `streamCoreLoopTurn` annotates its rejection; anything else (an abort, a
42
+ * router-level error) carries none, which the renderer shows as its generic
43
+ * card. Returns undefined rather than an empty object so the persisted
44
+ * metadata omits the field entirely.
40
45
  */
41
- export declare function userFacingCoreLoopFailure(reason: string): string;
46
+ export declare function readTurnFailureInfo(err: unknown): TurnFailureInfo | undefined;
42
47
  export interface StreamOptions {
43
48
  /**
44
49
  * 取消信号。renderer 主动 Stop、窗口被销毁、应用退出时由 main 进程 abort。
@@ -47,6 +52,37 @@ export interface StreamOptions {
47
52
  */
48
53
  signal?: AbortSignal;
49
54
  }
55
+ /**
56
+ * Extracts the fields a "reconnecting" indicator needs from a retry notice.
57
+ *
58
+ * The loop announces every retry as a `hook_action`; only attempts on the
59
+ * same model request are a wait the user notices, so the round and delay are
60
+ * carried through. Returns null for any other hook action so the caller can
61
+ * keep its round-boundary handling unchanged.
62
+ */
63
+ /**
64
+ * `completionStatus` of a persisted assistant message, or null.
65
+ *
66
+ * Only the terminal assistant message's status decides whether Continue is
67
+ * offered, so this reads the field and leaves the meaning to
68
+ * `resumableTurn`. A message without metadata (older builds) yields null,
69
+ * which refuses — resuming a turn whose end state is unknown could append a
70
+ * reply to a conversation that already finished.
71
+ */
72
+ export declare function readCompletionStatus(message: {
73
+ messageMetadata?: string | null;
74
+ } | undefined): string | null;
75
+ export declare function readRetryNotice(notice: {
76
+ kind?: string;
77
+ action?: unknown;
78
+ delayMs?: unknown;
79
+ round?: unknown;
80
+ reason?: unknown;
81
+ } | undefined): {
82
+ round?: number;
83
+ delayMs?: number;
84
+ reason?: string;
85
+ } | null;
50
86
  /**
51
87
  * Out-of-band 事件推送回调。用于把"在请求生命周期之外"才出结果的事件(例如
52
88
  * 后台 AI 标题生成)异步广播到所有 renderer。main.ts 注入 `webContents.send`
@@ -16,7 +16,7 @@ import { buildAmbientDelegateRoster, buildDelegateDispatchInstruction, buildDele
16
16
  import { resolveMentionedAgentIds } from './mention-targets.js';
17
17
  import { presentToolCall } from '../tool-presentation.js';
18
18
  import { readSidecarHistoryEntries, timelineFromHistoryEntries, } from './task-process.js';
19
- import { appendTimelineDelta, freezeTimelineReasoning, sealLastTimelineBlock, syncTimelineTools, } from './turn-timeline.js';
19
+ import { appendTimelineDelta, dropAttemptBlocks, freezeTimelineReasoning, sealLastTimelineBlock, syncTimelineTools, } from './turn-timeline.js';
20
20
  import { turnDurationMs } from './turn-duration.js';
21
21
  import { DEFAULT_SYSTEM_PROMPT, telemetryEnabled } from '../storage/index.js';
22
22
  import { isSkillPinned, isToolAllowed, mergeAgentCapabilities, normalizeToolPolicy, resolveSkillExcludes, } from './agent-capability.js';
@@ -44,8 +44,9 @@ import { formatHistoryForSummary, truncateMiddle, } from './context-compactor.js
44
44
  import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateForkOrdinal, } from './regenerate-helper.js';
45
45
  import { resolveBranchActivation } from './branch-helper.js';
46
46
  import { detectInterruptedTurn } from './interrupted-helper.js';
47
+ import { resumableTurn } from './resumable-turn.js';
47
48
  import { handlePortableRequest } from './portable-service.js';
48
- import { getProductConfig, isPortableProduct } from '../product-config.js';
49
+ import { getProductConfig, isPortableProduct, productConfigDirectConnection, } from '../product-config.js';
49
50
  import { dropCurrentUserMessage } from './history-helper.js';
50
51
  import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
51
52
  import { chatAttachmentsDirPath, isValidChatAttachmentsKey } from '../attachments.js';
@@ -152,23 +153,27 @@ function downloadMimeForPath(filePath) {
152
153
  return DOWNLOAD_MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
153
154
  }
154
155
  /**
155
- * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
156
- * 原始错误仍保存在 messageMetadata.completionReason 里,便于排查。
156
+ * Reads the structured failure detail off a rejected stream.
157
+ *
158
+ * `streamCoreLoopTurn` annotates its rejection; anything else (an abort, a
159
+ * router-level error) carries none, which the renderer shows as its generic
160
+ * card. Returns undefined rather than an empty object so the persisted
161
+ * metadata omits the field entirely.
157
162
  */
158
- export function userFacingCoreLoopFailure(reason) {
159
- if (reason.includes('HTTP 401') || reason.includes('HTTP 403')) {
160
- return '[回复失败] 模型服务认证失败,请检查 API Key 配置。';
161
- }
162
- if (reason.includes('HTTP 404')) {
163
- return '[回复失败] 模型服务或模型名不存在(HTTP 404),请检查 baseUrl 与 model 配置。';
164
- }
165
- if (reason.includes('ECONNREFUSED') ||
166
- reason.includes('ENOTFOUND') ||
167
- reason.includes('ETIMEDOUT') ||
168
- reason.includes('fetch failed')) {
169
- return '[回复失败] 无法连接模型服务,请检查网络或 baseUrl 配置。';
170
- }
171
- return `[回复失败] ${reason}`;
163
+ export function readTurnFailureInfo(err) {
164
+ const info = err?.failureInfo;
165
+ if (!info || typeof info !== 'object')
166
+ return undefined;
167
+ const record = info;
168
+ const pick = (key) => typeof record[key] === 'string' && record[key] ? record[key] : undefined;
169
+ const pickNumber = (key) => typeof record[key] === 'number' ? record[key] : undefined;
170
+ const out = {
171
+ errorKind: pick('errorKind'),
172
+ proxy: pick('proxy'),
173
+ attempts: pickNumber('attempts'),
174
+ statusCode: pickNumber('statusCode'),
175
+ };
176
+ return Object.values(out).some((value) => value !== undefined) ? out : undefined;
172
177
  }
173
178
  /**
174
179
  * Parses a pagination-style query param (`?page=`, `?limit=`) into a finite
@@ -251,6 +256,44 @@ function isRoundBoundaryHook(notice) {
251
256
  return false;
252
257
  return notice.action === 'retry' || notice.action === 'narrate';
253
258
  }
259
+ /**
260
+ * Extracts the fields a "reconnecting" indicator needs from a retry notice.
261
+ *
262
+ * The loop announces every retry as a `hook_action`; only attempts on the
263
+ * same model request are a wait the user notices, so the round and delay are
264
+ * carried through. Returns null for any other hook action so the caller can
265
+ * keep its round-boundary handling unchanged.
266
+ */
267
+ /**
268
+ * `completionStatus` of a persisted assistant message, or null.
269
+ *
270
+ * Only the terminal assistant message's status decides whether Continue is
271
+ * offered, so this reads the field and leaves the meaning to
272
+ * `resumableTurn`. A message without metadata (older builds) yields null,
273
+ * which refuses — resuming a turn whose end state is unknown could append a
274
+ * reply to a conversation that already finished.
275
+ */
276
+ export function readCompletionStatus(message) {
277
+ const raw = message?.messageMetadata;
278
+ if (typeof raw !== 'string' || !raw)
279
+ return null;
280
+ try {
281
+ const status = JSON.parse(raw).completionStatus;
282
+ return typeof status === 'string' && status ? status : null;
283
+ }
284
+ catch {
285
+ return null;
286
+ }
287
+ }
288
+ export function readRetryNotice(notice) {
289
+ if (notice?.kind !== 'hook_action' || notice.action !== 'retry')
290
+ return null;
291
+ return {
292
+ ...(typeof notice.round === 'number' ? { round: notice.round } : {}),
293
+ ...(typeof notice.delayMs === 'number' ? { delayMs: notice.delayMs } : {}),
294
+ ...(typeof notice.reason === 'string' && notice.reason ? { reason: notice.reason } : {}),
295
+ };
296
+ }
254
297
  function buildLocalAgent() {
255
298
  const now = new Date().toISOString();
256
299
  return {
@@ -2040,6 +2083,14 @@ export class LocalBackendRouter {
2040
2083
  data: await this.store.getUsageSummary(days),
2041
2084
  };
2042
2085
  }
2086
+ // 二期 R4:网络失败统计。窗口默认 7 天(设置页「最近 7 天」那一块)。
2087
+ if (method === 'GET' && pathname === '/api/v2/usage/reliability') {
2088
+ const days = parsePositiveIntParam(url.searchParams.get('days'), 7);
2089
+ return {
2090
+ status: 200,
2091
+ data: await this.store.getReliabilitySummary(days),
2092
+ };
2093
+ }
2043
2094
  if (method === 'POST' && pathname.match(/^\/api\/v2\/chats\/[^/]+\/suggest-replies$/)) {
2044
2095
  return {
2045
2096
  status: 200,
@@ -2454,25 +2505,42 @@ export class LocalBackendRouter {
2454
2505
  });
2455
2506
  }
2456
2507
  else if (isResume) {
2457
- // W7-1: 继续被中断的 turn —— 不追加新用户消息(记录里已有),而是让
2458
- // sidecar 回放 durable record 的投影作为循环种子。只在检测确为
2459
- // interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
2460
- // 凭空多接一段回复。
2508
+ // 继续上一轮 —— 不追加新用户消息(记录里已有),而是让 sidecar 回放
2509
+ // durable record 的投影作为循环种子。允许续跑的三种终态见
2510
+ // `resumable-turn.ts`:崩溃中断、失败、用户停止。正常完成的回合拒绝,
2511
+ // 否则会给完整对话凭空多接一段回复。
2461
2512
  const prior = await this.store.listMessages(chatId, 200);
2462
- const interrupted = detectInterruptedTurn({
2463
- turnActive: await this.store.getTurnActive(chatId) !== null,
2513
+ const lastAssistant = prior.find((m) => m.role === 'assistant');
2514
+ const reason = resumableTurn({
2515
+ turnActive: (await this.store.getTurnActive(chatId)) !== null,
2464
2516
  streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
2465
2517
  lastMessageRole: prior[0]?.role ?? null,
2518
+ lastAssistantStatus: readCompletionStatus(lastAssistant),
2466
2519
  });
2467
2520
  const lastUser = prior.find(m => m.role === 'user');
2468
- if (!interrupted || !lastUser) {
2469
- emit(this.sse('error', { message: '没有可继续的中断回复' }));
2521
+ if (!reason || !lastUser) {
2522
+ emit(this.sse('error', { message: '没有可继续的回复' }));
2470
2523
  return { status: 409 };
2471
2524
  }
2472
2525
  // 触发轮的用户消息仍在库里(崩溃只丢了回复)——取其原文喂给
2473
2526
  // buildConversationMessages,让 mode preamble / deferred-exec 检测
2474
2527
  // 仍然基于真实诉求。发给 sidecar 的 messages 为空(见下方调用点)。
2475
2528
  userMessageText = lastUser.content;
2529
+ // 在原先失败/停止的那条回复上记下「已从这里继续」:界面据此把整张
2530
+ // 失败卡片收成一行历史记录,重启后仍然是这一行,而不是又一次失败。
2531
+ if (lastAssistant && reason !== 'interrupted') {
2532
+ // 记不下来只会让卡片多留一张,不值得让续跑失败——所以吞掉异常,
2533
+ // 但用 try/catch 而不是 `.catch()`:store 实现可以是同步的。
2534
+ try {
2535
+ await this.store.patchMessageMetadata(chatId, lastAssistant.id, {
2536
+ continuedAt: new Date().toISOString(),
2537
+ continuedReason: reason,
2538
+ });
2539
+ }
2540
+ catch (patchErr) {
2541
+ console.warn('[local-backend] continue: metadata patch failed', patchErr);
2542
+ }
2543
+ }
2476
2544
  }
2477
2545
  else {
2478
2546
  userMessageText = String(payload.message || '').trim();
@@ -3660,12 +3728,33 @@ export class LocalBackendRouter {
3660
3728
  // approval:"off" 或 STEERABLE_APPROVAL=0 本轮不挂审批。
3661
3729
  const approval = resolveTurnApproval();
3662
3730
  let assistantText = '';
3731
+ // Text this attempt streamed (reset on restart) and the timeline length
3732
+ // when the attempt began, so a retry can withdraw exactly its own output.
3733
+ let streamedText = '';
3734
+ let attemptBlockStart = 0;
3735
+ // A retry notice arrived and no output followed yet: the turn is waiting
3736
+ // on the network, not on the model. `stream_restart` clears it because the
3737
+ // retry has begun producing again.
3738
+ let retrying = false;
3739
+ // Reliability accounting for this turn (R4). A model request is either a
3740
+ // pass (a fresh loop run) or a retry inside one (the sidecar re-issuing
3741
+ // the same request), so attempts = passes + retries. The retry and direct
3742
+ // flags are sticky: one retry anywhere in the turn is what the statistic
3743
+ // asks about.
3744
+ let turnPasses = 0;
3745
+ let turnRetries = 0;
3746
+ let retriedThisTurn = false;
3747
+ let directThisTurn = false;
3663
3748
  const executedActions = [];
3664
3749
  const timeline = [];
3665
3750
  // 编排子代理生命周期事件累积(切回后用于重建 currentTurnChildren)。
3666
3751
  const children = [];
3667
3752
  let completionStatus = 'completed';
3668
3753
  let completionReason = '';
3754
+ // Provider classification of the failing request (see TurnFailureInfo).
3755
+ // Stays empty for a clean sidecar-reported failure, which carries its own
3756
+ // `completionReason` but no per-request detail.
3757
+ let failureInfo;
3669
3758
  let outcomeTraceId;
3670
3759
  // Every pass records its own sidecar trace; all of them are persisted
3671
3760
  // against the one assistant message this turn produces.
@@ -3735,6 +3824,9 @@ export class LocalBackendRouter {
3735
3824
  provider: sidecarWireProvider(settings.provider),
3736
3825
  model: overrideModel || settings.model,
3737
3826
  reasoningEffort,
3827
+ // 产品声明模型服务是否直连(跳过系统代理)。只在产品显式打开时下发,
3828
+ // 未声明的产品线上字节与旧版完全一致。
3829
+ ...(productConfigDirectConnection() ? { directConnection: true } : {}),
3738
3830
  // W2.2.2 凭证代理:broker 活跃且命中 provider 主机时,baseUrl 改写
3739
3831
  // 为 http(代理解析绝对 URI 后注入凭证转发 https),apiKey 不再下
3740
3832
  // 发——sidecar 进程(沙箱内、可被 agent 行为影响)永远拿不到真实
@@ -3801,8 +3893,32 @@ export class LocalBackendRouter {
3801
3893
  assistantText += delta;
3802
3894
  appendTimelineDelta(timeline, 'text', delta);
3803
3895
  live.content = assistantText;
3896
+ // Track what this attempt emitted so a retry can take exactly it
3897
+ // back: the host has already handed these bytes to the renderer.
3898
+ streamedText += delta;
3804
3899
  emit(this.sseData({ content: delta }));
3805
3900
  },
3901
+ onRestart: () => {
3902
+ // The sidecar is re-issuing this request. Withdraw the attempt's
3903
+ // text and reasoning — persisted, live, and renderer views — so the
3904
+ // retry's reply does not arrive after a half sentence the model
3905
+ // never finished. Tool blocks stay: those calls ran.
3906
+ if (streamedText) {
3907
+ assistantText = assistantText.slice(0, assistantText.length - streamedText.length);
3908
+ streamedText = '';
3909
+ }
3910
+ // The retry is producing again; a Stop from here on is not a stop
3911
+ // "during reconnection".
3912
+ retrying = false;
3913
+ live.content = assistantText;
3914
+ dropAttemptBlocks(timeline, attemptBlockStart);
3915
+ attemptBlockStart = timeline.length;
3916
+ emit(this.sseData({
3917
+ type: 'stream_restart',
3918
+ content: assistantText,
3919
+ blocks: timeline,
3920
+ }));
3921
+ },
3806
3922
  onReasoning: (delta) => {
3807
3923
  appendTimelineDelta(timeline, 'reasoning', delta);
3808
3924
  emit(this.sseData({ type: 'reasoning', content: delta }));
@@ -3856,6 +3972,17 @@ export class LocalBackendRouter {
3856
3972
  }
3857
3973
  },
3858
3974
  onNotice: (kind, notice) => {
3975
+ // A retry is a wait the user can feel: connection backoff runs for
3976
+ // seconds. Announce it so the turn can show "reconnecting" instead
3977
+ // of looking stuck, and so the wait itself is visible before the
3978
+ // next attempt starts. Not persisted — history keeps outcomes only.
3979
+ const retry = readRetryNotice(notice);
3980
+ if (retry) {
3981
+ retrying = true;
3982
+ retriedThisTurn = true;
3983
+ turnRetries += 1;
3984
+ emit(this.sseData({ type: 'turn_retry', ...retry }));
3985
+ }
3859
3986
  if (kind === 'budget_exhausted') {
3860
3987
  // 预算维度(rounds/tokens)在 notice.budget;notice.kind 是信封
3861
3988
  // 类型(loop 事件 data 里的 budget 键经 sidecar 透传)。message
@@ -3920,6 +4047,7 @@ export class LocalBackendRouter {
3920
4047
  afterPass: async (outcome) => {
3921
4048
  if (outcome.traceId)
3922
4049
  passTraceIds.push(outcome.traceId);
4050
+ turnPasses += 1;
3923
4051
  // W6-9 用量归因:落一条 chat 用量事件(含成本估算,无单价模型 costUsd=null)。
3924
4052
  // 每趟各落一条——续跑的花费是真实发生的,不该被合并掉。
3925
4053
  if (outcome.usage) {
@@ -3965,11 +4093,16 @@ export class LocalBackendRouter {
3965
4093
  catch (err) {
3966
4094
  if (err instanceof DOMException && err.name === 'AbortError') {
3967
4095
  completionStatus = 'cancelled';
3968
- completionReason = 'aborted_by_user';
4096
+ // Stop lands for two very different reasons, and the card says which:
4097
+ // while waiting out a retry (almost always impatience with a slow
4098
+ // reconnect) or while the model was writing (a deliberate stop).
4099
+ // Recorded here because only the host saw whether a retry was pending.
4100
+ completionReason = retrying ? 'aborted_during_retry' : 'aborted_by_user';
3969
4101
  }
3970
4102
  else {
3971
4103
  completionStatus = 'failed';
3972
4104
  completionReason = err instanceof Error ? err.message : String(err);
4105
+ failureInfo = readTurnFailureInfo(err);
3973
4106
  emit(this.sse('error', { message: completionReason }));
3974
4107
  }
3975
4108
  // streamCoreLoopTurn attaches the sidecar trace id to failures — the
@@ -3980,6 +4113,72 @@ export class LocalBackendRouter {
3980
4113
  passTraceIds.push(failureTraceId);
3981
4114
  }
3982
4115
  }
4116
+ // 可靠性统计(R4):一回合一条终态记录,与 token 用量分开记——用量
4117
+ // 是每趟各一条(自动续跑的花费是真实发生的),终态只有一个。
4118
+ // `usedDirect` 取产品当前配置:直连是产品级开关,不是每回合的决定。
4119
+ try {
4120
+ await this.store.recordUsageEvent({
4121
+ chatId,
4122
+ kind: 'turn',
4123
+ provider: settings.provider,
4124
+ model: overrideModel || settings.model,
4125
+ promptTokens: 0,
4126
+ completionTokens: 0,
4127
+ totalTokens: 0,
4128
+ outcome: completionStatus === 'failed'
4129
+ ? 'failed'
4130
+ : completionStatus === 'cancelled'
4131
+ ? 'cancelled'
4132
+ : 'completed',
4133
+ failureKind: completionStatus === 'failed' ? (failureInfo?.errorKind ?? 'unknown') : null,
4134
+ attempts: turnPasses + turnRetries || null,
4135
+ retried: retriedThisTurn,
4136
+ usedDirect: directThisTurn || productConfigDirectConnection(),
4137
+ });
4138
+ }
4139
+ catch (reliabilityErr) {
4140
+ // 统计写不进去不该影响回合收尾——消息已经落库,用户看到的是结果。
4141
+ console.warn('[local-backend] record turn outcome failed', reliabilityErr);
4142
+ }
4143
+ // 配了遥测就把这一回合的终态作为计数上报(R4)。指标只有类别与次数,
4144
+ // 没有对话内容,所以 `metadata` 档位也可以发;没配 endpoint 就一条不发。
4145
+ // 失败不阻断:回合已经完成,投递不了是收集端的事。
4146
+ // 取遥测设置:读不到只意味着这一回合不上报,不该影响回合收尾。用
4147
+ // try/catch 而不是 `.catch()`——store 实现是同步返回时没有 thenable,
4148
+ // 链式 `.catch` 会直接抛。
4149
+ let telemetryForTurn = null;
4150
+ try {
4151
+ telemetryForTurn = await this.store.getTelemetrySettings();
4152
+ }
4153
+ catch (telemetryReadErr) {
4154
+ console.warn('[local-backend] read telemetry settings failed', telemetryReadErr);
4155
+ }
4156
+ if (telemetryEnabled(telemetryForTurn) && telemetryForTurn?.endpoint) {
4157
+ void supervisor
4158
+ ?.call('metrics.export', {
4159
+ // 一个端点同时收 trace 与 metric 时,把 /v1/traces 换成 /v1/metrics。
4160
+ endpoint: telemetryForTurn.endpoint.replace(/\/v1\/traces$/, '/v1/metrics'),
4161
+ serviceName: telemetryForTurn.serviceName,
4162
+ samples: [
4163
+ {
4164
+ outcome: completionStatus === 'failed'
4165
+ ? 'failed'
4166
+ : completionStatus === 'cancelled'
4167
+ ? 'cancelled'
4168
+ : 'completed',
4169
+ ...(completionStatus === 'failed'
4170
+ ? { failureKind: failureInfo?.errorKind ?? 'unknown' }
4171
+ : {}),
4172
+ attempts: turnPasses + turnRetries,
4173
+ retried: retriedThisTurn,
4174
+ usedDirect: directThisTurn || productConfigDirectConnection(),
4175
+ },
4176
+ ],
4177
+ }, { timeoutMs: 10_000 })
4178
+ .catch((err) => {
4179
+ console.warn('[local-backend] telemetry metrics export failed', err);
4180
+ });
4181
+ }
3983
4182
  const durationMs = turnDurationMs((await this.store.getTurnActive(chatId))?.startedAt, Date.now());
3984
4183
  // 回合产物文件列表(成功/失败/取消都收集——半截回合写出的文件同样是
3985
4184
  // 产物)。收集失败只记日志,永远不该拖垮回合收尾。
@@ -4010,6 +4209,7 @@ export class LocalBackendRouter {
4010
4209
  ...(durationMs != null ? { durationMs } : {}),
4011
4210
  ...(autoContinuations > 0 ? { autoContinuations } : {}),
4012
4211
  ...(turnFiles.length > 0 ? { turnFiles } : {}),
4212
+ ...(failureInfo ? { failure: failureInfo } : {}),
4013
4213
  }));
4014
4214
  try {
4015
4215
  await recordInsightTurn(this.store, {
@@ -17,6 +17,20 @@ export type PersistedTurnBlock = {
17
17
  actions: Array<Record<string, unknown>>;
18
18
  };
19
19
  export declare function appendTimelineDelta(blocks: PersistedTurnBlock[], type: 'text' | 'reasoning', delta: string, now?: number): void;
20
+ /**
21
+ * Withdraw a discarded attempt's streamed text and reasoning.
22
+ *
23
+ * A retry after partial output restarts the reply from nothing, so the blocks
24
+ * this attempt produced must not survive into the persisted turn: the retry's
25
+ * own text would otherwise be appended after text the model never finished
26
+ * saying. Tool blocks are kept — a tool call that already ran is a fact, and
27
+ * the retry does not repeat it.
28
+ *
29
+ * @param blocks Timeline to prune in place.
30
+ * @param sinceLength `blocks.length` recorded when the attempt started; only
31
+ * entries at or after that index are candidates.
32
+ */
33
+ export declare function dropAttemptBlocks(blocks: PersistedTurnBlock[], sinceLength: number): void;
20
34
  /**
21
35
  * Close the current reasoning/text segment so the next same-kind delta
22
36
  * starts a new block. Mirrors apps/web `sealLastBlock`.
@@ -25,6 +25,27 @@ export function appendTimelineDelta(blocks, type, delta, now = Date.now()) {
25
25
  }
26
26
  blocks.push({ type, content: delta });
27
27
  }
28
+ /**
29
+ * Withdraw a discarded attempt's streamed text and reasoning.
30
+ *
31
+ * A retry after partial output restarts the reply from nothing, so the blocks
32
+ * this attempt produced must not survive into the persisted turn: the retry's
33
+ * own text would otherwise be appended after text the model never finished
34
+ * saying. Tool blocks are kept — a tool call that already ran is a fact, and
35
+ * the retry does not repeat it.
36
+ *
37
+ * @param blocks Timeline to prune in place.
38
+ * @param sinceLength `blocks.length` recorded when the attempt started; only
39
+ * entries at or after that index are candidates.
40
+ */
41
+ export function dropAttemptBlocks(blocks, sinceLength) {
42
+ for (let index = blocks.length - 1; index >= sinceLength; index -= 1) {
43
+ const block = blocks[index];
44
+ if (block && (block.type === 'text' || block.type === 'reasoning')) {
45
+ blocks.splice(index, 1);
46
+ }
47
+ }
48
+ }
28
49
  /**
29
50
  * Close the current reasoning/text segment so the next same-kind delta
30
51
  * starts a new block. Mirrors apps/web `sealLastBlock`.
@@ -239,8 +239,14 @@ export declare class LocalExecutor {
239
239
  /**
240
240
  * P2b:会话恢复时重灌 read-before-write 证据。sidecar 扫描持久记录里的
241
241
  * 文件工具结果,把 path → version 经 `read_state.seed` 推到这里——进程
242
- * 重启后自动 CAS 仍然成立(CC `seed_read_state` 语义)。键是 sidecar 侧
243
- * 已解析的绝对路径,原样存储;resolvePath 对绝对路径是恒等,查询能命中。
242
+ * 重启后自动 CAS 仍然成立(CC `seed_read_state` 语义)。
243
+ *
244
+ * 键必须过一遍 `resolvePath`:读证据的每一次读写都用规范化后的路径做键,
245
+ * 而 seed 来的路径是 sidecar 记录的原文。两者不一致时自动 CAS 永远查不
246
+ * 中,write/edit 会退回「未读先写已拒绝」。macOS 上 `/var/folders/…` 与
247
+ * `/private/var/folders/…` 就是这种情形(`os.tmpdir()` 返回未解析的
248
+ * 形式),Linux 的 `/tmp` 符号链接同理。
249
+ *
244
250
  * 返回实际收录的条目数(畸形条目跳过)。
245
251
  */
246
252
  seedReadState(state: Record<string, unknown>): number;
@@ -329,15 +329,21 @@ export class LocalExecutor {
329
329
  /**
330
330
  * P2b:会话恢复时重灌 read-before-write 证据。sidecar 扫描持久记录里的
331
331
  * 文件工具结果,把 path → version 经 `read_state.seed` 推到这里——进程
332
- * 重启后自动 CAS 仍然成立(CC `seed_read_state` 语义)。键是 sidecar 侧
333
- * 已解析的绝对路径,原样存储;resolvePath 对绝对路径是恒等,查询能命中。
332
+ * 重启后自动 CAS 仍然成立(CC `seed_read_state` 语义)。
333
+ *
334
+ * 键必须过一遍 `resolvePath`:读证据的每一次读写都用规范化后的路径做键,
335
+ * 而 seed 来的路径是 sidecar 记录的原文。两者不一致时自动 CAS 永远查不
336
+ * 中,write/edit 会退回「未读先写已拒绝」。macOS 上 `/var/folders/…` 与
337
+ * `/private/var/folders/…` 就是这种情形(`os.tmpdir()` 返回未解析的
338
+ * 形式),Linux 的 `/tmp` 符号链接同理。
339
+ *
334
340
  * 返回实际收录的条目数(畸形条目跳过)。
335
341
  */
336
342
  seedReadState(state) {
337
343
  let seeded = 0;
338
344
  for (const [p, version] of Object.entries(state)) {
339
345
  if (typeof p === 'string' && p.length > 0 && typeof version === 'string') {
340
- this.readFileState.set(p, version);
346
+ this.readFileState.set(this.resolvePath(p), version);
341
347
  seeded += 1;
342
348
  }
343
349
  }
@@ -132,9 +132,26 @@ export interface ProductConfig {
132
132
  * 超过则接口返回 413,不创建新会话。
133
133
  */
134
134
  forkWorkspaceMaxBytes?: number;
135
+ /**
136
+ * 模型请求是否跳过系统代理直连。缺省关(跟随系统代理)。
137
+ *
138
+ * 打开后这一端点的请求不经过用户在系统里配置的代理。用于国内常见
139
+ * 「开着 Clash,但模型服务本来就在直连规则里」的情形:代理多转一手
140
+ * 只会用更短的超时把本来能连上的请求断掉。
141
+ *
142
+ * 要求流量必须经过公司代理的环境(例如企业产品)应保持关闭。
143
+ */
144
+ directModelConnection?: boolean;
135
145
  }
136
146
  /** shell 内置智能体:只有产品显式 `true` 才开。 */
137
147
  export declare function isShellBuiltinAgentEnabled(id: 'local-assistant' | 'all-round-assistant', config?: ProductConfig): boolean;
148
+ /**
149
+ * 模型请求是否直连(不经过系统代理)。只有产品显式 `true` 才开。
150
+ *
151
+ * 企业产品可能要求所有出网流量经过公司代理,所以缺省是关:产品不声明
152
+ * 就保持跟随系统代理的旧行为。
153
+ */
154
+ export declare function productConfigDirectConnection(config?: ProductConfig): boolean;
138
155
  /** 对话与配置能否导出/导入。缺省关,产品必须显式打开。 */
139
156
  export declare function isPortableProduct(config?: ProductConfig): boolean;
140
157
  /** shell 内置技能:`true` 全开;否则只有对象里显式 `true` 的目录开。 */
@@ -13,6 +13,15 @@
13
13
  export function isShellBuiltinAgentEnabled(id, config = getProductConfig()) {
14
14
  return config.builtinAgents?.[id] === true;
15
15
  }
16
+ /**
17
+ * 模型请求是否直连(不经过系统代理)。只有产品显式 `true` 才开。
18
+ *
19
+ * 企业产品可能要求所有出网流量经过公司代理,所以缺省是关:产品不声明
20
+ * 就保持跟随系统代理的旧行为。
21
+ */
22
+ export function productConfigDirectConnection(config = getProductConfig()) {
23
+ return config.directModelConnection === true;
24
+ }
16
25
  /** 对话与配置能否导出/导入。缺省关,产品必须显式打开。 */
17
26
  export function isPortableProduct(config = getProductConfig()) {
18
27
  return config.portable === true;
@@ -545,6 +545,17 @@ export interface SidecarStreamChunk {
545
545
  kind: string;
546
546
  [key: string]: unknown;
547
547
  };
548
+ /**
549
+ * The sidecar abandoned the attempt it was streaming and re-issued the
550
+ * request. Deltas received before this point belong to a reply that will
551
+ * never be completed, so the host withdraws them from the display rather
552
+ * than appending the retry's text after them.
553
+ */
554
+ restart?: {
555
+ reason: string;
556
+ round?: number;
557
+ attempt?: number;
558
+ };
548
559
  finishReason?: string;
549
560
  usage?: {
550
561
  promptTokens: number;
@@ -580,6 +591,22 @@ export interface SidecarStreamError {
580
591
  traceId?: string;
581
592
  kind: string;
582
593
  message: string;
594
+ /**
595
+ * Provider classification (`transport` / `auth` / `rate_limit` / `server` /
596
+ * `context_overflow` / `invalid_request` / `unknown`). Absent from older
597
+ * sidecars and from non-provider failures; treat missing as `unknown` rather
598
+ * than parsing the message.
599
+ */
600
+ errorKind?: string;
601
+ /**
602
+ * Proxy the model request went through, credentials stripped. Absent means
603
+ * the request went direct or the sidecar predates this field.
604
+ */
605
+ proxy?: string;
606
+ /** Provider attempts spent on the failing request, including the last. */
607
+ attempts?: number;
608
+ /** HTTP status when the provider answered with one. */
609
+ statusCode?: number;
583
610
  }
584
611
  /**
585
612
  * `agent.child` notification payload (child-agent lifecycle), demuxed by
@@ -4,6 +4,7 @@ import { type TelemetrySettings } from './telemetry-settings.js';
4
4
  import { type WebSearchSettings } from './web-search-settings.js';
5
5
  import { type InsightKind, type InsightsProfile, type InsightsSettings, type InsightsSettingsPatch } from './insights-settings.js';
6
6
  import { type UsageSummary } from './usage-summary.js';
7
+ import type { ReliabilitySummary } from './reliability-summary.js';
7
8
  import type { TenantScope } from './driver.js';
8
9
  import type { ScopedStore } from './scoped-store.js';
9
10
  export type { UsageModelBucket, UsageSummary } from './usage-summary.js';
@@ -326,7 +327,32 @@ export declare class SqliteScopedStore implements ScopedStore {
326
327
  totalTokens: number;
327
328
  cachedPromptTokens?: number;
328
329
  costUsd?: number | null;
330
+ /** How the turn ended; omitted keeps the historical 'completed' default. */
331
+ outcome?: 'completed' | 'failed' | 'cancelled';
332
+ /** Provider classification, recorded only for `failed`. */
333
+ failureKind?: string | null;
334
+ /** Model requests spent on the turn, retries included. */
335
+ attempts?: number | null;
336
+ /** Whether any request in the turn was retried. */
337
+ retried?: boolean;
338
+ /** Whether any request went direct instead of through the proxy. */
339
+ usedDirect?: boolean;
329
340
  }): Promise<void>;
341
+ /**
342
+ * Reliability summary for the last `sinceDays` days.
343
+ *
344
+ * Splits the three terminal states apart because they answer different
345
+ * questions: a failed turn is something to fix, a cancelled one is the user's
346
+ * decision and must not inflate the failure rate, and a retried turn that
347
+ * completed is the measure of whether retrying is doing its job. Turns
348
+ * recorded before these columns existed default to `completed`, which is the
349
+ * only honest reading — their outcome was not observed.
350
+ *
351
+ * @param sinceDays Window length in days, ending now.
352
+ * @returns Counts per outcome, failures broken down by provider
353
+ * classification, and how many completed turns had retried.
354
+ */
355
+ getReliabilitySummary(sinceDays?: number): Promise<ReliabilitySummary>;
330
356
  /**
331
357
  * 聚合最近 `sinceDays` 天的用量:按 model 分桶( token 合计 + 成本合计),
332
358
  * 外加总计。成本只统计有单价的模型(cost_usd 非 NULL);无单价模型的
@@ -335,6 +335,20 @@ export class SqliteScopedStore {
335
335
  this.ensureColumn('tasks', 'owner_instance_id', 'TEXT');
336
336
  this.ensureColumn('insights_outbox', 'claimed_by', 'TEXT');
337
337
  this.ensureColumn('insights_outbox', 'claimed_at', 'TEXT');
338
+ // 网络失败统计(二期 R4)。一次回合一行,与 token 用量共用一张表:
339
+ // 两者都是「这一回合发生了什么」的事实,分成两张表只会让复盘时
340
+ // 要 join。
341
+ //
342
+ // outcome 取值 completed / failed / cancelled。cancelled 是用户主动
343
+ // 停止,不算失败——界面与统计都不该把它和网络故障混为一谈。
344
+ this.ensureColumn('usage_events', 'outcome', `TEXT NOT NULL DEFAULT 'completed'`);
345
+ // 失败类别(provider 分类:transport / auth / rate_limit / …)。
346
+ this.ensureColumn('usage_events', 'failure_kind', 'TEXT');
347
+ // 本回合一共发出了几次模型请求(含重试)。
348
+ this.ensureColumn('usage_events', 'attempts', 'INTEGER');
349
+ // 是否发生过重试,以及是否改走过直连。
350
+ this.ensureColumn('usage_events', 'retried', 'INTEGER NOT NULL DEFAULT 0');
351
+ this.ensureColumn('usage_events', 'used_direct', 'INTEGER NOT NULL DEFAULT 0');
338
352
  // 智能体技能面(增量迁移):load_all_skills = 无视触发条件全量加载。
339
353
  // 此前这个行为是 router 里 `chatAgentId === 'all-round-assistant'` 的
340
354
  // 硬编码,自建智能体拿不到;建列时一次性回填给内置「智能助手」,之后
@@ -1124,9 +1138,59 @@ export class SqliteScopedStore {
1124
1138
  async recordUsageEvent(input) {
1125
1139
  this.db.prepare(`
1126
1140
  INSERT INTO usage_events
1127
- (tenant_id, user_id, id, chat_id, kind, provider, model, prompt_tokens, completion_tokens, total_tokens, cached_prompt_tokens, cost_usd, created_at)
1128
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1129
- `).run(this.scope.tenantId, this.scope.userId, randomUUID(), input.chatId ?? null, input.kind, input.provider ?? null, input.model ?? null, Math.max(0, Math.floor(input.promptTokens)), Math.max(0, Math.floor(input.completionTokens)), Math.max(0, Math.floor(input.totalTokens)), Math.max(0, Math.floor(input.cachedPromptTokens ?? 0)), input.costUsd ?? null, new Date().toISOString());
1141
+ (tenant_id, user_id, id, chat_id, kind, provider, model, prompt_tokens, completion_tokens, total_tokens, cached_prompt_tokens, cost_usd, created_at, outcome, failure_kind, attempts, retried, used_direct)
1142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1143
+ `).run(this.scope.tenantId, this.scope.userId, randomUUID(), input.chatId ?? null, input.kind, input.provider ?? null, input.model ?? null, Math.max(0, Math.floor(input.promptTokens)), Math.max(0, Math.floor(input.completionTokens)), Math.max(0, Math.floor(input.totalTokens)), Math.max(0, Math.floor(input.cachedPromptTokens ?? 0)), input.costUsd ?? null, new Date().toISOString(), input.outcome ?? 'completed', input.failureKind ?? null, input.attempts ?? null, input.retried ? 1 : 0, input.usedDirect ? 1 : 0);
1144
+ }
1145
+ /**
1146
+ * Reliability summary for the last `sinceDays` days.
1147
+ *
1148
+ * Splits the three terminal states apart because they answer different
1149
+ * questions: a failed turn is something to fix, a cancelled one is the user's
1150
+ * decision and must not inflate the failure rate, and a retried turn that
1151
+ * completed is the measure of whether retrying is doing its job. Turns
1152
+ * recorded before these columns existed default to `completed`, which is the
1153
+ * only honest reading — their outcome was not observed.
1154
+ *
1155
+ * @param sinceDays Window length in days, ending now.
1156
+ * @returns Counts per outcome, failures broken down by provider
1157
+ * classification, and how many completed turns had retried.
1158
+ */
1159
+ async getReliabilitySummary(sinceDays = 7) {
1160
+ const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString();
1161
+ const totals = this.db
1162
+ .prepare(`SELECT
1163
+ COUNT(*) AS total,
1164
+ SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS failed,
1165
+ SUM(CASE WHEN outcome = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
1166
+ SUM(CASE WHEN outcome = 'completed' AND retried = 1 THEN 1 ELSE 0 END) AS recovered,
1167
+ SUM(CASE WHEN used_direct = 1 THEN 1 ELSE 0 END) AS used_direct
1168
+ FROM usage_events
1169
+ WHERE tenant_id = ? AND user_id = ? AND created_at >= ?`)
1170
+ .get(this.scope.tenantId, this.scope.userId, since);
1171
+ const byKind = this.db
1172
+ .prepare(
1173
+ // Group by the expression, not the output alias: a bare `GROUP BY kind`
1174
+ // resolves to a source column when one exists, which silently merges
1175
+ // every row into the wrong bucket.
1176
+ `SELECT COALESCE(failure_kind, 'unknown') AS kind, COUNT(*) AS count
1177
+ FROM usage_events
1178
+ WHERE tenant_id = ? AND user_id = ? AND created_at >= ?
1179
+ AND outcome = 'failed'
1180
+ GROUP BY COALESCE(failure_kind, 'unknown')
1181
+ ORDER BY count DESC, kind`)
1182
+ .all(this.scope.tenantId, this.scope.userId, since);
1183
+ return {
1184
+ sinceDays,
1185
+ total: totals.total ?? 0,
1186
+ failed: totals.failed ?? 0,
1187
+ cancelled: totals.cancelled ?? 0,
1188
+ /** Completed turns that had retried at least once. */
1189
+ recoveredByRetry: totals.recovered ?? 0,
1190
+ /** Turns where the model request went direct instead of via a proxy. */
1191
+ usedDirect: totals.used_direct ?? 0,
1192
+ failuresByKind: byKind.map((row) => ({ kind: row.kind, count: row.count })),
1193
+ };
1130
1194
  }
1131
1195
  /**
1132
1196
  * 聚合最近 `sinceDays` 天的用量:按 model 分桶( token 合计 + 成本合计),
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 二期 R4 —— 网络失败的统计口径(纯逻辑)。
3
+ *
4
+ * 与 `usage-summary.ts` 同样的拆分理由:`LocalStore` 顶层加载的
5
+ * better-sqlite3 native 模块与 vitest 运行时二进制不兼容,SQL 留在
6
+ * store 里,口径与派生量放这里,可以直接单测。
7
+ *
8
+ * 三个终态分开数,因为它们回答不同的问题:
9
+ *
10
+ * - `failed` 是要修的东西。失败率的分母用它。
11
+ * - `cancelled` 是用户自己的决定,**不算失败**。把停止算成失败,会让
12
+ * 「网络在变差」和「用户等得不耐烦」看起来一样。
13
+ * - `recoveredByRetry` 是重试有没有用的直接度量:重试过、最终成功了
14
+ * 的回合数。它低不代表重试没价值(可能是网络好),但配合失败数看就
15
+ * 能判断 R3 的策略是否合适。
16
+ *
17
+ * 早于这三列存在之前写入的行,`outcome` 取默认值 `completed`——那时候
18
+ * 也没观测过终态,按完成计是唯一诚实的读法。
19
+ */
20
+ /** Provider failure classification, as `LLMError.kind` names them. */
21
+ export type FailureKind = 'transport' | 'rate_limit' | 'server' | 'auth' | 'invalid_request' | 'context_overflow' | 'unknown';
22
+ /** One failure category with its count. */
23
+ export interface FailureKindCount {
24
+ kind: string;
25
+ count: number;
26
+ }
27
+ /** Reliability numbers for one window. */
28
+ export interface ReliabilitySummary {
29
+ sinceDays: number;
30
+ /** Every recorded turn in the window, whatever its outcome. */
31
+ total: number;
32
+ failed: number;
33
+ cancelled: number;
34
+ /** Completed turns in which at least one model request was retried. */
35
+ recoveredByRetry: number;
36
+ /** Turns whose model request went direct instead of through a proxy. */
37
+ usedDirect: number;
38
+ /** Failure counts per provider classification, largest first. */
39
+ failuresByKind: FailureKindCount[];
40
+ }
41
+ /**
42
+ * Share of turns that failed, as a number in [0, 1].
43
+ *
44
+ * Cancelled turns are excluded from the denominator: the user stopping is not
45
+ * evidence about the network. Returns null when nothing was observed, so a
46
+ * reader can tell "no failures" from "no data" — a dashboard that shows 0%
47
+ * on an empty window invites the wrong conclusion.
48
+ */
49
+ export declare function failureRate(summary: ReliabilitySummary): number | null;
50
+ /**
51
+ * The network-attributable share of failures, in [0, 1], or null when there
52
+ * were none. `transport` is the kind this phase's work targets; the rest are
53
+ * configuration (`auth`), throttling, or the provider's own errors.
54
+ */
55
+ export declare function networkFailureShare(summary: ReliabilitySummary): number | null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 二期 R4 —— 网络失败的统计口径(纯逻辑)。
3
+ *
4
+ * 与 `usage-summary.ts` 同样的拆分理由:`LocalStore` 顶层加载的
5
+ * better-sqlite3 native 模块与 vitest 运行时二进制不兼容,SQL 留在
6
+ * store 里,口径与派生量放这里,可以直接单测。
7
+ *
8
+ * 三个终态分开数,因为它们回答不同的问题:
9
+ *
10
+ * - `failed` 是要修的东西。失败率的分母用它。
11
+ * - `cancelled` 是用户自己的决定,**不算失败**。把停止算成失败,会让
12
+ * 「网络在变差」和「用户等得不耐烦」看起来一样。
13
+ * - `recoveredByRetry` 是重试有没有用的直接度量:重试过、最终成功了
14
+ * 的回合数。它低不代表重试没价值(可能是网络好),但配合失败数看就
15
+ * 能判断 R3 的策略是否合适。
16
+ *
17
+ * 早于这三列存在之前写入的行,`outcome` 取默认值 `completed`——那时候
18
+ * 也没观测过终态,按完成计是唯一诚实的读法。
19
+ */
20
+ /**
21
+ * Share of turns that failed, as a number in [0, 1].
22
+ *
23
+ * Cancelled turns are excluded from the denominator: the user stopping is not
24
+ * evidence about the network. Returns null when nothing was observed, so a
25
+ * reader can tell "no failures" from "no data" — a dashboard that shows 0%
26
+ * on an empty window invites the wrong conclusion.
27
+ */
28
+ export function failureRate(summary) {
29
+ const observed = summary.total - summary.cancelled;
30
+ if (observed <= 0)
31
+ return null;
32
+ return summary.failed / observed;
33
+ }
34
+ /**
35
+ * The network-attributable share of failures, in [0, 1], or null when there
36
+ * were none. `transport` is the kind this phase's work targets; the rest are
37
+ * configuration (`auth`), throttling, or the provider's own errors.
38
+ */
39
+ export function networkFailureShare(summary) {
40
+ if (summary.failed <= 0)
41
+ return null;
42
+ const transport = summary.failuresByKind.find((row) => row.kind === 'transport');
43
+ return (transport?.count ?? 0) / summary.failed;
44
+ }
@@ -3,6 +3,7 @@ import type { InsightKind, InsightsSettings, InsightsSettingsPatch } from './ins
3
3
  import type { LlmSettings } from './llm-settings.js';
4
4
  import type { TelemetrySettings } from './telemetry-settings.js';
5
5
  import type { UsageSummary } from './usage-summary.js';
6
+ import type { ReliabilitySummary } from './reliability-summary.js';
6
7
  import type { WebSearchSettings } from './web-search-settings.js';
7
8
  import type { TenantScope } from './driver.js';
8
9
  /** Explicit asynchronous storage API bound to one tenant and user. */
@@ -79,8 +80,14 @@ export interface ScopedStore {
79
80
  totalTokens: number;
80
81
  cachedPromptTokens?: number;
81
82
  costUsd?: number | null;
83
+ outcome?: 'completed' | 'failed' | 'cancelled';
84
+ failureKind?: string | null;
85
+ attempts?: number | null;
86
+ retried?: boolean;
87
+ usedDirect?: boolean;
82
88
  }): Promise<void>;
83
89
  getUsageSummary(sinceDays?: number): Promise<UsageSummary>;
90
+ getReliabilitySummary(sinceDays?: number): Promise<ReliabilitySummary>;
84
91
  getLlmSettings(): Promise<LlmSettings | null>;
85
92
  setLlmSettings(settings: LlmSettings): Promise<LlmSettings>;
86
93
  getTelemetrySettings(): Promise<TelemetrySettings | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.7.4",
3
+ "version": "0.8.1",
4
4
  "description": "Steerable framework — product-neutral Node HostRuntime and headless HTTP server shared by Tauri desktop and browser deployments; scenario packs extend it through @steerable/pack-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://steerableframework.com/",
@@ -53,9 +53,9 @@
53
53
  "jpeg-js": "^0.4.4",
54
54
  "node-pty": "^1.1.0",
55
55
  "pngjs": "^7.0.0",
56
- "@steerable/agent-harness": "0.7.4",
57
- "@steerable/agent-protocol": "0.7.4",
58
- "@steerable/pack-sdk": "0.7.4"
56
+ "@steerable/agent-harness": "0.8.1",
57
+ "@steerable/agent-protocol": "0.8.1",
58
+ "@steerable/pack-sdk": "0.8.1"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/better-sqlite3": "^7.6.13",