protoagent 0.1.13 → 0.1.15

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.
@@ -0,0 +1,198 @@
1
+ // Error handling module for the agentic loop.
2
+ // Handles API errors with various retry strategies:
3
+ // - 400 errors: JSON repair, orphaned tool cleanup, truncation, "continue" prompts
4
+ // - 429 errors: rate limit backoff
5
+ // - 5xx errors: exponential backoff
6
+ // - Context window exceeded: forced compaction
7
+ import { compactIfNeeded } from '../utils/compactor.js';
8
+ import { logger } from '../utils/logger.js';
9
+ const LIMITS = {
10
+ MAX_REPAIR: 2,
11
+ MAX_CONTEXT: 2,
12
+ MAX_TRUNCATE: 5,
13
+ MAX_CONTINUE: 1,
14
+ };
15
+ // Sleep with abort signal support.
16
+ export async function sleepWithAbort(delayMs, abortSignal) {
17
+ if (!abortSignal) {
18
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
19
+ return;
20
+ }
21
+ if (abortSignal.aborted) {
22
+ throw new Error('Operation aborted');
23
+ }
24
+ await new Promise((resolve, reject) => {
25
+ const timer = setTimeout(() => {
26
+ abortSignal.removeEventListener('abort', onAbort);
27
+ resolve();
28
+ }, delayMs);
29
+ const onAbort = () => {
30
+ clearTimeout(timer);
31
+ abortSignal.removeEventListener('abort', onAbort);
32
+ reject(new Error('Operation aborted'));
33
+ };
34
+ abortSignal.addEventListener('abort', onAbort, { once: true });
35
+ });
36
+ }
37
+ // Handle an API error with appropriate retry strategy.
38
+ export async function handleApiError(apiError, messages, _validToolNames, pricing, retryState, iterationCount, onEvent, client, model, requestDefaults, sessionId) {
39
+ const errMsg = apiError?.message || 'Unknown API error';
40
+ const status = apiError?.status;
41
+ logger.error(`API error: ${errMsg}`, { status, code: apiError?.code });
42
+ const retryableStatus = status === 408 || status === 409 || status === 425;
43
+ const retryableCode = ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENETUNREACH', 'EAI_AGAIN'].includes(apiError?.code);
44
+ // Context window exceeded - force compaction (check before generic 400 handling)
45
+ const isContextTooLong = status === 400 &&
46
+ /prompt.*too long|context.*length|maximum.*token|tokens?.*exceed/i.test(errMsg);
47
+ if (isContextTooLong && retryState.contextCount < LIMITS.MAX_CONTEXT) {
48
+ retryState.contextCount++;
49
+ logger.warn(`Prompt too long (attempt ${retryState.contextCount})`);
50
+ onEvent({
51
+ type: 'error',
52
+ error: 'Prompt too long. Compacting conversation...',
53
+ transient: true,
54
+ });
55
+ if (pricing && client && model) {
56
+ try {
57
+ const compacted = await compactIfNeeded(client, model, messages, pricing.contextWindow, requestDefaults || {}, sessionId);
58
+ messages.length = 0;
59
+ messages.push(...compacted);
60
+ }
61
+ catch (compactErr) {
62
+ logger.error(`Compaction failed: ${compactErr}`);
63
+ }
64
+ }
65
+ // Truncate oversized tool results as fallback
66
+ const MAX_TOOL_CHARS = 20_000;
67
+ for (let i = 0; i < messages.length; i++) {
68
+ const m = messages[i];
69
+ if (m.role === 'tool' && typeof m.content === 'string' && m.content.length > MAX_TOOL_CHARS) {
70
+ messages[i] = {
71
+ ...m,
72
+ content: m.content.slice(0, MAX_TOOL_CHARS) + '\n... (truncated)',
73
+ };
74
+ }
75
+ }
76
+ return { handled: true, shouldAbort: false, silentRetry: true };
77
+ }
78
+ // Rate limit - backoff
79
+ if (status === 429) {
80
+ const retryAfter = parseInt(apiError?.headers?.['retry-after'] || '5', 10);
81
+ const backoff = Math.min(retryAfter * 1000, 60_000);
82
+ logger.info(`Rate limited, retrying in ${backoff / 1000}s...`);
83
+ onEvent({ type: 'error', error: `Rate limited. Retrying...`, transient: true });
84
+ await sleepWithAbort(backoff);
85
+ return { handled: true, shouldAbort: false, silentRetry: true };
86
+ }
87
+ // Server error - exponential backoff
88
+ if (status >= 500 || retryableStatus || retryableCode) {
89
+ const backoff = Math.min(2 ** iterationCount * 1000, 30_000);
90
+ logger.info(`Request failed, retrying in ${backoff / 1000}s...`);
91
+ onEvent({ type: 'error', error: `Request failed. Retrying...`, transient: true });
92
+ await sleepWithAbort(backoff);
93
+ return { handled: true, shouldAbort: false, silentRetry: true };
94
+ }
95
+ // Generic 400 errors - try repair/truncate/continue
96
+ if (status === 400) {
97
+ return await handle400Error(messages, retryState, onEvent);
98
+ }
99
+ // Non-retryable
100
+ return { handled: false, shouldAbort: false, silentRetry: false, errorMessage: errMsg };
101
+ }
102
+ // Handle 400 errors: repair JSON → remove orphaned → truncate → continue.
103
+ async function handle400Error(messages, retryState, onEvent) {
104
+ // 1. Try JSON repairs on tool arguments
105
+ // Models sometimes emit invalid escape sequences in tool args (e.g., \| from grep regex)
106
+ // which cause JSON.parse to fail. These persist across requests unless repaired.
107
+ if (retryState.repairCount < LIMITS.MAX_REPAIR) {
108
+ let repaired = false;
109
+ for (const msg of messages) {
110
+ const msgAny = msg;
111
+ if (msg.role === 'assistant' && Array.isArray(msgAny.tool_calls)) {
112
+ for (const tc of msgAny.tool_calls) {
113
+ const args = tc.function?.arguments;
114
+ if (args && typeof args === 'string') {
115
+ const fixed = repairInvalidEscapes(args);
116
+ if (fixed !== args) {
117
+ tc.function.arguments = fixed;
118
+ repaired = true;
119
+ }
120
+ }
121
+ }
122
+ }
123
+ }
124
+ if (repaired) {
125
+ retryState.repairCount++;
126
+ logger.warn('400 response: repaired invalid JSON escapes');
127
+ return { handled: true, shouldAbort: false, silentRetry: true };
128
+ }
129
+ }
130
+ // 2. Remove orphaned tool results
131
+ // This happens when messages are truncated and the assistant's tool_calls are
132
+ // removed but the tool results remain. The API rejects orphaned tool results.
133
+ const cleaned = removeOrphanedToolResults(messages);
134
+ if (cleaned.changed) {
135
+ messages.length = 0;
136
+ messages.push(...cleaned.messages);
137
+ logger.warn('400 response: removed orphaned tool results');
138
+ return { handled: true, shouldAbort: false, silentRetry: true };
139
+ }
140
+ // 3. Truncate messages progressively
141
+ // If repairs didn't work, remove the last message (usually the problematic one)
142
+ // and retry. We keep at least system + 1 user message.
143
+ if (retryState.truncateCount < LIMITS.MAX_TRUNCATE && messages.length > 2) {
144
+ retryState.truncateCount++;
145
+ const removed = messages.splice(-1);
146
+ logger.debug('400 error: removed last message', {
147
+ role: removed[0]?.role,
148
+ remaining: messages.length,
149
+ });
150
+ return { handled: true, shouldAbort: false, silentRetry: true };
151
+ }
152
+ // 4. Try "continue" prompt
153
+ // Sometimes the model just needs a nudge to continue after getting stuck.
154
+ if (retryState.continueCount < LIMITS.MAX_CONTINUE) {
155
+ retryState.continueCount++;
156
+ messages.push({ role: 'user', content: 'continue' });
157
+ logger.warn('400 error: adding "continue" message');
158
+ onEvent({ type: 'error', error: 'Retrying with "continue"...', transient: true });
159
+ return { handled: true, shouldAbort: false, silentRetry: true };
160
+ }
161
+ // All strategies exhausted
162
+ return {
163
+ handled: false,
164
+ shouldAbort: false,
165
+ silentRetry: false,
166
+ errorMessage: 'Could not recover from error. Try /clear to start fresh.',
167
+ };
168
+ }
169
+ // Repair invalid JSON escape sequences.
170
+ // Models sometimes emit \| \! \- etc. (e.g. grep regex args).
171
+ function repairInvalidEscapes(value) {
172
+ return value.replace(/\\([^"\\\/bfnrtu])/g, '\\\\$1');
173
+ }
174
+ // Remove orphaned tool result messages that don't have a matching tool_call_id.
175
+ function removeOrphanedToolResults(messages) {
176
+ const validToolCallIds = new Set();
177
+ for (const msg of messages) {
178
+ const msgAny = msg;
179
+ if (msg.role === 'assistant' && Array.isArray(msgAny.tool_calls)) {
180
+ for (const tc of msgAny.tool_calls) {
181
+ if (tc.id)
182
+ validToolCallIds.add(tc.id);
183
+ }
184
+ }
185
+ }
186
+ const filtered = messages.filter((msg) => {
187
+ const msgAny = msg;
188
+ if (msg.role === 'tool' && msgAny.tool_call_id) {
189
+ const isOrphaned = !validToolCallIds.has(msgAny.tool_call_id);
190
+ if (isOrphaned) {
191
+ logger.warn('Removing orphaned tool result', { id: msgAny.tool_call_id });
192
+ }
193
+ return !isOrphaned;
194
+ }
195
+ return true;
196
+ });
197
+ return { messages: filtered, changed: filtered.length !== messages.length };
198
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Tool execution module for the agentic loop.
3
+ *
4
+ * Handles execution of tool calls including special handling for
5
+ * sub-agents and proper abort signal management between tool calls.
6
+ */
7
+ import { handleToolCall } from '../tools/index.js';
8
+ import { runSubAgent } from '../sub-agent.js';
9
+ import { logger } from '../utils/logger.js';
10
+ /**
11
+ * Execute all tool calls from an assistant message.
12
+ *
13
+ * Handles:
14
+ * - Abort checking between tool calls
15
+ * - Sub-agent special case with progress reporting
16
+ * - Error handling and result accumulation
17
+ * - Pending tool call tracking for abort scenarios
18
+ *
19
+ * Returns true if execution completed normally, false if aborted.
20
+ */
21
+ export async function executeToolCalls(toolCalls, messages, onEvent, context) {
22
+ const { sessionId, abortSignal, requestDefaults, client, model, pricing } = context;
23
+ // Track which tool_call_ids still need a tool result message.
24
+ // This set is used to inject stub responses on abort, preventing
25
+ // orphaned tool_call_ids from permanently bricking the session.
26
+ const pendingToolCallIds = new Set(toolCalls.map((tc) => tc.id));
27
+ const injectStubsForPendingToolCalls = () => {
28
+ for (const id of pendingToolCallIds) {
29
+ messages.push({
30
+ role: 'tool',
31
+ tool_call_id: id,
32
+ content: 'Aborted by user.',
33
+ });
34
+ }
35
+ };
36
+ for (const toolCall of toolCalls) {
37
+ // Check abort between tool calls
38
+ if (abortSignal?.aborted) {
39
+ logger.debug('Agentic loop aborted between tool calls');
40
+ injectStubsForPendingToolCalls();
41
+ return { completed: false, shouldAbort: true };
42
+ }
43
+ const { name, arguments: argsStr } = toolCall.function;
44
+ onEvent({
45
+ type: 'tool_call',
46
+ toolCall: { id: toolCall.id, name, args: argsStr, status: 'running' },
47
+ });
48
+ try {
49
+ const args = JSON.parse(argsStr);
50
+ let result;
51
+ // Handle sub-agent tool specially
52
+ if (name === 'sub_agent') {
53
+ const subProgress = (evt) => {
54
+ onEvent({
55
+ type: 'sub_agent_iteration',
56
+ subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration, args: evt.args },
57
+ });
58
+ };
59
+ const subResult = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress, abortSignal, pricing);
60
+ result = subResult.response;
61
+ // Emit sub-agent usage for the UI to add to total cost
62
+ if (subResult.usage.inputTokens > 0 || subResult.usage.outputTokens > 0) {
63
+ onEvent({
64
+ type: 'sub_agent_iteration',
65
+ subAgentUsage: subResult.usage,
66
+ });
67
+ }
68
+ }
69
+ else {
70
+ result = await handleToolCall(name, args, { sessionId, abortSignal });
71
+ }
72
+ logger.info('Tool completed', {
73
+ tool: name,
74
+ resultLength: result.length,
75
+ });
76
+ messages.push({
77
+ role: 'tool',
78
+ tool_call_id: toolCall.id,
79
+ content: result,
80
+ });
81
+ pendingToolCallIds.delete(toolCall.id);
82
+ onEvent({
83
+ type: 'tool_result',
84
+ toolCall: { id: toolCall.id, name, args: argsStr, status: 'done', result },
85
+ });
86
+ }
87
+ catch (err) {
88
+ const errMsg = err instanceof Error ? err.message : String(err);
89
+ messages.push({
90
+ role: 'tool',
91
+ tool_call_id: toolCall.id,
92
+ content: `Error: ${errMsg}`,
93
+ });
94
+ pendingToolCallIds.delete(toolCall.id);
95
+ // If the tool was aborted, inject stubs for remaining pending calls and stop
96
+ if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
97
+ logger.debug('Agentic loop aborted during tool execution');
98
+ injectStubsForPendingToolCalls();
99
+ return { completed: false, shouldAbort: true };
100
+ }
101
+ onEvent({
102
+ type: 'tool_result',
103
+ toolCall: { id: toolCall.id, name, args: argsStr, status: 'error', result: errMsg },
104
+ });
105
+ }
106
+ }
107
+ return { completed: true, shouldAbort: false };
108
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Stream processing module for the agentic loop.
3
+ *
4
+ * Handles accumulation of streaming response chunks into a complete
5
+ * assistant message, including content, tool calls, and usage data.
6
+ */
7
+ import { estimateTokens, estimateConversationTokens, createUsageInfo, getContextInfo } from '../utils/cost-tracker.js';
8
+ import { logger } from '../utils/logger.js';
9
+ /**
10
+ * Process a streaming API response, accumulating content and tool calls.
11
+ *
12
+ * Emits text_delta events for immediate UI display and usage info
13
+ * when available. Returns the complete accumulated message.
14
+ */
15
+ export async function processStream(stream, messages, model, pricing, onEvent) {
16
+ const assistantMessage = {
17
+ role: 'assistant',
18
+ content: '',
19
+ tool_calls: [],
20
+ };
21
+ let streamedContent = '';
22
+ let hasToolCalls = false;
23
+ let actualUsage;
24
+ for await (const chunk of stream) {
25
+ const delta = chunk.choices[0]?.delta;
26
+ if (chunk.usage) {
27
+ actualUsage = chunk.usage;
28
+ }
29
+ // Stream text content (and return to UI for immediate display via onEvent)
30
+ if (delta?.content) {
31
+ streamedContent += delta.content;
32
+ assistantMessage.content = streamedContent;
33
+ if (!hasToolCalls) {
34
+ onEvent({ type: 'text_delta', content: delta.content });
35
+ }
36
+ }
37
+ // Accumulate tool calls across stream chunks
38
+ if (delta?.tool_calls) {
39
+ hasToolCalls = true;
40
+ for (const tc of delta.tool_calls) {
41
+ const idx = tc.index || 0;
42
+ if (!assistantMessage.tool_calls[idx]) {
43
+ assistantMessage.tool_calls[idx] = {
44
+ id: '',
45
+ type: 'function',
46
+ function: { name: '', arguments: '' },
47
+ };
48
+ }
49
+ if (tc.id)
50
+ assistantMessage.tool_calls[idx].id = tc.id;
51
+ if (tc.function?.name) {
52
+ assistantMessage.tool_calls[idx].function.name += tc.function.name;
53
+ }
54
+ if (tc.function?.arguments) {
55
+ assistantMessage.tool_calls[idx].function.arguments += tc.function.arguments;
56
+ }
57
+ // Gemini 3+ models include an `extra_content` field on tool calls
58
+ // containing a `thought_signature`. This MUST be preserved and sent
59
+ // back in subsequent requests, otherwise Gemini returns a 400.
60
+ // See: https://ai.google.dev/gemini-api/docs/openai
61
+ // See also: https://gist.github.com/thomasgauvin/3cfe8e907c957fba4e132e6cf0f06292
62
+ if (tc.extra_content) {
63
+ assistantMessage.tool_calls[idx].extra_content = tc.extra_content;
64
+ }
65
+ }
66
+ }
67
+ }
68
+ // Calculate usage metrics
69
+ const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(messages);
70
+ const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
71
+ const cachedTokens = actualUsage?.prompt_tokens_details?.cached_tokens;
72
+ const cost = pricing
73
+ ? createUsageInfo(inputTokens, outputTokens, pricing, cachedTokens).estimatedCost
74
+ : 0;
75
+ const contextPercent = pricing
76
+ ? getContextInfo(messages, pricing).utilizationPercentage
77
+ : 0;
78
+ // Log API response with usage info at INFO level
79
+ logger.info('Received API response', {
80
+ model,
81
+ inputTokens,
82
+ outputTokens,
83
+ cachedTokens,
84
+ cost: cost > 0 ? `$${cost.toFixed(4)}` : 'N/A',
85
+ contextPercent: contextPercent > 0 ? `${contextPercent.toFixed(1)}%` : 'N/A',
86
+ hasToolCalls: assistantMessage.tool_calls.length > 0,
87
+ contentLength: assistantMessage.content?.length || 0,
88
+ });
89
+ onEvent({
90
+ type: 'usage',
91
+ usage: { inputTokens, outputTokens, cost, contextPercent },
92
+ });
93
+ // Log the full assistant message for debugging
94
+ logger.debug('Assistant response details', {
95
+ contentLength: assistantMessage.content?.length || 0,
96
+ contentPreview: assistantMessage.content?.slice(0, 200) || '(empty)',
97
+ toolCallsCount: assistantMessage.tool_calls?.length || 0,
98
+ toolCalls: assistantMessage.tool_calls?.map((tc) => ({
99
+ id: tc.id,
100
+ name: tc.function?.name,
101
+ argsPreview: tc.function?.arguments?.slice(0, 100),
102
+ })),
103
+ });
104
+ return {
105
+ assistantMessage,
106
+ hasToolCalls,
107
+ usage: { inputTokens, outputTokens, cost, contextPercent },
108
+ };
109
+ }