@steerable/agent-shell 0.7.4 → 0.8.0
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/dist/local-backend/coreloop-stream.d.ts +24 -0
- package/dist/local-backend/coreloop-stream.js +11 -1
- package/dist/local-backend/resumable-turn.d.ts +48 -0
- package/dist/local-backend/resumable-turn.js +53 -0
- package/dist/local-backend/router.d.ts +39 -3
- package/dist/local-backend/router.js +227 -27
- package/dist/local-backend/turn-timeline.d.ts +14 -0
- package/dist/local-backend/turn-timeline.js +21 -0
- package/dist/product-config.d.ts +17 -0
- package/dist/product-config.js +9 -0
- package/dist/sidecar/types.d.ts +27 -0
- package/dist/storage/index.d.ts +26 -0
- package/dist/storage/index.js +67 -3
- package/dist/storage/reliability-summary.d.ts +55 -0
- package/dist/storage/reliability-summary.js +44 -0
- package/dist/storage/scoped-store.d.ts +7 -0
- package/package.json +4 -4
|
@@ -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
|
-
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
-
*
|
|
156
|
-
*
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
return
|
|
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
|
-
//
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
2460
|
-
//
|
|
2508
|
+
// 继续上一轮 —— 不追加新用户消息(记录里已有),而是让 sidecar 回放
|
|
2509
|
+
// durable record 的投影作为循环种子。允许续跑的三种终态见
|
|
2510
|
+
// `resumable-turn.ts`:崩溃中断、失败、用户停止。正常完成的回合拒绝,
|
|
2511
|
+
// 否则会给完整对话凭空多接一段回复。
|
|
2461
2512
|
const prior = await this.store.listMessages(chatId, 200);
|
|
2462
|
-
const
|
|
2463
|
-
|
|
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 (!
|
|
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
|
-
|
|
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`.
|
package/dist/product-config.d.ts
CHANGED
|
@@ -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` 的目录开。 */
|
package/dist/product-config.js
CHANGED
|
@@ -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;
|
package/dist/sidecar/types.d.ts
CHANGED
|
@@ -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
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -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);无单价模型的
|
package/dist/storage/index.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.8.0",
|
|
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.
|
|
57
|
-
"@steerable/agent-protocol": "0.
|
|
58
|
-
"@steerable/pack-sdk": "0.
|
|
56
|
+
"@steerable/agent-harness": "0.8.0",
|
|
57
|
+
"@steerable/agent-protocol": "0.8.0",
|
|
58
|
+
"@steerable/pack-sdk": "0.8.0"
|
|
59
59
|
},
|
|
60
60
|
"devDependencies": {
|
|
61
61
|
"@types/better-sqlite3": "^7.6.13",
|