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.
Files changed (58) hide show
  1. package/README.md +99 -19
  2. package/dist/App.js +602 -0
  3. package/dist/agentic-loop.js +492 -525
  4. package/dist/cli.js +39 -0
  5. package/dist/components/CollapsibleBox.js +26 -0
  6. package/dist/components/ConfigDialog.js +40 -0
  7. package/dist/components/ConsolidatedToolMessage.js +41 -0
  8. package/dist/components/FormattedMessage.js +93 -0
  9. package/dist/components/Table.js +275 -0
  10. package/dist/config.js +171 -0
  11. package/dist/mcp.js +170 -0
  12. package/dist/providers.js +137 -0
  13. package/dist/sessions.js +161 -0
  14. package/dist/skills.js +229 -0
  15. package/dist/sub-agent.js +103 -0
  16. package/dist/system-prompt.js +131 -0
  17. package/dist/tools/bash.js +178 -0
  18. package/dist/tools/edit-file.js +65 -171
  19. package/dist/tools/index.js +79 -134
  20. package/dist/tools/list-directory.js +20 -73
  21. package/dist/tools/read-file.js +57 -101
  22. package/dist/tools/search-files.js +74 -162
  23. package/dist/tools/todo.js +57 -140
  24. package/dist/tools/webfetch.js +310 -0
  25. package/dist/tools/write-file.js +44 -135
  26. package/dist/utils/approval.js +69 -0
  27. package/dist/utils/compactor.js +87 -0
  28. package/dist/utils/cost-tracker.js +26 -81
  29. package/dist/utils/format-message.js +26 -0
  30. package/dist/utils/logger.js +101 -307
  31. package/dist/utils/path-validation.js +74 -0
  32. package/package.json +45 -51
  33. package/LICENSE +0 -21
  34. package/dist/config/client.js +0 -315
  35. package/dist/config/commands.js +0 -223
  36. package/dist/config/manager.js +0 -117
  37. package/dist/config/mcp-commands.js +0 -266
  38. package/dist/config/mcp-manager.js +0 -240
  39. package/dist/config/mcp-types.js +0 -28
  40. package/dist/config/providers.js +0 -229
  41. package/dist/config/setup.js +0 -209
  42. package/dist/config/system-prompt.js +0 -397
  43. package/dist/config/types.js +0 -4
  44. package/dist/index.js +0 -229
  45. package/dist/tools/create-directory.js +0 -76
  46. package/dist/tools/directory-operations.js +0 -195
  47. package/dist/tools/file-operations.js +0 -211
  48. package/dist/tools/run-shell-command.js +0 -746
  49. package/dist/tools/search-operations.js +0 -179
  50. package/dist/tools/shell-operations.js +0 -342
  51. package/dist/tools/task-complete.js +0 -26
  52. package/dist/tools/view-directory-tree.js +0 -125
  53. package/dist/tools.js +0 -2
  54. package/dist/utils/conversation-compactor.js +0 -140
  55. package/dist/utils/enhanced-prompt.js +0 -23
  56. package/dist/utils/file-operations-approval.js +0 -373
  57. package/dist/utils/interrupt-handler.js +0 -127
  58. package/dist/utils/user-cancellation.js +0 -34
