protoagent 0.1.9 → 0.1.11
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 +0 -1
- package/dist/App.js +114 -81
- package/dist/agentic-loop.js +182 -31
- package/dist/cli.js +3 -3
- package/dist/config.js +76 -22
- package/dist/mcp.js +15 -0
- package/dist/providers.js +8 -15
- package/dist/sessions.js +13 -3
- package/dist/skills.js +2 -1
- package/dist/sub-agent.js +138 -20
- package/dist/system-prompt.js +45 -0
- package/dist/tools/bash.js +1 -1
- package/dist/tools/index.js +1 -1
- package/dist/utils/approval.js +8 -8
- package/dist/utils/cost-tracker.js +9 -3
- package/dist/utils/file-time.js +0 -9
- package/package.json +23 -3
package/dist/agentic-loop.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* React state accordingly. This keeps the core logic testable
|
|
17
17
|
* and UI-independent.
|
|
18
18
|
*/
|
|
19
|
+
import { setMaxListeners } from 'node:events';
|
|
19
20
|
import { getAllTools, handleToolCall } from './tools/index.js';
|
|
20
21
|
import { generateSystemPrompt } from './system-prompt.js';
|
|
21
22
|
import { subAgentTool, runSubAgent } from './sub-agent.js';
|
|
@@ -255,6 +256,17 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
255
256
|
const abortSignal = options.abortSignal;
|
|
256
257
|
const sessionId = options.sessionId;
|
|
257
258
|
const requestDefaults = options.requestDefaults || {};
|
|
259
|
+
// The same AbortSignal is passed into every OpenAI SDK call and every
|
|
260
|
+
// sleepWithAbort() across all loop iterations and sub-agent calls.
|
|
261
|
+
// The SDK attaches an 'abort' listener per request, so on a long run
|
|
262
|
+
// the default limit of 10 listeners is quickly exceeded, producing the
|
|
263
|
+
// MaxListenersExceededWarning. AbortSignal is a Web API EventTarget,
|
|
264
|
+
// not a Node EventEmitter, so the instance method .setMaxListeners()
|
|
265
|
+
// doesn't exist on it — use the standalone setMaxListeners() from
|
|
266
|
+
// node:events instead, which handles both EventEmitter and EventTarget.
|
|
267
|
+
if (abortSignal) {
|
|
268
|
+
setMaxListeners(0, abortSignal); // 0 = unlimited, scoped to this signal only
|
|
269
|
+
}
|
|
258
270
|
// Note: userInput is passed for context/logging but user message should already be in messages array
|
|
259
271
|
// (added by the caller in handleSubmit for immediate UI display)
|
|
260
272
|
const updatedMessages = [...messages];
|
|
@@ -267,6 +279,10 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
267
279
|
let iterationCount = 0;
|
|
268
280
|
let repairRetryCount = 0;
|
|
269
281
|
let contextRetryCount = 0;
|
|
282
|
+
let retriggerCount = 0;
|
|
283
|
+
let truncateRetryCount = 0;
|
|
284
|
+
const MAX_RETRIGGERS = 3;
|
|
285
|
+
const MAX_TRUNCATE_RETRIES = 5;
|
|
270
286
|
const validToolNames = getValidToolNames();
|
|
271
287
|
while (iterationCount < maxIterations) {
|
|
272
288
|
// Check if abort was requested
|
|
@@ -286,14 +302,15 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
286
302
|
updatedMessages.push(...compacted);
|
|
287
303
|
}
|
|
288
304
|
}
|
|
305
|
+
// Declare assistantMessage outside try block so it's accessible in catch
|
|
306
|
+
let assistantMessage;
|
|
289
307
|
try {
|
|
290
308
|
// Build tools list: core tools + sub-agent tool + dynamic (MCP) tools
|
|
291
309
|
const allTools = [...getAllTools(), subAgentTool];
|
|
292
|
-
logger.
|
|
310
|
+
logger.info('Making API request', {
|
|
293
311
|
model,
|
|
294
312
|
toolsCount: allTools.length,
|
|
295
313
|
messagesCount: updatedMessages.length,
|
|
296
|
-
toolNames: allTools.map((t) => t.function?.name).join(', '),
|
|
297
314
|
});
|
|
298
315
|
// Log message structure for debugging provider compatibility
|
|
299
316
|
for (const msg of updatedMessages) {
|
|
@@ -335,7 +352,7 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
335
352
|
signal: abortSignal,
|
|
336
353
|
});
|
|
337
354
|
// Accumulate the streamed response
|
|
338
|
-
|
|
355
|
+
assistantMessage = {
|
|
339
356
|
role: 'assistant',
|
|
340
357
|
content: '',
|
|
341
358
|
tool_calls: [],
|
|
@@ -387,23 +404,47 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
387
404
|
}
|
|
388
405
|
}
|
|
389
406
|
}
|
|
390
|
-
//
|
|
407
|
+
// Log API response with usage info at INFO level
|
|
391
408
|
{
|
|
392
409
|
const inputTokens = actualUsage?.prompt_tokens ?? estimateConversationTokens(updatedMessages);
|
|
393
410
|
const outputTokens = actualUsage?.completion_tokens ?? estimateTokens(assistantMessage.content || '');
|
|
411
|
+
const cachedTokens = actualUsage?.prompt_tokens_details?.cached_tokens;
|
|
394
412
|
const cost = pricing
|
|
395
|
-
? createUsageInfo(inputTokens, outputTokens, pricing).estimatedCost
|
|
413
|
+
? createUsageInfo(inputTokens, outputTokens, pricing, cachedTokens).estimatedCost
|
|
396
414
|
: 0;
|
|
397
415
|
const contextPercent = pricing
|
|
398
416
|
? getContextInfo(updatedMessages, pricing).utilizationPercentage
|
|
399
417
|
: 0;
|
|
418
|
+
logger.info('Received API response', {
|
|
419
|
+
model,
|
|
420
|
+
inputTokens,
|
|
421
|
+
outputTokens,
|
|
422
|
+
cachedTokens,
|
|
423
|
+
cost: cost > 0 ? `$${cost.toFixed(4)}` : 'N/A',
|
|
424
|
+
contextPercent: contextPercent > 0 ? `${contextPercent.toFixed(1)}%` : 'N/A',
|
|
425
|
+
hasToolCalls: assistantMessage.tool_calls.length > 0,
|
|
426
|
+
contentLength: assistantMessage.content?.length || 0,
|
|
427
|
+
});
|
|
400
428
|
onEvent({
|
|
401
429
|
type: 'usage',
|
|
402
430
|
usage: { inputTokens, outputTokens, cost, contextPercent },
|
|
403
431
|
});
|
|
404
432
|
}
|
|
433
|
+
// Log the full assistant message for debugging
|
|
434
|
+
logger.debug('Assistant response details', {
|
|
435
|
+
contentLength: assistantMessage.content?.length || 0,
|
|
436
|
+
contentPreview: assistantMessage.content?.slice(0, 200) || '(empty)',
|
|
437
|
+
toolCallsCount: assistantMessage.tool_calls?.length || 0,
|
|
438
|
+
toolCalls: assistantMessage.tool_calls?.map((tc) => ({
|
|
439
|
+
id: tc.id,
|
|
440
|
+
name: tc.function?.name,
|
|
441
|
+
argsPreview: tc.function?.arguments?.slice(0, 100),
|
|
442
|
+
})),
|
|
443
|
+
});
|
|
405
444
|
// Handle tool calls
|
|
406
445
|
if (assistantMessage.tool_calls.length > 0) {
|
|
446
|
+
// Reset retrigger count on valid tool call response
|
|
447
|
+
retriggerCount = 0;
|
|
407
448
|
// Clean up empty tool_calls entries (from sparse array)
|
|
408
449
|
assistantMessage.tool_calls = assistantMessage.tool_calls.filter(Boolean);
|
|
409
450
|
assistantMessage.tool_calls = assistantMessage.tool_calls.map((toolCall) => {
|
|
@@ -416,13 +457,33 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
416
457
|
}
|
|
417
458
|
return sanitized.toolCall;
|
|
418
459
|
});
|
|
419
|
-
|
|
460
|
+
// Validate that all tool calls have valid JSON arguments
|
|
461
|
+
const invalidToolCalls = assistantMessage.tool_calls.filter((tc) => {
|
|
462
|
+
const args = tc.function?.arguments;
|
|
463
|
+
if (!args)
|
|
464
|
+
return false; // Empty args is valid
|
|
465
|
+
try {
|
|
466
|
+
JSON.parse(args);
|
|
467
|
+
return false; // Valid JSON
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return true; // Invalid JSON
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
if (invalidToolCalls.length > 0) {
|
|
474
|
+
logger.warn('Assistant produced tool calls with invalid JSON, skipping this turn', {
|
|
475
|
+
invalidToolCalls: invalidToolCalls.map((tc) => ({
|
|
476
|
+
name: tc.function?.name,
|
|
477
|
+
argsPreview: tc.function?.arguments?.slice(0, 100),
|
|
478
|
+
})),
|
|
479
|
+
});
|
|
480
|
+
// Don't add the malformed assistant message to conversation
|
|
481
|
+
// The loop will continue and retry
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
logger.info('Model returned tool calls', {
|
|
420
485
|
count: assistantMessage.tool_calls.length,
|
|
421
|
-
|
|
422
|
-
id: tc.id,
|
|
423
|
-
name: tc.function?.name,
|
|
424
|
-
argsPreview: tc.function?.arguments?.slice(0, 100),
|
|
425
|
-
})),
|
|
486
|
+
tools: assistantMessage.tool_calls.map((tc) => tc.function?.name).join(', '),
|
|
426
487
|
});
|
|
427
488
|
updatedMessages.push(assistantMessage);
|
|
428
489
|
// Track which tool_call_ids still need a tool result message.
|
|
@@ -459,19 +520,25 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
459
520
|
const subProgress = (evt) => {
|
|
460
521
|
onEvent({
|
|
461
522
|
type: 'sub_agent_iteration',
|
|
462
|
-
subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration },
|
|
523
|
+
subAgentTool: { tool: evt.tool, status: evt.status, iteration: evt.iteration, args: evt.args },
|
|
463
524
|
});
|
|
464
525
|
};
|
|
465
|
-
|
|
526
|
+
const subResult = await runSubAgent(client, model, args.task, args.max_iterations, requestDefaults, subProgress, abortSignal, pricing);
|
|
527
|
+
result = subResult.response;
|
|
528
|
+
// Emit sub-agent usage for the UI to add to total cost
|
|
529
|
+
if (subResult.usage.inputTokens > 0 || subResult.usage.outputTokens > 0) {
|
|
530
|
+
onEvent({
|
|
531
|
+
type: 'sub_agent_iteration',
|
|
532
|
+
subAgentUsage: subResult.usage,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
466
535
|
}
|
|
467
536
|
else {
|
|
468
537
|
result = await handleToolCall(name, args, { sessionId, abortSignal });
|
|
469
538
|
}
|
|
470
|
-
logger.
|
|
539
|
+
logger.info('Tool completed', {
|
|
471
540
|
tool: name,
|
|
472
|
-
tool_call_id: toolCall.id,
|
|
473
541
|
resultLength: result.length,
|
|
474
|
-
resultPreview: result.slice(0, 200),
|
|
475
542
|
});
|
|
476
543
|
updatedMessages.push({
|
|
477
544
|
role: 'tool',
|
|
@@ -517,14 +584,67 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
517
584
|
role: 'assistant',
|
|
518
585
|
content: assistantMessage.content,
|
|
519
586
|
});
|
|
587
|
+
// Reset retrigger count on valid content response
|
|
588
|
+
retriggerCount = 0;
|
|
589
|
+
}
|
|
590
|
+
// Check if we need to retrigger: if the last message is a tool result
|
|
591
|
+
// but we got no assistant response (empty content, no tool_calls), the AI
|
|
592
|
+
// may have stopped prematurely. Inject a 'continue' prompt and retry.
|
|
593
|
+
const lastMessage = updatedMessages[updatedMessages.length - 1];
|
|
594
|
+
if (lastMessage?.role === 'tool' && retriggerCount < MAX_RETRIGGERS) {
|
|
595
|
+
retriggerCount++;
|
|
596
|
+
logger.warn('AI stopped after tool call without responding; retriggering', {
|
|
597
|
+
retriggerCount,
|
|
598
|
+
maxRetriggers: MAX_RETRIGGERS,
|
|
599
|
+
lastMessageRole: lastMessage.role,
|
|
600
|
+
assistantContent: assistantMessage.content || '(empty)',
|
|
601
|
+
hasToolCalls: assistantMessage.tool_calls.length > 0,
|
|
602
|
+
});
|
|
603
|
+
// Inject a 'continue' prompt to help the AI continue
|
|
604
|
+
updatedMessages.push({
|
|
605
|
+
role: 'user',
|
|
606
|
+
content: 'Please continue.',
|
|
607
|
+
});
|
|
608
|
+
continue;
|
|
520
609
|
}
|
|
521
610
|
repairRetryCount = 0;
|
|
611
|
+
retriggerCount = 0;
|
|
522
612
|
onEvent({ type: 'done' });
|
|
523
613
|
return updatedMessages;
|
|
524
614
|
}
|
|
525
615
|
catch (apiError) {
|
|
526
616
|
if (abortSignal?.aborted || apiError?.name === 'AbortError' || apiError?.message === 'Operation aborted') {
|
|
527
617
|
logger.debug('Agentic loop request aborted');
|
|
618
|
+
// If we have a partial assistant message with tool_calls, we need to
|
|
619
|
+
// add it to the conversation history before returning, otherwise the
|
|
620
|
+
// message sequence will be invalid (tool results without assistant tool_calls).
|
|
621
|
+
if (assistantMessage && (assistantMessage.content || assistantMessage.tool_calls?.length > 0)) {
|
|
622
|
+
// Clean up empty tool_calls entries
|
|
623
|
+
if (assistantMessage.tool_calls?.length > 0) {
|
|
624
|
+
assistantMessage.tool_calls = assistantMessage.tool_calls.filter(Boolean);
|
|
625
|
+
// Filter out tool calls with malformed/incomplete JSON arguments
|
|
626
|
+
assistantMessage.tool_calls = assistantMessage.tool_calls.filter((tc) => {
|
|
627
|
+
const args = tc.function?.arguments;
|
|
628
|
+
if (!args)
|
|
629
|
+
return true; // No args is valid
|
|
630
|
+
try {
|
|
631
|
+
JSON.parse(args);
|
|
632
|
+
return true; // Valid JSON
|
|
633
|
+
}
|
|
634
|
+
catch {
|
|
635
|
+
logger.warn('Filtering out tool call with malformed JSON arguments due to abort', {
|
|
636
|
+
tool: tc.function?.name,
|
|
637
|
+
argsPreview: args.slice(0, 100),
|
|
638
|
+
});
|
|
639
|
+
return false; // Invalid JSON, filter out
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
// Only add the assistant message if we have content or valid tool calls
|
|
644
|
+
if (assistantMessage.content || assistantMessage.tool_calls?.length > 0) {
|
|
645
|
+
updatedMessages.push(assistantMessage);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
528
648
|
emitAbortAndFinish(onEvent);
|
|
529
649
|
return updatedMessages;
|
|
530
650
|
}
|
|
@@ -557,21 +677,42 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
557
677
|
});
|
|
558
678
|
const retryableStatus = apiError?.status === 408 || apiError?.status === 409 || apiError?.status === 425;
|
|
559
679
|
const retryableCode = ['ECONNRESET', 'ECONNABORTED', 'ETIMEDOUT', 'ENETUNREACH', 'EAI_AGAIN'].includes(apiError?.code);
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
680
|
+
// Handle 400 errors: try sanitization first, then truncate messages
|
|
681
|
+
if (apiError?.status === 400) {
|
|
682
|
+
// Try sanitization first
|
|
683
|
+
if (repairRetryCount < 2) {
|
|
684
|
+
const sanitized = sanitizeMessagesForRetry(updatedMessages, getValidToolNames());
|
|
685
|
+
if (sanitized.changed) {
|
|
686
|
+
repairRetryCount++;
|
|
687
|
+
updatedMessages.length = 0;
|
|
688
|
+
updatedMessages.push(...sanitized.messages);
|
|
689
|
+
logger.warn('400 response after malformed tool payload; retrying with sanitized messages', {
|
|
690
|
+
repairRetryCount,
|
|
691
|
+
});
|
|
692
|
+
// Silently retry without showing error to user
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// If sanitization didn't help, try removing messages one at a time (up to 5)
|
|
697
|
+
if (truncateRetryCount < MAX_TRUNCATE_RETRIES) {
|
|
698
|
+
truncateRetryCount++;
|
|
699
|
+
const removedCount = Math.min(1, Math.max(0, updatedMessages.length - 2)); // Remove 1 at a time, keep system + at least 1 user
|
|
700
|
+
if (removedCount > 0) {
|
|
701
|
+
const removed = updatedMessages.splice(-removedCount);
|
|
702
|
+
logger.debug('400 error: removing message from history to attempt fix', {
|
|
703
|
+
truncateRetryCount,
|
|
704
|
+
maxRetries: MAX_TRUNCATE_RETRIES,
|
|
705
|
+
removedCount,
|
|
706
|
+
removedRoles: removed.map((m) => m.role),
|
|
707
|
+
removedPreviews: removed.map((m) => ({
|
|
708
|
+
role: m.role,
|
|
709
|
+
content: m.content?.slice(0, 100),
|
|
710
|
+
tool_calls: m.tool_calls?.map((tc) => tc.function?.name),
|
|
711
|
+
})),
|
|
712
|
+
});
|
|
713
|
+
// Silently retry without showing error to user
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
575
716
|
}
|
|
576
717
|
}
|
|
577
718
|
// Handle context-window-exceeded (prompt too long) — attempt forced compaction
|
|
@@ -633,6 +774,16 @@ export async function runAgenticLoop(client, model, messages, userInput, onEvent
|
|
|
633
774
|
await sleepWithAbort(backoff, abortSignal);
|
|
634
775
|
continue;
|
|
635
776
|
}
|
|
777
|
+
// 400 error that couldn't be fixed by sanitization or truncation
|
|
778
|
+
if (apiError?.status === 400) {
|
|
779
|
+
onEvent({
|
|
780
|
+
type: 'error',
|
|
781
|
+
error: 'The conversation history appears to be corrupted and could not be automatically repaired. Try /clear to start fresh.',
|
|
782
|
+
transient: false,
|
|
783
|
+
});
|
|
784
|
+
onEvent({ type: 'done' });
|
|
785
|
+
return updatedMessages;
|
|
786
|
+
}
|
|
636
787
|
// Non-retryable error
|
|
637
788
|
onEvent({ type: 'error', error: errMsg });
|
|
638
789
|
onEvent({ type: 'done' });
|
package/dist/cli.js
CHANGED
|
@@ -22,12 +22,12 @@ const program = new Command();
|
|
|
22
22
|
program
|
|
23
23
|
.description('ProtoAgent — a simple, hackable coding agent CLI')
|
|
24
24
|
.version(packageJson.version)
|
|
25
|
-
.option('--dangerously-
|
|
26
|
-
.option('--log-level <level>', 'Log level: TRACE, DEBUG, INFO, WARN, ERROR', '
|
|
25
|
+
.option('--dangerously-skip-permissions', 'Auto-approve all file writes and shell commands')
|
|
26
|
+
.option('--log-level <level>', 'Log level: TRACE, DEBUG, INFO, WARN, ERROR', 'DEBUG')
|
|
27
27
|
.option('--session <id>', 'Resume a previous session by ID')
|
|
28
28
|
.action((options) => {
|
|
29
29
|
// Default action - start the main app
|
|
30
|
-
render(_jsx(App, {
|
|
30
|
+
render(_jsx(App, { dangerouslySkipPermissions: options.dangerouslySkipPermissions || false, logLevel: options.logLevel, sessionId: options.session }));
|
|
31
31
|
});
|
|
32
32
|
// Configure subcommand
|
|
33
33
|
program
|
package/dist/config.js
CHANGED
|
@@ -16,21 +16,24 @@ function hardenPermissions(targetPath, mode) {
|
|
|
16
16
|
chmodSync(targetPath, mode);
|
|
17
17
|
}
|
|
18
18
|
export function resolveApiKey(config) {
|
|
19
|
-
const directApiKey = config.apiKey?.trim();
|
|
20
|
-
if (directApiKey) {
|
|
21
|
-
return directApiKey;
|
|
22
|
-
}
|
|
23
19
|
const provider = getProvider(config.provider);
|
|
20
|
+
// 1. Provider-specific environment variable
|
|
24
21
|
if (provider?.apiKeyEnvVar) {
|
|
25
22
|
const providerEnvOverride = process.env[provider.apiKeyEnvVar]?.trim();
|
|
26
23
|
if (providerEnvOverride) {
|
|
27
24
|
return providerEnvOverride;
|
|
28
25
|
}
|
|
29
26
|
}
|
|
27
|
+
// 2. Generic environment variable
|
|
30
28
|
const envOverride = process.env.PROTOAGENT_API_KEY?.trim();
|
|
31
29
|
if (envOverride) {
|
|
32
30
|
return envOverride;
|
|
33
31
|
}
|
|
32
|
+
// 3. Config file (either from selected provider or direct apiKey)
|
|
33
|
+
const directApiKey = config.apiKey?.trim();
|
|
34
|
+
if (directApiKey) {
|
|
35
|
+
return directApiKey;
|
|
36
|
+
}
|
|
34
37
|
const providerApiKey = provider?.apiKey?.trim();
|
|
35
38
|
if (providerApiKey) {
|
|
36
39
|
return providerApiKey;
|
|
@@ -84,9 +87,47 @@ const RUNTIME_CONFIG_TEMPLATE = `{
|
|
|
84
87
|
// - custom providers/models
|
|
85
88
|
// - MCP server definitions
|
|
86
89
|
// - request default parameters
|
|
87
|
-
"providers": {
|
|
90
|
+
"providers": {
|
|
91
|
+
// "provider-id": {
|
|
92
|
+
// "name": "Display Name",
|
|
93
|
+
// "baseURL": "https://api.example.com/v1",
|
|
94
|
+
// "apiKey": "your-api-key",
|
|
95
|
+
// "apiKeyEnvVar": "ENV_VAR_NAME",
|
|
96
|
+
// "headers": {
|
|
97
|
+
// "X-Custom-Header": "value"
|
|
98
|
+
// },
|
|
99
|
+
// "defaultParams": {},
|
|
100
|
+
// "models": {
|
|
101
|
+
// "model-id": {
|
|
102
|
+
// "name": "Display Name",
|
|
103
|
+
// "contextWindow": 128000,
|
|
104
|
+
// "inputPricePerMillion": 2.5,
|
|
105
|
+
// "outputPricePerMillion": 10.0,
|
|
106
|
+
// "cachedPricePerMillion": 1.25,
|
|
107
|
+
// "defaultParams": {}
|
|
108
|
+
// }
|
|
109
|
+
// }
|
|
110
|
+
// }
|
|
111
|
+
},
|
|
88
112
|
"mcp": {
|
|
89
|
-
"servers": {
|
|
113
|
+
"servers": {
|
|
114
|
+
// "server-name": {
|
|
115
|
+
// "type": "stdio",
|
|
116
|
+
// "command": "npx",
|
|
117
|
+
// "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
|
|
118
|
+
// "env": { "KEY": "value" },
|
|
119
|
+
// "cwd": "/working/directory",
|
|
120
|
+
// "enabled": true,
|
|
121
|
+
// "timeoutMs": 30000
|
|
122
|
+
// },
|
|
123
|
+
// "http-server": {
|
|
124
|
+
// "type": "http",
|
|
125
|
+
// "url": "https://mcp-server.example.com",
|
|
126
|
+
// "headers": { "Authorization": "Bearer token" },
|
|
127
|
+
// "enabled": true,
|
|
128
|
+
// "timeoutMs": 30000
|
|
129
|
+
// }
|
|
130
|
+
}
|
|
90
131
|
}
|
|
91
132
|
}
|
|
92
133
|
`;
|
|
@@ -215,7 +256,13 @@ export const ResetPrompt = ({ existingConfig, setStep, setConfigWritten }) => {
|
|
|
215
256
|
}
|
|
216
257
|
} })] }));
|
|
217
258
|
};
|
|
218
|
-
export const
|
|
259
|
+
export const TargetSelection = ({ title, subtitle, onSelect, }) => {
|
|
260
|
+
return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { color: "green", bold: true, children: title }), subtitle && _jsx(Text, { children: subtitle }), _jsx(Box, { marginTop: 1, children: _jsx(Select, { options: [
|
|
261
|
+
{ label: `Project config — ${getProjectRuntimeConfigPath()}`, value: 'project' },
|
|
262
|
+
{ label: `Shared user config — ${getUserRuntimeConfigPath()}`, value: 'user' },
|
|
263
|
+
], onChange: (value) => onSelect(value) }) })] }));
|
|
264
|
+
};
|
|
265
|
+
export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, onSelect, setStep, title, }) => {
|
|
219
266
|
const items = getAllProviders().flatMap((provider) => provider.models.map((model) => ({
|
|
220
267
|
label: `${provider.name} - ${model.name}`,
|
|
221
268
|
value: `${provider.id}:::${model.id}`,
|
|
@@ -224,11 +271,16 @@ export const ModelSelection = ({ setSelectedProviderId, setSelectedModelId, setS
|
|
|
224
271
|
const [providerId, modelId] = value.split(':::');
|
|
225
272
|
setSelectedProviderId(providerId);
|
|
226
273
|
setSelectedModelId(modelId);
|
|
227
|
-
|
|
274
|
+
if (onSelect) {
|
|
275
|
+
onSelect(providerId, modelId);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
setStep?.(3);
|
|
279
|
+
}
|
|
228
280
|
};
|
|
229
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Select an AI Model:" }), _jsx(Select, { options: items, onChange: handleSelect })] }));
|
|
281
|
+
return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { color: "green", bold: true, children: title }), _jsx(Text, { children: "Select an AI Model:" }), _jsx(Select, { options: items, onChange: handleSelect })] }));
|
|
230
282
|
};
|
|
231
|
-
export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target, setStep, setConfigWritten, }) => {
|
|
283
|
+
export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target = 'user', title, showProviderHeaders = true, onComplete, setStep, setConfigWritten, }) => {
|
|
232
284
|
const [errorMessage, setErrorMessage] = useState('');
|
|
233
285
|
const provider = getProvider(selectedProviderId);
|
|
234
286
|
const canUseResolvedAuth = Boolean(resolveApiKey({ provider: selectedProviderId, apiKey: undefined }));
|
|
@@ -243,10 +295,15 @@ export const ApiKeyInput = ({ selectedProviderId, selectedModelId, target, setSt
|
|
|
243
295
|
...(value.trim().length > 0 ? { apiKey: value.trim() } : {}),
|
|
244
296
|
};
|
|
245
297
|
writeConfig(newConfig, target);
|
|
246
|
-
|
|
247
|
-
|
|
298
|
+
if (onComplete) {
|
|
299
|
+
onComplete(newConfig);
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
setConfigWritten?.(true);
|
|
303
|
+
setStep?.(4);
|
|
304
|
+
}
|
|
248
305
|
};
|
|
249
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [canUseResolvedAuth ? 'Optional API Key' : 'Enter API Key', " for ", provider?.name || selectedProviderId, ":"] }), provider?.headers && Object.keys(provider.headers).length > 0 && (_jsx(Text, { dimColor: true, children: "This provider can authenticate with configured headers or environment variables." })), errorMessage && _jsx(Text, { color: "red", children: errorMessage }), _jsx(PasswordInput, { placeholder: canUseResolvedAuth ? 'Press enter to keep resolved auth' : `Enter your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: handleApiKeySubmit })] }));
|
|
306
|
+
return (_jsxs(Box, { flexDirection: "column", children: [title && _jsx(Text, { color: "green", bold: true, children: title }), _jsxs(Text, { children: [canUseResolvedAuth ? 'Optional API Key' : 'Enter API Key', " for ", provider?.name || selectedProviderId, ":"] }), showProviderHeaders && provider?.headers && Object.keys(provider.headers).length > 0 && (_jsx(Text, { dimColor: true, children: "This provider can authenticate with configured headers or environment variables." })), errorMessage && _jsx(Text, { color: "red", children: errorMessage }), _jsx(PasswordInput, { placeholder: canUseResolvedAuth ? 'Press enter to keep resolved auth' : `Enter your ${provider?.apiKeyEnvVar || 'API'} key`, onSubmit: handleApiKeySubmit })] }));
|
|
250
307
|
};
|
|
251
308
|
export const ConfigResult = ({ configWritten }) => {
|
|
252
309
|
return (_jsxs(Box, { flexDirection: "column", children: [configWritten ? (_jsx(Text, { color: "green", children: "Configuration saved successfully!" })) : (_jsx(Text, { color: "yellow", children: "Configuration not changed." })), _jsx(Text, { children: "You can now run ProtoAgent." })] }));
|
|
@@ -259,15 +316,12 @@ export const ConfigureComponent = () => {
|
|
|
259
316
|
const [selectedModelId, setSelectedModelId] = useState('');
|
|
260
317
|
const [configWritten, setConfigWritten] = useState(false);
|
|
261
318
|
if (step === 0) {
|
|
262
|
-
return (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
setExistingConfig(existing);
|
|
269
|
-
setStep(existing ? 1 : 2);
|
|
270
|
-
} }) })] }));
|
|
319
|
+
return (_jsx(TargetSelection, { subtitle: "Choose where to configure ProtoAgent:", onSelect: (value) => {
|
|
320
|
+
setTarget(value);
|
|
321
|
+
const existing = readConfig(value);
|
|
322
|
+
setExistingConfig(existing);
|
|
323
|
+
setStep(existing ? 1 : 2);
|
|
324
|
+
} }));
|
|
271
325
|
}
|
|
272
326
|
switch (step) {
|
|
273
327
|
case 1:
|
package/dist/mcp.js
CHANGED
|
@@ -42,6 +42,7 @@ async function connectStdioServer(serverName, config) {
|
|
|
42
42
|
...(config.env || {}),
|
|
43
43
|
},
|
|
44
44
|
cwd: config.cwd,
|
|
45
|
+
stderr: 'pipe',
|
|
45
46
|
});
|
|
46
47
|
const client = new Client({
|
|
47
48
|
name: 'protoagent',
|
|
@@ -50,6 +51,14 @@ async function connectStdioServer(serverName, config) {
|
|
|
50
51
|
capabilities: {},
|
|
51
52
|
});
|
|
52
53
|
await client.connect(transport);
|
|
54
|
+
// Pipe stderr from the spawned process to the logger instead of letting it
|
|
55
|
+
// bleed through to the terminal and corrupt the Ink UI.
|
|
56
|
+
transport.stderr?.on('data', (data) => {
|
|
57
|
+
for (const line of data.toString('utf-8').split('\n')) {
|
|
58
|
+
if (line.trim())
|
|
59
|
+
logger.debug(`MCP [${serverName}] ${line}`);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
53
62
|
return {
|
|
54
63
|
client,
|
|
55
64
|
serverName,
|
|
@@ -169,3 +178,9 @@ export async function closeMcp() {
|
|
|
169
178
|
}
|
|
170
179
|
connections.clear();
|
|
171
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Get the names of all connected MCP servers.
|
|
183
|
+
*/
|
|
184
|
+
export function getConnectedMcpServers() {
|
|
185
|
+
return Array.from(connections.keys());
|
|
186
|
+
}
|
package/dist/providers.js
CHANGED
|
@@ -11,9 +11,9 @@ export const BUILTIN_PROVIDERS = [
|
|
|
11
11
|
name: 'OpenAI',
|
|
12
12
|
apiKeyEnvVar: 'OPENAI_API_KEY',
|
|
13
13
|
models: [
|
|
14
|
-
{ id: 'gpt-5.
|
|
15
|
-
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', contextWindow:
|
|
16
|
-
{ id: 'gpt-4.1', name: 'GPT-4.1', contextWindow:
|
|
14
|
+
{ id: 'gpt-5.4', name: 'GPT-5.4', contextWindow: 1_048_576, pricingPerMillionInput: 2.50, pricingPerMillionOutput: 15.00 },
|
|
15
|
+
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', contextWindow: 1_000_000, pricingPerMillionInput: 0.25, pricingPerMillionOutput: 2.00 },
|
|
16
|
+
{ id: 'gpt-4.1', name: 'GPT-4.1', contextWindow: 1_048_576, pricingPerMillionInput: 2.0, pricingPerMillionOutput: 8.00 },
|
|
17
17
|
],
|
|
18
18
|
},
|
|
19
19
|
{
|
|
@@ -33,21 +33,12 @@ export const BUILTIN_PROVIDERS = [
|
|
|
33
33
|
baseURL: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
|
34
34
|
apiKeyEnvVar: 'GEMINI_API_KEY',
|
|
35
35
|
models: [
|
|
36
|
-
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 0.
|
|
37
|
-
{ id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro (Preview)', contextWindow: 1_000_000, pricingPerMillionInput:
|
|
38
|
-
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1_000_000, pricingPerMillionInput: 0.
|
|
36
|
+
{ id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 0.50, pricingPerMillionOutput: 3.0 },
|
|
37
|
+
{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro (Preview)', contextWindow: 1_000_000, pricingPerMillionInput: 2.0, pricingPerMillionOutput: 12.0 },
|
|
38
|
+
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1_000_000, pricingPerMillionInput: 0.30, pricingPerMillionOutput: 2.5 },
|
|
39
39
|
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1_000_000, pricingPerMillionInput: 1.25, pricingPerMillionOutput: 10.0 },
|
|
40
40
|
],
|
|
41
41
|
},
|
|
42
|
-
{
|
|
43
|
-
id: 'cerebras',
|
|
44
|
-
name: 'Cerebras',
|
|
45
|
-
baseURL: 'https://api.cerebras.ai/v1',
|
|
46
|
-
apiKeyEnvVar: 'CEREBRAS_API_KEY',
|
|
47
|
-
models: [
|
|
48
|
-
{ id: 'llama-4-scout-17b-16e-instruct', name: 'Llama 4 Scout 17B', contextWindow: 128_000, pricingPerMillionInput: 0.0, pricingPerMillionOutput: 0.0 },
|
|
49
|
-
],
|
|
50
|
-
},
|
|
51
42
|
];
|
|
52
43
|
function sanitizeDefaultParams(defaultParams) {
|
|
53
44
|
if (!defaultParams || Object.keys(defaultParams).length === 0)
|
|
@@ -67,6 +58,7 @@ function mergeModelLists(baseModels, overrideModels) {
|
|
|
67
58
|
contextWindow: override.contextWindow ?? current?.contextWindow ?? 0,
|
|
68
59
|
pricingPerMillionInput: override.inputPricePerMillion ?? current?.pricingPerMillionInput ?? 0,
|
|
69
60
|
pricingPerMillionOutput: override.outputPricePerMillion ?? current?.pricingPerMillionOutput ?? 0,
|
|
61
|
+
pricingPerMillionCached: override.cachedPricePerMillion ?? current?.pricingPerMillionCached,
|
|
70
62
|
defaultParams: sanitizeDefaultParams({
|
|
71
63
|
...(current?.defaultParams || {}),
|
|
72
64
|
...(override.defaultParams || {}),
|
|
@@ -109,6 +101,7 @@ export function getModelPricing(providerId, modelId) {
|
|
|
109
101
|
return {
|
|
110
102
|
inputPerToken: details.pricingPerMillionInput / 1_000_000,
|
|
111
103
|
outputPerToken: details.pricingPerMillionOutput / 1_000_000,
|
|
104
|
+
cachedPerToken: details.pricingPerMillionCached != null ? details.pricingPerMillionCached / 1_000_000 : undefined,
|
|
112
105
|
contextWindow: details.contextWindow,
|
|
113
106
|
};
|
|
114
107
|
}
|
package/dist/sessions.js
CHANGED
|
@@ -9,22 +9,32 @@
|
|
|
9
9
|
import fs from 'node:fs/promises';
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import os from 'node:os';
|
|
12
|
-
import crypto from 'node:crypto';
|
|
13
12
|
import { chmodSync } from 'node:fs';
|
|
14
13
|
import { logger } from './utils/logger.js';
|
|
15
14
|
const SESSION_DIR_MODE = 0o700;
|
|
16
15
|
const SESSION_FILE_MODE = 0o600;
|
|
17
16
|
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
17
|
+
const SHORT_ID_PATTERN = /^[0-9a-z]{8}$/i;
|
|
18
18
|
function hardenPermissions(targetPath, mode) {
|
|
19
19
|
if (process.platform === 'win32')
|
|
20
20
|
return;
|
|
21
21
|
chmodSync(targetPath, mode);
|
|
22
22
|
}
|
|
23
23
|
function assertValidSessionId(id) {
|
|
24
|
-
|
|
24
|
+
// Accept both legacy UUIDs and new short IDs
|
|
25
|
+
if (!SESSION_ID_PATTERN.test(id) && !SHORT_ID_PATTERN.test(id)) {
|
|
25
26
|
throw new Error(`Invalid session ID: ${id}`);
|
|
26
27
|
}
|
|
27
28
|
}
|
|
29
|
+
/** Generate a short, readable session ID (8 alphanumeric characters). */
|
|
30
|
+
function generateSessionId() {
|
|
31
|
+
const chars = '0123456789abcdefghijklmnopqrstuvwxyz';
|
|
32
|
+
let id = '';
|
|
33
|
+
for (let i = 0; i < 8; i++) {
|
|
34
|
+
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
35
|
+
}
|
|
36
|
+
return id;
|
|
37
|
+
}
|
|
28
38
|
export function ensureSystemPromptAtTop(messages, systemPrompt) {
|
|
29
39
|
const firstSystemIndex = messages.findIndex((message) => message.role === 'system');
|
|
30
40
|
if (firstSystemIndex === -1) {
|
|
@@ -62,7 +72,7 @@ function sessionPath(id) {
|
|
|
62
72
|
/** Create a new session. */
|
|
63
73
|
export function createSession(model, provider) {
|
|
64
74
|
return {
|
|
65
|
-
id:
|
|
75
|
+
id: generateSessionId(),
|
|
66
76
|
title: 'New session',
|
|
67
77
|
createdAt: new Date().toISOString(),
|
|
68
78
|
updatedAt: new Date().toISOString(),
|
package/dist/skills.js
CHANGED
|
@@ -36,6 +36,7 @@ function parseFrontmatter(rawContent) {
|
|
|
36
36
|
function isValidSkillName(name) {
|
|
37
37
|
return name.length >= 1 && name.length <= 64 && VALID_SKILL_NAME.test(name);
|
|
38
38
|
}
|
|
39
|
+
// normalizeMetadata ensures the metadata field is an object with string values, or undefined if not provided or invalid
|
|
39
40
|
function normalizeMetadata(value) {
|
|
40
41
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
41
42
|
return undefined;
|
|
@@ -89,7 +90,7 @@ async function loadSkillFromDirectory(skillDir, source) {
|
|
|
89
90
|
const rawContent = await fs.readFile(location, 'utf8');
|
|
90
91
|
const parsed = parseFrontmatter(rawContent);
|
|
91
92
|
const skill = validateSkill(parsed, skillDir, source, location);
|
|
92
|
-
logger.
|
|
93
|
+
logger.info(`Loaded skill: ${skill.name} (${source})`, { location });
|
|
93
94
|
return skill;
|
|
94
95
|
}
|
|
95
96
|
catch (error) {
|