@steerable/agent-shell 0.7.2 → 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/host/runtime.js +24 -1
- package/dist/local-backend/coreloop-stream.d.ts +25 -0
- package/dist/local-backend/coreloop-stream.js +11 -1
- package/dist/local-backend/live-stream.d.ts +10 -1
- package/dist/local-backend/live-stream.js +30 -0
- 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 +43 -3
- package/dist/local-backend/router.js +240 -29
- 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/reverse-approval.d.ts +18 -3
- package/dist/sidecar/reverse-approval.js +23 -10
- package/dist/sidecar/reverse-ask-user.d.ts +19 -0
- package/dist/sidecar/reverse-ask-user.js +38 -1
- package/dist/sidecar/types.d.ts +29 -1
- 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/dist/tool-presentation.js +19 -1
- package/package.json +4 -4
|
@@ -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';
|
|
@@ -27,6 +27,7 @@ import { sanitizeCompatOverrides, sanitizePresetsChoice, sanitizeLlmProvider, sa
|
|
|
27
27
|
import { setDefaultExecTimeoutMs } from '../local-executor.js';
|
|
28
28
|
import { allowEgressForBaseUrl, getActiveEgressBroker, getEgressPosture } from '../sidecar/egress-proxy.js';
|
|
29
29
|
import { buildExecSandbox, parseExecPolicy } from '../sidecar/exec-sandbox.js';
|
|
30
|
+
import { storedAskResult } from '../sidecar/reverse-ask-user.js';
|
|
30
31
|
import { clampExecPolicy } from '../host-tools.js';
|
|
31
32
|
import { getResolvedHostTools, isProductLlmLocked, resolveRuntimeLlmSettings, resolveTurnApproval, resolveTurnChatMode, resolveTurnOrchestration, } from '../host-tools-runtime.js';
|
|
32
33
|
import { SidecarSupervisor } from '../sidecar/index.js';
|
|
@@ -43,8 +44,9 @@ import { formatHistoryForSummary, truncateMiddle, } from './context-compactor.js
|
|
|
43
44
|
import { planRegenerateTruncate, resolveRegenerateContext, resolveRegenerateForkOrdinal, } from './regenerate-helper.js';
|
|
44
45
|
import { resolveBranchActivation } from './branch-helper.js';
|
|
45
46
|
import { detectInterruptedTurn } from './interrupted-helper.js';
|
|
47
|
+
import { resumableTurn } from './resumable-turn.js';
|
|
46
48
|
import { handlePortableRequest } from './portable-service.js';
|
|
47
|
-
import { getProductConfig, isPortableProduct } from '../product-config.js';
|
|
49
|
+
import { getProductConfig, isPortableProduct, productConfigDirectConnection, } from '../product-config.js';
|
|
48
50
|
import { dropCurrentUserMessage } from './history-helper.js';
|
|
49
51
|
import { parseImageAttachments, processImageAttachments } from '../image-attachment.js';
|
|
50
52
|
import { chatAttachmentsDirPath, isValidChatAttachmentsKey } from '../attachments.js';
|
|
@@ -151,23 +153,27 @@ function downloadMimeForPath(filePath) {
|
|
|
151
153
|
return DOWNLOAD_MIME_BY_EXT[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
|
152
154
|
}
|
|
153
155
|
/**
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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.
|
|
156
162
|
*/
|
|
157
|
-
export function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
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;
|
|
171
177
|
}
|
|
172
178
|
/**
|
|
173
179
|
* Parses a pagination-style query param (`?page=`, `?limit=`) into a finite
|
|
@@ -250,6 +256,44 @@ function isRoundBoundaryHook(notice) {
|
|
|
250
256
|
return false;
|
|
251
257
|
return notice.action === 'retry' || notice.action === 'narrate';
|
|
252
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
|
+
}
|
|
253
297
|
function buildLocalAgent() {
|
|
254
298
|
const now = new Date().toISOString();
|
|
255
299
|
return {
|
|
@@ -320,6 +364,7 @@ export class LocalBackendRouter {
|
|
|
320
364
|
broadcast;
|
|
321
365
|
/** 4.6a/4.6c:任务面板的路由入口(列表 + 合并/丢弃)。未注入时任务路由 503。 */
|
|
322
366
|
taskService;
|
|
367
|
+
takeAskReplay = null;
|
|
323
368
|
constructor(toolRouter, options) {
|
|
324
369
|
this.toolRouter = toolRouter;
|
|
325
370
|
this.defaultStore = options.store;
|
|
@@ -331,6 +376,10 @@ export class LocalBackendRouter {
|
|
|
331
376
|
this.broadcast?.('goal-changed', { chatId, goal });
|
|
332
377
|
});
|
|
333
378
|
}
|
|
379
|
+
/** The host records each answered ask; the following tool row stores that text. */
|
|
380
|
+
setAskReplay(take) {
|
|
381
|
+
this.takeAskReplay = take;
|
|
382
|
+
}
|
|
334
383
|
get store() {
|
|
335
384
|
return this.storeContext.getStore() ?? this.defaultStore;
|
|
336
385
|
}
|
|
@@ -2034,6 +2083,14 @@ export class LocalBackendRouter {
|
|
|
2034
2083
|
data: await this.store.getUsageSummary(days),
|
|
2035
2084
|
};
|
|
2036
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
|
+
}
|
|
2037
2094
|
if (method === 'POST' && pathname.match(/^\/api\/v2\/chats\/[^/]+\/suggest-replies$/)) {
|
|
2038
2095
|
return {
|
|
2039
2096
|
status: 200,
|
|
@@ -2448,25 +2505,42 @@ export class LocalBackendRouter {
|
|
|
2448
2505
|
});
|
|
2449
2506
|
}
|
|
2450
2507
|
else if (isResume) {
|
|
2451
|
-
//
|
|
2452
|
-
//
|
|
2453
|
-
//
|
|
2454
|
-
//
|
|
2508
|
+
// 继续上一轮 —— 不追加新用户消息(记录里已有),而是让 sidecar 回放
|
|
2509
|
+
// durable record 的投影作为循环种子。允许续跑的三种终态见
|
|
2510
|
+
// `resumable-turn.ts`:崩溃中断、失败、用户停止。正常完成的回合拒绝,
|
|
2511
|
+
// 否则会给完整对话凭空多接一段回复。
|
|
2455
2512
|
const prior = await this.store.listMessages(chatId, 200);
|
|
2456
|
-
const
|
|
2457
|
-
|
|
2513
|
+
const lastAssistant = prior.find((m) => m.role === 'assistant');
|
|
2514
|
+
const reason = resumableTurn({
|
|
2515
|
+
turnActive: (await this.store.getTurnActive(chatId)) !== null,
|
|
2458
2516
|
streamActive: getActiveCoreLoopStreamId(chatId) !== undefined,
|
|
2459
2517
|
lastMessageRole: prior[0]?.role ?? null,
|
|
2518
|
+
lastAssistantStatus: readCompletionStatus(lastAssistant),
|
|
2460
2519
|
});
|
|
2461
2520
|
const lastUser = prior.find(m => m.role === 'user');
|
|
2462
|
-
if (!
|
|
2463
|
-
emit(this.sse('error', { message: '
|
|
2521
|
+
if (!reason || !lastUser) {
|
|
2522
|
+
emit(this.sse('error', { message: '没有可继续的回复' }));
|
|
2464
2523
|
return { status: 409 };
|
|
2465
2524
|
}
|
|
2466
2525
|
// 触发轮的用户消息仍在库里(崩溃只丢了回复)——取其原文喂给
|
|
2467
2526
|
// buildConversationMessages,让 mode preamble / deferred-exec 检测
|
|
2468
2527
|
// 仍然基于真实诉求。发给 sidecar 的 messages 为空(见下方调用点)。
|
|
2469
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
|
+
}
|
|
2470
2544
|
}
|
|
2471
2545
|
else {
|
|
2472
2546
|
userMessageText = String(payload.message || '').trim();
|
|
@@ -3654,12 +3728,33 @@ export class LocalBackendRouter {
|
|
|
3654
3728
|
// approval:"off" 或 STEERABLE_APPROVAL=0 本轮不挂审批。
|
|
3655
3729
|
const approval = resolveTurnApproval();
|
|
3656
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;
|
|
3657
3748
|
const executedActions = [];
|
|
3658
3749
|
const timeline = [];
|
|
3659
3750
|
// 编排子代理生命周期事件累积(切回后用于重建 currentTurnChildren)。
|
|
3660
3751
|
const children = [];
|
|
3661
3752
|
let completionStatus = 'completed';
|
|
3662
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;
|
|
3663
3758
|
let outcomeTraceId;
|
|
3664
3759
|
// Every pass records its own sidecar trace; all of them are persisted
|
|
3665
3760
|
// against the one assistant message this turn produces.
|
|
@@ -3674,6 +3769,9 @@ export class LocalBackendRouter {
|
|
|
3674
3769
|
// 实时快照:executedActions / timeline / children 传引用(原地 mutate),
|
|
3675
3770
|
// content 由 onText 逐段更新。切走再切回的 renderer 靠 GET /live-stream 读它。
|
|
3676
3771
|
const live = registerLiveStream(chatId, { executedActions, timeline, children });
|
|
3772
|
+
live.notify = () => {
|
|
3773
|
+
emit(this.sseData({ type: 'executed_actions', actions: [...executedActions] }));
|
|
3774
|
+
};
|
|
3677
3775
|
// 场景包的回合观察器(如识别包技能调用并广播产物更新)。
|
|
3678
3776
|
const packObservers = beginPackTurnObservers({ chatId, broadcast: this.broadcast ?? undefined });
|
|
3679
3777
|
// 回合产物文件列表(回合收尾时收集):扫描根与 exec 沙箱可写根同源
|
|
@@ -3726,6 +3824,9 @@ export class LocalBackendRouter {
|
|
|
3726
3824
|
provider: sidecarWireProvider(settings.provider),
|
|
3727
3825
|
model: overrideModel || settings.model,
|
|
3728
3826
|
reasoningEffort,
|
|
3827
|
+
// 产品声明模型服务是否直连(跳过系统代理)。只在产品显式打开时下发,
|
|
3828
|
+
// 未声明的产品线上字节与旧版完全一致。
|
|
3829
|
+
...(productConfigDirectConnection() ? { directConnection: true } : {}),
|
|
3729
3830
|
// W2.2.2 凭证代理:broker 活跃且命中 provider 主机时,baseUrl 改写
|
|
3730
3831
|
// 为 http(代理解析绝对 URI 后注入凭证转发 https),apiKey 不再下
|
|
3731
3832
|
// 发——sidecar 进程(沙箱内、可被 agent 行为影响)永远拿不到真实
|
|
@@ -3792,8 +3893,32 @@ export class LocalBackendRouter {
|
|
|
3792
3893
|
assistantText += delta;
|
|
3793
3894
|
appendTimelineDelta(timeline, 'text', delta);
|
|
3794
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;
|
|
3795
3899
|
emit(this.sseData({ content: delta }));
|
|
3796
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
|
+
},
|
|
3797
3922
|
onReasoning: (delta) => {
|
|
3798
3923
|
appendTimelineDelta(timeline, 'reasoning', delta);
|
|
3799
3924
|
emit(this.sseData({ type: 'reasoning', content: delta }));
|
|
@@ -3817,13 +3942,15 @@ export class LocalBackendRouter {
|
|
|
3817
3942
|
const idx = action.id
|
|
3818
3943
|
? executedActions.findIndex((row) => row.id === action.id)
|
|
3819
3944
|
: -1;
|
|
3945
|
+
const replay = action.tool === 'ask_user' ? this.takeAskReplay?.(args.chatId) : undefined;
|
|
3946
|
+
const result = replay ? storedAskResult(replay) : action.result;
|
|
3820
3947
|
const row = {
|
|
3821
3948
|
id: action.id,
|
|
3822
3949
|
tool: action.tool,
|
|
3823
3950
|
mode: chatMode,
|
|
3824
3951
|
arguments: action.arguments,
|
|
3825
|
-
view: presentToolCall(action.tool, action.arguments,
|
|
3826
|
-
result
|
|
3952
|
+
view: presentToolCall(action.tool, action.arguments, result),
|
|
3953
|
+
result,
|
|
3827
3954
|
success: action.success,
|
|
3828
3955
|
error: action.error,
|
|
3829
3956
|
durationMs: action.durationMs,
|
|
@@ -3845,6 +3972,17 @@ export class LocalBackendRouter {
|
|
|
3845
3972
|
}
|
|
3846
3973
|
},
|
|
3847
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
|
+
}
|
|
3848
3986
|
if (kind === 'budget_exhausted') {
|
|
3849
3987
|
// 预算维度(rounds/tokens)在 notice.budget;notice.kind 是信封
|
|
3850
3988
|
// 类型(loop 事件 data 里的 budget 键经 sidecar 透传)。message
|
|
@@ -3909,6 +4047,7 @@ export class LocalBackendRouter {
|
|
|
3909
4047
|
afterPass: async (outcome) => {
|
|
3910
4048
|
if (outcome.traceId)
|
|
3911
4049
|
passTraceIds.push(outcome.traceId);
|
|
4050
|
+
turnPasses += 1;
|
|
3912
4051
|
// W6-9 用量归因:落一条 chat 用量事件(含成本估算,无单价模型 costUsd=null)。
|
|
3913
4052
|
// 每趟各落一条——续跑的花费是真实发生的,不该被合并掉。
|
|
3914
4053
|
if (outcome.usage) {
|
|
@@ -3954,11 +4093,16 @@ export class LocalBackendRouter {
|
|
|
3954
4093
|
catch (err) {
|
|
3955
4094
|
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
3956
4095
|
completionStatus = 'cancelled';
|
|
3957
|
-
|
|
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';
|
|
3958
4101
|
}
|
|
3959
4102
|
else {
|
|
3960
4103
|
completionStatus = 'failed';
|
|
3961
4104
|
completionReason = err instanceof Error ? err.message : String(err);
|
|
4105
|
+
failureInfo = readTurnFailureInfo(err);
|
|
3962
4106
|
emit(this.sse('error', { message: completionReason }));
|
|
3963
4107
|
}
|
|
3964
4108
|
// streamCoreLoopTurn attaches the sidecar trace id to failures — the
|
|
@@ -3969,6 +4113,72 @@ export class LocalBackendRouter {
|
|
|
3969
4113
|
passTraceIds.push(failureTraceId);
|
|
3970
4114
|
}
|
|
3971
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
|
+
}
|
|
3972
4182
|
const durationMs = turnDurationMs((await this.store.getTurnActive(chatId))?.startedAt, Date.now());
|
|
3973
4183
|
// 回合产物文件列表(成功/失败/取消都收集——半截回合写出的文件同样是
|
|
3974
4184
|
// 产物)。收集失败只记日志,永远不该拖垮回合收尾。
|
|
@@ -3999,6 +4209,7 @@ export class LocalBackendRouter {
|
|
|
3999
4209
|
...(durationMs != null ? { durationMs } : {}),
|
|
4000
4210
|
...(autoContinuations > 0 ? { autoContinuations } : {}),
|
|
4001
4211
|
...(turnFiles.length > 0 ? { turnFiles } : {}),
|
|
4212
|
+
...(failureInfo ? { failure: failureInfo } : {}),
|
|
4002
4213
|
}));
|
|
4003
4214
|
try {
|
|
4004
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;
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Fail-closed by construction: no window, no listener, a renderer error,
|
|
7
7
|
* or an invalid reply all become `deny_once` — never a hang, never an
|
|
8
|
-
* auto-allow.
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
8
|
+
* auto-allow. A command on the dangerous-command list is the exception:
|
|
9
|
+
* the bridge returns `allow_once` without a card so the executor can
|
|
10
|
+
* reject it with the blocked-command error. An explicit `timeoutMs` on
|
|
11
|
+
* the approval config still fails closed as `timed_out`; the desktop
|
|
12
|
+
* omits it, so the prompt waits until the user decides.
|
|
11
13
|
*
|
|
12
14
|
* The renderer answers via the `approval:decide` invoke with the requestId
|
|
13
15
|
* echoed back; decisions are validated against the algebra's kind set
|
|
@@ -68,6 +70,19 @@ export interface ApprovalBridgeDeps {
|
|
|
68
70
|
arguments: Record<string, unknown>;
|
|
69
71
|
chatId?: string;
|
|
70
72
|
}) => boolean | 'project' | 'workspace' | Promise<boolean | 'project' | 'workspace'>;
|
|
73
|
+
/**
|
|
74
|
+
* Pattern that will block this command in the executor.
|
|
75
|
+
* When set, the bridge does not show a card.
|
|
76
|
+
*/
|
|
77
|
+
blockedCommand?: (toolName: string, args: Record<string, unknown>) => string | null;
|
|
78
|
+
/**
|
|
79
|
+
* An allowed network_egress grant is not a tool result. The host stores it
|
|
80
|
+
* on the running turn so the row survives reopening the chat.
|
|
81
|
+
*/
|
|
82
|
+
recordEgressGrant?: (prompt: ApprovalPromptRequest, decision: {
|
|
83
|
+
kind: ApprovalDecisionKind;
|
|
84
|
+
reason: string;
|
|
85
|
+
}) => void;
|
|
71
86
|
}
|
|
72
87
|
export interface ApprovalBridge {
|
|
73
88
|
/** Reverse-channel handler for the sidecar's `approval.request`. */
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Fail-closed by construction: no window, no listener, a renderer error,
|
|
7
7
|
* or an invalid reply all become `deny_once` — never a hang, never an
|
|
8
|
-
* auto-allow.
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
8
|
+
* auto-allow. A command on the dangerous-command list is the exception:
|
|
9
|
+
* the bridge returns `allow_once` without a card so the executor can
|
|
10
|
+
* reject it with the blocked-command error. An explicit `timeoutMs` on
|
|
11
|
+
* the approval config still fails closed as `timed_out`; the desktop
|
|
12
|
+
* omits it, so the prompt waits until the user decides.
|
|
11
13
|
*
|
|
12
14
|
* The renderer answers via the `approval:decide` invoke with the requestId
|
|
13
15
|
* echoed back; decisions are validated against the algebra's kind set
|
|
@@ -38,15 +40,19 @@ export function createApprovalBridge(deps) {
|
|
|
38
40
|
if (!toolName) {
|
|
39
41
|
return { kind: 'deny_once', reason: 'malformed approval request (no toolName)' };
|
|
40
42
|
}
|
|
43
|
+
const args = p.arguments && typeof p.arguments === 'object'
|
|
44
|
+
? p.arguments
|
|
45
|
+
: {};
|
|
46
|
+
if (deps.blockedCommand?.(toolName, args)) {
|
|
47
|
+
deps.onLog?.(`approval: ${toolName} matches the dangerous-command list; no card`);
|
|
48
|
+
return { kind: 'allow_once', reason: '' };
|
|
49
|
+
}
|
|
41
50
|
if (!deps.hasWindow()) {
|
|
42
51
|
deps.onLog?.(`approval: no renderer window; denying ${toolName} once`);
|
|
43
52
|
return deny('no renderer window available to approve the call');
|
|
44
53
|
}
|
|
45
54
|
const requestId = randomUUID();
|
|
46
55
|
const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
|
|
47
|
-
const args = p.arguments && typeof p.arguments === 'object'
|
|
48
|
-
? p.arguments
|
|
49
|
-
: {};
|
|
50
56
|
const category = typeof p.category === 'string' ? p.category : toolName;
|
|
51
57
|
const categoryLabel = deps.categoryLabel?.(toolName, category);
|
|
52
58
|
const fence = deps.outsideWorkspace
|
|
@@ -91,10 +97,17 @@ export function createApprovalBridge(deps) {
|
|
|
91
97
|
entry.resolve(deny(`renderer returned an invalid decision kind: ${String(p.kind)}`));
|
|
92
98
|
return { ok: true };
|
|
93
99
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
100
|
+
const reason = typeof p.reason === 'string' ? p.reason : '';
|
|
101
|
+
entry.resolve({ kind, reason });
|
|
102
|
+
if (entry.prompt.toolName === 'network_egress'
|
|
103
|
+
&& (kind === 'allow_once' || kind === 'allow_for_session' || kind === 'allow_always')) {
|
|
104
|
+
try {
|
|
105
|
+
deps.recordEgressGrant?.(entry.prompt, { kind, reason });
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
deps.onLog?.(`approval: failed to store egress grant: ${err instanceof Error ? err.message : String(err)}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
98
111
|
return { ok: true };
|
|
99
112
|
},
|
|
100
113
|
};
|
|
@@ -19,6 +19,13 @@ export interface AskUserPromptRequest {
|
|
|
19
19
|
/** 发起提问的对话。renderer 只在这条会话的输入框展示卡片。 */
|
|
20
20
|
chatId?: string;
|
|
21
21
|
}
|
|
22
|
+
/** One answered ask, kept so the tool row can store the full text. */
|
|
23
|
+
export interface ResolvedAsk {
|
|
24
|
+
intro: string;
|
|
25
|
+
questions: Array<Record<string, unknown>>;
|
|
26
|
+
answers: Record<string, string | string[]>;
|
|
27
|
+
unattended?: boolean;
|
|
28
|
+
}
|
|
22
29
|
export interface AskUserBridgeDeps {
|
|
23
30
|
broadcast: (channel: 'ask-user:request', payload: AskUserPromptRequest) => void;
|
|
24
31
|
/** 是否有能应答的窗口/浏览器连接;没有则立即空答。 */
|
|
@@ -34,5 +41,17 @@ export interface AskUserBridge {
|
|
|
34
41
|
answer: (payload: unknown) => {
|
|
35
42
|
ok: boolean;
|
|
36
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* The next answered ask for this chat. The tool result that follows only
|
|
46
|
+
* carries a short preview, so the turn stores this instead.
|
|
47
|
+
*/
|
|
48
|
+
takeResolved: (chatId: string) => ResolvedAsk | undefined;
|
|
37
49
|
}
|
|
50
|
+
/** The tool-row result stored in place of the truncated preview. */
|
|
51
|
+
export declare function storedAskResult(ask: ResolvedAsk): {
|
|
52
|
+
intro: string;
|
|
53
|
+
questions: Array<Record<string, unknown>>;
|
|
54
|
+
answers: Record<string, string | string[]>;
|
|
55
|
+
unattended?: true;
|
|
56
|
+
};
|
|
38
57
|
export declare function createAskUserBridge(deps: AskUserBridgeDeps): AskUserBridge;
|