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/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
+ }