actoviq-agent-sdk 0.3.0 → 0.3.1

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.
Files changed (128) hide show
  1. package/dist/src/cli/actoviq-react.js +16 -7
  2. package/dist/src/cli/actoviq-react.js.map +1 -1
  3. package/dist/src/config/anthropicEnvMapping.d.ts +18 -0
  4. package/dist/src/config/anthropicEnvMapping.d.ts.map +1 -0
  5. package/dist/src/config/anthropicEnvMapping.js +40 -0
  6. package/dist/src/config/anthropicEnvMapping.js.map +1 -0
  7. package/dist/src/config/resolveRuntimeConfig.d.ts.map +1 -1
  8. package/dist/src/config/resolveRuntimeConfig.js +37 -13
  9. package/dist/src/config/resolveRuntimeConfig.js.map +1 -1
  10. package/dist/src/index.d.ts +3 -0
  11. package/dist/src/index.d.ts.map +1 -1
  12. package/dist/src/index.js +3 -0
  13. package/dist/src/index.js.map +1 -1
  14. package/dist/src/memory/actoviqMemory.d.ts.map +1 -1
  15. package/dist/src/memory/actoviqMemory.js +10 -6
  16. package/dist/src/memory/actoviqMemory.js.map +1 -1
  17. package/dist/src/memory/actoviqTranscriptLogger.d.ts.map +1 -1
  18. package/dist/src/memory/actoviqTranscriptLogger.js +2 -2
  19. package/dist/src/memory/actoviqTranscriptLogger.js.map +1 -1
  20. package/dist/src/parity/actoviqBridgeEvents.d.ts.map +1 -1
  21. package/dist/src/parity/actoviqBridgeEvents.js +4 -2
  22. package/dist/src/parity/actoviqBridgeEvents.js.map +1 -1
  23. package/dist/src/parity/actoviqBridgeSdk.d.ts.map +1 -1
  24. package/dist/src/parity/actoviqBridgeSdk.js +9 -1
  25. package/dist/src/parity/actoviqBridgeSdk.js.map +1 -1
  26. package/dist/src/parity/actoviqCleanBridgeCompatSdk.d.ts +277 -0
  27. package/dist/src/parity/actoviqCleanBridgeCompatSdk.d.ts.map +1 -0
  28. package/dist/src/parity/actoviqCleanBridgeCompatSdk.js +1391 -0
  29. package/dist/src/parity/actoviqCleanBridgeCompatSdk.js.map +1 -0
  30. package/dist/src/parity/portableSessions.d.ts.map +1 -1
  31. package/dist/src/parity/portableSessions.js +6 -2
  32. package/dist/src/parity/portableSessions.js.map +1 -1
  33. package/dist/src/provider/client.d.ts +3 -0
  34. package/dist/src/provider/client.d.ts.map +1 -1
  35. package/dist/src/provider/client.js +77 -6
  36. package/dist/src/provider/client.js.map +1 -1
  37. package/dist/src/provider/openai-client.d.ts.map +1 -1
  38. package/dist/src/provider/openai-client.js +43 -4
  39. package/dist/src/provider/openai-client.js.map +1 -1
  40. package/dist/src/runtime/actoviqAgents.d.ts +6 -0
  41. package/dist/src/runtime/actoviqAgents.d.ts.map +1 -1
  42. package/dist/src/runtime/actoviqAgents.js +110 -14
  43. package/dist/src/runtime/actoviqAgents.js.map +1 -1
  44. package/dist/src/runtime/actoviqApiMicrocompact.d.ts +14 -0
  45. package/dist/src/runtime/actoviqApiMicrocompact.d.ts.map +1 -1
  46. package/dist/src/runtime/actoviqApiMicrocompact.js +102 -0
  47. package/dist/src/runtime/actoviqApiMicrocompact.js.map +1 -1
  48. package/dist/src/runtime/actoviqBackgroundTasks.d.ts +1 -0
  49. package/dist/src/runtime/actoviqBackgroundTasks.d.ts.map +1 -1
  50. package/dist/src/runtime/actoviqBackgroundTasks.js +1 -0
  51. package/dist/src/runtime/actoviqBackgroundTasks.js.map +1 -1
  52. package/dist/src/runtime/actoviqCompact.d.ts +43 -0
  53. package/dist/src/runtime/actoviqCompact.d.ts.map +1 -1
  54. package/dist/src/runtime/actoviqCompact.js +237 -7
  55. package/dist/src/runtime/actoviqCompact.js.map +1 -1
  56. package/dist/src/runtime/actoviqPermissions.js +18 -18
  57. package/dist/src/runtime/actoviqPermissions.js.map +1 -1
  58. package/dist/src/runtime/agentClient.d.ts +11 -0
  59. package/dist/src/runtime/agentClient.d.ts.map +1 -1
  60. package/dist/src/runtime/agentClient.js +302 -4
  61. package/dist/src/runtime/agentClient.js.map +1 -1
  62. package/dist/src/runtime/conversationEngine.d.ts.map +1 -1
  63. package/dist/src/runtime/conversationEngine.js +496 -36
  64. package/dist/src/runtime/conversationEngine.js.map +1 -1
  65. package/dist/src/runtime/defaultActoviqAgents.d.ts +3 -0
  66. package/dist/src/runtime/defaultActoviqAgents.d.ts.map +1 -0
  67. package/dist/src/runtime/defaultActoviqAgents.js +44 -0
  68. package/dist/src/runtime/defaultActoviqAgents.js.map +1 -0
  69. package/dist/src/runtime/safetyChecks.d.ts.map +1 -1
  70. package/dist/src/runtime/safetyChecks.js +15 -9
  71. package/dist/src/runtime/safetyChecks.js.map +1 -1
  72. package/dist/src/runtime/sessionManager.d.ts.map +1 -1
  73. package/dist/src/runtime/sessionManager.js +10 -5
  74. package/dist/src/runtime/sessionManager.js.map +1 -1
  75. package/dist/src/runtime/tools.d.ts.map +1 -1
  76. package/dist/src/runtime/tools.js +1 -0
  77. package/dist/src/runtime/tools.js.map +1 -1
  78. package/dist/src/scheduling/cron.d.ts.map +1 -1
  79. package/dist/src/scheduling/cron.js +3 -1
  80. package/dist/src/scheduling/cron.js.map +1 -1
  81. package/dist/src/storage/backgroundTaskStore.d.ts.map +1 -1
  82. package/dist/src/storage/backgroundTaskStore.js +3 -2
  83. package/dist/src/storage/backgroundTaskStore.js.map +1 -1
  84. package/dist/src/storage/mailboxStore.d.ts.map +1 -1
  85. package/dist/src/storage/mailboxStore.js +3 -3
  86. package/dist/src/storage/mailboxStore.js.map +1 -1
  87. package/dist/src/storage/pathSafety.d.ts +4 -0
  88. package/dist/src/storage/pathSafety.d.ts.map +1 -0
  89. package/dist/src/storage/pathSafety.js +28 -0
  90. package/dist/src/storage/pathSafety.js.map +1 -0
  91. package/dist/src/storage/sessionStore.d.ts +2 -2
  92. package/dist/src/storage/sessionStore.d.ts.map +1 -1
  93. package/dist/src/storage/sessionStore.js +23 -8
  94. package/dist/src/storage/sessionStore.js.map +1 -1
  95. package/dist/src/storage/teammateStore.d.ts.map +1 -1
  96. package/dist/src/storage/teammateStore.js +3 -2
  97. package/dist/src/storage/teammateStore.js.map +1 -1
  98. package/dist/src/tools/actoviqCoreTools.d.ts +7 -2
  99. package/dist/src/tools/actoviqCoreTools.d.ts.map +1 -1
  100. package/dist/src/tools/actoviqCoreTools.js +2 -2
  101. package/dist/src/tools/actoviqCoreTools.js.map +1 -1
  102. package/dist/src/tools/actoviqFileTools.d.ts.map +1 -1
  103. package/dist/src/tools/actoviqFileTools.js +16 -5
  104. package/dist/src/tools/actoviqFileTools.js.map +1 -1
  105. package/dist/src/tools/actoviqNotebookEdit.d.ts.map +1 -1
  106. package/dist/src/tools/actoviqNotebookEdit.js +121 -4
  107. package/dist/src/tools/actoviqNotebookEdit.js.map +1 -1
  108. package/dist/src/tools/actoviqShellTools.d.ts.map +1 -1
  109. package/dist/src/tools/actoviqShellTools.js +38 -9
  110. package/dist/src/tools/actoviqShellTools.js.map +1 -1
  111. package/dist/src/tools/actoviqWebTools.d.ts.map +1 -1
  112. package/dist/src/tools/actoviqWebTools.js +50 -18
  113. package/dist/src/tools/actoviqWebTools.js.map +1 -1
  114. package/dist/src/tools/bash/BashTool.js +4 -4
  115. package/dist/src/tools/bash/BashTool.js.map +1 -1
  116. package/dist/src/tools/todo/TodoWriteTool.d.ts +14 -0
  117. package/dist/src/tools/todo/TodoWriteTool.d.ts.map +1 -1
  118. package/dist/src/tools/todo/TodoWriteTool.js +45 -2
  119. package/dist/src/tools/todo/TodoWriteTool.js.map +1 -1
  120. package/dist/src/types.d.ts +116 -1
  121. package/dist/src/types.d.ts.map +1 -1
  122. package/dist/src/types.js.map +1 -1
  123. package/dist/src/workflow/workflowEngine.js +26 -3
  124. package/dist/src/workflow/workflowEngine.js.map +1 -1
  125. package/dist/src/workspace/actoviqWorkspace.d.ts.map +1 -1
  126. package/dist/src/workspace/actoviqWorkspace.js +25 -2
  127. package/dist/src/workspace/actoviqWorkspace.js.map +1 -1
  128. package/package.json +25 -1
