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.
- package/README.md +1 -4
- package/dist/App.js +77 -442
- package/dist/agentic-loop/errors.js +198 -0
- package/dist/agentic-loop/executor.js +108 -0
- package/dist/agentic-loop/stream.js +109 -0
- package/dist/agentic-loop.js +67 -593
- package/dist/components/ApprovalPrompt.js +18 -0
- package/dist/components/CommandFilter.js +19 -0
- package/dist/components/InlineSetup.js +33 -0
- package/dist/components/UsageDisplay.js +10 -0
- package/dist/config.js +52 -51
- package/dist/hooks/useAgentEventHandler.js +356 -0
- package/dist/mcp.js +3 -0
- package/dist/runtime-config.js +64 -33
- package/dist/skills.js +3 -1
- package/dist/sub-agent.js +11 -16
- package/dist/tools/bash.js +37 -11
- package/dist/tools/edit-file.js +8 -49
- package/dist/tools/read-file.js +3 -66
- package/dist/tools/search-files.js +70 -12
- package/dist/tools/webfetch.js +77 -62
- package/dist/tools/write-file.js +39 -3
- package/dist/utils/approval.js +2 -0
- package/dist/utils/compactor.js +2 -1
- package/dist/utils/cost-tracker.js +5 -2
- package/dist/utils/format-message.js +13 -0
- package/dist/utils/logger.js +16 -3
- package/dist/utils/path-suggestions.js +74 -0
- package/dist/utils/path-validation.js +2 -5
- package/dist/utils/tool-display.js +53 -0
- package/package.json +11 -4
- package/dist/components/CollapsibleBox.js +0 -27
- package/dist/components/ConfigDialog.js +0 -42
- package/dist/components/ConsolidatedToolMessage.js +0 -34
- package/dist/components/FormattedMessage.js +0 -170
|
@@ -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
|
+
}
|