@compilr-dev/agents 0.5.8 → 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/README.md +3 -0
- package/dist/agent.d.ts +123 -10
- package/dist/agent.js +204 -79
- package/dist/context/manager.d.ts +23 -1
- package/dist/context/manager.js +23 -1
- package/dist/providers/claude.d.ts +26 -1
- package/dist/providers/claude.js +26 -1
- package/dist/providers/types.d.ts +15 -2
- package/dist/tools/define.d.ts +5 -0
- package/dist/tools/define.js +1 -0
- package/dist/tools/types.d.ts +37 -1
- package/package.json +9 -3
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
[](https://www.npmjs.com/package/@compilr-dev/agents)
|
|
16
16
|
[](https://fsl.software/)
|
|
17
|
+
[](https://compilr-dev.github.io/agents/)
|
|
18
|
+
|
|
19
|
+
**[API Reference](https://compilr-dev.github.io/agents/)** | **[llms.txt](https://compilr.dev/llms.txt)** (for AI agents)
|
|
17
20
|
|
|
18
21
|
> [!WARNING]
|
|
19
22
|
> This package is in beta. APIs may change between minor versions.
|
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;
|
|
@@ -1134,13 +1152,33 @@ export declare class Agent {
|
|
|
1134
1152
|
hasPins(): boolean;
|
|
1135
1153
|
/**
|
|
1136
1154
|
* Get the current model ID for this agent.
|
|
1155
|
+
*
|
|
1156
|
+
* @returns The model ID string (e.g., 'claude-sonnet-4-20250514')
|
|
1137
1157
|
*/
|
|
1138
1158
|
getModel(): string;
|
|
1139
1159
|
/**
|
|
1140
|
-
* Change the model for subsequent LLM calls
|
|
1141
|
-
*
|
|
1160
|
+
* Change the model for subsequent LLM calls (same provider only).
|
|
1161
|
+
*
|
|
1162
|
+
* Takes effect on the next `run()` or `stream()` call — never interrupts
|
|
1163
|
+
* a running turn. Conversation history is preserved (it's provider-agnostic).
|
|
1164
|
+
* Emits a `model_changed` event.
|
|
1165
|
+
*
|
|
1166
|
+
* Use this to switch between models within the same provider, e.g.,
|
|
1167
|
+
* Claude Sonnet → Claude Opus for a harder task, then back.
|
|
1142
1168
|
*
|
|
1143
1169
|
* @param modelId - The new model ID (e.g., 'claude-opus-4-20250514')
|
|
1170
|
+
* @throws If modelId is empty or not a string
|
|
1171
|
+
*
|
|
1172
|
+
* @example
|
|
1173
|
+
* ```typescript
|
|
1174
|
+
* console.log(agent.getModel()); // 'claude-sonnet-4-20250514'
|
|
1175
|
+
* agent.setModel('claude-opus-4-20250514');
|
|
1176
|
+
* // Next run() uses Opus
|
|
1177
|
+
* const result = await agent.run('Solve this complex problem');
|
|
1178
|
+
* agent.setModel('claude-sonnet-4-20250514'); // Switch back
|
|
1179
|
+
* ```
|
|
1180
|
+
*
|
|
1181
|
+
* @since 0.5.8
|
|
1144
1182
|
*/
|
|
1145
1183
|
setModel(modelId: string): void;
|
|
1146
1184
|
/** @deprecated Use addPin() instead */
|
|
@@ -1647,11 +1685,38 @@ export declare class Agent {
|
|
|
1647
1685
|
*/
|
|
1648
1686
|
private generateContextSummary;
|
|
1649
1687
|
/**
|
|
1650
|
-
* Register a tool
|
|
1688
|
+
* Register a tool that the agent can call during conversations.
|
|
1689
|
+
*
|
|
1690
|
+
* Tools are functions the LLM can invoke to perform actions like reading
|
|
1691
|
+
* files, running commands, or querying APIs. The LLM sees the tool's name,
|
|
1692
|
+
* description, and parameter schema, then decides when to call it.
|
|
1693
|
+
*
|
|
1694
|
+
* @param tool - Tool definition created with `defineTool()`
|
|
1695
|
+
* @returns The agent instance (for chaining)
|
|
1696
|
+
*
|
|
1697
|
+
* @example
|
|
1698
|
+
* ```typescript
|
|
1699
|
+
* agent.registerTool(defineTool({
|
|
1700
|
+
* name: 'get_weather',
|
|
1701
|
+
* description: 'Get current weather for a city',
|
|
1702
|
+
* parameters: {
|
|
1703
|
+
* type: 'object',
|
|
1704
|
+
* properties: { city: { type: 'string' } },
|
|
1705
|
+
* required: ['city'],
|
|
1706
|
+
* },
|
|
1707
|
+
* execute: async ({ city }) => {
|
|
1708
|
+
* const data = await fetchWeather(city);
|
|
1709
|
+
* return { content: JSON.stringify(data) };
|
|
1710
|
+
* },
|
|
1711
|
+
* }));
|
|
1712
|
+
* ```
|
|
1651
1713
|
*/
|
|
1652
1714
|
registerTool(tool: Tool): this;
|
|
1653
1715
|
/**
|
|
1654
|
-
* Register multiple tools at once
|
|
1716
|
+
* Register multiple tools at once.
|
|
1717
|
+
*
|
|
1718
|
+
* @param tools - Array of tool definitions
|
|
1719
|
+
* @returns The agent instance (for chaining)
|
|
1655
1720
|
*/
|
|
1656
1721
|
registerTools(tools: Tool[]): this;
|
|
1657
1722
|
/**
|
|
@@ -1663,14 +1728,62 @@ export declare class Agent {
|
|
|
1663
1728
|
*/
|
|
1664
1729
|
isToolSilent(name: string): boolean;
|
|
1665
1730
|
/**
|
|
1666
|
-
* Run the agent with a user message
|
|
1731
|
+
* Run the agent with a user message and return the result.
|
|
1732
|
+
*
|
|
1733
|
+
* This is the main entry point for agent interaction. The agent will:
|
|
1734
|
+
* 1. Add the user message to conversation history
|
|
1735
|
+
* 2. Send the conversation to the LLM
|
|
1736
|
+
* 3. Execute any tool calls the LLM requests
|
|
1737
|
+
* 4. Repeat steps 2-3 until the LLM responds with text (no tool calls)
|
|
1738
|
+
* 5. Return the final text response and metadata
|
|
1739
|
+
*
|
|
1740
|
+
* Events are emitted throughout the process via the `onEvent` callback
|
|
1741
|
+
* configured at construction time.
|
|
1742
|
+
*
|
|
1743
|
+
* @param userMessage - The user's message (string or content blocks for images)
|
|
1744
|
+
* @param options - Optional run configuration (max iterations, abort signal, etc.)
|
|
1745
|
+
* @returns The agent's response, tool call history, and context stats
|
|
1746
|
+
*
|
|
1747
|
+
* @example
|
|
1748
|
+
* ```typescript
|
|
1749
|
+
* const result = await agent.run('What files are in this directory?');
|
|
1750
|
+
* console.log(result.response);
|
|
1751
|
+
* console.log(`Used ${result.toolCalls.length} tool calls`);
|
|
1752
|
+
* ```
|
|
1753
|
+
*
|
|
1754
|
+
* @example
|
|
1755
|
+
* ```typescript
|
|
1756
|
+
* // With abort signal
|
|
1757
|
+
* const controller = new AbortController();
|
|
1758
|
+
* const result = await agent.run('Refactor this file', {
|
|
1759
|
+
* signal: controller.signal,
|
|
1760
|
+
* });
|
|
1761
|
+
* ```
|
|
1667
1762
|
*/
|
|
1668
1763
|
run(userMessage: string | ContentBlock[], options?: RunOptions): Promise<AgentRunResult>;
|
|
1669
1764
|
/**
|
|
1670
|
-
* Stream the agent's response
|
|
1765
|
+
* Stream the agent's response as events.
|
|
1766
|
+
*
|
|
1767
|
+
* Yields `AgentEvent` objects in real time as the agent thinks, calls tools,
|
|
1768
|
+
* and generates text. Use this for building interactive UIs that show
|
|
1769
|
+
* progress as it happens.
|
|
1770
|
+
*
|
|
1771
|
+
* @param userMessage - The user's message
|
|
1772
|
+
* @param options - Optional run configuration
|
|
1773
|
+
* @returns An async iterable of agent events
|
|
1671
1774
|
*
|
|
1672
|
-
*
|
|
1673
|
-
*
|
|
1775
|
+
* @example
|
|
1776
|
+
* ```typescript
|
|
1777
|
+
* for await (const event of agent.stream('Explain this code')) {
|
|
1778
|
+
* if (event.type === 'llm_chunk') {
|
|
1779
|
+
* process.stdout.write(event.chunk.text ?? '');
|
|
1780
|
+
* } else if (event.type === 'tool_start') {
|
|
1781
|
+
* console.log(`\nCalling tool: ${event.name}`);
|
|
1782
|
+
* } else if (event.type === 'done') {
|
|
1783
|
+
* console.log('\n\nDone!');
|
|
1784
|
+
* }
|
|
1785
|
+
* }
|
|
1786
|
+
* ```
|
|
1674
1787
|
*/
|
|
1675
1788
|
stream(userMessage: string, options?: RunOptions): AsyncIterable<AgentEvent>;
|
|
1676
1789
|
/**
|
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 =
|
|
@@ -526,15 +528,35 @@ export class Agent {
|
|
|
526
528
|
// --- Model management -------------------------------------------------------
|
|
527
529
|
/**
|
|
528
530
|
* Get the current model ID for this agent.
|
|
531
|
+
*
|
|
532
|
+
* @returns The model ID string (e.g., 'claude-sonnet-4-20250514')
|
|
529
533
|
*/
|
|
530
534
|
getModel() {
|
|
531
535
|
return this.provider.getModel();
|
|
532
536
|
}
|
|
533
537
|
/**
|
|
534
|
-
* Change the model for subsequent LLM calls
|
|
535
|
-
*
|
|
538
|
+
* Change the model for subsequent LLM calls (same provider only).
|
|
539
|
+
*
|
|
540
|
+
* Takes effect on the next `run()` or `stream()` call — never interrupts
|
|
541
|
+
* a running turn. Conversation history is preserved (it's provider-agnostic).
|
|
542
|
+
* Emits a `model_changed` event.
|
|
543
|
+
*
|
|
544
|
+
* Use this to switch between models within the same provider, e.g.,
|
|
545
|
+
* Claude Sonnet → Claude Opus for a harder task, then back.
|
|
536
546
|
*
|
|
537
547
|
* @param modelId - The new model ID (e.g., 'claude-opus-4-20250514')
|
|
548
|
+
* @throws If modelId is empty or not a string
|
|
549
|
+
*
|
|
550
|
+
* @example
|
|
551
|
+
* ```typescript
|
|
552
|
+
* console.log(agent.getModel()); // 'claude-sonnet-4-20250514'
|
|
553
|
+
* agent.setModel('claude-opus-4-20250514');
|
|
554
|
+
* // Next run() uses Opus
|
|
555
|
+
* const result = await agent.run('Solve this complex problem');
|
|
556
|
+
* agent.setModel('claude-sonnet-4-20250514'); // Switch back
|
|
557
|
+
* ```
|
|
558
|
+
*
|
|
559
|
+
* @since 0.5.8
|
|
538
560
|
*/
|
|
539
561
|
setModel(modelId) {
|
|
540
562
|
if (!modelId || typeof modelId !== 'string') {
|
|
@@ -1518,14 +1540,41 @@ export class Agent {
|
|
|
1518
1540
|
return summaryParts.join('\n');
|
|
1519
1541
|
}
|
|
1520
1542
|
/**
|
|
1521
|
-
* Register a tool
|
|
1543
|
+
* Register a tool that the agent can call during conversations.
|
|
1544
|
+
*
|
|
1545
|
+
* Tools are functions the LLM can invoke to perform actions like reading
|
|
1546
|
+
* files, running commands, or querying APIs. The LLM sees the tool's name,
|
|
1547
|
+
* description, and parameter schema, then decides when to call it.
|
|
1548
|
+
*
|
|
1549
|
+
* @param tool - Tool definition created with `defineTool()`
|
|
1550
|
+
* @returns The agent instance (for chaining)
|
|
1551
|
+
*
|
|
1552
|
+
* @example
|
|
1553
|
+
* ```typescript
|
|
1554
|
+
* agent.registerTool(defineTool({
|
|
1555
|
+
* name: 'get_weather',
|
|
1556
|
+
* description: 'Get current weather for a city',
|
|
1557
|
+
* parameters: {
|
|
1558
|
+
* type: 'object',
|
|
1559
|
+
* properties: { city: { type: 'string' } },
|
|
1560
|
+
* required: ['city'],
|
|
1561
|
+
* },
|
|
1562
|
+
* execute: async ({ city }) => {
|
|
1563
|
+
* const data = await fetchWeather(city);
|
|
1564
|
+
* return { content: JSON.stringify(data) };
|
|
1565
|
+
* },
|
|
1566
|
+
* }));
|
|
1567
|
+
* ```
|
|
1522
1568
|
*/
|
|
1523
1569
|
registerTool(tool) {
|
|
1524
1570
|
this.toolRegistry.register(tool);
|
|
1525
1571
|
return this;
|
|
1526
1572
|
}
|
|
1527
1573
|
/**
|
|
1528
|
-
* Register multiple tools at once
|
|
1574
|
+
* Register multiple tools at once.
|
|
1575
|
+
*
|
|
1576
|
+
* @param tools - Array of tool definitions
|
|
1577
|
+
* @returns The agent instance (for chaining)
|
|
1529
1578
|
*/
|
|
1530
1579
|
registerTools(tools) {
|
|
1531
1580
|
for (const tool of tools) {
|
|
@@ -1547,7 +1596,37 @@ export class Agent {
|
|
|
1547
1596
|
return tool?.silent === true;
|
|
1548
1597
|
}
|
|
1549
1598
|
/**
|
|
1550
|
-
* Run the agent with a user message
|
|
1599
|
+
* Run the agent with a user message and return the result.
|
|
1600
|
+
*
|
|
1601
|
+
* This is the main entry point for agent interaction. The agent will:
|
|
1602
|
+
* 1. Add the user message to conversation history
|
|
1603
|
+
* 2. Send the conversation to the LLM
|
|
1604
|
+
* 3. Execute any tool calls the LLM requests
|
|
1605
|
+
* 4. Repeat steps 2-3 until the LLM responds with text (no tool calls)
|
|
1606
|
+
* 5. Return the final text response and metadata
|
|
1607
|
+
*
|
|
1608
|
+
* Events are emitted throughout the process via the `onEvent` callback
|
|
1609
|
+
* configured at construction time.
|
|
1610
|
+
*
|
|
1611
|
+
* @param userMessage - The user's message (string or content blocks for images)
|
|
1612
|
+
* @param options - Optional run configuration (max iterations, abort signal, etc.)
|
|
1613
|
+
* @returns The agent's response, tool call history, and context stats
|
|
1614
|
+
*
|
|
1615
|
+
* @example
|
|
1616
|
+
* ```typescript
|
|
1617
|
+
* const result = await agent.run('What files are in this directory?');
|
|
1618
|
+
* console.log(result.response);
|
|
1619
|
+
* console.log(`Used ${result.toolCalls.length} tool calls`);
|
|
1620
|
+
* ```
|
|
1621
|
+
*
|
|
1622
|
+
* @example
|
|
1623
|
+
* ```typescript
|
|
1624
|
+
* // With abort signal
|
|
1625
|
+
* const controller = new AbortController();
|
|
1626
|
+
* const result = await agent.run('Refactor this file', {
|
|
1627
|
+
* signal: controller.signal,
|
|
1628
|
+
* });
|
|
1629
|
+
* ```
|
|
1551
1630
|
*/
|
|
1552
1631
|
async run(userMessage, options) {
|
|
1553
1632
|
let maxIterations = options?.maxIterations ?? this.maxIterations;
|
|
@@ -1682,6 +1761,8 @@ export class Agent {
|
|
|
1682
1761
|
// Tool loop detection: track consecutive identical calls
|
|
1683
1762
|
let lastToolCallHash = '';
|
|
1684
1763
|
let consecutiveIdenticalCalls = 0;
|
|
1764
|
+
// Self-heal: how many corrective nudges we've injected for the current streak
|
|
1765
|
+
let loopNudgeCount = 0;
|
|
1685
1766
|
// Hash function for tool call comparison.
|
|
1686
1767
|
// Uses a JSON replacer that recursively sorts object keys for stable hashing.
|
|
1687
1768
|
// NOTE: A simple `JSON.stringify(input, Object.keys(input).sort())` only sorts
|
|
@@ -1703,6 +1784,87 @@ export class Agent {
|
|
|
1703
1784
|
const hashToolCall = (name, input) => {
|
|
1704
1785
|
return `${name}:${stableStringify(input)}`;
|
|
1705
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
|
+
};
|
|
1706
1868
|
// Wrap agentic loop in try/finally to ensure conversation history is always
|
|
1707
1869
|
// preserved, even when ToolLoopError or MaxIterationsError is thrown.
|
|
1708
1870
|
// Without this, a thrown error skips the history append and the agent
|
|
@@ -1835,9 +1997,19 @@ export class Agent {
|
|
|
1835
1997
|
durationMs: Date.now() - llmStartTime,
|
|
1836
1998
|
});
|
|
1837
1999
|
}
|
|
2000
|
+
// Accumulate every iteration's text into finalResponse, separated by
|
|
2001
|
+
// blank lines. The previous code only assigned finalResponse in the
|
|
2002
|
+
// terminal iteration (no tool uses), which silently dropped any
|
|
2003
|
+
// text the agent produced BEFORE intermediate tool calls. Pattern:
|
|
2004
|
+
// iter 1: "Running test 1..." → tool t1 ← text was lost
|
|
2005
|
+
// iter 2: "Now test 2..." → tool t2 ← text was lost
|
|
2006
|
+
// iter 3: "All done." → no tool ← only this survived
|
|
2007
|
+
// Now every text segment is preserved in finalResponse.
|
|
2008
|
+
if (text) {
|
|
2009
|
+
finalResponse = finalResponse ? finalResponse + '\n\n' + text : text;
|
|
2010
|
+
}
|
|
1838
2011
|
// If no tool uses, we're done
|
|
1839
2012
|
if (toolUses.length === 0) {
|
|
1840
|
-
finalResponse = text;
|
|
1841
2013
|
// Add final assistant response to history (only if non-empty)
|
|
1842
2014
|
// Empty responses can occur after silent tools like 'suggest'
|
|
1843
2015
|
if (text) {
|
|
@@ -2166,41 +2338,8 @@ export class Agent {
|
|
|
2166
2338
|
// so the conversation history stays valid if we throw
|
|
2167
2339
|
messages.push(toolResultMsg);
|
|
2168
2340
|
newMessages.push(toolResultMsg);
|
|
2169
|
-
// Tool loop detection (
|
|
2170
|
-
|
|
2171
|
-
const currentHash = hashToolCall(toolUse.name, toolUse.input);
|
|
2172
|
-
if (currentHash === lastToolCallHash) {
|
|
2173
|
-
consecutiveIdenticalCalls++;
|
|
2174
|
-
if (consecutiveIdenticalCalls >= this.maxConsecutiveToolCalls) {
|
|
2175
|
-
if (this.onToolLoopDetected) {
|
|
2176
|
-
// Ask user: continue or stop?
|
|
2177
|
-
const shouldContinue = await this.onToolLoopDetected({
|
|
2178
|
-
toolName: toolUse.name,
|
|
2179
|
-
consecutiveCalls: consecutiveIdenticalCalls,
|
|
2180
|
-
input: toolUse.input,
|
|
2181
|
-
});
|
|
2182
|
-
if (shouldContinue) {
|
|
2183
|
-
consecutiveIdenticalCalls = 0; // Reset counter
|
|
2184
|
-
}
|
|
2185
|
-
else {
|
|
2186
|
-
throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
else {
|
|
2190
|
-
throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
emit({
|
|
2194
|
-
type: 'tool_loop_warning',
|
|
2195
|
-
toolName: toolUse.name,
|
|
2196
|
-
consecutiveCalls: consecutiveIdenticalCalls,
|
|
2197
|
-
});
|
|
2198
|
-
}
|
|
2199
|
-
else {
|
|
2200
|
-
lastToolCallHash = currentHash;
|
|
2201
|
-
consecutiveIdenticalCalls = 1;
|
|
2202
|
-
}
|
|
2203
|
-
}
|
|
2341
|
+
// Tool loop detection (result-aware + self-heal)
|
|
2342
|
+
await handleToolLoop(toolUse, toolResultMsg.content[0]?.content ?? '');
|
|
2204
2343
|
// Stamp for observation masking
|
|
2205
2344
|
if (this.observationMasker) {
|
|
2206
2345
|
const block = toolResultMsg.content[0];
|
|
@@ -2227,40 +2366,8 @@ export class Agent {
|
|
|
2227
2366
|
// so the conversation history stays valid if we throw
|
|
2228
2367
|
messages.push(toolResultMsg);
|
|
2229
2368
|
newMessages.push(toolResultMsg);
|
|
2230
|
-
// Tool loop detection
|
|
2231
|
-
|
|
2232
|
-
const currentHash = hashToolCall(toolUse.name, toolUse.input);
|
|
2233
|
-
if (currentHash === lastToolCallHash) {
|
|
2234
|
-
consecutiveIdenticalCalls++;
|
|
2235
|
-
if (consecutiveIdenticalCalls >= this.maxConsecutiveToolCalls) {
|
|
2236
|
-
if (this.onToolLoopDetected) {
|
|
2237
|
-
const shouldContinue = await this.onToolLoopDetected({
|
|
2238
|
-
toolName: toolUse.name,
|
|
2239
|
-
consecutiveCalls: consecutiveIdenticalCalls,
|
|
2240
|
-
input: toolUse.input,
|
|
2241
|
-
});
|
|
2242
|
-
if (shouldContinue) {
|
|
2243
|
-
consecutiveIdenticalCalls = 0;
|
|
2244
|
-
}
|
|
2245
|
-
else {
|
|
2246
|
-
throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
|
|
2247
|
-
}
|
|
2248
|
-
}
|
|
2249
|
-
else {
|
|
2250
|
-
throw new ToolLoopError(toolUse.name, consecutiveIdenticalCalls, toolUse.input);
|
|
2251
|
-
}
|
|
2252
|
-
}
|
|
2253
|
-
emit({
|
|
2254
|
-
type: 'tool_loop_warning',
|
|
2255
|
-
toolName: toolUse.name,
|
|
2256
|
-
consecutiveCalls: consecutiveIdenticalCalls,
|
|
2257
|
-
});
|
|
2258
|
-
}
|
|
2259
|
-
else {
|
|
2260
|
-
lastToolCallHash = currentHash;
|
|
2261
|
-
consecutiveIdenticalCalls = 1;
|
|
2262
|
-
}
|
|
2263
|
-
}
|
|
2369
|
+
// Tool loop detection (result-aware + self-heal)
|
|
2370
|
+
await handleToolLoop(toolUse, toolResultMsg.content[0]?.content ?? '');
|
|
2264
2371
|
// Stamp for observation masking
|
|
2265
2372
|
if (this.observationMasker) {
|
|
2266
2373
|
const block = toolResultMsg.content[0];
|
|
@@ -2425,10 +2532,28 @@ export class Agent {
|
|
|
2425
2532
|
return result;
|
|
2426
2533
|
}
|
|
2427
2534
|
/**
|
|
2428
|
-
* Stream the agent's response
|
|
2535
|
+
* Stream the agent's response as events.
|
|
2536
|
+
*
|
|
2537
|
+
* Yields `AgentEvent` objects in real time as the agent thinks, calls tools,
|
|
2538
|
+
* and generates text. Use this for building interactive UIs that show
|
|
2539
|
+
* progress as it happens.
|
|
2429
2540
|
*
|
|
2430
|
-
*
|
|
2431
|
-
*
|
|
2541
|
+
* @param userMessage - The user's message
|
|
2542
|
+
* @param options - Optional run configuration
|
|
2543
|
+
* @returns An async iterable of agent events
|
|
2544
|
+
*
|
|
2545
|
+
* @example
|
|
2546
|
+
* ```typescript
|
|
2547
|
+
* for await (const event of agent.stream('Explain this code')) {
|
|
2548
|
+
* if (event.type === 'llm_chunk') {
|
|
2549
|
+
* process.stdout.write(event.chunk.text ?? '');
|
|
2550
|
+
* } else if (event.type === 'tool_start') {
|
|
2551
|
+
* console.log(`\nCalling tool: ${event.name}`);
|
|
2552
|
+
* } else if (event.type === 'done') {
|
|
2553
|
+
* console.log('\n\nDone!');
|
|
2554
|
+
* }
|
|
2555
|
+
* }
|
|
2556
|
+
* ```
|
|
2432
2557
|
*/
|
|
2433
2558
|
async *stream(userMessage, options) {
|
|
2434
2559
|
// Use a simple queue-based approach
|
|
@@ -52,7 +52,29 @@ export interface ContextManagerOptions {
|
|
|
52
52
|
fileTracker?: FileAccessTracker;
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* Manages the agent's context window — tracks token usage, triggers
|
|
56
|
+
* compaction when the conversation grows too large, and ensures the
|
|
57
|
+
* agent never exceeds the model's context limit.
|
|
58
|
+
*
|
|
59
|
+
* The context window is divided into budgets:
|
|
60
|
+
* - **System prompt** (~15%) — always present, never compacted
|
|
61
|
+
* - **Anchors/pins** (~10%) — critical info that survives compaction
|
|
62
|
+
* - **Conversation history** (~75%) — compacted when budget exceeded
|
|
63
|
+
*
|
|
64
|
+
* When conversation history exceeds its budget, the manager triggers
|
|
65
|
+
* smart windowing (3-zone compaction): recent messages preserved,
|
|
66
|
+
* middle messages summarized, old messages dropped.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```typescript
|
|
70
|
+
* const agent = new Agent({
|
|
71
|
+
* provider,
|
|
72
|
+
* contextManager: new ContextManager({
|
|
73
|
+
* maxTokens: 100000,
|
|
74
|
+
* budgets: { system: 0.15, anchors: 0.10, conversation: 0.75 },
|
|
75
|
+
* }),
|
|
76
|
+
* });
|
|
77
|
+
* ```
|
|
56
78
|
*/
|
|
57
79
|
export declare class ContextManager {
|
|
58
80
|
private readonly provider;
|
package/dist/context/manager.js
CHANGED
|
@@ -62,7 +62,29 @@ export const DEFAULT_CONTEXT_CONFIG = {
|
|
|
62
62
|
},
|
|
63
63
|
};
|
|
64
64
|
/**
|
|
65
|
-
*
|
|
65
|
+
* Manages the agent's context window — tracks token usage, triggers
|
|
66
|
+
* compaction when the conversation grows too large, and ensures the
|
|
67
|
+
* agent never exceeds the model's context limit.
|
|
68
|
+
*
|
|
69
|
+
* The context window is divided into budgets:
|
|
70
|
+
* - **System prompt** (~15%) — always present, never compacted
|
|
71
|
+
* - **Anchors/pins** (~10%) — critical info that survives compaction
|
|
72
|
+
* - **Conversation history** (~75%) — compacted when budget exceeded
|
|
73
|
+
*
|
|
74
|
+
* When conversation history exceeds its budget, the manager triggers
|
|
75
|
+
* smart windowing (3-zone compaction): recent messages preserved,
|
|
76
|
+
* middle messages summarized, old messages dropped.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```typescript
|
|
80
|
+
* const agent = new Agent({
|
|
81
|
+
* provider,
|
|
82
|
+
* contextManager: new ContextManager({
|
|
83
|
+
* maxTokens: 100000,
|
|
84
|
+
* budgets: { system: 0.15, anchors: 0.10, conversation: 0.75 },
|
|
85
|
+
* }),
|
|
86
|
+
* });
|
|
87
|
+
* ```
|
|
66
88
|
*/
|
|
67
89
|
export class ContextManager {
|
|
68
90
|
provider;
|
|
@@ -67,7 +67,32 @@ export interface ClaudeProviderConfig {
|
|
|
67
67
|
enableExtendedContext?: boolean;
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
*
|
|
70
|
+
* LLM provider for Anthropic's Claude models (Opus, Sonnet, Haiku).
|
|
71
|
+
*
|
|
72
|
+
* Supports streaming, tool use, prompt caching, extended context (1M tokens),
|
|
73
|
+
* and token-efficient tool schemas.
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```typescript
|
|
77
|
+
* const provider = new ClaudeProvider({
|
|
78
|
+
* apiKey: process.env.ANTHROPIC_API_KEY,
|
|
79
|
+
* model: 'claude-sonnet-4-20250514',
|
|
80
|
+
* });
|
|
81
|
+
*
|
|
82
|
+
* const agent = new Agent({
|
|
83
|
+
* provider,
|
|
84
|
+
* systemPrompt: 'You are a helpful assistant.',
|
|
85
|
+
* });
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* // Using the factory function
|
|
91
|
+
* const provider = createClaudeProvider({
|
|
92
|
+
* apiKey: 'sk-ant-...',
|
|
93
|
+
* enableExtendedContext: true, // 1M token context
|
|
94
|
+
* });
|
|
95
|
+
* ```
|
|
71
96
|
*/
|
|
72
97
|
export declare class ClaudeProvider implements LLMProvider {
|
|
73
98
|
readonly name = "claude";
|
package/dist/providers/claude.js
CHANGED
|
@@ -22,7 +22,32 @@ const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
|
22
22
|
*/
|
|
23
23
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
25
|
+
* LLM provider for Anthropic's Claude models (Opus, Sonnet, Haiku).
|
|
26
|
+
*
|
|
27
|
+
* Supports streaming, tool use, prompt caching, extended context (1M tokens),
|
|
28
|
+
* and token-efficient tool schemas.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const provider = new ClaudeProvider({
|
|
33
|
+
* apiKey: process.env.ANTHROPIC_API_KEY,
|
|
34
|
+
* model: 'claude-sonnet-4-20250514',
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* const agent = new Agent({
|
|
38
|
+
* provider,
|
|
39
|
+
* systemPrompt: 'You are a helpful assistant.',
|
|
40
|
+
* });
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* // Using the factory function
|
|
46
|
+
* const provider = createClaudeProvider({
|
|
47
|
+
* apiKey: 'sk-ant-...',
|
|
48
|
+
* enableExtendedContext: true, // 1M token context
|
|
49
|
+
* });
|
|
50
|
+
* ```
|
|
26
51
|
*/
|
|
27
52
|
export class ClaudeProvider {
|
|
28
53
|
name = 'claude';
|
|
@@ -226,13 +226,26 @@ export interface ToolResult {
|
|
|
226
226
|
/**
|
|
227
227
|
* LLM Provider interface - all providers must implement this
|
|
228
228
|
*/
|
|
229
|
+
/**
|
|
230
|
+
* Interface for LLM providers. Implement this to add support for a new AI model.
|
|
231
|
+
*
|
|
232
|
+
* Built-in providers: `ClaudeProvider`, `OpenAIProvider`, `GeminiProvider`,
|
|
233
|
+
* `OllamaProvider`, `TogetherProvider`, `GroqProvider`, `FireworksProvider`,
|
|
234
|
+
* `PerplexityProvider`, `OpenRouterProvider`.
|
|
235
|
+
*
|
|
236
|
+
* For OpenAI-compatible APIs, extend `OpenAICompatibleProvider` instead of
|
|
237
|
+
* implementing this interface directly.
|
|
238
|
+
*/
|
|
229
239
|
export interface LLMProvider {
|
|
230
240
|
/**
|
|
231
|
-
* Provider
|
|
241
|
+
* Provider identifier (e.g., 'claude', 'openai', 'gemini')
|
|
232
242
|
*/
|
|
233
243
|
readonly name: string;
|
|
234
244
|
/**
|
|
235
|
-
* Send messages and
|
|
245
|
+
* Send messages to the LLM and stream the response.
|
|
246
|
+
*
|
|
247
|
+
* Yields `StreamChunk` objects containing text fragments, tool calls,
|
|
248
|
+
* usage stats, and other provider-specific data.
|
|
236
249
|
*/
|
|
237
250
|
chat(messages: Message[], options?: ChatOptions): AsyncIterable<StreamChunk>;
|
|
238
251
|
/**
|
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
|
@@ -73,7 +73,36 @@ export interface ToolExecutionContext {
|
|
|
73
73
|
*/
|
|
74
74
|
export type ToolHandler<T = object> = (input: T, context?: ToolExecutionContext) => Promise<ToolExecutionResult>;
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
76
|
+
* A tool that the agent can call during conversations.
|
|
77
|
+
*
|
|
78
|
+
* Tools are the primary way agents interact with the outside world — reading
|
|
79
|
+
* files, running commands, querying APIs, etc. Each tool has a definition
|
|
80
|
+
* (name, description, parameters) that the LLM sees, and an execute function
|
|
81
|
+
* that runs when the LLM decides to call it.
|
|
82
|
+
*
|
|
83
|
+
* Use `defineTool()` to create tools with type-safe parameters.
|
|
84
|
+
*
|
|
85
|
+
* @typeParam T - The type of the tool's input parameters
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```typescript
|
|
89
|
+
* const readFileTool: Tool<{ path: string }> = {
|
|
90
|
+
* definition: {
|
|
91
|
+
* name: 'read_file',
|
|
92
|
+
* description: 'Read the contents of a file',
|
|
93
|
+
* parameters: {
|
|
94
|
+
* type: 'object',
|
|
95
|
+
* properties: { path: { type: 'string', description: 'File path' } },
|
|
96
|
+
* required: ['path'],
|
|
97
|
+
* },
|
|
98
|
+
* },
|
|
99
|
+
* execute: async ({ path }) => {
|
|
100
|
+
* const content = await fs.readFile(path, 'utf-8');
|
|
101
|
+
* return { content };
|
|
102
|
+
* },
|
|
103
|
+
* readonly: true, // Safe for parallel execution
|
|
104
|
+
* };
|
|
105
|
+
* ```
|
|
77
106
|
*/
|
|
78
107
|
export interface Tool<T = object> {
|
|
79
108
|
definition: ToolDefinition;
|
|
@@ -98,6 +127,13 @@ export interface Tool<T = object> {
|
|
|
98
127
|
* Default: false
|
|
99
128
|
*/
|
|
100
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;
|
|
101
137
|
}
|
|
102
138
|
/**
|
|
103
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",
|
|
@@ -28,7 +28,11 @@
|
|
|
28
28
|
"test:watch": "vitest",
|
|
29
29
|
"test:coverage": "vitest run --coverage",
|
|
30
30
|
"prepublishOnly": "npm run clean && npm run lint && npm run test && npm run build",
|
|
31
|
-
"typecheck": "tsc --noEmit"
|
|
31
|
+
"typecheck": "tsc --noEmit",
|
|
32
|
+
"docs": "typedoc && node scripts/inject-frontmatter.mjs && typedoc --options typedoc.llms.json && mv docs/llms/README.md docs/llms.txt",
|
|
33
|
+
"docs:api": "typedoc",
|
|
34
|
+
"docs:llms": "typedoc --options typedoc.llms.json",
|
|
35
|
+
"docs:watch": "typedoc --watch"
|
|
32
36
|
},
|
|
33
37
|
"repository": {
|
|
34
38
|
"type": "git",
|
|
@@ -76,6 +80,8 @@
|
|
|
76
80
|
"dotenv": "^17.2.3",
|
|
77
81
|
"eslint": "^9.39.1",
|
|
78
82
|
"prettier": "^3.7.1",
|
|
83
|
+
"typedoc": "^0.28.19",
|
|
84
|
+
"typedoc-plugin-markdown": "^4.11.0",
|
|
79
85
|
"typescript": "^5.3.0",
|
|
80
86
|
"typescript-eslint": "^8.48.0",
|
|
81
87
|
"vitest": "^4.0.18"
|
|
@@ -87,7 +93,7 @@
|
|
|
87
93
|
"js-tiktoken": "^1.0.21"
|
|
88
94
|
},
|
|
89
95
|
"overrides": {
|
|
90
|
-
"hono": "^4.
|
|
96
|
+
"hono": "^4.12.21",
|
|
91
97
|
"minimatch": ">=10.2.1",
|
|
92
98
|
"glob": ">=11.0.0"
|
|
93
99
|
}
|