agent-pulse 1.1.0 → 1.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
@@ -49,7 +49,6 @@ const agentWithKey = new Agent({
49
49
  provider: new openAI('gpt-4o', process.env.CUSTOM_KEY_NAME || 'your-key'),
50
50
  system: 'You are a helpful assistant.'
51
51
  });
52
- ```
53
52
 
54
53
  // Real-time typing effect
55
54
  agent.on('token', (chunk) => {
@@ -132,7 +131,8 @@ import { Agent, OpenAIProvider, GoogleProvider, GrokProvider } from 'agent-pulse
132
131
  | `files` | string[] | Array of file paths or content strings to include in context. |
133
132
  | `tools` | Array | List of executable tools with Zod schemas. |
134
133
  | `output_schema` | ZodSchema | Enforce structured JSON output (if supported by provider). |
135
- | `saveFunction` | function | Async function to persist messages (`(msg) => Promise<void>`). |
134
+ | `saveFunction` | function | Async function to persist messages (`(msg: AgentMessage) => Promise<void>`). |
135
+ | `max_tool_iterations` | number | Max iterations for tool call loops (default: 1). |
136
136
 
137
137
  ## Events
138
138
 
@@ -154,7 +154,8 @@ The `response` event and the `agent.run()` promise resolve to a standardized `Ag
154
154
 
155
155
  ```typescript
156
156
  {
157
- content: string | object, // The Markdown text or parsed JSON
157
+ content: string | object, // The Markdown text, parsed JSON, or tool result (if iterations=1)
158
+ message?: string, // The original LLM text response (useful when a tool is also called)
158
159
  usage: {
159
160
  input_tokens: number,
160
161
  output_tokens: number,
@@ -167,6 +168,9 @@ The `response` event and the `agent.run()` promise resolve to a standardized `Ag
167
168
  }
168
169
  ```
169
170
 
171
+ > [!NOTE]
172
+ > If an LLM responds with both text and a tool call (common in Gemini), `content` stays consistent with legacy behavior (holding the tool result), while the new `message` field preserves the original LLM text.
173
+
170
174
  You can access token usage stats from the `usage` property.
171
175
 
172
176
  ## Error Codes
@@ -226,7 +230,18 @@ if (result.content?.type === 'INTENT_COMPLETE') {
226
230
  }
227
231
  ```
228
232
 
229
- #### Option B: Events (Side Effects)
233
+ #### Option B: Handling Text + Tool (Gemini Style)
234
+ When using models like Gemini that often provide a text explanation *and* a tool call in one turn, use the `message` field to access the text.
235
+
236
+ ```typescript
237
+ const result = await agent.run("Tell me a joke and then get the weather.");
238
+
239
+ // If weatherTool was called:
240
+ console.log(result.message); // "Sure! Here's a joke: ... Now, let me get the weather for you."
241
+ console.log(result.content); // { temp: 20, unit: 'celsius' } (The tool result)
242
+ ```
243
+
244
+ #### Option C: Events (Side Effects)
230
245
  Best for logging, UI updates, or real-time monitoring.
231
246
 
