agentic-ai-framework 1.0.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 +626 -0
- package/index.js +84 -0
- package/package.json +38 -0
- package/src/agent/Agent.js +278 -0
- package/src/agent/AgentConfig.js +88 -0
- package/src/agent/AgentRunner.js +256 -0
- package/src/llm/BaseLLMProvider.js +78 -0
- package/src/llm/LLMRouter.js +80 -0
- package/src/llm/providers/ClaudeProvider.js +307 -0
- package/src/llm/providers/GrokProvider.js +208 -0
- package/src/llm/providers/OpenAIProvider.js +194 -0
- package/src/memory/FileStore.js +102 -0
- package/src/memory/MemoryManager.js +55 -0
- package/src/memory/SessionMemory.js +124 -0
- package/src/prompt/PromptBuilder.js +95 -0
- package/src/prompt/PromptTemplate.js +58 -0
- package/src/team/AgentTeam.js +308 -0
- package/src/team/TeamResult.js +60 -0
- package/src/tool/Tool.js +138 -0
- package/src/tool/ToolRegistry.js +81 -0
- package/src/utils/errors.js +46 -0
- package/src/utils/logger.js +33 -0
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentic-ai-framework",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Reusable agentic framework with session memory, tool calling, CoT, and multi-agent team support",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./providers/claude": "./src/llm/providers/ClaudeProvider.js",
|
|
10
|
+
"./providers/grok": "./src/llm/providers/GrokProvider.js",
|
|
11
|
+
"./providers/openai": "./src/llm/providers/OpenAIProvider.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"src/",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20.0.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"pino": "^9.6.0",
|
|
23
|
+
"pino-pretty": "^13.1.3",
|
|
24
|
+
"zod": "^3.24.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"dotenv": "^16.4.7"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"agent",
|
|
31
|
+
"llm",
|
|
32
|
+
"ai",
|
|
33
|
+
"framework",
|
|
34
|
+
"tool-calling",
|
|
35
|
+
"memory"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { AgentConfig } from './AgentConfig.js';
|
|
3
|
+
import { AgentRunner } from './AgentRunner.js';
|
|
4
|
+
import { SessionMemory } from '../memory/SessionMemory.js';
|
|
5
|
+
import { MemoryManager } from '../memory/MemoryManager.js';
|
|
6
|
+
import { ToolRegistry } from '../tool/ToolRegistry.js';
|
|
7
|
+
import { LLMRouter } from '../llm/LLMRouter.js';
|
|
8
|
+
import { PromptBuilder } from '../prompt/PromptBuilder.js';
|
|
9
|
+
import { AgentError } from '../utils/errors.js';
|
|
10
|
+
import { createLogger } from '../utils/logger.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The core Agent class.
|
|
14
|
+
*
|
|
15
|
+
* An Agent owns:
|
|
16
|
+
* - An LLM provider (via LLMRouter)
|
|
17
|
+
* - A PromptBuilder (template + optional CoT injection)
|
|
18
|
+
* - A SessionMemory (conversation history + working context)
|
|
19
|
+
* - A ToolRegistry (registered tools callable by the LLM)
|
|
20
|
+
* - An AgentRunner (the tool-calling loop engine)
|
|
21
|
+
*
|
|
22
|
+
* Concurrency note:
|
|
23
|
+
* Create a FRESH Agent instance per request in backend handlers.
|
|
24
|
+
* Each instance has its own SessionMemory — no shared state between requests.
|
|
25
|
+
* See examples/simple-agent.js for the recommended usage pattern.
|
|
26
|
+
*/
|
|
27
|
+
export class Agent {
|
|
28
|
+
/**
|
|
29
|
+
* @param {AgentConfig} config
|
|
30
|
+
*/
|
|
31
|
+
constructor(config) {
|
|
32
|
+
if (!(config instanceof AgentConfig)) {
|
|
33
|
+
throw new AgentError('Agent: config must be an AgentConfig instance');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.name = config.name;
|
|
38
|
+
this.description = config.description ?? '';
|
|
39
|
+
|
|
40
|
+
this._log = createLogger('Agent', { agent: this.name });
|
|
41
|
+
|
|
42
|
+
// LLM provider (cached globally by LLMRouter)
|
|
43
|
+
this._llm = LLMRouter.get(config.provider, config.model ?? null, config.apiKey);
|
|
44
|
+
|
|
45
|
+
// Memory
|
|
46
|
+
this._memory = new SessionMemory({ maxMessages: config.maxHistoryMessages });
|
|
47
|
+
this._memoryManager = config.persistenceDir
|
|
48
|
+
? new MemoryManager({ dir: config.persistenceDir })
|
|
49
|
+
: null;
|
|
50
|
+
|
|
51
|
+
// Tools
|
|
52
|
+
this._toolRegistry = new ToolRegistry();
|
|
53
|
+
|
|
54
|
+
// Prompt builder
|
|
55
|
+
this._promptBuilder = new PromptBuilder({
|
|
56
|
+
systemPromptTemplate: config.systemPromptTemplate,
|
|
57
|
+
systemPromptFile: config.systemPromptFile,
|
|
58
|
+
cotEnabled: config.chainOfThought,
|
|
59
|
+
cotMode: config.cotMode,
|
|
60
|
+
cotStyle: config.cotStyle,
|
|
61
|
+
cotCustomInstructions: config.cotCustomInstructions,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Runner (tool-calling loop engine)
|
|
65
|
+
this._runner = new AgentRunner({
|
|
66
|
+
llm: this._llm,
|
|
67
|
+
toolRegistry: this._toolRegistry,
|
|
68
|
+
maxIterations: config.maxToolIterations,
|
|
69
|
+
temperature: config.temperature,
|
|
70
|
+
maxTokens: config.maxTokens,
|
|
71
|
+
outputSchema: config.outputSchema,
|
|
72
|
+
reflectMode: this._promptBuilder.isReflectMode,
|
|
73
|
+
agentName: this.name,
|
|
74
|
+
loopTimeoutMs: config.loopTimeoutMs,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
this._sessionId = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Tool registration ─────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Register a tool the agent can call. Chainable.
|
|
84
|
+
* @param {import('../tool/Tool.js').Tool} tool
|
|
85
|
+
* @returns {Agent}
|
|
86
|
+
*/
|
|
87
|
+
registerTool(tool) {
|
|
88
|
+
this._toolRegistry.register(tool);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register multiple tools at once. Chainable.
|
|
94
|
+
* @param {import('../tool/Tool.js').Tool[]} tools
|
|
95
|
+
* @returns {Agent}
|
|
96
|
+
*/
|
|
97
|
+
registerTools(tools) {
|
|
98
|
+
for (const tool of tools) this._toolRegistry.register(tool);
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get the tool registry. Used by AgentTeam to register delegation tools.
|
|
104
|
+
* @returns {import('../tool/ToolRegistry.js').ToolRegistry}
|
|
105
|
+
*/
|
|
106
|
+
getToolRegistry() {
|
|
107
|
+
return this._toolRegistry;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Session lifecycle ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start a session. If sessionId is provided and a persisted session exists,
|
|
114
|
+
* its history and context are restored. If no sessionId is provided, a new
|
|
115
|
+
* UUID is generated.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} [sessionId] - Existing session ID to resume, or omit to create new
|
|
118
|
+
* @returns {Promise<string>} The active sessionId
|
|
119
|
+
*/
|
|
120
|
+
async startSession(sessionId) {
|
|
121
|
+
this._sessionId = sessionId ?? randomUUID();
|
|
122
|
+
|
|
123
|
+
if (this._memoryManager && sessionId) {
|
|
124
|
+
const snapshot = await this._memoryManager.load(sessionId);
|
|
125
|
+
if (snapshot) {
|
|
126
|
+
this._memory.restore(snapshot);
|
|
127
|
+
this._log.info({ sessionId, historyLength: this._memory.historyLength }, 'session restored');
|
|
128
|
+
} else {
|
|
129
|
+
this._log.info({ sessionId }, 'new session started (no prior data)');
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return this._sessionId;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Persist the current session memory to disk.
|
|
138
|
+
* Requires persistenceDir to be configured in AgentConfig.
|
|
139
|
+
* Requires startSession() to have been called first.
|
|
140
|
+
*/
|
|
141
|
+
async saveSession() {
|
|
142
|
+
if (!this._sessionId) {
|
|
143
|
+
throw new AgentError(`${this.name}: call startSession() before saveSession()`);
|
|
144
|
+
}
|
|
145
|
+
if (this._memoryManager) {
|
|
146
|
+
await this._memoryManager.save(this._sessionId, this._memory.snapshot());
|
|
147
|
+
this._log.debug({ sessionId: this._sessionId }, 'session saved');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Clear in-memory state and optionally delete the persisted session file.
|
|
153
|
+
*
|
|
154
|
+
* @param {boolean} [deletePersisted=false] - If true, also delete the JSON file on disk
|
|
155
|
+
*/
|
|
156
|
+
async endSession(deletePersisted = false) {
|
|
157
|
+
if (this._memoryManager && this._sessionId && deletePersisted) {
|
|
158
|
+
await this._memoryManager.delete(this._sessionId);
|
|
159
|
+
this._log.info({ sessionId: this._sessionId }, 'session deleted');
|
|
160
|
+
}
|
|
161
|
+
this._memory.clear();
|
|
162
|
+
this._sessionId = null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Working context ───────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Store a named value in working context. Injected into system prompt as ${key}.
|
|
169
|
+
* Chainable.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} key
|
|
172
|
+
* @param {*} value
|
|
173
|
+
* @returns {Agent}
|
|
174
|
+
*/
|
|
175
|
+
setContext(key, value) {
|
|
176
|
+
this._memory.setContext(key, value);
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Retrieve a context value.
|
|
182
|
+
* @param {string} key
|
|
183
|
+
* @returns {*}
|
|
184
|
+
*/
|
|
185
|
+
getContext(key) {
|
|
186
|
+
return this._memory.getContext(key);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Main execution ────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Run the agent on a user input.
|
|
193
|
+
*
|
|
194
|
+
* The agent:
|
|
195
|
+
* 1. Builds the system prompt (template + context vars + optional CoT block)
|
|
196
|
+
* 2. Assembles messages: [system, ...history, user]
|
|
197
|
+
* 3. Runs the tool-calling loop via AgentRunner
|
|
198
|
+
* 4. Optionally appends the exchange to session history
|
|
199
|
+
*
|
|
200
|
+
* Per-request pattern (stateless, no session needed):
|
|
201
|
+
* const result = await agent.run(userInput, { appendToHistory: false });
|
|
202
|
+
*
|
|
203
|
+
* Stateful pattern (session memory):
|
|
204
|
+
* await agent.startSession('user-123');
|
|
205
|
+
* const result = await agent.run(userInput);
|
|
206
|
+
* await agent.saveSession();
|
|
207
|
+
*
|
|
208
|
+
* @param {string} userInput
|
|
209
|
+
* @param {Object} [opts={}]
|
|
210
|
+
* @param {Record<string, string | number | boolean>} [opts.templateVars={}]
|
|
211
|
+
* Additional variables for system prompt interpolation (merged with context).
|
|
212
|
+
* @param {boolean} [opts.appendToHistory=true]
|
|
213
|
+
* Whether to add this exchange to session memory.
|
|
214
|
+
* Set to false for stateless (per-request) agents.
|
|
215
|
+
* @returns {Promise<import('./AgentRunner.js').AgentResult>}
|
|
216
|
+
*/
|
|
217
|
+
async run(userInput, opts = {}) {
|
|
218
|
+
const { templateVars = {}, appendToHistory = true } = opts;
|
|
219
|
+
|
|
220
|
+
this._log.debug({ userInput: userInput.slice(0, 100) }, 'agent.run()');
|
|
221
|
+
|
|
222
|
+
// Build system prompt (CoT appended here if cotMode='prompt')
|
|
223
|
+
const systemPrompt = await this._promptBuilder.build({
|
|
224
|
+
vars: { ...this._memory.getContextSnapshot(), ...templateVars },
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Assemble messages: system + conversation history + new user message
|
|
228
|
+
const messages = [
|
|
229
|
+
{ role: 'system', content: systemPrompt },
|
|
230
|
+
...this._memory.getHistory(),
|
|
231
|
+
{ role: 'user', content: userInput },
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
// Run the tool-calling loop
|
|
235
|
+
const result = await this._runner.run(messages);
|
|
236
|
+
|
|
237
|
+
// Append exchange to session history (unless disabled)
|
|
238
|
+
if (appendToHistory) {
|
|
239
|
+
this._memory.appendExchange(userInput, result.text ?? result.content ?? '');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this._log.debug(
|
|
243
|
+
{ success: result.success, iterations: result.iterations, tools: result.toolCallHistory.length },
|
|
244
|
+
'agent.run() complete'
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Introspection ─────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get a snapshot of the agent's current state.
|
|
254
|
+
* @returns {Object}
|
|
255
|
+
*/
|
|
256
|
+
getInfo() {
|
|
257
|
+
return {
|
|
258
|
+
name: this.name,
|
|
259
|
+
description: this.description,
|
|
260
|
+
provider: this.config.provider,
|
|
261
|
+
model: this.config.model ?? 'default',
|
|
262
|
+
sessionId: this._sessionId,
|
|
263
|
+
historyLength: this._memory.historyLength,
|
|
264
|
+
registeredTools: this._toolRegistry.listNames(),
|
|
265
|
+
persistenceEnabled: !!this._memoryManager,
|
|
266
|
+
chainOfThought: this.config.chainOfThought,
|
|
267
|
+
cotMode: this.config.cotMode,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Test connectivity to the configured LLM provider.
|
|
273
|
+
* @returns {Promise<boolean>}
|
|
274
|
+
*/
|
|
275
|
+
async testConnection() {
|
|
276
|
+
return this._llm.testConnection();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { ConfigError } from '../utils/errors.js';
|
|
3
|
+
|
|
4
|
+
// Zod schema for AgentConfig validation
|
|
5
|
+
const AgentConfigSchema = z.object({
|
|
6
|
+
// Required
|
|
7
|
+
name: z.string().min(1, 'name must be a non-empty string'),
|
|
8
|
+
provider: z.enum(['grok', 'claude', 'openai']).or(z.string().min(1)),
|
|
9
|
+
apiKey: z.string().min(1, 'apiKey is required'),
|
|
10
|
+
|
|
11
|
+
// Prompt — exactly one of these must be provided
|
|
12
|
+
systemPromptTemplate: z.string().optional(),
|
|
13
|
+
systemPromptFile: z.string().optional(),
|
|
14
|
+
|
|
15
|
+
// LLM options
|
|
16
|
+
model: z.string().optional(),
|
|
17
|
+
temperature: z.number().min(0).max(2).default(0.1),
|
|
18
|
+
maxTokens: z.number().int().positive().default(4000),
|
|
19
|
+
maxToolIterations: z.number().int().positive().default(5),
|
|
20
|
+
outputSchema: z.record(z.unknown()).nullable().default(null),
|
|
21
|
+
|
|
22
|
+
// Memory options
|
|
23
|
+
maxHistoryMessages: z.number().int().positive().default(50),
|
|
24
|
+
persistenceDir: z.string().nullable().default(null),
|
|
25
|
+
|
|
26
|
+
// CoT options
|
|
27
|
+
chainOfThought: z.boolean().default(true),
|
|
28
|
+
cotMode: z.enum(['prompt', 'reflect']).default('prompt'),
|
|
29
|
+
cotStyle: z.enum(['step-by-step', 'pros-cons', 'custom']).default('step-by-step'),
|
|
30
|
+
cotCustomInstructions: z.string().nullable().default(null),
|
|
31
|
+
|
|
32
|
+
// Agent description (used by AgentTeam)
|
|
33
|
+
description: z.string().default(''),
|
|
34
|
+
|
|
35
|
+
// Runner timeout for the tool-calling loop (ms)
|
|
36
|
+
loopTimeoutMs: z.number().int().positive().default(300000), // 5 minutes
|
|
37
|
+
|
|
38
|
+
// LLM request timeout (ms) — passed to the provider
|
|
39
|
+
requestTimeoutMs: z.number().int().positive().optional(),
|
|
40
|
+
}).strict(); // reject unknown fields to catch typos early
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validated configuration value object for an Agent.
|
|
44
|
+
* Throws ConfigError on invalid input.
|
|
45
|
+
*/
|
|
46
|
+
export class AgentConfig {
|
|
47
|
+
/**
|
|
48
|
+
* @param {Object} options
|
|
49
|
+
* @param {string} options.name
|
|
50
|
+
* @param {string} options.provider - 'grok' | 'claude' | 'openai' | custom
|
|
51
|
+
* @param {string} options.apiKey
|
|
52
|
+
* @param {string} [options.systemPromptTemplate] - Inline prompt template
|
|
53
|
+
* @param {string} [options.systemPromptFile] - Path to .md prompt file
|
|
54
|
+
* @param {string} [options.model]
|
|
55
|
+
* @param {number} [options.temperature=0.1]
|
|
56
|
+
* @param {number} [options.maxTokens=4000]
|
|
57
|
+
* @param {number} [options.maxToolIterations=5]
|
|
58
|
+
* @param {Object} [options.outputSchema=null] - JSON Schema for structured output
|
|
59
|
+
* @param {number} [options.maxHistoryMessages=50]
|
|
60
|
+
* @param {string} [options.persistenceDir=null] - Directory for session persistence
|
|
61
|
+
* @param {boolean} [options.chainOfThought=true]
|
|
62
|
+
* @param {string} [options.cotMode='prompt'] - 'prompt' | 'reflect'
|
|
63
|
+
* @param {string} [options.cotStyle='step-by-step']
|
|
64
|
+
* @param {string} [options.cotCustomInstructions=null]
|
|
65
|
+
* @param {string} [options.description=''] - Agent description for AgentTeam
|
|
66
|
+
*/
|
|
67
|
+
constructor(options) {
|
|
68
|
+
const result = AgentConfigSchema.safeParse(options);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
const issues = result.error.issues
|
|
71
|
+
.map(i => `${i.path.join('.')}: ${i.message}`)
|
|
72
|
+
.join('; ');
|
|
73
|
+
throw new ConfigError(`Invalid AgentConfig: ${issues}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const config = result.data;
|
|
77
|
+
|
|
78
|
+
// Validate that exactly one prompt source is provided
|
|
79
|
+
if (!config.systemPromptTemplate && !config.systemPromptFile) {
|
|
80
|
+
throw new ConfigError(
|
|
81
|
+
'AgentConfig: provide either systemPromptTemplate (inline string) or systemPromptFile (path to file)',
|
|
82
|
+
{ field: 'systemPromptTemplate | systemPromptFile' }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
Object.assign(this, config);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createLogger } from '../utils/logger.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Executes the tool-calling loop for an agent.
|
|
5
|
+
*
|
|
6
|
+
* This is the core engine extracted from the existing masterAgent, sqlAgent, etc.
|
|
7
|
+
* Each of those had ~50 lines of identical loop code; this replaces all of them.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Call LLM with current messages + registered tools
|
|
11
|
+
* 2. If LLM returns tool calls → execute tools, append results, loop
|
|
12
|
+
* 3. If LLM returns final text answer → return AgentResult
|
|
13
|
+
* 4. If max iterations reached → return failure result
|
|
14
|
+
*
|
|
15
|
+
* Optionally, if cotMode='reflect', a second LLM call is made after the final
|
|
16
|
+
* answer to verify and potentially improve it.
|
|
17
|
+
*/
|
|
18
|
+
export class AgentRunner {
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} options
|
|
21
|
+
* @param {import('../llm/BaseLLMProvider.js').BaseLLMProvider} options.llm
|
|
22
|
+
* @param {import('../tool/ToolRegistry.js').ToolRegistry} options.toolRegistry
|
|
23
|
+
* @param {number} options.maxIterations
|
|
24
|
+
* @param {number} options.temperature
|
|
25
|
+
* @param {number} options.maxTokens
|
|
26
|
+
* @param {Object|null} options.outputSchema - JSON Schema for structured output
|
|
27
|
+
* @param {boolean} options.reflectMode - Whether to run reflection pass after answer
|
|
28
|
+
* @param {string} options.agentName - For logging
|
|
29
|
+
*/
|
|
30
|
+
constructor({ llm, toolRegistry, maxIterations, temperature, maxTokens, outputSchema, reflectMode, agentName, loopTimeoutMs }) {
|
|
31
|
+
this._llm = llm;
|
|
32
|
+
this._toolRegistry = toolRegistry;
|
|
33
|
+
this._maxIterations = maxIterations;
|
|
34
|
+
this._temperature = temperature;
|
|
35
|
+
this._maxTokens = maxTokens;
|
|
36
|
+
this._outputSchema = outputSchema;
|
|
37
|
+
this._reflectMode = reflectMode ?? false;
|
|
38
|
+
this._loopTimeoutMs = loopTimeoutMs ?? 300000; // 5 minutes default
|
|
39
|
+
this._log = createLogger('AgentRunner', { agent: agentName });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run the tool-calling loop given an already-assembled messages array.
|
|
44
|
+
*
|
|
45
|
+
* @param {Array<{role: string, content: string}>} messages - System + history + user message
|
|
46
|
+
* @returns {Promise<AgentResult>}
|
|
47
|
+
*
|
|
48
|
+
* @typedef {Object} AgentResult
|
|
49
|
+
* @property {boolean} success
|
|
50
|
+
* @property {string} [content] - Raw LLM text output
|
|
51
|
+
* @property {Object} [parsed] - Parsed JSON (when outputSchema set)
|
|
52
|
+
* @property {string} [text] - Convenience: parsed.text_answer ?? parsed.answer ?? content
|
|
53
|
+
* @property {Array} toolCallHistory - All tool calls with timestamps
|
|
54
|
+
* @property {Object} [usage] - Token usage from final LLM call
|
|
55
|
+
* @property {number} iterations - Number of tool-calling iterations
|
|
56
|
+
* @property {string} [error] - Present when success=false
|
|
57
|
+
* @property {Object} [cotTrace] - Present when reflectMode=true
|
|
58
|
+
*/
|
|
59
|
+
async run(messages) {
|
|
60
|
+
const tools = this._toolRegistry.getDefinitions();
|
|
61
|
+
const hasTools = tools.length > 0;
|
|
62
|
+
const toolCallHistory = [];
|
|
63
|
+
let iteration = 0;
|
|
64
|
+
let currentMessages = [...messages];
|
|
65
|
+
|
|
66
|
+
// Extract the original user question for reflect-mode
|
|
67
|
+
const userQuestion = messages.findLast(m => m.role === 'user')?.content ?? '';
|
|
68
|
+
|
|
69
|
+
const loopStart = Date.now();
|
|
70
|
+
|
|
71
|
+
while (iteration <= this._maxIterations) {
|
|
72
|
+
// Guard against runaway loops
|
|
73
|
+
if (Date.now() - loopStart > this._loopTimeoutMs) {
|
|
74
|
+
return {
|
|
75
|
+
success: false,
|
|
76
|
+
error: `Tool-calling loop timed out after ${this._loopTimeoutMs}ms`,
|
|
77
|
+
toolCallHistory,
|
|
78
|
+
iterations: iteration,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let response;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const callOptions = {
|
|
85
|
+
messages: currentMessages,
|
|
86
|
+
temperature: this._temperature,
|
|
87
|
+
maxTokens: this._maxTokens,
|
|
88
|
+
enableTools: hasTools,
|
|
89
|
+
tools: hasTools ? tools : undefined,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
response = await this._callLLMWithRetry(callOptions);
|
|
93
|
+
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return {
|
|
96
|
+
success: false,
|
|
97
|
+
error: `LLM call failed: ${err.message}`,
|
|
98
|
+
toolCallHistory,
|
|
99
|
+
iterations: iteration,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Tool calls requested ─────────────────────────────────────────
|
|
104
|
+
if (response.toolCalls?.length > 0) {
|
|
105
|
+
iteration++;
|
|
106
|
+
|
|
107
|
+
if (iteration > this._maxIterations) {
|
|
108
|
+
return {
|
|
109
|
+
success: false,
|
|
110
|
+
error: `Max tool iterations (${this._maxIterations}) reached without final answer`,
|
|
111
|
+
toolCallHistory,
|
|
112
|
+
iterations: iteration,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Update messages with assistant's tool-call response
|
|
117
|
+
currentMessages = response.messages ?? [...currentMessages];
|
|
118
|
+
|
|
119
|
+
for (const toolCall of response.toolCalls) {
|
|
120
|
+
this._log.debug({ tool: toolCall.name, args: toolCall.arguments }, 'executing tool');
|
|
121
|
+
|
|
122
|
+
let toolResult;
|
|
123
|
+
try {
|
|
124
|
+
toolResult = await this._toolRegistry.execute(toolCall.name, toolCall.arguments);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
toolResult = `Error executing tool "${toolCall.name}": ${err.message}`;
|
|
127
|
+
this._log.warn({ tool: toolCall.name, err: err.message }, 'tool execution failed');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
toolCallHistory.push({
|
|
131
|
+
id: toolCall.id,
|
|
132
|
+
name: toolCall.name,
|
|
133
|
+
arguments: toolCall.arguments,
|
|
134
|
+
result: typeof toolResult === 'string' ? toolResult.slice(0, 500) : toolResult, // truncate for log
|
|
135
|
+
iteration,
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Append tool result to messages
|
|
140
|
+
currentMessages.push({
|
|
141
|
+
role: 'tool',
|
|
142
|
+
tool_call_id: toolCall.id,
|
|
143
|
+
name: toolCall.name,
|
|
144
|
+
content: toolResult,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
continue; // next iteration
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Final answer ─────────────────────────────────────────────────
|
|
152
|
+
if (response.content || response.parsed) {
|
|
153
|
+
const baseResult = {
|
|
154
|
+
success: true,
|
|
155
|
+
content: response.content,
|
|
156
|
+
parsed: response.parsed ?? null,
|
|
157
|
+
text: response.parsed?.text_answer
|
|
158
|
+
?? response.parsed?.answer
|
|
159
|
+
?? response.content
|
|
160
|
+
?? '',
|
|
161
|
+
toolCallHistory,
|
|
162
|
+
usage: response.usage,
|
|
163
|
+
iterations: iteration,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Reflect mode: make a second call to verify the answer
|
|
167
|
+
if (this._reflectMode && userQuestion) {
|
|
168
|
+
return this._reflect(baseResult, userQuestion, currentMessages);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return baseResult;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Empty response — shouldn't happen but break to avoid infinite loop
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
success: false,
|
|
180
|
+
error: 'Agent returned empty response',
|
|
181
|
+
toolCallHistory,
|
|
182
|
+
iterations: iteration,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Reflection pass: asks the LLM to verify and potentially improve its answer.
|
|
188
|
+
* @param {AgentResult} baseResult
|
|
189
|
+
* @param {string} userQuestion
|
|
190
|
+
* @param {Array} currentMessages
|
|
191
|
+
* @returns {Promise<AgentResult>}
|
|
192
|
+
*/
|
|
193
|
+
async _reflect(baseResult, userQuestion, currentMessages) {
|
|
194
|
+
const reflectPrompt = `Review your answer below. Does it fully and correctly address the original question?
|
|
195
|
+
If the answer is correct and complete, repeat it unchanged.
|
|
196
|
+
If it has issues, provide the correct answer.
|
|
197
|
+
|
|
198
|
+
Original question: ${userQuestion}
|
|
199
|
+
|
|
200
|
+
Your answer: ${baseResult.text}`;
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const reflectMessages = [
|
|
204
|
+
...currentMessages,
|
|
205
|
+
{ role: 'user', content: reflectPrompt },
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
const reflectResponse = await this._llm.complete('', {
|
|
209
|
+
messages: reflectMessages,
|
|
210
|
+
temperature: 0.1,
|
|
211
|
+
maxTokens: this._maxTokens,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const improvedText = reflectResponse.content || baseResult.text;
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
...baseResult,
|
|
218
|
+
text: improvedText,
|
|
219
|
+
content: improvedText,
|
|
220
|
+
cotTrace: {
|
|
221
|
+
original: baseResult.text,
|
|
222
|
+
reflected: improvedText,
|
|
223
|
+
changed: improvedText !== baseResult.text,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
} catch (err) {
|
|
227
|
+
this._log.warn({ err: err.message }, 'reflect pass failed — returning original answer');
|
|
228
|
+
return { ...baseResult, cotTrace: { original: baseResult.text, reflectError: err.message } };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Call the LLM with retry + exponential backoff for transient errors (429, 5xx, timeouts).
|
|
234
|
+
* @param {Object} callOptions
|
|
235
|
+
* @param {number} [maxRetries=2]
|
|
236
|
+
* @returns {Promise<Object>}
|
|
237
|
+
*/
|
|
238
|
+
async _callLLMWithRetry(callOptions, maxRetries = 2) {
|
|
239
|
+
let lastError;
|
|
240
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
241
|
+
try {
|
|
242
|
+
return this._outputSchema
|
|
243
|
+
? await this._llm.completeWithSchema('', this._outputSchema, callOptions)
|
|
244
|
+
: await this._llm.complete('', callOptions);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
lastError = err;
|
|
247
|
+
const isTransient = /timed out|429|500|502|503|504|rate.limit/i.test(err.message);
|
|
248
|
+
if (!isTransient || attempt >= maxRetries) throw err;
|
|
249
|
+
const delayMs = Math.min(1000 * 2 ** attempt, 8000);
|
|
250
|
+
this._log.warn({ attempt: attempt + 1, delayMs, err: err.message }, 'transient LLM error — retrying');
|
|
251
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
throw lastError;
|
|
255
|
+
}
|
|
256
|
+
}
|