protoagent 0.1.5 → 0.1.7
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 +206 -185
- package/dist/agentic-loop.js +103 -8
- package/dist/components/CollapsibleBox.js +4 -3
- package/dist/components/ConsolidatedToolMessage.js +9 -8
- package/dist/components/FormattedMessage.js +3 -2
- package/dist/components/LeftBar.js +30 -0
- package/dist/sub-agent.js +11 -3
- package/dist/system-prompt.js +6 -6
- package/package.json +2 -2
package/dist/App.js
CHANGED
|
@@ -6,10 +6,10 @@ 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
|
|
10
|
-
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
9
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
10
|
+
import { Box, Text, Static, useApp, useInput, useStdout } from 'ink';
|
|
11
|
+
import { LeftBar } from './components/LeftBar.js';
|
|
11
12
|
import { TextInput, Select, PasswordInput } from '@inkjs/ui';
|
|
12
|
-
import BigText from 'ink-big-text';
|
|
13
13
|
import { OpenAI } from 'openai';
|
|
14
14
|
import { readConfig, writeConfig, resolveApiKey } from './config.js';
|
|
15
15
|
import { loadRuntimeConfig } from './runtime-config.js';
|
|
@@ -21,117 +21,103 @@ import { createSession, ensureSystemPromptAtTop, saveSession, loadSession, gener
|
|
|
21
21
|
import { clearTodos, getTodosForSession, setTodosForSession } from './tools/todo.js';
|
|
22
22
|
import { initializeMcp, closeMcp } from './mcp.js';
|
|
23
23
|
import { generateSystemPrompt } from './system-prompt.js';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (skippedIndices.has(localIndex)) {
|
|
32
|
-
return;
|
|
33
|
-
}
|
|
34
|
-
const index = startIndex + localIndex;
|
|
35
|
-
const msgAny = msg;
|
|
36
|
-
const isToolCall = msg.role === 'assistant' && msgAny.tool_calls && msgAny.tool_calls.length > 0;
|
|
37
|
-
const displayContent = 'content' in msg && typeof msg.content === 'string' ? msg.content : null;
|
|
38
|
-
const normalizedContent = normalizeMessageSpacing(displayContent || '');
|
|
39
|
-
const isFirstSystemMessage = msg.role === 'system' && !allMessages.slice(0, index).some((message) => message.role === 'system');
|
|
40
|
-
const previousMessage = index > 0 ? allMessages[index - 1] : null;
|
|
41
|
-
const followsToolMessage = previousMessage?.role === 'tool';
|
|
42
|
-
const currentSpeaker = getVisualSpeaker(msg);
|
|
43
|
-
const previousSpeaker = getVisualSpeaker(previousMessage);
|
|
44
|
-
const isConversationTurn = currentSpeaker === 'user' || currentSpeaker === 'assistant';
|
|
45
|
-
const previousWasConversationTurn = previousSpeaker === 'user' || previousSpeaker === 'assistant';
|
|
46
|
-
const speakerChanged = previousSpeaker !== currentSpeaker;
|
|
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) {
|
|
54
|
-
rendered.push(_jsx(Text, { children: " " }, `spacer-${index}`));
|
|
55
|
-
}
|
|
56
|
-
if (msg.role === 'user') {
|
|
57
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: '> ' }), _jsx(Text, { children: displayContent })] }) }, index));
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
if (msg.role === 'system') {
|
|
61
|
-
rendered.push(_jsx(CollapsibleBox, { title: "System Prompt", content: displayContent || '', titleColor: "green", dimColor: false, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (isToolCall) {
|
|
65
|
-
if (normalizedContent.length > 0) {
|
|
66
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, `${index}-text`));
|
|
67
|
-
}
|
|
68
|
-
const toolCalls = msgAny.tool_calls.map((tc) => ({
|
|
69
|
-
id: tc.id,
|
|
70
|
-
name: tc.function?.name || 'tool',
|
|
71
|
-
}));
|
|
72
|
-
const toolResults = new Map();
|
|
73
|
-
let nextLocalIndex = localIndex + 1;
|
|
74
|
-
for (const toolCall of toolCalls) {
|
|
75
|
-
if (nextLocalIndex < messagesToRender.length) {
|
|
76
|
-
const nextMsg = messagesToRender[nextLocalIndex];
|
|
77
|
-
if (nextMsg.role === 'tool' && nextMsg.tool_call_id === toolCall.id) {
|
|
78
|
-
toolResults.set(toolCall.id, {
|
|
79
|
-
content: normalizeMessageSpacing(nextMsg.content || ''),
|
|
80
|
-
name: nextMsg.name || toolCall.name,
|
|
81
|
-
});
|
|
82
|
-
skippedIndices.add(nextLocalIndex);
|
|
83
|
-
nextLocalIndex++;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
rendered.push(_jsx(ConsolidatedToolMessage, { toolCalls: toolCalls, toolResults: toolResults, expanded: expandedMessages.has(index) }, index));
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
if (msg.role === 'tool') {
|
|
91
|
-
rendered.push(_jsx(CollapsibleBox, { title: `${msgAny.name || 'tool'} result`, content: normalizedContent, dimColor: true, maxPreviewLines: 3, expanded: expandedMessages.has(index) }, index));
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
rendered.push(_jsx(Box, { flexDirection: "column", children: _jsx(FormattedMessage, { content: normalizedContent, deferTables: deferTables }) }, index));
|
|
95
|
-
});
|
|
96
|
-
return rendered;
|
|
24
|
+
// ─── Scrollback helpers ───
|
|
25
|
+
// These functions append text to the permanent scrollback buffer via the
|
|
26
|
+
// <Static> component. Ink flushes new Static items within its own render
|
|
27
|
+
// cycle, so there are no timing issues with write()/log-update.
|
|
28
|
+
let _staticCounter = 0;
|
|
29
|
+
function makeStaticId() {
|
|
30
|
+
return `s${++_staticCounter}`;
|
|
97
31
|
}
|
|
98
|
-
function
|
|
99
|
-
const
|
|
32
|
+
function printBanner(addStatic) {
|
|
33
|
+
const green = '\x1b[38;2;9;164;105m';
|
|
34
|
+
const reset = '\x1b[0m';
|
|
35
|
+
addStatic([
|
|
36
|
+
`${green}█▀█ █▀█ █▀█ ▀█▀ █▀█ ▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${reset}`,
|
|
37
|
+
`${green}█▀▀ █▀▄ █▄█ █ █▄█ █▀█ █▄█ ██▄ █ ▀█ █${reset}`,
|
|
38
|
+
'',
|
|
39
|
+
].join('\n'));
|
|
40
|
+
}
|
|
41
|
+
function printRuntimeHeader(addStatic, config, session, logFilePath, dangerouslyAcceptAll) {
|
|
42
|
+
const provider = getProvider(config.provider);
|
|
43
|
+
let line = `Model: ${provider?.name || config.provider} / ${config.model}`;
|
|
44
|
+
if (dangerouslyAcceptAll)
|
|
45
|
+
line += ' (auto-approve all)';
|
|
46
|
+
if (session)
|
|
47
|
+
line += ` | Session: ${session.id.slice(0, 8)}`;
|
|
48
|
+
let text = `${line}\n`;
|
|
49
|
+
if (logFilePath) {
|
|
50
|
+
text += `Debug logs: ${logFilePath}\n`;
|
|
51
|
+
}
|
|
52
|
+
text += '\n';
|
|
53
|
+
addStatic(text);
|
|
54
|
+
}
|
|
55
|
+
function normalizeTranscriptText(text) {
|
|
56
|
+
const normalized = text.replace(/\r\n/g, '\n');
|
|
100
57
|
const lines = normalized.split('\n');
|
|
101
|
-
while (lines.length > 0 && lines[0].trim() === '')
|
|
58
|
+
while (lines.length > 0 && lines[0].trim() === '')
|
|
102
59
|
lines.shift();
|
|
103
|
-
|
|
104
|
-
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
|
|
60
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === '')
|
|
105
61
|
lines.pop();
|
|
106
|
-
}
|
|
107
62
|
return lines.join('\n');
|
|
108
63
|
}
|
|
109
|
-
function
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return
|
|
114
|
-
|
|
115
|
-
|
|
64
|
+
function printMessageToScrollback(addStatic, role, text) {
|
|
65
|
+
const normalized = normalizeTranscriptText(text);
|
|
66
|
+
if (!normalized) {
|
|
67
|
+
addStatic('\n');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (role === 'user') {
|
|
71
|
+
addStatic(`\x1b[32m>\x1b[0m ${normalized}\n`);
|
|
72
|
+
return;
|
|
116
73
|
}
|
|
117
|
-
|
|
74
|
+
addStatic(`${normalized}\n\n`);
|
|
75
|
+
}
|
|
76
|
+
function replayMessagesToScrollback(addStatic, messages) {
|
|
77
|
+
for (const message of messages) {
|
|
78
|
+
const msgAny = message;
|
|
79
|
+
if (message.role === 'system')
|
|
80
|
+
continue;
|
|
81
|
+
if (message.role === 'user' && typeof message.content === 'string') {
|
|
82
|
+
printMessageToScrollback(addStatic, 'user', message.content);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (message.role === 'assistant' && typeof message.content === 'string' && message.content.trim().length > 0) {
|
|
86
|
+
printMessageToScrollback(addStatic, 'assistant', message.content);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (message.role === 'tool') {
|
|
90
|
+
const toolName = msgAny.name || 'tool';
|
|
91
|
+
const compact = String(msgAny.content || '').replace(/\s+/g, ' ').trim().slice(0, 180);
|
|
92
|
+
addStatic(`\x1b[2m▶ ${toolName}: ${compact}\x1b[0m\n`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (messages.length > 0) {
|
|
96
|
+
addStatic('\n');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Returns only the last N displayable lines of text so the live streaming box
|
|
100
|
+
// never grows taller than the terminal, preventing Ink's clearTerminal wipe.
|
|
101
|
+
const STREAMING_RESERVED_ROWS = 3; // usage bar + spinner + input line
|
|
102
|
+
function clipToRows(text, terminalRows) {
|
|
103
|
+
const maxLines = Math.max(1, terminalRows - STREAMING_RESERVED_ROWS);
|
|
104
|
+
const lines = text.split('\n');
|
|
105
|
+
if (lines.length <= maxLines)
|
|
106
|
+
return text;
|
|
107
|
+
return lines.slice(lines.length - maxLines).join('\n');
|
|
118
108
|
}
|
|
119
109
|
// ─── Available slash commands ───
|
|
120
110
|
const SLASH_COMMANDS = [
|
|
121
111
|
{ name: '/clear', description: 'Clear conversation and start fresh' },
|
|
122
|
-
{ name: '/collapse', description: 'Collapse all long messages' },
|
|
123
|
-
{ name: '/expand', description: 'Expand all collapsed messages' },
|
|
124
112
|
{ name: '/help', description: 'Show all available commands' },
|
|
125
113
|
{ name: '/quit', description: 'Exit ProtoAgent' },
|
|
126
114
|
];
|
|
127
115
|
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
128
116
|
const HELP_TEXT = [
|
|
129
117
|
'Commands:',
|
|
130
|
-
' /clear
|
|
131
|
-
' /
|
|
132
|
-
' /
|
|
133
|
-
' /help - Show this help',
|
|
134
|
-
' /quit - Exit ProtoAgent',
|
|
118
|
+
' /clear - Clear conversation and start fresh',
|
|
119
|
+
' /help - Show this help',
|
|
120
|
+
' /quit - Exit ProtoAgent',
|
|
135
121
|
].join('\n');
|
|
136
122
|
function buildClient(config) {
|
|
137
123
|
const provider = getProvider(config.provider);
|
|
@@ -194,13 +180,13 @@ const ApprovalPrompt = ({ request, onRespond }) => {
|
|
|
194
180
|
{ label: sessionApprovalLabel, value: 'approve_session' },
|
|
195
181
|
{ label: 'Reject', value: 'reject' },
|
|
196
182
|
];
|
|
197
|
-
return (_jsxs(
|
|
183
|
+
return (_jsxs(LeftBar, { color: "green", marginTop: 1, marginBottom: 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) }) })] }));
|
|
198
184
|
};
|
|
199
185
|
/** Cost/usage display in the status bar. */
|
|
200
186
|
const UsageDisplay = ({ usage, totalCost }) => {
|
|
201
187
|
if (!usage && totalCost === 0)
|
|
202
188
|
return null;
|
|
203
|
-
return (_jsxs(Box, {
|
|
189
|
+
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)] }))] }));
|
|
204
190
|
};
|
|
205
191
|
/** Inline setup wizard — shown when no config exists. */
|
|
206
192
|
const InlineSetup = ({ onComplete }) => {
|
|
@@ -240,10 +226,27 @@ const InlineSetup = ({ onComplete }) => {
|
|
|
240
226
|
export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
241
227
|
const { exit } = useApp();
|
|
242
228
|
const { stdout } = useStdout();
|
|
229
|
+
const terminalRows = stdout?.rows ?? 24;
|
|
230
|
+
// ─── Static scrollback state ───
|
|
231
|
+
// Each item appended here is rendered once by <Static> and permanently
|
|
232
|
+
// flushed to the terminal scrollback by Ink, within its own render cycle.
|
|
233
|
+
// This eliminates all write()/log-update timing issues.
|
|
234
|
+
const [staticItems, setStaticItems] = useState([]);
|
|
235
|
+
const addStatic = useCallback((text) => {
|
|
236
|
+
setStaticItems((prev) => [...prev, { id: makeStaticId(), text }]);
|
|
237
|
+
}, []);
|
|
243
238
|
// Core state
|
|
244
239
|
const [config, setConfig] = useState(null);
|
|
245
240
|
const [completionMessages, setCompletionMessages] = useState([]);
|
|
246
241
|
const [inputText, setInputText] = useState('');
|
|
242
|
+
// isStreaming: true while the assistant is producing tokens.
|
|
243
|
+
// streamingText: the live in-progress token buffer shown in the dynamic Ink
|
|
244
|
+
// frame while the response streams. Cleared to '' at done and flushed to
|
|
245
|
+
// <Static> as a permanent scrollback item. Keeping it in React state (not a
|
|
246
|
+
// ref) is safe because the Ink frame height does NOT change as tokens arrive —
|
|
247
|
+
// the streaming box is always 1+ lines tall while loading=true.
|
|
248
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
249
|
+
const [streamingText, setStreamingText] = useState('');
|
|
247
250
|
const [loading, setLoading] = useState(false);
|
|
248
251
|
const [error, setError] = useState(null);
|
|
249
252
|
const [helpMessage, setHelpMessage] = useState(null);
|
|
@@ -253,18 +256,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
253
256
|
const [logFilePath, setLogFilePath] = useState(null);
|
|
254
257
|
// Input reset key — incremented on submit to force TextInput remount and clear
|
|
255
258
|
const [inputResetKey, setInputResetKey] = useState(0);
|
|
256
|
-
const [inputWidthKey, setInputWidthKey] = useState(stdout?.columns ?? 80);
|
|
257
|
-
// Collapsible state — only applies to live (current turn) messages
|
|
258
|
-
const [expandedMessages, setExpandedMessages] = useState(new Set());
|
|
259
|
-
const expandLatestMessage = useCallback((index) => {
|
|
260
|
-
setExpandedMessages((prev) => {
|
|
261
|
-
if (prev.has(index))
|
|
262
|
-
return prev;
|
|
263
|
-
const next = new Set(prev);
|
|
264
|
-
next.add(index);
|
|
265
|
-
return next;
|
|
266
|
-
});
|
|
267
|
-
}, []);
|
|
268
259
|
// Approval state
|
|
269
260
|
const [pendingApproval, setPendingApproval] = useState(null);
|
|
270
261
|
// Usage state
|
|
@@ -279,12 +270,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
279
270
|
const [quittingSession, setQuittingSession] = useState(null);
|
|
280
271
|
// OpenAI client ref (stable across renders)
|
|
281
272
|
const clientRef = useRef(null);
|
|
282
|
-
// Track current assistant message being built in the event handler
|
|
283
273
|
const assistantMessageRef = useRef(null);
|
|
284
274
|
// Abort controller for cancelling the current completion
|
|
285
275
|
const abortControllerRef = useRef(null);
|
|
286
|
-
|
|
287
|
-
const
|
|
276
|
+
const didPrintIntroRef = useRef(false);
|
|
277
|
+
const printedThreadErrorIdsRef = useRef(new Set());
|
|
278
|
+
const printedLogPathRef = useRef(null);
|
|
288
279
|
// ─── Post-config initialization (reused after inline setup) ───
|
|
289
280
|
const initializeWithConfig = useCallback(async (loadedConfig) => {
|
|
290
281
|
setConfig(loadedConfig);
|
|
@@ -301,6 +292,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
301
292
|
setTodosForSession(loadedSession.id, loadedSession.todos);
|
|
302
293
|
setSession(loadedSession);
|
|
303
294
|
setCompletionMessages(loadedSession.completionMessages);
|
|
295
|
+
if (!didPrintIntroRef.current) {
|
|
296
|
+
printBanner(addStatic);
|
|
297
|
+
printRuntimeHeader(addStatic, loadedConfig, loadedSession, logFilePath, dangerouslyAcceptAll);
|
|
298
|
+
replayMessagesToScrollback(addStatic, loadedSession.completionMessages);
|
|
299
|
+
didPrintIntroRef.current = true;
|
|
300
|
+
}
|
|
304
301
|
}
|
|
305
302
|
else {
|
|
306
303
|
setError(`Session "${sessionId}" not found. Starting a new session.`);
|
|
@@ -314,10 +311,15 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
314
311
|
clearTodos(newSession.id);
|
|
315
312
|
newSession.completionMessages = initialCompletionMessages;
|
|
316
313
|
setSession(newSession);
|
|
314
|
+
if (!didPrintIntroRef.current) {
|
|
315
|
+
printBanner(addStatic);
|
|
316
|
+
printRuntimeHeader(addStatic, loadedConfig, newSession, logFilePath, dangerouslyAcceptAll);
|
|
317
|
+
didPrintIntroRef.current = true;
|
|
318
|
+
}
|
|
317
319
|
}
|
|
318
320
|
setNeedsSetup(false);
|
|
319
321
|
setInitialized(true);
|
|
320
|
-
}, [sessionId]);
|
|
322
|
+
}, [dangerouslyAcceptAll, logFilePath, sessionId, addStatic]);
|
|
321
323
|
// ─── Initialization ───
|
|
322
324
|
useEffect(() => {
|
|
323
325
|
if (!loading) {
|
|
@@ -330,17 +332,26 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
330
332
|
return () => clearInterval(interval);
|
|
331
333
|
}, [loading]);
|
|
332
334
|
useEffect(() => {
|
|
333
|
-
if (
|
|
335
|
+
if (error) {
|
|
336
|
+
addStatic(`\x1b[31mError: ${error}\x1b[0m\n\n`);
|
|
337
|
+
}
|
|
338
|
+
}, [error, addStatic]);
|
|
339
|
+
useEffect(() => {
|
|
340
|
+
if (!didPrintIntroRef.current || !logFilePath || printedLogPathRef.current === logFilePath) {
|
|
334
341
|
return;
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
}
|
|
343
|
+
printedLogPathRef.current = logFilePath;
|
|
344
|
+
addStatic(`Debug logs: ${logFilePath}\n\n`);
|
|
345
|
+
}, [logFilePath, addStatic]);
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
for (const threadError of threadErrors) {
|
|
348
|
+
if (threadError.transient || printedThreadErrorIdsRef.current.has(threadError.id)) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
printedThreadErrorIdsRef.current.add(threadError.id);
|
|
352
|
+
addStatic(`\x1b[31mError: ${threadError.message}\x1b[0m\n\n`);
|
|
353
|
+
}
|
|
354
|
+
}, [threadErrors, addStatic]);
|
|
344
355
|
useEffect(() => {
|
|
345
356
|
const init = async () => {
|
|
346
357
|
// Set log level and initialize log file
|
|
@@ -418,7 +429,6 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
418
429
|
setLastUsage(null);
|
|
419
430
|
setTotalCost(0);
|
|
420
431
|
setThreadErrors([]);
|
|
421
|
-
setExpandedMessages(new Set());
|
|
422
432
|
if (session) {
|
|
423
433
|
const newSession = createSession(config.model, config.provider);
|
|
424
434
|
clearTodos(session.id);
|
|
@@ -429,13 +439,8 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
429
439
|
});
|
|
430
440
|
return true;
|
|
431
441
|
case '/expand':
|
|
432
|
-
// Expand all collapsed messages
|
|
433
|
-
const allIndices = new Set(completionMessages.map((_, i) => i));
|
|
434
|
-
setExpandedMessages(allIndices);
|
|
435
|
-
return true;
|
|
436
442
|
case '/collapse':
|
|
437
|
-
//
|
|
438
|
-
setExpandedMessages(new Set());
|
|
443
|
+
// expand/collapse removed — transcript lives in scrollback
|
|
439
444
|
return true;
|
|
440
445
|
case '/help':
|
|
441
446
|
setHelpMessage(HELP_TEXT);
|
|
@@ -461,13 +466,19 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
461
466
|
setInputText('');
|
|
462
467
|
setInputResetKey((prev) => prev + 1); // Force TextInput to remount and clear
|
|
463
468
|
setLoading(true);
|
|
469
|
+
setIsStreaming(false);
|
|
470
|
+
setStreamingText('');
|
|
464
471
|
setError(null);
|
|
465
472
|
setHelpMessage(null);
|
|
466
473
|
setThreadErrors([]);
|
|
467
|
-
//
|
|
474
|
+
// Reset turn tracking
|
|
475
|
+
assistantMessageRef.current = null;
|
|
476
|
+
// Print the user message directly to scrollback so it is selectable/copyable.
|
|
477
|
+
// We still push it into completionMessages for session saving.
|
|
468
478
|
const userMessage = { role: 'user', content: trimmed };
|
|
479
|
+
printMessageToScrollback(addStatic, 'user', trimmed);
|
|
469
480
|
setCompletionMessages((prev) => [...prev, userMessage]);
|
|
470
|
-
// Reset assistant message tracker
|
|
481
|
+
// Reset assistant message tracker (streamed indices were reset above)
|
|
471
482
|
assistantMessageRef.current = null;
|
|
472
483
|
try {
|
|
473
484
|
const pricing = getModelPricing(config.provider, config.model);
|
|
@@ -476,30 +487,35 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
476
487
|
abortControllerRef.current = new AbortController();
|
|
477
488
|
const updatedMessages = await runAgenticLoop(clientRef.current, config.model, [...completionMessages, userMessage], trimmed, (event) => {
|
|
478
489
|
switch (event.type) {
|
|
479
|
-
case 'text_delta':
|
|
480
|
-
//
|
|
490
|
+
case 'text_delta': {
|
|
491
|
+
// Accumulate tokens into streamingText React state — shown live in
|
|
492
|
+
// the dynamic Ink frame. The frame height stays constant (spinner +
|
|
493
|
+
// streaming box + input) so setState here does NOT trigger
|
|
494
|
+
// clearTerminal. At 'done' the full text is flushed to <Static>.
|
|
481
495
|
if (!assistantMessageRef.current || assistantMessageRef.current.kind !== 'streaming_text') {
|
|
482
|
-
// First text delta
|
|
496
|
+
// First text delta of this turn: initialise ref, show streaming indicator.
|
|
483
497
|
const assistantMsg = { role: 'assistant', content: event.content || '', tool_calls: [] };
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
498
|
+
const idx = completionMessages.length + 1;
|
|
499
|
+
assistantMessageRef.current = { message: assistantMsg, index: idx, kind: 'streaming_text' };
|
|
500
|
+
setIsStreaming(true);
|
|
501
|
+
setStreamingText(event.content || '');
|
|
502
|
+
setCompletionMessages((prev) => [...prev, assistantMsg]);
|
|
488
503
|
}
|
|
489
504
|
else {
|
|
490
|
-
// Subsequent deltas —
|
|
505
|
+
// Subsequent deltas — append to ref AND to React state for live display.
|
|
491
506
|
assistantMessageRef.current.message.content += event.content || '';
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
507
|
+
setStreamingText((prev) => prev + (event.content || ''));
|
|
508
|
+
}
|
|
509
|
+
break;
|
|
510
|
+
}
|
|
511
|
+
case 'sub_agent_iteration':
|
|
512
|
+
if (event.subAgentTool) {
|
|
513
|
+
const { tool, status } = event.subAgentTool;
|
|
514
|
+
if (status === 'running') {
|
|
515
|
+
setActiveTool(`sub_agent → ${tool}`);
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
setActiveTool(null);
|
|
503
519
|
}
|
|
504
520
|
}
|
|
505
521
|
break;
|
|
@@ -553,16 +569,20 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
553
569
|
if (event.toolCall) {
|
|
554
570
|
const toolCall = event.toolCall;
|
|
555
571
|
setActiveTool(null);
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
572
|
+
// Write the tool summary immediately — at this point loading is
|
|
573
|
+
// still true but the frame height is stable (spinner + input box).
|
|
574
|
+
// The next state change (setActiveTool(null)) doesn't affect
|
|
575
|
+
// frame height so write() restores the correct frame.
|
|
576
|
+
const compactResult = (toolCall.result || '')
|
|
577
|
+
.replace(/\s+/g, ' ')
|
|
578
|
+
.trim()
|
|
579
|
+
.slice(0, 180);
|
|
580
|
+
addStatic(`\x1b[2m▶ ${toolCall.name}: ${compactResult}\x1b[0m\n`);
|
|
581
|
+
// Flush the assistant message + tool result into completionMessages
|
|
582
|
+
// for session saving.
|
|
563
583
|
setCompletionMessages((prev) => {
|
|
564
584
|
const updated = [...prev];
|
|
565
|
-
// Sync assistant message
|
|
585
|
+
// Sync assistant message
|
|
566
586
|
if (assistantMessageRef.current) {
|
|
567
587
|
updated[assistantMessageRef.current.index] = {
|
|
568
588
|
...assistantMessageRef.current.message,
|
|
@@ -617,13 +637,27 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
617
637
|
}
|
|
618
638
|
break;
|
|
619
639
|
case 'iteration_done':
|
|
620
|
-
assistantMessageRef.current
|
|
640
|
+
if (assistantMessageRef.current?.kind === 'tool_call_assistant') {
|
|
641
|
+
assistantMessageRef.current = null;
|
|
642
|
+
}
|
|
621
643
|
break;
|
|
622
644
|
case 'done':
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
645
|
+
if (assistantMessageRef.current?.kind === 'streaming_text') {
|
|
646
|
+
const finalRef = assistantMessageRef.current;
|
|
647
|
+
// Flush the complete streamed text to <Static> (permanent scrollback),
|
|
648
|
+
// then clear the live streaming state from the dynamic Ink frame.
|
|
649
|
+
const normalized = normalizeTranscriptText(finalRef.message.content || '');
|
|
650
|
+
if (normalized) {
|
|
651
|
+
addStatic(`${normalized}\n\n`);
|
|
652
|
+
}
|
|
653
|
+
setIsStreaming(false);
|
|
654
|
+
setStreamingText('');
|
|
655
|
+
setCompletionMessages((prev) => {
|
|
656
|
+
const updated = [...prev];
|
|
657
|
+
updated[finalRef.index] = { ...finalRef.message };
|
|
658
|
+
return updated;
|
|
659
|
+
});
|
|
660
|
+
assistantMessageRef.current = null;
|
|
627
661
|
}
|
|
628
662
|
setActiveTool(null);
|
|
629
663
|
setThreadErrors((prev) => prev.filter((threadError) => !threadError.transient));
|
|
@@ -651,7 +685,7 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
651
685
|
finally {
|
|
652
686
|
setLoading(false);
|
|
653
687
|
}
|
|
654
|
-
}, [loading, config, completionMessages, session, handleSlashCommand,
|
|
688
|
+
}, [loading, config, completionMessages, session, handleSlashCommand, addStatic]);
|
|
655
689
|
// ─── Keyboard shortcuts ───
|
|
656
690
|
useInput((input, key) => {
|
|
657
691
|
if (key.ctrl && input === 'c') {
|
|
@@ -663,25 +697,12 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
|
|
|
663
697
|
}
|
|
664
698
|
});
|
|
665
699
|
// ─── Render ───
|
|
666
|
-
|
|
667
|
-
const liveStartIndex = loading
|
|
668
|
-
? (typeof assistantMessageRef.current?.index === 'number'
|
|
669
|
-
? assistantMessageRef.current.index
|
|
670
|
-
: Math.max(completionMessages.length - 1, 0))
|
|
671
|
-
: completionMessages.length;
|
|
672
|
-
const archivedMessages = completionMessages.slice(0, liveStartIndex);
|
|
673
|
-
const liveMessages = completionMessages.slice(liveStartIndex);
|
|
674
|
-
const archivedMessageNodes = useMemo(() => renderMessageList(archivedMessages, completionMessages, expandedMessages), [archivedMessages, completionMessages, expandedMessages]);
|
|
675
|
-
const liveMessageNodes = useMemo(() => renderMessageList(liveMessages, completionMessages, expandedMessages, liveStartIndex, loading), [liveMessages, completionMessages, expandedMessages, liveStartIndex, loading]);
|
|
676
|
-
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) => {
|
|
700
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: staticItems, children: (item) => (_jsx(Text, { children: item.text }, item.id)) }), helpMessage && (_jsx(LeftBar, { color: "green", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: helpMessage }) })), !initialized && !error && !needsSetup && _jsx(Text, { children: "Initializing..." }), needsSetup && (_jsx(InlineSetup, { onComplete: (newConfig) => {
|
|
677
701
|
initializeWithConfig(newConfig).catch((err) => {
|
|
678
702
|
setError(`Initialization failed: ${err.message}`);
|
|
679
703
|
});
|
|
680
|
-
} })), _jsxs(
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
pendingApproval.resolve(response);
|
|
685
|
-
setPendingApproval(null);
|
|
686
|
-
} }))] }), _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] })] }))] }));
|
|
704
|
+
} })), isStreaming && (_jsxs(Text, { wrap: "wrap", children: [clipToRows(streamingText, terminalRows), _jsx(Text, { dimColor: true, children: "\u258D" })] })), threadErrors.filter((threadError) => threadError.transient).map((threadError) => (_jsx(LeftBar, { color: "red", marginBottom: 1, children: _jsxs(Text, { color: "red", children: ["Error: ", threadError.message] }) }, `thread-error-${threadError.id}`))), pendingApproval && (_jsx(ApprovalPrompt, { request: pendingApproval.request, onRespond: (response) => {
|
|
705
|
+
pendingApproval.resolve(response);
|
|
706
|
+
setPendingApproval(null);
|
|
707
|
+
} })), _jsx(UsageDisplay, { usage: lastUsage ?? null, totalCost: totalCost }), initialized && !pendingApproval && inputText.startsWith('/') && (_jsx(CommandFilter, { inputText: inputText })), initialized && !pendingApproval && loading && !isStreaming && (_jsx(Box, { children: _jsxs(Text, { color: "green", bold: true, children: [SPINNER_FRAMES[spinnerFrame], ' ', activeTool ? `Running ${activeTool}...` : 'Working...'] }) })), initialized && !pendingApproval && (_jsx(Box, { 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) })] }) })), 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] })] }))] }));
|
|
687
708
|
};
|
package/dist/agentic-loop.js
CHANGED
|
@@ -126,6 +126,21 @@ function extractFirstCompleteJsonValue(value) {
|
|
|
126
126
|
}
|
|
127
127
|
return null;
|
|
128
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Repair invalid JSON escape sequences in a string value.
|
|
131
|
+
*
|
|
132
|
+
* JSON only allows: \" \\ \/ \b \f \n \r \t \uXXXX
|
|
133
|
+
* Models sometimes emit \| \! \- etc. (e.g. grep regex args) which make
|
|
134
|
+
* JSON.parse throw, and Anthropic strict-validates tool_call arguments on
|
|
135
|
+
* every subsequent request, bricking the session permanently.
|
|
136
|
+
*
|
|
137
|
+
* We double the backslash for any \X where X is not a valid JSON escape char.
|
|
138
|
+
*/
|
|
139
|
+
function repairInvalidEscapes(value) {
|
|
140
|
+
// Match a backslash followed by any character that is NOT a valid JSON escape
|
|
141
|
+
// Valid escapes: " \ / b f n r t u
|
|
142
|
+
return value.replace(/\\([^"\\\/bfnrtu])/g, '\\\\$1');
|
|
143
|
+
}
|
|
129
144
|
function normalizeJsonArguments(argumentsText) {
|
|
130
145
|
const trimmed = argumentsText.trim();
|
|
131
146
|
if (!trimmed)
|
|
@@ -157,6 +172,25 @@ function normalizeJsonArguments(argumentsText) {
|
|
|
157
172
|
// Give up and return the original text below.
|
|
158
173
|
}
|
|
159
174
|
}
|
|
175
|
+
// Heuristic: repair invalid escape sequences (e.g. \| from grep regex args)
|
|
176
|
+
const repaired = repairInvalidEscapes(trimmed);
|
|
177
|
+
if (repaired !== trimmed) {
|
|
178
|
+
try {
|
|
179
|
+
JSON.parse(repaired);
|
|
180
|
+
return repaired;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Try repair + first-value extraction together
|
|
184
|
+
const repairedFirst = extractFirstCompleteJsonValue(repaired);
|
|
185
|
+
if (repairedFirst) {
|
|
186
|
+
try {
|
|
187
|
+
JSON.parse(repairedFirst);
|
|
188
|
+
return repairedFirst;
|
|
189
|
+
}
|
|
190
|
+
catch { /* give up */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
160
194
|
return argumentsText;
|
|
161
195
|
}
|
|
162
196
|
function sanitizeToolCall(toolCall, validToolNames) {
|
|
@@ -231,6 +265,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
231
265
|
}
|
|
232
266
|
let iterationCount = 0;
|
|
233
267
|
let repairRetryCount = 0;
|
|
268
|
+
let contextRetryCount = 0;
|
|
234
269
|
const validToolNames = getValidToolNames();
|
|
235
270
|
while (iterationCount < maxIterations) {
|
|
236
271
|
// Check if abort was requested
|
|
@@ -389,10 +424,24 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
389
424
|
})),
|
|
390
425
|
});
|
|
391
426
|
updatedMessages.push(assistantMessage);
|
|
427
|
+
// Track which tool_call_ids still need a tool result message.
|
|
428
|
+
// This set is used to inject stub responses on abort, preventing
|
|
429
|
+
// orphaned tool_call_ids from permanently bricking the session.
|
|
430
|
+
const pendingToolCallIds = new Set(assistantMessage.tool_calls.map((tc) => tc.id));
|
|
431
|
+
const injectStubsForPendingToolCalls = () => {
|
|
432
|
+
for (const id of pendingToolCallIds) {
|
|
433
|
+
updatedMessages.push({
|
|
434
|
+
role: 'tool',
|
|
435
|
+
tool_call_id: id,
|
|
436
|
+
content: 'Aborted by user.',
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
};
|
|
392
440
|
for (const toolCall of assistantMessage.tool_calls) {
|
|
393
441
|
// Check abort between tool calls
|
|
394
442
|
if (abortSignal?.aborted) {
|
|
395
443
|
logger.debug('Agentic loop aborted between tool calls');
|
|
444
|
+
injectStubsForPendingToolCalls();
|
|
396
445
|
emitAbortAndFinish(onEvent);
|
|
397
446
|
return updatedMessages;
|
|
398
447
|
}
|
|
@@ -408,16 +457,11 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
408
457
|
if (name === 'sub_agent') {
|
|
409
458
|
const subProgress = (evt) => {
|
|
410
459
|
onEvent({
|
|
411
|
-
type: '
|
|
412
|
-
|
|
413
|
-
id: toolCall.id,
|
|
414
|
-
name: `sub_agent → ${evt.tool}`,
|
|
415
|
-
args: '',
|
|
416
|
-
status: evt.status === 'running' ? 'running' : 'done',
|
|
417
|
-
},
|
|
460
|
+
type: 'sub_agent_iteration',
|
|
461
|
+
subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration },
|
|
418
462
|
});
|
|
419
463
|
};
|
|
420
|
-
result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress);
|
|
464
|
+
result = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress, abortSignal);
|
|
421
465
|
}
|
|
422
466
|
else {
|
|
423
467
|
result = await handleToolCall(name, args, { sessionId, abortSignal });
|
|
@@ -433,6 +477,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
433
477
|
tool_call_id: toolCall.id,
|
|
434
478
|
content: result,
|
|
435
479
|
});
|
|
480
|
+
pendingToolCallIds.delete(toolCall.id);
|
|
436
481
|
onEvent({
|
|
437
482
|
type: 'tool_result',
|
|
438
483
|
toolCall: { id: toolCall.id, name, args: argsStr, status: 'done', result },
|
|
@@ -445,6 +490,14 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
445
490
|
tool_call_id: toolCall.id,
|
|
446
491
|
content: `Error: ${errMsg}`,
|
|
447
492
|
});
|
|
493
|
+
pendingToolCallIds.delete(toolCall.id);
|
|
494
|
+
// If the tool was aborted, inject stubs for remaining pending calls and stop
|
|
495
|
+
if (abortSignal?.aborted || (err instanceof Error && (err.name === 'AbortError' || err.message === 'Operation aborted'))) {
|
|
496
|
+
logger.debug('Agentic loop aborted during tool execution');
|
|
497
|
+
injectStubsForPendingToolCalls();
|
|
498
|
+
emitAbortAndFinish(onEvent);
|
|
499
|
+
return updatedMessages;
|
|
500
|
+
}
|
|
448
501
|
onEvent({
|
|
449
502
|
type: 'tool_result',
|
|
450
503
|
toolCall: { id: toolCall.id, name, args: argsStr, status: 'error', result: errMsg },
|
|
@@ -520,6 +573,48 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
520
573
|
continue;
|
|
521
574
|
}
|
|
522
575
|
}
|
|
576
|
+
// Handle context-window-exceeded (prompt too long) — attempt forced compaction
|
|
577
|
+
// This fires when our token estimate was too low (e.g. base64 images from MCP tools)
|
|
578
|
+
// and the request actually hit the hard provider limit.
|
|
579
|
+
const isContextTooLong = apiError?.status === 400 &&
|
|
580
|
+
typeof errMsg === 'string' &&
|
|
581
|
+
/prompt.{0,30}too long|context.{0,30}length|maximum.{0,30}token|tokens?.{0,10}exceed/i.test(errMsg);
|
|
582
|
+
if (isContextTooLong && contextRetryCount < 2) {
|
|
583
|
+
contextRetryCount++;
|
|
584
|
+
logger.warn(`Prompt too long (attempt ${contextRetryCount}); forcing compaction`, { errMsg });
|
|
585
|
+
onEvent({
|
|
586
|
+
type: 'error',
|
|
587
|
+
error: 'Prompt too long. Compacting conversation and retrying...',
|
|
588
|
+
transient: true,
|
|
589
|
+
});
|
|
590
|
+
if (pricing) {
|
|
591
|
+
// Use the normal LLM-based compaction path
|
|
592
|
+
try {
|
|
593
|
+
const compacted = await compactIfNeeded(client, model, updatedMessages, pricing.contextWindow,
|
|
594
|
+
// Pass the context window itself as currentTokens to force compaction
|
|
595
|
+
pricing.contextWindow, requestDefaults, sessionId);
|
|
596
|
+
updatedMessages.length = 0;
|
|
597
|
+
updatedMessages.push(...compacted);
|
|
598
|
+
}
|
|
599
|
+
catch (compactErr) {
|
|
600
|
+
logger.error(`Forced compaction failed: ${compactErr}`);
|
|
601
|
+
// Fall through to truncation fallback below
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
// Fallback: truncate any tool result messages whose content looks like
|
|
605
|
+
// base64 or is extremely large (e.g. MCP screenshot data)
|
|
606
|
+
const MAX_TOOL_RESULT_CHARS = 20_000;
|
|
607
|
+
for (let i = 0; i < updatedMessages.length; i++) {
|
|
608
|
+
const m = updatedMessages[i];
|
|
609
|
+
if (m.role === 'tool' && typeof m.content === 'string' && m.content.length > MAX_TOOL_RESULT_CHARS) {
|
|
610
|
+
updatedMessages[i] = {
|
|
611
|
+
...m,
|
|
612
|
+
content: m.content.slice(0, MAX_TOOL_RESULT_CHARS) + '\n... (truncated — content was too large)',
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
523
618
|
// Retry on 429 (rate limit) with backoff
|
|
524
619
|
if (apiError?.status === 429) {
|
|
525
620
|
const retryAfter = parseInt(apiError?.headers?.['retry-after'] || '5', 10);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import { LeftBar } from './LeftBar.js';
|
|
3
4
|
export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, maxPreviewLines = 3, maxPreviewChars = 500, expanded = false, marginBottom = 0, }) => {
|
|
4
5
|
const lines = content.split('\n');
|
|
5
6
|
const isTooManyLines = lines.length > maxPreviewLines;
|
|
@@ -7,7 +8,7 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
|
|
|
7
8
|
const isLong = isTooManyLines || isTooManyChars;
|
|
8
9
|
// If content is short, always show it
|
|
9
10
|
if (!isLong) {
|
|
10
|
-
return (_jsxs(
|
|
11
|
+
return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsx(Text, { color: titleColor, dimColor: dimColor, bold: true, children: title }), _jsx(Text, { dimColor: dimColor, children: content })] }));
|
|
11
12
|
}
|
|
12
13
|
// For long content, show preview or full content
|
|
13
14
|
let preview;
|
|
@@ -22,5 +23,5 @@ export const CollapsibleBox = ({ title, content, titleColor, dimColor = false, m
|
|
|
22
23
|
: linesTruncated;
|
|
23
24
|
}
|
|
24
25
|
const hasMore = !expanded;
|
|
25
|
-
return (_jsxs(
|
|
26
|
+
return (_jsxs(LeftBar, { color: titleColor ?? 'white', marginBottom: marginBottom, children: [_jsxs(Text, { color: titleColor, dimColor: dimColor, bold: true, children: [expanded ? '▼' : '▶', " ", title] }), _jsx(Text, { dimColor: dimColor, children: preview }), hasMore && _jsx(Text, { dimColor: true, children: "... (use /expand to see all)" })] }));
|
|
26
27
|
};
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { FormattedMessage } from './FormattedMessage.js';
|
|
4
|
+
import { LeftBar } from './LeftBar.js';
|
|
4
5
|
export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = false, }) => {
|
|
5
6
|
const toolNames = toolCalls.map((toolCall) => toolCall.name);
|
|
6
7
|
const title = `Called: ${toolNames.join(', ')}`;
|
|
7
8
|
const containsTodoTool = toolCalls.some((toolCall) => toolCall.name === 'todo_read' || toolCall.name === 'todo_write');
|
|
8
|
-
const titleColor = containsTodoTool ? 'green' : '
|
|
9
|
+
const titleColor = containsTodoTool ? 'green' : 'cyan';
|
|
9
10
|
const isExpanded = expanded || containsTodoTool;
|
|
10
11
|
if (isExpanded) {
|
|
11
|
-
return (_jsxs(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
return (_jsxs(LeftBar, { color: titleColor, children: [_jsxs(Text, { color: titleColor, bold: true, children: ["\u25BC ", title] }), toolCalls.map((toolCall, idx) => {
|
|
13
|
+
const result = toolResults.get(toolCall.id);
|
|
14
|
+
if (!result)
|
|
15
|
+
return null;
|
|
16
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "cyan", bold: true, children: ["[", result.name, "]:"] }), _jsx(FormattedMessage, { content: result.content })] }, idx));
|
|
17
|
+
})] }));
|
|
17
18
|
}
|
|
18
19
|
const compactLines = toolCalls.flatMap((toolCall) => {
|
|
19
20
|
const result = toolResults.get(toolCall.id);
|
|
@@ -29,5 +30,5 @@ export const ConsolidatedToolMessage = ({ toolCalls, toolResults, expanded = fal
|
|
|
29
30
|
const preview = compactPreview.length > previewLimit
|
|
30
31
|
? `${compactPreview.slice(0, previewLimit).trimEnd()}... (use /expand)`
|
|
31
32
|
: compactPreview;
|
|
32
|
-
return (_jsxs(
|
|
33
|
+
return (_jsxs(LeftBar, { color: "white", children: [_jsxs(Text, { color: titleColor, dimColor: true, bold: true, children: ["\u25B6 ", title] }), _jsx(Text, { dimColor: true, children: preview })] }));
|
|
33
34
|
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { formatMessage } from '../utils/format-message.js';
|
|
4
|
+
import { LeftBar } from './LeftBar.js';
|
|
4
5
|
export const DEFERRED_TABLE_PLACEHOLDER = 'table loading';
|
|
5
6
|
const graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
|
6
7
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
@@ -156,10 +157,10 @@ export const FormattedMessage = ({ content, deferTables = false }) => {
|
|
|
156
157
|
if (deferTables) {
|
|
157
158
|
return (_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: DEFERRED_TABLE_PLACEHOLDER }) }, index));
|
|
158
159
|
}
|
|
159
|
-
return (_jsx(
|
|
160
|
+
return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { children: renderPreformattedTable(block.content) }) }, index));
|
|
160
161
|
}
|
|
161
162
|
if (block.type === 'code') {
|
|
162
|
-
return (_jsx(
|
|
163
|
+
return (_jsx(LeftBar, { color: "gray", marginTop: 1, marginBottom: 1, children: _jsx(Text, { dimColor: true, children: block.content }) }, index));
|
|
163
164
|
}
|
|
164
165
|
// Text Block
|
|
165
166
|
if (!block.content.trim())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* LeftBar — renders a bold green vertical bar (│) on the left side of
|
|
4
|
+
* content, like a GitHub "note" callout. The bar stretches to match the
|
|
5
|
+
* full height of the content by measuring the content box after each render
|
|
6
|
+
* and repeating the │ character once per row.
|
|
7
|
+
*
|
|
8
|
+
* Uses Ink's measureElement (available in stock Ink) rather than a Box
|
|
9
|
+
* border, so it adds zero extra border lines and avoids ghosting on resize.
|
|
10
|
+
*/
|
|
11
|
+
import { useRef, useState, useLayoutEffect } from 'react';
|
|
12
|
+
import { Box, Text, measureElement } from 'ink';
|
|
13
|
+
export const LeftBar = ({ color = 'green', children, marginTop = 0, marginBottom = 0, }) => {
|
|
14
|
+
const contentRef = useRef(null);
|
|
15
|
+
const [height, setHeight] = useState(1);
|
|
16
|
+
useLayoutEffect(() => {
|
|
17
|
+
if (contentRef.current) {
|
|
18
|
+
try {
|
|
19
|
+
const { height: h } = measureElement(contentRef.current);
|
|
20
|
+
if (h > 0)
|
|
21
|
+
setHeight(h);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// measureElement can throw before layout is complete; keep previous height
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const bar = Array.from({ length: height }, () => '│').join('\n');
|
|
29
|
+
return (_jsxs(Box, { flexDirection: "row", marginTop: marginTop, marginBottom: marginBottom, children: [_jsx(Box, { flexDirection: "column", marginRight: 1, children: _jsx(Text, { color: color, bold: true, children: bar }) }), _jsx(Box, { ref: contentRef, flexDirection: "column", flexGrow: 1, children: children })] }));
|
|
30
|
+
};
|
package/dist/sub-agent.js
CHANGED
|
@@ -39,7 +39,7 @@ export const subAgentTool = {
|
|
|
39
39
|
* Run a sub-agent with its own isolated conversation.
|
|
40
40
|
* Returns the sub-agent's final text response.
|
|
41
41
|
*/
|
|
42
|
-
export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}, onProgress) {
|
|
42
|
+
export async function runSubAgent(client, model, task, maxIterations = 30, requestDefaults = {}, onProgress, abortSignal) {
|
|
43
43
|
const op = logger.startOperation('sub-agent');
|
|
44
44
|
const subAgentSessionId = `sub-agent-${crypto.randomUUID()}`;
|
|
45
45
|
const systemPrompt = await generateSystemPrompt();
|
|
@@ -56,13 +56,17 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
|
56
56
|
];
|
|
57
57
|
try {
|
|
58
58
|
for (let i = 0; i < maxIterations; i++) {
|
|
59
|
+
// Check abort at the top of each iteration
|
|
60
|
+
if (abortSignal?.aborted) {
|
|
61
|
+
return '(sub-agent aborted)';
|
|
62
|
+
}
|
|
59
63
|
const response = await client.chat.completions.create({
|
|
60
64
|
...requestDefaults,
|
|
61
65
|
model,
|
|
62
66
|
messages,
|
|
63
67
|
tools: getAllTools(),
|
|
64
68
|
tool_choice: 'auto',
|
|
65
|
-
});
|
|
69
|
+
}, { signal: abortSignal });
|
|
66
70
|
const message = response.choices[0]?.message;
|
|
67
71
|
if (!message)
|
|
68
72
|
break;
|
|
@@ -70,12 +74,16 @@ Do NOT ask the user questions — work autonomously with the tools available.`;
|
|
|
70
74
|
if (message.tool_calls && message.tool_calls.length > 0) {
|
|
71
75
|
messages.push(message);
|
|
72
76
|
for (const toolCall of message.tool_calls) {
|
|
77
|
+
// Check abort between tool calls
|
|
78
|
+
if (abortSignal?.aborted) {
|
|
79
|
+
return '(sub-agent aborted)';
|
|
80
|
+
}
|
|
73
81
|
const { name, arguments: argsStr } = toolCall.function;
|
|
74
82
|
logger.debug(`Sub-agent tool call: ${name}`);
|
|
75
83
|
onProgress?.({ tool: name, status: 'running', iteration: i });
|
|
76
84
|
try {
|
|
77
85
|
const args = JSON.parse(argsStr);
|
|
78
|
-
const result = await handleToolCall(name, args, { sessionId: subAgentSessionId });
|
|
86
|
+
const result = await handleToolCall(name, args, { sessionId: subAgentSessionId, abortSignal });
|
|
79
87
|
messages.push({
|
|
80
88
|
role: 'tool',
|
|
81
89
|
tool_call_id: toolCall.id,
|
package/dist/system-prompt.js
CHANGED
|
@@ -85,13 +85,13 @@ GUIDELINES
|
|
|
85
85
|
|
|
86
86
|
OUTPUT FORMAT:
|
|
87
87
|
- You are running in a terminal. Be concise. Optimise for scannability.
|
|
88
|
-
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
- Use flat
|
|
88
|
+
- Do NOT use Markdown formatting. No **bold**, no *italic*, no # headers, no --- dividers.
|
|
89
|
+
- Do NOT use Markdown code fences (backticks) unless the content is actual code or a command.
|
|
90
|
+
- For structured data, use plain text with aligned columns (spaces, not pipes/dashes).
|
|
91
|
+
- Keep tables compact: narrower columns, minimal padding. Wrap cell content rather than making very wide tables.
|
|
92
|
+
- Use flat plain-text lists with a simple dash or symbol prefix (e.g. - item, or ✅ done, ❌ failed).
|
|
93
93
|
- NEVER use nested indentation. Keep all lists flat — one level only.
|
|
94
|
-
- Markdown links [text](url)
|
|
94
|
+
- Do NOT use Markdown links [text](url) — just write URLs inline.
|
|
95
95
|
|
|
96
96
|
SUBAGENT STRATEGY:
|
|
97
97
|
Delegate work to specialized subagents aggressively. They excel at focused, parallel tasks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "protoagent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"commander": "^14.0.1",
|
|
29
29
|
"he": "^1.2.0",
|
|
30
30
|
"html-to-text": "^9.0.5",
|
|
31
|
-
"ink": "^6.
|
|
31
|
+
"ink": "^6.8.0",
|
|
32
32
|
"ink-big-text": "^2.0.0",
|
|
33
33
|
"jsonc-parser": "^3.3.1",
|
|
34
34
|
"openai": "^5.23.1",
|