@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.
@@ -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
- * 把 CoreLoop/LLM 的底层错误翻译成适合直接显示在助手消息里的中文提示。
155
- * 原始错误仍保存在 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.
156
162
  */
157
- export function userFacingCoreLoopFailure(reason) {
158
- if (reason.includes('HTTP 401') || reason.includes('HTTP 403')) {
159
- return '[回复失败] 模型服务认证失败,请检查 API Key 配置。';
160
- }
161
- if (reason.includes('HTTP 404')) {
162
- return '[回复失败] 模型服务或模型名不存在(HTTP 404),请检查 baseUrl 与 model 配置。';
163
- }
164
- if (reason.includes('ECONNREFUSED') ||
165
- reason.includes('ENOTFOUND') ||
166
- reason.includes('ETIMEDOUT') ||
167
- reason.includes('fetch failed')) {
168
- return '[回复失败] 无法连接模型服务,请检查网络或 baseUrl 配置。';
169
- }
170
- 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;
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
- // W7-1: 继续被中断的 turn —— 不追加新用户消息(记录里已有),而是让
2452
- // sidecar 回放 durable record 的投影作为循环种子。只在检测确为
2453
- // interrupted 时接受:对一个已正常完结的 turn 续跑只会给完整对话
2454
- // 凭空多接一段回复。
2508
+ // 继续上一轮 —— 不追加新用户消息(记录里已有),而是让 sidecar 回放
2509
+ // durable record 的投影作为循环种子。允许续跑的三种终态见
2510
+ // `resumable-turn.ts`:崩溃中断、失败、用户停止。正常完成的回合拒绝,
2511
+ // 否则会给完整对话凭空多接一段回复。
2455
2512
  const prior = await this.store.listMessages(chatId, 200);
2456
- const interrupted = detectInterruptedTurn({
2457
- 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,
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 (!interrupted || !lastUser) {
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, action.result),
3826
- result: action.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
- 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';
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`.
@@ -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;
@@ -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. An explicit `timeoutMs` on the approval config still fails
9
- * closed as `timed_out`; the desktop omits it, so the prompt waits until
10
- * the user decides.
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. An explicit `timeoutMs` on the approval config still fails
9
- * closed as `timed_out`; the desktop omits it, so the prompt waits until
10
- * the user decides.
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
- entry.resolve({
95
- kind,
96
- reason: typeof p.reason === 'string' ? p.reason : '',
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;