@webskill/sdk 0.2.8 → 0.4.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.
Files changed (38) hide show
  1. package/README.md +42 -0
  2. package/dist/browser.d.ts +2 -2
  3. package/dist/browser.js +4 -9
  4. package/dist/{catalogComponents-C_V39rbF-BOHveMWa.js → catalogComponents-KsujmL4b-Clx1kCnU.js} +278 -122
  5. package/dist/client-BCM6Z3yq-qUQNkKrK.js +7787 -0
  6. package/dist/{dist-BQzncxXg.js → dist-8oQRa8Xz.js} +212 -13
  7. package/dist/{dist-B9VLwOME.js → dist-C-Sh0MDU.js} +1019 -317
  8. package/dist/{dist-CtBLBbEz.js → dist-D9Lcn5Pp.js} +528 -838
  9. package/dist/governance.d.ts +46 -11
  10. package/dist/governance.js +152 -25
  11. package/dist/{index-7DVaZJU7.d.ts → index-CHXxDccV.d.ts} +62 -144
  12. package/dist/{index-QrHtAudz.d.ts → index-DLfR2Y6I.d.ts} +412 -21
  13. package/dist/index.d.ts +3 -3
  14. package/dist/index.js +4 -3
  15. package/dist/mcp.d.ts +2 -2
  16. package/dist/mcp.js +2 -2
  17. package/dist/memoryArtifactStore-BtOeB_hm-tj3fC5ip.js +78 -0
  18. package/dist/node.d.ts +297 -4
  19. package/dist/node.js +281 -7
  20. package/dist/{openUiLibrary-B8-Cvou9-D3RsU2EB.js → openUiLibrary-YLS-cxyT-C96jWDQq.js} +6 -5
  21. package/dist/skillVersionStore-uyefLPR1-DXOzbksv.d.ts +158 -0
  22. package/dist/stdio-CFMoANJJ-BxrTeXh7.js +31 -0
  23. package/dist/{testing-BUoXvm1u.js → testing-DDCJWvgA.js} +8 -6
  24. package/dist/testing.d.ts +1 -1
  25. package/dist/testing.js +2 -2
  26. package/dist/{types-AmKCKJn_-BogJPQHU.d.ts → types-7Wcg--Vh-1YlQ4jF9.d.ts} +219 -70
  27. package/dist/types-WovEf4ED-CZSDiiBU.js +6215 -0
  28. package/dist/ui-react.d.ts +329 -18
  29. package/dist/ui-react.js +3715 -3464
  30. package/dist/ui-vue.d.ts +1 -1
  31. package/dist/ui-vue.js +1 -1
  32. package/dist/ui.d.ts +4 -3
  33. package/dist/ui.js +3 -3
  34. package/dist/{webskillLitCatalog-CNaUpasU-CfSRvqCZ.js → webskillLitCatalog-CSTbhBe_-CYIs5BX8.js} +312 -122
  35. package/package.json +4 -7
  36. package/dist/jsonRenderRegistry-9GrWP_hE-CQY8bT9w.js +0 -2468
  37. package/dist/memoryArtifactStore-C9lFVqPF-yFz6yJj0.js +0 -48
  38. package/dist/skillVersionStore-B7rGjtMi-BgnQho9v.d.ts +0 -365
@@ -1,28 +1,111 @@
1
- import { C as messageOf, N as validateSkills, O as renderAvailableSkillsXml, T as parseSkillMarkdown, c as SkillDiscovery, f as assertSafePathSegment, j as resolveInsideRoot, l as SkillReader, m as buildCatalog, u as WebSkillError } from "./dist-BQzncxXg.js";
2
- import { t as MemoryArtifactStore } from "./memoryArtifactStore-C9lFVqPF-yFz6yJj0.js";
1
+ import { A as parseSkillMarkdown, L as resolveInsideRoot, O as messageOf, P as renderAvailableSkillsXml, V as validateSkills, f as SkillDiscovery, g as assertSafePathSegment, m as WebSkillError, p as SkillReader, v as buildCatalog } from "./dist-8oQRa8Xz.js";
2
+ import { a as validateLlmMessages, i as textParts, n as partsToText, r as rejectUnsupportedPart, t as MemoryArtifactStore } from "./memoryArtifactStore-BtOeB_hm-tj3fC5ip.js";
3
3
 
4
4
  //#region ../runtime/dist/index.js
