protoagent 0.0.5 → 0.1.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 +99 -19
- package/dist/App.js +602 -0
- package/dist/agentic-loop.js +492 -525
- package/dist/cli.js +39 -0
- package/dist/components/CollapsibleBox.js +26 -0
- package/dist/components/ConfigDialog.js +40 -0
- package/dist/components/ConsolidatedToolMessage.js +41 -0
- package/dist/components/FormattedMessage.js +93 -0
- package/dist/components/Table.js +275 -0
- package/dist/config.js +171 -0
- package/dist/mcp.js +170 -0
- package/dist/providers.js +137 -0
- package/dist/sessions.js +161 -0
- package/dist/skills.js +229 -0
- package/dist/sub-agent.js +103 -0
- package/dist/system-prompt.js +131 -0
- package/dist/tools/bash.js +178 -0
- package/dist/tools/edit-file.js +65 -171
- package/dist/tools/index.js +79 -134
- package/dist/tools/list-directory.js +20 -73
- package/dist/tools/read-file.js +57 -101
- package/dist/tools/search-files.js +74 -162
- package/dist/tools/todo.js +57 -140
- package/dist/tools/webfetch.js +310 -0
- package/dist/tools/write-file.js +44 -135
- package/dist/utils/approval.js +69 -0
- package/dist/utils/compactor.js +87 -0
- package/dist/utils/cost-tracker.js +26 -81
- package/dist/utils/format-message.js +26 -0
- package/dist/utils/logger.js +101 -307
- package/dist/utils/path-validation.js +74 -0
- package/package.json +45 -51
- package/LICENSE +0 -21
- package/dist/config/client.js +0 -315
- package/dist/config/commands.js +0 -223
- package/dist/config/manager.js +0 -117
- package/dist/config/mcp-commands.js +0 -266
- package/dist/config/mcp-manager.js +0 -240
- package/dist/config/mcp-types.js +0 -28
- package/dist/config/providers.js +0 -229
- package/dist/config/setup.js +0 -209
- package/dist/config/system-prompt.js +0 -397
- package/dist/config/types.js +0 -4
- package/dist/index.js +0 -229
- package/dist/tools/create-directory.js +0 -76
- package/dist/tools/directory-operations.js +0 -195
- package/dist/tools/file-operations.js +0 -211
- package/dist/tools/run-shell-command.js +0 -746
- package/dist/tools/search-operations.js +0 -179
- package/dist/tools/shell-operations.js +0 -342
- package/dist/tools/task-complete.js +0 -26
- package/dist/tools/view-directory-tree.js +0 -125
- package/dist/tools.js +0 -2
- package/dist/utils/conversation-compactor.js +0 -140
- package/dist/utils/enhanced-prompt.js +0 -23
- package/dist/utils/file-operations-approval.js +0 -373
- package/dist/utils/interrupt-handler.js +0 -127
- package/dist/utils/user-cancellation.js +0 -34
package/dist/agentic-loop.js
CHANGED
|
@@ -1,568 +1,535 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The agentic loop — the core of ProtoAgent.
|
|
3
|
+
*
|
|
4
|
+
* This module implements the standard tool-use loop:
|
|
5
|
+
*
|
|
6
|
+
* 1. Send the conversation to the LLM with tool definitions
|
|
7
|
+
* 2. If the response contains tool_calls:
|
|
8
|
+
* a. Execute each tool
|
|
9
|
+
* b. Append the results to the conversation
|
|
10
|
+
* c. Go to step 1
|
|
11
|
+
* 3. If the response is plain text:
|
|
12
|
+
* a. Return it to the caller (the UI renders it)
|
|
13
|
+
*
|
|
14
|
+
* The loop is a plain TypeScript module — not an Ink component.
|
|
15
|
+
* The UI subscribes to events emitted by the loop and updates
|
|
16
|
+
* React state accordingly. This keeps the core logic testable
|
|
17
|
+
* and UI-independent.
|
|
18
|
+
*/
|
|
19
|
+
import { getAllTools, handleToolCall } from './tools/index.js';
|
|
20
|
+
import { generateSystemPrompt } from './system-prompt.js';
|
|
21
|
+
import { subAgentTool, runSubAgent } from './sub-agent.js';
|
|
22
|
+
import { estimateTokens, estimateConversationTokens, createUsageInfo, getContextInfo, } from './utils/cost-tracker.js';
|
|
23
|
+
import { compactIfNeeded } from './utils/compactor.js';
|
|
7
24
|
import { logger } from './utils/logger.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
async function createSystemMessage() {
|
|
11
|
-
const systemPrompt = await generateSystemPrompt();
|
|
12
|
-
return {
|
|
13
|
-
role: 'system',
|
|
14
|
-
content: systemPrompt
|
|
15
|
-
};
|
|
25
|
+
function emitAbortAndFinish(onEvent) {
|
|
26
|
+
onEvent({ type: 'done' });
|
|
16
27
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
async function sleepWithAbort(delayMs, abortSignal) {
|
|
29
|
+
if (!abortSignal) {
|
|
30
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (abortSignal.aborted) {
|
|
34
|
+
throw new Error('Operation aborted');
|
|
35
|
+
}
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
39
|
+
resolve();
|
|
40
|
+
}, delayMs);
|
|
41
|
+
const onAbort = () => {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
abortSignal.removeEventListener('abort', onAbort);
|
|
44
|
+
reject(new Error('Operation aborted'));
|
|
25
45
|
};
|
|
26
|
-
|
|
46
|
+
abortSignal.addEventListener('abort', onAbort, { once: true });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function appendStreamingFragment(current, fragment) {
|
|
50
|
+
if (!fragment)
|
|
51
|
+
return current;
|
|
52
|
+
if (!current)
|
|
53
|
+
return fragment;
|
|
54
|
+
if (current === fragment)
|
|
55
|
+
return current;
|
|
56
|
+
if (fragment.startsWith(current))
|
|
57
|
+
return fragment;
|
|
58
|
+
const maxOverlap = Math.min(current.length, fragment.length);
|
|
59
|
+
for (let overlap = maxOverlap; overlap > 0; overlap--) {
|
|
60
|
+
if (current.endsWith(fragment.slice(0, overlap))) {
|
|
61
|
+
return current + fragment.slice(overlap);
|
|
62
|
+
}
|
|
27
63
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
return current + fragment;
|
|
65
|
+
}
|
|
66
|
+
function collapseRepeatedString(value) {
|
|
67
|
+
if (!value)
|
|
68
|
+
return value;
|
|
69
|
+
for (let size = 1; size <= Math.floor(value.length / 2); size++) {
|
|
70
|
+
if (value.length % size !== 0)
|
|
71
|
+
continue;
|
|
72
|
+
const candidate = value.slice(0, size);
|
|
73
|
+
if (candidate.repeat(value.length / size) === value) {
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
function normalizeToolName(name, validToolNames) {
|
|
80
|
+
if (!name)
|
|
81
|
+
return name;
|
|
82
|
+
if (validToolNames.has(name))
|
|
83
|
+
return name;
|
|
84
|
+
const collapsed = collapseRepeatedString(name);
|
|
85
|
+
if (validToolNames.has(collapsed)) {
|
|
86
|
+
return collapsed;
|
|
39
87
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
88
|
+
return name;
|
|
89
|
+
}
|
|
90
|
+
function extractFirstCompleteJsonValue(value) {
|
|
91
|
+
const trimmed = value.trim();
|
|
92
|
+
if (!trimmed)
|
|
93
|
+
return null;
|
|
94
|
+
const opening = trimmed[0];
|
|
95
|
+
const closing = opening === '{' ? '}' : opening === '[' ? ']' : null;
|
|
96
|
+
if (!closing)
|
|
97
|
+
return null;
|
|
98
|
+
let depth = 0;
|
|
99
|
+
let inString = false;
|
|
100
|
+
let escaped = false;
|
|
101
|
+
for (let i = 0; i < trimmed.length; i++) {
|
|
102
|
+
const char = trimmed[i];
|
|
103
|
+
if (inString) {
|
|
104
|
+
if (escaped) {
|
|
105
|
+
escaped = false;
|
|
106
|
+
}
|
|
107
|
+
else if (char === '\\') {
|
|
108
|
+
escaped = true;
|
|
109
|
+
}
|
|
110
|
+
else if (char === '"') {
|
|
111
|
+
inString = false;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (char === '"') {
|
|
116
|
+
inString = true;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (char === opening)
|
|
120
|
+
depth++;
|
|
121
|
+
if (char === closing)
|
|
122
|
+
depth--;
|
|
123
|
+
if (depth === 0) {
|
|
124
|
+
return trimmed.slice(0, i + 1);
|
|
125
|
+
}
|
|
45
126
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function normalizeJsonArguments(argumentsText) {
|
|
130
|
+
const trimmed = argumentsText.trim();
|
|
131
|
+
if (!trimmed)
|
|
132
|
+
return argumentsText;
|
|
133
|
+
try {
|
|
134
|
+
JSON.parse(trimmed);
|
|
135
|
+
return trimmed;
|
|
51
136
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
*/
|
|
55
|
-
addMessage(message) {
|
|
56
|
-
this.messages.push(message);
|
|
137
|
+
catch {
|
|
138
|
+
// Fall through to repair heuristics.
|
|
57
139
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*/
|
|
61
|
-
async processUserInput(userInput) {
|
|
62
|
-
logger.debug('📥 Processing user input', {
|
|
63
|
-
component: 'AgenticLoop',
|
|
64
|
-
inputLength: userInput.length,
|
|
65
|
-
currentMessageCount: this.messages.length
|
|
66
|
-
});
|
|
140
|
+
const collapsed = collapseRepeatedString(trimmed);
|
|
141
|
+
if (collapsed !== trimmed) {
|
|
67
142
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
143
|
+
JSON.parse(collapsed);
|
|
144
|
+
return collapsed;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Fall through to next heuristic.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const firstJsonValue = extractFirstCompleteJsonValue(trimmed);
|
|
151
|
+
if (firstJsonValue) {
|
|
152
|
+
try {
|
|
153
|
+
JSON.parse(firstJsonValue);
|
|
154
|
+
return firstJsonValue;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// Give up and return the original text below.
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return argumentsText;
|
|
161
|
+
}
|
|
162
|
+
function sanitizeToolCall(toolCall, validToolNames) {
|
|
163
|
+
const originalName = toolCall.function?.name || '';
|
|
164
|
+
const originalArgs = toolCall.function?.arguments || '';
|
|
165
|
+
const normalizedName = normalizeToolName(originalName, validToolNames);
|
|
166
|
+
const normalizedArgs = normalizeJsonArguments(originalArgs);
|
|
167
|
+
const changed = normalizedName !== originalName || normalizedArgs !== originalArgs;
|
|
168
|
+
if (!changed) {
|
|
169
|
+
return { toolCall, changed: false };
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
changed: true,
|
|
173
|
+
toolCall: {
|
|
174
|
+
...toolCall,
|
|
175
|
+
function: {
|
|
176
|
+
...toolCall.function,
|
|
177
|
+
name: normalizedName,
|
|
178
|
+
arguments: normalizedArgs,
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function sanitizeMessagesForRetry(messages, validToolNames) {
|
|
184
|
+
let changed = false;
|
|
185
|
+
const sanitizedMessages = messages.map((message) => {
|
|
186
|
+
const msgAny = message;
|
|
187
|
+
if (message.role !== 'assistant' || !Array.isArray(msgAny.tool_calls) || msgAny.tool_calls.length === 0) {
|
|
188
|
+
return message;
|
|
189
|
+
}
|
|
190
|
+
const nextToolCalls = msgAny.tool_calls.map((toolCall) => {
|
|
191
|
+
const sanitized = sanitizeToolCall(toolCall, validToolNames);
|
|
192
|
+
changed = changed || sanitized.changed;
|
|
193
|
+
return sanitized.toolCall;
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
...msgAny,
|
|
197
|
+
tool_calls: nextToolCalls,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
return { messages: sanitizedMessages, changed };
|
|
201
|
+
}
|
|
202
|
+
function getValidToolNames() {
|
|
203
|
+
return new Set([...getAllTools(), subAgentTool]
|
|
204
|
+
.map((tool) => tool.function?.name)
|
|
205
|
+
.filter((name) => Boolean(name)));
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Process a single user input through the agentic loop.
|
|
209
|
+
*
|
|
210
|
+
* Takes the full conversation history (including system message),
|
|
211
|
+
* appends the user message, runs the loop, and returns the updated
|
|
212
|
+
* message history.
|
|
213
|
+
*
|
|
214
|
+
* The `onEvent` callback is called for each event (text deltas,
|
|
215
|
+
* tool calls, usage info, etc.) so the UI can render progress.
|
|
216
|
+
*/
|
|
217
|
+
export async function runAgenticLoop(client, model, messages, userInput, onEvent, options = {}) {
|
|
218
|
+
const maxIterations = options.maxIterations ?? 100;
|
|
219
|
+
const pricing = options.pricing;
|
|
220
|
+
const abortSignal = options.abortSignal;
|
|
221
|
+
const sessionId = options.sessionId;
|
|
222
|
+
// Note: userInput is passed for context/logging but user message should already be in messages array
|
|
223
|
+
// (added by the caller in handleSubmit for immediate UI display)
|
|
224
|
+
const updatedMessages = [...messages];
|
|
225
|
+
// Refresh system prompt to pick up any new skills or project changes
|
|
226
|
+
const newSystemPrompt = await generateSystemPrompt();
|
|
227
|
+
const systemMsgIndex = updatedMessages.findIndex((m) => m.role === 'system');
|
|
228
|
+
if (systemMsgIndex !== -1) {
|
|
229
|
+
updatedMessages[systemMsgIndex] = { role: 'system', content: newSystemPrompt };
|
|
230
|
+
}
|
|
231
|
+
let iterationCount = 0;
|
|
232
|
+
let repairRetryCount = 0;
|
|
233
|
+
const validToolNames = getValidToolNames();
|
|
234
|
+
while (iterationCount < maxIterations) {
|
|
235
|
+
// Check if abort was requested
|
|
236
|
+
if (abortSignal?.aborted) {
|
|
237
|
+
logger.debug('Agentic loop aborted by user');
|
|
238
|
+
emitAbortAndFinish(onEvent);
|
|
239
|
+
return updatedMessages;
|
|
240
|
+
}
|
|
241
|
+
iterationCount++;
|
|
242
|
+
// Check for compaction
|
|
243
|
+
if (pricing) {
|
|
244
|
+
const contextInfo = getContextInfo(updatedMessages, pricing);
|
|
245
|
+
if (contextInfo.needsCompaction) {
|
|
246
|
+
const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow, contextInfo.currentTokens);
|
|
247
|
+
// Replace messages in-place
|
|
248
|
+
updatedMessages.length = 0;
|
|
249
|
+
updatedMessages.push(...compacted);
|
|
80
250
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
// Build tools list: core tools + sub-agent tool + dynamic (MCP) tools
|
|
254
|
+
const allTools = [...getAllTools(), subAgentTool];
|
|
255
|
+
logger.debug('Making API request', {
|
|
256
|
+
model,
|
|
257
|
+
toolsCount: allTools.length,
|
|
258
|
+
messagesCount: updatedMessages.length,
|
|
259
|
+
toolNames: allTools.map((t) => t.function?.name).join(', '),
|
|
86
260
|
});
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
conversationTokensApprox: this.messages.reduce((sum, msg) => sum + (typeof msg.content === 'string' ? msg.content.length / 4 : 0), 0)
|
|
97
|
-
});
|
|
98
|
-
try {
|
|
99
|
-
// Check if conversation needs compaction before making API call
|
|
100
|
-
logger.debug('🔍 Checking context window usage', { component: 'AgenticLoop', iteration: iterationCount });
|
|
101
|
-
const modelConfig = getModelConfig(this.config.provider, this.config.model);
|
|
102
|
-
if (modelConfig) {
|
|
103
|
-
const contextInfo = getContextInfo(this.messages, modelConfig);
|
|
104
|
-
logger.debug('📊 Context analysis complete', {
|
|
105
|
-
component: 'AgenticLoop',
|
|
106
|
-
currentTokens: contextInfo.currentTokens,
|
|
107
|
-
maxTokens: modelConfig.contextWindow,
|
|
108
|
-
needsCompaction: contextInfo.needsCompaction,
|
|
109
|
-
percentageUsed: ((contextInfo.currentTokens / modelConfig.contextWindow) * 100).toFixed(1)
|
|
110
|
-
});
|
|
111
|
-
if (contextInfo.needsCompaction) {
|
|
112
|
-
logger.consoleLog('\n🗜️ Context window approaching limit, compacting conversation...');
|
|
113
|
-
logger.info('🗜️ Context compaction message displayed to user');
|
|
114
|
-
logger.debug('🗜️ Starting conversation compaction', { component: 'AgenticLoop' });
|
|
115
|
-
this.messages = await checkAndCompactIfNeeded(this.openaiClient, this.config.model, this.messages, modelConfig.contextWindow, contextInfo.currentTokens);
|
|
116
|
-
logger.debug('✅ Conversation compaction complete', {
|
|
117
|
-
component: 'AgenticLoop',
|
|
118
|
-
newMessageCount: this.messages.length
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
logger.debug('🌐 Creating chat completion request', {
|
|
123
|
-
component: 'AgenticLoop',
|
|
124
|
-
model: this.config.model,
|
|
125
|
-
messageCount: this.messages.length,
|
|
126
|
-
toolsCount: tools.length,
|
|
127
|
-
streaming: true
|
|
128
|
-
});
|
|
129
|
-
// Create completion using OpenAI with built-in retry logic and cost tracking
|
|
130
|
-
const { stream, estimatedInputTokens } = await createChatCompletion(this.openaiClient, {
|
|
131
|
-
model: this.config.model,
|
|
132
|
-
messages: this.messages,
|
|
133
|
-
tools: tools,
|
|
134
|
-
tool_choice: 'auto',
|
|
135
|
-
stream: true
|
|
136
|
-
}, this.config, this.messages);
|
|
137
|
-
logger.debug('📡 Chat completion stream created', {
|
|
138
|
-
component: 'AgenticLoop',
|
|
139
|
-
estimatedInputTokens,
|
|
140
|
-
iteration: iterationCount
|
|
261
|
+
// Log message structure for debugging provider compatibility
|
|
262
|
+
for (const msg of updatedMessages) {
|
|
263
|
+
const m = msg;
|
|
264
|
+
if (m.role === 'tool') {
|
|
265
|
+
logger.trace('Message payload', {
|
|
266
|
+
role: m.role,
|
|
267
|
+
tool_call_id: m.tool_call_id,
|
|
268
|
+
contentLength: m.content?.length,
|
|
269
|
+
contentPreview: m.content?.slice(0, 100),
|
|
141
270
|
});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
role:
|
|
146
|
-
|
|
147
|
-
tool_calls: []
|
|
148
|
-
};
|
|
149
|
-
let streamedContent = '';
|
|
150
|
-
let hasToolCalls = false;
|
|
151
|
-
let actualUsage = undefined;
|
|
152
|
-
let chunkCount = 0;
|
|
153
|
-
// Start listening for 'Q' key interrupts during streaming
|
|
154
|
-
interruptHandler.startListening();
|
|
155
|
-
try {
|
|
156
|
-
for await (const chunk of stream) {
|
|
157
|
-
// Check for user interrupt
|
|
158
|
-
if (interruptHandler.isInterrupted()) {
|
|
159
|
-
logger.debug('User requested pause during streaming', { component: 'AgenticLoop' });
|
|
160
|
-
break;
|
|
161
|
-
}
|
|
162
|
-
chunkCount++;
|
|
163
|
-
const delta = chunk.choices[0]?.delta;
|
|
164
|
-
logger.trace('📦 Processing chunk', {
|
|
165
|
-
component: 'AgenticLoop',
|
|
166
|
-
chunkNumber: chunkCount,
|
|
167
|
-
hasContent: !!delta?.content,
|
|
168
|
-
hasToolCalls: !!delta?.tool_calls,
|
|
169
|
-
finishReason: chunk.choices[0]?.finish_reason
|
|
170
|
-
});
|
|
171
|
-
if (chunk.usage) {
|
|
172
|
-
actualUsage = chunk.usage;
|
|
173
|
-
logger.trace('📊 Received usage data', {
|
|
174
|
-
component: 'AgenticLoop',
|
|
175
|
-
promptTokens: chunk.usage.prompt_tokens,
|
|
176
|
-
completionTokens: chunk.usage.completion_tokens,
|
|
177
|
-
totalTokens: chunk.usage.total_tokens
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
if (delta?.content) {
|
|
181
|
-
streamedContent += delta.content;
|
|
182
|
-
assistantMessage.content = streamedContent;
|
|
183
|
-
// Stream content to user in real-time if no tool calls are being made
|
|
184
|
-
if (this.options.streamOutput && !hasToolCalls && !delta.tool_calls) {
|
|
185
|
-
if (streamedContent === delta.content) {
|
|
186
|
-
// First content chunk
|
|
187
|
-
process.stdout.write('\n🤖 ProtoAgent: ');
|
|
188
|
-
}
|
|
189
|
-
process.stdout.write(delta.content);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (delta?.tool_calls) {
|
|
193
|
-
hasToolCalls = true;
|
|
194
|
-
logger.trace('🔧 Tool calls detected in delta', {
|
|
195
|
-
component: 'AgenticLoop',
|
|
196
|
-
toolCallsCount: delta.tool_calls.length
|
|
197
|
-
});
|
|
198
|
-
// Initialize tool_calls array if not exists
|
|
199
|
-
if (!assistantMessage.tool_calls) {
|
|
200
|
-
assistantMessage.tool_calls = [];
|
|
201
|
-
}
|
|
202
|
-
// Handle tool calls in streaming
|
|
203
|
-
for (const toolCallDelta of delta.tool_calls) {
|
|
204
|
-
const index = toolCallDelta.index || 0;
|
|
205
|
-
logger.trace('🛠️ Processing tool call delta', {
|
|
206
|
-
component: 'AgenticLoop',
|
|
207
|
-
index,
|
|
208
|
-
hasId: !!toolCallDelta.id,
|
|
209
|
-
hasName: !!toolCallDelta.function?.name,
|
|
210
|
-
hasArgs: !!toolCallDelta.function?.arguments
|
|
211
|
-
});
|
|
212
|
-
// Ensure we have an entry at this index
|
|
213
|
-
if (!assistantMessage.tool_calls[index]) {
|
|
214
|
-
assistantMessage.tool_calls[index] = {
|
|
215
|
-
id: '',
|
|
216
|
-
type: 'function',
|
|
217
|
-
function: { name: '', arguments: '' }
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
if (toolCallDelta.id) {
|
|
221
|
-
assistantMessage.tool_calls[index].id = toolCallDelta.id;
|
|
222
|
-
}
|
|
223
|
-
if (toolCallDelta.function?.name) {
|
|
224
|
-
assistantMessage.tool_calls[index].function.name += toolCallDelta.function.name;
|
|
225
|
-
}
|
|
226
|
-
if (toolCallDelta.function?.arguments) {
|
|
227
|
-
assistantMessage.tool_calls[index].function.arguments += toolCallDelta.function.arguments;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
logger.debug('✅ Stream processing complete', {
|
|
233
|
-
component: 'AgenticLoop',
|
|
234
|
-
totalChunks: chunkCount,
|
|
235
|
-
finalContentLength: streamedContent.length,
|
|
236
|
-
finalToolCallsCount: assistantMessage.tool_calls?.length || 0,
|
|
237
|
-
hasActualUsage: !!actualUsage
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
finally {
|
|
241
|
-
// Always stop listening for interrupts when streaming ends
|
|
242
|
-
interruptHandler.stopListening();
|
|
243
|
-
}
|
|
244
|
-
// Check if streaming was interrupted
|
|
245
|
-
if (interruptHandler.isInterrupted()) {
|
|
246
|
-
logger.debug('User paused during streaming', { component: 'AgenticLoop' });
|
|
247
|
-
interruptHandler.reset();
|
|
248
|
-
throw new UserInterruptError('User paused during streaming');
|
|
249
|
-
}
|
|
250
|
-
const message = assistantMessage;
|
|
251
|
-
// DEBUG: Log the complete AI response
|
|
252
|
-
logger.debug('🤖 AI Response received', {
|
|
253
|
-
component: 'AgenticLoop',
|
|
254
|
-
iteration: iterationCount,
|
|
255
|
-
responseType: message.tool_calls?.length > 0 ? 'TOOL_CALLS' : 'TEXT_RESPONSE',
|
|
256
|
-
contentLength: message.content?.length || 0,
|
|
257
|
-
contentPreview: message.content ? message.content.substring(0, 200) + (message.content.length > 200 ? '...' : '') : null,
|
|
258
|
-
rawMessage: JSON.stringify(message),
|
|
259
|
-
toolCallsCount: message.tool_calls?.length || 0,
|
|
260
|
-
toolNames: message.tool_calls?.map((tc) => tc.function.name) || [],
|
|
261
|
-
toolCallsDetails: message.tool_calls?.map((tc) => ({
|
|
271
|
+
}
|
|
272
|
+
else if (m.role === 'assistant' && m.tool_calls?.length) {
|
|
273
|
+
logger.trace('Message payload', {
|
|
274
|
+
role: m.role,
|
|
275
|
+
toolCalls: m.tool_calls.map((tc) => ({
|
|
262
276
|
id: tc.id,
|
|
263
|
-
name: tc.function
|
|
264
|
-
argsLength: tc.function
|
|
265
|
-
|
|
266
|
-
})) || []
|
|
277
|
+
name: tc.function?.name,
|
|
278
|
+
argsLength: tc.function?.arguments?.length,
|
|
279
|
+
})),
|
|
267
280
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
hasContent: !!(message.content && message.content.trim().length > 0),
|
|
274
|
-
toolCount: message.tool_calls?.length || 0,
|
|
275
|
-
contentLength: message.content?.length || 0
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
logger.trace('Message payload', {
|
|
284
|
+
role: m.role,
|
|
285
|
+
contentLength: m.content?.length,
|
|
276
286
|
});
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const stream = await client.chat.completions.create({
|
|
290
|
+
model,
|
|
291
|
+
messages: updatedMessages,
|
|
292
|
+
tools: allTools,
|
|
293
|
+
tool_choice: 'auto',
|
|
294
|
+
stream: true,
|
|
295
|
+
stream_options: { include_usage: true },
|
|
296
|
+
}, {
|
|
297
|
+
signal: abortSignal,
|
|
298
|
+
});
|
|
299
|
+
// Accumulate the streamed response
|
|
300
|
+
const assistantMessage = {
|
|
301
|
+
role: 'assistant',
|
|
302
|
+
content: '',
|
|
303
|
+
tool_calls: [],
|
|
304
|
+
};
|
|
305
|
+
let streamedContent = '';
|
|
306
|
+
let hasToolCalls = false;
|
|
307
|
+
let actualUsage;
|
|
308
|
+
for await (const chunk of stream) {
|
|
309
|
+
const delta = chunk.choices[0]?.delta;
|
|
310
|
+
if (chunk.usage) {
|
|
311
|
+
actualUsage = chunk.usage;
|
|
312
|
+
}
|
|
313
|
+
// Stream text content
|
|
314
|
+
if (delta?.content) {
|
|
315
|
+
streamedContent += delta.content;
|
|
316
|
+
assistantMessage.content = streamedContent;
|
|
317
|
+
if (!hasToolCalls) {
|
|
318
|
+
onEvent({ type: 'text_delta', content: delta.content });
|
|
307
319
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
hasContent: !!message.content,
|
|
321
|
-
contentLength: message.content?.length || 0,
|
|
322
|
-
toolCallsCount: message.tool_calls?.length || 0,
|
|
323
|
-
conversationLength: this.messages.length
|
|
324
|
-
});
|
|
325
|
-
logger.consoleLog(`🔧 Using ${message.tool_calls.length} tool(s)...`);
|
|
326
|
-
logger.info(`🔧 Tool usage message displayed: ${message.tool_calls.length} tools`);
|
|
327
|
-
// Execute each tool call
|
|
328
|
-
// Start listening for interrupts during tool execution
|
|
329
|
-
interruptHandler.startListening();
|
|
330
|
-
try {
|
|
331
|
-
for (const toolCall of message.tool_calls) {
|
|
332
|
-
// Check for user interrupt before each tool
|
|
333
|
-
if (interruptHandler.isInterrupted()) {
|
|
334
|
-
logger.debug('User requested pause during tool execution', { component: 'AgenticLoop' });
|
|
335
|
-
interruptHandler.reset();
|
|
336
|
-
throw new UserInterruptError('User paused during tool execution');
|
|
337
|
-
}
|
|
338
|
-
const { name, arguments: args } = toolCall.function;
|
|
339
|
-
logger.debug('🛠️ Executing tool', {
|
|
340
|
-
component: 'AgenticLoop',
|
|
341
|
-
toolName: name,
|
|
342
|
-
toolId: toolCall.id,
|
|
343
|
-
argsLength: args.length
|
|
344
|
-
});
|
|
345
|
-
logger.consoleLog(`🛠️ ${name}`);
|
|
346
|
-
logger.info(`🛠️ Tool execution message displayed: ${name}`);
|
|
347
|
-
try {
|
|
348
|
-
const toolArgs = JSON.parse(args);
|
|
349
|
-
logger.debug('📋 Tool arguments parsed', {
|
|
350
|
-
component: 'AgenticLoop',
|
|
351
|
-
toolName: name,
|
|
352
|
-
parsedArgs: Object.keys(toolArgs)
|
|
353
|
-
});
|
|
354
|
-
const startTime = Date.now();
|
|
355
|
-
const result = await handleToolCall(name, toolArgs);
|
|
356
|
-
const executionTime = Date.now() - startTime;
|
|
357
|
-
logger.debug('✅ Tool execution successful', {
|
|
358
|
-
component: 'AgenticLoop',
|
|
359
|
-
toolName: name,
|
|
360
|
-
executionTime,
|
|
361
|
-
resultLength: result.length
|
|
362
|
-
});
|
|
363
|
-
// Add tool result to conversation
|
|
364
|
-
this.addMessage({
|
|
365
|
-
role: 'tool',
|
|
366
|
-
tool_call_id: toolCall.id,
|
|
367
|
-
content: result
|
|
368
|
-
});
|
|
369
|
-
// Show abbreviated result to user
|
|
370
|
-
const lines = result.split('\n');
|
|
371
|
-
if (lines.length > 10) {
|
|
372
|
-
logger.consoleLog(` ✅ ${lines.slice(0, 3).join('\n ')}\n ... (${lines.length - 6} more lines) ...\n ${lines.slice(-3).join('\n ')}`);
|
|
373
|
-
logger.info(` ✅ Tool result displayed (abbreviated, ${lines.length} lines)`);
|
|
374
|
-
}
|
|
375
|
-
else {
|
|
376
|
-
logger.consoleLog(` ✅ ${result.slice(0, 200)}${result.length > 200 ? '...' : ''}`);
|
|
377
|
-
logger.info(` ✅ Tool result displayed (full, ${result.length} chars)`);
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
catch (error) {
|
|
381
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
382
|
-
logger.error('❌ Tool execution failed', {
|
|
383
|
-
component: 'AgenticLoop',
|
|
384
|
-
toolName: name,
|
|
385
|
-
error: errorMessage
|
|
386
|
-
});
|
|
387
|
-
logger.consoleLog(` ❌ Error: ${errorMessage}`);
|
|
388
|
-
logger.error(` ❌ Tool error message displayed: ${errorMessage}`);
|
|
389
|
-
// Add error result to conversation
|
|
390
|
-
this.addMessage({
|
|
391
|
-
role: 'tool',
|
|
392
|
-
tool_call_id: toolCall.id,
|
|
393
|
-
content: `Error: ${errorMessage}`
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
}
|
|
320
|
+
}
|
|
321
|
+
// Accumulate tool calls
|
|
322
|
+
if (delta?.tool_calls) {
|
|
323
|
+
hasToolCalls = true;
|
|
324
|
+
for (const tc of delta.tool_calls) {
|
|
325
|
+
const idx = tc.index || 0;
|
|
326
|
+
if (!assistantMessage.tool_calls[idx]) {
|
|
327
|
+
assistantMessage.tool_calls[idx] = {
|
|
328
|
+
id: '',
|
|
329
|
+
type: 'function',
|
|
330
|
+
function: { name: '', arguments: '' },
|
|
331
|
+
};
|
|
397
332
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
333
|
+
if (tc.id)
|
|
334
|
+
assistantMessage.tool_calls[idx].id = tc.id;
|
|
335
|
+
if (tc.function?.name) {
|
|
336
|
+
assistantMessage.tool_calls[idx].function.name = appendStreamingFragment(assistantMessage.tool_calls[idx].function.name, tc.function.name);
|
|
401
337
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
component: 'AgenticLoop',
|
|
405
|
-
decision: 'CONTINUE_PROCESSING',
|
|
406
|
-
reasoning: 'Tools were executed, AI needs another thinking cycle to process results',
|
|
407
|
-
nextIteration: iterationCount + 1
|
|
408
|
-
});
|
|
409
|
-
continue;
|
|
410
|
-
}
|
|
411
|
-
else {
|
|
412
|
-
// AI provided a regular response
|
|
413
|
-
if (message.content && !hasToolCalls) {
|
|
414
|
-
// Content was already streamed to user during the loop above
|
|
415
|
-
if (this.options.streamOutput) {
|
|
416
|
-
logger.consoleLog('\n'); // Add newline after streaming is complete
|
|
417
|
-
logger.debug('\\n AI response streaming completed');
|
|
418
|
-
}
|
|
419
|
-
else {
|
|
420
|
-
logger.consoleLog(`\n🤖 ProtoAgent: ${message.content}\n`);
|
|
421
|
-
logger.info('🤖 AI response displayed to user (non-streaming)');
|
|
422
|
-
}
|
|
423
|
-
// Add AI response to conversation history
|
|
424
|
-
this.addMessage({
|
|
425
|
-
role: 'assistant',
|
|
426
|
-
content: message.content
|
|
427
|
-
});
|
|
428
|
-
logger.debug('➕ AI text response added to conversation', {
|
|
429
|
-
component: 'AgenticLoop',
|
|
430
|
-
messageRole: 'assistant',
|
|
431
|
-
contentLength: message.content?.length || 0,
|
|
432
|
-
conversationLength: this.messages.length
|
|
433
|
-
});
|
|
338
|
+
if (tc.function?.arguments) {
|
|
339
|
+
assistantMessage.tool_calls[idx].function.arguments = appendStreamingFragment(assistantMessage.tool_calls[idx].function.arguments, tc.function.arguments);
|
|
434
340
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
else {
|
|
444
|
-
logger.consoleLog(`\n🤖 ProtoAgent: ${message.content}\n`);
|
|
445
|
-
logger.info('🤖 AI response with tool calls displayed (non-streaming)');
|
|
446
|
-
}
|
|
447
|
-
// Add AI response to conversation history
|
|
448
|
-
this.addMessage({
|
|
449
|
-
role: 'assistant',
|
|
450
|
-
content: message.content
|
|
451
|
-
});
|
|
452
|
-
logger.debug('➕ AI mixed response (content + tool calls) added to conversation', {
|
|
453
|
-
component: 'AgenticLoop',
|
|
454
|
-
messageRole: 'assistant',
|
|
455
|
-
contentLength: message.content?.length || 0,
|
|
456
|
-
conversationLength: this.messages.length
|
|
457
|
-
});
|
|
341
|
+
// Gemini 3+ models include an `extra_content` field on tool calls
|
|
342
|
+
// containing a `thought_signature`. This MUST be preserved and sent
|
|
343
|
+
// back in subsequent requests, otherwise Gemini returns a 400.
|
|
344
|
+
// See: https://ai.google.dev/gemini-api/docs/openai
|
|
345
|
+
// See also: https://gist.github.com/thomasgauvin/3cfe8e907c957fba4e132e6cf0f06292
|
|
346
|
+
if (tc.extra_content) {
|
|
347
|
+
assistantMessage.tool_calls[idx].extra_content = tc.extra_content;
|
|
458
348
|
}
|
|
459
|
-
logger.trace('✅ Agentic loop complete - stopping processing', {
|
|
460
|
-
component: 'AgenticLoop',
|
|
461
|
-
decision: 'STOP_PROCESSING',
|
|
462
|
-
reasoning: 'AI provided final response without tool calls',
|
|
463
|
-
totalIterations: iterationCount
|
|
464
|
-
});
|
|
465
|
-
continueProcessing = false;
|
|
466
349
|
}
|
|
467
350
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
351
|
+
}
|
|
352
|
+
// Emit usage info
|
|
353
|
+
if (pricing) {
|
|
354
|
+
const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(updatedMessages);
|
|
355
|
+
const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
|
|
356
|
+
const usageInfo = createUsageInfo(inputTokens, outputTokens, pricing);
|
|
357
|
+
const contextInfo = getContextInfo(updatedMessages, pricing);
|
|
358
|
+
onEvent({
|
|
359
|
+
type: 'usage',
|
|
360
|
+
usage: {
|
|
361
|
+
inputTokens,
|
|
362
|
+
outputTokens,
|
|
363
|
+
cost: usageInfo.estimatedCost,
|
|
364
|
+
contextPercent: contextInfo.utilizationPercentage,
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// Handle tool calls
|
|
369
|
+
if (assistantMessage.tool_calls.length > 0) {
|
|
370
|
+
// Clean up empty tool_calls entries (from sparse array)
|
|
371
|
+
assistantMessage.tool_calls = assistantMessage.tool_calls.filter(Boolean);
|
|
372
|
+
assistantMessage.tool_calls = assistantMessage.tool_calls.map((toolCall) => {
|
|
373
|
+
const sanitized = sanitizeToolCall(toolCall, validToolNames);
|
|
374
|
+
if (sanitized.changed) {
|
|
375
|
+
logger.warn('Sanitized streamed tool call', {
|
|
376
|
+
originalName: toolCall.function?.name,
|
|
377
|
+
sanitizedName: sanitized.toolCall.function?.name,
|
|
474
378
|
});
|
|
475
|
-
console.log('\n'); // Just a clean newline
|
|
476
|
-
return;
|
|
477
379
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
380
|
+
return sanitized.toolCall;
|
|
381
|
+
});
|
|
382
|
+
logger.debug('Model returned tool calls', {
|
|
383
|
+
count: assistantMessage.tool_calls.length,
|
|
384
|
+
calls: assistantMessage.tool_calls.map((tc) => ({
|
|
385
|
+
id: tc.id,
|
|
386
|
+
name: tc.function?.name,
|
|
387
|
+
argsPreview: tc.function?.arguments?.slice(0, 100),
|
|
388
|
+
})),
|
|
389
|
+
});
|
|
390
|
+
updatedMessages.push(assistantMessage);
|
|
391
|
+
for (const toolCall of assistantMessage.tool_calls) {
|
|
392
|
+
const { name, arguments: argsStr } = toolCall.function;
|
|
393
|
+
onEvent({
|
|
394
|
+
type: 'tool_call',
|
|
395
|
+
toolCall: { id: toolCall.id, name, args: argsStr, status: 'running' },
|
|
490
396
|
});
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
397
|
+
try {
|
|
398
|
+
const args = JSON.parse(argsStr);
|
|
399
|
+
let result;
|
|
400
|
+
// Handle sub-agent tool specially
|
|
401
|
+
if (name === 'sub_agent') {
|
|
402
|
+
result = await runSubAgent(client, model, args.task, args.max_iterations);
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
result = await handleToolCall(name, args, { sessionId });
|
|
406
|
+
}
|
|
407
|
+
logger.debug('Tool result', {
|
|
408
|
+
tool: name,
|
|
409
|
+
tool_call_id: toolCall.id,
|
|
410
|
+
resultLength: result.length,
|
|
411
|
+
resultPreview: result.slice(0, 200),
|
|
412
|
+
});
|
|
413
|
+
updatedMessages.push({
|
|
414
|
+
role: 'tool',
|
|
415
|
+
tool_call_id: toolCall.id,
|
|
416
|
+
content: result,
|
|
417
|
+
});
|
|
418
|
+
onEvent({
|
|
419
|
+
type: 'tool_result',
|
|
420
|
+
toolCall: { id: toolCall.id, name, args: argsStr, status: 'done', result },
|
|
421
|
+
});
|
|
505
422
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
423
|
+
catch (err) {
|
|
424
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
425
|
+
updatedMessages.push({
|
|
426
|
+
role: 'tool',
|
|
427
|
+
tool_call_id: toolCall.id,
|
|
428
|
+
content: `Error: ${errMsg}`,
|
|
429
|
+
});
|
|
430
|
+
onEvent({
|
|
431
|
+
type: 'tool_result',
|
|
432
|
+
toolCall: { id: toolCall.id, name, args: argsStr, status: 'error', result: errMsg },
|
|
433
|
+
});
|
|
509
434
|
}
|
|
510
|
-
// Exit the processing loop for API errors
|
|
511
|
-
break;
|
|
512
435
|
}
|
|
436
|
+
// Continue loop — let the LLM process tool results
|
|
437
|
+
continue;
|
|
513
438
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
reasoning: 'Reached maximum allowed iterations to prevent infinite loops'
|
|
439
|
+
// Plain text response — we're done
|
|
440
|
+
if (assistantMessage.content) {
|
|
441
|
+
updatedMessages.push({
|
|
442
|
+
role: 'assistant',
|
|
443
|
+
content: assistantMessage.content,
|
|
520
444
|
});
|
|
521
|
-
logger.consoleLog('\n⚠️ Maximum iteration limit reached. Task may be incomplete.');
|
|
522
|
-
logger.warn('⚠️ Max iteration warning displayed to user');
|
|
523
445
|
}
|
|
446
|
+
repairRetryCount = 0;
|
|
447
|
+
onEvent({ type: 'done' });
|
|
448
|
+
return updatedMessages;
|
|
524
449
|
}
|
|
525
|
-
catch (
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
450
|
+
catch (apiError) {
|
|
451
|
+
if (abortSignal?.aborted || apiError?.name === 'AbortError' || apiError?.message === 'Operation aborted') {
|
|
452
|
+
logger.debug('Agentic loop request aborted');
|
|
453
|
+
emitAbortAndFinish(onEvent);
|
|
454
|
+
return updatedMessages;
|
|
455
|
+
}
|
|
456
|
+
const errMsg = apiError?.message || 'Unknown API error';
|
|
457
|
+
// Try to extract response body for more details
|
|
458
|
+
let responseBody;
|
|
459
|
+
try {
|
|
460
|
+
if (apiError?.response) {
|
|
461
|
+
responseBody = JSON.stringify(apiError.response);
|
|
462
|
+
}
|
|
463
|
+
else if (apiError?.error) {
|
|
464
|
+
responseBody = JSON.stringify(apiError.error);
|
|
465
|
+
}
|
|
534
466
|
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
467
|
+
catch { /* ignore */ }
|
|
468
|
+
logger.error(`API error: ${errMsg}`, {
|
|
469
|
+
status: apiError?.status,
|
|
470
|
+
code: apiError?.code,
|
|
471
|
+
responseBody,
|
|
472
|
+
headers: apiError?.headers ? Object.fromEntries(Object.entries(apiError.headers).filter(([k]) => ['content-type', 'x-error', 'retry-after'].includes(k.toLowerCase()))) : undefined,
|
|
473
|
+
});
|
|
474
|
+
// Log the last few messages to help debug format issues
|
|
475
|
+
logger.debug('Messages at time of error', {
|
|
476
|
+
lastMessages: updatedMessages.slice(-3).map((m) => ({
|
|
477
|
+
role: m.role,
|
|
478
|
+
hasToolCalls: !!(m.tool_calls?.length),
|
|
479
|
+
tool_call_id: m.tool_call_id,
|
|
480
|
+
contentPreview: m.content?.slice(0, 150),
|
|
481
|
+
})),
|
|
540
482
|
});
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
483
|
+
const retryableStatus = apiError?.status === 408 || apiError?.status === 409 || apiError?.status === 425;
|
|
484
|
+
const retryableCode = ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENETUNREACH', 'EAI_AGAIN'].includes(apiError?.code);
|
|
485
|
+
if (apiError?.status === 400 && repairRetryCount < 2) {
|
|
486
|
+
const sanitized = sanitizeMessagesForRetry(updatedMessages, getValidToolNames());
|
|
487
|
+
if (sanitized.changed) {
|
|
488
|
+
repairRetryCount++;
|
|
489
|
+
updatedMessages.length = 0;
|
|
490
|
+
updatedMessages.push(...sanitized.messages);
|
|
491
|
+
logger.warn('400 response after malformed tool payload; retrying with sanitized messages', {
|
|
492
|
+
repairRetryCount,
|
|
493
|
+
});
|
|
494
|
+
onEvent({
|
|
495
|
+
type: 'error',
|
|
496
|
+
error: 'Provider rejected the tool payload. Repairing the request and retrying...',
|
|
497
|
+
transient: true,
|
|
498
|
+
});
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
546
501
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
502
|
+
// Retry on 429 (rate limit) with backoff
|
|
503
|
+
if (apiError?.status === 429) {
|
|
504
|
+
const retryAfter = parseInt(apiError?.headers?.['retry-after'] || '5', 10);
|
|
505
|
+
const backoff = Math.min(retryAfter * 1000, 60_000);
|
|
506
|
+
logger.info(`Rate limited, retrying in ${backoff / 1000}s...`);
|
|
507
|
+
onEvent({ type: 'error', error: `Rate limited. Retrying in ${backoff / 1000}s...`, transient: true });
|
|
508
|
+
await sleepWithAbort(backoff, abortSignal);
|
|
509
|
+
continue;
|
|
551
510
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
511
|
+
// Retry on transient request failures
|
|
512
|
+
if (apiError?.status >= 500 || retryableStatus || retryableCode) {
|
|
513
|
+
const backoff = Math.min(2 ** iterationCount * 1000, 30_000);
|
|
514
|
+
logger.info(`Request failed, retrying in ${backoff / 1000}s...`);
|
|
515
|
+
onEvent({ type: 'error', error: `Request failed. Retrying in ${backoff / 1000}s...`, transient: true });
|
|
516
|
+
await sleepWithAbort(backoff, abortSignal);
|
|
517
|
+
continue;
|
|
555
518
|
}
|
|
556
|
-
|
|
557
|
-
|
|
519
|
+
// Non-retryable error
|
|
520
|
+
onEvent({ type: 'error', error: errMsg });
|
|
521
|
+
onEvent({ type: 'done' });
|
|
522
|
+
return updatedMessages;
|
|
558
523
|
}
|
|
559
524
|
}
|
|
525
|
+
onEvent({ type: 'error', error: 'Maximum iteration limit reached.' });
|
|
526
|
+
onEvent({ type: 'done' });
|
|
527
|
+
return updatedMessages;
|
|
560
528
|
}
|
|
561
529
|
/**
|
|
562
|
-
*
|
|
530
|
+
* Initialize the conversation with the system prompt.
|
|
563
531
|
*/
|
|
564
|
-
export async function
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
return loop;
|
|
532
|
+
export async function initializeMessages() {
|
|
533
|
+
const systemPrompt = await generateSystemPrompt();
|
|
534
|
+
return [{ role: 'system', content: systemPrompt }];
|
|
568
535
|
}
|