@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.
@@ -11,25 +11,59 @@
11
11
  */
12
12
  import { randomUUID } from 'node:crypto';
13
13
  const EMPTY_REPLY = { answers: {} };
14
+ function rememberAsk(resolved, chatId, intro, questions, answers, unattended) {
15
+ if (questions.length === 0)
16
+ return;
17
+ const key = chatId ?? '';
18
+ const queue = resolved.get(key) ?? [];
19
+ queue.push({
20
+ intro,
21
+ questions,
22
+ answers,
23
+ ...(unattended ? { unattended: true } : {}),
24
+ });
25
+ resolved.set(key, queue);
26
+ }
27
+ function shiftAsk(resolved, key) {
28
+ const queue = resolved.get(key);
29
+ if (!queue || queue.length === 0)
30
+ return undefined;
31
+ const next = queue.shift();
32
+ if (queue.length === 0)
33
+ resolved.delete(key);
34
+ return next;
35
+ }
36
+ /** The tool-row result stored in place of the truncated preview. */
37
+ export function storedAskResult(ask) {
38
+ return {
39
+ intro: ask.intro,
40
+ questions: ask.questions,
41
+ answers: ask.answers,
42
+ ...(ask.unattended ? { unattended: true } : {}),
43
+ };
44
+ }
14
45
  export function createAskUserBridge(deps) {
15
46
  const pending = new Map();
47
+ const resolved = new Map();
16
48
  return {
17
49
  handler: (params) => {
18
50
  const p = (params ?? {});
19
51
  const questions = Array.isArray(p.questions) ? p.questions : [];
20
52
  const chatId = typeof p.chatId === 'string' && p.chatId ? p.chatId : undefined;
53
+ const intro = typeof p.intro === 'string' ? p.intro : '';
21
54
  if (questions.length === 0) {
22
55
  deps.onLog?.('ask_user: malformed request (no questions); answering empty');
23
56
  return Promise.resolve({ ...EMPTY_REPLY });
24
57
  }
25
58
  if (!deps.hasWindow()) {
26
59
  deps.onLog?.('ask_user: no renderer window; answering empty');
60
+ rememberAsk(resolved, chatId, intro, questions, {}, false);
27
61
  return Promise.resolve({ ...EMPTY_REPLY });
28
62
  }
29
63
  const requestId = randomUUID();
30
64
  const prompt = {
31
65
  requestId,
32
- intro: typeof p.intro === 'string' ? p.intro : '',
66
+ intro,
33
67
  questions: questions,
34
68
  ...(chatId ? { chatId } : {}),
35
69
  };
@@ -41,6 +75,7 @@ export function createAskUserBridge(deps) {
41
75
  catch (err) {
42
76
  pending.delete(requestId);
43
77
  deps.onLog?.(`ask_user: broadcast failed: ${String(err)}`);
78
+ rememberAsk(resolved, chatId, intro, prompt.questions, {}, false);
44
79
  resolve({ ...EMPTY_REPLY });
45
80
  }
46
81
  });
@@ -55,8 +90,10 @@ export function createAskUserBridge(deps) {
55
90
  const answers = p.answers && typeof p.answers === 'object' && !Array.isArray(p.answers)
56
91
  ? p.answers
57
92
  : {};
93
+ rememberAsk(resolved, entry.prompt.chatId, entry.prompt.intro, entry.prompt.questions, answers, p.unattended === true);
58
94
  entry.resolve({ answers });
59
95
  return { ok: true };
60
96
  },
97
+ takeResolved: (chatId) => shiftAsk(resolved, chatId) ?? (chatId ? shiftAsk(resolved, '') : undefined),
61
98
  };
62
99
  }
@@ -531,12 +531,13 @@ export interface SidecarStreamChunk {
531
531
  backend?: string;
532
532
  enforcement: string;
533
533
  };
534
- /** Session-allow reuse. `cached` means this call did not show a card. */
534
+ /** Session-allow reuse. `cached` means this call did not show a card. `auto` is an unattended allow. */
535
535
  approval?: {
536
536
  kind?: string;
537
537
  category?: string;
538
538
  cached?: boolean;
539
539
  waitMs?: number;
540
+ auto?: boolean;
540
541
  };
541
542
  };
542
543
  /** CoreLoop notices: soft_timeout / budget_exhausted / round_end / hook_action. */
@@ -544,6 +545,17 @@ export interface SidecarStreamChunk {
544
545
  kind: string;
545
546
  [key: string]: unknown;
546
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
+ };
547
559
  finishReason?: string;