5
+ function createSseFrameReader() {
6
+ let buffer = "";
7
+ let data = [];
8
+ const dispatch = (out) => {
9
+ if (data.length === 0) return;
10
+ out.push(data.join("\n"));
11
+ data = [];
12
+ };
13
+ const consumeLine = (line, out) => {
14
+ if (line === "") {
15
+ dispatch(out);
16
+ return;
17
+ }
18
+ if (line.startsWith(":")) return;
19
+ const colon = line.indexOf(":");
20
+ if (colon < 0) return;
21
+ if (line.slice(0, colon) !== "data") return;
22
+ const value = line.slice(colon + 1);
23
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
24
+ };
25
+ return {
26
+ push(chunk) {
27
+ const out = [];
28
+ buffer += chunk;
29
+ buffer = buffer.replace(/\r\n/g, "\n");
30
+ let newline;
31
+ while ((newline = buffer.indexOf("\n")) >= 0) {
32
+ const line = buffer.slice(0, newline);
33
+ buffer = buffer.slice(newline + 1);
34
+ consumeLine(line, out);
35
+ }
36
+ return out;
37
+ },
38
+ flush() {
39
+ const out = [];
40
+ if (buffer !== "") {
41
+ const line = buffer;
42
+ buffer = "";
43
+ consumeLine(line, out);
44
+ }
45
+ dispatch(out);
46
+ return out;
47
+ }
48
+ };
49
+ }
50
+ const dataUrl = (part) => `data:${part.mimeType};base64,${part.data}`;
51
+ /** parts → OpenAI content;纯文本折叠成字符串(兼容端点对数组形态支持不一) */
52
+ const toOpenAiContent = (parts, where) => {
53
+ if (parts.every((p) => p.type === "text")) return partsToText(parts);
54
+ return parts.map((part) => {
55
+ switch (part.type) {
56
+ case "text": return {
57
+ type: "text",
58
+ text: part.text
59
+ };
60
+ case "image": return {
61
+ type: "image_url",
62
+ image_url: { url: dataUrl(part) }
63
+ };
64
+ case "file": return {
65
+ type: "file",
66
+ file: {
67
+ ...part.name !== void 0 ? { filename: part.name } : {},
68
+ file_data: dataUrl(part)
69
+ }
70
+ };
71
+ default: return rejectUnsupportedPart(part, "OpenAI", where);
72
+ }
73
+ });
74
+ };
75
+ /** system / tool 消息只接受文本:这两类在 OpenAI 协议里没有多模态形态 */
76
+ const toOpenAiText = (parts, where) => {
77
+ const nonText = parts.find((p) => p.type !== "text");
78
+ if (nonText) rejectUnsupportedPart(nonText, "OpenAI", where);
79
+ return partsToText(parts);
80
+ };
5
81
  const toOpenAiMessage = (msg) => {
6
82
  if (msg.role === "tool") return {
7
83
  role: "tool",
8
84
  tool_call_id: msg.toolCallId,
9
- content: msg.content
85
+ content: toOpenAiText(msg.content, "tool")
10
86
  };
11
- if (msg.role === "assistant" && msg.toolCalls?.length) return {
12
- role: "assistant",
13
- content: msg.content === "" ? null : msg.content,
14
- tool_calls: msg.toolCalls.map((call) => ({
15
- id: call.id,
16
- type: "function",
17
- function: {
18
- name: call.name,
19
- arguments: JSON.stringify(call.arguments)
20
- }
21
- }))
87
+ if (msg.role === "system") return {
88
+ role: "system",
89
+ content: toOpenAiText(msg.content, "system")
22
90
  };
91
+ if (msg.role === "assistant" && msg.toolCalls?.length) {
92
+ const content = toOpenAiContent(msg.content, "assistant");
93
+ return {
94
+ role: "assistant",
95
+ content: content === "" ? null : content,
96
+ tool_calls: msg.toolCalls.map((call) => ({
97
+ id: call.id,
98
+ type: "function",
99
+ function: {
100
+ name: call.name,
101
+ arguments: JSON.stringify(call.arguments)
102
+ }
103
+ }))
104
+ };
105
+ }
23
106
  return {
24
107
  role: msg.role,
25
- content: msg.content
108
+ content: toOpenAiContent(msg.content, msg.role)
26
109
  };
27
110
  };
28
111
  const toOpenAiTools = (tools) => tools.map((tool) => ({
@@ -68,7 +151,7 @@ var OpenAiCompatibleClient = class {
68
151
  const toolCallsByIndex = /* @__PURE__ */ new Map();
69
152
  const decoder = new TextDecoder();
70
153
  const reader = res.body.getReader();
71
- let buffer = "";
154
+ const frames = createSseFrameReader();
72
155
  let done = false;
73
156
  const handleFrame = function* (data) {
74
157
  if (data === "[DONE]") {
@@ -104,19 +187,12 @@ var OpenAiCompatibleClient = class {
104
187
  try {
105
188
  for (;;) {
106
189
  const { value, done: readerDone } = await reader.read();
107
- if (readerDone) break;
108
- buffer += decoder.decode(value, { stream: true });
109
- buffer = buffer.replace(/\r\n/g, "\n");
110
- let newline;
111
- while ((newline = buffer.indexOf("\n")) >= 0) {
112
- const line = buffer.slice(0, newline).trim();
113
- buffer = buffer.slice(newline + 1);
114
- if (line === "" || line.startsWith(":")) continue;
115
- if (!line.startsWith("data:")) continue;
116
- yield* handleFrame(line.slice(5).trim());
190
+ const payloads = readerDone ? frames.flush() : frames.push(decoder.decode(value, { stream: true }));
191
+ for (const payload of payloads) {
192
+ yield* handleFrame(payload);
117
193
  if (done) break;
118
194
  }
119
- if (done) break;
195
+ if (done || readerDone) break;
120
196
  }
121
197
  } finally {
122
198
  reader.releaseLock();
@@ -143,6 +219,7 @@ var OpenAiCompatibleClient = class {
143
219
  }
144
220
  async #postChat(input, stream) {
145
221
  const { baseUrl, apiKey, model } = this.#requireConfig();
222
+ validateLlmMessages(input.messages);
146
223
  const body = {
147
224
  model: input.model ?? model,
148
225
  messages: input.messages.map(toOpenAiMessage)
@@ -196,7 +273,7 @@ var OpenAiCompatibleClient = class {
196
273
  });
197
274
  const content = choice["content"];
198
275
  return {
199
- content: typeof content === "string" ? content : void 0,
276
+ content: typeof content === "string" && content !== "" ? textParts(content) : void 0,
200
277
  toolCalls: toolCalls?.length ? toolCalls : void 0,
201
278
  raw: data
202
279
  };
@@ -207,9 +284,41 @@ var OpenAiCompatibleClient = class {
207
284
  };
208
285
  const ANTHROPIC_VERSION = "2023-06-01";
209
286
  const errorMessage$1 = (e) => e instanceof Error ? e.message : String(e);
287
+ /** parts → Anthropic content blocks(image/document 均为 base64 source) */
288
+ const toAnthropicBlocks = (parts, where) => parts.map((part) => {
289
+ switch (part.type) {
290
+ case "text": return {
291
+ type: "text",
292
+ text: part.text
293
+ };
294
+ case "image": return {
295
+ type: "image",
296
+ source: {
297
+ type: "base64",
298
+ media_type: part.mimeType,
299
+ data: part.data
300
+ }
301
+ };
302
+ case "file": return {
303
+ type: "document",
304
+ source: {
305
+ type: "base64",
306
+ media_type: part.mimeType,
307
+ data: part.data
308
+ }
309
+ };
310
+ default: return rejectUnsupportedPart(part, "Anthropic", where);
311
+ }
312
+ });
313
+ /** system 是独立的字符串参数,无多模态形态 */
314
+ const toAnthropicSystemText = (parts) => {
315
+ const nonText = parts.find((p) => p.type !== "text");
316
+ if (nonText) rejectUnsupportedPart(nonText, "Anthropic", "system");
317
+ return partsToText(parts);
318
+ };
210
319
  /** LlmMessage 序列 → system 独立参数 + user/assistant 消息(tool 结果合并进 user 消息 tool_result blocks) */
211
320
  function toAnthropicMessages(messages) {
212
- const system = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n");
321
+ const system = messages.filter((m) => m.role === "system").map((m) => toAnthropicSystemText(m.content)).join("\n");
213
322
  const out = [];
214
323
  for (const msg of messages) {
215
324
  if (msg.role === "system") continue;
@@ -217,7 +326,7 @@ function toAnthropicMessages(messages) {
217
326
  const block = {
218
327
  type: "tool_result",
219
328
  tool_use_id: msg.toolCallId ?? "",
220
- content: msg.content
329
+ content: toAnthropicBlocks(msg.content, "tool")
221
330
  };
222
331
  const last = out.at(-1);
223
332
  if (last && last.role === "user" && Array.isArray(last.content)) last.content.push(block);
@@ -228,11 +337,7 @@ function toAnthropicMessages(messages) {
228
337
  continue;
229
338
  }
230
339
  if (msg.role === "assistant" && msg.toolCalls?.length) {
231
- const content = [];
232
- if (msg.content !== "") content.push({
233
- type: "text",
234
- text: msg.content
235
- });
340
+ const content = toAnthropicBlocks(msg.content, "assistant");
236
341
  for (const call of msg.toolCalls) content.push({
237
342
  type: "tool_use",
238
343
  id: call.id,
@@ -247,7 +352,7 @@ function toAnthropicMessages(messages) {
247
352
  }
248
353
  out.push({
249
354
  role: msg.role,
250
- content: msg.content
355
+ content: toAnthropicBlocks(msg.content, msg.role)
251
356
  });
252
357
  }
253
358
  return system === "" ? { messages: out } : {
@@ -296,7 +401,7 @@ var AnthropicClient = class {
296
401
  const toolCallsByIndex = /* @__PURE__ */ new Map();
297
402
  const decoder = new TextDecoder();
298
403
  const reader = res.body.getReader();
299
- let buffer = "";
404
+ const frames = createSseFrameReader();
300
405
  const handleFrame = function* (data) {
301
406
  let chunk;
302
407
  try {
@@ -343,17 +448,9 @@ var AnthropicClient = class {
343
448
  try {
344
449
  for (;;) {
345
450
  const { value, done: readerDone } = await reader.read();
451
+ const payloads = readerDone ? frames.flush() : frames.push(decoder.decode(value, { stream: true }));
452
+ for (const payload of payloads) yield* handleFrame(payload);
346
453
  if (readerDone) break;
347
- buffer += decoder.decode(value, { stream: true });
348
- buffer = buffer.replace(/\r\n/g, "\n");
349
- let newline;
350
- while ((newline = buffer.indexOf("\n")) >= 0) {
351
- const line = buffer.slice(0, newline).trim();
352
- buffer = buffer.slice(newline + 1);
353
- if (line === "" || line.startsWith(":") || line.startsWith("event:")) continue;
354
- if (!line.startsWith("data:")) continue;
355
- yield* handleFrame(line.slice(5).trim());
356
- }
357
454
  }
358
455
  } finally {
359
456
  reader.releaseLock();
@@ -379,6 +476,7 @@ var AnthropicClient = class {
379
476
  yield { type: "done" };
380
477
  }
381
478
  async #post(input, stream) {
479
+ validateLlmMessages(input.messages);
382
480
  const { system, messages } = toAnthropicMessages(input.messages);
383
481
  const body = {
384
482
  model: input.model ?? this.#config.model,
@@ -425,7 +523,7 @@ var AnthropicClient = class {
425
523
  arguments: typeof b["input"] === "object" && b["input"] !== null ? b["input"] : {}
426
524
  }));
427
525
  return {
428
- content: text === "" ? void 0 : text,
526
+ content: text === "" ? void 0 : textParts(text),
429
527
  toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
430
528
  raw: data
431
529
  };
@@ -435,6 +533,24 @@ var AnthropicClient = class {
435
533
  }
436
534
  };
437
535
  const errorMessage = (e) => e instanceof Error ? e.message : String(e);
536
+ /** parts → GenAI parts(二进制统一走 inlineData) */
537
+ const toGenAiParts = (parts, where) => parts.map((part) => {
538
+ switch (part.type) {
539
+ case "text": return { text: part.text };
540
+ case "image":
541
+ case "file": return { inlineData: {
542
+ mimeType: part.mimeType,
543
+ data: part.data
544
+ } };
545
+ default: return rejectUnsupportedPart(part, "Google GenAI", where);
546
+ }
547
+ });
548
+ /** systemInstruction 与 functionResponse 只接受文本 */
549
+ const toGenAiText = (parts, where) => {
550
+ const nonText = parts.find((p) => p.type !== "text");
551
+ if (nonText) rejectUnsupportedPart(nonText, "Google GenAI", where);
552
+ return partsToText(parts);
553
+ };
438
554
  /** tool 结果文本包装为 functionResponse.response 对象 */
439
555
  function responseObject(content) {
440
556
  try {
@@ -445,7 +561,7 @@ function responseObject(content) {
445
561
  }
446
562
  /** LlmMessage 序列 → systemInstruction + contents(tool 结果合并进 user 消息 functionResponse parts) */
447
563
  function toGenAiContents(messages) {
448
- const system = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n");
564
+ const system = messages.filter((m) => m.role === "system").map((m) => toGenAiText(m.content, "system")).join("\n");
449
565
  const nameByCallId = /* @__PURE__ */ new Map();
450
566
  for (const msg of messages) if (msg.role === "assistant") for (const call of msg.toolCalls ?? []) nameByCallId.set(call.id, call.name);
451
567
  const out = [];
@@ -455,7 +571,7 @@ function toGenAiContents(messages) {
455
571
  const callId = msg.toolCallId ?? "";
456
572
  const part = { functionResponse: {
457
573
  name: nameByCallId.get(callId) ?? callId,
458
- response: responseObject(msg.content)
574
+ response: responseObject(toGenAiText(msg.content, "tool"))
459
575
  } };
460
576
  const last = out.at(-1);
461
577
  if (last && last.role === "user") last.parts.push(part);
@@ -466,8 +582,7 @@ function toGenAiContents(messages) {
466
582
  continue;
467
583
  }
468
584
  if (msg.role === "assistant") {
469
- const parts = [];
470
- if (msg.content !== "") parts.push({ text: msg.content });
585
+ const parts = toGenAiParts(msg.content, "assistant");
471
586
  for (const call of msg.toolCalls ?? []) parts.push({ functionCall: {
472
587
  name: call.name,
473
588
  args: call.arguments
@@ -480,7 +595,7 @@ function toGenAiContents(messages) {
480
595
  }
481
596
  out.push({
482
597
  role: "user",
483
- parts: [{ text: msg.content }]
598
+ parts: toGenAiParts(msg.content, msg.role)
484
599
  });
485
600
  }
486
601
  return system === "" ? { contents: out } : {
@@ -516,7 +631,7 @@ var GoogleGenAiClient = class {
516
631
  const text = parts.filter((p) => typeof p["text"] === "string").map((p) => String(p["text"])).join("");
517
632
  const toolCalls = this.#functionCalls(parts);
518
633
  return {
519
- content: text === "" ? void 0 : text,
634
+ content: text === "" ? void 0 : textParts(text),
520
635
  toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
521
636
  raw: data
522
637
  };
@@ -528,7 +643,7 @@ var GoogleGenAiClient = class {
528
643
  const toolCalls = [];
529
644
  const decoder = new TextDecoder();
530
645
  const reader = res.body.getReader();
531
- let buffer = "";
646
+ const frames = createSseFrameReader();
532
647
  const handleFrame = function* (data) {
533
648
  let chunk;
534
649
  try {
@@ -556,17 +671,9 @@ var GoogleGenAiClient = class {
556
671
  try {
557
672
  for (;;) {
558
673
  const { value, done: readerDone } = await reader.read();
674
+ const payloads = readerDone ? frames.flush() : frames.push(decoder.decode(value, { stream: true }));
675
+ for (const payload of payloads) yield* handleFrame(payload);
559
676
  if (readerDone) break;
560
- buffer += decoder.decode(value, { stream: true });
561
- buffer = buffer.replace(/\r\n/g, "\n");
562
- let newline;
563
- while ((newline = buffer.indexOf("\n")) >= 0) {
564
- const line = buffer.slice(0, newline).trim();
565
- buffer = buffer.slice(newline + 1);
566
- if (line === "" || line.startsWith(":") || line.startsWith("event:")) continue;
567
- if (!line.startsWith("data:")) continue;
568
- yield* handleFrame(line.slice(5).trim());
569
- }
570
677
  }
571
678
  } finally {
572
679
  reader.releaseLock();
@@ -580,6 +687,7 @@ var GoogleGenAiClient = class {
580
687
  async #post(input, stream) {
581
688
  const model = input.model ?? this.#config.model;
582
689
  const action = stream ? ":streamGenerateContent?alt=sse" : ":generateContent";
690
+ validateLlmMessages(input.messages);
583
691
  const { systemInstruction, contents } = toGenAiContents(input.messages);
584
692
  const body = { contents };
585
693
  if (systemInstruction) body["systemInstruction"] = systemInstruction;
@@ -655,7 +763,7 @@ function fromVercelResult(result) {
655
763
  };
656
764
  });
657
765
  return {
658
- content: typeof r.text === "string" && r.text !== "" ? r.text : void 0,
766
+ content: typeof r.text === "string" && r.text !== "" ? textParts(r.text) : void 0,
659
767
  toolCalls: toolCalls.length ? toolCalls : void 0,
660
768
  raw: result
661
769
  };
@@ -938,11 +1046,8 @@ function buildRenderResult(run, output, renderBlocks = []) {
938
1046
  }
939
1047
  const MAX_SURFACE_BYTES = 256 * 1024;
940
1048
  const MAX_ACTIONS = 32;
941
- const MAX_FORM_FIELDS = 64;
942
- const MAX_OPTIONS = 256;
943
- const MAX_TABLE_COLUMNS = 128;
944
- const MAX_TABLE_ROWS = 1e4;
945
- const MAX_CHART_POINTS = 2e4;
1049
+ const MAX_NODES = 2e3;
1050
+ const MAX_NODE_DEPTH = 32;
946
1051
  const MAX_JSON_DEPTH = 16;
947
1052
  const MAX_PATCH_OPERATIONS = 128;
948
1053
  const actionIntents = /* @__PURE__ */ new Set([
@@ -952,16 +1057,6 @@ const actionIntents = /* @__PURE__ */ new Set([
952
1057
  "download",
953
1058
  "refresh"
954
1059
  ]);
955
- const fieldTypes = /* @__PURE__ */ new Set([
956
- "text",
957
- "number",
958
- "date",
959
- "textarea",
960
- "select",
961
- "multi-select",
962
- "toggle",
963
- "file"
964
- ]);
965
1060
  function isRecord$1(value) {
966
1061
  return typeof value === "object" && value !== null && !Array.isArray(value);
967
1062
  }
@@ -982,98 +1077,39 @@ function isJsonValue(value, depth = 0) {
982
1077
  if (!isRecord$1(value)) return false;
983
1078
  return Object.keys(value).every((key) => key !== "__proto__" && key !== "constructor" && isJsonValue(value[key], depth + 1));
984
1079
  }
1080
+ /**
1081
+ * Runtime 只做结构校验;组件名白名单与 props schema 属于 catalog 语义,
1082
+ * 留在 `@webskill/ui`——runtime 不得为了校验反向依赖 ui。
1083
+ */
1084
+ function assertNode(value, path, depth, counter) {
1085
+ if (depth > MAX_NODE_DEPTH) reject(`A UI spec tree must not nest deeper than ${MAX_NODE_DEPTH} levels`);
1086
+ if (++counter.nodes > MAX_NODES) reject(`A UI spec tree must contain at most ${MAX_NODES} nodes`);
1087
+ if (!isRecord$1(value)) reject(`${path} must be an object`);
1088
+ requireString(value["component"], `${path}.component`);
1089
+ if (value["id"] !== void 0) requireString(value["id"], `${path}.id`);
1090
+ if (value["props"] !== void 0 && (!isRecord$1(value["props"]) || !isJsonValue(value["props"]))) reject(`${path}.props must be a JSON object`);
1091
+ const children = value["children"];
1092
+ if (children === void 0) return;
1093
+ if (!Array.isArray(children)) reject(`${path}.children must be an array`);
1094
+ children.forEach((child, index) => assertNode(child, `${path}.children[${index}]`, depth + 1, counter));
1095
+ }
985
1096
  function assertActions(value) {
986
1097
  if (value === void 0) return;
987
1098
  if (!Array.isArray(value) || value.length > MAX_ACTIONS) reject(`Surface actions must contain at most ${MAX_ACTIONS} items`);
988
1099
  for (const action of value) {
989
1100
  if (!isRecord$1(action)) reject("A surface action must be an object");
990
1101
  requireString(action["id"], "Surface action ID");
991
- requireString(action["label"], "Surface action label");
992
- if (typeof action["intent"] !== "string" || !actionIntents.has(action["intent"])) reject("Surface action intent is invalid");
993
- if (action["disabled"] !== void 0 && typeof action["disabled"] !== "boolean") reject("Surface action disabled must be a boolean");
1102
+ const intent = action["intent"];
1103
+ if (typeof intent !== "string" || !actionIntents.has(intent)) reject("Surface action intent is invalid");
994
1104
  if (action["awaitResponse"] !== void 0 && typeof action["awaitResponse"] !== "boolean") reject("Surface action awaitResponse must be a boolean");
995
1105
  if (action["nonce"] !== void 0 && (typeof action["nonce"] !== "string" || action["nonce"] === "")) reject("Surface action nonce must be a non-empty string");
996
1106
  }
997
1107
  }
998
- function assertForm(surface) {
999
- if (!Array.isArray(surface["fields"]) || surface["fields"].length > MAX_FORM_FIELDS) reject(`A form surface must contain at most ${MAX_FORM_FIELDS} fields`);
1000
- for (const field of surface["fields"]) {
1001
- if (!isRecord$1(field)) reject("A form field must be an object");
1002
- requireString(field["name"], "Form field name");
1003
- requireString(field["label"], "Form field label");
1004
- if (typeof field["type"] !== "string" || !fieldTypes.has(field["type"])) reject("Form field type is invalid");
1005
- if (field["required"] !== void 0 && typeof field["required"] !== "boolean") reject("Form field required must be a boolean");
1006
- if (field["description"] !== void 0 && typeof field["description"] !== "string") reject("Form field description must be a string");
1007
- if (field["defaultValue"] !== void 0 && !isJsonValue(field["defaultValue"])) reject("Form field defaultValue must be JSON data");
1008
- if (field["options"] !== void 0) {
1009
- if (!Array.isArray(field["options"]) || field["options"].length > MAX_OPTIONS) reject(`Form field options must contain at most ${MAX_OPTIONS} items`);
1010
- for (const option of field["options"]) {
1011
- if (!isRecord$1(option)) reject("A form option must be an object");
1012
- requireString(option["label"], "Form option label");
1013
- if (!isJsonValue(option["value"])) reject("Form option value must be JSON data");
1014
- }
1015
- }
1016
- }
1017
- assertActions(surface["actions"]);
1018
- }
1019
- function assertChart(surface) {
1020
- const chart = surface["chart"];
1021
- if (!isRecord$1(chart)) reject("A chart surface requires a chart object");
1022
- if (chart["kind"] !== "bar" && chart["kind"] !== "line" && chart["kind"] !== "pie") reject("Chart kind is invalid");
1023
- if (!Array.isArray(chart["labels"]) || !chart["labels"].every((label) => typeof label === "string")) reject("Chart labels must be an array of strings");
1024
- if (!Array.isArray(chart["series"])) reject("Chart series must be an array");
1025
- let points = 0;
1026
- for (const series of chart["series"]) {
1027
- if (!isRecord$1(series) || !Array.isArray(series["data"]) || !series["data"].every((point) => typeof point === "number" && Number.isFinite(point))) reject("Chart series data must be finite numbers");
1028
- if (series["name"] !== void 0 && typeof series["name"] !== "string") reject("Chart series name must be a string");
1029
- points += series["data"].length;
1030
- }
1031
- if (points > MAX_CHART_POINTS) reject(`Chart data exceeds the ${MAX_CHART_POINTS}-point limit`);
1032
- assertActions(surface["actions"]);
1033
- }
1034
- function assertTable(surface) {
1035
- if (!Array.isArray(surface["columns"]) || surface["columns"].length > MAX_TABLE_COLUMNS || !surface["columns"].every((column) => typeof column === "string")) reject(`Table columns must be strings and contain at most ${MAX_TABLE_COLUMNS} items`);
1036
- if (!Array.isArray(surface["rows"]) || surface["rows"].length > MAX_TABLE_ROWS) reject(`Table rows must contain at most ${MAX_TABLE_ROWS} items`);
1037
- for (const row of surface["rows"]) if (!Array.isArray(row) || row.length > surface["columns"].length || !row.every((cell) => isJsonValue(cell))) reject("Table rows must contain JSON cells within the declared column count");
1038
- assertActions(surface["actions"]);
1039
- }
1040
- /** Validates the allowlisted, data-only shape accepted by a UI surface renderer. @experimental */
1041
- function validateUiSurface(value) {
1042
- if (!isRecord$1(value)) reject("A UI surface must be an object");
1043
- requireString(value["id"], "UI surface ID");
1044
- if (value["title"] !== void 0 && typeof value["title"] !== "string") reject("UI surface title must be a string");
1045
- switch (value["kind"]) {
1046
- case "form":
1047
- assertForm(value);
1048
- break;
1049
- case "chart":
1050
- assertChart(value);
1051
- break;
1052
- case "table":
1053
- assertTable(value);
1054
- break;
1055
- case "metric":
1056
- requireString(value["label"], "Metric label");
1057
- if (typeof value["value"] !== "string" && (typeof value["value"] !== "number" || !Number.isFinite(value["value"]))) reject("Metric value must be a string or finite number");
1058
- if (value["trend"] !== void 0 && value["trend"] !== "up" && value["trend"] !== "down" && value["trend"] !== "neutral") reject("Metric trend is invalid");
1059
- break;
1060
- case "file": {
1061
- requireString(value["path"], "File path");
1062
- if (value["mimeType"] !== void 0 && typeof value["mimeType"] !== "string") reject("File mimeType must be a string");
1063
- const fileSize = value["size"];
1064
- if (fileSize !== void 0 && (typeof fileSize !== "number" || !Number.isSafeInteger(fileSize) || fileSize < 0)) reject("File size must be a non-negative integer");
1065
- assertActions(value["actions"]);
1066
- break;
1067
- }
1068
- case "custom":
1069
- requireString(value["component"], "Custom surface component");
1070
- if (!isJsonValue(value["props"])) reject("Custom surface props must be JSON data");
1071
- assertActions(value["actions"]);
1072
- break;
1073
- default: reject("UI surface kind is invalid");
1074
- }
1075
- if (!isJsonValue(value)) reject("A UI surface must contain JSON data only");
1076
- if (JSON.stringify(value).length > MAX_SURFACE_BYTES) reject(`A UI surface exceeds the ${MAX_SURFACE_BYTES}-byte limit`);
1108
+ /** Validates the allowlisted, data-only node tree accepted by a UI surface renderer. @experimental */
1109
+ function validateUiSpecNode(value) {
1110
+ assertNode(value, "root", 0, { nodes: 0 });
1111
+ if (!isJsonValue(value)) reject("A UI spec tree must contain JSON data only");
1112
+ if (JSON.stringify(value).length > MAX_SURFACE_BYTES) reject(`A UI spec tree exceeds the ${MAX_SURFACE_BYTES}-byte limit`);
1077
1113
  return structuredClone(value);
1078
1114
  }
1079
1115
  function assertPatch(value) {
@@ -1084,15 +1120,20 @@ function assertPatch(value) {
1084
1120
  if (!isJsonValue(value["value"])) reject("Surface patch value must be JSON data");
1085
1121
  }
1086
1122
  /** Validates an individual event in the framework-neutral surface stream. @experimental */
1087
- function validateUiSurfaceEvent(value) {
1123
+ function validateUiSpecEvent(value) {
1088
1124
  if (!isRecord$1(value) || typeof value["type"] !== "string") reject("A UI surface event must have a type");
1089
1125
  if (value["runId"] !== void 0) requireString(value["runId"], "Surface event run ID");
1090
1126
  switch (value["type"]) {
1091
- case "open": return {
1092
- type: "open",
1093
- ...value["runId"] ? { runId: value["runId"] } : {},
1094
- surface: validateUiSurface(value["surface"])
1095
- };
1127
+ case "open":
1128
+ requireString(value["id"], "UI surface ID");
1129
+ assertActions(value["actions"]);
1130
+ return {
1131
+ type: "open",
1132
+ ...value["runId"] ? { runId: value["runId"] } : {},
1133
+ id: value["id"],
1134
+ node: validateUiSpecNode(value["node"]),
1135
+ ...value["actions"] ? { actions: structuredClone(value["actions"]) } : {}
1136
+ };
1096
1137
  case "patch":
1097
1138
  requireString(value["id"], "Surface patch ID");
1098
1139
  requireNonNegativeInteger(value["revision"], "Surface patch revision");
@@ -1137,10 +1178,10 @@ function validateUiSurfaceEvent(value) {
1137
1178
  }
1138
1179
  }
1139
1180
  /** Extracts validated surface stream events from structured tool output. @experimental */
1140
- function extractUiSurfaceEvents(data) {
1181
+ function extractUiSpecEvents(data) {
1141
1182
  if (!isRecord$1(data) || data["$surface"] === void 0) return [];
1142
1183
  const raw = data["$surface"];
1143
- return (Array.isArray(raw) ? raw : [raw]).map((event) => validateUiSurfaceEvent(event));
1184
+ return (Array.isArray(raw) ? raw : [raw]).map((event) => validateUiSpecEvent(event));
1144
1185
  }
1145
1186
  /**
1146
1187
  * JsonSchema → 表单模型:按 properties 生成字段,required 标记必填;
@@ -1287,6 +1328,148 @@ var TraceRecorder = class {
1287
1328
  return [...this.#events];
1288
1329
  }
1289
1330
  };
1331
+ const RUN_SNAPSHOT_SCHEMA_VERSION = 2;
1332
+ /** @experimental */
1333
+ function isUnsupportedRunSnapshot(entry) {
1334
+ return entry.unsupported === true;
1335
+ }
1336
+ const SNAPSHOT_SUFFIX = ".snapshot.json";
1337
+ /**
1338
+ * FileSystemProvider 后端的快照存储:<root>/<runId>.snapshot.json。
1339
+ * 坏 JSON → RUN_SNAPSHOT_INCOMPATIBLE 并自动清理坏文件;runId 过路径安全校验。
1340
+ * save/list 时顺带清理已过 interactionExpiresAt 的过期快照。
1341
+ *
1342
+ * 数据敏感性说明:快照含完整对话历史(用户输入、工具结果、可能的凭据片段),
1343
+ * 以明文 JSON 落盘于宿主提供的 fs;宿主应将其视为会话数据同等保护。
1344
+ * @experimental
1345
+ */
1346
+ var FsRunSnapshotStore = class {
1347
+ #root;
1348
+ #fs;
1349
+ constructor(deps) {
1350
+ this.#root = deps.root.replace(/\/+$/, "");
1351
+ this.#fs = deps.fs;
1352
+ }
1353
+ #path(runId) {
1354
+ return resolveInsideRoot(this.#root, `${runId}${SNAPSHOT_SUFFIX}`);
1355
+ }
1356
+ async save(snapshot) {
1357
+ await this.#fs.writeText(this.#path(snapshot.runId), JSON.stringify(snapshot, null, 2));
1358
+ await this.#pruneExpired();
1359
+ }
1360
+ async load(runId) {
1361
+ const path = this.#path(runId);
1362
+ if (!await this.#fs.exists(path)) return void 0;
1363
+ let parsed;
1364
+ try {
1365
+ parsed = JSON.parse(await this.#fs.readText(path));
1366
+ } catch (e) {
1367
+ await this.#fs.remove(path).catch(() => void 0);
1368
+ throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" is corrupted and was deleted: ${e instanceof Error ? e.message : String(e)}`, e);
1369
+ }
1370
+ const snapshot = parsed;
1371
+ if (typeof snapshot !== "object" || snapshot === null) {
1372
+ await this.#fs.remove(path).catch(() => void 0);
1373
+ throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" has an unexpected shape and was deleted`);
1374
+ }
1375
+ const schemaVersion = typeof snapshot.schemaVersion === "number" ? snapshot.schemaVersion : 0;
1376
+ if (schemaVersion !== 2) throw new WebSkillError("RUN_SNAPSHOT_SCHEMA_UNSUPPORTED", `Snapshot for run "${runId}" uses schema version ${schemaVersion}; this runtime reads version 2. The file was kept for read-only inspection.`);
1377
+ if (snapshot.runId !== runId) {
1378
+ await this.#fs.remove(path).catch(() => void 0);
1379
+ throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" has an unexpected shape and was deleted`);
1380
+ }
1381
+ return snapshot;
1382
+ }
1383
+ async delete(runId) {
1384
+ const path = this.#path(runId);
1385
+ if (await this.#fs.exists(path)) await this.#fs.remove(path);
1386
+ }
1387
+ async list() {
1388
+ await this.#pruneExpired();
1389
+ if (!await this.#fs.exists(this.#root)) return [];
1390
+ const out = [];
1391
+ for (const entry of await this.#fs.list(this.#root)) {
1392
+ if (entry.type !== "file" || !entry.path.endsWith(SNAPSHOT_SUFFIX)) continue;
1393
+ try {
1394
+ const parsed = JSON.parse(await this.#fs.readText(entry.path));
1395
+ const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 0;
1396
+ if (schemaVersion === 2) {
1397
+ out.push(parsed);
1398
+ continue;
1399
+ }
1400
+ out.push({
1401
+ unsupported: true,
1402
+ schemaVersion,
1403
+ runId: String(parsed.runId ?? entry.path),
1404
+ snapshotAt: typeof parsed.snapshotAt === "string" ? parsed.snapshotAt : "",
1405
+ ...typeof parsed.sessionId === "string" ? { sessionId: parsed.sessionId } : {},
1406
+ ...typeof parsed.userPrompt === "string" ? { userPrompt: parsed.userPrompt } : {},
1407
+ ...typeof parsed.interactionExpiresAt === "string" ? { interactionExpiresAt: parsed.interactionExpiresAt } : {}
1408
+ });
1409
+ } catch {}
1410
+ }
1411
+ return out.sort((a, b) => a.snapshotAt.localeCompare(b.snapshotAt));
1412
+ }
1413
+ /** 过期快照清理(save/list 时顺带;失败静默不阻断主流程) */
1414
+ async #pruneExpired() {
1415
+ try {
1416
+ if (!await this.#fs.exists(this.#root)) return;
1417
+ const now = Date.now();
1418
+ for (const entry of await this.#fs.list(this.#root)) {
1419
+ if (entry.type !== "file" || !entry.path.endsWith(SNAPSHOT_SUFFIX)) continue;
1420
+ try {
1421
+ const parsed = JSON.parse(await this.#fs.readText(entry.path));
1422
+ if (parsed.interactionExpiresAt !== void 0 && Date.parse(parsed.interactionExpiresAt) < now) await this.#fs.remove(entry.path);
1423
+ } catch {}
1424
+ }
1425
+ } catch {}
1426
+ }
1427
+ };
1428
+ /**
1429
+ * LLM 可见名 → 作者在 SKILL.md 里写的形态。
1430
+ * `<已激活技能>__<脚本>` 保持原样(裸标识符按技能作用域单独匹配),
1431
+ * `mcp__x` → `mcp#x`,其余 `a__b` → `endpoint:a/b`。
1432
+ */
1433
+ function canonicalToolName(llmToolName, activated) {
1434
+ const sep = llmToolName.indexOf("__");
1435
+ if (sep > 0 && activated.has(llmToolName.slice(0, sep))) return llmToolName;
1436
+ if (llmToolName.startsWith("mcp__")) return `mcp#${llmToolName.slice(5)}`;
1437
+ if (sep > 0) return `endpoint:${llmToolName.slice(0, sep)}/${llmToolName.slice(sep + 2)}`;
1438
+ return llmToolName;
1439
+ }
1440
+ /**
1441
+ * 只支持整段匹配与尾部 `/*`。通配符越自由,作者越容易写出一个自以为很窄、实际很宽的规则;
1442
+ * 裸 `endpoint:github`(不带 `/*`)判为未匹配——含糊写法应当报警而不是被善意解释。
1443
+ */
1444
+ function matchesPattern(pattern, declaringSkill, llmToolName, canonical) {
1445
+ if (!pattern.includes(":") && !pattern.includes("#")) return llmToolName === `${declaringSkill}__${pattern}`;
1446
+ if (pattern === canonical) return true;
1447
+ if (pattern.endsWith("/*")) {
1448
+ const prefix = pattern.slice(0, -1);
1449
+ return canonical.startsWith(prefix) && canonical.length > prefix.length;
1450
+ }
1451
+ return false;
1452
+ }
1453
+ function denialReason(state, canonical) {
1454
+ const skills = [...state.skillAllowedTools.keys()].sort().map((s) => `"${s}"`);
1455
+ const subject = skills.length === 1 ? `Skill ${skills[0]} declares` : `Skills ${skills.join(", ")} declare`;
1456
+ const slash = canonical.lastIndexOf("/");
1457
+ return `${subject} allowed-tools, but tool "${canonical}" is not listed and was rejected. Add "${canonical}"${slash > 0 ? ` or "${canonical.slice(0, slash)}/*"` : ""} to allowed-tools.`;
1458
+ }
1459
+ /**
1460
+ * 多技能语义:无人声明 → 不受限;有人声明 → 命中任一清单,
1461
+ * 或存在一个**未声明**清单的激活技能(否则就是把 A 的声明施加到 B 头上)。
1462
+ */
1463
+ function evaluateToolAccess(state, llmToolName) {
1464
+ if (state.skillAllowedTools.size === 0) return { allowed: true };
1465
+ for (const skill of state.activated) if (!state.skillAllowedTools.has(skill)) return { allowed: true };
1466
+ const canonical = canonicalToolName(llmToolName, state.activated);
1467
+ for (const [skill, patterns] of state.skillAllowedTools) for (const pattern of patterns) if (matchesPattern(pattern, skill, llmToolName, canonical)) return { allowed: true };
1468
+ return {
1469
+ allowed: false,
1470
+ reason: denialReason(state, canonical)
1471
+ };
1472
+ }
1290
1473
  const MAX_SURFACE_PATCHES_PER_SECOND = 240;
1291
1474
  /** 交互终态(取消/超时):从工具执行深处直接终止 run */
1292
1475
  var RunTerminated = class extends Error {
@@ -1312,7 +1495,6 @@ const summarizeArgs = (args) => {
1312
1495
  const json = JSON.stringify(args);
1313
1496
  return json.length > 100 ? `${json.slice(0, 100)}…` : json;
1314
1497
  };
1315
- const surfaceActions = (surface) => "actions" in surface ? surface.actions ?? [] : [];
1316
1498
  /**
1317
1499
  * 多轮 Agent 循环。
1318
1500
  * 工具失败一律转为 ToolResult{ok:false} 回喂 LLM 继续循环;
@@ -1381,6 +1563,9 @@ var AgentLoop = class {
1381
1563
  reader: new SkillReader(this.#deps.fs, this.#deps.skillIndex),
1382
1564
  activated: /* @__PURE__ */ new Set(),
1383
1565
  activatedTools: /* @__PURE__ */ new Map(),
1566
+ skillAllowedTools: /* @__PURE__ */ new Map(),
1567
+ warnedDeniedTools: /* @__PURE__ */ new Set(),
1568
+ integrityVerdicts: /* @__PURE__ */ new Map(),
1384
1569
  toolTimeoutMs: this.#config.toolTimeoutMs,
1385
1570
  now,
1386
1571
  interactionSeq: 0,
@@ -1392,6 +1577,7 @@ var AgentLoop = class {
1392
1577
  surfacePatchWindowStartedAt: Date.now(),
1393
1578
  surfacePatchCount: 0,
1394
1579
  processedSurfaceActionNonces: /* @__PURE__ */ new Set(),
1580
+ emittedToolEvents: /* @__PURE__ */ new Set(),
1395
1581
  startMs,
1396
1582
  pausedMs: 0,
1397
1583
  maxTurns: this.#config.maxTurns,
@@ -1408,7 +1594,13 @@ var AgentLoop = class {
1408
1594
  strategy: route.strategy,
1409
1595
  skillCount: route.catalog.entries.length
1410
1596
  } });
1411
- await this.#lifecycle("route", state, { strategy: route.strategy });
1597
+ await this.#lifecycle({
1598
+ phase: "route",
1599
+ data: {
1600
+ strategy: route.strategy,
1601
+ candidates: route.catalog.entries.map((e) => e.name)
1602
+ }
1603
+ }, state);
1412
1604
  const externalSpecs = (await Promise.all((this.#deps.externalTools ?? []).map(async (source) => {
1413
1605
  try {
1414
1606
  return await source.listToolSpecs();
@@ -1417,15 +1609,25 @@ var AgentLoop = class {
1417
1609
  return [];
1418
1610
  }
1419
1611
  }))).flat();
1612
+ const externalSystemPrompts = [];
1613
+ for (const source of this.#deps.externalTools ?? []) {
1614
+ if (source.systemPrompt === void 0) continue;
1615
+ try {
1616
+ const text = (await source.systemPrompt())?.trim();
1617
+ if (text !== void 0 && text !== "") externalSystemPrompts.push(text);
1618
+ } catch (e) {
1619
+ trace.record("run.warning", { message: `External tool source "${source.kind}" failed to build a system prompt: ${messageOf(e)}` });
1620
+ }
1621
+ }
1420
1622
  state.messages = [
1421
1623
  {
1422
1624
  role: "system",
1423
- content: route.systemPrompt
1625
+ content: textParts([route.systemPrompt, ...externalSystemPrompts].join("\n\n"))
1424
1626
  },
1425
1627
  ...(input.history ?? []).map((m) => ({ ...m })),
1426
1628
  {
1427
1629
  role: "user",
1428
- content: input.userPrompt
1630
+ content: textParts(input.userPrompt)
1429
1631
  }
1430
1632
  ];
1431
1633
  try {
@@ -1455,6 +1657,27 @@ var AgentLoop = class {
1455
1657
  state.timer = void 0;
1456
1658
  }
1457
1659
  }
1660
+ /**
1661
+ * D10 判定的唯一出口(暴露点 + 分发点共用)。
1662
+ * 0.4.0(D1)起两个调用点都看返回值:暴露点过滤、分发点回喂 TOOL_NOT_ALLOWED。
1663
+ * 判定语义与 0.3.0 一致,改的只是调用点与 trace 事件类型(warning → denied)。
1664
+ */
1665
+ #checkToolAccess(state, toolName) {
1666
+ const verdict = evaluateToolAccess(state, toolName);
1667
+ if (verdict.allowed) return true;
1668
+ if (!state.warnedDeniedTools.has(toolName)) {
1669
+ state.warnedDeniedTools.add(toolName);
1670
+ state.trace.record("tool.denied", {
1671
+ message: verdict.reason ?? `Tool "${toolName}" is not allowed`,
1672
+ data: { name: toolName }
1673
+ });
1674
+ }
1675
+ return false;
1676
+ }
1677
+ /** 分发点被拒时回喂给模型的结构化错误(不抛异常:模型造名字是常态,抛异常会终止整个 run) */
1678
+ #deniedToolError(state, toolName) {
1679
+ return toolError("TOOL_NOT_ALLOWED", evaluateToolAccess(state, toolName).reason ?? `Tool "${toolName}" is not allowed`);
1680
+ }
1458
1681
  /** 主循环(run 从第 1 轮、resume 从快照轮次续跑;totalTimeout 以 startedAt 续算) */
1459
1682
  async #turnLoop(state, startTurn, externalSpecs) {
1460
1683
  const finish = (status, reason, output, errorCode) => this.#finish(state, status, reason, output, errorCode);
@@ -1466,11 +1689,11 @@ var AgentLoop = class {
1466
1689
  state.turn = turn;
1467
1690
  if (turn > state.maxTurns) return finish("failed", "max-turns", `Agent loop exceeded the maximum of ${state.maxTurns} turns`, "RUN_MAX_TURNS_EXCEEDED");
1468
1691
  if (this.#elapsed(state) > state.totalTimeoutMs) return finish("failed", "timeout", `Agent loop exceeded the total timeout of ${state.totalTimeoutMs}ms`, "RUN_TIMEOUT");
1692
+ const skillToolSpecs = [...[...state.activatedTools.values()].map(toLlmToolSpec), ...externalSpecs].filter((spec) => this.#checkToolAccess(state, spec.name));
1469
1693
  const toolSpecs = [
1470
1694
  toLlmToolSpec(READ_SKILL_FILE_TOOL),
1471
1695
  ...this.#deps.uiBridge ? [toLlmToolSpec(ASK_USER_TOOL)] : [],
1472
- ...[...state.activatedTools.values()].map(toLlmToolSpec),
1473
- ...externalSpecs
1696
+ ...skillToolSpecs
1474
1697
  ];
1475
1698
  trace.record("llm.request", { data: {
1476
1699
  turn,
@@ -1500,22 +1723,29 @@ var AgentLoop = class {
1500
1723
  const code = e instanceof WebSkillError && (e.code === "LLM_UNAVAILABLE" || e.code === "LLM_REQUEST_FAILED") ? e.code : "LLM_REQUEST_FAILED";
1501
1724
  return finish("failed", "llm-error", messageOf(e), code);
1502
1725
  }
1726
+ const responseText = partsToText(response.content);
1503
1727
  trace.record("llm.response", { data: {
1504
1728
  turn,
1505
1729
  hasToolCalls: Boolean(response.toolCalls?.length),
1506
- contentLength: response.content?.length ?? 0
1730
+ contentLength: responseText.length
1507
1731
  } });
1508
1732
  if (!response.toolCalls?.length) {
1509
1733
  messages.push({
1510
1734
  role: "assistant",
1511
- content: response.content ?? ""
1735
+ content: response.content ?? []
1512
1736
  });
1513
- return finish("completed", "final-answer", response.content ?? "");
1737
+ return finish("completed", "final-answer", responseText);
1514
1738
  }
1515
- await this.#lifecycle("execute", state, { turn });
1739
+ await this.#lifecycle({
1740
+ phase: "execute",
1741
+ data: {
1742
+ kind: "turn",
1743
+ turn
1744
+ }
1745
+ }, state);
1516
1746
  messages.push({
1517
1747
  role: "assistant",
1518
- content: response.content ?? "",
1748
+ content: response.content ?? [],
1519
1749
  toolCalls: response.toolCalls
1520
1750
  });
1521
1751
  for (const call of response.toolCalls) {
@@ -1529,7 +1759,7 @@ var AgentLoop = class {
1529
1759
  messages.push({
1530
1760
  role: "tool",
1531
1761
  toolCallId: call.id,
1532
- content: await this.#serializeToolResult(call, result, state)
1762
+ content: textParts(await this.#serializeToolResult(call, result, state))
1533
1763
  });
1534
1764
  await this.#drainSurfaceAction(state);
1535
1765
  }
@@ -1575,7 +1805,10 @@ var AgentLoop = class {
1575
1805
  trace.record("run.warning", { message: `Failed to delete run snapshot: ${messageOf(e)}` });
1576
1806
  }
1577
1807
  try {
1578
- await this.#lifecycle(status === "completed" ? "complete" : "fail", state, { reason });
1808
+ await this.#lifecycle({
1809
+ phase: status === "completed" ? "complete" : "fail",
1810
+ data: { reason }
1811
+ }, state);
1579
1812
  } catch (e) {
1580
1813
  trace.record("run.warning", { message: `Terminal lifecycle hook failed: ${messageOf(e)}` });
1581
1814
  }
@@ -1591,7 +1824,7 @@ var AgentLoop = class {
1591
1824
  const store = this.#deps.snapshotStore;
1592
1825
  if (!store) return;
1593
1826
  const snapshot = {
1594
- schemaVersion: 1,
1827
+ schemaVersion: 2,
1595
1828
  runId: state.runId,
1596
1829
  sessionId: state.run.sessionId,
1597
1830
  userPrompt: state.run.userPrompt,
@@ -1600,6 +1833,7 @@ var AgentLoop = class {
1600
1833
  turn: state.turn,
1601
1834
  activeSkillNames: [...state.activated].sort(),
1602
1835
  activatedTools: [...state.activatedTools.values()],
1836
+ ...state.skillAllowedTools.size > 0 ? { skillAllowedTools: Object.fromEntries([...state.skillAllowedTools].map(([k, v]) => [k, [...v]])) } : {},
1603
1837
  ...pending.interaction ? { pendingInteraction: pending.interaction } : {},
1604
1838
  ...pending.surfaceAction ? { pendingSurfaceAction: pending.surfaceAction } : {},
1605
1839
  interactionExpiresAt: state.run.interruptExpiresAt,
@@ -1649,6 +1883,9 @@ var AgentLoop = class {
1649
1883
  reader: new SkillReader(this.#deps.fs, this.#deps.skillIndex),
1650
1884
  activated: new Set(snapshot.activeSkillNames),
1651
1885
  activatedTools: new Map(snapshot.activatedTools.map((d) => [d.name, d])),
1886
+ skillAllowedTools: new Map(Object.entries(snapshot.skillAllowedTools ?? {})),
1887
+ warnedDeniedTools: /* @__PURE__ */ new Set(),
1888
+ integrityVerdicts: /* @__PURE__ */ new Map(),
1652
1889
  toolTimeoutMs: snapshot.config.toolTimeoutMs,
1653
1890
  now,
1654
1891
  interactionSeq: snapshot.interactionSeq ?? 0,
@@ -1660,6 +1897,7 @@ var AgentLoop = class {
1660
1897
  surfacePatchWindowStartedAt: Date.now(),
1661
1898
  surfacePatchCount: 0,
1662
1899
  processedSurfaceActionNonces: new Set(snapshot.processedSurfaceActionNonces ?? []),
1900
+ emittedToolEvents: /* @__PURE__ */ new Set(),
1663
1901
  startMs,
1664
1902
  pausedMs: snapshot.pausedMs ?? 0,
1665
1903
  maxTurns: snapshot.config.maxTurns,
@@ -1696,7 +1934,7 @@ var AgentLoop = class {
1696
1934
  state.messages.push({
1697
1935
  role: "tool",
1698
1936
  toolCallId: pendingCall.id,
1699
- content: await this.#serializeToolResult(pendingCall, result, state)
1937
+ content: textParts(await this.#serializeToolResult(pendingCall, result, state))
1700
1938
  });
1701
1939
  await this.#drainSurfaceAction(state);
1702
1940
  } else if (pending?.type === "ask" && pendingCall) {
@@ -1718,7 +1956,7 @@ var AgentLoop = class {
1718
1956
  state.messages.push({
1719
1957
  role: "tool",
1720
1958
  toolCallId: pendingCall.id,
1721
- content: await this.#serializeToolResult(pendingCall, result, state)
1959
+ content: textParts(await this.#serializeToolResult(pendingCall, result, state))
1722
1960
  });
1723
1961
  await this.#drainSurfaceAction(state);
1724
1962
  } else if (pendingCall) {
@@ -1726,7 +1964,7 @@ var AgentLoop = class {
1726
1964
  state.messages.push({
1727
1965
  role: "tool",
1728
1966
  toolCallId: pendingCall.id,
1729
- content: await this.#serializeToolResult(pendingCall, result, state)
1967
+ content: textParts(await this.#serializeToolResult(pendingCall, result, state))
1730
1968
  });
1731
1969
  await this.#drainSurfaceAction(state);
1732
1970
  }
@@ -1737,7 +1975,7 @@ var AgentLoop = class {
1737
1975
  state.messages.push({
1738
1976
  role: "tool",
1739
1977
  toolCallId: next.id,
1740
- content: await this.#serializeToolResult(next, result, state)
1978
+ content: textParts(await this.#serializeToolResult(next, result, state))
1741
1979
  });
1742
1980
  await this.#drainSurfaceAction(state);
1743
1981
  }
@@ -1780,7 +2018,7 @@ var AgentLoop = class {
1780
2018
  sessionId: state.run.sessionId,
1781
2019
  ts: state.now(),
1782
2020
  data: {
1783
- type: "llm.delta",
2021
+ kind: "llm-delta",
1784
2022
  delta
1785
2023
  }
1786
2024
  });
@@ -1794,23 +2032,23 @@ var AgentLoop = class {
1794
2032
  emitDelta(event.delta);
1795
2033
  } else if (event.type === "tool-calls") toolCalls.push(...event.toolCalls);
1796
2034
  else if (event.type === "done") doneContent = event.content;
2035
+ const text = doneContent ?? content;
1797
2036
  return {
1798
- content: doneContent ?? (content === "" ? void 0 : content),
2037
+ content: text === "" ? void 0 : textParts(text),
1799
2038
  toolCalls: toolCalls.length > 0 ? toolCalls : void 0
1800
2039
  };
1801
2040
  }
1802
2041
  /** 生命周期接线:更新 phase、发事件、跑钩子 */
1803
- async #lifecycle(phase, state, data) {
1804
- state.run.phase = phase;
2042
+ async #lifecycle(init, state) {
2043
+ state.run.phase = init.phase;
1805
2044
  const event = {
1806
- phase,
2045
+ ...init,
1807
2046
  runId: state.runId,
1808
2047
  sessionId: state.run.sessionId,
1809
- ts: state.now(),
1810
- ...data ? { data } : {}
2048
+ ts: state.now()
1811
2049
  };
1812
2050
  this.#deps.eventBus?.emit(event);
1813
- if (this.#deps.hooks) await this.#deps.hooks.run(phase, {
2051
+ if (this.#deps.hooks) await this.#deps.hooks.run(init.phase, {
1814
2052
  event,
1815
2053
  run: state.run
1816
2054
  });
@@ -1837,10 +2075,14 @@ var AgentLoop = class {
1837
2075
  run.status = "interrupted";
1838
2076
  run.interruptExpiresAt = new Date(Date.parse(state.now()) + this.#policy.interactionTimeoutMs).toISOString();
1839
2077
  await this.#saveSnapshot(state, { interaction: request });
1840
- await this.#lifecycle("interact", state, {
1841
- interactionId: request.id,
1842
- type: request.type
1843
- });
2078
+ await this.#lifecycle({
2079
+ phase: "interact",
2080
+ data: {
2081
+ kind: "interaction",
2082
+ interactionId: request.id,
2083
+ interactionType: request.type
2084
+ }
2085
+ }, state);
1844
2086
  state.trace.record("ui.requested", { data: {
1845
2087
  interactionId: request.id,
1846
2088
  type: request.type,
@@ -1873,7 +2115,13 @@ var AgentLoop = class {
1873
2115
  interactionId: request.id,
1874
2116
  type: request.type
1875
2117
  } });
1876
- await this.#lifecycle("execute", state, { resumed: request.id });
2118
+ await this.#lifecycle({
2119
+ phase: "execute",
2120
+ data: {
2121
+ kind: "interaction-resumed",
2122
+ interactionId: request.id
2123
+ }
2124
+ }, state);
1877
2125
  await this.#appendParamHistory(state, request, response.value);
1878
2126
  return response.value;
1879
2127
  }
@@ -1907,6 +2155,7 @@ var AgentLoop = class {
1907
2155
  if (call.argumentsParseError) result = toolError("VALIDATION_FAILED", `Tool arguments were not valid JSON: ${call.argumentsParseError}`);
1908
2156
  else if (call.name === "read_skill_file") result = await this.#handleReadSkillFile(call, state);
1909
2157
  else if (call.name === "ask_user") result = await this.#handleAskUser(call, state);
2158
+ else if (!this.#checkToolAccess(state, call.name)) result = this.#deniedToolError(state, call.name);
1910
2159
  else {
1911
2160
  const resolution = resolveToolName(call.name, state.activated);
1912
2161
  if (resolution.kind === "script") result = await this.#handleScriptTool(call, resolution.skillName, resolution.scriptName, state);
@@ -1932,7 +2181,7 @@ var AgentLoop = class {
1932
2181
  chart
1933
2182
  });
1934
2183
  try {
1935
- for (const event of extractUiSurfaceEvents(item.data)) await this.#renderSurface(state, event);
2184
+ for (const event of extractUiSpecEvents(item.data)) await this.#renderSurface(state, event);
1936
2185
  } catch (e) {
1937
2186
  state.trace.record("run.warning", {
1938
2187
  message: `UI surface rejected: ${messageOf(e)}`,
@@ -1971,14 +2220,14 @@ var AgentLoop = class {
1971
2220
  await bridge.renderSurface(attributed);
1972
2221
  state.surfaceEvents.push(structuredClone(attributed));
1973
2222
  if (attributed.type === "open") {
1974
- const waiting = surfaceActions(attributed.surface).filter((action) => action.awaitResponse);
2223
+ const waiting = (attributed.actions ?? []).filter((action) => action.awaitResponse);
1975
2224
  if (waiting.length > 1) throw new WebSkillError("VALIDATION_FAILED", "A UI surface can wait for only one action");
1976
2225
  const action = waiting[0];
1977
2226
  if (action?.nonce) if (!bridge.requestSurfaceAction) state.trace.record("run.warning", { message: "UiBridge does not support requestSurfaceAction; UI surface action will not pause the run" });
1978
2227
  else if (state.pendingSurfaceAction) throw new WebSkillError("VALIDATION_FAILED", "Only one UI surface action can be pending at a time");
1979
2228
  else state.pendingSurfaceAction = {
1980
2229
  runId: state.runId,
1981
- surfaceId: attributed.surface.id,
2230
+ surfaceId: attributed.id,
1982
2231
  actionId: action.id,
1983
2232
  intent: action.intent,
1984
2233
  nonce: action.nonce
@@ -1996,21 +2245,17 @@ var AgentLoop = class {
1996
2245
  }
1997
2246
  /** Assigns unforgeable action nonces after model output has passed structural validation. */
1998
2247
  #attributeSurfaceEvent(state, event) {
1999
- const actions = event.type === "open" ? surfaceActions(event.surface) : [];
2000
- if (event.type !== "open" || actions.length === 0) return {
2248
+ if (event.type !== "open" || (event.actions ?? []).length === 0) return {
2001
2249
  ...event,
2002
2250
  runId: state.runId
2003
2251
  };
2004
2252
  return {
2005
- type: "open",
2253
+ ...event,
2006
2254
  runId: state.runId,
2007
- surface: {
2008
- ...event.surface,
2009
- actions: actions.map((action) => ({
2010
- ...action,
2011
- nonce: `surface-${state.runId}-${++state.surfaceActionSeq}`
2012
- }))
2013
- }
2255
+ actions: (event.actions ?? []).map((action) => ({
2256
+ ...action,
2257
+ nonce: `surface-${state.runId}-${++state.surfaceActionSeq}`
2258
+ }))
2014
2259
  };
2015
2260
  }
2016
2261
  /** Awaits the single action emitted with the most recently persisted tool result. */
@@ -2037,13 +2282,13 @@ var AgentLoop = class {
2037
2282
  state.processedSurfaceActionNonces.add(response.nonce);
2038
2283
  state.messages.push({
2039
2284
  role: "user",
2040
- content: JSON.stringify({
2285
+ content: textParts(JSON.stringify({
2041
2286
  type: "webskill_surface_action",
2042
2287
  surfaceId: response.surfaceId,
2043
2288
  actionId: response.actionId,
2044
2289
  intent: response.intent,
2045
2290
  value: response.value ?? null
2046
- })
2291
+ }))
2047
2292
  });
2048
2293
  }
2049
2294
  async #interactSurfaceAction(state, request, resumed) {
@@ -2055,20 +2300,26 @@ var AgentLoop = class {
2055
2300
  const { run } = state;
2056
2301
  run.status = "interrupted";
2057
2302
  run.interruptExpiresAt = new Date(Date.parse(state.now()) + this.#policy.interactionTimeoutMs).toISOString();
2303
+ const pendingResponse = bridge.requestSurfaceAction(request);
2304
+ pendingResponse.catch(() => void 0);
2058
2305
  await this.#saveSnapshot(state, { surfaceAction: request });
2059
- await this.#lifecycle("interact", state, {
2060
- surfaceId: request.surfaceId,
2061
- actionId: request.actionId,
2062
- nonce: request.nonce,
2063
- ...resumed ? { resumed: true } : {}
2064
- });
2306
+ await this.#lifecycle({
2307
+ phase: "interact",
2308
+ data: {
2309
+ kind: "surface-action",
2310
+ surfaceId: request.surfaceId,
2311
+ actionId: request.actionId,
2312
+ nonce: request.nonce,
2313
+ resumed
2314
+ }
2315
+ }, state);
2065
2316
  state.trace.record("ui.surface-action.requested", { data: {
2066
2317
  surfaceId: request.surfaceId,
2067
2318
  actionId: request.actionId,
2068
2319
  nonce: request.nonce,
2069
2320
  ...resumed ? { resumed: true } : {}
2070
2321
  } });
2071
- const response = await this.#withInteractionTimeout(bridge.requestSurfaceAction(request), this.#policy.interactionTimeoutMs, () => bridge.cancelSurfaceAction?.(request.nonce));
2322
+ const response = await this.#withInteractionTimeout(pendingResponse, this.#policy.interactionTimeoutMs, () => bridge.cancelSurfaceAction?.(request.nonce));
2072
2323
  run.status = "running";
2073
2324
  run.interruptExpiresAt = void 0;
2074
2325
  state.trace.record("ui.surface-action.resolved", { data: {
@@ -2076,9 +2327,17 @@ var AgentLoop = class {
2076
2327
  actionId: request.actionId,
2077
2328
  nonce: request.nonce
2078
2329
  } });
2079
- await this.#lifecycle("execute", state, { surfaceAction: request.actionId });
2330
+ await this.#lifecycle({
2331
+ phase: "execute",
2332
+ data: {
2333
+ kind: "surface-action-resumed",
2334
+ surfaceId: request.surfaceId,
2335
+ actionId: request.actionId
2336
+ }
2337
+ }, state);
2080
2338
  return response;
2081
2339
  } catch (e) {
2340
+ bridge.cancelSurfaceAction?.(request.nonce);
2082
2341
  state.run.status = "running";
2083
2342
  state.run.interruptExpiresAt = void 0;
2084
2343
  if (e instanceof WebSkillError && e.code === "RUN_INTERACTION_TIMEOUT") throw new RunTerminated({
@@ -2108,15 +2367,25 @@ var AgentLoop = class {
2108
2367
  * 逐工具实时事件(execute 相位,data.type='tool'):chatbot 思维链工具行等的 live 状态源;
2109
2368
  * 与既有 execute 相位事件({turn})并存,监听方按 data.type 区分。
2110
2369
  * data.args 为参数摘要(JSON 截断 100 字符,展开详情用)。
2370
+ *
2371
+ * 幂等:同一 run 内每个 `(callId, status)` 至多投递一次。
2372
+ * 模型在不同轮次重发同一个 tool call id 是真实发生的,消费侧本来各自
2373
+ * 建去重表兑付;幂等是事件流自己的语义,不应该要求每个订阅者重建一次。
2374
+ *
2375
+ * 集合挂在 LoopState 上、**不写进快照**:写进快照会让跨进程恢复的
2376
+ * 消费者永远收不到它本来就没见过的事件。
2111
2377
  */
2112
2378
  #emitTool(state, status, call) {
2379
+ const key = `${call.id}:${status}`;
2380
+ if (state.emittedToolEvents.has(key)) return;
2381
+ state.emittedToolEvents.add(key);
2113
2382
  this.#deps.eventBus?.emit({
2114
2383
  phase: "execute",
2115
2384
  runId: state.runId,
2116
2385
  sessionId: state.run.sessionId,
2117
2386
  ts: state.now(),
2118
2387
  data: {
2119
- type: "tool",
2388
+ kind: "tool",
2120
2389
  status,
2121
2390
  name: call.name,
2122
2391
  callId: call.id,
@@ -2186,7 +2455,13 @@ var AgentLoop = class {
2186
2455
  skillName: name,
2187
2456
  source: "external"
2188
2457
  } });
2189
- await this.#lifecycle("activate", state, { skillName: name });
2458
+ await this.#lifecycle({
2459
+ phase: "activate",
2460
+ data: {
2461
+ skillName: name,
2462
+ source: "external"
2463
+ }
2464
+ }, state);
2190
2465
  await this.#writeActivationMemory(name, state);
2191
2466
  }
2192
2467
  return {
@@ -2240,18 +2515,62 @@ var AgentLoop = class {
2240
2515
  if (!check) return false;
2241
2516
  return await check(skillName) === false;
2242
2517
  }
2518
+ /**
2519
+ * D3 激活期完整性校验。结论按技能名在 run 内缓存,**失败也缓存**:
2520
+ * 校验失败的技能不会进 `activated` 集合,不缓存的话同一个坏技能每尝试激活一次
2521
+ * 就要把它的文件全扫一遍——成本随文件数线性放大,正是需求 §3 验收 3 要防的。
2522
+ *
2523
+ * 设计 §3.5 写的缓存键是 `(skillName, manifest.integrity.digest)`,但 digest 只有
2524
+ * **调用之后**才知道;runtime 读不到 manifest,两元组键在这一层无法实现。
2525
+ * 实际键是技能名,digest 作为结论的一部分留在 trace 里供事后对账。
2526
+ *
2527
+ * 失败不抛错:抛错会终止整个 run,而「某个技能被改过」不该让其余技能一起停摆。
2528
+ */
2529
+ async #integrityOk(skillName, state) {
2530
+ const guard = this.#deps.skillIntegrityGuard;
2531
+ if (!guard?.verifyOnActivate) return true;
2532
+ let verdict = state.integrityVerdicts.get(skillName);
2533
+ if (verdict === void 0) {
2534
+ try {
2535
+ verdict = await guard.verifyOnActivate(skillName);
2536
+ } catch (e) {
2537
+ verdict = {
2538
+ ok: false,
2539
+ reason: `the integrity guard threw: ${messageOf(e)}`
2540
+ };
2541
+ }
2542
+ state.integrityVerdicts.set(skillName, verdict);
2543
+ }
2544
+ if (verdict.ok) return true;
2545
+ state.trace.record("skill.integrity-failed", {
2546
+ message: `Skill "${skillName}" failed integrity verification on activation: ${verdict.reason ?? "no reason given"}`,
2547
+ data: {
2548
+ skillName,
2549
+ ...verdict.digest !== void 0 ? { digest: verdict.digest } : {}
2550
+ }
2551
+ });
2552
+ return false;
2553
+ }
2243
2554
  /** 首次读到 SKILL.md 时激活技能:加载其 scripts 工具定义,供后续轮次使用;dependencies 级联激活(via 记录来源) */
2244
2555
  async #activateSkill(skillName, state, via, skillMdText) {
2245
2556
  if (await this.#guardDenied("canActivate", skillName)) {
2246
2557
  state.trace.record("run.warning", { message: `Skill "${skillName}" is blocked by the skill state guard (activate)` });
2247
2558
  return "";
2248
2559
  }
2560
+ if (!await this.#integrityOk(skillName, state)) return "";
2249
2561
  state.activated.add(skillName);
2250
2562
  state.trace.record("skill.activated", { data: {
2251
2563
  skillName,
2252
2564
  ...via ? { via } : {}
2253
2565
  } });
2254
- await this.#lifecycle("activate", state, { skillName });
2566
+ await this.#lifecycle({
2567
+ phase: "activate",
2568
+ data: {
2569
+ skillName,
2570
+ source: "local",
2571
+ ...via ? { via } : {}
2572
+ }
2573
+ }, state);
2255
2574
  await this.#writeActivationMemory(skillName, state);
2256
2575
  const root = this.#deps.skillIndex.get(skillName);
2257
2576
  if (!root) return "";
@@ -2262,8 +2581,10 @@ var AgentLoop = class {
2262
2581
  const rawDeps = metadata["dependencies"];
2263
2582
  if (Array.isArray(rawDeps)) dependencies = rawDeps.filter((d) => typeof d === "string");
2264
2583
  const rawAllowed = metadata["allowed-tools"];
2265
- if (rawAllowed !== void 0) if (Array.isArray(rawAllowed)) allowedTools = rawAllowed.filter((e) => typeof e === "string");
2266
- else state.trace.record("run.warning", { message: `Skill "${skillName}" has a non-array "allowed-tools" metadata entry; ignored` });
2584
+ if (rawAllowed !== void 0) if (Array.isArray(rawAllowed)) {
2585
+ allowedTools = rawAllowed.filter((e) => typeof e === "string");
2586
+ state.skillAllowedTools.set(skillName, allowedTools);
2587
+ } else state.trace.record("run.warning", { message: `Skill "${skillName}" has a non-array "allowed-tools" metadata entry; ignored` });
2267
2588
  } catch (e) {
2268
2589
  state.trace.record("run.warning", { message: `Failed to read or parse SKILL.md of skill "${skillName}": ${messageOf(e)}` });
2269
2590
  }
@@ -2280,7 +2601,10 @@ var AgentLoop = class {
2280
2601
  for (const file of scriptFiles) {
2281
2602
  const match = /^(.*)\.(ts|js)$/.exec(file);
2282
2603
  if (!match?.[1]) continue;
2283
- if (allowedTools && !allowedTools.includes(match[1])) continue;
2604
+ if (allowedTools && !allowedTools.includes(match[1])) {
2605
+ state.trace.record("run.warning", { message: `Skill "${skillName}" declares allowed-tools, so its script "${match[1]}" is not registered as a tool. Add "${match[1]}" to allowed-tools if that was not intended.` });
2606
+ continue;
2607
+ }
2284
2608
  try {
2285
2609
  const def = await executor.loadDefinition(root, match[1]);
2286
2610
  await this.#enrichDefinition(root, match[1], def, state);
@@ -2380,11 +2704,41 @@ var AgentLoop = class {
2380
2704
  if (fresh.length > 0) result.artifacts = [...result.artifacts ?? [], ...fresh];
2381
2705
  }
2382
2706
  await this.#bumpSkillStat(skillName, result.ok ? "successes" : "failures", state);
2707
+ if (!result.ok) await this.#reportSkillFailure(skillName, state, {
2708
+ code: result.error?.code ?? "TOOL_EXECUTION_FAILED",
2709
+ message: result.error?.message ?? `Tool "${call.name}" returned a failure result`
2710
+ });
2383
2711
  return result;
2384
2712
  } catch (e) {
2385
2713
  if (e instanceof RunTerminated) throw e;
2386
2714
  await this.#bumpSkillStat(skillName, "failures", state);
2387
- return toolError(e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED", `Tool "${call.name}" failed: ${messageOf(e)}`);
2715
+ const code = e instanceof WebSkillError ? e.code : "TOOL_EXECUTION_FAILED";
2716
+ const message = `Tool "${call.name}" failed: ${messageOf(e)}`;
2717
+ await this.#reportSkillFailure(skillName, state, {
2718
+ code,
2719
+ message
2720
+ });
2721
+ return toolError(code, message);
2722
+ }
2723
+ }
2724
+ /**
2725
+ * F1 技能失败上报:喂给治理的失败计数器(`SkillStatePolicy.recordFailure`),
2726
+ * 达阈值即自动隔离。无注入即整段跳过(默认关闭)。
2727
+ *
2728
+ * 上报方抛错降级为 `run.warning`:治理写盘失败不该把一次「工具出错但已回喂给 LLM」
2729
+ * 的 run 变成崩溃——那会让引入治理反而降低可用性。
2730
+ */
2731
+ async #reportSkillFailure(skillName, state, detail) {
2732
+ const report = this.#deps.skillOutcomeReporter?.onSkillFailed;
2733
+ if (report === void 0) return;
2734
+ try {
2735
+ await report.call(this.#deps.skillOutcomeReporter, {
2736
+ skillName,
2737
+ runId: state.runId,
2738
+ ...detail
2739
+ });
2740
+ } catch (e) {
2741
+ state.trace.record("run.warning", { message: `Skill failure report for "${skillName}" was not recorded: ${messageOf(e)}` });
2388
2742
  }
2389
2743
  }
2390
2744
  /** context.confirm 触发点:默认真实询问;auto-approve 直通;无 bridge 降级直通 + warning */
@@ -2494,79 +2848,6 @@ function mergeCatalogEntries(localEntries, providerEntries) {
2494
2848
  for (const entry of providerEntries) if (!byName.has(entry.name)) byName.set(entry.name, entry);
2495
2849
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
2496
2850
  }
2497
- const RUN_SNAPSHOT_SCHEMA_VERSION = 1;
2498
- const SNAPSHOT_SUFFIX = ".snapshot.json";
2499
- /**
2500
- * FileSystemProvider 后端的快照存储:<root>/<runId>.snapshot.json。
2501
- * 坏 JSON → RUN_SNAPSHOT_INCOMPATIBLE 并自动清理坏文件;runId 过路径安全校验。
2502
- * save/list 时顺带清理已过 interactionExpiresAt 的过期快照。
2503
- *
2504
- * 数据敏感性说明:快照含完整对话历史(用户输入、工具结果、可能的凭据片段),
2505
- * 以明文 JSON 落盘于宿主提供的 fs;宿主应将其视为会话数据同等保护。
2506
- * @experimental
2507
- */
2508
- var FsRunSnapshotStore = class {
2509
- #root;
2510
- #fs;
2511
- constructor(deps) {
2512
- this.#root = deps.root.replace(/\/+$/, "");
2513
- this.#fs = deps.fs;
2514
- }
2515
- #path(runId) {
2516
- return resolveInsideRoot(this.#root, `${runId}${SNAPSHOT_SUFFIX}`);
2517
- }
2518
- async save(snapshot) {
2519
- await this.#fs.writeText(this.#path(snapshot.runId), JSON.stringify(snapshot, null, 2));
2520
- await this.#pruneExpired();
2521
- }
2522
- async load(runId) {
2523
- const path = this.#path(runId);
2524
- if (!await this.#fs.exists(path)) return void 0;
2525
- let parsed;
2526
- try {
2527
- parsed = JSON.parse(await this.#fs.readText(path));
2528
- } catch (e) {
2529
- await this.#fs.remove(path).catch(() => void 0);
2530
- throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" is corrupted and was deleted: ${e instanceof Error ? e.message : String(e)}`, e);
2531
- }
2532
- const snapshot = parsed;
2533
- if (typeof snapshot !== "object" || snapshot === null || snapshot.runId !== runId) {
2534
- await this.#fs.remove(path).catch(() => void 0);
2535
- throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" has an unexpected shape and was deleted`);
2536
- }
2537
- return snapshot;
2538
- }
2539
- async delete(runId) {
2540
- const path = this.#path(runId);
2541
- if (await this.#fs.exists(path)) await this.#fs.remove(path);
2542
- }
2543
- async list() {
2544
- await this.#pruneExpired();
2545
- if (!await this.#fs.exists(this.#root)) return [];
2546
- const out = [];
2547
- for (const entry of await this.#fs.list(this.#root)) {
2548
- if (entry.type !== "file" || !entry.path.endsWith(SNAPSHOT_SUFFIX)) continue;
2549
- try {
2550
- out.push(JSON.parse(await this.#fs.readText(entry.path)));
2551
- } catch {}
2552
- }
2553
- return out.sort((a, b) => a.snapshotAt.localeCompare(b.snapshotAt));
2554
- }
2555
- /** 过期快照清理(save/list 时顺带;失败静默不阻断主流程) */
2556
- async #pruneExpired() {
2557
- try {
2558
- if (!await this.#fs.exists(this.#root)) return;
2559
- const now = Date.now();
2560
- for (const entry of await this.#fs.list(this.#root)) {
2561
- if (entry.type !== "file" || !entry.path.endsWith(SNAPSHOT_SUFFIX)) continue;
2562
- try {
2563
- const parsed = JSON.parse(await this.#fs.readText(entry.path));
2564
- if (parsed.interactionExpiresAt !== void 0 && Date.parse(parsed.interactionExpiresAt) < now) await this.#fs.remove(entry.path);
2565
- } catch {}
2566
- }
2567
- } catch {}
2568
- }
2569
- };
2570
2851
  /**
2571
2852
  * session history 滚动裁剪:超出预算时裁掉中段,保留首尾。
2572
2853
  * 边界对齐 LLM tool 契约:head 不以未应答的 assistant toolCalls 结尾,
@@ -2700,7 +2981,9 @@ var WebSkillRuntime = class {
2700
2981
  skillProviders: this.#deps.skillProviders,
2701
2982
  catalogFilter: this.#deps.catalogFilter,
2702
2983
  snapshotStore: this.#deps.snapshotStore,
2703
- skillStateGuard: this.#deps.skillStateGuard
2984
+ skillStateGuard: this.#deps.skillStateGuard,
2985
+ skillIntegrityGuard: this.#deps.skillIntegrityGuard,
2986
+ skillOutcomeReporter: this.#deps.skillOutcomeReporter
2704
2987
  }, this.#deps.config);
2705
2988
  const runId = `run-${Math.random().toString(36).slice(2, 10)}`;
2706
2989
  this.#loops.set(runId, loop);
@@ -2737,14 +3020,14 @@ var WebSkillRuntime = class {
2737
3020
  });
2738
3021
  return result;
2739
3022
  }
2740
- /** D3:列出可恢复的 interrupted run(供 UI 展示"未完成任务" */
3023
+ /** D3:列出 interrupted run(供 UI 展示"未完成任务");版本不受支持的项带 unsupported 标记 */
2741
3024
  async listInterruptedRuns() {
2742
3025
  if (!this.#deps.snapshotStore) return [];
2743
3026
  return this.#deps.snapshotStore.list();
2744
3027
  }
2745
3028
  /**
2746
3029
  * D3 恢复 interrupted run:
2747
- * 不存在 → RUN_SNAPSHOT_NOT_FOUND;schemaVersion 不匹配删除 + RUN_SNAPSHOT_INCOMPATIBLE;
3030
+ * 不存在 → RUN_SNAPSHOT_NOT_FOUND;schema 版本不受支持RUN_SNAPSHOT_SCHEMA_UNSUPPORTED(由 store 抛出,不删文件);
2748
3031
  * 已过期 → interaction-timeout 终态并删快照;否则重建 LoopState 重新发起交互续跑。
2749
3032
  * @experimental
2750
3033
  */
@@ -2752,10 +3035,6 @@ var WebSkillRuntime = class {
2752
3035
  const store = this.#deps.snapshotStore;
2753
3036
  const snapshot = store ? await store.load(runId) : void 0;
2754
3037
  if (!snapshot) throw new WebSkillError("RUN_SNAPSHOT_NOT_FOUND", `No snapshot found for run "${runId}"`);
2755
- if (snapshot.schemaVersion !== 1) {
2756
- await store.delete(runId);
2757
- throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" has incompatible schemaVersion ${String(snapshot.schemaVersion)}`);
2758
- }
2759
3038
  if (Date.now() > Date.parse(snapshot.interactionExpiresAt)) {
2760
3039
  await store.delete(runId);
2761
3040
  const endedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2808,7 +3087,9 @@ var WebSkillRuntime = class {
2808
3087
  skillProviders: this.#deps.skillProviders,
2809
3088
  catalogFilter: this.#deps.catalogFilter,
2810
3089
  snapshotStore: store,
2811
- skillStateGuard: this.#deps.skillStateGuard
3090
+ skillStateGuard: this.#deps.skillStateGuard,
3091
+ skillIntegrityGuard: this.#deps.skillIntegrityGuard,
3092
+ skillOutcomeReporter: this.#deps.skillOutcomeReporter
2812
3093
  }, this.#deps.config);
2813
3094
  this.#loops.set(runId, loop);
2814
3095
  try {
@@ -2927,6 +3208,7 @@ function sourceFromUrl(url) {
2927
3208
  /** 受控钩子执行器:逐个执行,超时/异常默认降级为 warning,可切严格模式 */
2928
3209
  var HookRunner = class {
2929
3210
  #hooks = /* @__PURE__ */ new Map();
3211
+ /** 宿主可在装配后按 RuntimeConfig 调整(console Settings › Agent Runtime 即经此生效) */
2930
3212
  timeoutMs;
2931
3213
  failOnHookError;
2932
3214
  onWarning;
@@ -2942,6 +3224,15 @@ var HookRunner = class {
2942
3224
  this.#hooks.set(key, list);
2943
3225
  return this;
2944
3226
  }
3227
+ /**
3228
+ * 已注册钩子的**计数**,按注册相位分组(`'*'` 为全相位钩子)。
3229
+ * 刻意不返回函数引用:那会给 UI 一条调用宿主钩子的执行路径,而面板只需要「装没装上」。
3230
+ */
3231
+ listRegisteredHooks() {
3232
+ const counts = /* @__PURE__ */ new Map();
3233
+ for (const [phase, hooks] of this.#hooks) counts.set(phase, hooks.length);
3234
+ return counts;
3235
+ }
2945
3236
  async run(phase, ctx) {
2946
3237
  const hooks = [...this.#hooks.get(phase) ?? [], ...this.#hooks.get("*") ?? []];
2947
3238
  for (const hook of hooks) try {
@@ -3046,7 +3337,7 @@ var FsMemoryStore = class {
3046
3337
  for (const entry of await this.#fs.list(this.#root)) await this.#fs.remove(entry.path, { recursive: true });
3047
3338
  }
3048
3339
  };
3049
- const INDEX_FILE = "index.json";
3340
+ const INDEX_FILE$1 = "index.json";
3050
3341
  /**
3051
3342
  * 基于 FileSystemProvider 的 ArtifactStore:产物落盘 <root>/<runId>/<path>,
3052
3343
  * 每次写入同步更新 <root>/<runId>/index.json,新实例可凭索引恢复列表。
@@ -3080,7 +3371,7 @@ var FsArtifactStore = class {
3080
3371
  }
3081
3372
  async listArtifacts(runId) {
3082
3373
  assertSafePathSegment(runId, "runId");
3083
- const indexPath = `${this.#root}/${runId}/${INDEX_FILE}`;
3374
+ const indexPath = `${this.#root}/${runId}/${INDEX_FILE$1}`;
3084
3375
  if (!await this.#fs.exists(indexPath)) return [];
3085
3376
  const raw = await this.#fs.readText(indexPath);
3086
3377
  try {
@@ -3113,7 +3404,7 @@ var FsArtifactStore = class {
3113
3404
  metadata: input.metadata
3114
3405
  };
3115
3406
  const next = [...(await this.listArtifacts(input.runId)).filter((a) => a.id !== artifact.id), artifact];
3116
- await this.#fs.writeText(`${this.#root}/${input.runId}/${INDEX_FILE}`, JSON.stringify({ artifacts: next }, null, 2));
3407
+ await this.#fs.writeText(`${this.#root}/${input.runId}/${INDEX_FILE$1}`, JSON.stringify({ artifacts: next }, null, 2));
3117
3408
  return artifact;
3118
3409
  }
3119
3410
  };
@@ -3332,6 +3623,417 @@ var CapabilityApproval = class CapabilityApproval {
3332
3623
  return "allowed";
3333
3624
  }
3334
3625
  };
3626
+ const RUN_TRACE_SCHEMA_VERSION = 1;
3627
+ /** 终止原因:取最后一条 run.completed/cancelled/failed 事件的 data.reason */
3628
+ function extractEndReason(events) {
3629
+ for (let i = events.length - 1; i >= 0; i--) {
3630
+ const event = events[i];
3631
+ if (event.type === "run.completed" || event.type === "run.cancelled" || event.type === "run.failed") {
3632
+ const reason = event.data?.["reason"];
3633
+ return typeof reason === "string" ? reason : void 0;
3634
+ }
3635
+ }
3636
+ }
3637
+ function summarize(trace) {
3638
+ const endReason = extractEndReason(trace.events);
3639
+ const durationMs = trace.endedAt === void 0 ? void 0 : Date.parse(trace.endedAt) - Date.parse(trace.startedAt);
3640
+ return {
3641
+ runId: trace.runId,
3642
+ startedAt: trace.startedAt,
3643
+ status: trace.status,
3644
+ activeSkills: trace.activeSkills,
3645
+ eventCount: trace.events.length,
3646
+ turnCount: trace.events.filter((e) => e.type === "llm.request").length,
3647
+ ...trace.sessionId !== void 0 ? { sessionId: trace.sessionId } : {},
3648
+ ...trace.endedAt !== void 0 ? { endedAt: trace.endedAt } : {},
3649
+ ...endReason !== void 0 ? { endReason } : {},
3650
+ ...durationMs !== void 0 && Number.isFinite(durationMs) && durationMs >= 0 ? { durationMs } : {}
3651
+ };
3652
+ }
3653
+ function parseTraceFile(raw, path) {
3654
+ let data;
3655
+ try {
3656
+ data = JSON.parse(raw);
3657
+ } catch {
3658
+ return;
3659
+ }
3660
+ if (typeof data !== "object" || data === null) return void 0;
3661
+ if (typeof data.runId !== "string" || typeof data.startedAt !== "string") return void 0;
3662
+ const schemaVersion = typeof data.schemaVersion === "number" ? data.schemaVersion : 0;
3663
+ if (schemaVersion > 1) throw new WebSkillError("RUN_TRACE_INCOMPATIBLE", `Run trace "${path}" declares schemaVersion ${schemaVersion}, which is newer than the supported 1`);
3664
+ return {
3665
+ schemaVersion,
3666
+ runId: data.runId,
3667
+ ...typeof data.sessionId === "string" ? { sessionId: data.sessionId } : {},
3668
+ startedAt: data.startedAt,
3669
+ ...typeof data.endedAt === "string" ? { endedAt: data.endedAt } : {},
3670
+ status: typeof data.status === "string" ? data.status : "unknown",
3671
+ activeSkills: Array.isArray(data.activeSkills) ? data.activeSkills.filter((s) => typeof s === "string") : [],
3672
+ events: Array.isArray(data.events) ? data.events : []
3673
+ };
3674
+ }
3675
+ const INDEX_FILE = "index.jsonl";
3676
+ /**
3677
+ * `FileSystemProvider` 后端:`<root>/<runId>.json` 存全量 trace,
3678
+ * `<root>/index.jsonl` 是 append-only 的摘要旁路索引。
3679
+ *
3680
+ * 索引存在的唯一理由是**读次数**:0.0.1 的实现为了拼一份列表要整读每个 trace
3681
+ * 文件,点一次指标再来一遍。有了索引,`list()` 与 `metrics()` 各只读一个文件。
3682
+ *
3683
+ * 索引与 trace 目录发散(写完 trace 崩在追加前)的检测不引入新的读放大:
3684
+ * 目录列举本来就要做,比较 `*.json` 个数与索引行数即可;不等则整体重建。
3685
+ * @stable
3686
+ */
3687
+ var FsRunTraceStore = class {
3688
+ #root;
3689
+ #fs;
3690
+ #onError;
3691
+ constructor(deps) {
3692
+ this.#root = deps.root.replace(/\/+$/, "");
3693
+ this.#fs = deps.fs;
3694
+ this.#onError = deps.onError ?? ((error, run) => {
3695
+ console.warn(`Failed to persist run trace "${run.id}": ${messageOf(error)}`);
3696
+ });
3697
+ }
3698
+ #path(runId) {
3699
+ return resolveInsideRoot(this.#root, `${runId}.json`);
3700
+ }
3701
+ async put(run) {
3702
+ const trace = {
3703
+ schemaVersion: 1,
3704
+ runId: run.id,
3705
+ sessionId: run.sessionId,
3706
+ startedAt: run.startedAt,
3707
+ ...run.endedAt !== void 0 ? { endedAt: run.endedAt } : {},
3708
+ status: run.status === "completed" || run.status === "cancelled" ? run.status : "failed",
3709
+ activeSkills: run.activeSkillNames,
3710
+ events: run.trace
3711
+ };
3712
+ try {
3713
+ await this.#fs.writeText(this.#path(run.id), JSON.stringify(trace, null, 2));
3714
+ await this.#fs.appendText(`${this.#root}/${INDEX_FILE}`, `${JSON.stringify(summarize(trace))}\n`);
3715
+ } catch (e) {
3716
+ this.#onError(e, run);
3717
+ }
3718
+ }
3719
+ async get(runId) {
3720
+ assertSafePathSegment(runId, "run id");
3721
+ const path = this.#path(runId);
3722
+ let raw;
3723
+ try {
3724
+ raw = await this.#fs.readText(path);
3725
+ } catch (e) {
3726
+ if (e instanceof WebSkillError) throw e;
3727
+ throw new WebSkillError("FS_NOT_FOUND", `Trace not found for run ${JSON.stringify(runId)}: ${path}`);
3728
+ }
3729
+ const trace = parseTraceFile(raw, path);
3730
+ if (!trace) throw new WebSkillError("VALIDATION_FAILED", `Trace file is corrupted or malformed: ${path}`);
3731
+ return trace;
3732
+ }
3733
+ async list(filter = {}) {
3734
+ const { summaries } = await this.#readIndex();
3735
+ const matched = applyFilter(summaries, filter);
3736
+ const offset = Math.max(0, filter.offset ?? 0);
3737
+ const sliced = matched.slice(offset);
3738
+ return filter.limit !== void 0 ? sliced.slice(0, Math.max(0, filter.limit)) : sliced;
3739
+ }
3740
+ async metrics(filter = {}) {
3741
+ const { summaries, skipped } = await this.#readIndex();
3742
+ const runs = applyFilter(summaries, filter);
3743
+ const succeeded = runs.filter((r) => r.status === "completed").length;
3744
+ const failedRuns = runs.filter((r) => r.status === "failed");
3745
+ const durations = runs.map((r) => r.durationMs).filter((ms) => ms !== void 0);
3746
+ return {
3747
+ totalRuns: runs.length,
3748
+ succeeded,
3749
+ failed: failedRuns.length,
3750
+ successRate: runs.length === 0 ? 0 : succeeded / runs.length,
3751
+ avgTurns: runs.length === 0 ? 0 : runs.reduce((sum, r) => sum + r.turnCount, 0) / runs.length,
3752
+ skippedRuns: skipped,
3753
+ avgDurationMs: durations.length === 0 ? 0 : durations.reduce((a, b) => a + b, 0) / durations.length,
3754
+ recentFailures: failedRuns.slice(0, 5)
3755
+ };
3756
+ }
3757
+ /** 健康路径恒为「一次目录列举 + 一次索引读」,与 run 数无关。 */
3758
+ async #readIndex() {
3759
+ if (!await this.#fs.exists(this.#root)) return {
3760
+ summaries: [],
3761
+ skipped: 0
3762
+ };
3763
+ const traceCount = (await this.#fs.list(this.#root)).filter((f) => f.type === "file" && f.path.endsWith(".json")).length;
3764
+ const indexPath = `${this.#root}/${INDEX_FILE}`;
3765
+ let lines = [];
3766
+ if (await this.#fs.exists(indexPath)) lines = (await this.#fs.readText(indexPath)).split("\n").filter((line) => line.trim() !== "");
3767
+ if (lines.length !== traceCount) return {
3768
+ summaries: await this.#rebuildIndex(),
3769
+ skipped: 0
3770
+ };
3771
+ const summaries = [];
3772
+ let skipped = 0;
3773
+ for (const line of lines) try {
3774
+ summaries.push(JSON.parse(line));
3775
+ } catch {
3776
+ skipped += 1;
3777
+ }
3778
+ if (skipped > 0) return {
3779
+ summaries: await this.#rebuildIndex(),
3780
+ skipped
3781
+ };
3782
+ return {
3783
+ summaries: sortByStartedAtDesc(summaries),
3784
+ skipped: 0
3785
+ };
3786
+ }
3787
+ /** 异常路径,允许 O(N):整读全部 trace 文件后用 temp + rename 原子替换索引 */
3788
+ async #rebuildIndex() {
3789
+ const summaries = [];
3790
+ for (const entry of await this.#fs.list(this.#root)) {
3791
+ if (entry.type !== "file" || !entry.path.endsWith(".json")) continue;
3792
+ let trace;
3793
+ try {
3794
+ trace = parseTraceFile(await this.#fs.readText(entry.path), entry.path);
3795
+ } catch {
3796
+ continue;
3797
+ }
3798
+ if (!trace) continue;
3799
+ summaries.push(summarize(trace));
3800
+ }
3801
+ const sorted = sortByStartedAtDesc(summaries);
3802
+ const indexPath = `${this.#root}/${INDEX_FILE}`;
3803
+ const tempPath = `${this.#root}/${INDEX_FILE}.rebuilding`;
3804
+ try {
3805
+ await this.#fs.writeText(tempPath, sorted.map((s) => `${JSON.stringify(s)}\n`).join(""));
3806
+ await this.#fs.rename(tempPath, indexPath);
3807
+ } catch {
3808
+ await this.#fs.remove(tempPath).catch(() => void 0);
3809
+ }
3810
+ return sorted;
3811
+ }
3812
+ };
3813
+ /** ISO 时间戳可按字典序比较 */
3814
+ function sortByStartedAtDesc(summaries) {
3815
+ return [...summaries].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
3816
+ }
3817
+ function applyFilter(summaries, filter) {
3818
+ let runs = summaries;
3819
+ if (filter.status !== void 0 && filter.status !== "") runs = runs.filter((r) => r.status === filter.status);
3820
+ const q = filter.search?.trim().toLowerCase();
3821
+ if (q !== void 0 && q !== "") runs = runs.filter((r) => r.runId.toLowerCase().includes(q) || r.activeSkills.some((s) => s.toLowerCase().includes(q)));
3822
+ return runs;
3823
+ }
3824
+ /**
3825
+ * 从 run 的 trace 推导终态工具调用列表。
3826
+ *
3827
+ * 消费者过去要靠自己遍历 trace 才能拿到这份列表,而遍历 trace 的同一段代码
3828
+ * 又顺手承担了「补发漏掉的 live 事件」的职责——两件事绑在一起,谁也删不掉。
3829
+ * 幂等归 runtime(见 `AgentLoop` 的 per-run 已发集合)之后,
3830
+ * 推导终态列表就是纯函数,独立导出。
3831
+ * @stable
3832
+ */
3833
+ function summarizeToolCalls(run) {
3834
+ const calls = [];
3835
+ for (const event of run.trace) {
3836
+ if (event.type !== "tool.completed" && event.type !== "tool.failed") continue;
3837
+ const name = event.data?.["name"];
3838
+ const callId = event.data?.["callId"];
3839
+ if (typeof name !== "string" || typeof callId !== "string") continue;
3840
+ const args = event.data?.["args"];
3841
+ const durationMs = event.data?.["durationMs"];
3842
+ calls.push({
3843
+ callId,
3844
+ name,
3845
+ status: event.type === "tool.completed" ? "completed" : "failed",
3846
+ ...typeof args === "string" ? { args } : {},
3847
+ ...typeof durationMs === "number" ? { durationMs } : {}
3848
+ });
3849
+ }
3850
+ return calls;
3851
+ }
3852
+ const SESSION_SCHEMA_VERSION = 1;
3853
+ /** `FsSessionStore` 的缺省页长。缺省值属于实现,不属于调用方——否则「下推」只推了一半 */
3854
+ const FS_SESSION_PAGE_SIZE = 50;
3855
+ const toMeta = (record) => ({
3856
+ id: record.id,
3857
+ createdAt: record.createdAt,
3858
+ ...record.title !== void 0 ? { title: record.title } : {},
3859
+ ...record.titleLocked === true ? { titleLocked: true } : {},
3860
+ ...record.archived === true ? { archived: true } : {},
3861
+ messageCount: record.messages.length
3862
+ });
3863
+ const newSessionId = () => `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
3864
+ /**
3865
+ * 从新到旧的一页:无游标取末尾 `limit` 条,有游标取该位置**之前**的 `limit` 条。
3866
+ * 游标编码成「本页起点下标」——对会话文件这种整体重写的存储来说下标是稳定的,
3867
+ * 但它是不透明串,调用方不得自己构造(构造出来的越界值一律按 `VALIDATION_FAILED` 拒绝)。
3868
+ */
3869
+ function takeTailPage(all, options, what) {
3870
+ const limit = options.limit ?? 50;
3871
+ if (!Number.isInteger(limit) || limit <= 0) throw new WebSkillError("VALIDATION_FAILED", `Page limit must be a positive integer, received ${String(limit)}`);
3872
+ let end = all.length;
3873
+ if (options.cursor !== void 0) {
3874
+ end = Number(options.cursor);
3875
+ if (!Number.isInteger(end) || end < 0 || end > all.length) throw new WebSkillError("VALIDATION_FAILED", `Invalid ${what} cursor: ${JSON.stringify(options.cursor)}`);
3876
+ }
3877
+ const start = Math.max(0, end - limit);
3878
+ return {
3879
+ items: all.slice(start, end),
3880
+ ...start > 0 ? { nextCursor: String(start) } : {}
3881
+ };
3882
+ }
3883
+ /**
3884
+ * 解析会话文件。缺 `schemaVersion` 视为 0(0.0.1 时代文件,兼容读);
3885
+ * 高于当前版本拒绝读,避免新版写的字段被旧版静默丢弃。
3886
+ */
3887
+ function parseSessionFile(raw, path) {
3888
+ let parsed;
3889
+ try {
3890
+ parsed = JSON.parse(raw);
3891
+ } catch (e) {
3892
+ throw new WebSkillError("VALIDATION_FAILED", `Session file is not valid JSON: ${path}`, e);
3893
+ }
3894
+ if (typeof parsed !== "object" || parsed === null) throw new WebSkillError("VALIDATION_FAILED", `Session file is not an object: ${path}`);
3895
+ const file = parsed;
3896
+ const schemaVersion = typeof file.schemaVersion === "number" ? file.schemaVersion : 0;
3897
+ if (schemaVersion > 1) throw new WebSkillError("SESSION_INCOMPATIBLE", `Session file at ${path} has schemaVersion ${schemaVersion}, but this build understands at most 1`);
3898
+ if (typeof file.id !== "string" || typeof file.createdAt !== "string") throw new WebSkillError("VALIDATION_FAILED", `Session file is missing "id" or "createdAt": ${path}`);
3899
+ return {
3900
+ schemaVersion,
3901
+ id: file.id,
3902
+ createdAt: file.createdAt,
3903
+ ...typeof file.title === "string" ? { title: file.title } : {},
3904
+ ...file.titleLocked === true ? { titleLocked: true } : {},
3905
+ ...file.archived === true ? { archived: true } : {},
3906
+ messages: Array.isArray(file.messages) ? file.messages : [],
3907
+ messageCount: Array.isArray(file.messages) ? file.messages.length : 0
3908
+ };
3909
+ }
3910
+ /**
3911
+ * `FileSystemProvider` 后端的会话存储:`<root>/<id>.json`。
3912
+ *
3913
+ * 所有变更操作按 id 串到一条 promise 链上:会话文件是整读整写的,
3914
+ * 两个并发 `appendMessages` 若都先读后写,后写的会覆盖先写的那条消息。
3915
+ */
3916
+ var FsSessionStore = class {
3917
+ #root;
3918
+ #fs;
3919
+ /** id → 该 id 上最后一次变更的完成时点,用于串行化读改写 */
3920
+ #writes = /* @__PURE__ */ new Map();
3921
+ constructor(deps) {
3922
+ this.#root = deps.root.replace(/\/+$/, "");
3923
+ this.#fs = deps.fs;
3924
+ }
3925
+ #path(id) {
3926
+ assertSafePathSegment(id, "session id");
3927
+ return resolveInsideRoot(this.#root, `${id}.json`);
3928
+ }
3929
+ /** 把 mutation 排到该 id 的队尾;前一个失败不阻塞后一个 */
3930
+ async #serialize(id, work) {
3931
+ const next = (this.#writes.get(id) ?? Promise.resolve()).catch(() => void 0).then(work);
3932
+ this.#writes.set(id, next);
3933
+ try {
3934
+ return await next;
3935
+ } finally {
3936
+ if (this.#writes.get(id) === next) this.#writes.delete(id);
3937
+ }
3938
+ }
3939
+ async #write(record) {
3940
+ const file = {
3941
+ schemaVersion: 1,
3942
+ id: record.id,
3943
+ createdAt: record.createdAt,
3944
+ ...record.title !== void 0 ? { title: record.title } : {},
3945
+ ...record.titleLocked === true ? { titleLocked: true } : {},
3946
+ ...record.archived === true ? { archived: true } : {},
3947
+ messages: record.messages
3948
+ };
3949
+ await this.#fs.writeText(this.#path(record.id), JSON.stringify(file, null, 2));
3950
+ }
3951
+ async #require(id) {
3952
+ const record = await this.get(id);
3953
+ if (!record) throw new WebSkillError("FS_NOT_FOUND", `Session "${id}" not found under ${this.#root}`);
3954
+ return record;
3955
+ }
3956
+ async list(options = {}) {
3957
+ if (!await this.#fs.exists(this.#root)) return { items: [] };
3958
+ const metas = [];
3959
+ for (const entry of await this.#fs.list(this.#root)) {
3960
+ if (entry.type !== "file" || !entry.path.endsWith(".json")) continue;
3961
+ let record;
3962
+ try {
3963
+ record = parseSessionFile(await this.#fs.readText(entry.path), entry.path);
3964
+ } catch (e) {
3965
+ console.warn(`Skipping unreadable session file "${entry.path}": ${e instanceof Error ? e.message : String(e)}`);
3966
+ continue;
3967
+ }
3968
+ if (record.archived === true && options.includeArchived !== true) continue;
3969
+ metas.push(toMeta(record));
3970
+ }
3971
+ metas.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
3972
+ return takeTailPage(metas, options, "session");
3973
+ }
3974
+ /**
3975
+ * 整文件读后切片。磁盘 I/O 复杂度没有改善(备案 D25),
3976
+ * 但**跨出端口的记录数**已是常量——这正是分页对上层的意义。
3977
+ */
3978
+ async listMessages(id, options = {}) {
3979
+ return takeTailPage((await this.#require(id)).messages, options, "message");
3980
+ }
3981
+ async get(id) {
3982
+ const path = this.#path(id);
3983
+ if (!await this.#fs.exists(path)) return void 0;
3984
+ return parseSessionFile(await this.#fs.readText(path), path);
3985
+ }
3986
+ async create(init = {}) {
3987
+ const record = {
3988
+ schemaVersion: 1,
3989
+ id: init.id ?? newSessionId(),
3990
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3991
+ ...init.title !== void 0 ? { title: init.title } : {},
3992
+ messages: [],
3993
+ messageCount: 0
3994
+ };
3995
+ return this.#serialize(record.id, async () => {
3996
+ await this.#write(record);
3997
+ return toMeta(record);
3998
+ });
3999
+ }
4000
+ async appendMessages(id, messages) {
4001
+ if (messages.length === 0) return;
4002
+ await this.#serialize(id, async () => {
4003
+ const record = await this.#require(id);
4004
+ record.messages.push(...messages);
4005
+ await this.#write(record);
4006
+ });
4007
+ }
4008
+ async replaceMessages(id, messages) {
4009
+ await this.#serialize(id, async () => {
4010
+ const record = await this.#require(id);
4011
+ record.messages = [...messages];
4012
+ await this.#write(record);
4013
+ });
4014
+ }
4015
+ async setTitle(id, title, options = {}) {
4016
+ await this.#serialize(id, async () => {
4017
+ const record = await this.#require(id);
4018
+ record.title = title;
4019
+ if (options.lock === true) record.titleLocked = true;
4020
+ await this.#write(record);
4021
+ });
4022
+ }
4023
+ async setArchived(id, archived) {
4024
+ await this.#serialize(id, async () => {
4025
+ const record = await this.#require(id);
4026
+ record.archived = archived;
4027
+ await this.#write(record);
4028
+ });
4029
+ }
4030
+ async delete(id) {
4031
+ await this.#serialize(id, async () => {
4032
+ const path = this.#path(id);
4033
+ if (await this.#fs.exists(path)) await this.#fs.remove(path);
4034
+ });
4035
+ }
4036
+ };
3335
4037
 
3336
4038
  //#endregion
3337
- export { fromVercelStreamPart as A, schemaToForm as B, bridgeError as C, extractChartSpec as D, createWebSkillApi as E, normalizeErrorCode as F, toVercelToolSpecs as H, normalizeToolContent as I, normalizeToolError as L, mergeCatalogEntries as M, networkPolicyLibSource as N, extractUiSurfaceEvents as O, networkUrlHost as P, parseBridgeRequest as R, WebSkillRuntime as S, createScriptContext as T, validateUiSurface as U, toLlmToolSpec as V, validateUiSurfaceEvent as W, READ_SKILL_FILE_TOOL as _, AnthropicClient as a, SerializingMemoryStore as b, FsArtifactStore as c, FullDisclosureRouter as d, GoogleGenAiClient as f, READ_SKILL_FILE_INPUT_SCHEMA as g, ProgressiveRouter as h, AgentLoop as i, isNetworkAllowed as j, fromVercelResult as k, FsMemoryStore as l, OpenAiCompatibleClient as m, ASK_USER_TOOL as n, CapabilityApproval as o, HookRunner as p, ASK_USER_TOOL_NAME as r, EventBus as s, ASK_USER_INPUT_SCHEMA as t, FsRunSnapshotStore as u, READ_SKILL_FILE_TOOL_NAME as v, buildRenderResult as w, TraceRecorder as x, RUN_SNAPSHOT_SCHEMA_VERSION as y, resolveToolName as z };
4039
+ export { createScriptContext as A, networkUrlHost as B, RUN_TRACE_SCHEMA_VERSION as C, WebSkillRuntime as D, TraceRecorder as E, fromVercelStreamPart as F, resolveToolName as G, normalizeToolContent as H, isNetworkAllowed as I, toLlmToolSpec as J, schemaToForm as K, isUnsupportedRunSnapshot as L, extractChartSpec as M, extractUiSpecEvents as N, bridgeError as O, fromVercelResult as P, mergeCatalogEntries as R, RUN_SNAPSHOT_SCHEMA_VERSION as S, SerializingMemoryStore as T, normalizeToolError as U, normalizeErrorCode as V, parseBridgeRequest as W, validateUiSpecEvent as X, toVercelToolSpecs as Y, validateUiSpecNode as Z, OpenAiCompatibleClient as _, AnthropicClient as a, READ_SKILL_FILE_TOOL as b, FS_SESSION_PAGE_SIZE as c, FsRunSnapshotStore as d, FsRunTraceStore as f, HookRunner as g, GoogleGenAiClient as h, AgentLoop as i, createWebSkillApi as j, buildRenderResult as k, FsArtifactStore as l, FullDisclosureRouter as m, ASK_USER_TOOL as n, CapabilityApproval as o, FsSessionStore as p, summarizeToolCalls as q, ASK_USER_TOOL_NAME as r, EventBus as s, ASK_USER_INPUT_SCHEMA as t, FsMemoryStore as u, ProgressiveRouter as v, SESSION_SCHEMA_VERSION as w, READ_SKILL_FILE_TOOL_NAME as x, READ_SKILL_FILE_INPUT_SCHEMA as y, networkPolicyLibSource as z };