232
247
  ```typescript
@@ -238,11 +253,85 @@ agent.on('tool_start', (evt) => {
238
253
  agent.on('tool_end', (evt) => {
239
254
  console.log(`[UI] ✔️ Tool finished:`, evt.result);
240
255
  });
256
+ ```
257
+
258
+
259
+ ### 2. Multi-Turn Tool Calling (Autonomous Agent)
260
+
261
+ By setting `max_tool_iterations`, the agent can autonomously call tools, receive results, and reason until it has a final answer.
262
+
263
+ ```typescript
264
+ const agent = new Agent({
265
+ name: 'researcher',
266
+ provider: new openAI('gpt-4o'),
267
+ tools: [weatherTool, searchTool],
268
+ max_tool_iterations: 5 // Allow up to 5 loop turns
269
+ });
241
270
 
242
- await agent.run("I want to go to Tokyo.");
271
+ const result = await agent.run("What's the weather like in London today?");
272
+ // Agent calls weatherTool -> receives result -> reasons -> returns final text.
273
+ ```
274
+
275
+ ### 3. Manual Tool Responses (Client-Side Loops)
276
+
277
+ If your agent is running on a server but needs the **client** to perform an action (like opening a modal or reading a local file), you can return a UI instruction and then send the result back in the next `run()` call.
278
+
279
+ #### OpenAI Example
280
+ ```typescript
281
+ const agent = new Agent({
282
+ name: 'account-mgr',
283
+ provider: new openAI('gpt-4o'),
284
+ tools: [requestConfirmationTool]
285
+ });
286
+
287
+ // 1. First Run: Agent requests a tool call
288
+ const res = await agent.run("Delete my account ACC-123");
289
+
290
+ // 2. Client handles the tool call manually (e.g., shows a modal)
291
+ const confirmed = await myUi.showModal(res.content.payload);
292
+
293
+ // 3. Second Run: Send the result back to the agent
294
+ const final = await agent.run([
295
+ { role: 'user', content: "Delete my account ACC-123" },
296
+ {
297
+ role: 'assistant',
298
+ content: null,
299
+ tool_calls: [{ id: 'call_123', name: 'request_delete', arguments: { id: 'ACC-123' } }]
300
+ },
301
+ {
302
+ role: 'tool',
303
+ tool_call_id: 'call_123',
304
+ content: JSON.stringify({ confirmed })
305
+ }
306
+ ]);
307
+ ```
308
+
309
+ #### Gemini Example
310
+ The same pattern works for Gemini! While Google's API uses a different internal format (`functionResponse`), **Agent Pulse** handles the mapping for you. Simply use the standardized `tool` role:
311
+
312
+ ```typescript
313
+ const agent = new Agent({
314
+ name: 'gemini-agent',
315
+ provider: new google('gemini-1.5-flash')
316
+ });
317
+
318
+ const final = await agent.run([
319
+ { role: 'user', content: "Search for weather" },
320
+ {
321
+ role: 'assistant',
322
+ content: null,
323
+ tool_calls: [{ id: 'call_abc', name: 'get_weather', arguments: { loc: 'London' } }]
324
+ },
325
+ {
326
+ role: 'tool',
327
+ tool_call_id: 'call_abc',
328
+ name: 'get_weather', // Required for Gemini
329
+ content: JSON.stringify({ temp: 20 })
330
+ }
331
+ ]);
243
332
  ```
244
333
 
245
- ### 2. File Input
334
+ ### 4. File Input
246
335
 
247
336
  Pass file content context to the agent.
248
337
 
@@ -257,7 +346,7 @@ const agent = new Agent({
257
346
  });
258
347
  ```
259
348
 
260
- ### 3. Structured Output (JSON)
349
+ ### 4. Structured Output (JSON)
261
350
 
262
351
  Enforce a specific JSON schema for the response.
263
352
 
@@ -285,7 +374,7 @@ agent.on('response', (result) => {
285
374
  await agent.run("How do I make pancakes?");
286
375
  ```
287
376
 
288
- ### 4. Server-Side Streaming (SSE)
377
+ ### 5. Server-Side Streaming (SSE)
289
378
 
290
379
  Bridge agent events to a Server-Sent Events stream for frontend consumption (e.g., in Express).
291
380
 
@@ -311,7 +400,7 @@ app.get('/chat', async (req, res) => {
311
400
  });
312
401
  ```
313
402
 
314
- ### 5. Google Search Grounding
403
+ ### 6. Google Search Grounding
315
404
 
316
405
  Enable real-time search results and citations with Google models.
317
406
 
@@ -359,7 +448,7 @@ const agent = new Agent({
359
448
  provider: new MyProvider('my-model')
360
449
  });
361
450
  ```