548
560
  usage?: {
549
561
  promptTokens: number;
@@ -579,6 +591,22 @@ export interface SidecarStreamError {
579
591
  traceId?: string;
580
592
  kind: string;
581
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;
582
610
  }
583
611
  /**
584
612
  * `agent.child` notification payload (child-agent lifecycle), demuxed by
@@ -4,6 +4,7 @@ import { type TelemetrySettings } from './telemetry-settings.js';
4
4
  import { type WebSearchSettings } from './web-search-settings.js';
5
5
  import { type InsightKind, type InsightsProfile, type InsightsSettings, type InsightsSettingsPatch } from './insights-settings.js';
6
6
  import { type UsageSummary } from './usage-summary.js';
7
+ import type { ReliabilitySummary } from './reliability-summary.js';
7
8
  import type { TenantScope } from './driver.js';
8
9
  import type { ScopedStore } from './scoped-store.js';
9
10
  export type { UsageModelBucket, UsageSummary } from './usage-summary.js';
@@ -326,7 +327,32 @@ export declare class SqliteScopedStore implements ScopedStore {
326
327
  totalTokens: number;
327
328
  cachedPromptTokens?: number;
328
329
  costUsd?: number | null;
330
+ /** How the turn ended; omitted keeps the historical 'completed' default. */
331
+ outcome?: 'completed' | 'failed' | 'cancelled';
332
+ /** Provider classification, recorded only for `failed`. */
333
+ failureKind?: string | null;
334
+ /** Model requests spent on the turn, retries included. */
335
+ attempts?: number | null;
336
+ /** Whether any request in the turn was retried. */
337
+ retried?: boolean;
338
+ /** Whether any request went direct instead of through the proxy. */
339
+ usedDirect?: boolean;
329
340
  }): Promise<void>;