@@ -1,12 +1,19 @@
1
- import { ActoviqSdkError, RunAbortedError, ToolExecutionError } from '../errors.js';
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ActoviqProviderApiError, ActoviqSdkError, RunAbortedError, ToolExecutionError } from '../errors.js';
4
+ import { getActoviqTodoSnapshot, formatActoviqTodoListLines, TODO_WRITE_TOOL_NAME, } from '../tools/todo/TodoWriteTool.js';
2
5
  import { asError, deepClone, nowIso, signalAborted } from './helpers.js';
3
6
  import { resolveActoviqPostSamplingHooks, resolveActoviqStopHooks } from '../hooks/actoviqHooks.js';
4
- import { getActoviqApiContextManagement } from './actoviqApiMicrocompact.js';
7
+ import { compactActoviqConversationIfNeeded } from './actoviqCompact.js';
8
+ import { getActoviqApiContextManagement, prepareActoviqProviderRequestMessages, } from './actoviqApiMicrocompact.js';
5
9
  import { decideActoviqToolPermission } from './actoviqPermissions.js';
6
- import { assistantMessageToParam, buildUserMessage, extractTextFromContent, } from './messageUtils.js';
10
+ import { assistantMessageToParam, buildUserMessage, extractTextFromContent, extractTextFromToolResultContent, } from './messageUtils.js';
11
+ const MAX_CONCURRENT_TOOL_USES = 10;
12
+ const TODO_REMINDER_INTERVAL = 10;
13
+ const MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3;
7
14
  export async function executeConversation(options) {
8
15
  const startedAt = nowIso();
9
- const model = options.model ?? options.config.model;
16
+ let model = options.model ?? options.config.model;
10
17
  const promptText = typeof options.input === 'string' ? options.input : extractTextFromContent(options.input);
11
18
  const postSamplingHooks = resolveActoviqPostSamplingHooks(options.hooks);
12
19
  const conversation = deepClone(options.messages ?? []);
@@ -32,16 +39,50 @@ export async function executeConversation(options) {
32
39
  let toolResults = [];
33
40
  let consecutiveFailures = 0;
34
41
  let lastFailedTool = '';
42
+ let maxTokensRecoveryCount = 0;
43
+ let modelFallbackUsed = false;
44
+ let iterationsSinceTodoWrite = 0;
45
+ let streamInterruptionRetryIteration = 0;
46
+ let streamInterruptionRetries = 0;
35
47
  while (true) {
36
48
  ensureNotAborted(options.signal);
37
49
  iteration += 1;
38
- options.emit?.({
39
- type: 'request.started',
40
- runId: options.runId,
41
- iteration,
42
- timestamp: nowIso(),
50
+ // In-loop auto-compact: keep a single long run within the context window
51
+ // by summarizing old turns before each provider request. Mirrors Claude
52
+ // Code's per-iteration autocompact and never throws.
53
+ const loopCompact = await compactActoviqConversationIfNeeded(conversation, {
54
+ model,
55
+ modelApi: options.modelApi,
56
+ compactConfig: options.config.compact,
57
+ maxTokens: options.maxTokens ?? options.config.maxTokens,
58
+ runKey: options.runId,
59
+ signal: options.signal,
43
60
  });
44
- const request = {
61
+ if (loopCompact.compacted) {
62
+ conversation.splice(0, conversation.length, ...loopCompact.messages);
63
+ options.emit?.({
64
+ type: 'conversation.compacted',
65
+ runId: options.runId,
66
+ iteration,
67
+ tokenEstimateBefore: loopCompact.tokenEstimateBefore,
68
+ tokenEstimateAfter: loopCompact.tokenEstimateAfter,
69
+ messagesSummarized: loopCompact.messagesSummarized,
70
+ preservedMessages: loopCompact.preservedMessages,
71
+ clearedToolResults: loopCompact.clearedToolResults,
72
+ timestamp: nowIso(),
73
+ });
74
+ }
75
+ const useAnthropicContextManagement = isAnthropicAPI(options.config.baseURL);
76
+ const preparedMessages = prepareActoviqProviderRequestMessages(conversation, options.config.compact, { localToolResultMicrocompact: !useAnthropicContextManagement });
77
+ let localMicrocompact = preparedMessages.clearedToolResults > 0
78
+ ? {
79
+ enabled: true,
80
+ clearedToolResults: preparedMessages.clearedToolResults,
81
+ tokenEstimateBefore: preparedMessages.tokenEstimateBefore,
82
+ tokenEstimateAfter: preparedMessages.tokenEstimateAfter,
83
+ }
84
+ : undefined;
85
+ let request = {
45
86
  model,
46
87
  max_tokens: options.maxTokens ?? options.config.maxTokens,
47
88
  system: options.systemPrompt ?? options.config.systemPrompt,
@@ -53,15 +94,102 @@ export async function executeConversation(options) {
53
94
  : undefined,
54
95
  // Skip context_management for third-party providers — their APIs
55
96
  // may not support server-side message edits, causing undefined behavior.
56
- context_management: isAnthropicAPI(options.config.baseURL)
97
+ context_management: useAnthropicContextManagement
57
98
  ? getActoviqApiContextManagement(conversation, options.config.compact)
58
99
  : undefined,
59
- messages: deepClone(conversation),
100
+ messages: deepClone(preparedMessages.messages),
60
101
  signal: options.signal,
61
102
  };
62
- const message = options.streaming
63
- ? await consumeStream(request, options.modelApi, iteration, options.emit, options.runId)
64
- : await options.modelApi.createMessage(request);
103
+ let requestByteLength = getRequestByteLength(request);
104
+ const maxRequestBytes = options.config.compact.apiMicrocompactMaxRequestBytes;
105
+ if (!useAnthropicContextManagement &&
106
+ maxRequestBytes &&
107
+ requestByteLength > maxRequestBytes) {
108
+ const forcedMessages = prepareActoviqProviderRequestMessages(conversation, options.config.compact, { localToolResultMicrocompact: true, force: true });
109
+ const requestByteLengthBefore = requestByteLength;
110
+ request = {
111
+ ...request,
112
+ messages: deepClone(forcedMessages.messages),
113
+ };
114
+ requestByteLength = getRequestByteLength(request);
115
+ localMicrocompact = {
116
+ enabled: true,
117
+ clearedToolResults: forcedMessages.clearedToolResults,
118
+ tokenEstimateBefore: forcedMessages.tokenEstimateBefore,
119
+ tokenEstimateAfter: forcedMessages.tokenEstimateAfter,
120
+ requestByteLengthBefore,
121
+ requestByteLengthAfter: requestByteLength,
122
+ };
123
+ }
124
+ // Prompt caching: a single cache breakpoint on the last message caches the
125
+ // entire prefix (tools + system + conversation). Anthropic API hosts only.
126
+ if (useAnthropicContextManagement && options.config.promptCachingEnabled !== false) {
127
+ applyCacheControlToLastMessage(request.messages);
128
+ }
129
+ options.emit?.({
130
+ type: 'request.started',
131
+ runId: options.runId,
132
+ iteration,
133
+ requestTokenEstimate: localMicrocompact?.tokenEstimateAfter ?? preparedMessages.tokenEstimateAfter,
134
+ requestByteLength,
135
+ localMicrocompact,
136
+ timestamp: nowIso(),
137
+ });
138
+ let message;
139
+ try {
140
+ message = options.streaming
141
+ ? await consumeStream(request, options.modelApi, iteration, options.emit, options.runId)
142
+ : await options.modelApi.createMessage(request);
143
+ }
144
+ catch (error) {
145
+ // Mid-stream interruptions (socket loss after response headers) never hit
146
+ // the provider-level retry loop; retry the whole iteration here.
147
+ if (streamInterruptionRetryIteration !== iteration) {
148
+ streamInterruptionRetryIteration = iteration;
149
+ streamInterruptionRetries = 0;
150
+ }
151
+ if (isRetryableStreamInterruption(error) &&
152
+ streamInterruptionRetries < MAX_STREAM_INTERRUPTION_RETRIES) {
153
+ streamInterruptionRetries += 1;
154
+ options.emit?.({
155
+ type: 'request.interrupted',
156
+ runId: options.runId,
157
+ iteration,
158
+ retry: streamInterruptionRetries,
159
+ maxRetries: MAX_STREAM_INTERRUPTION_RETRIES,
160
+ reason: asError(error).message,
161
+ timestamp: nowIso(),
162
+ });
163
+ await sleep(1000 * streamInterruptionRetries);
164
+ iteration -= 1;
165
+ continue;
166
+ }
167
+ // Fallback model: after transport-level retries are exhausted, switch to
168
+ // the configured fallback model once and retry this iteration.
169
+ const fallbackModel = options.config.fallbackModel;
170
+ if (fallbackModel &&
171
+ !modelFallbackUsed &&
172
+ fallbackModel !== model &&
173
+ isModelFallbackEligibleError(error)) {
174
+ modelFallbackUsed = true;
175
+ const fromModel = model;
176
+ model = fallbackModel;
177
+ options.emit?.({
178
+ type: 'model.fallback',
179
+ runId: options.runId,
180
+ iteration,
181
+ fromModel,
182
+ toModel: fallbackModel,
183
+ reason: asError(error).message,
184
+ timestamp: nowIso(),
185
+ });
186
+ iteration -= 1;
187
+ continue;
188
+ }
189
+ throw error;
190
+ }
191
+ streamInterruptionRetryIteration = 0;
192
+ streamInterruptionRetries = 0;
65
193
  if (!options.streaming) {
66
194
  const text = extractTextFromContent(message.content);
67
195
  if (text) {
@@ -171,8 +299,26 @@ export async function executeConversation(options) {
171
299
  usage: message.usage,
172
300
  text: extractTextFromContent(message.content),
173
301
  createdAt: nowIso(),
302
+ requestTokenEstimate: localMicrocompact?.tokenEstimateAfter ?? preparedMessages.tokenEstimateAfter,
303
+ requestByteLength,
304
+ localMicrocompact,
174
305
  });
175
306
  const toolUses = message.content.filter((block) => block.type === 'tool_use');
307
+ // max_tokens recovery: when the response was truncated mid-thought with no
308
+ // tool calls, nudge the model to resume instead of ending the run on a
309
+ // half-finished answer. Mirrors Claude Code's recovery loop (limit 3).
310
+ if (!preventContinuation &&
311
+ toolUses.length === 0 &&
312
+ message.stop_reason === 'max_tokens' &&
313
+ maxTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
314
+ maxTokensRecoveryCount += 1;
315
+ conversation.push({
316
+ role: 'user',
317
+ content: 'Output token limit hit. Resume directly — no apology, no recap of what you were doing. ' +
318
+ 'Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.',
319
+ });
320
+ continue;
321
+ }
176
322
  if (preventContinuation || toolUses.length === 0) {
177
323
  const completedAt = nowIso();
178
324
  if (!finalMessage) {
@@ -196,6 +342,17 @@ export async function executeConversation(options) {
196
342
  };
197
343
  }
198
344
  if (iteration >= options.config.maxToolIterations) {
345
+ if (toolUses.length > 0) {
346
+ conversation.push({
347
+ role: 'user',
348
+ content: toolUses.map(toolUse => ({
349
+ type: 'tool_result',
350
+ tool_use_id: toolUse.id,
351
+ is_error: true,
352
+ content: `The run exceeded the max tool iteration limit (${options.config.maxToolIterations}) before this tool could execute.`,
353
+ })),
354
+ });
355
+ }
199
356
  const completedAt = nowIso();
200
357
  if (finalMessage) {
201
358
  return {
@@ -206,6 +363,8 @@ export async function executeConversation(options) {
206
363
  message: finalMessage,
207
364
  messages: conversation,
208
365
  stopReason: finalMessage.stop_reason ?? null,
366
+ incompleteReason: `max_tool_iterations_exceeded:${options.config.maxToolIterations}`,
367
+ maxToolIterationsExceeded: true,
209
368
  hookStopReason,
210
369
  usage: finalMessage.usage,
211
370
  requests: requestSummaries,
@@ -217,7 +376,7 @@ export async function executeConversation(options) {
217
376
  }
218
377
  throw new ActoviqSdkError(`The run exceeded the max tool iteration limit (${options.config.maxToolIterations}).`);
219
378
  }
220
- for (const toolUse of toolUses) {
379
+ const runSingleToolUse = async (toolUse) => {
221
380
  ensureNotAborted(options.signal);
222
381
  const started = nowIso();
223
382
  const startedClock = Date.now();
@@ -306,10 +465,22 @@ export async function executeConversation(options) {
306
465
  model,
307
466
  provider: options.config.provider,
308
467
  }, onProgress);
309
- outputText = execution.text;
468
+ // Per-tool declared cap first (default 50k via tool factory), clamped
469
+ // by the global artifact ceiling. MCP tools without a declared cap use
470
+ // the global ceiling only.
471
+ const artifactMaxChars = Math.min(adapter.maxResultSizeChars ?? Number.POSITIVE_INFINITY, options.config.compact.toolResultArtifactMaxChars ?? 80_000);
472
+ const modelFacingExecution = await artifactToolExecutionIfLarge(execution, {
473
+ runId: options.runId,
474
+ iteration,
475
+ toolUseId: toolUse.id,
476
+ toolName: toolUse.name,
477
+ workDir: options.config.workDir,
478
+ maxChars: artifactMaxChars,
479
+ });
480
+ outputText = modelFacingExecution.text;
310
481
  output = execution.rawOutput;
311
482
  isError = execution.isError ?? false;
312
- content = execution.content;
483
+ content = modelFacingExecution.content;
313
484
  }
314
485
  catch (error) {
315
486
  const normalized = error instanceof ToolExecutionError
@@ -328,20 +499,61 @@ export async function executeConversation(options) {
328
499
  completedAt: nowIso(),
329
500
  durationMs: Date.now() - startedClock,
330
501
  };
331
- toolCalls.push(record);
332
- toolResults.push({
333
- type: 'tool_result',
334
- tool_use_id: toolUse.id,
335
- content,
336
- is_error: isError,
337
- });
338
- options.emit?.({
339
- type: 'tool.result',
340
- runId: options.runId,
341
- iteration,
342
- result: record,
343
- timestamp: record.completedAt,
344
- });
502
+ return {
503
+ record,
504
+ resultBlock: {
505
+ type: 'tool_result',
506
+ tool_use_id: toolUse.id,
507
+ content,
508
+ is_error: isError,
509
+ },
510
+ };
511
+ };
512
+ // Execute tool batches: consecutive concurrency-safe (read-only) tools run
513
+ // in parallel (limit 10), everything else serially. Results are recorded
514
+ // in the original tool_use order regardless of completion order.
515
+ for (const batch of partitionToolUsesForConcurrency(toolUses, toolMap)) {
516
+ const outcomes = batch.concurrent && batch.toolUses.length > 1
517
+ ? await runWithConcurrencyLimit(batch.toolUses, MAX_CONCURRENT_TOOL_USES, runSingleToolUse)
518
+ : await runSequentially(batch.toolUses, runSingleToolUse);
519
+ for (const outcome of outcomes) {
520
+ toolCalls.push(outcome.record);
521
+ toolResults.push(outcome.resultBlock);
522
+ options.emit?.({
523
+ type: 'tool.result',
524
+ runId: options.runId,
525
+ iteration,
526
+ result: outcome.record,
527
+ timestamp: outcome.record.completedAt,
528
+ });
529
+ }
530
+ }
531
+ // Aggregate per-message budget: N parallel tools can each pass the
532
+ // per-tool cap yet collectively flood one user message. Artifact the
533
+ // largest results until the batch fits (mirrors Claude Code's 200k cap).
534
+ await enforceToolResultsAggregateBudget(toolResults, {
535
+ runId: options.runId,
536
+ iteration,
537
+ workDir: options.config.workDir,
538
+ maxTotalChars: options.config.compact.toolResultsPerMessageMaxChars ?? 200_000,
539
+ nameByToolUseId: new Map(toolCalls.slice(-toolUses.length).map((record) => [record.id, record.publicName])),
540
+ });
541
+ // Todo continuity reminder: when TodoWrite is available but unused for a
542
+ // stretch of iterations, re-inject the current todo state so long runs
543
+ // stay anchored to the plan (mirrors Claude Code's 10-turn reminder).
544
+ if (toolUses.some((toolUse) => toolUse.name === TODO_WRITE_TOOL_NAME)) {
545
+ iterationsSinceTodoWrite = 0;
546
+ }
547
+ else {
548
+ iterationsSinceTodoWrite += 1;
549
+ if (toolMap.has(TODO_WRITE_TOOL_NAME) && iterationsSinceTodoWrite >= TODO_REMINDER_INTERVAL) {
550
+ const reminder = buildTodoReminderText(getActoviqTodoSnapshot(options.sessionId ?? options.runId));
551
+ const lastResult = toolResults.at(-1);
552
+ if (lastResult) {
553
+ appendTextToToolResultContent(lastResult, reminder);
554
+ iterationsSinceTodoWrite = 0;
555
+ }
556
+ }
345
557
  }
346
558
  // Detect repeated tool failures to prevent retry loops
347
559
  // Only check newly added results (from this iteration)
@@ -361,6 +573,14 @@ export async function executeConversation(options) {
361
573
  lastFailedTool = '';
362
574
  }
363
575
  }
576
+ // Always push tool results before any early return so the conversation
577
+ // never ends with dangling tool_use blocks (which would make a persisted
578
+ // session unusable: providers reject unpaired tool_use ids on resume).
579
+ conversation.push({
580
+ role: 'user',
581
+ content: toolResults,
582
+ });
583
+ toolResults = [];
364
584
  if (consecutiveFailures >= 3 && lastFailedTool) {
365
585
  const completedAt = nowIso();
366
586
  if (finalMessage) {
@@ -372,6 +592,7 @@ export async function executeConversation(options) {
372
592
  message: finalMessage,
373
593
  messages: conversation,
374
594
  stopReason: finalMessage.stop_reason ?? null,
595
+ incompleteReason: `consecutive_tool_failures:${lastFailedTool}`,
375
596
  hookStopReason,
376
597
  usage: finalMessage.usage,
377
598
  requests: requestSummaries,
@@ -383,13 +604,252 @@ export async function executeConversation(options) {
383
604
  }
384
605
  throw new ActoviqSdkError(`Tool "${lastFailedTool}" failed ${consecutiveFailures} times consecutively. Stopping to prevent retry loop.`);
385
606
  }
386
- conversation.push({
387
- role: 'user',
388
- content: toolResults,
607
+ }
608
+ }
609
+ const ARTIFACTED_OUTPUT_MARKER = 'Tool output was large (';
610
+ async function writeToolResultArtifact(text, options) {
611
+ const artifactDir = path.join(options.workDir, '.actoviq-artifacts', 'tool-results', sanitizeArtifactSegment(options.runId));
612
+ await mkdir(artifactDir, { recursive: true });
613
+ const artifactPath = path.join(artifactDir, `${String(options.iteration).padStart(3, '0')}-${sanitizeArtifactSegment(options.toolUseId)}-${sanitizeArtifactSegment(options.toolName)}.txt`);
614
+ await writeFile(artifactPath, text, 'utf8');
615
+ const preview = text.slice(0, Math.max(options.previewChars, 0));
616
+ const omittedChars = Math.max(text.length - preview.length, 0);
617
+ return [
618
+ `${ARTIFACTED_OUTPUT_MARKER}${text.length} characters).`,
619
+ `Full output saved to: ${artifactPath}`,
620
+ omittedChars > 0 ? `Preview (${preview.length} characters, ${omittedChars} omitted):` : 'Preview:',
621
+ preview,
622
+ ].join('\n');
623
+ }
624
+ async function artifactToolExecutionIfLarge(execution, options) {
625
+ if (options.maxChars <= 0 || execution.text.length <= options.maxChars) {
626
+ return {
627
+ text: execution.text,
628
+ content: execution.content,
629
+ };
630
+ }
631
+ const summary = await writeToolResultArtifact(execution.text, {
632
+ runId: options.runId,
633
+ iteration: options.iteration,
634
+ toolUseId: options.toolUseId,
635
+ toolName: options.toolName,
636
+ workDir: options.workDir,
637
+ previewChars: Math.min(options.maxChars, 4_000),
638
+ });
639
+ return {
640
+ text: summary,
641
+ content: summary,
642
+ };
643
+ }
644
+ /**
645
+ * Enforce an aggregate character budget over all tool_result blocks produced
646
+ * in one iteration. The largest non-error, not-yet-artifacted results are
647
+ * persisted to disk (largest first) until the batch fits the budget.
648
+ */
649
+ async function enforceToolResultsAggregateBudget(toolResults, options) {
650
+ if (options.maxTotalChars <= 0 || toolResults.length === 0) {
651
+ return;
652
+ }
653
+ const measured = toolResults.map((block, index) => ({
654
+ block,
655
+ index,
656
+ length: extractTextFromToolResultContent(block.content).length,
657
+ }));
658
+ let totalChars = measured.reduce((sum, entry) => sum + entry.length, 0);
659
+ if (totalChars <= options.maxTotalChars) {
660
+ return;
661
+ }
662
+ const candidates = measured
663
+ .filter((entry) => {
664
+ if (entry.block.is_error)
665
+ return false;
666
+ const text = extractTextFromToolResultContent(entry.block.content);
667
+ return !text.startsWith(ARTIFACTED_OUTPUT_MARKER);
668
+ })
669
+ .sort((a, b) => b.length - a.length);
670
+ for (const candidate of candidates) {
671
+ if (totalChars <= options.maxTotalChars) {
672
+ break;
673
+ }
674
+ const text = extractTextFromToolResultContent(candidate.block.content);
675
+ if (!text) {
676
+ continue;
677
+ }
678
+ const summary = await writeToolResultArtifact(text, {
679
+ runId: options.runId,
680
+ iteration: options.iteration,
681
+ toolUseId: candidate.block.tool_use_id,
682
+ toolName: options.nameByToolUseId.get(candidate.block.tool_use_id) ?? 'tool',
683
+ workDir: options.workDir,
684
+ previewChars: 2_000,
389
685
  });
390
- toolResults = [];
686
+ candidate.block.content = summary;
687
+ totalChars = totalChars - text.length + summary.length;
391
688
  }
392
689
  }
690
+ /**
691
+ * Partition tool calls into batches: consecutive concurrency-safe tools are
692
+ * grouped for parallel execution, everything else runs as a serial batch of
693
+ * one. Mirrors Claude Code's read-only batching behavior.
694
+ */
695
+ function partitionToolUsesForConcurrency(toolUses, toolMap) {
696
+ const batches = [];
697
+ for (const toolUse of toolUses) {
698
+ const adapter = toolMap.get(toolUse.name);
699
+ let safe = false;
700
+ if (adapter && adapter.requiresUserInteraction?.() !== true) {
701
+ try {
702
+ safe = adapter.isConcurrencySafe?.() ?? adapter.isReadOnly?.(toolUse.input) ?? false;
703
+ }
704
+ catch {
705
+ safe = false;
706
+ }
707
+ }
708
+ const last = batches.at(-1);
709
+ if (safe && last?.concurrent) {
710
+ last.toolUses.push(toolUse);
711
+ }
712
+ else {
713
+ batches.push({ concurrent: safe, toolUses: [toolUse] });
714
+ }
715
+ }
716
+ return batches;
717
+ }
718
+ async function runSequentially(items, run) {
719
+ const results = [];
720
+ for (const item of items) {
721
+ results.push(await run(item));
722
+ }
723
+ return results;
724
+ }
725
+ async function runWithConcurrencyLimit(items, limit, run) {
726
+ const results = new Array(items.length);
727
+ let nextIndex = 0;
728
+ const workers = Array.from({ length: Math.min(Math.max(limit, 1), items.length) }, async () => {
729
+ while (true) {
730
+ const index = nextIndex;
731
+ nextIndex += 1;
732
+ if (index >= items.length) {
733
+ return;
734
+ }
735
+ results[index] = await run(items[index]);
736
+ }
737
+ });
738
+ await Promise.all(workers);
739
+ return results;
740
+ }
741
+ function appendTextToToolResultContent(block, text) {
742
+ if (block.content === undefined || block.content === null) {
743
+ block.content = text;
744
+ return;
745
+ }
746
+ if (typeof block.content === 'string') {
747
+ block.content = `${block.content}\n\n${text}`;
748
+ return;
749
+ }
750
+ if (Array.isArray(block.content)) {
751
+ block.content.push({ type: 'text', text });
752
+ }
753
+ }
754
+ function buildTodoReminderText(todos) {
755
+ if (todos.length === 0) {
756
+ return [
757
+ '<system-reminder>',
758
+ 'The TodoWrite tool has not been used recently. If you are working on a multi-step task, use TodoWrite to track progress and keep exactly one item in_progress.',
759
+ 'Do not mention this reminder to the user.',
760
+ '</system-reminder>',
761
+ ].join('\n');
762
+ }
763
+ return [
764
+ '<system-reminder>',
765
+ 'Current todo list state (re-injected for continuity):',
766
+ formatActoviqTodoListLines(todos),
767
+ 'Continue working through pending items, update statuses with TodoWrite as you progress, and do not mention this reminder to the user.',
768
+ '</system-reminder>',
769
+ ].join('\n');
770
+ }
771
+ function isModelFallbackEligibleError(error) {
772
+ if (error instanceof ActoviqProviderApiError) {
773
+ const status = error.status ?? 0;
774
+ return status === 429 || status === 529 || (status >= 500 && status < 600);
775
+ }
776
+ return false;
777
+ }
778
+ const MAX_STREAM_INTERRUPTION_RETRIES = 3;
779
+ const TRANSPORT_ERROR_CODES = new Set([
780
+ 'ECONNRESET',
781
+ 'ECONNREFUSED',
782
+ 'EPIPE',
783
+ 'ETIMEDOUT',
784
+ 'EAI_AGAIN',
785
+ ]);
786
+ const TRANSPORT_ERROR_PATTERN = /terminated|fetch failed|socket|other side closed|premature|network|connection (?:closed|reset|error)/i;
787
+ /**
788
+ * Transport-level failures that occur after the provider accepted the request
789
+ * (mid-stream socket loss, abrupt connection close). HTTP-status errors are
790
+ * excluded — the provider client already retried those before throwing — and
791
+ * so are non-network errors, which must keep propagating.
792
+ */
793
+ function isRetryableStreamInterruption(error) {
794
+ if (error instanceof ActoviqProviderApiError ||
795
+ error instanceof RunAbortedError ||
796
+ error instanceof ActoviqSdkError ||
797
+ error instanceof ToolExecutionError) {
798
+ return false;
799
+ }
800
+ if (!(error instanceof Error) || error.name === 'AbortError') {
801
+ return false;
802
+ }
803
+ const cause = error.cause;
804
+ const causeCode = typeof cause?.code === 'string' ? cause.code : '';
805
+ if (causeCode.startsWith('UND_ERR') || TRANSPORT_ERROR_CODES.has(causeCode)) {
806
+ return true;
807
+ }
808
+ const causeMessage = typeof cause?.message === 'string' ? cause.message : '';
809
+ return TRANSPORT_ERROR_PATTERN.test(`${error.message} ${causeMessage}`);
810
+ }
811
+ function sleep(ms) {
812
+ return new Promise((resolve) => setTimeout(resolve, ms));
813
+ }
814
+ /**
815
+ * Mark the last content block of the last message with an ephemeral
816
+ * cache_control breakpoint. One breakpoint caches the entire request prefix
817
+ * (tools + system + conversation) on Anthropic API hosts. String-content
818
+ * messages (e.g. the first user prompt) are converted to an equivalent single
819
+ * text block so the breakpoint still applies — otherwise the whole request
820
+ * goes uncached whenever the last message is a plain string.
821
+ */
822
+ function applyCacheControlToLastMessage(messages) {
823
+ const last = messages.at(-1);
824
+ if (!last) {
825
+ return;
826
+ }
827
+ if (typeof last.content === 'string') {
828
+ if (last.content.length === 0) {
829
+ return;
830
+ }
831
+ last.content = [
832
+ { type: 'text', text: last.content, cache_control: { type: 'ephemeral' } },
833
+ ];
834
+ return;
835
+ }
836
+ if (!Array.isArray(last.content) || last.content.length === 0) {
837
+ return;
838
+ }
839
+ const lastBlock = last.content[last.content.length - 1];
840
+ if (lastBlock && typeof lastBlock === 'object') {
841
+ lastBlock.cache_control = { type: 'ephemeral' };
842
+ }
843
+ }
844
+ function sanitizeArtifactSegment(value) {
845
+ return value.replace(/[^A-Za-z0-9_.-]+/g, '_').slice(0, 80) || 'artifact';
846
+ }
847
+ function getRequestByteLength(request) {
848
+ return Buffer.byteLength(JSON.stringify({
849
+ ...request,
850
+ signal: undefined,
851
+ }), 'utf8');
852
+ }
393
853
  async function consumeStream(request, modelApi, iteration, emit, runId) {
394
854
  const stream = modelApi.streamMessage(request);
395
855
  let textSnapshot = '';