@evanovation/open-cursor 2.4.15

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 (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. package/src/utils/perf.ts +76 -0
@@ -0,0 +1,644 @@
1
+ import type { OpenAiToolCall } from "../proxy/tool-loop.js";
2
+
3
+ type ToolLoopErrorClass =
4
+ | "validation"
5
+ | "not_found"
6
+ | "permission"
7
+ | "timeout"
8
+ | "tool_error"
9
+ | "success"
10
+ | "unknown";
11
+
12
+ const UNKNOWN_AS_SUCCESS_TOOLS = new Set([
13
+ // Core filesystem tools
14
+ "bash",
15
+ "shell",
16
+ "read",
17
+ "write",
18
+ "edit",
19
+ "grep",
20
+ "ls",
21
+ "glob",
22
+ "stat",
23
+ "mkdir",
24
+ "rm",
25
+ // Web/network tools
26
+ "webfetch",
27
+ // cursor-agent specific tools (passthrough, but should not trigger loop guard)
28
+ // Discovered via tests/experiments/ harness - see docs/cursor-agent-tools.md
29
+ "semsearch", // semantic code search
30
+ "readlints", // lint/diagnostic reader
31
+ ]);
32
+
33
+ // Exploratory tools that commonly iterate over many files/patterns.
34
+ // These are exempt from COARSE fingerprint tracking (tool|errorClass) to allow
35
+ // legitimate multi-file exploration. Strict fingerprints (tool|args|errorClass)
36
+ // still apply to catch identical repeated failures.
37
+ const EXPLORATION_TOOLS = new Set([
38
+ "read",
39
+ "grep",
40
+ "glob",
41
+ "ls",
42
+ "stat",
43
+ "semsearch",
44
+ "bash",
45
+ "shell",
46
+ "webfetch",
47
+ "task",
48
+ ]);
49
+
50
+ export interface ToolLoopGuardDecision {
51
+ fingerprint: string;
52
+ repeatCount: number;
53
+ maxRepeat: number;
54
+ errorClass: ToolLoopErrorClass;
55
+ triggered: boolean;
56
+ tracked: boolean;
57
+ }
58
+
59
+ export interface ToolLoopGuard {
60
+ evaluate(toolCall: OpenAiToolCall): ToolLoopGuardDecision;
61
+ evaluateValidation(toolCall: OpenAiToolCall, validationSignature: string): ToolLoopGuardDecision;
62
+ resetFingerprint(fingerprint: string): void;
63
+ }
64
+
65
+ export function parseToolLoopMaxRepeat(
66
+ value: string | undefined,
67
+ ): { value: number; valid: boolean } {
68
+ if (value === undefined) {
69
+ return { value: 2, valid: true };
70
+ }
71
+ const parsed = Number(value);
72
+ if (!Number.isFinite(parsed) || parsed < 1) {
73
+ return { value: 2, valid: false };
74
+ }
75
+ return { value: Math.floor(parsed), valid: true };
76
+ }
77
+
78
+ // Coarse fingerprint (tool|errorClass without args) uses a higher multiplier
79
+ // to allow legitimate exploration across different files/targets while still
80
+ // catching spray-and-pray patterns.
81
+ const COARSE_LIMIT_MULTIPLIER = 3;
82
+ const EXPLORATION_LIMIT_MULTIPLIER = 5;
83
+
84
+ export function createToolLoopGuard(
85
+ messages: Array<unknown>,
86
+ maxRepeat: number,
87
+ ): ToolLoopGuard {
88
+ const coarseMaxRepeat = maxRepeat * COARSE_LIMIT_MULTIPLIER;
89
+ const {
90
+ byCallId,
91
+ latest,
92
+ latestByToolName,
93
+ initialCounts,
94
+ initialCoarseCounts,
95
+ initialValidationCounts,
96
+ initialValidationCoarseCounts,
97
+ } = indexToolLoopHistory(messages);
98
+ const counts = new Map<string, number>(initialCounts);
99
+ const coarseCounts = new Map<string, number>(initialCoarseCounts);
100
+ const validationCounts = new Map<string, number>(initialValidationCounts);
101
+ const validationCoarseCounts = new Map<string, number>(initialValidationCoarseCounts);
102
+
103
+ return {
104
+ evaluate(toolCall) {
105
+ const errorClass = normalizeErrorClassForTool(
106
+ toolCall.function.name,
107
+ byCallId.get(toolCall.id)
108
+ ?? latestByToolName.get(toolCall.function.name)
109
+ ?? latest
110
+ ?? "unknown",
111
+ );
112
+ const argShape = deriveArgumentShape(toolCall.function.arguments);
113
+ if (errorClass === "success") {
114
+ // For success paths, only track identical value payloads to avoid blocking
115
+ // legitimate repeated tool usage with different arguments.
116
+ const valueSignature = deriveArgumentValueSignature(toolCall.function.arguments);
117
+ const successFingerprint = `${toolCall.function.name}|values:${valueSignature}|success`;
118
+ const repeatCount = (counts.get(successFingerprint) ?? 0) + 1;
119
+ counts.set(successFingerprint, repeatCount);
120
+
121
+ // Exploration tools (read, grep, glob, etc.) get a higher limit because
122
+ // re-reading the same file across turns is legitimate behavior (verifying
123
+ // edits, checking state, etc.). Use 5x multiplier for these tools.
124
+ const isExplorationTool = EXPLORATION_TOOLS.has(
125
+ toolCall.function.name.toLowerCase(),
126
+ );
127
+ const effectiveMaxRepeat = isExplorationTool
128
+ ? maxRepeat * EXPLORATION_LIMIT_MULTIPLIER
129
+ : maxRepeat;
130
+
131
+ // Some tools (notably edit/write) can get stuck in "successful" loops where
132
+ // the model keeps re-issuing the same operation with slightly different
133
+ // content (e.g. trailing newline differences). Track a coarse signature for
134
+ // these cases so we can still terminate noisy loops without blocking
135
+ // legitimate multi-step edits (which typically have non-empty old_string).
136
+ const coarseSuccessFingerprint = deriveSuccessCoarseFingerprint(
137
+ toolCall.function.name,
138
+ toolCall.function.arguments,
139
+ );
140
+ const coarseRepeatCount = coarseSuccessFingerprint
141
+ ? (coarseCounts.get(coarseSuccessFingerprint) ?? 0) + 1
142
+ : 0;
143
+ if (coarseSuccessFingerprint) {
144
+ coarseCounts.set(coarseSuccessFingerprint, coarseRepeatCount);
145
+ }
146
+ const coarseTriggered = coarseSuccessFingerprint
147
+ ? coarseRepeatCount > effectiveMaxRepeat
148
+ : false;
149
+ return {
150
+ fingerprint: coarseTriggered ? coarseSuccessFingerprint! : successFingerprint,
151
+ repeatCount: coarseTriggered ? coarseRepeatCount : repeatCount,
152
+ maxRepeat: effectiveMaxRepeat,
153
+ errorClass,
154
+ triggered: repeatCount > effectiveMaxRepeat || coarseTriggered,
155
+ tracked: true,
156
+ };
157
+ }
158
+ const strictFingerprint = `${toolCall.function.name}|${argShape}|${errorClass}`;
159
+ const coarseFingerprint = `${toolCall.function.name}|${errorClass}`;
160
+
161
+ return evaluateWithFingerprints(
162
+ toolCall.function.name,
163
+ errorClass,
164
+ strictFingerprint,
165
+ coarseFingerprint,
166
+ counts,
167
+ coarseCounts,
168
+ maxRepeat,
169
+ coarseMaxRepeat,
170
+ );
171
+ },
172
+
173
+ evaluateValidation(toolCall, validationSignature) {
174
+ const normalizedSignature = normalizeValidationSignature(validationSignature);
175
+ const strictFingerprint = `${toolCall.function.name}|schema:${normalizedSignature}|validation`;
176
+ const coarseFingerprint = `${toolCall.function.name}|validation`;
177
+ return evaluateWithFingerprints(
178
+ toolCall.function.name,
179
+ "validation",
180
+ strictFingerprint,
181
+ coarseFingerprint,
182
+ validationCounts,
183
+ validationCoarseCounts,
184
+ maxRepeat,
185
+ coarseMaxRepeat,
186
+ );
187
+ },
188
+
189
+ resetFingerprint(fingerprint) {
190
+ counts.delete(fingerprint);
191
+ coarseCounts.delete(fingerprint);
192
+ validationCounts.delete(fingerprint);
193
+ validationCoarseCounts.delete(fingerprint);
194
+ const parts = fingerprint.split("|");
195
+ if (parts.length >= 3) {
196
+ const tool = parts[0];
197
+ const errorClass = parts[parts.length - 1];
198
+ coarseCounts.delete(`${tool}|${errorClass}`);
199
+ validationCoarseCounts.delete(`${tool}|${errorClass}`);
200
+ } else if (parts.length === 2) {
201
+ const tool = parts[0];
202
+ const errorClass = parts[1];
203
+ for (const key of counts.keys()) {
204
+ if (key.startsWith(`${tool}|`) && key.endsWith(`|${errorClass}`)) {
205
+ counts.delete(key);
206
+ }
207
+ }
208
+ for (const key of validationCounts.keys()) {
209
+ if (key.startsWith(`${tool}|`) && key.endsWith(`|${errorClass}`)) {
210
+ validationCounts.delete(key);
211
+ }
212
+ }
213
+ }
214
+ },
215
+ };
216
+ }
217
+
218
+ function indexToolResultErrorClasses(messages: Array<unknown>): {
219
+ byCallId: Map<string, ToolLoopErrorClass>;
220
+ latest: ToolLoopErrorClass | null;
221
+ } {
222
+ const byCallId = new Map<string, ToolLoopErrorClass>();
223
+ let latest: ToolLoopErrorClass | null = null;
224
+
225
+ for (const message of messages) {
226
+ if (!isRecord(message) || message.role !== "tool") {
227
+ continue;
228
+ }
229
+
230
+ const errorClass = classifyToolResult(message.content);
231
+ latest = errorClass;
232
+
233
+ const callId =
234
+ typeof message.tool_call_id === "string" && message.tool_call_id.length > 0
235
+ ? message.tool_call_id
236
+ : null;
237
+ if (callId) {
238
+ byCallId.set(callId, errorClass);
239
+ }
240
+ }
241
+
242
+ return { byCallId, latest };
243
+ }
244
+
245
+ function indexToolLoopHistory(messages: Array<unknown>): {
246
+ byCallId: Map<string, ToolLoopErrorClass>;
247
+ latest: ToolLoopErrorClass | null;
248
+ latestByToolName: Map<string, ToolLoopErrorClass>;
249
+ initialCounts: Map<string, number>;
250
+ initialCoarseCounts: Map<string, number>;
251
+ initialValidationCounts: Map<string, number>;
252
+ initialValidationCoarseCounts: Map<string, number>;
253
+ } {
254
+ const { byCallId, latest } = indexToolResultErrorClasses(messages);
255
+ const initialCounts = new Map<string, number>();
256
+ const initialCoarseCounts = new Map<string, number>();
257
+ const initialValidationCounts = new Map<string, number>();
258
+ const initialValidationCoarseCounts = new Map<string, number>();
259
+ const assistantCalls = extractAssistantToolCalls(messages);
260
+
261
+ // Build per-tool-name latest errorClass by cross-referencing assistant calls
262
+ // with tool result classifications. In multi-tool turns (e.g. edit + context_info),
263
+ // the global `latest` may belong to the wrong tool; this map ensures each tool
264
+ // name resolves to the errorClass of *its own* most recent result.
265
+ const latestByToolName = new Map<string, ToolLoopErrorClass>();
266
+ for (const call of assistantCalls) {
267
+ const ec = byCallId.get(call.id);
268
+ if (ec !== undefined) {
269
+ latestByToolName.set(call.name, normalizeErrorClassForTool(call.name, ec));
270
+ }
271
+ }
272
+
273
+ for (const call of assistantCalls) {
274
+ const schemaSignature = deriveSchemaValidationSignature(call.name, call.argKeys);
275
+ const errorClass = normalizeErrorClassForTool(
276
+ call.name,
277
+ byCallId.get(call.id) ?? latestByToolName.get(call.name) ?? latest ?? "unknown",
278
+ );
279
+
280
+ if (errorClass === "success") {
281
+ incrementCount(
282
+ initialCounts,
283
+ `${call.name}|values:${call.argValueSignature}|success`,
284
+ );
285
+ const coarseSuccessFP = deriveSuccessCoarseFingerprint(
286
+ call.name,
287
+ call.rawArguments,
288
+ );
289
+ if (coarseSuccessFP) {
290
+ incrementCount(initialCoarseCounts, coarseSuccessFP);
291
+ }
292
+
293
+ if (schemaSignature) {
294
+ incrementCount(
295
+ initialValidationCounts,
296
+ `${call.name}|schema:${schemaSignature}|validation`,
297
+ );
298
+ incrementCount(initialValidationCoarseCounts, `${call.name}|validation`);
299
+ }
300
+ continue;
301
+ }
302
+ const strictFingerprint = `${call.name}|${call.argShape}|${errorClass}`;
303
+ const coarseFingerprint = `${call.name}|${errorClass}`;
304
+ incrementCount(initialCounts, strictFingerprint);
305
+ incrementCount(initialCoarseCounts, coarseFingerprint);
306
+ if (!schemaSignature) {
307
+ continue;
308
+ }
309
+ incrementCount(
310
+ initialValidationCounts,
311
+ `${call.name}|schema:${schemaSignature}|validation`,
312
+ );
313
+ incrementCount(initialValidationCoarseCounts, `${call.name}|validation`);
314
+ }
315
+
316
+ return {
317
+ byCallId,
318
+ latest,
319
+ latestByToolName,
320
+ initialCounts,
321
+ initialCoarseCounts,
322
+ initialValidationCounts,
323
+ initialValidationCoarseCounts,
324
+ };
325
+ }
326
+
327
+ function classifyToolResult(content: unknown): ToolLoopErrorClass {
328
+ const text = toLowerText(content);
329
+ if (!text) {
330
+ return "unknown";
331
+ }
332
+
333
+ if (
334
+ containsAny(text, [
335
+ "missing required",
336
+ "missing required argument",
337
+ "invalid",
338
+ "schema",
339
+ "unexpected",
340
+ "type error",
341
+ "must be of type",
342
+ ])
343
+ ) {
344
+ return "validation";
345
+ }
346
+ if (containsAny(text, ["enoent", "not found", "no such file"])) {
347
+ return "not_found";
348
+ }
349
+ if (containsAny(text, ["permission denied", "eacces", "forbidden"])) {
350
+ return "permission";
351
+ }
352
+ if (containsAny(text, ["timeout", "timed out"])) {
353
+ return "timeout";
354
+ }
355
+ if (containsAny(text, ["# todos", "\n[ ] ", "\n[x] ", "\n[x]"])) {
356
+ return "success";
357
+ }
358
+ if (containsAny(text, ["success", "completed", "\"ok\":true", "\"success\":true"])) {
359
+ return "success";
360
+ }
361
+ if (containsAny(text, ["error", "failed", "\"is_error\":true", "\"success\":false"])) {
362
+ return "tool_error";
363
+ }
364
+
365
+ return "unknown";
366
+ }
367
+
368
+ function deriveArgumentShape(rawArguments: string): string {
369
+ try {
370
+ const parsed = JSON.parse(rawArguments);
371
+ return JSON.stringify(shapeOf(parsed));
372
+ } catch {
373
+ return "invalid_json";
374
+ }
375
+ }
376
+
377
+ function deriveArgumentValueSignature(rawArguments: string): string {
378
+ try {
379
+ const parsed = JSON.parse(rawArguments);
380
+ return hashString(JSON.stringify(canonicalizeValue(parsed)));
381
+ } catch {
382
+ return `invalid:${hashString(rawArguments)}`;
383
+ }
384
+ }
385
+
386
+ function deriveSuccessCoarseFingerprint(toolName: string, rawArguments: string): string | null {
387
+ // Keep this intentionally conservative: only guard noisy success loops for tools
388
+ // that are commonly used for "create/overwrite file" operations.
389
+ const lowered = toolName.toLowerCase();
390
+ if (lowered !== "edit" && lowered !== "write") {
391
+ return null;
392
+ }
393
+
394
+ try {
395
+ const parsed = JSON.parse(rawArguments);
396
+ if (!isRecord(parsed)) {
397
+ return null;
398
+ }
399
+ const path = typeof parsed.path === "string" ? parsed.path : "";
400
+ if (!path) {
401
+ return null;
402
+ }
403
+
404
+ if (lowered === "edit") {
405
+ const oldString = typeof parsed.old_string === "string" ? parsed.old_string : null;
406
+ // Only treat "full file replace" edits as coarse-success tracked; multi-step
407
+ // edits with a non-empty old_string are common and should not be blocked.
408
+ if (oldString !== "") {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ return `${toolName}|path:${hashString(path)}|success`;
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
418
+
419
+ function extractAssistantToolCalls(messages: Array<unknown>): Array<{
420
+ id: string;
421
+ name: string;
422
+ rawArguments: string;
423
+ argShape: string;
424
+ argValueSignature: string;
425
+ argKeys: string[];
426
+ }> {
427
+ const calls: Array<{
428
+ id: string;
429
+ name: string;
430
+ rawArguments: string;
431
+ argShape: string;
432
+ argValueSignature: string;
433
+ argKeys: string[];
434
+ }> = [];
435
+ for (const message of messages) {
436
+ if (!isRecord(message) || message.role !== "assistant" || !Array.isArray(message.tool_calls)) {
437
+ continue;
438
+ }
439
+ for (const call of message.tool_calls) {
440
+ if (!isRecord(call)) {
441
+ continue;
442
+ }
443
+ const id = typeof call.id === "string" ? call.id : "";
444
+ const fn = isRecord(call.function) ? call.function : null;
445
+ const name = fn && typeof fn.name === "string" ? fn.name : "";
446
+ const rawArguments =
447
+ fn && typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn?.arguments ?? {});
448
+ if (!id || !name) {
449
+ continue;
450
+ }
451
+ calls.push({
452
+ id,
453
+ name,
454
+ rawArguments,
455
+ argShape: deriveArgumentShape(rawArguments),
456
+ argValueSignature: deriveArgumentValueSignature(rawArguments),
457
+ argKeys: extractArgumentKeys(rawArguments),
458
+ });
459
+ }
460
+ }
461
+ return calls;
462
+ }
463
+
464
+ function extractArgumentKeys(rawArguments: string): string[] {
465
+ try {
466
+ const parsed = JSON.parse(rawArguments);
467
+ if (!isRecord(parsed)) {
468
+ return [];
469
+ }
470
+ return Object.keys(parsed);
471
+ } catch {
472
+ return [];
473
+ }
474
+ }
475
+
476
+ function deriveSchemaValidationSignature(toolName: string, argKeys: string[]): string | null {
477
+ if (toolName !== "edit") {
478
+ return null;
479
+ }
480
+ const argKeySet = new Set(argKeys);
481
+ const required = ["path", "old_string", "new_string"];
482
+ const missing = required.filter((key) => !argKeySet.has(key));
483
+ if (missing.length === 0) {
484
+ return null;
485
+ }
486
+ return `missing:${missing.join(",")}`;
487
+ }
488
+
489
+ function normalizeValidationSignature(signature: string): string {
490
+ const normalized = signature.trim().toLowerCase();
491
+ return normalized.length > 0 ? normalized : "invalid";
492
+ }
493
+
494
+ function evaluateWithFingerprints(
495
+ toolName: string,
496
+ errorClass: ToolLoopErrorClass,
497
+ strictFingerprint: string,
498
+ coarseFingerprint: string,
499
+ strictCounts: Map<string, number>,
500
+ coarseCounts: Map<string, number>,
501
+ maxRepeat: number,
502
+ coarseMaxRepeat: number,
503
+ ): ToolLoopGuardDecision {
504
+ if (errorClass === "success") {
505
+ return {
506
+ fingerprint: strictFingerprint,
507
+ repeatCount: 0,
508
+ maxRepeat,
509
+ errorClass,
510
+ triggered: false,
511
+ tracked: false,
512
+ };
513
+ }
514
+
515
+ const isExplorationTool = EXPLORATION_TOOLS.has(toolName.toLowerCase());
516
+ const effectiveMaxRepeat = isExplorationTool
517
+ ? maxRepeat * EXPLORATION_LIMIT_MULTIPLIER
518
+ : maxRepeat;
519
+
520
+ const strictRepeatCount = (strictCounts.get(strictFingerprint) ?? 0) + 1;
521
+ strictCounts.set(strictFingerprint, strictRepeatCount);
522
+ const strictTriggered = strictRepeatCount > effectiveMaxRepeat;
523
+
524
+ if (isExplorationTool) {
525
+ return {
526
+ fingerprint: strictFingerprint,
527
+ repeatCount: strictRepeatCount,
528
+ maxRepeat: effectiveMaxRepeat,
529
+ errorClass,
530
+ triggered: strictTriggered,
531
+ tracked: true,
532
+ };
533
+ }
534
+
535
+ const coarseRepeatCount = (coarseCounts.get(coarseFingerprint) ?? 0) + 1;
536
+ coarseCounts.set(coarseFingerprint, coarseRepeatCount);
537
+ const coarseTriggered = coarseRepeatCount > coarseMaxRepeat;
538
+ const preferCoarseFingerprint = coarseTriggered && !strictTriggered;
539
+ return {
540
+ fingerprint: preferCoarseFingerprint ? coarseFingerprint : strictFingerprint,
541
+ repeatCount: preferCoarseFingerprint ? coarseRepeatCount : strictRepeatCount,
542
+ maxRepeat: preferCoarseFingerprint ? coarseMaxRepeat : maxRepeat,
543
+ errorClass,
544
+ triggered: strictTriggered || coarseTriggered,
545
+ tracked: true,
546
+ };
547
+ }
548
+
549
+ function incrementCount(map: Map<string, number>, key: string): void {
550
+ map.set(key, (map.get(key) ?? 0) + 1);
551
+ }
552
+
553
+ function shapeOf(value: unknown): unknown {
554
+ if (Array.isArray(value)) {
555
+ if (value.length === 0) {
556
+ return ["empty"];
557
+ }
558
+ return [shapeOf(value[0])];
559
+ }
560
+ if (isRecord(value)) {
561
+ const shaped: Record<string, unknown> = {};
562
+ for (const key of Object.keys(value).sort()) {
563
+ shaped[key] = shapeOf(value[key]);
564
+ }
565
+ return shaped;
566
+ }
567
+ if (value === null) {
568
+ return "null";
569
+ }
570
+ return typeof value;
571
+ }
572
+
573
+ function canonicalizeValue(value: unknown): unknown {
574
+ if (Array.isArray(value)) {
575
+ return value.map((entry) => canonicalizeValue(entry));
576
+ }
577
+ if (isRecord(value)) {
578
+ const canonical: Record<string, unknown> = {};
579
+ for (const key of Object.keys(value).sort()) {
580
+ canonical[key] = canonicalizeValue(value[key]);
581
+ }
582
+ return canonical;
583
+ }
584
+ return value;
585
+ }
586
+
587
+ function hashString(value: string): string {
588
+ // FNV-1a 32-bit hash is stable and cheap for loop-guard fingerprints.
589
+ let hash = 0x811c9dc5;
590
+ for (let i = 0; i < value.length; i += 1) {
591
+ hash ^= value.charCodeAt(i);
592
+ hash = Math.imul(hash, 0x01000193);
593
+ }
594
+ return (hash >>> 0).toString(16).padStart(8, "0");
595
+ }
596
+
597
+ function normalizeErrorClassForTool(
598
+ toolName: string,
599
+ errorClass: ToolLoopErrorClass,
600
+ ): ToolLoopErrorClass {
601
+ if (
602
+ errorClass === "unknown"
603
+ && UNKNOWN_AS_SUCCESS_TOOLS.has(toolName.toLowerCase())
604
+ ) {
605
+ return "success";
606
+ }
607
+ return errorClass;
608
+ }
609
+
610
+ function toLowerText(content: unknown): string {
611
+ const rendered = renderContent(content);
612
+ return rendered.trim().toLowerCase();
613
+ }
614
+
615
+ function renderContent(content: unknown): string {
616
+ if (typeof content === "string") {
617
+ return content;
618
+ }
619
+ if (Array.isArray(content)) {
620
+ return content
621
+ .map((part) => {
622
+ if (typeof part === "string") {
623
+ return part;
624
+ }
625
+ if (isRecord(part) && typeof part.text === "string") {
626
+ return part.text;
627
+ }
628
+ return JSON.stringify(part);
629
+ })
630
+ .join(" ");
631
+ }
632
+ if (content === null || content === undefined) {
633
+ return "";
634
+ }
635
+ return JSON.stringify(content);
636
+ }
637
+
638
+ function containsAny(text: string, patterns: string[]): boolean {
639
+ return patterns.some((pattern) => text.includes(pattern));
640
+ }
641
+
642
+ function isRecord(value: unknown): value is Record<string, unknown> {
643
+ return typeof value === "object" && value !== null && !Array.isArray(value);
644
+ }