341
+ /**
342
+ * Reliability summary for the last `sinceDays` days.
343
+ *
344
+ * Splits the three terminal states apart because they answer different
345
+ * questions: a failed turn is something to fix, a cancelled one is the user's
346
+ * decision and must not inflate the failure rate, and a retried turn that
347
+ * completed is the measure of whether retrying is doing its job. Turns
348
+ * recorded before these columns existed default to `completed`, which is the
349
+ * only honest reading — their outcome was not observed.
350
+ *
351
+ * @param sinceDays Window length in days, ending now.
352
+ * @returns Counts per outcome, failures broken down by provider
353
+ * classification, and how many completed turns had retried.
354
+ */
355
+ getReliabilitySummary(sinceDays?: number): Promise<ReliabilitySummary>;
330
356
  /**
331
357
  * 聚合最近 `sinceDays` 天的用量:按 model 分桶( token 合计 + 成本合计),
332
358
  * 外加总计。成本只统计有单价的模型(cost_usd 非 NULL);无单价模型的
@@ -335,6 +335,20 @@ export class SqliteScopedStore {
335
335
  this.ensureColumn('tasks', 'owner_instance_id', 'TEXT');
336
336
  this.ensureColumn('insights_outbox', 'claimed_by', 'TEXT');
337
337
  this.ensureColumn('insights_outbox', 'claimed_at', 'TEXT');
338
+ // 网络失败统计(二期 R4)。一次回合一行,与 token 用量共用一张表:
339
+ // 两者都是「这一回合发生了什么」的事实,分成两张表只会让复盘时
340
+ // 要 join。
341
+ //
342
+ // outcome 取值 completed / failed / cancelled。cancelled 是用户主动
343
+ // 停止,不算失败——界面与统计都不该把它和网络故障混为一谈。
344
+ this.ensureColumn('usage_events', 'outcome', `TEXT NOT NULL DEFAULT 'completed'`);
345
+ // 失败类别(provider 分类:transport / auth / rate_limit / …)。
346
+ this.ensureColumn('usage_events', 'failure_kind', 'TEXT');
347
+ // 本回合一共发出了几次模型请求(含重试)。
348
+ this.ensureColumn('usage_events', 'attempts', 'INTEGER');
349
+ // 是否发生过重试,以及是否改走过直连。
350
+ this.ensureColumn('usage_events', 'retried', 'INTEGER NOT NULL DEFAULT 0');
351
+ this.ensureColumn('usage_events', 'used_direct', 'INTEGER NOT NULL DEFAULT 0');
338
352
  // 智能体技能面(增量迁移):load_all_skills = 无视触发条件全量加载。
339
353
  // 此前这个行为是 router 里 `chatAgentId === 'all-round-assistant'` 的
340
354
  // 硬编码,自建智能体拿不到;建列时一次性回填给内置「智能助手」,之后
@@ -1124,9 +1138,59 @@ export class SqliteScopedStore {
1124
1138
  async recordUsageEvent(input) {
1125
1139
  this.db.prepare(`
1126
1140
  INSERT INTO usage_events
1127
- (tenant_id, user_id, id, chat_id, kind, provider, model, prompt_tokens, completion_tokens, total_tokens, cached_prompt_tokens, cost_usd, created_at)
1128
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1129
- `).run(this.scope.tenantId, this.scope.userId, randomUUID(), input.chatId ?? null, input.kind, input.provider ?? null, input.model ?? null, Math.max(0, Math.floor(input.promptTokens)), Math.max(0, Math.floor(input.completionTokens)), Math.max(0, Math.floor(input.totalTokens)), Math.max(0, Math.floor(input.cachedPromptTokens ?? 0)), input.costUsd ?? null, new Date().toISOString());
1141
+ (tenant_id, user_id, id, chat_id, kind, provider, model, prompt_tokens, completion_tokens, total_tokens, cached_prompt_tokens, cost_usd, created_at, outcome, failure_kind, attempts, retried, used_direct)
1142
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1143
+ `).run(this.scope.tenantId, this.scope.userId, randomUUID(), input.chatId ?? null, input.kind, input.provider ?? null, input.model ?? null, Math.max(0, Math.floor(input.promptTokens)), Math.max(0, Math.floor(input.completionTokens)), Math.max(0, Math.floor(input.totalTokens)), Math.max(0, Math.floor(input.cachedPromptTokens ?? 0)), input.costUsd ?? null, new Date().toISOString(), input.outcome ?? 'completed', input.failureKind ?? null, input.attempts ?? null, input.retried ? 1 : 0, input.usedDirect ? 1 : 0);
1144
+ }
1145
+ /**
1146
+ * Reliability summary for the last `sinceDays` days.
1147
+ *
1148
+ * Splits the three terminal states apart because they answer different
1149
+ * questions: a failed turn is something to fix, a cancelled one is the user's
1150
+ * decision and must not inflate the failure rate, and a retried turn that
1151
+ * completed is the measure of whether retrying is doing its job. Turns
1152
+ * recorded before these columns existed default to `completed`, which is the
1153
+ * only honest reading — their outcome was not observed.
1154
+ *
1155
+ * @param sinceDays Window length in days, ending now.
1156
+ * @returns Counts per outcome, failures broken down by provider
1157
+ * classification, and how many completed turns had retried.
1158
+ */
1159
+ async getReliabilitySummary(sinceDays = 7) {
1160
+ const since = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString();
1161
+ const totals = this.db
1162
+ .prepare(`SELECT
1163
+ COUNT(*) AS total,
1164
+ SUM(CASE WHEN outcome = 'failed' THEN 1 ELSE 0 END) AS failed,
1165
+ SUM(CASE WHEN outcome = 'cancelled' THEN 1 ELSE 0 END) AS cancelled,
1166
+ SUM(CASE WHEN outcome = 'completed' AND retried = 1 THEN 1 ELSE 0 END) AS recovered,
1167
+ SUM(CASE WHEN used_direct = 1 THEN 1 ELSE 0 END) AS used_direct
1168
+ FROM usage_events
1169
+ WHERE tenant_id = ? AND user_id = ? AND created_at >= ?`)
1170
+ .get(this.scope.tenantId, this.scope.userId, since);
1171
+ const byKind = this.db
1172
+ .prepare(
1173
+ // Group by the expression, not the output alias: a bare `GROUP BY kind`
1174
+ // resolves to a source column when one exists, which silently merges
1175
+ // every row into the wrong bucket.
1176
+ `SELECT COALESCE(failure_kind, 'unknown') AS kind, COUNT(*) AS count
1177
+ FROM usage_events
1178
+ WHERE tenant_id = ? AND user_id = ? AND created_at >= ?
1179
+ AND outcome = 'failed'
1180
+ GROUP BY COALESCE(failure_kind, 'unknown')
1181
+ ORDER BY count DESC, kind`)
1182
+ .all(this.scope.tenantId, this.scope.userId, since);
1183
+ return {
1184
+ sinceDays,
1185
+ total: totals.total ?? 0,
1186
+ failed: totals.failed ?? 0,
1187
+ cancelled: totals.cancelled ?? 0,
1188
+ /** Completed turns that had retried at least once. */
1189
+ recoveredByRetry: totals.recovered ?? 0,
1190
+ /** Turns where the model request went direct instead of via a proxy. */
1191
+ usedDirect: totals.used_direct ?? 0,
1192
+ failuresByKind: byKind.map((row) => ({ kind: row.kind, count: row.count })),
1193
+ };
1130
1194
  }
