@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 CHANGED
@@ -14,6 +14,9 @@
14
14
 
15
15
  [![npm version](https://img.shields.io/npm/v/@compilr-dev/agents.svg)](https://www.npmjs.com/package/@compilr-dev/agents)
16
16
  [![License: FSL-1.1-MIT](https://img.shields.io/badge/License-FSL--1.1--MIT-blue.svg)](https://fsl.software/)
17
+ [![API Docs](https://img.shields.io/badge/API_Docs-GitHub_Pages-blue)](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 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;
@@ -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. Same provider only.
1141
- * Takes effect on the next chat() call, not mid-stream.
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 for the agent to use
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 with full tool use support
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
- * Yields AgentEvent objects as the agent executes, allowing
1673
- * real-time monitoring of the agentic loop.
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 ?? 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 =
@@ -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. Same provider only.
535
- * Takes effect on the next chat() call, not mid-stream.
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 for the agent to use
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 (still applies per-tool)
2170
- if (this.maxConsecutiveToolCalls > 0) {
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
- if (this.maxConsecutiveToolCalls > 0) {
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 with full tool use support
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
- * Yields AgentEvent objects as the agent executes, allowing
2431
- * real-time monitoring of the agentic loop.
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
- * ContextManager tracks and manages context window usage
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;
@@ -62,7 +62,29 @@ export const DEFAULT_CONTEXT_CONFIG = {
62
62
  },
63
63
  };
64
64
  /**
65
- * ContextManager tracks and manages context window usage
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
- * ClaudeProvider implements LLMProvider for Anthropic's Claude API
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";
@@ -22,7 +22,32 @@ const DEFAULT_MODEL = 'claude-sonnet-4-6';
22
22
  */
23
23
  const DEFAULT_MAX_TOKENS = 4096;
24
24
  /**
25
- * ClaudeProvider implements LLMProvider for Anthropic's Claude API
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 name (e.g., 'claude', 'openai', 'gemini')
241
+ * Provider identifier (e.g., 'claude', 'openai', 'gemini')
232
242
  */
233
243
  readonly name: string;
234
244
  /**
235
- * Send messages and get a streaming response
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
  /**
@@ -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
  /**
@@ -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
- * Tool implementation - combines definition with handler
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.5.8",
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.11.10",
96
+ "hono": "^4.12.21",
91
97
  "minimatch": ">=10.2.1",
92
98
  "glob": ">=11.0.0"
93
99
  }