protoagent 0.1.2 → 0.1.4
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/dist/App.js +151 -85
- package/dist/agentic-loop.js +33 -12
- package/dist/components/CollapsibleBox.js +2 -2
- package/dist/components/ConfigDialog.js +6 -4
- package/dist/components/ConsolidatedToolMessage.js +3 -11
- package/dist/components/FormattedMessage.js +80 -4
- package/dist/config.js +45 -17
- package/dist/mcp.js +20 -19
- package/dist/providers.js +73 -124
- package/dist/runtime-config.js +175 -0
- package/dist/sub-agent.js +5 -1
- package/dist/system-prompt.js +3 -1
- package/dist/tools/edit-file.js +248 -16
- package/dist/tools/index.js +1 -1
- package/dist/tools/read-file.js +89 -3
- package/dist/tools/search-files.js +92 -1
- package/dist/utils/compactor.js +10 -4
- package/dist/utils/file-time.js +54 -0
- package/package.json +2 -1
- package/dist/components/Table.js +0 -275
package/dist/App.js
CHANGED
|
@@ -6,13 +6,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
* and cost/usage info. All heavy logic lives in `agentic-loop.ts`;
|
|
7
7
|
* this file is purely presentation + state wiring.
|
|
8
8
|
*/
|
|
9
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
10
|
-
import { Box, Text, useApp, useInput } from 'ink';
|
|
9
|
+
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
|
10
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
11
11
|
import { TextInput, Select, PasswordInput } from '@inkjs/ui';
|
|
12
12
|
import BigText from 'ink-big-text';
|
|
13
13
|
import { OpenAI } from 'openai';
|
|
14
14
|
import { readConfig, writeConfig, resolveApiKey } from './config.js';
|
|
15
|
-
import {
|
|
15
|
+
import { loadRuntimeConfig } from './runtime-config.js';
|
|
16
|
+
import { getAllProviders, getProvider, getModelPricing, getRequestDefaultParams } from './providers.js';
|
|
16
17
|
import { runAgenticLoop, initializeMessages, } from './agentic-loop.js';
|
|
17
18
|
import { setDangerouslyAcceptAll, setApprovalHandler, clearApprovalHandler } from './tools/index.js';
|
|
18
19
|
import { setLogLevel, LogLevel, initLogFile, logger } from './utils/logger.js';
|
|
@@ -23,7 +24,7 @@ import { generateSystemPrompt } from './system-prompt.js';
|
|
|
23
24
|
import { CollapsibleBox } from './components/CollapsibleBox.js';
|
|
24
25
|
import { ConsolidatedToolMessage } from './components/ConsolidatedToolMessage.js';
|
|
25
26
|
import { FormattedMessage } from './components/FormattedMessage.js';
|
|
26
|
-
function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0) {
|
|
27
|
+
function renderMessageList(messagesToRender, allMessages, expandedMessages, startIndex = 0, deferTables = false) {
|
|
27
28
|
const rendered = [];
|
|
28
29
|
const skippedIndices = new Set();
|
|
29
30
|
messagesToRender.forEach((msg, localIndex) => {
|
|
@@ -34,6 +35,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
34
35
|
const msgAny = msg;
|
|
35
36
|
const isToolCall = msg.role === 'assistant' && msgAny.tool_calls && msgAny.tool_calls.length > 0;
|
|
36
37
|
const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
|
|
38
|
+
const normalizedContent = normalizeMessageSpacing(displayContent || '');
|
|
37
39
|
const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
|
|
38
40
|
const previousMessage = index > 0 ? allMessages[index - 1] : null;
|
|
39
41
|
const followsToolMessage = previousMessage?.role === 'tool';
|
|
@@ -42,12 +44,15 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
42
44
|
const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
|
|
43
45
|
const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
|
|
44
46
|
const speakerChanged = previousSpeaker !== currentSpeaker;
|
|
45
|
-
if
|
|
47
|
+
// Determine if we need a blank-line spacer above this message.
|
|
48
|
+
// At most one spacer is added per message to avoid doubling.
|
|
49
|
+
const needsSpacer = isFirstSystemMessage ||
|
|
50
|
+
(isConversationTurn && previousWasConversationTurn && speakerChanged) ||
|
|
51
|
+
followsToolMessage ||
|
|
52
|
+
(isToolCall && previousMessage != null);
|
|
53
|
+
if (needsSpacer) {
|
|
46
54
|
rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
|
|
47
55
|
}
|
|
48
|
-
if (isConversationTurn && previousWasConversationTurn && speakerChanged) {
|
|
49
|
-
rendered.push(_jsx(Text, { children: " " }, `turn-spacer-${index}`));
|
|
50
|
-
}
|
|
51
56
|
if (msg.role === 'user') {
|
|
52
57
|
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
|
|
53
58
|
return;
|
|
@@ -57,8 +62,8 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
57
62
|
return;
|
|
58
63
|
}
|
|
59
64
|
if (isToolCall) {
|
|
60
|
-
if (
|
|
61
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content:
|
|
65
|
+
if (normalizedContent.length > 0) {
|
|
66
|
+
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, `${index}-text`));
|
|
62
67
|
}
|
|
63
68
|
const toolCalls = msgAny.tool_calls.map((tc) => ({
|
|
64
69
|
id: tc.id,
|
|
@@ -71,7 +76,7 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
71
76
|
const nextMsg = messagesToRender[nextLocalIndex];
|
|
72
77
|
if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
|
|
73
78
|
toolResults.set(toolCall.id, {
|
|
74
|
-
content: nextMsg.content || '',
|
|
79
|
+
content: normalizeMessageSpacing(nextMsg.content || ''),
|
|
75
80
|
name: nextMsg.name || toolCall.name,
|
|
76
81
|
});
|
|
77
82
|
skippedIndices.add(nextLocalIndex);
|
|
@@ -83,19 +88,23 @@ function renderMessageList(messagesToRender, allMessages, expandedMessages, star
|
|
|
83
88
|
return;
|
|
84
89
|
}
|
|
85
90
|
if (msg.role === 'tool') {
|
|
86
|
-
rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content:
|
|
91
|
+
rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content: normalizedContent, dimColor: true, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
87
92
|
return;
|
|
88
93
|
}
|
|
89
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content:
|
|
94
|
+
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, index));
|
|
90
95
|
});
|
|
91
96
|
return rendered;
|
|
92
97
|
}
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
function normalizeMessageSpacing(message) {
|
|
99
|
+
const normalized = message.replace(/\r\n/g, '\n');
|
|
100
|
+
const lines = normalized.split('\n');
|
|
101
|
+
while (lines.length > 0 && lines[0].trim() === '') {
|
|
102
|
+
lines.shift();
|
|
103
|
+
}
|
|
104
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
105
|
+
lines.pop();
|
|
106
|
+
}
|
|
107
|
+
return lines.join('\n');
|
|
99
108
|
}
|
|
100
109
|
function getVisualSpeaker(message) {
|
|
101
110
|
if (!message)
|
|
@@ -143,7 +152,7 @@ function buildClient(config) {
|
|
|
143
152
|
if (baseURL) {
|
|
144
153
|
clientOptions.baseURL = baseURL;
|
|
145
154
|
}
|
|
146
|
-
// Custom headers:
|
|
155
|
+
// Custom headers: env override takes precedence over provider defaults
|
|
147
156
|
const rawHeaders = process.env.PROTOAGENT_CUSTOM_HEADERS?.trim();
|
|
148
157
|
if (rawHeaders) {
|
|
149
158
|
const defaultHeaders = {};
|
|
@@ -160,6 +169,9 @@ function buildClient(config) {
|
|
|
160
169
|
clientOptions.defaultHeaders = defaultHeaders;
|
|
161
170
|
}
|
|
162
171
|
}
|
|
172
|
+
else if (provider?.headers && Object.keys(provider.headers).length > 0) {
|
|
173
|
+
clientOptions.defaultHeaders = provider.headers;
|
|
174
|
+
}
|
|
163
175
|
return new OpenAI(clientOptions);
|
|
164
176
|
}
|
|
165
177
|
// ─── Sub-components ───
|
|
@@ -182,13 +194,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
|
|
|
182
194
|
{ label: sessionApprovalLabel, value: 'approve_session' },
|
|
183
195
|
{ label: 'Reject', value: 'reject' },
|
|
184
196
|
];
|
|
185
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "
|
|
197
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, marginY: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Approval Required" }), _jsx(Text, { children: request.description }), request.detail && (_jsx(Text, { dimColor: true, children: request.detail.length > 200 ? request.detail.slice(0, 200) + '...' : request.detail })), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: items.map((item) => ({ value: item.value, label: item.label })), onChange: (value) => onRespond(value) }) })] }));
|
|
186
198
|
};
|
|
187
199
|
/** Cost/usage display in the status bar. */
|
|
188
200
|
const UsageDisplay = ({ usage, totalCost }) => {
|
|
189
201
|
if (!usage && totalCost === 0)
|
|
190
202
|
return null;
|
|
191
|
-
return (_jsxs(Box, { children: [usage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191 | ctx: ", usage.contextPercent.toFixed(0), "%"] })), totalCost > 0 && (_jsxs(Text, { dimColor: true, children: [" | cost: $", totalCost.toFixed(4)] }))] }));
|
|
203
|
+
return (_jsxs(Box, { marginTop: 1, children: [usage && (_jsxs(Text, { dimColor: true, children: ["tokens: ", usage.inputTokens, "\u2193 ", usage.outputTokens, "\u2191 | ctx: ", usage.contextPercent.toFixed(0), "%"] })), totalCost > 0 && (_jsxs(Text, { dimColor: true, children: [" | cost: $", totalCost.toFixed(4)] }))] }));
|
|
192
204
|
};
|
|
193
205
|
/** Inline setup wizard — shown when no config exists. */
|
|
194
206
|
const InlineSetup = ({ onComplete }) => {
|
|
@@ -196,7 +208,7 @@ const InlineSetup = ({ onComplete }) => {
|
|
|
196
208
|
const [selectedProviderId, setSelectedProviderId] = useState('');
|
|
197
209
|
const [selectedModelId, setSelectedModelId] = useState('');
|
|
198
210
|
const [apiKeyError, setApiKeyError] = useState('');
|
|
199
|
-
const providerItems =
|
|
211
|
+
const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
|
|
200
212
|
label: `${provider.name} - ${model.name}`,
|
|
201
213
|
value: `${provider.id}:::${model.id}`,
|
|
202
214
|
})));
|
|
@@ -209,15 +221,16 @@ const InlineSetup = ({ onComplete }) => {
|
|
|
209
221
|
} }) })] }));
|
|
210
222
|
}
|
|
211
223
|
const provider = getProvider(selectedProviderId);
|
|
212
|
-
|
|
213
|
-
|
|
224
|
+
const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
|
|
225
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", bold: true, children: "First-time setup" }), _jsxs(Text, { dimColor: true, children: ["Selected: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key:' : 'Enter your API key:' }), apiKeyError && _jsx(Text, { color: "red", children: apiKeyError }), _jsx(PasswordInput, { placeholder: hasResolvedAuth ? 'Press enter to keep resolved auth' : `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
|
|
226
|
+
if (value.trim().length === 0 && !hasResolvedAuth) {
|
|
214
227
|
setApiKeyError('API key cannot be empty.');
|
|
215
228
|
return;
|
|
216
229
|
}
|
|
217
230
|
const newConfig = {
|
|
218
231
|
provider: selectedProviderId,
|
|
219
232
|
model: selectedModelId,
|
|
220
|
-
apiKey: value.trim(),
|
|
233
|
+
...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
|
|
221
234
|
};
|
|
222
235
|
writeConfig(newConfig);
|
|
223
236
|
onComplete(newConfig);
|
|
@@ -226,6 +239,7 @@ const InlineSetup = ({ onComplete }) => {
|
|
|
226
239
|
// ─── Main App ───
|
|
227
240
|
export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
228
241
|
const { exit } = useApp();
|
|
242
|
+
const { stdout } = useStdout();
|
|
229
243
|
// Core state
|
|
230
244
|
const [config, setConfig] = useState(null);
|
|
231
245
|
const [completionMessages, setCompletionMessages] = useState([]);
|
|
@@ -237,7 +251,10 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
237
251
|
const [initialized, setInitialized] = useState(false);
|
|
238
252
|
const [needsSetup, setNeedsSetup] = useState(false);
|
|
239
253
|
const [logFilePath, setLogFilePath] = useState(null);
|
|
240
|
-
//
|
|
254
|
+
// Input reset key — incremented on submit to force TextInput remount and clear
|
|
255
|
+
const [inputResetKey, setInputResetKey] = useState(0);
|
|
256
|
+
const [inputWidthKey, setInputWidthKey] = useState(stdout?.columns ?? 80);
|
|
257
|
+
// Collapsible state — only applies to live (current turn) messages
|
|
241
258
|
const [expandedMessages, setExpandedMessages] = useState(new Set());
|
|
242
259
|
const expandLatestMessage = useCallback((index) => {
|
|
243
260
|
setExpandedMessages((prev) => {
|
|
@@ -254,6 +271,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
254
271
|
const [lastUsage, setLastUsage] = useState(null);
|
|
255
272
|
const [totalCost, setTotalCost] = useState(0);
|
|
256
273
|
const [spinnerFrame, setSpinnerFrame] = useState(0);
|
|
274
|
+
// Active tool tracking — shows which tool is currently executing
|
|
275
|
+
const [activeTool, setActiveTool] = useState(null);
|
|
257
276
|
// Session state
|
|
258
277
|
const [session, setSession] = useState(null);
|
|
259
278
|
// Quitting state — shows the resume command before exiting
|
|
@@ -264,6 +283,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
264
283
|
const assistantMessageRef = useRef(null);
|
|
265
284
|
// Abort controller for cancelling the current completion
|
|
266
285
|
const abortControllerRef = useRef(null);
|
|
286
|
+
// Debounce timer for text_delta renders (~50ms batching)
|
|
287
|
+
const textFlushTimerRef = useRef(null);
|
|
267
288
|
// ─── Post-config initialization (reused after inline setup) ───
|
|
268
289
|
const initializeWithConfig = useCallback(async (loadedConfig) => {
|
|
269
290
|
setConfig(loadedConfig);
|
|
@@ -308,6 +329,18 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
308
329
|
}, 100);
|
|
309
330
|
return () => clearInterval(interval);
|
|
310
331
|
}, [loading]);
|
|
332
|
+
useEffect(() => {
|
|
333
|
+
if (!stdout)
|
|
334
|
+
return;
|
|
335
|
+
const handleResize = () => {
|
|
336
|
+
setInputWidthKey(stdout.columns ?? 80);
|
|
337
|
+
};
|
|
338
|
+
handleResize();
|
|
339
|
+
stdout.on('resize', handleResize);
|
|
340
|
+
return () => {
|
|
341
|
+
stdout.off('resize', handleResize);
|
|
342
|
+
};
|
|
343
|
+
}, [stdout]);
|
|
311
344
|
useEffect(() => {
|
|
312
345
|
const init = async () => {
|
|
313
346
|
// Set log level and initialize log file
|
|
@@ -331,6 +364,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
331
364
|
setPendingApproval({ request: req, resolve });
|
|
332
365
|
});
|
|
333
366
|
});
|
|
367
|
+
await loadRuntimeConfig();
|
|
334
368
|
// Load config — if none exists, show inline setup
|
|
335
369
|
const loadedConfig = readConfig();
|
|
336
370
|
if (!loadedConfig) {
|
|
@@ -410,7 +444,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
410
444
|
default:
|
|
411
445
|
return false;
|
|
412
446
|
}
|
|
413
|
-
}, [exit, session, completionMessages]);
|
|
447
|
+
}, [config, exit, session, completionMessages]);
|
|
414
448
|
// ─── Submit handler ───
|
|
415
449
|
const handleSubmit = useCallback(async (value) => {
|
|
416
450
|
const trimmed = value.trim();
|
|
@@ -421,10 +455,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
421
455
|
const handled = await handleSlashCommand(trimmed);
|
|
422
456
|
if (handled) {
|
|
423
457
|
setInputText('');
|
|
458
|
+
setInputResetKey((prev) => prev + 1);
|
|
424
459
|
return;
|
|
425
460
|
}
|
|
426
461
|
}
|
|
427
462
|
setInputText('');
|
|
463
|
+
setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
|
|
428
464
|
setLoading(true);
|
|
429
465
|
setError(null);
|
|
430
466
|
setHelpMessage(null);
|
|
@@ -436,14 +472,15 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
436
472
|
assistantMessageRef.current = null;
|
|
437
473
|
try {
|
|
438
474
|
const pricing = getModelPricing(config.provider, config.model);
|
|
475
|
+
const requestDefaults = getRequestDefaultParams(config.provider, config.model);
|
|
439
476
|
// Create abort controller for this completion
|
|
440
477
|
abortControllerRef.current = new AbortController();
|
|
441
478
|
const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
|
|
442
479
|
switch (event.type) {
|
|
443
480
|
case 'text_delta':
|
|
444
|
-
// Update the current assistant message in completionMessages
|
|
481
|
+
// Update the current assistant message in completionMessages
|
|
445
482
|
if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
|
|
446
|
-
// First text delta
|
|
483
|
+
// First text delta — create the assistant message immediately
|
|
447
484
|
const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
|
|
448
485
|
setCompletionMessages((prev) => {
|
|
449
486
|
assistantMessageRef.current = { message: assistantMsg, index: prev.length, kind: 'streaming_text' };
|
|
@@ -451,80 +488,96 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
451
488
|
});
|
|
452
489
|
}
|
|
453
490
|
else {
|
|
454
|
-
// Subsequent
|
|
491
|
+
// Subsequent deltas — accumulate in ref, debounce the render (~50ms)
|
|
455
492
|
assistantMessageRef.current.message.content += event.content || '';
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
493
|
+
if (!textFlushTimerRef.current) {
|
|
494
|
+
textFlushTimerRef.current = setTimeout(() => {
|
|
495
|
+
textFlushTimerRef.current = null;
|
|
496
|
+
setCompletionMessages((prev) => {
|
|
497
|
+
if (!assistantMessageRef.current)
|
|
498
|
+
return prev;
|
|
499
|
+
const updated = [...prev];
|
|
500
|
+
updated[assistantMessageRef.current.index] = { ...assistantMessageRef.current.message };
|
|
501
|
+
return updated;
|
|
502
|
+
});
|
|
503
|
+
}, 50);
|
|
504
|
+
}
|
|
461
505
|
}
|
|
462
506
|
break;
|
|
463
507
|
case 'tool_call':
|
|
464
508
|
if (event.toolCall) {
|
|
465
509
|
const toolCall = event.toolCall;
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
:
|
|
474
|
-
const assistantMsg = existingMessage || {
|
|
475
|
-
role: 'assistant',
|
|
476
|
-
content: '',
|
|
477
|
-
tool_calls: [],
|
|
478
|
-
};
|
|
479
|
-
const existingToolCallIndex = assistantMsg.tool_calls.findIndex((existingToolCall) => existingToolCall.id === toolCall.id);
|
|
480
|
-
const nextToolCall = {
|
|
481
|
-
id: toolCall.id,
|
|
482
|
-
type: 'function',
|
|
483
|
-
function: {
|
|
484
|
-
name: toolCall.name,
|
|
485
|
-
arguments: toolCall.args,
|
|
486
|
-
},
|
|
487
|
-
};
|
|
488
|
-
if (existingToolCallIndex === -1) {
|
|
489
|
-
assistantMsg.tool_calls.push(nextToolCall);
|
|
490
|
-
}
|
|
491
|
-
else {
|
|
492
|
-
assistantMsg.tool_calls[existingToolCallIndex] = nextToolCall;
|
|
510
|
+
setActiveTool(toolCall.name);
|
|
511
|
+
// Track the tool call in the ref WITHOUT triggering a render.
|
|
512
|
+
// The render will happen when tool_result arrives.
|
|
513
|
+
const existingRef = assistantMessageRef.current;
|
|
514
|
+
const assistantMsg = existingRef?.message
|
|
515
|
+
? {
|
|
516
|
+
...existingRef.message,
|
|
517
|
+
tool_calls: [...(existingRef.message.tool_calls || [])],
|
|
493
518
|
}
|
|
494
|
-
|
|
519
|
+
: { role: 'assistant', content: '', tool_calls: [] };
|
|
520
|
+
const nextToolCall = {
|
|
521
|
+
id: toolCall.id,
|
|
522
|
+
type: 'function',
|
|
523
|
+
function: { name: toolCall.name, arguments: toolCall.args },
|
|
524
|
+
};
|
|
525
|
+
const idx = assistantMsg.tool_calls.findIndex((tc) => tc.id === toolCall.id);
|
|
526
|
+
if (idx === -1) {
|
|
527
|
+
assistantMsg.tool_calls.push(nextToolCall);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
assistantMsg.tool_calls[idx] = nextToolCall;
|
|
531
|
+
}
|
|
532
|
+
if (!existingRef) {
|
|
533
|
+
// First tool call — we need to add the assistant message to state
|
|
534
|
+
setCompletionMessages((prev) => {
|
|
535
|
+
assistantMessageRef.current = {
|
|
536
|
+
message: assistantMsg,
|
|
537
|
+
index: prev.length,
|
|
538
|
+
kind: 'tool_call_assistant',
|
|
539
|
+
};
|
|
540
|
+
return [...prev, assistantMsg];
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
// Subsequent tool calls — just update the ref, no render
|
|
495
545
|
assistantMessageRef.current = {
|
|
546
|
+
...existingRef,
|
|
496
547
|
message: assistantMsg,
|
|
497
|
-
index: nextIndex,
|
|
498
548
|
kind: 'tool_call_assistant',
|
|
499
549
|
};
|
|
500
|
-
|
|
501
|
-
const updated = [...prev];
|
|
502
|
-
updated[existingRef.index] = assistantMsg;
|
|
503
|
-
return updated;
|
|
504
|
-
}
|
|
505
|
-
return [...prev, assistantMsg];
|
|
506
|
-
});
|
|
550
|
+
}
|
|
507
551
|
}
|
|
508
552
|
break;
|
|
509
553
|
case 'tool_result':
|
|
510
554
|
if (event.toolCall) {
|
|
511
555
|
const toolCall = event.toolCall;
|
|
556
|
+
setActiveTool(null);
|
|
512
557
|
if (toolCall.name === 'todo_read' || toolCall.name === 'todo_write') {
|
|
513
558
|
const currentAssistantIndex = assistantMessageRef.current?.index;
|
|
514
559
|
if (typeof currentAssistantIndex === 'number') {
|
|
515
560
|
expandLatestMessage(currentAssistantIndex);
|
|
516
561
|
}
|
|
517
562
|
}
|
|
518
|
-
//
|
|
519
|
-
setCompletionMessages((prev) =>
|
|
520
|
-
...prev
|
|
521
|
-
|
|
563
|
+
// Flush the assistant message update + tool result in a SINGLE state update
|
|
564
|
+
setCompletionMessages((prev) => {
|
|
565
|
+
const updated = [...prev];
|
|
566
|
+
// Sync assistant message (may have new tool_calls since last render)
|
|
567
|
+
if (assistantMessageRef.current) {
|
|
568
|
+
updated[assistantMessageRef.current.index] = {
|
|
569
|
+
...assistantMessageRef.current.message,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
// Append tool result
|
|
573
|
+
updated.push({
|
|
522
574
|
role: 'tool',
|
|
523
575
|
tool_call_id: toolCall.id,
|
|
524
576
|
content: toolCall.result || '',
|
|
525
577
|
name: toolCall.name,
|
|
526
|
-
}
|
|
527
|
-
|
|
578
|
+
});
|
|
579
|
+
return updated;
|
|
580
|
+
});
|
|
528
581
|
}
|
|
529
582
|
break;
|
|
530
583
|
case 'usage':
|
|
@@ -564,11 +617,25 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
564
617
|
setError('Unknown error');
|
|
565
618
|
}
|
|
566
619
|
break;
|
|
620
|
+
case 'iteration_done':
|
|
621
|
+
assistantMessageRef.current = null;
|
|
622
|
+
break;
|
|
567
623
|
case 'done':
|
|
624
|
+
// Clear any pending text delta timer
|
|
625
|
+
if (textFlushTimerRef.current) {
|
|
626
|
+
clearTimeout(textFlushTimerRef.current);
|
|
627
|
+
textFlushTimerRef.current = null;
|
|
628
|
+
}
|
|
629
|
+
setActiveTool(null);
|
|
568
630
|
setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
|
|
569
631
|
break;
|
|
570
632
|
}
|
|
571
|
-
}, {
|
|
633
|
+
}, {
|
|
634
|
+
pricing: pricing || undefined,
|
|
635
|
+
abortSignal: abortControllerRef.current.signal,
|
|
636
|
+
sessionId: session?.id,
|
|
637
|
+
requestDefaults,
|
|
638
|
+
});
|
|
572
639
|
// Final update to ensure we have the complete message history
|
|
573
640
|
setCompletionMessages(updatedMessages);
|
|
574
641
|
// Update session
|
|
@@ -605,18 +672,17 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
605
672
|
: completionMessages.length;
|
|
606
673
|
const archivedMessages = completionMessages.slice(0, liveStartIndex);
|
|
607
674
|
const liveMessages = completionMessages.slice(liveStartIndex);
|
|
608
|
-
const archivedMessageNodes = renderMessageList(archivedMessages, completionMessages, expandedMessages);
|
|
609
|
-
const liveMessageNodes = renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex);
|
|
610
|
-
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BigText, { text: "ProtoAgent", font: "tiny", colors: ["#09A469"] }),
|
|
675
|
+
const archivedMessageNodes = useMemo(() => renderMessageList(archivedMessages, completionMessages, expandedMessages), [archivedMessages, completionMessages, expandedMessages]);
|
|
676
|
+
const liveMessageNodes = useMemo(() => renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex, loading), [liveMessages, completionMessages, expandedMessages, liveStartIndex, loading]);
|
|
677
|
+
return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BigText, { text: "ProtoAgent", font: "tiny", colors: ["#09A469"] }), config && (_jsxs(Text, { dimColor: true, children: ["Model: ", providerInfo?.name || config.provider, " / ", config.model, dangerouslyAcceptAll && _jsx(Text, { color: "red", children: " (auto-approve all)" }), session && _jsxs(Text, { dimColor: true, children: [" | Session: ", session.id.slice(0, 8)] })] })), logFilePath && _jsxs(Text, { dimColor: true, children: ["Debug logs: ", logFilePath] }), error && _jsx(Text, { color: "red", children: error }), helpMessage && (_jsx(CollapsibleBox, { title: "Help", content: helpMessage, titleColor: "green", dimColor: false, maxPreviewLines: 10, expanded: true })), !initialized && !error && !needsSetup && _jsx(Text, { children: "Initializing..." }), needsSetup && (_jsx(InlineSetup, { onComplete: (newConfig) => {
|
|
611
678
|
initializeWithConfig(newConfig).catch((err) => {
|
|
612
679
|
setError(`Initialization failed: ${err.message}`);
|
|
613
680
|
});
|
|
614
|
-
} })), _jsxs(Box, { flexDirection: "column", flexGrow: 1,
|
|
681
|
+
} })), _jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [archivedMessageNodes, liveMessageNodes, threadErrors.map((threadError) => (_jsx(Box, { marginBottom: 1, borderStyle: "round", borderColor: "red", paddingX: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }) }, `thread-error-${threadError.id}`))), loading && completionMessages.length > 0 && ((() => {
|
|
615
682
|
const lastMsg = completionMessages[completionMessages.length - 1];
|
|
616
|
-
// Show "Thinking..." only if the last message is a user message (no assistant response yet)
|
|
617
683
|
return lastMsg.role === 'user' ? _jsx(Text, { dimColor: true, children: "Thinking..." }) : null;
|
|
618
684
|
})()), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
|
|
619
685
|
pendingApproval.resolve(response);
|
|
620
686
|
setPendingApproval(null);
|
|
621
|
-
} }))] }), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame],
|
|
687
|
+
} }))] }), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && (_jsx(Box, { borderStyle: "round", borderColor: "green", paddingX: 1, flexDirection: "column", children: _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: "green", bold: true, children: '>' }) }), _jsx(Box, { flexGrow: 1, minWidth: 10, children: _jsx(TextInput, { defaultValue: inputText, onChange: setInputText, placeholder: "Type your message... (/help for commands)", onSubmit: handleSubmit }, `${inputResetKey}-${inputWidthKey}`) })] }) }, `input-shell-${inputWidthKey}`)), quittingSession && (_jsxs(Box, { flexDirection: "column", marginTop: 1, paddingX: 1, marginBottom: 1, children: [_jsx(Text, { dimColor: true, children: "Session saved. Resume with:" }), _jsxs(Text, { color: "green", children: ["protoagent --session ", quittingSession.id] })] }))] }));
|
|
622
688
|
};
|
package/dist/agentic-loop.js
CHANGED
|
@@ -219,6 +219,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
219
219
|
const pricing = options.pricing;
|
|
220
220
|
const abortSignal = options.abortSignal;
|
|
221
221
|
const sessionId = options.sessionId;
|
|
222
|
+
const requestDefaults = options.requestDefaults || {};
|
|
222
223
|
// Note: userInput is passed for context/logging but user message should already be in messages array
|
|
223
224
|
// (added by the caller in handleSubmit for immediate UI display)
|
|
224
225
|
const updatedMessages = [...messages];
|
|
@@ -243,7 +244,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
243
244
|
if (pricing) {
|
|
244
245
|
const contextInfo = getContextInfo(updatedMessages, pricing);
|
|
245
246
|
if (contextInfo.needsCompaction) {
|
|
246
|
-
const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow, contextInfo.currentTokens);
|
|
247
|
+
const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow, contextInfo.currentTokens, requestDefaults, sessionId);
|
|
247
248
|
// Replace messages in-place
|
|
248
249
|
updatedMessages.length = 0;
|
|
249
250
|
updatedMessages.push(...compacted);
|
|
@@ -287,6 +288,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
287
288
|
}
|
|
288
289
|
}
|
|
289
290
|
const stream = await client.chat.completions.create({
|
|
291
|
+
...requestDefaults,
|
|
290
292
|
model,
|
|
291
293
|
messages: updatedMessages,
|
|
292
294
|
tools: allTools,
|
|
@@ -349,20 +351,19 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
349
351
|
}
|
|
350
352
|
}
|
|
351
353
|
}
|
|
352
|
-
// Emit usage info
|
|
353
|
-
|
|
354
|
+
// Emit usage info — always emit, even without pricing (use estimates)
|
|
355
|
+
{
|
|
354
356
|
const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(updatedMessages);
|
|
355
357
|
const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
+
const cost = pricing
|
|
359
|
+
? createUsageInfo(inputTokens, outputTokens, pricing).estimatedCost
|
|
360
|
+
: 0;
|
|
361
|
+
const contextPercent = pricing
|
|
362
|
+
? getContextInfo(updatedMessages, pricing).utilizationPercentage
|
|
363
|
+
: 0;
|
|
358
364
|
onEvent({
|
|
359
365
|
type: 'usage',
|
|
360
|
-
usage: {
|
|
361
|
-
inputTokens,
|
|
362
|
-
outputTokens,
|
|
363
|
-
cost: usageInfo.estimatedCost,
|
|
364
|
-
contextPercent: contextInfo.utilizationPercentage,
|
|
365
|
-
},
|
|
366
|
+
usage: { inputTokens, outputTokens, cost, contextPercent },
|
|
366
367
|
});
|
|
367
368
|
}
|
|
368
369
|
// Handle tool calls
|
|
@@ -389,6 +390,12 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
389
390
|
});
|
|
390
391
|
updatedMessages.push(assistantMessage);
|
|
391
392
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
393
|
+
// Check abort between tool calls
|
|
394
|
+
if (abortSignal?.aborted) {
|
|
395
|
+
logger.debug('Agentic loop aborted between tool calls');
|
|
396
|
+
emitAbortAndFinish(onEvent);
|
|
397
|
+
return updatedMessages;
|
|
398
|
+
}
|
|
392
399
|
const { name, arguments: argsStr } = toolCall.function;
|
|
393
400
|
onEvent({
|
|
394
401
|
type: 'tool_call',
|
|
@@ -399,7 +406,18 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
399
406
|
let result;
|
|
400
407
|
// Handle sub-agent tool specially
|
|
401
408
|
if (name === 'sub_agent') {
|
|
402
|
-
|
|
409
|
+
const subProgress = (evt) => {
|
|
410
|
+
onEvent({
|
|
411
|
+
type: 'tool_call',
|
|
412
|
+
toolCall: {
|
|
413
|
+
id: toolCall.id,
|
|
414
|
+
name: `sub_agent → ${evt.tool}`,
|
|
415
|
+
args: '',
|
|
416
|
+
status: evt.status === 'running' ? 'running' : 'done',
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress);
|
|
403
421
|
}
|
|
404
422
|
else {
|
|
405
423
|
result = await handleToolCall(name, args, { sessionId });
|
|
@@ -433,6 +451,9 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
433
451
|
});
|
|
434
452
|
}
|
|
435
453
|
}
|
|
454
|
+
// Signal UI that this iteration's tool calls are all done,
|
|
455
|
+
// so it can flush completed messages to static output.
|
|
456
|
+
onEvent({ type: 'iteration_done' });
|
|
436
457
|
// Continue loop — let the LLM process tool results
|
|
437
458
|
continue;
|
|
438
459
|
}
|
|
@@ -7,7 +7,7 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
|
|
|
7
7
|
const isLong = isTooManyLines || isTooManyChars;
|
|
8
8
|
// If content is short, always show it
|
|
9
9
|
if (!isLong) {
|
|
10
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom,
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom, children: [_jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }), _jsx(Box, { marginLeft: 1, children: _jsx(Text, { dimColor: dimColor, children: content }) })] }));
|
|
11
11
|
}
|
|
12
12
|
// For long content, show preview or full content
|
|
13
13
|
let preview;
|
|
@@ -22,5 +22,5 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
|
|
|
22
22
|
: linesTruncated;
|
|
23
23
|
}
|
|
24
24
|
const hasMore = !expanded;
|
|
25
|
-
return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom,
|
|
25
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: marginBottom, children: [_jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }), _jsxs(Box, { flexDirection: "column", marginLeft: 1, children: [_jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] })] }));
|
|
26
26
|
};
|
|
@@ -7,12 +7,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
7
7
|
import { useState } from 'react';
|
|
8
8
|
import { Box, Text } from 'ink';
|
|
9
9
|
import { PasswordInput, Select } from '@inkjs/ui';
|
|
10
|
-
import {
|
|
10
|
+
import { getAllProviders, getProvider } from '../providers.js';
|
|
11
|
+
import { resolveApiKey } from '../config.js';
|
|
11
12
|
export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
|
|
12
13
|
const [step, setStep] = useState('select_provider');
|
|
13
14
|
const [selectedProviderId, setSelectedProviderId] = useState(currentConfig.provider);
|
|
14
15
|
const [selectedModelId, setSelectedModelId] = useState(currentConfig.model);
|
|
15
|
-
const providerItems =
|
|
16
|
+
const providerItems = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
|
|
16
17
|
label: `${provider.name} - ${model.name}`,
|
|
17
18
|
value: `${provider.id}:::${model.id}`,
|
|
18
19
|
})));
|
|
@@ -28,12 +29,13 @@ export const ConfigDialog = ({ currentConfig, onComplete, onCancel, }) => {
|
|
|
28
29
|
}
|
|
29
30
|
// API key entry step
|
|
30
31
|
const provider = getProvider(selectedProviderId);
|
|
31
|
-
|
|
32
|
+
const hasResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
|
|
33
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", borderColor: "green", paddingX: 1, children: [_jsx(Text, { color: "green", bold: true, children: "Confirm Configuration" }), _jsxs(Text, { dimColor: true, children: ["Provider: ", provider?.name, " / ", selectedModelId] }), _jsx(Text, { children: hasResolvedAuth ? 'Optional API key (leave empty to keep resolved auth):' : 'Enter your API key:' }), _jsx(PasswordInput, { placeholder: `Paste your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: (value) => {
|
|
32
34
|
const finalApiKey = value.trim().length > 0 ? value.trim() : currentConfig.apiKey;
|
|
33
35
|
const newConfig = {
|
|
34
36
|
provider: selectedProviderId,
|
|
35
37
|
model: selectedModelId,
|
|
36
|
-
apiKey: finalApiKey,
|
|
38
|
+
...(finalApiKey?.trim() ? { apiKey: finalApiKey.trim() } : {}),
|
|
37
39
|
};
|
|
38
40
|
onComplete(newConfig);
|
|
39
41
|
} })] }));
|