@webskill/sdk 0.0.3 → 0.0.5

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.
@@ -259,6 +259,15 @@ function checkSkillRules(input) {
259
259
  severity: "error",
260
260
  message: `Unsupported script file in scripts/ of directory ${dirName}: ${fileName} (only .ts/.js and .schema.json sidecars allowed)`
261
261
  });
262
+ const allowedTools = metadata?.["allowed-tools"];
263
+ if (Array.isArray(allowedTools)) {
264
+ const scriptNames = new Set((input.scriptFileNames ?? []).filter((f) => SCRIPT_EXTENSION_RE.test(f)).map((f) => f.replace(SCRIPT_EXTENSION_RE, "")));
265
+ for (const entry of allowedTools) if (typeof entry === "string" && !scriptNames.has(entry)) issues.push({
266
+ code: "SKILL_UNKNOWN_ALLOWED_TOOL",
267
+ severity: "warning",
268
+ message: `allowed-tools entry ${JSON.stringify(entry)} of directory ${dirName} does not match any script in scripts/`
269
+ });
270
+ }
262
271
  return issues;
263
272
  }
264
273
  /** 由条目列表构建 Catalog,保证按名称排序 */
@@ -908,7 +917,7 @@ const ASK_USER_TOOL = {
908
917
  * 不暴露 fs 本体(沙箱语义,对齐 deferred-items D1)。
909
918
  */
910
919
  function createScriptContext(deps) {
911
- const { fs, artifactStore, skillName, skillRoot, runId, confirm } = deps;
920
+ const { fs, artifactStore, skillName, skillRoot, runId, confirm, onWarning } = deps;
912
921
  return {
913
922
  skillName,
914
923
  runId,
@@ -928,7 +937,8 @@ function createScriptContext(deps) {
928
937
  mimeType: options?.mimeType
929
938
  });
930
939
  },
931
- ...confirm ? { confirm } : {}
940
+ ...confirm ? { confirm } : {},
941
+ ...onWarning ? { onWarning } : {}
932
942
  };
933
943
  }
934
944
  /**
@@ -1310,6 +1320,91 @@ function bridgeError(id, code, message) {
1310
1320
  }
1311
1321
  };
1312
1322
  }
1323
+ /**
1324
+ * 判定 URL 是否被策略放行:
1325
+ * - 'deny-all'(默认)全拒;'allow-all' 全放
1326
+ * - { allow } 条目支持三种写法:
1327
+ * - 精确域名 'api.example.com'(忽略端口与路径)
1328
+ * - 通配 '*.example.com'(含 apex 与任意深度子域)
1329
+ * - 源 'http://localhost:3000'(协议 + host + port 全等)
1330
+ * - URL 解析失败一律拒绝
1331
+ */
1332
+ function isNetworkAllowed(policy, url) {
1333
+ if (policy === "allow-all") return true;
1334
+ if (policy === "deny-all" || !policy || typeof policy !== "object") return false;
1335
+ var allow = policy.allow;
1336
+ if (!Array.isArray(allow)) return false;
1337
+ var parsed;
1338
+ try {
1339
+ parsed = new URL(url);
1340
+ } catch {
1341
+ return false;
1342
+ }
1343
+ var host = parsed.hostname.toLowerCase();
1344
+ for (var i = 0; i < allow.length; i++) {
1345
+ var entry = allow[i];
1346
+ if (typeof entry !== "string" || entry === "") continue;
1347
+ if (entry.indexOf("://") !== -1) {
1348
+ try {
1349
+ if (new URL(entry).origin === parsed.origin) return true;
1350
+ } catch {}
1351
+ continue;
1352
+ }
1353
+ var rule = entry.toLowerCase();
1354
+ if (rule.indexOf("*.") === 0) {
1355
+ var suffix = rule.slice(2);
1356
+ if (host === suffix || host.endsWith("." + suffix)) return true;
1357
+ } else if (host === rule) return true;
1358
+ }
1359
+ return false;
1360
+ }
1361
+ /** 阻断 trace 用的脱敏 host(解析失败返回占位,不记录完整 URL) */
1362
+ function networkUrlHost(url) {
1363
+ try {
1364
+ return new URL(url).hostname;
1365
+ } catch {
1366
+ return "(unparseable-url)";
1367
+ }
1368
+ }
1369
+ /**
1370
+ * Per-capability 强制授权判定(宿主侧,browser/node 执行器共用单一来源)。
1371
+ * 'require-approval' 模式下经注入的 UiBridge 发 authorize 交互;
1372
+ * once-per-run(默认)在 run 内记忆已批准能力,every-call 每次调用都问。
1373
+ */
1374
+ var CapabilityApproval = class {
1375
+ #uiBridge;
1376
+ #scope;
1377
+ /** runId → 已批准能力集合(once-per-run 记忆) */
1378
+ #approved = /* @__PURE__ */ new Map();
1379
+ #seq = 0;
1380
+ constructor(deps = {}) {
1381
+ this.#uiBridge = deps.uiBridge;
1382
+ this.#scope = deps.scope ?? "once-per-run";
1383
+ }
1384
+ async authorize(input) {
1385
+ const { runId, capability, mode } = input;
1386
+ if (mode === false) return "disabled";
1387
+ if (mode !== "require-approval") return "allowed";
1388
+ if (this.#scope === "once-per-run" && this.#approved.get(runId)?.has(capability)) return "allowed";
1389
+ if (!this.#uiBridge) return "denied";
1390
+ if ((await this.#uiBridge.request({
1391
+ type: "authorize",
1392
+ id: `authorize-${runId}-${capability}-${++this.#seq}`,
1393
+ capability,
1394
+ message: input.message,
1395
+ ...input.details !== void 0 ? { details: input.details } : {}
1396
+ })).value !== true) return "denied";
1397
+ if (this.#scope === "once-per-run") {
1398
+ let set = this.#approved.get(runId);
1399
+ if (!set) {
1400
+ set = /* @__PURE__ */ new Set();
1401
+ this.#approved.set(runId, set);
1402
+ }
1403
+ set.add(capability);
1404
+ }
1405
+ return "allowed";
1406
+ }
1407
+ };
1313
1408
  /** 结构化 trace 记录器;时钟与 id 工厂可注入(golden trace 用固定值保证确定性) */
