@spotpatch/agent 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,10 +1,33 @@
1
- # @spotpatch/agent
1
+ <h1><a href="https://github.com/huanglvjing/spotpatch"><img src="https://raw.githubusercontent.com/huanglvjing/spotpatch/main/docs/assets/spotpatch-npm-icon.png" alt="SpotPatch" width="48" height="48" align="absmiddle" /></a> <code>@spotpatch/agent</code></h1>
2
2
 
3
- The Node-only SpotPatch Agent engine for provider protocols, bounded tools,
4
- isolated Git worktrees, validation checks, review, Apply, and conflict-safe
5
- Revert.
3
+ ## English
6
4
 
7
- Applications should enable this capability through trusted provider profiles in
8
- [`@spotpatch/vite`](https://www.npmjs.com/package/@spotpatch/vite). This package
9
- does not expose model-controlled shell execution and must never receive browser
10
- environment API keys.
5
+ The Node-only Agent engine used by SpotPatch framework adapters. It owns OpenAI-compatible provider sessions, capability probes, bounded file tools, isolated Git worktrees, validation checks, Diff review, Apply, and conflict-safe Revert.
6
+
7
+ Applications should enable this capability through a framework adapter such as [`@spotpatch/vite`](https://www.npmjs.com/package/@spotpatch/vite). This package is public so the adapter dependency graph can be installed and versioned; it is not a standalone UI integration.
8
+
9
+ Security boundaries:
10
+
11
+ - no model-controlled arbitrary shell;
12
+ - no browser-side API keys;
13
+ - bounded paths, reads, writes, Diff sizes, turns, and tool calls;
14
+ - no implicit stash, reset, commit, push, publish, or deployment;
15
+ - review is the default apply mode.
16
+
17
+ Requires Node.js `>=20.19.0`.
18
+
19
+ ## 简体中文
20
+
21
+ 这是 SpotPatch 框架适配器使用的 Node-only Agent 引擎,负责 OpenAI-compatible Provider 会话、能力探测、有界文件工具、隔离 Git worktree、项目检查、Diff 审阅、Apply 和冲突安全的 Revert。
22
+
23
+ 业务应用应通过 [`@spotpatch/vite`](https://www.npmjs.com/package/@spotpatch/vite) 等框架适配器启用该能力。本包公开发布是为了形成可安装、可版本化的依赖图,不是独立 UI 接入入口。
24
+
25
+ 安全边界包括:不向模型开放任意 Shell、不把 API Key 放入浏览器、限制路径/读写/Diff/轮次/工具调用,并且不隐式执行 stash、reset、commit、push、发包或部署。默认应用模式必须经过审阅。
26
+
27
+ 要求 Node.js `>=20.19.0`。
28
+
29
+ ## Links / 链接
30
+
31
+ - [Repository / 仓库](https://github.com/huanglvjing/spotpatch)
32
+ - [AI execution model / AI 执行模型](https://github.com/huanglvjing/spotpatch/blob/main/docs/%E6%8A%80%E6%9C%AF%E6%96%B9%E6%A1%88/16-AIAgent%E6%89%A7%E8%A1%8C%E4%B8%8E%E5%8F%98%E6%9B%B4%E5%AE%A1%E9%98%85.md)
33
+ - [MIT License / 许可证](https://github.com/huanglvjing/spotpatch/blob/main/LICENSE)
package/dist/index.cjs CHANGED
@@ -69,9 +69,27 @@ function parseJsonRecord(value) {
69
69
  }
70
70
  function parseToolArguments(value) {
71
71
  if (typeof value !== "string") {
72
- throw new import_shared.SpotPatchError(import_shared.ERROR_CODES.PROVIDER_PROTOCOL_UNSUPPORTED);
72
+ throw new import_shared.SpotPatchError(import_shared.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
73
+ }
74
+ let parsed;
75
+ try {
76
+ parsed = JSON.parse(value);
77
+ } catch {
78
+ throw new import_shared.SpotPatchError(import_shared.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
79
+ }
80
+ if (!isRecord(parsed)) {
81
+ throw new import_shared.SpotPatchError(import_shared.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
82
+ }
83
+ return parsed;
84
+ }
85
+ function assertUniqueToolCallIds(calls) {
86
+ const ids = /* @__PURE__ */ new Set();
87
+ for (const call of calls) {
88
+ if (ids.has(call.id)) {
89
+ throw new import_shared.SpotPatchError(import_shared.ERROR_CODES.TOOL_CALL_ID_CONFLICT);
90
+ }
91
+ ids.add(call.id);
73
92
  }
74
- return parseJsonRecord(value);
75
93
  }
76
94
  function requireString(record, field) {
77
95
  const value = record[field];
@@ -81,6 +99,7 @@ function requireString(record, field) {
81
99
  return value;
82
100
  }
83
101
  function validateToolResults(pendingCalls, results) {
102
+ assertUniqueToolCallIds(pendingCalls);
84
103
  if (pendingCalls.length === 0) {
85
104
  if (results !== void 0 && results.length > 0) {
86
105
  throw new import_shared.SpotPatchError(import_shared.ERROR_CODES.INTERNAL_ERROR);
@@ -478,6 +497,7 @@ function parseChatEvents(events) {
478
497
  }
479
498
  const finalText = content.join("");
480
499
  const toolCalls = finalizeToolCalls(calls);
500
+ assertUniqueToolCallIds(toolCalls);
481
501
  if (toolCalls.length === 0 && finalText.trim().length === 0) {
482
502
  throw new import_shared5.SpotPatchError(import_shared5.ERROR_CODES.PROVIDER_PROTOCOL_UNSUPPORTED);
483
503
  }
@@ -574,7 +594,7 @@ function collectFunctionCall(item, calls) {
574
594
  });
575
595
  const existing = calls.get(id);
576
596
  if (existing !== void 0 && (existing.name !== call.name || JSON.stringify(existing.arguments) !== JSON.stringify(call.arguments))) {
577
- throw new import_shared6.SpotPatchError(import_shared6.ERROR_CODES.PROVIDER_PROTOCOL_UNSUPPORTED);
597
+ throw new import_shared6.SpotPatchError(import_shared6.ERROR_CODES.TOOL_CALL_ID_CONFLICT);
578
598
  }
579
599
  calls.set(id, call);
580
600
  }
@@ -874,7 +894,7 @@ function encodeUtf8Text(content, includeByteOrderMark) {
874
894
  }
875
895
  async function writeAgentTextFileIfContentMatches(root, relativePath, expectedContent, nextContent, maximumBytes) {
876
896
  if (nextContent.includes("\0")) {
877
- throw new import_shared9.SpotPatchError(import_shared9.ERROR_CODES.TOOL_INPUT_INVALID);
897
+ throw new import_shared9.SpotPatchError(import_shared9.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
878
898
  }
879
899
  const absolutePath = await resolveExistingAgentPath(root, relativePath);
880
900
  const [metadata, currentBytes] = await Promise.all([
@@ -1066,7 +1086,10 @@ function minimalProcessEnvironment() {
1066
1086
  "LANG",
1067
1087
  "LC_ALL"
1068
1088
  ];
1069
- const environment = { CI: "1", NO_COLOR: "1" };
1089
+ const environment = {
1090
+ CI: "1",
1091
+ NO_COLOR: "1"
1092
+ };
1070
1093
  for (const name of allowedNames) {
1071
1094
  const value = process.env[name];
1072
1095
  if (value !== void 0) {
@@ -1143,7 +1166,7 @@ async function runConfiguredCheck(options) {
1143
1166
  function requireConfiguredCheck(checkId, checks) {
1144
1167
  const check = checks[checkId];
1145
1168
  if (check === void 0) {
1146
- throw new import_shared10.SpotPatchError(import_shared10.ERROR_CODES.TOOL_INPUT_INVALID);
1169
+ throw new import_shared10.SpotPatchError(import_shared10.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
1147
1170
  }
1148
1171
  return check;
1149
1172
  }
@@ -1455,7 +1478,7 @@ var MAX_DISCOVERED_FILES = 2e4;
1455
1478
  var TEXT_SAMPLE_BYTES = 8192;
1456
1479
  function compileGlob(glob) {
1457
1480
  if (glob.length === 0 || glob.length > 256 || glob.includes("\0") || glob.includes("\\") || glob.startsWith("/") || ["[", "]", "{", "}", "(", ")", "!"].some((character) => glob.includes(character)) || glob.split("/").some((segment) => segment === "..")) {
1458
- throw new import_shared14.SpotPatchError(import_shared14.ERROR_CODES.TOOL_INPUT_INVALID);
1481
+ throw new import_shared14.SpotPatchError(import_shared14.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
1459
1482
  }
1460
1483
  let expression = "^";
1461
1484
  for (let index = 0; index < glob.length; index += 1) {
@@ -1694,7 +1717,7 @@ var runCheckSchema = import_zod.z.strictObject({
1694
1717
  checkId: import_zod.z.string().min(1).max(64).regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/u)
1695
1718
  });
1696
1719
  function invalidTool() {
1697
- throw new import_shared15.SpotPatchError(import_shared15.ERROR_CODES.TOOL_INPUT_INVALID);
1720
+ throw new import_shared15.SpotPatchError(import_shared15.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
1698
1721
  }
1699
1722
  function parseArguments(schema, value) {
1700
1723
  const parsed = schema.safeParse(value);
@@ -1751,8 +1774,16 @@ function retryableWriteRejection(reason, guidance) {
1751
1774
  guidance
1752
1775
  });
1753
1776
  }
1777
+ function retryableArgumentsRejection() {
1778
+ return Object.freeze({
1779
+ errorCode: import_shared15.ERROR_CODES.TOOL_ARGUMENTS_INVALID,
1780
+ retryable: true,
1781
+ reason: "ARGUMENTS_DO_NOT_MATCH_CONTRACT",
1782
+ guidance: "No files changed. Retry once with a new tool call ID and only the declared fields and value types."
1783
+ });
1784
+ }
1754
1785
  function createAgentToolExecutor(options) {
1755
- const cache = /* @__PURE__ */ new Map();
1786
+ const cacheByTurn = /* @__PURE__ */ new Map();
1756
1787
  const touchedPaths = /* @__PURE__ */ new Set();
1757
1788
  const executeUncached = async (call, signal) => {
1758
1789
  switch (call.name) {
@@ -1968,20 +1999,32 @@ function createAgentToolExecutor(options) {
1968
1999
  }
1969
2000
  };
1970
2001
  return Object.freeze({
1971
- async execute(call, signal) {
2002
+ async execute(call, scope, signal) {
2003
+ if (!Number.isSafeInteger(scope.turn) || scope.turn < 1) {
2004
+ throw new import_shared15.SpotPatchError(import_shared15.ERROR_CODES.INTERNAL_ERROR);
2005
+ }
2006
+ const turnCache = cacheByTurn.get(scope.turn) ?? /* @__PURE__ */ new Map();
2007
+ cacheByTurn.set(scope.turn, turnCache);
1972
2008
  const signature = `${call.name}\0${JSON.stringify(call.arguments)}`;
1973
- const cached = cache.get(call.id);
2009
+ const cached = turnCache.get(call.id);
1974
2010
  if (cached !== void 0) {
1975
2011
  if (cached.signature !== signature) {
1976
- return invalidTool();
2012
+ throw new import_shared15.SpotPatchError(import_shared15.ERROR_CODES.TOOL_CALL_ID_CONFLICT);
1977
2013
  }
1978
2014
  return cached.result;
1979
2015
  }
1980
- const result = Object.freeze({
1981
- toolCallId: call.id,
1982
- output: await executeUncached(call, signal)
1983
- });
1984
- cache.set(call.id, Object.freeze({ signature, result }));
2016
+ let output;
2017
+ try {
2018
+ output = await executeUncached(call, signal);
2019
+ } catch (error) {
2020
+ if (error instanceof import_shared15.SpotPatchError && error.code === import_shared15.ERROR_CODES.TOOL_ARGUMENTS_INVALID) {
2021
+ output = retryableArgumentsRejection();
2022
+ } else {
2023
+ throw error;
2024
+ }
2025
+ }
2026
+ const result = Object.freeze({ toolCallId: call.id, output });
2027
+ turnCache.set(call.id, Object.freeze({ signature, result }));
1985
2028
  return result;
1986
2029
  },
1987
2030
  touchedPaths() {
@@ -2552,6 +2595,7 @@ Follow these rules exactly:
2552
2595
  - Use apply_patch only when creating or deleting a file, or when the change cannot be expressed as one exact replacement. apply_patch accepts only a raw canonical unified Git diff.
2553
2596
  - Every patch must begin with 'diff --git a/<path> b/<path>', include matching '--- a/<path>' and '+++ b/<path>' headers and valid '@@' hunks. Send only the raw diff: no Markdown fences, prose, shell commands, or '*** Begin Patch' markers.
2554
2597
  - If a write tool returns a retryable PATCH_REJECTED result, no file changed. Follow its guidance, re-read the current file, and retry once with a new tool call ID.
2598
+ - If any tool returns a retryable TOOL_ARGUMENTS_INVALID result, no file changed. Retry once with a new tool call ID using only the declared fields and value types.
2555
2599
  - Never modify credentials, environment files, lockfiles, generated output, Git metadata, or dependencies.
2556
2600
  - Do not claim a check passed unless run_check returned a passed status.
2557
2601
  - Finish with a concise factual summary after all needed tool calls. Do not include secrets or absolute paths.`;
@@ -2568,6 +2612,12 @@ function sliceText(value, maximum) {
2568
2612
  function createBoundedTarget(target, maximumCharacters) {
2569
2613
  const detailBudget = Math.max(192, maximumCharacters - 420);
2570
2614
  const bounded = {
2615
+ ...target.page === void 0 ? {} : {
2616
+ page: Object.freeze({
2617
+ ...target.page,
2618
+ url: (0, import_shared19.sanitizeUrl)(target.page.url, "http://spotpatch.invalid")
2619
+ })
2620
+ },
2571
2621
  source: target.source,
2572
2622
  react: Object.freeze({
2573
2623
  supported: target.react.supported,
@@ -2711,7 +2761,7 @@ function isRetryableToolFailure(result) {
2711
2761
  return false;
2712
2762
  }
2713
2763
  const candidate = output;
2714
- return candidate.errorCode === import_shared20.ERROR_CODES.PATCH_REJECTED && candidate.retryable === true;
2764
+ return candidate.retryable === true && (candidate.errorCode === import_shared20.ERROR_CODES.PATCH_REJECTED || candidate.errorCode === import_shared20.ERROR_CODES.TOOL_ARGUMENTS_INVALID);
2715
2765
  }
2716
2766
  function throwIfCancelled(signal) {
2717
2767
  if (signal.aborted) {
@@ -2787,8 +2837,10 @@ async function executeAgentChange(options) {
2787
2837
  let summary;
2788
2838
  let toolCallCount = 0;
2789
2839
  for (let turn = 0; turn < options.execution.limits.maxTurns; turn += 1) {
2840
+ const turnNumber = turn + 1;
2790
2841
  throwIfCancelled(controller.signal);
2791
2842
  const response = await session.next(pendingResults, controller.signal);
2843
+ assertUniqueToolCallIds(response.toolCalls);
2792
2844
  if (response.toolCalls.length === 0) {
2793
2845
  summary = response.finalText.trim().slice(0, options.execution.limits.maxToolOutputCharacters);
2794
2846
  break;
@@ -2801,16 +2853,22 @@ async function executeAgentChange(options) {
2801
2853
  for (const call of response.toolCalls) {
2802
2854
  options.callbacks?.onTool?.(
2803
2855
  Object.freeze({
2856
+ turn: turnNumber,
2804
2857
  toolCallId: call.id,
2805
2858
  toolName: call.name,
2806
2859
  state: "started"
2807
2860
  })
2808
2861
  );
2809
2862
  try {
2810
- const result2 = await executor.execute(call, controller.signal);
2863
+ const result2 = await executor.execute(
2864
+ call,
2865
+ Object.freeze({ turn: turnNumber }),
2866
+ controller.signal
2867
+ );
2811
2868
  results.push(result2);
2812
2869
  options.callbacks?.onTool?.(
2813
2870
  Object.freeze({
2871
+ turn: turnNumber,
2814
2872
  toolCallId: call.id,
2815
2873
  toolName: call.name,
2816
2874
  state: isRetryableToolFailure(result2) ? "failed" : "succeeded"
@@ -2819,6 +2877,7 @@ async function executeAgentChange(options) {
2819
2877
  } catch (error) {
2820
2878
  options.callbacks?.onTool?.(
2821
2879
  Object.freeze({
2880
+ turn: turnNumber,
2822
2881
  toolCallId: call.id,
2823
2882
  toolName: call.name,
2824
2883
  state: "failed"