protoagent 0.1.5 → 0.1.6

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 CHANGED
@@ -503,6 +503,17 @@ export const App = ({ dangerouslyAcceptAll = false, logLevel, sessionId, }) => {
503
503
  }
504
504
  }
505
505
  break;
506
+ case 'sub_agent_iteration':
507
+ if (event.subAgentTool) {
508
+ const { tool, status } = event.subAgentTool;
509
+ if (status === 'running') {
510
+ setActiveTool(`sub_agent → ${tool}`);
511
+ }
512
+ else {
513
+ setActiveTool(null);
514
+ }
515
+ }
516
+ break;
506
517
  case 'tool_call':
507
518
  if (event.toolCall) {
508
519
  const toolCall = event.toolCall;
@@ -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: 'tool_call',
412
- toolCall: {
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);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protoagent",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",