1314
1409
  var TraceRecorder = class {
1315
1410
  #runId;
@@ -1384,34 +1479,35 @@ var AgentLoop = class {
1384
1479
  };
1385
1480
  }
1386
1481
  async run(input) {
1387
- const llm = this.#deps.llm;
1388
- const artifactStore = this.#deps.artifactStore;
1482
+ this.#deps.llm;
1483
+ this.#deps.artifactStore;
1389
1484
  const now = this.#deps.clock?.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1390
1485
  const runId = input.runId ?? `run-${Math.random().toString(36).slice(2, 10)}`;
1391
1486
  const trace = new TraceRecorder(runId, this.#deps.clock);
1392
1487
  const startedAt = now();
1393
1488
  const startMs = Date.parse(startedAt);
1394
- const run = {
1395
- id: runId,
1396
- sessionId: input.sessionId,
1397
- status: "running",
1398
- phase: "route",
1399
- userPrompt: input.userPrompt,
1400
- startedAt,
1401
- activeSkillNames: [],
1402
- artifacts: [],
1403
- trace: []
1404
- };
1405
1489
  const state = {
1406
1490
  runId,
1407
- run,
1491
+ run: {
1492
+ id: runId,
1493
+ sessionId: input.sessionId,
1494
+ status: "running",
1495
+ phase: "route",
1496
+ userPrompt: input.userPrompt,
1497
+ startedAt,
1498
+ activeSkillNames: [],
1499
+ artifacts: [],
1500
+ trace: []
1501
+ },
1408
1502
  trace,
1409
1503
  reader: new SkillReader(this.#deps.fs, this.#deps.skillIndex),
1410
1504
  activated: /* @__PURE__ */ new Set(),
1411
1505
  activatedTools: /* @__PURE__ */ new Map(),
1412
1506
  toolTimeoutMs: this.#config.toolTimeoutMs,
1413
1507
  now,
1414
- interactionSeq: 0
1508
+ interactionSeq: 0,
1509
+ messages: [],
1510
+ turn: 0
1415
1511
  };
1416
1512
  if (this.#deps.hooks && !this.#deps.hooks.onWarning) this.#deps.hooks.onWarning = (message) => trace.record("run.warning", { message });
1417
1513
  const route = this.#deps.catalogFilter ? {
@@ -1431,114 +1527,273 @@ var AgentLoop = class {
1431
1527
  return [];
1432
1528
  }
1433
1529
  }))).flat();