1131
1195
  /**
1132
1196
  * 聚合最近 `sinceDays` 天的用量:按 model 分桶( token 合计 + 成本合计),
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 二期 R4 —— 网络失败的统计口径(纯逻辑)。
3
+ *
4
+ * 与 `usage-summary.ts` 同样的拆分理由:`LocalStore` 顶层加载的
5
+ * better-sqlite3 native 模块与 vitest 运行时二进制不兼容,SQL 留在
6
+ * store 里,口径与派生量放这里,可以直接单测。
7
+ *
8
+ * 三个终态分开数,因为它们回答不同的问题:
9
+ *
10
+ * - `failed` 是要修的东西。失败率的分母用它。
11
+ * - `cancelled` 是用户自己的决定,**不算失败**。把停止算成失败,会让
12
+ * 「网络在变差」和「用户等得不耐烦」看起来一样。
13
+ * - `recoveredByRetry` 是重试有没有用的直接度量:重试过、最终成功了
14
+ * 的回合数。它低不代表重试没价值(可能是网络好),但配合失败数看就
15
+ * 能判断 R3 的策略是否合适。
16
+ *
17
+ * 早于这三列存在之前写入的行,`outcome` 取默认值 `completed`——那时候
18
+ * 也没观测过终态,按完成计是唯一诚实的读法。
19
+ */
20
+ /** Provider failure classification, as `LLMError.kind` names them. */
21
+ export type FailureKind = 'transport' | 'rate_limit' | 'server' | 'auth' | 'invalid_request' | 'context_overflow' | 'unknown';
22
+ /** One failure category with its count. */
23
+ export interface FailureKindCount {
24
+ kind: string;
25
+ count: number;
26
+ }
27
+ /** Reliability numbers for one window. */
28
+ export interface ReliabilitySummary {
29
+ sinceDays: number;
30
+ /** Every recorded turn in the window, whatever its outcome. */
31
+ total: number;
32
+ failed: number;
33
+ cancelled: number;
34
+ /** Completed turns in which at least one model request was retried. */
35
+ recoveredByRetry: number;
36
+ /** Turns whose model request went direct instead of through a proxy. */
37
+ usedDirect: number;
38
+ /** Failure counts per provider classification, largest first. */
39
+ failuresByKind: FailureKindCount[];
40
+ }
41
+ /**
42
+ * Share of turns that failed, as a number in [0, 1].
43
+ *
44
+ * Cancelled turns are excluded from the denominator: the user stopping is not
45
+ * evidence about the network. Returns null when nothing was observed, so a
46
+ * reader can tell "no failures" from "no data" — a dashboard that shows 0%
47
+ * on an empty window invites the wrong conclusion.
48
+ */
49
+ export declare function failureRate(summary: ReliabilitySummary): number | null;
50
+ /**
51
+ * The network-attributable share of failures, in [0, 1], or null when there
52
+ * were none. `transport` is the kind this phase's work targets; the rest are
53
+ * configuration (`auth`), throttling, or the provider's own errors.
54
+ */
55
+ export declare function networkFailureShare(summary: ReliabilitySummary): number | null;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * 二期 R4 —— 网络失败的统计口径(纯逻辑)。
3
+ *
4
+ * 与 `usage-summary.ts` 同样的拆分理由:`LocalStore` 顶层加载的
5
+ * better-sqlite3 native 模块与 vitest 运行时二进制不兼容,SQL 留在
6
+ * store 里,口径与派生量放这里,可以直接单测。
7
+ *
8
+ * 三个终态分开数,因为它们回答不同的问题:
9
+ *
10
+ * - `failed` 是要修的东西。失败率的分母用它。
11
+ * - `cancelled` 是用户自己的决定,**不算失败**。把停止算成失败,会让
12
+ * 「网络在变差」和「用户等得不耐烦」看起来一样。
13
+ * - `recoveredByRetry` 是重试有没有用的直接度量:重试过、最终成功了
14
+ * 的回合数。它低不代表重试没价值(可能是网络好),但配合失败数看就
15
+ * 能判断 R3 的策略是否合适。
16
+ *
17
+ * 早于这三列存在之前写入的行,`outcome` 取默认值 `completed`——那时候
18
+ * 也没观测过终态,按完成计是唯一诚实的读法。
19
+ */
20
+ /**
21
+ * Share of turns that failed, as a number in [0, 1].
22
+ *
23
+ * Cancelled turns are excluded from the denominator: the user stopping is not
24
+ * evidence about the network. Returns null when nothing was observed, so a
25
+ * reader can tell "no failures" from "no data" — a dashboard that shows 0%
26
+ * on an empty window invites the wrong conclusion.
27
+ */
28
+ export function failureRate(summary) {
29
+ const observed = summary.total - summary.cancelled;
30
+ if (observed <= 0)
31
+ return null;
32
+ return summary.failed / observed;
33
+ }
34
+ /**
35
+ * The network-attributable share of failures, in [0, 1], or null when there
36
+ * were none. `transport` is the kind this phase's work targets; the rest are
37
+ * configuration (`auth`), throttling, or the provider's own errors.
38
+ */
39
+ export function networkFailureShare(summary) {
40
+ if (summary.failed <= 0)
41
+ return null;
42
+ const transport = summary.failuresByKind.find((row) => row.kind === 'transport');
43
+ return (transport?.count ?? 0) / summary.failed;
44
+ }
@@ -3,6 +3,7 @@ import type { InsightKind, InsightsSettings, InsightsSettingsPatch } from './ins
3
3
  import type { LlmSettings } from './llm-settings.js';
