@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 +20 -2
- package/dist/agent.js +90 -70
- package/dist/tools/define.d.ts +5 -0
- package/dist/tools/define.js +1 -0
- package/dist/tools/types.d.ts +7 -0
- package/package.json +2 -2
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
|
|
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 ??
|
|
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 (
|
|
2257
|
-
|
|
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
|
-
|
|
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];
|
package/dist/tools/define.d.ts
CHANGED
|
@@ -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
|
package/dist/tools/define.js
CHANGED
package/dist/tools/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
96
|
+
"hono": "^4.12.21",
|
|
97
97
|
"minimatch": ">=10.2.1",
|
|
98
98
|
"glob": ">=11.0.0"
|
|
99
99
|
}
|