1434
- const messages = [{
1530
+ state.messages = [{
1435
1531
  role: "system",
1436
1532
  content: route.systemPrompt
1437
1533
  }, {
1438
1534
  role: "user",
1439
1535
  content: input.userPrompt
1440
1536
  }];
1441
- const finish = async (status, reason, output, errorCode) => {
1442
- run.status = status;
1443
- run.terminationReason = reason;
1444
- run.endedAt = now();
1445
- run.activeSkillNames = [...state.activated].sort();
1446
- run.artifacts = await artifactStore.listArtifacts(runId);
1447
- if (status === "completed") trace.record("run.completed", { data: { reason } });
1448
- else if (status === "cancelled") trace.record("run.cancelled", {
1449
- message: output,
1450
- data: { reason }
1451
- });
1452
- else trace.record("run.failed", {
1453
- message: output,
1454
- data: {
1455
- reason,
1456
- ...errorCode ? { code: errorCode } : {}
1457
- }
1458
- });
1459
- const bridge = this.#deps.uiBridge;
1460
- if (status === "completed" && bridge?.renderResult && this.#config.renderResult !== "off") try {
1461
- const request = buildRenderResult(run, output);
1462
- await bridge.renderResult(request);
1463
- trace.record("ui.rendered", { data: { blockCount: request.blocks.length } });
1464
- } catch (e) {
1465
- trace.record("run.warning", { message: `renderResult failed: ${messageOf(e)}` });
1466
- }
1537
+ try {
1538
+ return await this.#turnLoop(state, startMs, 1, externalSpecs);
1539
+ } catch (e) {
1540
+ if (e instanceof WebSkillError && e.code === "RUN_FAILED") return this.#finish(state, "failed", "hook-error", messageOf(e), "RUN_FAILED");
1541
+ throw e;
1542
+ }
1543
+ }
1544
+ /** 主循环(run 从第 1 轮、resume 从快照轮次续跑;totalTimeout 以 startedAt 续算) */
1545
+ async #turnLoop(state, startMs, startTurn, externalSpecs) {
1546
+ const finish = (status, reason, output, errorCode) => this.#finish(state, status, reason, output, errorCode);
1547
+ const { llm } = this.#deps;
1548
+ const { trace, messages } = state;
1549
+ for (let turn = startTurn;; turn++) {
1550
+ state.turn = turn;
1551
+ if (turn > this.#config.maxTurns) return finish("failed", "max-turns", `Agent loop exceeded the maximum of ${this.#config.maxTurns} turns`, "RUN_MAX_TURNS_EXCEEDED");
1552
+ if (Date.parse(state.now()) - startMs > this.#config.totalTimeoutMs) return finish("failed", "timeout", `Agent loop exceeded the total timeout of ${this.#config.totalTimeoutMs}ms`, "RUN_TIMEOUT");
1553
+ const toolSpecs = [
1554
+ toLlmToolSpec(READ_SKILL_FILE_TOOL),
1555
+ ...this.#deps.uiBridge ? [toLlmToolSpec(ASK_USER_TOOL)] : [],
1556
+ ...[...state.activatedTools.values()].map(toLlmToolSpec),
1557
+ ...externalSpecs
1558
+ ];
1559
+ trace.record("llm.request", { data: {
1560
+ turn,
1561
+ messageCount: messages.length,
1562
+ toolCount: toolSpecs.length
1563
+ } });
1564
+ let response;
1467
1565
  try {
1468
- await this.#lifecycle(status === "completed" ? "complete" : "fail", state, { reason });
1566
+ response = llm.stream ? await this.#completeStreaming(llm, state, {
1567
+ model: this.#deps.model,
1568
+ messages,
1569
+ tools: toolSpecs,
1570
+ temperature: this.#config.temperature
1571
+ }) : await llm.complete({
1572
+ model: this.#deps.model,
1573
+ messages,
1574
+ tools: toolSpecs,
1575
+ temperature: this.#config.temperature
1576
+ });
1469
1577
  } catch (e) {
1470
- trace.record("run.warning", { message: `Terminal lifecycle hook failed: ${messageOf(e)}` });
1578
+ const code = e instanceof WebSkillError && (e.code === "LLM_UNAVAILABLE" || e.code === "LLM_REQUEST_FAILED") ? e.code : "LLM_REQUEST_FAILED";
1579
+ return finish("failed", "llm-error", messageOf(e), code);
1471
1580
  }
1472
- run.trace = trace.list();
1473
- return {
1474
- output,
1475
- run
1476
- };
1477
- };
1478
- try {
1479
- for (let turn = 1;; turn++) {
1480
- if (turn > this.#config.maxTurns) return await finish("failed", "max-turns", `Agent loop exceeded the maximum of ${this.#config.maxTurns} turns`, "RUN_MAX_TURNS_EXCEEDED");
1481
- if (Date.parse(now()) - startMs > this.#config.totalTimeoutMs) return await finish("failed", "timeout", `Agent loop exceeded the total timeout of ${this.#config.totalTimeoutMs}ms`, "RUN_TIMEOUT");
1482
- const toolSpecs = [
1483
- toLlmToolSpec(READ_SKILL_FILE_TOOL),
1484
- ...this.#deps.uiBridge ? [toLlmToolSpec(ASK_USER_TOOL)] : [],
1485
- ...[...state.activatedTools.values()].map(toLlmToolSpec),
1486
- ...externalSpecs
1487
- ];
1488
- trace.record("llm.request", { data: {
1489
- turn,
1490
- messageCount: messages.length,
1491
- toolCount: toolSpecs.length
1492
- } });
1493
- let response;
1581
+ trace.record("llm.response", { data: {
1582
+ turn,
1583
+ hasToolCalls: Boolean(response.toolCalls?.length),
1584
+ contentLength: response.content?.length ?? 0
1585
+ } });
1586
+ if (!response.toolCalls?.length) return finish("completed", "final-answer", response.content ?? "");
1587
+ await this.#lifecycle("execute", state, { turn });
1588
+ messages.push({
1589
+ role: "assistant",
1590
+ content: response.content ?? "",
1591
+ toolCalls: response.toolCalls
1592
+ });
1593
+ for (const call of response.toolCalls) {
1594
+ let result;
1494
1595
  try {
1495
- response = llm.stream ? await this.#completeStreaming(llm, state, {
1496
- model: this.#deps.model,
1497
- messages,
1498
- tools: toolSpecs,
1499
- temperature: this.#config.temperature
1500
- }) : await llm.complete({
1501
- model: this.#deps.model,
1502
- messages,
1503
- tools: toolSpecs,
1504
- temperature: this.#config.temperature
1505
- });
1596
+ result = await this.#executeCall(call, state);
1506
1597
  } catch (e) {
1507
- const code = e instanceof WebSkillError && (e.code === "LLM_UNAVAILABLE" || e.code === "LLM_REQUEST_FAILED") ? e.code : "LLM_REQUEST_FAILED";
1508
- return await finish("failed", "llm-error", messageOf(e), code);
1598
+ if (e instanceof RunTerminated) return finish(e.outcome.status, e.outcome.reason, e.outcome.message, e.outcome.code);
1599
+ throw e;
1509
1600
  }
1510
- trace.record("llm.response", { data: {
1511
- turn,
1512
- hasToolCalls: Boolean(response.toolCalls?.length),
1513
- contentLength: response.content?.length ?? 0
1514
- } });
1515
- if (!response.toolCalls?.length) return await finish("completed", "final-answer", response.content ?? "");
1516
- await this.#lifecycle("execute", state, { turn });
1517
1601
  messages.push({
1518
- role: "assistant",
1519
- content: response.content ?? "",
1520
- toolCalls: response.toolCalls
1602
+ role: "tool",
1603
+ toolCallId: call.id,
1604
+ content: JSON.stringify(result)
1521
1605
  });
1522
- for (const call of response.toolCalls) {
1523
- let result;
1524
- try {
1525
- result = await this.#executeCall(call, state);
1526
- } catch (e) {
1527
- if (e instanceof RunTerminated) return await finish(e.outcome.status, e.outcome.reason, e.outcome.message, e.outcome.code);
1528
- throw e;
1529
- }
1530
- messages.push({
1531
- role: "tool",
1532
- toolCallId: call.id,
1533
- content: JSON.stringify(result)
1534
- });
1535
- }
1536
1606
  }
1607
+ }
1608
+ }
1609
+ /** 统一终态处理:completed/failed/cancelled;终态即删快照(快照是续命机制,审计归治理) */
1610
+ async #finish(state, status, reason, output, errorCode) {
1611
+ const { run, trace } = state;
1612
+ run.status = status;
1613
+ run.terminationReason = reason;
1614
+ run.endedAt = state.now();
1615
+ run.activeSkillNames = [...state.activated].sort();
1616
+ run.artifacts = await this.#deps.artifactStore.listArtifacts(state.runId);
1617
+ if (status === "completed") trace.record("run.completed", { data: { reason } });
1618
+ else if (status === "cancelled") trace.record("run.cancelled", {
1619
+ message: output,
1620
+ data: { reason }
1621
+ });
1622
+ else trace.record("run.failed", {
1623
+ message: output,
1624
+ data: {
1625
+ reason,
1626
+ ...errorCode ? { code: errorCode } : {}
1627
+ }
1628
+ });
1629
+ const bridge = this.#deps.uiBridge;
1630
+ if (status === "completed" && bridge?.renderResult && this.#config.renderResult !== "off") try {
1631
+ const request = buildRenderResult(run, output);
1632
+ await bridge.renderResult(request);
1633
+ trace.record("ui.rendered", { data: { blockCount: request.blocks.length } });
1537
1634
  } catch (e) {
1538
- if (e instanceof WebSkillError && e.code === "RUN_FAILED") return await finish("failed", "hook-error", messageOf(e), "RUN_FAILED");
1635
+ trace.record("run.warning", { message: `renderResult failed: ${messageOf(e)}` });
1636
+ }
1637
+ if (this.#deps.snapshotStore) try {
1638
+ await this.#deps.snapshotStore.delete(state.runId);
1639
+ } catch (e) {
1640
+ trace.record("run.warning", { message: `Failed to delete run snapshot: ${messageOf(e)}` });
1641
+ }
1642
+ try {
1643
+ await this.#lifecycle(status === "completed" ? "complete" : "fail", state, { reason });
1644
+ } catch (e) {
1645
+ trace.record("run.warning", { message: `Terminal lifecycle hook failed: ${messageOf(e)}` });
1646
+ }
1647
+ run.trace = trace.list();
1648
+ return {
1649
+ output,
1650
+ run
1651
+ };
1652
+ }
1653
+ /** D3 快照写入(含 pendingInteraction 与过期时间;写失败降级 run.warning) */
1654
+ async #saveSnapshot(state, request) {
1655
+ const store = this.#deps.snapshotStore;
1656
+ if (!store) return;
1657
+ const snapshot = {
1658
+ schemaVersion: 1,
1659
+ runId: state.runId,
1660
+ sessionId: state.run.sessionId,
1661
+ userPrompt: state.run.userPrompt,
1662
+ startedAt: state.run.startedAt,
1663
+ messages: state.messages.map((m) => ({ ...m })),
1664
+ turn: state.turn,
1665
+ activeSkillNames: [...state.activated].sort(),
1666
+ activatedTools: [...state.activatedTools.values()],
1667
+ pendingInteraction: request,
1668
+ interactionExpiresAt: state.run.interruptExpiresAt,
1669
+ config: {
1670
+ maxTurns: this.#config.maxTurns,
1671
+ totalTimeoutMs: this.#config.totalTimeoutMs,
1672
+ toolTimeoutMs: this.#config.toolTimeoutMs,
1673
+ ...this.#config.temperature !== void 0 ? { temperature: this.#config.temperature } : {}
1674
+ },
1675
+ snapshotAt: state.now()
1676
+ };
1677
+ try {
1678
+ await store.save(snapshot);
1679
+ } catch (e) {
1680
+ state.trace.record("run.warning", { message: `Failed to save run snapshot: ${messageOf(e)}` });
1681
+ }
1682
+ }
1683
+ /**
1684
+ * D3 恢复:以快照重建 LoopState,重新发起等待中的交互(表单重新渲染),
1685
+ * 应答后从快照轮次续跑主循环;totalTimeout 以快照 startedAt 续算。
1686
+ */
1687
+ async resume(snapshot) {
1688
+ const now = this.#deps.clock?.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
1689
+ const runId = snapshot.runId;
1690
+ const trace = new TraceRecorder(runId, this.#deps.clock);
1691
+ const startMs = Date.parse(snapshot.startedAt);
1692
+ const state = {
1693
+ runId,
1694
+ run: {
1695
+ id: runId,
1696
+ sessionId: snapshot.sessionId,
1697
+ status: "running",
1698
+ phase: "execute",
1699
+ userPrompt: snapshot.userPrompt,
1700
+ startedAt: snapshot.startedAt,
1701
+ activeSkillNames: snapshot.activeSkillNames,
1702
+ artifacts: [],
1703
+ trace: []
1704
+ },
1705
+ trace,
1706
+ reader: new SkillReader(this.#deps.fs, this.#deps.skillIndex),
1707
+ activated: new Set(snapshot.activeSkillNames),
1708
+ activatedTools: new Map(snapshot.activatedTools.map((d) => [d.name, d])),
1709
+ toolTimeoutMs: snapshot.config.toolTimeoutMs,
1710
+ now,
1711
+ interactionSeq: 0,
1712
+ messages: snapshot.messages.map((m) => ({ ...m })),
1713
+ turn: snapshot.turn
1714
+ };
1715
+ if (this.#deps.hooks && !this.#deps.hooks.onWarning) this.#deps.hooks.onWarning = (message) => trace.record("run.warning", { message });
1716
+ trace.record("run.resumed", { data: {
1717
+ snapshotAt: snapshot.snapshotAt,
1718
+ turn: snapshot.turn,
1719
+ interactionType: snapshot.pendingInteraction.type
1720
+ } });
1721
+ const pending = snapshot.pendingInteraction;
1722
+ const pendingCall = this.#findPendingToolCall(state.messages);
1723
+ try {
1724
+ if ((pending.type === "form" || pending.type === "select") && pendingCall) {
1725
+ const value = await this.#interact(state, pending, {
1726
+ tool: pendingCall.name,
1727
+ resumed: true
1728
+ });
1729
+ const args = {
1730
+ ...pendingCall.arguments,
1731
+ ...typeof value === "object" && value !== null ? value : {}
1732
+ };
1733
+ const result = await this.#executeCall({
1734
+ ...pendingCall,
1735
+ arguments: args
1736
+ }, state);
1737
+ state.messages.push({
1738
+ role: "tool",
1739
+ toolCallId: pendingCall.id,
1740
+ content: JSON.stringify(result)
1741
+ });
1742
+ } else if (pending.type === "ask" && pendingCall) {
1743
+ const value = await this.#interact(state, pending, {
1744
+ tool: pendingCall.name,
1745
+ resumed: true
1746
+ });
1747
+ const result = {
1748
+ ok: true,
1749
+ content: [{
1750
+ type: "text",
1751
+ text: typeof value === "string" ? value : JSON.stringify(value ?? "")
1752
+ }]
1753
+ };
1754
+ state.trace.record("tool.completed", { data: {
1755
+ name: pendingCall.name,
1756
+ callId: pendingCall.id
1757
+ } });
1758
+ state.messages.push({
1759
+ role: "tool",
1760
+ toolCallId: pendingCall.id,
1761
+ content: JSON.stringify(result)
1762
+ });
1763
+ } else if (pendingCall) {
1764
+ const result = await this.#executeCall(pendingCall, state);
1765
+ state.messages.push({
1766
+ role: "tool",
1767
+ toolCallId: pendingCall.id,
1768
+ content: JSON.stringify(result)
1769
+ });
1770
+ }
1771
+ } catch (e) {
1772
+ if (e instanceof RunTerminated) return this.#finish(state, e.outcome.status, e.outcome.reason, e.outcome.message, e.outcome.code);
1773
+ throw e;
1774
+ }
1775
+ const externalSpecs = (await Promise.all((this.#deps.externalTools ?? []).map(async (source) => {
1776
+ try {
1777
+ return await source.listToolSpecs();
1778
+ } catch (e) {
1779
+ trace.record("run.warning", { message: `External tool source "${source.kind}" failed to list specs: ${messageOf(e)}` });
1780
+ return [];
1781
+ }
1782
+ }))).flat();
1783
+ try {
1784
+ return await this.#turnLoop(state, startMs, snapshot.turn + 1, externalSpecs);
1785
+ } catch (e) {
1786
+ if (e instanceof WebSkillError && e.code === "RUN_FAILED") return this.#finish(state, "failed", "hook-error", messageOf(e), "RUN_FAILED");
1539
1787
  throw e;
1540
1788
  }
