@compilr-dev/agents 0.5.9 → 0.6.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.
package/dist/agent.d.ts CHANGED
@@ -86,6 +86,11 @@ export type AgentEvent = {
86
86
  type: 'tool_loop_warning';
87
87
  toolName: string;
88
88
  consecutiveCalls: number;
89
+ } | {
90
+ type: 'tool_loop_nudge';
91
+ toolName: string;
92
+ consecutiveCalls: number;
93
+ nudgeCount: number;
89
94
  } | {
90
95
  type: 'abort_checkpoint_saved';
91
96
  sessionId: string;
@@ -222,10 +227,22 @@ export interface AgentConfig {
222
227
  */
223
228
  maxIterations?: number;
224
229
  /**
225
- * Maximum consecutive identical tool calls before throwing ToolLoopError (default: 3).
226
- * Set to 0 to disable loop detection.
230
+ * Maximum consecutive identical tool calls (same name + input + result) before
231
+ * the loop detector trips (default: 5). Set to 0 to disable loop detection.
232
+ *
233
+ * Detection is result-aware: a repeated call only counts when its result is
234
+ * also identical to the previous one, so legitimate polling that returns
235
+ * changing output (e.g. `bash_output` while a build runs) does not trip.
227
236
  */
228
237
  maxConsecutiveToolCalls?: number;
238
+ /**
239
+ * On a loop trip, how many times to inject a corrective "self-heal" message
240
+ * and let the agent continue before throwing ToolLoopError (default: 1).
241
+ * Set to 0 to throw immediately on the first trip (no self-heal).
242
+ *
243
+ * Ignored when `onToolLoopDetected` is provided (that callback takes over).
244
+ */
245
+ maxToolLoopNudges?: number;
229
246
  /**
230
247
  * Behavior when max iterations is reached (default: 'error').
231
248
  * - 'error': Throw MaxIterationsError immediately
@@ -888,6 +905,7 @@ export declare class Agent {
888
905
  private readonly systemPrompt;
889
906
  private readonly maxIterations;
890
907
  private readonly maxConsecutiveToolCalls;
908
+ private readonly maxToolLoopNudges;
891
909
  private readonly iterationLimitBehavior;
892
910
  private readonly chatOptions;
893
911
  private readonly toolRegistry;
package/dist/agent.js CHANGED
@@ -44,6 +44,7 @@ export class Agent {
44
44
  systemPrompt;
45
45
  maxIterations;
46
46
  maxConsecutiveToolCalls;
47
+ maxToolLoopNudges;
47
48
  iterationLimitBehavior;
48
49
  chatOptions;
49
50
  toolRegistry;
@@ -122,7 +123,8 @@ export class Agent {
122
123
  this.provider = config.provider;
123
124
  this.systemPrompt = config.systemPrompt ?? '';
124
125
  this.maxIterations = config.maxIterations ?? 10;
125
- this.maxConsecutiveToolCalls = config.maxConsecutiveToolCalls ?? 3;
126
+ this.maxConsecutiveToolCalls = config.maxConsecutiveToolCalls ?? 5;
127
+ this.maxToolLoopNudges = config.maxToolLoopNudges ?? 1;
126
128
  this.iterationLimitBehavior = config.iterationLimitBehavior ?? 'error';
127
129
  this.chatOptions = config.chatOptions ?? {};
128
130
  this.toolRegistry =
@@ -1759,6 +1761,8 @@ export class Agent {
1759
1761
  // Tool loop detection: track consecutive identical calls
1760
1762
  let lastToolCallHash = '';
1761
1763
  let consecutiveIdenticalCalls = 0;
1764
+ // Self-heal: how many corrective nudges we've injected for the current streak
1765
+ let loopNudgeCount = 0;
1762
1766
  // Hash function for tool call comparison.
1763
1767
  // Uses a JSON replacer that recursively sorts object keys for stable hashing.
1764
1768
  // NOTE: A simple `JSON.stringify(input, Object.keys(input).sort())` only sorts
@@ -1780,6 +1784,87 @@ export class Agent {
1780
1784
  const hashToolCall = (name, input) => {
1781
1785
  return `${name}:${stableStringify(input)}`;
1782
1786
  };
1787
+ // Cheap djb2 string hash — keeps the result portion of the loop key small
1788
+ // instead of carrying full (possibly large) tool output around.
1789
+ const cheapHash = (s) => {
1790
+ let h = 5381;
1791
+ for (let i = 0; i < s.length; i++)
1792
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
1793
+ return String(h >>> 0);
1794
+ };
1795
+ /**
1796
+ * Result-aware tool-loop detection with self-heal.
1797
+ *
1798
+ * A call counts toward a loop only when name + input + result all match the
1799
+ * previous call (so progress-making polls don't trip). On a trip:
1800
+ * - if `onToolLoopDetected` is wired → legacy ask-continue/stop path;
1801
+ * - else inject a corrective message and continue, up to `maxToolLoopNudges`
1802
+ * times; after that, throw ToolLoopError.
1803
+ *
1804
+ * Pushes any nudge into both `messages` and `newMessages`, so it must run
1805
+ * AFTER the tool result has been pushed (history stays valid on throw).
1806
+ */
1807
+ const handleToolLoop = async (toolUse, resultContent) => {
1808
+ if (this.maxConsecutiveToolCalls <= 0)
1809
+ return;
1810
+ // Tools that are legitimately repeatable opt out entirely.
1811
+ if (this.toolRegistry.get(toolUse.name)?.repeatable)
1812
+ return;
1813
+ const currentHash = `${hashToolCall(toolUse.name, toolUse.input)}#${cheapHash(resultContent)}`;
1814
+ if (currentHash !== lastToolCallHash) {
1815
+ // Different call/result → progress. Fresh slate.
1816
+ lastToolCallHash = currentHash;
1817
+ consecutiveIdenticalCalls = 1;
1818
+ loopNudgeCount = 0;
1819
+ return;
1820
+ }
1821
+ consecutiveIdenticalCalls++;
1822
+ if (consecutiveIdenticalCalls < this.maxConsecutiveToolCalls) {
1823
+ emit({
1824
+ type: 'tool_loop_warning',
1825
+ toolName: toolUse.name,
1826
+ consecutiveCalls: consecutiveIdenticalCalls,
1827
+ });
1828
+ return;
1829
+ }
1830
+ // ── Trip ──
1831
+ // Legacy callback path (back-compat): ask the host to continue or stop.
1832
+ if (this.onToolLoopDetected) {
1833
+ const shouldContinue = await this.onToolLoopDetected({
1834
+ toolName: toolUse.name,
1835
+ consecutiveCalls: consecutiveIdenticalCalls,
1836
+ input: toolUse.input,
1837
+ });
1838
+ if (shouldContinue) {
1839
+ consecutiveIdenticalCalls = 0;
1840
+ return;
1841
+ }
1842
+ throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
1843
+ }
1844
+ // Default: self-heal with a corrective message, up to maxToolLoopNudges.
1845
+ if (loopNudgeCount < this.maxToolLoopNudges) {
1846
+ loopNudgeCount++;
1847
+ const nudge = {
1848
+ role: 'user',
1849
+ content: `[system reminder] You've called \`${toolUse.name}\` with identical input and an ` +
1850
+ `unchanged result ${String(consecutiveIdenticalCalls)} times in a row. If a process ` +
1851
+ `is still running, wait before polling again or tell the user what you're waiting on. ` +
1852
+ `Otherwise stop repeating this call and take a different step toward the goal.`,
1853
+ };
1854
+ messages.push(nudge);
1855
+ newMessages.push(nudge);
1856
+ emit({
1857
+ type: 'tool_loop_nudge',
1858
+ toolName: toolUse.name,
1859
+ consecutiveCalls: consecutiveIdenticalCalls,
1860
+ nudgeCount: loopNudgeCount,
1861
+ });
1862
+ consecutiveIdenticalCalls = 0;
1863
+ return;
1864
+ }
1865
+ // Nudges exhausted → hard stop.
1866
+ throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
1867
+ };
1783
1868
  // Wrap agentic loop in try/finally to ensure conversation history is always
1784
1869
  // preserved, even when ToolLoopError or MaxIterationsError is thrown.
1785
1870
  // Without this, a thrown error skips the history append and the agent
@@ -2253,41 +2338,8 @@ export class Agent {
2253
2338
  // so the conversation history stays valid if we throw
2254
2339
  messages.push(toolResultMsg);
2255
2340
  newMessages.push(toolResultMsg);
2256
- // Tool loop detection (still applies per-tool)
2257
- if (this.maxConsecutiveToolCalls > 0) {
2258
- const currentHash = hashToolCall(toolUse.name, toolUse.input);
2259
- if (currentHash === lastToolCallHash) {
2260
- consecutiveIdenticalCalls++;
2261
- if (consecutiveIdenticalCalls >= this.maxConsecutiveToolCalls) {
2262
- if (this.onToolLoopDetected) {
2263
- // Ask user: continue or stop?
2264
- const shouldContinue = await this.onToolLoopDetected({
2265
- toolName: toolUse.name,
2266
- consecutiveCalls: consecutiveIdenticalCalls,
2267
- input: toolUse.input,
2268
- });
2269
- if (shouldContinue) {
2270
- consecutiveIdenticalCalls = 0; // Reset counter
2271
- }
2272
- else {
2273
- throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
2274
- }
2275
- }
2276
- else {
2277
- throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
2278
- }
2279
- }
2280
- emit({
2281
- type: 'tool_loop_warning',
2282
- toolName: toolUse.name,
2283
- consecutiveCalls: consecutiveIdenticalCalls,
2284
- });
2285
- }
2286
- else {
2287
- lastToolCallHash = currentHash;
2288
- consecutiveIdenticalCalls = 1;
2289
- }
2290
- }
2341
+ // Tool loop detection (result-aware + self-heal)
2342
+ await handleToolLoop(toolUse, toolResultMsg.content[0]?.content ?? '');
2291
2343
  // Stamp for observation masking
2292
2344
  if (this.observationMasker) {
2293
2345
  const block = toolResultMsg.content[0];
@@ -2314,40 +2366,8 @@ export class Agent {
2314
2366
  // so the conversation history stays valid if we throw
2315
2367
  messages.push(toolResultMsg);
2316
2368
  newMessages.push(toolResultMsg);
2317
- // Tool loop detection
2318
- if (this.maxConsecutiveToolCalls > 0) {
2319
- const currentHash = hashToolCall(toolUse.name, toolUse.input);
2320
- if (currentHash === lastToolCallHash) {
2321
- consecutiveIdenticalCalls++;
2322
- if (consecutiveIdenticalCalls >= this.maxConsecutiveToolCalls) {
2323
- if (this.onToolLoopDetected) {
2324
- const shouldContinue = await this.onToolLoopDetected({
2325
- toolName: toolUse.name,
2326
- consecutiveCalls: consecutiveIdenticalCalls,
2327
- input: toolUse.input,
2328
- });
2329
- if (shouldContinue) {
2330
- consecutiveIdenticalCalls = 0;
2331
- }
2332
- else {
2333
- throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
2334
- }
2335
- }
2336
- else {
2337
- throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
2338
- }
2339
- }
2340
- emit({
2341
- type: 'tool_loop_warning',
2342
- toolName: toolUse.name,
2343
- consecutiveCalls: consecutiveIdenticalCalls,
2344
- });
2345
- }
2346
- else {
2347
- lastToolCallHash = currentHash;
2348
- consecutiveIdenticalCalls = 1;
2349
- }
2350
- }
2369
+ // Tool loop detection (result-aware + self-heal)
2370
+ await handleToolLoop(toolUse, toolResultMsg.content[0]?.content ?? '');
2351
2371
  // Stamp for observation masking
2352
2372
  if (this.observationMasker) {
2353
2373
  const block = toolResultMsg.content[0];
@@ -42,6 +42,11 @@ export interface DefineToolOptions<T extends object> {
42
42
  * Default: false
43
43
  */
44
44
  readonly?: boolean;
45
+ /**
46
+ * If true, this tool is exempt from tool-loop detection (repeated identical
47
+ * calls are always legitimate). Default: false.
48
+ */
49
+ repeatable?: boolean;
45
50
  }
46
51
  /**
47
52
  * Define a tool with type-safe input handling
@@ -35,6 +35,7 @@ export function defineTool(options) {
35
35
  parallel: options.parallel,
36
36
  silent: options.silent,
37
37
  readonly: options.readonly,
38
+ repeatable: options.repeatable,
38
39
  };
39
40
  }
40
41
  /**
@@ -127,6 +127,13 @@ export interface Tool<T = object> {
127
127
  * Default: false
128
128
  */
129
129
  readonly?: boolean;
130
+ /**
131
+ * If true, this tool is exempt from tool-loop detection — repeated identical
132
+ * calls are always legitimate (e.g. genuinely idempotent pollers). Most
133
+ * polling tools do NOT need this: loop detection is result-aware, so calls
134
+ * that return changing output don't trip. Default: false.
135
+ */
136
+ repeatable?: boolean;
130
137
  }
131
138
  /**
132
139
  * Fallback handler for tools not found in the primary registry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/agents",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
4
4
  "description": "Lightweight multi-LLM agent library for building CLI AI assistants",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -93,7 +93,7 @@
93
93
  "js-tiktoken": "^1.0.21"
94
94
  },
95
95
  "overrides": {
96
- "hono": "^4.11.10",
96
+ "hono": "^4.12.21",
97
97
  "minimatch": ">=10.2.1",
98
98
  "glob": ">=11.0.0"
99
99
  }