@@ -1,568 +1,535 @@
1
- import { tools, handleToolCall } from './tools/index.js';
2
- import { createChatCompletion } from './config/client.js';
3
- import { generateSystemPrompt } from './config/system-prompt.js';
4
- import { getModelConfig } from './config/providers.js';
5
- import { estimateTokens, createUsageInfo, logUsageInfo, getContextInfo } from './utils/cost-tracker.js';
6
- import { checkAndCompactIfNeeded } from './utils/conversation-compactor.js';
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
- import { interruptHandler, UserInterruptError } from './utils/interrupt-handler.js';
9
- // Create system message for ProtoAgent dynamically
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
- export class AgenticLoop {
18
- constructor(openaiClient, config, options = {}) {
19
- this.systemMessage = null;
20
- this.openaiClient = openaiClient;
21
- this.config = config;
22
- this.options = {
23
- maxIterations: options.maxIterations || 100,
24
- streamOutput: options.streamOutput !== false, // Default to true
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
- this.messages = []; // Will be initialized with system message in initialize()
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
- * Initialize the agentic loop with dynamic system message
30
- */
31
- async initialize() {
32
- logger.debug('🚀 Initializing agentic loop', { component: 'AgenticLoop' });
33
- this.systemMessage = await createSystemMessage();
34
- this.messages = [this.systemMessage];
35
- logger.debug('✅ Agentic loop initialized', {
36
- component: 'AgenticLoop',
37
- systemMessageLength: this.systemMessage.content?.toString().length || 0
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
- * Get the current conversation history
42
- */
43
- getMessages() {
44
- return [...this.messages];
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
- * Clear the conversation history (keeps system message)
48
- */
49
- clearHistory() {
50
- this.messages = this.systemMessage ? [this.systemMessage] : [];
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
- * Add a message to the conversation history
54
- */
55
- addMessage(message) {
56
- this.messages.push(message);
137
+ catch {
138
+ // Fall through to repair heuristics.
57
139
  }
58
- /**
59
- * Process a user input and run the agentic loop
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
- // Add user message to conversation history
69
- logger.debug('➕ Adding user message to conversation', { component: 'AgenticLoop' });
70
- this.addMessage({
71
- role: 'user',
72
- content: userInput
73
- });
74
- // Show a subtle hint about Q key on first use (only if this is the first interaction)
75
- if (this.messages.length === 2) { // System message + first user message
76
- logger.consoleLog('🤔 Thinking... (press Q to pause)');
77
- }
78
- else {
79
- logger.consoleLog('🤔 Thinking...');
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
- logger.info('🤔 AI thinking process started');
82
- logger.debug('🧠 Starting thinking process', {
83
- component: 'AgenticLoop',
84
- totalMessages: this.messages.length,
85
- maxIterations: this.options.maxIterations
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
- // Start the agentic loop
88
- let continueProcessing = true;
89
- let iterationCount = 0;
90
- while (continueProcessing && iterationCount < this.options.maxIterations) {
91
- iterationCount++;
92
- logger.trace(`🔄 Starting agentic loop iteration ${iterationCount}/${this.options.maxIterations}`, {
93
- component: 'AgenticLoop',
94
- iteration: iterationCount,
95
- messageCount: this.messages.length,
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
- // Collect the streamed response
143
- logger.trace('📨 Starting to collect streamed response', { component: 'AgenticLoop' });
144
- let assistantMessage = {
145
- role: 'assistant',
146
- content: '',
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.name,
264
- argsLength: tc.function.arguments?.length || 0,
265
- argsPreview: tc.function.arguments ? tc.function.arguments.substring(0, 100) + (tc.function.arguments.length > 100 ? '...' : '') : null
266
- })) || []
277
+ name: tc.function?.name,
278
+ argsLength: tc.function?.arguments?.length,
279
+ })),
267
280
  });
268
- // TRACE: Log the AI's decision after thinking
269
- logger.trace('🧠 AI thinking complete - analyzing response...', {
270
- component: 'AgenticLoop',
271
- iteration: iterationCount,
272
- hasToolCalls: !!(message.tool_calls && message.tool_calls.length > 0),
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
- if (message.tool_calls && message.tool_calls.length > 0) {
278
- logger.trace('🔧 AI decided to use tools', {
279
- component: 'AgenticLoop',
280
- decision: 'TOOL_CALLS',
281
- tools: message.tool_calls.map((tc) => tc.function.name).join(', '),
282
- reasoning: 'AI determined that tool usage is needed to complete the task'
283
- });
284
- }
285
- else if (message.content && message.content.trim().length > 0) {
286
- logger.trace('💬 AI decided to provide direct response', {
287
- component: 'AgenticLoop',
288
- decision: 'DIRECT_RESPONSE',
289
- responsePreview: message.content.slice(0, 100) + (message.content.length > 100 ? '...' : ''),
290
- reasoning: 'AI determined that a direct text response is sufficient'
291
- });
292
- }
293
- else {
294
- logger.trace('⚠️ AI provided empty response', {
295
- component: 'AgenticLoop',
296
- decision: 'EMPTY_RESPONSE',
297
- reasoning: 'AI returned neither tool calls nor content - this may indicate an issue'
298
- });
299
- }
300
- // Calculate and log cost information
301
- if (modelConfig) {
302
- const finalInputTokens = actualUsage?.prompt_tokens ?? estimatedInputTokens;
303
- const finalOutputTokens = actualUsage?.completion_tokens ?? estimateTokens(message.content || '');
304
- const usageInfo = createUsageInfo(finalInputTokens, finalOutputTokens, modelConfig);
305
- const contextInfo = getContextInfo(this.messages, modelConfig);
306
- logUsageInfo(usageInfo, contextInfo, modelConfig);
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
- // Check if AI wants to use tools
309
- if (message.tool_calls && message.tool_calls.length > 0) {
310
- logger.debug('🔧 AI requested tool usage', {
311
- component: 'AgenticLoop',
312
- toolCount: message.tool_calls.length,
313
- toolNames: message.tool_calls.map((tc) => tc.function.name)
314
- });
315
- // Add the AI's message (with tool calls) to conversation
316
- this.addMessage(message);
317
- logger.debug('➕ AI message with tool calls added to conversation', {
318
- component: 'AgenticLoop',
319
- messageRole: 'assistant',
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
- finally {
399
- // Always stop listening for interrupts when tool execution ends
400
- interruptHandler.stopListening();
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
- // Continue the loop to let AI process tool results
403
- logger.trace('🔄 Continuing agentic loop for AI to process tool results', {
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
- else if (message.content && hasToolCalls) {
436
- // AI provided content along with tool calls (rare case)
437
- if (this.options.streamOutput) {
438
- process.stdout.write('\n🤖 ProtoAgent: ');
439
- process.stdout.write(message.content);
440
- logger.consoleLog('\n');
441
- logger.info('🤖 AI response with tool calls displayed (streaming)');
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
- catch (apiError) {
469
- // Check if this is a user interrupt first
470
- if (apiError instanceof UserInterruptError) {
471
- logger.warn('🛑 User pressed Q - interrupt during processing', {
472
- component: 'AgenticLoop',
473
- reason: apiError.message
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
- // Handle API errors that weren't caught by the retry logic
479
- logger.trace('❌ API Error occurred - stopping agentic loop', {
480
- component: 'AgenticLoop',
481
- decision: 'STOP_ON_API_ERROR',
482
- errorStatus: apiError?.status,
483
- errorMessage: apiError?.message,
484
- reasoning: 'Unrecoverable API error encountered'
485
- });
486
- console.error('\n❌ API Error:', apiError?.message || 'Unknown API error');
487
- logger.error('\n❌ API Error message displayed to user', {
488
- component: 'AgenticLoop',
489
- error: apiError?.message || 'Unknown API error'
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
- // Check for specific error types and provide helpful messages
492
- if (apiError?.status === 401) {
493
- logger.consoleLog('💡 Authentication failed. Your API key may be invalid or expired.');
494
- logger.consoleLog(' Run: protoagent config --update-key');
495
- logger.info('💡 Auth error help message displayed to user');
496
- }
497
- else if (apiError?.status === 403) {
498
- logger.consoleLog('💡 Access forbidden. Check your API key permissions or billing status.');
499
- logger.info('💡 Access forbidden help message displayed to user');
500
- }
501
- else if (apiError?.status === 400) {
502
- logger.consoleLog('💡 Bad request. There may be an issue with the request format.');
503
- logger.consoleLog(' This could be a bug in ProtoAgent. Please check for updates.');
504
- logger.info('💡 Bad request help message displayed to user');
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
- else {
507
- logger.consoleLog('💡 An unexpected API error occurred. Please try again.');
508
- logger.info('💡 Generic API error help message displayed to user');
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
- if (iterationCount >= this.options.maxIterations) {
515
- logger.trace('⚠️ Maximum iteration limit reached - stopping agentic loop', {
516
- component: 'AgenticLoop',
517
- decision: 'STOP_ON_MAX_ITERATIONS',
518
- maxIterations: this.options.maxIterations,
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 (error) {
526
- // Check if this is a user interrupt
527
- if (error instanceof UserInterruptError) {
528
- logger.debug('🛑 User interrupt detected - returning control to user', {
529
- component: 'AgenticLoop',
530
- reason: error.message
531
- });
532
- logger.consoleLog('\n');
533
- return;
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
- // Handle general processing errors
536
- console.error('\n❌ Error during processing:', error?.message || 'Unknown error');
537
- logger.error('\n❌ General processing error displayed to user', {
538
- component: 'AgenticLoop',
539
- error: error?.message || 'Unknown error'
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
- // Provide helpful error messages for common issues
542
- if (error?.message?.includes('API key')) {
543
- logger.consoleLog('💡 There seems to be an issue with your API key configuration.');
544
- logger.consoleLog(' Run: protoagent config --show');
545
- logger.info('💡 API key error help message displayed');
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
- else if (error?.message?.includes('model')) {
548
- logger.consoleLog('💡 There seems to be an issue with the selected model.');
549
- logger.consoleLog(' Run: protoagent config --update-model');
550
- logger.info('💡 Model error help message displayed');
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
- else {
553
- logger.consoleLog('💡 An unexpected error occurred. Please check your configuration and try again.');
554
- logger.info('💡 Generic error help message displayed');
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
- logger.consoleLog('\n🤖 ProtoAgent: I encountered an error and cannot continue processing this request.\n');
557
- logger.info('🤖 Error termination message displayed to user');
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
- * Create a new agentic loop instance
530
+ * Initialize the conversation with the system prompt.
563
531
  */
564
- export async function createAgenticLoop(openaiClient, config, options) {
565
- const loop = new AgenticLoop(openaiClient, config, options);
566
- await loop.initialize();
567
- return loop;
532
+ export async function initializeMessages() {
533
+ const systemPrompt = await generateSystemPrompt();
534
+ return [{ role: 'system', content: systemPrompt }];
568
535
  }