362
- # To locally link the package
451
+ ## To locally link the package
363
452
 
364
453
  1. Run `npm link` in the agent-pulse directory
365
454
  2. Run `npm link agent-pulse --legacy-peer-deps` in your project directory
package/dist/agent.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { EventEmitter } from 'events';
2
- import { AgentConfig, AgentResponse } from './types';
2
+ import { AgentConfig, AgentResponse, AgentMessage } from './types';
3
3
  export declare class Agent extends EventEmitter {
4
4
  private config;
5
5
  private provider;
6
6
  constructor(config: AgentConfig);
7
- run(inputContext: string | any[]): Promise<AgentResponse>;
7
+ run(inputContext: string | AgentMessage[]): Promise<AgentResponse>;
8
8
  }
package/dist/agent.js CHANGED
@@ -11,64 +11,96 @@ class Agent extends events_1.EventEmitter {
11
11
  async run(inputContext) {
12
12
  this.emit('start', { timestamp: Date.now(), inputContext });
13
13
  const startTime = Date.now();
14
- // Persistence: Save User Message if input is a string (new message)
14
+ // 1. Initialize message history
15
+ let messages = [];
16
+ if (Array.isArray(inputContext)) {
17
+ messages = [...inputContext];
18
+ }
19
+ else {
20
+ const content = typeof inputContext === 'string' ? inputContext : String(inputContext);
21
+ const userContent = this.config.prompt ? `${this.config.prompt}\n\n${content}` : content;
22
+ messages.push({ role: 'user', content: userContent });
23
+ }
24
+ // Persistence: Save initial User Message if it's new
15
25
  if (typeof inputContext === 'string' && this.config.saveFunction) {
16
26
  try {
17
- await this.config.saveFunction({ role: 'user', content: inputContext });
27
+ const lastMsg = messages[messages.length - 1];
28
+ await this.config.saveFunction(lastMsg);
18
29
  }
19
30
  catch (err) {
20
31
  console.error("Failed to save user message:", err);
21
32
  }
22
33
  }
23
- // Normalize input context
24
- let finalPrompt;
25
- if (Array.isArray(inputContext)) {
26
- finalPrompt = inputContext;
27
- }
28
- else {
29
- const prompt = typeof inputContext === 'string' ? inputContext : String(inputContext);
30
- finalPrompt = this.config.prompt ? `${this.config.prompt}\n\n${prompt}` : prompt;
31
- }
34
+ let iterations = 0;
35
+ const maxIterations = this.config.max_tool_iterations || 1;
36
+ let lastResponse = null;
32
37
  try {
33
- const response = await this.provider.generate(this.config.system, finalPrompt, this.config.files, this.config.tools, this.config.config, this.config.output_schema, (token) => this.emit('token', token));
34
- // Handle Tool Execution
35
- if (response.tool_calls && this.config.tools) {
36
- for (const call of response.tool_calls) {
37
- const tool = this.config.tools.find(t => t.name === call.name);
38
- if (tool) {
39
- try {
40
- // Execute the tool
41
- // Note: In this lightweight framework, we return the tool result as the final content.
42
- // This supports the "Intent Detection" pattern where the goal is to trigger an action, not just chat.
43
- this.emit('tool_start', { tool: tool.name, arguments: call.arguments });
44
- const result = await tool.execute(call.arguments);
45
- this.emit('tool_end', { tool: tool.name, result });
46
- response.content = result;
47
- }
48
- catch (e) {
49
- console.error(`Error executing tool ${tool.name}:`, e);
50
- }
51
- }
38
+ while (iterations < maxIterations) {
39
+ iterations++;
40
+ const response = await this.provider.generate(this.config.system, messages, this.config.files, this.config.tools, this.config.config, this.config.output_schema, (token) => this.emit('token', token));
41
+ lastResponse = response;
42
+ // Capture the original text as the "message" (LLM's primary text response)
43
+ if (typeof response.content === 'string') {
44
+ lastResponse.message = response.content;
52
45
  }
53
- }
54
- // Add latency to meta
55
- response.meta.latency_ms = Date.now() - startTime;
56
- // Persistence: Save Assistant Response
57
- if (this.config.saveFunction) {
58
- try {
59
- await this.config.saveFunction({
46
+ // Handle Tool Execution
47
+ if (response.tool_calls && this.config.tools) {
48
+ // Add Assistant's tool call message to history
49
+ const assistantMsg = {
60
50
  role: 'assistant',
61
- content: response.content,
62
- usage: response.usage,
63
- meta: response.meta
64
- });
65
- }
66
- catch (err) {
67
- console.error("Failed to save assistant response:", err);
51
+ content: response.content || null,
52
+ tool_calls: response.tool_calls
53
+ };
54
+ messages.push(assistantMsg);
55
+ if (this.config.saveFunction) {
56
+ await this.config.saveFunction(assistantMsg);
57
+ }
58
+ let lastToolResult = null;
59
+ for (const call of response.tool_calls) {
60
+ const tool = this.config.tools.find(t => t.name === call.name);
61
+ if (tool) {
62
+ try {
63
+ this.emit('tool_start', { tool: tool.name, arguments: call.arguments });
64
+ const result = await tool.execute(call.arguments);
65
+ this.emit('tool_end', { tool: tool.name, result });
66
+ lastToolResult = result;
67
+ const toolMsg = {
68
+ role: 'tool',
69
+ tool_call_id: call.id,
70
+ name: tool.name,
71
+ content: typeof result === 'string' ? result : JSON.stringify(result)
72
+ };
73
+ messages.push(toolMsg);
74
+ if (this.config.saveFunction) {
75
+ await this.config.saveFunction(toolMsg);
76
+ }
77
+ }
78
+ catch (e) {
79
+ console.error(`Error executing tool ${tool.name}:`, e);
80
+ // Add error as tool result so LLM knows what happened
81
+ const errorMsg = {
82
+ role: 'tool',
83
+ tool_call_id: call.id,
84
+ name: tool.name,
85
+ content: `Error: ${e instanceof Error ? e.message : String(e)}`
86
+ };
87
+ messages.push(errorMsg);
88
+ }
89
+ }
90
+ }
91
+ // For the "Intent Detection" pattern (maxIterations = 1),
92
+ // we return the last tool result as the content to preserve legacy behavior.
93
+ if (maxIterations === 1 && lastToolResult !== null) {
94
+ lastResponse.content = lastToolResult;
95
+ }
96
+ // If we have more iterations, continue the loop
97
+ if (iterations < maxIterations) {
98
+ continue;
99
+ }
68
100
  }
101
+ // If no tool calls OR we reached limit, break and return
102
+ break;
69
103
  }
70
- this.emit('response', response);
71
- return response;
72
104
  }
73
105
  catch (error) {
74
106
  this.emit('error', {
@@ -78,6 +110,27 @@ class Agent extends events_1.EventEmitter {
78
110
  });
79
111
  throw error;
80
112
  }
113
+ if (!lastResponse) {
114
+ throw new Error("Agent failed to generate a response");
115
+ }
116
+ // Add latency to meta
117
+ lastResponse.meta.latency_ms = Date.now() - startTime;
118
+ // Persistence: Save Final Assistant Response
119
+ if (this.config.saveFunction) {
120
+ try {
121
+ await this.config.saveFunction({
122
+ role: 'assistant',
123
+ content: lastResponse.content,
124
+ usage: lastResponse.usage,
125
+ meta: lastResponse.meta
126
+ });
127
+ }
128
+ catch (err) {
129
+ console.error("Failed to save assistant response:", err);
130
+ }
131
+ }
132
+ this.emit('response', lastResponse);
133
+ return lastResponse;
81
134
  }
82
135
  }
83
136
  exports.Agent = Agent;
@@ -1,8 +1,8 @@
1
- import { LLMProvider, AgentTool, AgentResponse } from '../types';
1
+ import { LLMProvider, AgentTool, AgentResponse, AgentMessage } from '../types';
2
2
  import { z } from 'zod';
3
3
  export declare class GoogleProvider implements LLMProvider {
4
4
  private client;
5
5
  private model;
6
6
  constructor(model: string, apiKey?: string);
7
- generate(system: string | undefined, prompt: string | any[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
7
+ generate(system: string | undefined, prompt: string | AgentMessage[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
8
8
  }
@@ -59,10 +59,35 @@ class GoogleProvider {
59
59
  let contents;
60
60
  if (Array.isArray(prompt)) {
61
61
  // Mapping standardized history to Google's format
62
- contents = prompt.map(m => ({
63
- role: m.role === 'assistant' ? 'model' : 'user',
64
- parts: [{ text: m.content }]
65
- }));
62
+ contents = prompt.map(m => {
63
+ const parts = [];
64
+ if (m.role === 'tool') {
65
+ parts.push({
66
+ functionResponse: {
67
+ name: m.name,
68
+ response: typeof m.content === 'string' ? JSON.parse(m.content) : m.content
69
+ }
70
+ });
71
+ return { role: 'user', parts }; // SDK v2 uses 'user' for function response
72
+ }
73
+ if (m.tool_calls) {
74
+ for (const tc of m.tool_calls) {
75
+ parts.push({
76
+ functionCall: {
77
+ name: tc.name,
78
+ args: tc.arguments
79
+ }
80
+ });
81
+ }
82
+ }
83
+ if (m.content) {
84
+ parts.push({ text: m.content });
85
+ }
86
+ return {
87
+ role: m.role === 'assistant' ? 'model' : 'user',
88
+ parts
89
+ };
90
+ });
66
91
  // Handle files by appending to the last message if it's from user
67
92
  if (files && files.length > 0) {
68
93
  const lastMsg = contents[contents.length - 1];
@@ -1,8 +1,8 @@
1
- import { LLMProvider, AgentTool, AgentResponse } from '../types';
1
+ import { LLMProvider, AgentTool, AgentResponse, AgentMessage } from '../types';
2
2
  import { z } from 'zod';
3
3
  export declare class GrokProvider implements LLMProvider {
4
4
  private client;
5
5
  private model;
6
6
  constructor(model: string, apiKey?: string);
7
- generate(system: string | undefined, prompt: string | any[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
7
+ generate(system: string | undefined, prompt: string | AgentMessage[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
8
8
  }
@@ -24,9 +24,33 @@ class GrokProvider {
24
24
  messages.push({ role: 'system', content: system });
25
25
  }
26
26
  if (Array.isArray(prompt)) {
27
- // If prompt is an array, we assume it's a list of messages (history)
28
- // We append them directly.
29
- messages.push(...prompt);
27
+ // Mapping AgentMessage to OpenAI messages (Grok is compatible)
28
+ for (const msg of prompt) {
29
+ if (msg.role === 'user') {
30
+ messages.push({ role: 'user', content: msg.content });
31
+ }
32
+ else if (msg.role === 'assistant') {
33
+ messages.push({
34
+ role: 'assistant',
35
+ content: msg.content || null,
36
+ tool_calls: msg.tool_calls?.map(tc => ({
37
+ id: tc.id,
38
+ type: 'function',
39
+ function: {
40
+ name: tc.name,
41
+ arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments)
42
+ }
43
+ }))
44
+ });
45
+ }
46
+ else if (msg.role === 'tool') {
47
+ messages.push({
48
+ role: 'tool',
49
+ tool_call_id: msg.tool_call_id,
50
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
51
+ });
52
+ }
53
+ }
30
54
  }
31
55
  else {
32
56
  // Handle files - read markdown files and inject into prompt
@@ -1,8 +1,8 @@
1
- import { LLMProvider, AgentTool, AgentResponse } from '../types';
1
+ import { LLMProvider, AgentTool, AgentResponse, AgentMessage } from '../types';
2
2
  import { z } from 'zod';
3
3
  export declare class OpenAIProvider implements LLMProvider {
4
4
  private client;
5
5
  private model;
6
6
  constructor(model: string, apiKey?: string);
7
- generate(system: string | undefined, prompt: string | any[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
7
+ generate(system: string | undefined, prompt: string | AgentMessage[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
8
8
  }
@@ -22,9 +22,33 @@ class OpenAIProvider {
22
22
  messages.push({ role: 'system', content: system });
23
23
  }
24
24
  if (Array.isArray(prompt)) {
25
- // If prompt is an array, we assume it's a list of messages (history)
26
- // We append them directly.
27
- messages.push(...prompt);
25
+ // Mapping AgentMessage to OpenAI messages
26
+ for (const msg of prompt) {
27
+ if (msg.role === 'user') {
28
+ messages.push({ role: 'user', content: msg.content });
29
+ }
30
+ else if (msg.role === 'assistant') {
31
+ messages.push({
32
+ role: 'assistant',
33
+ content: msg.content || null,
34
+ tool_calls: msg.tool_calls?.map(tc => ({
35
+ id: tc.id,
36
+ type: 'function',
37
+ function: {
38
+ name: tc.name,
39
+ arguments: typeof tc.arguments === 'string' ? tc.arguments : JSON.stringify(tc.arguments)
40
+ }
41
+ }))
42
+ });
43
+ }
44
+ else if (msg.role === 'tool') {
45
+ messages.push({
46
+ role: 'tool',
47
+ tool_call_id: msg.tool_call_id,
48
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
49
+ });
50
+ }
51
+ }
28
52
  }
29
53
  else {
30
54
  // Handle files - read markdown files and inject into prompt
package/dist/types.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  import { z } from 'zod';
2
+ export interface AgentMessage {
3
+ role: 'system' | 'user' | 'assistant' | 'tool';
4
+ content: string | any;
5
+ name?: string;
6
+ tool_calls?: any[];
7
+ tool_call_id?: string;
8
+ usage?: any;
9
+ meta?: any;
10
+ }
2
11
  export interface AgentConfig {
3
12
  name: string;
4
13
  provider: LLMProvider;
@@ -8,7 +17,8 @@ export interface AgentConfig {
8
17
  config?: Record<string, any>;
9
18
  tools?: AgentTool[];
10
19
  output_schema?: z.ZodType<any>;
11
- saveFunction?: (message: any) => Promise<void> | void;
20
+ saveFunction?: (message: AgentMessage) => Promise<void> | void;
21
+ max_tool_iterations?: number;
12
22
  }
13
23
  export interface AgentTool {
14
24
  name: string;
@@ -19,6 +29,7 @@ export interface AgentTool {
19
29
  export interface AgentResponse {
20
30
  content: string | object;
21
31
  tool_calls?: any[];
32
+ message?: string;
22
33
  usage: {
23
34
  input_tokens: number;
24
35
  output_tokens: number;
@@ -41,5 +52,5 @@ export interface AgentError {
41
52
  details?: any;
42
53
  }
43
54
  export interface LLMProvider {
44
- generate(system: string | undefined, prompt: string | any[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
55
+ generate(system: string | undefined, prompt: string | AgentMessage[], files: string[] | undefined, tools: AgentTool[] | undefined, config: Record<string, any> | undefined, output_schema: z.ZodType<any> | undefined, onToken: (token: string) => void): Promise<AgentResponse>;
45
56
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-pulse",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "A lightweight, agentic AI framework for JavaScript/TypeScript",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",