1541
1789
  }
1790
+ /** 最后一条 assistant 消息里第一个尚无 tool 响应的工具调用(即中断时正在处理的那个) */
1791
+ #findPendingToolCall(messages) {
1792
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant" && m.toolCalls?.length);
1793
+ if (!lastAssistant?.toolCalls) return void 0;
1794
+ const answered = new Set(messages.filter((m) => m.role === "tool").map((m) => m.toolCallId));
1795
+ return lastAssistant.toolCalls.find((call) => !answered.has(call.id));
1796
+ }
1542
1797
  /**
1543
1798
  * 流式 LLM 调用:text-delta 仅在 EventBus 有订阅者时发射 llm.delta 事件,
1544
1799
  * 并同步转发 uiBridge.onTextDelta;tool-calls/done 组装为 LlmResponse 走原有分支。
@@ -1598,6 +1853,7 @@ var AgentLoop = class {
1598
1853
  const { run } = state;
1599
1854
  run.status = "interrupted";
1600
1855
  run.interruptExpiresAt = new Date(Date.parse(state.now()) + this.#policy.interactionTimeoutMs).toISOString();
1856
+ await this.#saveSnapshot(state, request);
1601
1857
  await this.#lifecycle("interact", state, {
1602
1858
  interactionId: request.id,
1603
1859
  type: request.type
@@ -1777,6 +2033,13 @@ var AgentLoop = class {
1777
2033
  if (!root) return "";
1778
2034
  const executor = this.#deps.executor;
1779
2035
  if (!executor) return "";
2036
+ let allowedTools;
2037
+ try {
2038
+ const { metadata } = parseSkillMarkdown(await this.#deps.fs.readText(`${root}/SKILL.md`));
2039
+ const raw = metadata["allowed-tools"];
2040
+ if (raw !== void 0) if (Array.isArray(raw)) allowedTools = raw.filter((e) => typeof e === "string");
2041
+ else state.trace.record("run.warning", { message: `Skill "${skillName}" has a non-array "allowed-tools" metadata entry; ignored` });
2042
+ } catch {}
1780
2043
  const loaded = [];
1781
2044
  let scriptFiles = [];
1782
2045
  try {
@@ -1787,6 +2050,7 @@ var AgentLoop = class {
1787
2050
  for (const file of scriptFiles) {
1788
2051
  const match = /^(.*)\.(ts|js)$/.exec(file);
1789
2052
  if (!match?.[1]) continue;
2053
+ if (allowedTools && !allowedTools.includes(match[1])) continue;
1790
2054
  try {
1791
2055
  const def = await executor.loadDefinition(root, match[1]);
1792
2056
  await this.#enrichDefinition(root, match[1], def, state);
@@ -1821,10 +2085,12 @@ var AgentLoop = class {
1821
2085
  async #handleScriptTool(call, skillName, scriptName, state) {
1822
2086
  const root = this.#deps.skillIndex.get(skillName);
1823
2087
  if (!root) return toolError("TOOL_NOT_FOUND", `Skill "${skillName}" is not in the catalog`);
1824
- let args = call.arguments;
2088
+ if (!this.#deps.executor) return toolError("TOOL_UNSUPPORTED", `No script executor is configured; script tool "${call.name}" cannot run`);
1825
2089
  const def = state.activatedTools.get(call.name);
1826
- const missing = (def?.inputSchema?.required ?? []).filter((key) => args[key] === void 0);
1827
- if (missing.length > 0 && this.#deps.uiBridge && this.#policy.missingParams === "user" && def?.inputSchema) try {
2090
+ if (!def) return toolError("TOOL_NOT_FOUND", `Tool "${call.name}" is not available for skill "${skillName}"`);
2091
+ let args = call.arguments;
2092
+ const missing = (def.inputSchema?.required ?? []).filter((key) => args[key] === void 0);
2093
+ if (missing.length > 0 && this.#deps.uiBridge && this.#policy.missingParams === "user" && def.inputSchema) try {
1828
2094
  const value = await this.#interact(state, {
1829
2095
  type: "form",
1830
2096
  id: this.#nextInteractionId(state),
@@ -1842,14 +2108,14 @@ var AgentLoop = class {
1842
2108
  if (e instanceof BridgeRequestError) return toolError("UI_UNAVAILABLE", `Parameter form failed: ${e.message}`);
1843
2109
  throw e;
1844
2110
  }
1845
- if (!this.#deps.executor) return toolError("TOOL_UNSUPPORTED", `No script executor is configured; script tool "${call.name}" cannot run`);
1846
2111
  const context = createScriptContext({
1847
2112
  fs: this.#deps.fs,
1848
2113
  artifactStore: this.#deps.artifactStore,
1849
2114
  skillName,
1850
2115
  skillRoot: root,
1851
2116
  runId: state.runId,
1852
- confirm: (message) => this.#confirm(message, state)
2117
+ confirm: (message) => this.#confirm(message, state),
2118
+ onWarning: (message) => state.trace.record("run.warning", { message })
1853
2119
  });
1854
2120
  try {
1855
2121
  const before = new Set((await this.#deps.artifactStore.listArtifacts(state.runId)).map((a) => a.id));
@@ -1954,6 +2220,58 @@ function mergeCatalogEntries(localEntries, providerEntries) {
1954
2220
  for (const entry of providerEntries) if (!byName.has(entry.name)) byName.set(entry.name, entry);
1955
2221
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
1956
2222
  }
2223
+ const RUN_SNAPSHOT_SCHEMA_VERSION = 1;
2224
+ const SNAPSHOT_SUFFIX = ".snapshot.json";
2225
+ /**
2226
+ * FileSystemProvider 后端的快照存储:<root>/<runId>.snapshot.json。
2227
+ * 坏 JSON → RUN_SNAPSHOT_INCOMPATIBLE 并自动清理坏文件;runId 过路径安全校验。
2228
+ */
2229
+ var FsRunSnapshotStore = class {
2230
+ #root;
2231
+ #fs;
2232
+ constructor(deps) {
2233
+ this.#root = deps.root.replace(/\/+$/, "");
2234
+ this.#fs = deps.fs;
2235
+ }
2236
+ #path(runId) {
2237
+ return resolveInsideRoot(this.#root, `${runId}${SNAPSHOT_SUFFIX}`);
2238
+ }
2239
+ async save(snapshot) {
2240
+ await this.#fs.writeText(this.#path(snapshot.runId), JSON.stringify(snapshot, null, 2));
2241
+ }
2242
+ async load(runId) {
2243
+ const path = this.#path(runId);
2244
+ if (!await this.#fs.exists(path)) return void 0;
2245
+ let parsed;
2246
+ try {
2247
+ parsed = JSON.parse(await this.#fs.readText(path));
2248
+ } catch (e) {
2249
+ await this.#fs.remove(path).catch(() => void 0);
2250
+ throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" is corrupted and was deleted: ${e instanceof Error ? e.message : String(e)}`, e);
2251
+ }
2252
+ const snapshot = parsed;
2253
+ if (typeof snapshot !== "object" || snapshot === null || snapshot.runId !== runId) {
2254
+ await this.#fs.remove(path).catch(() => void 0);
2255
+ throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" has an unexpected shape and was deleted`);
2256
+ }
2257
+ return snapshot;
2258
+ }
2259
+ async delete(runId) {
2260
+ const path = this.#path(runId);
2261
+ if (await this.#fs.exists(path)) await this.#fs.remove(path);
2262
+ }
2263
+ async list() {
2264
+ if (!await this.#fs.exists(this.#root)) return [];
2265
+ const out = [];
2266
+ for (const entry of await this.#fs.list(this.#root)) {
2267
+ if (entry.type !== "file" || !entry.path.endsWith(SNAPSHOT_SUFFIX)) continue;
2268
+ try {
2269
+ out.push(JSON.parse(await this.#fs.readText(entry.path)));
2270
+ } catch {}
2271
+ }
2272
+ return out.sort((a, b) => a.snapshotAt.localeCompare(b.snapshotAt));
2273
+ }
2274
+ };
1957
2275
  /** runtime 门面:组合 discovery / router / agent loop / lifecycle */
1958
2276
  var WebSkillRuntime = class {
1959
2277
  #deps;
@@ -2017,7 +2335,8 @@ var WebSkillRuntime = class {
2017
2335
  longTerm: this.#deps.longTerm,
2018
2336
  externalTools: this.#deps.externalTools,
2019
2337
  skillProviders: this.#deps.skillProviders,
2020
- catalogFilter: this.#deps.catalogFilter
2338
+ catalogFilter: this.#deps.catalogFilter,
2339
+ snapshotStore: this.#deps.snapshotStore
2021
2340
  }, this.#deps.config).run({
2022
2341
  sessionId: this.#session.id,
2023
2342
  userPrompt,
@@ -2037,7 +2356,78 @@ var WebSkillRuntime = class {
2037
2356
  });
2038
2357
  return result;
2039
2358
  }
2359
+ /** D3:列出可恢复的 interrupted run(供 UI 展示"未完成任务") */
2360
+ async listInterruptedRuns() {
2361
+ if (!this.#deps.snapshotStore) return [];
2362
+ return this.#deps.snapshotStore.list();
2363
+ }
2364
+ /**
2365
+ * D3 恢复 interrupted run:
2366
+ * 不存在 → RUN_SNAPSHOT_NOT_FOUND;schemaVersion 不匹配 → 删除 + RUN_SNAPSHOT_INCOMPATIBLE;
2367
+ * 已过期 → interaction-timeout 终态并删快照;否则重建 LoopState 重新发起交互续跑。
2368
+ */
2369
+ async resumeRun(runId) {
2370
+ const store = this.#deps.snapshotStore;
2371
+ const snapshot = store ? await store.load(runId) : void 0;
2372
+ if (!snapshot) throw new WebSkillError("RUN_SNAPSHOT_NOT_FOUND", `No snapshot found for run "${runId}"`);
2373
+ if (snapshot.schemaVersion !== 1) {
2374
+ await store.delete(runId);
2375
+ throw new WebSkillError("RUN_SNAPSHOT_INCOMPATIBLE", `Snapshot for run "${runId}" has incompatible schemaVersion ${String(snapshot.schemaVersion)}`);
2376
+ }
2377
+ if (Date.now() > Date.parse(snapshot.interactionExpiresAt)) {
2378
+ await store.delete(runId);
2379
+ const endedAt = (/* @__PURE__ */ new Date()).toISOString();
2380
+ return {
2381
+ output: "Interaction timed out before the run was resumed",
2382
+ run: {
2383
+ id: runId,
2384
+ sessionId: snapshot.sessionId,
2385
+ status: "failed",
2386
+ phase: "fail",
2387
+ userPrompt: snapshot.userPrompt,
2388
+ startedAt: snapshot.startedAt,
2389
+ endedAt,
2390
+ terminationReason: "interaction-timeout",
2391
+ activeSkillNames: snapshot.activeSkillNames,
2392
+ artifacts: [],
2393
+ trace: [{
2394
+ id: `evt-expired-${runId}`,
2395
+ runId,
2396
+ ts: endedAt,
2397
+ type: "run.failed",
2398
+ message: `Interaction timed out before the run was resumed (expired at ${snapshot.interactionExpiresAt})`,
2399
+ data: {
2400
+ reason: "interaction-timeout",
2401
+ code: "RUN_INTERACTION_TIMEOUT"
2402
+ }
2403
+ }]
2404
+ }
2405
+ };
2406
+ }
2407
+ if (!this.#catalogCache) await this.discover();
2408
+ const cache = this.#catalogCache;
2409
+ if (!cache) throw new WebSkillError("RUN_FAILED", "discover() did not populate the catalog cache");
2410
+ return new AgentLoop({
2411
+ llm: this.#deps.llm,
2412
+ executor: this.#deps.executor,
2413
+ schemaInferer: this.#deps.schemaInferer,
2414
+ artifactStore: this.#deps.artifactStore,
2415
+ fs: this.#deps.fs,
2416
+ skillIndex: cache.index,
2417
+ model: this.#deps.model,
2418
+ uiBridge: this.#deps.uiBridge,
2419
+ interaction: this.#deps.interaction,
2420
+ memory: this.#deps.memory,
2421
+ hooks: this.#deps.hooks,
2422
+ eventBus: this.#events,
2423
+ longTerm: this.#deps.longTerm,
2424
+ externalTools: this.#deps.externalTools,
2425
+ skillProviders: this.#deps.skillProviders,
2426
+ catalogFilter: this.#deps.catalogFilter,
2427
+ snapshotStore: store
2428
+ }, this.#deps.config).resume(snapshot);
2429
+ }
2040
2430
  };
2041
2431
 
2042
2432
  //#endregion
2043
- export { validateSkills as $, schemaToForm as A, WebSkillError as B, createScriptContext as C, normalizeToolContent as D, mergeCatalogEntries as E, SKILL_MANIFEST_FILE as F, escapeXml as G, buildManifest as H, SKILL_NAME_MAX_LENGTH as I, normalizePath as J, isValidSkillName as K, SKILL_NAME_PATTERN as L, toVercelToolSpecs as M, MemoryFS as N, parseBridgeRequest as O, SKILLS_LOCKFILE as P, resolveInsideRoot as Q, SkillDiscovery as R, buildRenderResult as S, fromVercelStreamPart as T, checkSkillRules as U, buildCatalog as V, computeDigest as W, renderAvailableSkillsXml as X, parseSkillMarkdown as Y, renderCatalogJson as Z, READ_SKILL_FILE_TOOL as _, EventBus as a, WebSkillRuntime as b, FullDisclosureRouter as c, MemoryArtifactStore as d, verifyManifest as et, MockLlmClient as f, READ_SKILL_FILE_INPUT_SCHEMA as g, ProgressiveRouter as h, AgentLoop as i, toLlmToolSpec as j, resolveToolName as k, HookRunner as l, OpenAiCompatibleClient as m, ASK_USER_TOOL as n, FsArtifactStore as o, MockUiBridge as p, jsonRenderer as q, ASK_USER_TOOL_NAME as r, FsMemoryStore as s, ASK_USER_INPUT_SCHEMA as t, xmlRenderer as tt, InMemoryStore as u, READ_SKILL_FILE_TOOL_NAME as v, fromVercelResult as w, bridgeError as x, TraceRecorder as y, SkillReader as z };
2433
+ export { normalizePath as $, mergeCatalogEntries as A, SKILL_MANIFEST_FILE as B, WebSkillRuntime as C, fromVercelResult as D, createScriptContext as E, schemaToForm as F, WebSkillError as G, SKILL_NAME_PATTERN as H, toLlmToolSpec as I, checkSkillRules as J, buildCatalog as K, toVercelToolSpecs as L, normalizeToolContent as M, parseBridgeRequest as N, fromVercelStreamPart as O, resolveToolName as P, jsonRenderer as Q, MemoryFS as R, TraceRecorder as S, buildRenderResult as T, SkillDiscovery as U, SKILL_NAME_MAX_LENGTH as V, SkillReader as W, escapeXml as X, computeDigest as Y, isValidSkillName as Z, ProgressiveRouter as _, CapabilityApproval as a, verifyManifest as at, READ_SKILL_FILE_TOOL_NAME as b, FsMemoryStore as c, HookRunner as d, parseSkillMarkdown as et, InMemoryStore as f, OpenAiCompatibleClient as g, MockUiBridge as h, AgentLoop as i, validateSkills as it, networkUrlHost as j, isNetworkAllowed as k, FsRunSnapshotStore as l, MockLlmClient as m, ASK_USER_TOOL as n, renderCatalogJson as nt, EventBus as o, xmlRenderer as ot, MemoryArtifactStore as p, buildManifest as q, ASK_USER_TOOL_NAME as r, resolveInsideRoot as rt, FsArtifactStore as s, ASK_USER_INPUT_SCHEMA as t, renderAvailableSkillsXml as tt, FullDisclosureRouter as u, READ_SKILL_FILE_INPUT_SCHEMA as v, bridgeError as w, RUN_SNAPSHOT_SCHEMA_VERSION as x, READ_SKILL_FILE_TOOL as y, SKILLS_LOCKFILE as z };