@narimangardi/agent-loop 0.1.0 → 0.3.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
@@ -1,5 +1,9 @@
1
1
  # agent-loop
2
2
 
3
+ [![Tests](https://github.com/NarimanGardi/agent-loop/actions/workflows/tests.yml/badge.svg)](https://github.com/NarimanGardi/agent-loop/actions/workflows/tests.yml)
4
+ [![npm version](https://img.shields.io/npm/v/@narimangardi/agent-loop)](https://www.npmjs.com/package/@narimangardi/agent-loop)
5
+ [![License](https://img.shields.io/npm/l/@narimangardi/agent-loop)](LICENSE)
6
+
3
7
  A tiny, zero-dependency, provider-agnostic **agent tool-loop** for TypeScript.
4
8
  The whole thing is one small class: ask an LLM, run the tools it asks for, feed
5
9
  the results back, repeat until it answers. Bring your own model.
@@ -51,12 +55,28 @@ console.log(result.steps); // how many round-trips it took
51
55
 
52
56
  1. Send the conversation + tool specs to the provider.
53
57
  2. If the provider returns a **message**, that's the answer — return it.
54
- 3. If it returns **tool calls**, run each tool and append the results.
58
+ 3. If it returns **tool calls**, run them (concurrently within a turn) and append the results.
55
59
  4. Go back to step 1 (up to `maxSteps`, default 10).
56
60
 
57
61
  Unknown tools and thrown errors are handed back to the model as text, so it can
58
62
  recover instead of crashing the loop.
59
63
 
64
+ ## Observe the loop
65
+
66
+ Pass optional hooks to watch it run — for logging, a progress UI, or tracing:
67
+
68
+ ```ts
69
+ const agent = new Agent({
70
+ provider,
71
+ tools: [getWeather],
72
+ onStep: (n) => console.log(`step ${n}`),
73
+ onToolCall: (call) => console.log(`→ ${call.name}`, call.arguments),
74
+ onToolResult: (call, output) => console.log(`← ${call.name}: ${output}`),
75
+ });
76
+ ```
77
+
78
+ Hooks may be async — the loop awaits them.
79
+
60
80
  ## Plug in a model
61
81
 
62
82
  There's one interface to implement — map your LLM's tool-calling API to a
@@ -102,6 +122,11 @@ const openai: Provider = {
102
122
  The same shape works for Anthropic, Gemini, Ollama, or anything else — it's just
103
123
  "messages + tools in, message-or-tool-calls out."
104
124
 
125
+ Complete, typechecked adapters are in [`examples/openai.ts`](examples/openai.ts)
126
+ and [`examples/anthropic.ts`](examples/anthropic.ts) — the same interface mapped
127
+ onto two very different APIs — plus a runnable
128
+ [`examples/weather-agent.ts`](examples/weather-agent.ts).
129
+
105
130
  ## Limitations
106
131
 
107
132
  Deliberately small. Worth knowing:
package/dist/index.cjs CHANGED
@@ -43,6 +43,9 @@ var Agent = class {
43
43
  toolSpecs;
44
44
  system;
45
45
  maxSteps;
46
+ onStep;
47
+ onToolCall;
48
+ onToolResult;
46
49
  constructor(options) {
47
50
  const tools = options.tools ?? [];
48
51
  this.provider = options.provider;
@@ -50,25 +53,31 @@ var Agent = class {
50
53
  this.toolSpecs = tools.map(({ name, description, parameters }) => ({ name, description, parameters }));
51
54
  this.system = options.system;
52
55
  this.maxSteps = options.maxSteps ?? 10;
56
+ this.onStep = options.onStep;
57
+ this.onToolCall = options.onToolCall;
58
+ this.onToolResult = options.onToolResult;
53
59
  }
54
60
  async run(prompt) {
55
61
  const messages = [];
56
62
  if (this.system !== void 0) messages.push({ role: "system", content: this.system });
57
63
  messages.push({ role: "user", content: prompt });
58
64
  for (let step = 1; step <= this.maxSteps; step++) {
65
+ await this.onStep?.(step);
59
66
  const response = await this.provider.complete({ messages, tools: this.toolSpecs });
60
67
  if (response.kind === "message") {
61
68
  messages.push({ role: "assistant", content: response.content });
62
69
  return { text: response.content, steps: step, messages };
63
70
  }
64
71
  messages.push({ role: "assistant", content: null, toolCalls: response.toolCalls });
65
- for (const call of response.toolCalls) {
66
- messages.push({
67
- role: "tool",
68
- toolCallId: call.id,
69
- name: call.name,
70
- content: await this.runTool(call)
71
- });
72
+ const results = await Promise.all(
73
+ response.toolCalls.map(async (call) => {
74
+ await this.onToolCall?.(call);
75
+ return { call, output: await this.runTool(call) };
76
+ })
77
+ );
78
+ for (const { call, output } of results) {
79
+ await this.onToolResult?.(call, output);
80
+ messages.push({ role: "tool", toolCallId: call.id, name: call.name, content: output });
72
81
  }
73
82
  }
74
83
  throw new MaxStepsExceededError(this.maxSteps, messages);
package/dist/index.d.cts CHANGED
@@ -61,6 +61,12 @@ interface AgentOptions {
61
61
  system?: string;
62
62
  /** Max provider round-trips before giving up. Default 10. */
63
63
  maxSteps?: number;
64
+ /** Called before each provider round-trip (1-indexed). */
65
+ onStep?: (step: number) => void | Promise<void>;
66
+ /** Called when the model requests a tool, before it runs. */
67
+ onToolCall?: (call: ToolCall) => void | Promise<void>;
68
+ /** Called after a tool runs, with its output (or error text). */
69
+ onToolResult?: (call: ToolCall, output: string) => void | Promise<void>;
64
70
  }
65
71
  interface AgentResult {
66
72
  /** The assistant's final text answer. */
@@ -77,7 +83,8 @@ declare class MaxStepsExceededError extends Error {
77
83
  }
78
84
  /**
79
85
  * The whole agent: ask the provider, run any tools it calls, feed the results
80
- * back, repeat until it returns a final answer (or maxSteps is hit).
86
+ * back, repeat until it returns a final answer (or maxSteps is hit). Pass the
87
+ * optional hooks to watch each step go by.
81
88
  */
82
89
  declare class Agent {
83
90
  private readonly provider;
@@ -85,6 +92,9 @@ declare class Agent {
85
92
  private readonly toolSpecs;
86
93
  private readonly system?;
87
94
  private readonly maxSteps;
95
+ private readonly onStep?;
96
+ private readonly onToolCall?;
97
+ private readonly onToolResult?;
88
98
  constructor(options: AgentOptions);
89
99
  run(prompt: string): Promise<AgentResult>;
90
100
  /** Run one tool. Unknown tools and thrown errors come back as text the model can recover from. */
package/dist/index.d.ts CHANGED
@@ -61,6 +61,12 @@ interface AgentOptions {
61
61
  system?: string;
62
62
  /** Max provider round-trips before giving up. Default 10. */
63
63
  maxSteps?: number;
64
+ /** Called before each provider round-trip (1-indexed). */
65
+ onStep?: (step: number) => void | Promise<void>;
66
+ /** Called when the model requests a tool, before it runs. */
67
+ onToolCall?: (call: ToolCall) => void | Promise<void>;
68
+ /** Called after a tool runs, with its output (or error text). */
69
+ onToolResult?: (call: ToolCall, output: string) => void | Promise<void>;
64
70
  }
65
71
  interface AgentResult {
66
72
  /** The assistant's final text answer. */
@@ -77,7 +83,8 @@ declare class MaxStepsExceededError extends Error {
77
83
  }
78
84
  /**
79
85
  * The whole agent: ask the provider, run any tools it calls, feed the results
80
- * back, repeat until it returns a final answer (or maxSteps is hit).
86
+ * back, repeat until it returns a final answer (or maxSteps is hit). Pass the
87
+ * optional hooks to watch each step go by.
81
88
  */
82
89
  declare class Agent {
83
90
  private readonly provider;
@@ -85,6 +92,9 @@ declare class Agent {
85
92
  private readonly toolSpecs;
86
93
  private readonly system?;
87
94
  private readonly maxSteps;
95
+ private readonly onStep?;
96
+ private readonly onToolCall?;
97
+ private readonly onToolResult?;
88
98
  constructor(options: AgentOptions);
89
99
  run(prompt: string): Promise<AgentResult>;
90
100
  /** Run one tool. Unknown tools and thrown errors come back as text the model can recover from. */
package/dist/index.js CHANGED
@@ -15,6 +15,9 @@ var Agent = class {
15
15
  toolSpecs;
16
16
  system;
17
17
  maxSteps;
18
+ onStep;
19
+ onToolCall;
20
+ onToolResult;
18
21
  constructor(options) {
19
22
  const tools = options.tools ?? [];
20
23
  this.provider = options.provider;
@@ -22,25 +25,31 @@ var Agent = class {
22
25
  this.toolSpecs = tools.map(({ name, description, parameters }) => ({ name, description, parameters }));
23
26
  this.system = options.system;
24
27
  this.maxSteps = options.maxSteps ?? 10;
28
+ this.onStep = options.onStep;
29
+ this.onToolCall = options.onToolCall;
30
+ this.onToolResult = options.onToolResult;
25
31
  }
26
32
  async run(prompt) {
27
33
  const messages = [];
28
34
  if (this.system !== void 0) messages.push({ role: "system", content: this.system });
29
35
  messages.push({ role: "user", content: prompt });
30
36
  for (let step = 1; step <= this.maxSteps; step++) {
37
+ await this.onStep?.(step);
31
38
  const response = await this.provider.complete({ messages, tools: this.toolSpecs });
32
39
  if (response.kind === "message") {
33
40
  messages.push({ role: "assistant", content: response.content });
34
41
  return { text: response.content, steps: step, messages };
35
42
  }
36
43
  messages.push({ role: "assistant", content: null, toolCalls: response.toolCalls });
37
- for (const call of response.toolCalls) {
38
- messages.push({
39
- role: "tool",
40
- toolCallId: call.id,
41
- name: call.name,
42
- content: await this.runTool(call)
43
- });
44
+ const results = await Promise.all(
45
+ response.toolCalls.map(async (call) => {
46
+ await this.onToolCall?.(call);
47
+ return { call, output: await this.runTool(call) };
48
+ })
49
+ );
50
+ for (const { call, output } of results) {
51
+ await this.onToolResult?.(call, output);
52
+ messages.push({ role: "tool", toolCallId: call.id, name: call.name, content: output });
44
53
  }
45
54
  }
46
55
  throw new MaxStepsExceededError(this.maxSteps, messages);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narimangardi/agent-loop",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "A tiny, zero-dependency, provider-agnostic agent tool-loop for TypeScript. Build your own AI agent in ~100 lines.",
5
5
  "keywords": ["ai", "agent", "tool-use", "function-calling", "llm", "typescript"],
6
6
  "license": "MIT",
@@ -24,9 +24,11 @@
24
24
  "build": "tsup src/index.ts --format esm,cjs --dts --clean",
25
25
  "test": "vitest run",
26
26
  "typecheck": "tsc --noEmit",
27
+ "typecheck:examples": "tsc -p tsconfig.examples.json",
27
28
  "prepublishOnly": "npm run build"
28
29
  },
29
30
  "devDependencies": {
31
+ "@types/node": "^22.0.0",
30
32
  "tsup": "^8.0.0",
31
33
  "typescript": "^5.6.0",
32
34
  "vitest": "^3.0.0"