4
4
  import type { TelemetrySettings } from './telemetry-settings.js';
5
5
  import type { UsageSummary } from './usage-summary.js';
6
+ import type { ReliabilitySummary } from './reliability-summary.js';
6
7
  import type { WebSearchSettings } from './web-search-settings.js';
7
8
  import type { TenantScope } from './driver.js';
8
9
  /** Explicit asynchronous storage API bound to one tenant and user. */
@@ -79,8 +80,14 @@ export interface ScopedStore {
79
80
  totalTokens: number;
80
81
  cachedPromptTokens?: number;
81
82
  costUsd?: number | null;
83
+ outcome?: 'completed' | 'failed' | 'cancelled';
84
+ failureKind?: string | null;
85
+ attempts?: number | null;
86
+ retried?: boolean;
87
+ usedDirect?: boolean;
82
88
  }): Promise<void>;
83
89
  getUsageSummary(sinceDays?: number): Promise<UsageSummary>;
90
+ getReliabilitySummary(sinceDays?: number): Promise<ReliabilitySummary>;
84
91
  getLlmSettings(): Promise<LlmSettings | null>;
85
92
  setLlmSettings(settings: LlmSettings): Promise<LlmSettings>;
86
93
  getTelemetrySettings(): Promise<TelemetrySettings | null>;
@@ -38,6 +38,24 @@ function dataOf(result) {
38
38
  function failed(result) {
39
39
  return Boolean(result && typeof result === 'object' && result.success === false);
40
40
  }
41
+ function askTitle(args, result) {
42
+ return clip(firstQuestion(args) || firstQuestion(result) || str(args, 'intro') || str(args, 'question')) || '提问';
43
+ }
44
+ function firstQuestion(value) {
45
+ if (!value || typeof value !== 'object' || Array.isArray(value))
46
+ return '';
47
+ const questions = value.questions;
48
+ if (!Array.isArray(questions))
49
+ return '';
50
+ for (const question of questions) {
51
+ if (!question || typeof question !== 'object' || Array.isArray(question))
52
+ continue;
53
+ const text = str(question, 'text').trim();
54
+ if (text)
55
+ return text;
56
+ }
57
+ return '';
58
+ }
41
59
  function commandTitle(args) {
42
60
  return clip(str(args, 'command')) || 'shell';
43
61
  }
@@ -230,7 +248,7 @@ const PRESENTERS = {
230
248
  ask_user: {
231
249
  card: 'generic',
232
250
  kind: 'other',
233
- title: (args) => clip(str(args, 'intro') || str(args, 'question')) || '提问',
251
+ title: (args, result) => askTitle(args, result),
234
252
  },
235
253
  skill: { card: 'generic', kind: 'read', title: (args) => str(args, 'name') || 'skill' },
236
254
  delegate_subagent: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steerable/agent-shell",
3
- "version": "0.7.2",
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.7.2",
57
- "@steerable/agent-protocol": "0.7.2",
58
- "@steerable/pack-sdk": "0.7.2"
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",