@open-multi-agent/core 1.4.0

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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +373 -0
  3. package/dist/agent/agent.d.ts +153 -0
  4. package/dist/agent/agent.d.ts.map +1 -0
  5. package/dist/agent/agent.js +559 -0
  6. package/dist/agent/agent.js.map +1 -0
  7. package/dist/agent/loop-detector.d.ts +39 -0
  8. package/dist/agent/loop-detector.d.ts.map +1 -0
  9. package/dist/agent/loop-detector.js +122 -0
  10. package/dist/agent/loop-detector.js.map +1 -0
  11. package/dist/agent/pool.d.ts +158 -0
  12. package/dist/agent/pool.d.ts.map +1 -0
  13. package/dist/agent/pool.js +320 -0
  14. package/dist/agent/pool.js.map +1 -0
  15. package/dist/agent/runner.d.ts +242 -0
  16. package/dist/agent/runner.d.ts.map +1 -0
  17. package/dist/agent/runner.js +943 -0
  18. package/dist/agent/runner.js.map +1 -0
  19. package/dist/agent/structured-output.d.ts +33 -0
  20. package/dist/agent/structured-output.d.ts.map +1 -0
  21. package/dist/agent/structured-output.js +116 -0
  22. package/dist/agent/structured-output.js.map +1 -0
  23. package/dist/cli/oma.d.ts +30 -0
  24. package/dist/cli/oma.d.ts.map +1 -0
  25. package/dist/cli/oma.js +433 -0
  26. package/dist/cli/oma.js.map +1 -0
  27. package/dist/dashboard/layout-tasks.d.ts +23 -0
  28. package/dist/dashboard/layout-tasks.d.ts.map +1 -0
  29. package/dist/dashboard/layout-tasks.js +79 -0
  30. package/dist/dashboard/layout-tasks.js.map +1 -0
  31. package/dist/dashboard/render-team-run-dashboard.d.ts +11 -0
  32. package/dist/dashboard/render-team-run-dashboard.d.ts.map +1 -0
  33. package/dist/dashboard/render-team-run-dashboard.js +456 -0
  34. package/dist/dashboard/render-team-run-dashboard.js.map +1 -0
  35. package/dist/errors.d.ts +14 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +20 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/index.d.ts +79 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +92 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/llm/adapter.d.ts +54 -0
  44. package/dist/llm/adapter.d.ts.map +1 -0
  45. package/dist/llm/adapter.js +101 -0
  46. package/dist/llm/adapter.js.map +1 -0
  47. package/dist/llm/anthropic.d.ts +57 -0
  48. package/dist/llm/anthropic.d.ts.map +1 -0
  49. package/dist/llm/anthropic.js +432 -0
  50. package/dist/llm/anthropic.js.map +1 -0
  51. package/dist/llm/azure-openai.d.ts +74 -0
  52. package/dist/llm/azure-openai.d.ts.map +1 -0
  53. package/dist/llm/azure-openai.js +267 -0
  54. package/dist/llm/azure-openai.js.map +1 -0
  55. package/dist/llm/bedrock.d.ts +41 -0
  56. package/dist/llm/bedrock.d.ts.map +1 -0
  57. package/dist/llm/bedrock.js +345 -0
  58. package/dist/llm/bedrock.js.map +1 -0
  59. package/dist/llm/copilot.d.ts +92 -0
  60. package/dist/llm/copilot.d.ts.map +1 -0
  61. package/dist/llm/copilot.js +433 -0
  62. package/dist/llm/copilot.js.map +1 -0
  63. package/dist/llm/deepseek.d.ts +21 -0
  64. package/dist/llm/deepseek.d.ts.map +1 -0
  65. package/dist/llm/deepseek.js +24 -0
  66. package/dist/llm/deepseek.js.map +1 -0
  67. package/dist/llm/gemini.d.ts +65 -0
  68. package/dist/llm/gemini.d.ts.map +1 -0
  69. package/dist/llm/gemini.js +427 -0
  70. package/dist/llm/gemini.js.map +1 -0
  71. package/dist/llm/grok.d.ts +21 -0
  72. package/dist/llm/grok.d.ts.map +1 -0
  73. package/dist/llm/grok.js +24 -0
  74. package/dist/llm/grok.js.map +1 -0
  75. package/dist/llm/minimax.d.ts +21 -0
  76. package/dist/llm/minimax.d.ts.map +1 -0
  77. package/dist/llm/minimax.js +24 -0
  78. package/dist/llm/minimax.js.map +1 -0
  79. package/dist/llm/openai-common.d.ts +65 -0
  80. package/dist/llm/openai-common.d.ts.map +1 -0
  81. package/dist/llm/openai-common.js +286 -0
  82. package/dist/llm/openai-common.js.map +1 -0
  83. package/dist/llm/openai.d.ts +63 -0
  84. package/dist/llm/openai.d.ts.map +1 -0
  85. package/dist/llm/openai.js +256 -0
  86. package/dist/llm/openai.js.map +1 -0
  87. package/dist/llm/qiniu.d.ts +21 -0
  88. package/dist/llm/qiniu.d.ts.map +1 -0
  89. package/dist/llm/qiniu.js +24 -0
  90. package/dist/llm/qiniu.js.map +1 -0
  91. package/dist/mcp.d.ts +3 -0
  92. package/dist/mcp.d.ts.map +1 -0
  93. package/dist/mcp.js +2 -0
  94. package/dist/mcp.js.map +1 -0
  95. package/dist/memory/shared.d.ts +162 -0
  96. package/dist/memory/shared.d.ts.map +1 -0
  97. package/dist/memory/shared.js +294 -0
  98. package/dist/memory/shared.js.map +1 -0
  99. package/dist/memory/store.d.ts +72 -0
  100. package/dist/memory/store.d.ts.map +1 -0
  101. package/dist/memory/store.js +121 -0
  102. package/dist/memory/store.js.map +1 -0
  103. package/dist/orchestrator/orchestrator.d.ts +245 -0
  104. package/dist/orchestrator/orchestrator.d.ts.map +1 -0
  105. package/dist/orchestrator/orchestrator.js +1400 -0
  106. package/dist/orchestrator/orchestrator.js.map +1 -0
  107. package/dist/orchestrator/scheduler.d.ts +112 -0
  108. package/dist/orchestrator/scheduler.d.ts.map +1 -0
  109. package/dist/orchestrator/scheduler.js +256 -0
  110. package/dist/orchestrator/scheduler.js.map +1 -0
  111. package/dist/task/queue.d.ts +191 -0
  112. package/dist/task/queue.d.ts.map +1 -0
  113. package/dist/task/queue.js +408 -0
  114. package/dist/task/queue.js.map +1 -0
  115. package/dist/task/task.d.ts +90 -0
  116. package/dist/task/task.d.ts.map +1 -0
  117. package/dist/task/task.js +206 -0
  118. package/dist/task/task.js.map +1 -0
  119. package/dist/team/messaging.d.ts +106 -0
  120. package/dist/team/messaging.d.ts.map +1 -0
  121. package/dist/team/messaging.js +183 -0
  122. package/dist/team/messaging.js.map +1 -0
  123. package/dist/team/team.d.ts +141 -0
  124. package/dist/team/team.d.ts.map +1 -0
  125. package/dist/team/team.js +293 -0
  126. package/dist/team/team.js.map +1 -0
  127. package/dist/tool/built-in/bash.d.ts +12 -0
  128. package/dist/tool/built-in/bash.d.ts.map +1 -0
  129. package/dist/tool/built-in/bash.js +133 -0
  130. package/dist/tool/built-in/bash.js.map +1 -0
  131. package/dist/tool/built-in/delegate.d.ts +29 -0
  132. package/dist/tool/built-in/delegate.d.ts.map +1 -0
  133. package/dist/tool/built-in/delegate.js +92 -0
  134. package/dist/tool/built-in/delegate.js.map +1 -0
  135. package/dist/tool/built-in/file-edit.d.ts +14 -0
  136. package/dist/tool/built-in/file-edit.d.ts.map +1 -0
  137. package/dist/tool/built-in/file-edit.js +130 -0
  138. package/dist/tool/built-in/file-edit.js.map +1 -0
  139. package/dist/tool/built-in/file-read.d.ts +12 -0
  140. package/dist/tool/built-in/file-read.d.ts.map +1 -0
  141. package/dist/tool/built-in/file-read.js +82 -0
  142. package/dist/tool/built-in/file-read.js.map +1 -0
  143. package/dist/tool/built-in/file-write.d.ts +11 -0
  144. package/dist/tool/built-in/file-write.d.ts.map +1 -0
  145. package/dist/tool/built-in/file-write.js +70 -0
  146. package/dist/tool/built-in/file-write.js.map +1 -0
  147. package/dist/tool/built-in/fs-walk.d.ts +23 -0
  148. package/dist/tool/built-in/fs-walk.d.ts.map +1 -0
  149. package/dist/tool/built-in/fs-walk.js +78 -0
  150. package/dist/tool/built-in/fs-walk.js.map +1 -0
  151. package/dist/tool/built-in/glob.d.ts +12 -0
  152. package/dist/tool/built-in/glob.d.ts.map +1 -0
  153. package/dist/tool/built-in/glob.js +82 -0
  154. package/dist/tool/built-in/glob.js.map +1 -0
  155. package/dist/tool/built-in/grep.d.ts +15 -0
  156. package/dist/tool/built-in/grep.d.ts.map +1 -0
  157. package/dist/tool/built-in/grep.js +218 -0
  158. package/dist/tool/built-in/grep.js.map +1 -0
  159. package/dist/tool/built-in/index.d.ts +48 -0
  160. package/dist/tool/built-in/index.d.ts.map +1 -0
  161. package/dist/tool/built-in/index.js +56 -0
  162. package/dist/tool/built-in/index.js.map +1 -0
  163. package/dist/tool/executor.d.ts +100 -0
  164. package/dist/tool/executor.d.ts.map +1 -0
  165. package/dist/tool/executor.js +184 -0
  166. package/dist/tool/executor.js.map +1 -0
  167. package/dist/tool/framework.d.ts +167 -0
  168. package/dist/tool/framework.d.ts.map +1 -0
  169. package/dist/tool/framework.js +402 -0
  170. package/dist/tool/framework.js.map +1 -0
  171. package/dist/tool/mcp.d.ts +31 -0
  172. package/dist/tool/mcp.d.ts.map +1 -0
  173. package/dist/tool/mcp.js +175 -0
  174. package/dist/tool/mcp.js.map +1 -0
  175. package/dist/tool/text-tool-extractor.d.ts +32 -0
  176. package/dist/tool/text-tool-extractor.d.ts.map +1 -0
  177. package/dist/tool/text-tool-extractor.js +195 -0
  178. package/dist/tool/text-tool-extractor.js.map +1 -0
  179. package/dist/types.d.ts +916 -0
  180. package/dist/types.d.ts.map +1 -0
  181. package/dist/types.js +8 -0
  182. package/dist/types.js.map +1 -0
  183. package/dist/utils/keywords.d.ts +18 -0
  184. package/dist/utils/keywords.d.ts.map +1 -0
  185. package/dist/utils/keywords.js +32 -0
  186. package/dist/utils/keywords.js.map +1 -0
  187. package/dist/utils/semaphore.d.ts +49 -0
  188. package/dist/utils/semaphore.d.ts.map +1 -0
  189. package/dist/utils/semaphore.js +89 -0
  190. package/dist/utils/semaphore.js.map +1 -0
  191. package/dist/utils/tokens.d.ts +7 -0
  192. package/dist/utils/tokens.d.ts.map +1 -0
  193. package/dist/utils/tokens.js +30 -0
  194. package/dist/utils/tokens.js.map +1 -0
  195. package/dist/utils/trace.d.ts +12 -0
  196. package/dist/utils/trace.d.ts.map +1 -0
  197. package/dist/utils/trace.js +30 -0
  198. package/dist/utils/trace.js.map +1 -0
  199. package/docs/DECISIONS.md +49 -0
  200. package/docs/cli.md +265 -0
  201. package/docs/context-management.md +24 -0
  202. package/docs/featured-partner.md +28 -0
  203. package/docs/observability.md +56 -0
  204. package/docs/providers.md +78 -0
  205. package/docs/shared-memory.md +27 -0
  206. package/docs/tool-configuration.md +152 -0
  207. package/package.json +96 -0
@@ -0,0 +1,943 @@
1
+ /**
2
+ * @fileoverview Core conversation loop engine for open-multi-agent.
3
+ *
4
+ * {@link AgentRunner} is the heart of the framework. It handles:
5
+ * - Sending messages to the LLM adapter
6
+ * - Extracting tool-use blocks from the response
7
+ * - Executing tool calls in parallel via {@link ToolExecutor}
8
+ * - Appending tool results and looping back until `end_turn`
9
+ * - Accumulating token usage and timing data across all turns
10
+ *
11
+ * The loop follows a standard agentic conversation pattern:
12
+ * one outer `while (true)` that breaks on `end_turn` or maxTurns exhaustion.
13
+ */
14
+ import { TokenBudgetExceededError } from '../errors.js';
15
+ import { LoopDetector } from './loop-detector.js';
16
+ import { emitTrace } from '../utils/trace.js';
17
+ import { estimateTokens } from '../utils/tokens.js';
18
+ // ---------------------------------------------------------------------------
19
+ // Tool presets
20
+ // ---------------------------------------------------------------------------
21
+ /** Predefined tool sets for common agent use cases. */
22
+ export const TOOL_PRESETS = {
23
+ readonly: ['file_read', 'grep', 'glob'],
24
+ readwrite: ['file_read', 'file_write', 'file_edit', 'grep', 'glob'],
25
+ full: ['file_read', 'file_write', 'file_edit', 'grep', 'glob', 'bash'],
26
+ };
27
+ /** Framework-level disallowed tools for safety rails. */
28
+ export const AGENT_FRAMEWORK_DISALLOWED = [
29
+ // Empty for now, infrastructure for future built-in tools
30
+ ];
31
+ // ---------------------------------------------------------------------------
32
+ // Internal helpers
33
+ // ---------------------------------------------------------------------------
34
+ /** Extract every TextBlock from a content array and join them. */
35
+ function extractText(content) {
36
+ return content
37
+ .filter((b) => b.type === 'text')
38
+ .map(b => b.text)
39
+ .join('');
40
+ }
41
+ /** Extract every ToolUseBlock from a content array. */
42
+ function extractToolUseBlocks(content) {
43
+ return content.filter((b) => b.type === 'tool_use');
44
+ }
45
+ /**
46
+ * Group a flat message array into atomic turns so context-management
47
+ * strategies can split on safe boundaries.
48
+ *
49
+ * A turn is one of:
50
+ * - a single user / assistant text message, or
51
+ * - an assistant message containing one or more `tool_use` blocks plus the
52
+ * immediately following user message containing the matching `tool_result`
53
+ * blocks (kept together so neither half can be dropped on its own).
54
+ *
55
+ * Splitting on turn boundaries — instead of slicing by raw message count —
56
+ * prevents orphaned `tool_use_id` references that the Anthropic and OpenAI
57
+ * APIs reject. Modelled on `groupIntoTurns` from the context-chef library.
58
+ */
59
+ function groupIntoTurns(messages) {
60
+ const turns = [];
61
+ let i = 0;
62
+ while (i < messages.length) {
63
+ const msg = messages[i];
64
+ const hasToolUse = msg.role === 'assistant' && msg.content.some(b => b.type === 'tool_use');
65
+ if (hasToolUse) {
66
+ const start = i;
67
+ i++;
68
+ // Absorb the matching tool_result user message, when present.
69
+ if (i < messages.length
70
+ && messages[i].role === 'user'
71
+ && messages[i].content.some(b => b.type === 'tool_result')) {
72
+ i++;
73
+ }
74
+ turns.push({ startIndex: start, endIndex: i });
75
+ }
76
+ else {
77
+ turns.push({ startIndex: i, endIndex: i + 1 });
78
+ i++;
79
+ }
80
+ }
81
+ return turns;
82
+ }
83
+ /**
84
+ * Replace `image` blocks with text placeholders so binary attachment data
85
+ * never leaks into the summarisation prompt.
86
+ *
87
+ * `summarizeMessages` flattens old turns via `JSON.stringify(message)` and
88
+ * inlines the result into a text user-message it ships to the summary model.
89
+ * For an `ImageBlock`, that serialisation includes the full base64 payload —
90
+ * a 1MB image would balloon the "compression" call by ~250k tokens, defeating
91
+ * its purpose and risking context-limit rejection.
92
+ *
93
+ * The placeholder still tells the summariser that media was present at this
94
+ * turn, so the produced summary can reference it. Modelled on chef Janitor's
95
+ * `stripAttachmentsForCompression`.
96
+ */
97
+ function stripImageBlocksForSummary(messages) {
98
+ return messages.map((msg) => {
99
+ if (!msg.content.some(b => b.type === 'image'))
100
+ return msg;
101
+ const newContent = msg.content.map((block) => {
102
+ if (block.type === 'image') {
103
+ return { type: 'text', text: `[image: ${block.source.media_type}]` };
104
+ }
105
+ return block;
106
+ });
107
+ return { role: msg.role, content: newContent };
108
+ });
109
+ }
110
+ /** Add two {@link TokenUsage} values together, returning a new object. */
111
+ function addTokenUsage(a, b) {
112
+ return {
113
+ input_tokens: a.input_tokens + b.input_tokens,
114
+ output_tokens: a.output_tokens + b.output_tokens,
115
+ };
116
+ }
117
+ const ZERO_USAGE = { input_tokens: 0, output_tokens: 0 };
118
+ /** Default minimum content length before tool result compression kicks in. */
119
+ const DEFAULT_MIN_COMPRESS_CHARS = 500;
120
+ /**
121
+ * Prepends synthetic framing text to the first user message so we never emit
122
+ * consecutive `user` turns (Bedrock) and summaries do not concatenate onto
123
+ * the original user prompt (direct API). If there is no user message yet,
124
+ * inserts a single assistant text preamble.
125
+ */
126
+ function prependSyntheticPrefixToFirstUser(messages, prefix) {
127
+ const userIdx = messages.findIndex(m => m.role === 'user');
128
+ if (userIdx < 0) {
129
+ return [{
130
+ role: 'assistant',
131
+ content: [{ type: 'text', text: prefix.trimEnd() }],
132
+ }, ...messages];
133
+ }
134
+ const target = messages[userIdx];
135
+ const merged = {
136
+ role: 'user',
137
+ content: [{ type: 'text', text: prefix }, ...target.content],
138
+ };
139
+ return [...messages.slice(0, userIdx), merged, ...messages.slice(userIdx + 1)];
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // AgentRunner
143
+ // ---------------------------------------------------------------------------
144
+ /**
145
+ * Drives a full agentic conversation: LLM calls, tool execution, and looping.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * const runner = new AgentRunner(adapter, registry, executor, {
150
+ * model: 'claude-opus-4-6',
151
+ * maxTurns: 10,
152
+ * })
153
+ * const result = await runner.run(messages)
154
+ * console.log(result.output)
155
+ * ```
156
+ */
157
+ export class AgentRunner {
158
+ adapter;
159
+ toolRegistry;
160
+ toolExecutor;
161
+ options;
162
+ maxTurns;
163
+ summarizeCache = null;
164
+ constructor(adapter, toolRegistry, toolExecutor, options) {
165
+ this.adapter = adapter;
166
+ this.toolRegistry = toolRegistry;
167
+ this.toolExecutor = toolExecutor;
168
+ this.options = options;
169
+ this.maxTurns = options.maxTurns ?? 10;
170
+ }
171
+ serializeMessage(message) {
172
+ return JSON.stringify(message);
173
+ }
174
+ truncateToSlidingWindow(messages, maxTurns) {
175
+ if (maxTurns <= 0) {
176
+ return messages;
177
+ }
178
+ const firstUserIndex = messages.findIndex(m => m.role === 'user');
179
+ const firstUser = firstUserIndex >= 0 ? messages[firstUserIndex] : null;
180
+ const afterFirst = firstUserIndex >= 0
181
+ ? messages.slice(firstUserIndex + 1)
182
+ : messages.slice();
183
+ // Walk turns from the tail, accumulating message count until we have at
184
+ // least `maxTurns * 2` messages — preserving the historical "message-pair
185
+ // count" semantics of `maxTurns` for plain conversations while never
186
+ // splitting a tool_use/tool_result pair (see `groupIntoTurns`). The kept
187
+ // slice may exceed the target by one message when the boundary lands
188
+ // inside an atomic tool turn — that's the smallest safe slice.
189
+ const target = maxTurns * 2;
190
+ if (afterFirst.length <= target) {
191
+ return messages;
192
+ }
193
+ const turns = groupIntoTurns(afterFirst);
194
+ let cumulative = 0;
195
+ let cutoffTurnIdx = turns.length;
196
+ for (let i = turns.length - 1; i >= 0; i--) {
197
+ cumulative += turns[i].endIndex - turns[i].startIndex;
198
+ cutoffTurnIdx = i;
199
+ if (cumulative >= target)
200
+ break;
201
+ }
202
+ const keptTurns = turns.slice(cutoffTurnIdx);
203
+ const keepStartIdx = keptTurns[0].startIndex;
204
+ const kept = afterFirst.slice(keepStartIdx);
205
+ const droppedTurns = turns.length - keptTurns.length;
206
+ const result = [];
207
+ if (firstUser !== null) {
208
+ result.push(firstUser);
209
+ }
210
+ if (droppedTurns > 0) {
211
+ const notice = `[Earlier conversation history truncated — ${droppedTurns} turn(s) removed]\n\n`;
212
+ result.push(...prependSyntheticPrefixToFirstUser(kept, notice));
213
+ return result;
214
+ }
215
+ result.push(...kept);
216
+ return result;
217
+ }
218
+ async summarizeMessages(messages, maxTokens, summaryModel, baseChatOptions, turns, options) {
219
+ const estimated = estimateTokens(messages);
220
+ if (estimated <= maxTokens || messages.length < 4) {
221
+ return { messages, usage: ZERO_USAGE };
222
+ }
223
+ const firstUserIndex = messages.findIndex(m => m.role === 'user');
224
+ if (firstUserIndex < 0 || firstUserIndex === messages.length - 1) {
225
+ return { messages, usage: ZERO_USAGE };
226
+ }
227
+ const firstUser = messages[firstUserIndex];
228
+ const rest = messages.slice(firstUserIndex + 1);
229
+ if (rest.length < 2) {
230
+ return { messages, usage: ZERO_USAGE };
231
+ }
232
+ // Split on an even boundary so we never separate a tool_use assistant turn
233
+ // from its tool_result user message (rest is user/assistant pairs).
234
+ const splitAt = Math.max(2, Math.floor(rest.length / 4) * 2);
235
+ const oldPortion = rest.slice(0, splitAt);
236
+ const recentPortion = rest.slice(splitAt);
237
+ // Strip image attachments before serialising — JSON.stringify on an
238
+ // ImageBlock would inline the entire base64 payload into the summary
239
+ // prompt, so a 1MB image would defeat the very purpose of compression.
240
+ // The placeholder still flags that media existed at this turn so the
241
+ // summariser can mention it. recentPortion is untouched (returned to
242
+ // the caller verbatim, never serialised here).
243
+ const oldPortionForSummary = stripImageBlocksForSummary(oldPortion);
244
+ const oldSignature = oldPortionForSummary.map(m => this.serializeMessage(m)).join('\n');
245
+ if (this.summarizeCache !== null && this.summarizeCache.oldSignature === oldSignature) {
246
+ const mergedRecent = prependSyntheticPrefixToFirstUser(recentPortion, `${this.summarizeCache.summaryPrefix}\n\n`);
247
+ return { messages: [firstUser, ...mergedRecent], usage: ZERO_USAGE };
248
+ }
249
+ const summaryPrompt = [
250
+ 'Summarize the following conversation history for an LLM.',
251
+ '- Preserve user goals, constraints, and decisions.',
252
+ '- Keep key tool outputs and unresolved questions.',
253
+ '- Use concise bullets.',
254
+ '- Do not fabricate details.',
255
+ ].join('\n');
256
+ const summaryInput = [
257
+ {
258
+ role: 'user',
259
+ content: [
260
+ { type: 'text', text: summaryPrompt },
261
+ { type: 'text', text: `\n\nConversation:\n${oldSignature}` },
262
+ ],
263
+ },
264
+ ];
265
+ const summaryOptions = {
266
+ ...baseChatOptions,
267
+ model: summaryModel ?? this.options.model,
268
+ tools: undefined,
269
+ };
270
+ const summaryStartMs = Date.now();
271
+ const summaryResponse = await this.adapter.chat(summaryInput, summaryOptions);
272
+ if (options.onTrace) {
273
+ const summaryEndMs = Date.now();
274
+ emitTrace(options.onTrace, {
275
+ type: 'llm_call',
276
+ runId: options.runId ?? '',
277
+ taskId: options.taskId,
278
+ agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
279
+ model: summaryOptions.model,
280
+ phase: 'summary',
281
+ turn: turns,
282
+ tokens: summaryResponse.usage,
283
+ startMs: summaryStartMs,
284
+ endMs: summaryEndMs,
285
+ durationMs: summaryEndMs - summaryStartMs,
286
+ });
287
+ }
288
+ const summaryText = extractText(summaryResponse.content).trim();
289
+ const summaryPrefix = summaryText.length > 0
290
+ ? `[Conversation summary]\n${summaryText}`
291
+ : '[Conversation summary unavailable]';
292
+ this.summarizeCache = { oldSignature, summaryPrefix };
293
+ const mergedRecent = prependSyntheticPrefixToFirstUser(recentPortion, `${summaryPrefix}\n\n`);
294
+ return {
295
+ messages: [firstUser, ...mergedRecent],
296
+ usage: summaryResponse.usage,
297
+ };
298
+ }
299
+ async applyContextStrategy(messages, strategy, baseChatOptions, turns, options) {
300
+ if (strategy.type === 'sliding-window') {
301
+ return { messages: this.truncateToSlidingWindow(messages, strategy.maxTurns), usage: ZERO_USAGE };
302
+ }
303
+ if (strategy.type === 'summarize') {
304
+ return this.summarizeMessages(messages, strategy.maxTokens, strategy.summaryModel, baseChatOptions, turns, options);
305
+ }
306
+ if (strategy.type === 'compact') {
307
+ return { messages: this.compactMessages(messages, strategy), usage: ZERO_USAGE };
308
+ }
309
+ const estimated = estimateTokens(messages);
310
+ const compressed = await strategy.compress(messages, estimated);
311
+ if (!Array.isArray(compressed) || compressed.length === 0) {
312
+ throw new Error('contextStrategy.custom.compress must return a non-empty LLMMessage[]');
313
+ }
314
+ return { messages: compressed, usage: ZERO_USAGE };
315
+ }
316
+ // -------------------------------------------------------------------------
317
+ // Tool resolution
318
+ // -------------------------------------------------------------------------
319
+ /**
320
+ * Resolve the final set of tools available to this agent based on the
321
+ * three-layer configuration: preset → allowlist → denylist → framework safety.
322
+ *
323
+ * Returns LLMToolDef[] for direct use with LLM adapters.
324
+ */
325
+ resolveTools() {
326
+ // Validate configuration for contradictions
327
+ if (this.options.toolPreset && this.options.allowedTools) {
328
+ console.warn('AgentRunner: both toolPreset and allowedTools are set. ' +
329
+ 'Final tool access will be the intersection of both.');
330
+ }
331
+ if (this.options.allowedTools && this.options.disallowedTools) {
332
+ const overlap = this.options.allowedTools.filter(tool => this.options.disallowedTools.includes(tool));
333
+ if (overlap.length > 0) {
334
+ console.warn(`AgentRunner: tools [${overlap.map(name => `"${name}"`).join(', ')}] appear in both allowedTools and disallowedTools. ` +
335
+ 'This is contradictory and may lead to unexpected behavior.');
336
+ }
337
+ }
338
+ const allTools = this.toolRegistry.toToolDefs();
339
+ const runtimeCustomTools = this.toolRegistry.toRuntimeToolDefs();
340
+ const runtimeCustomToolNames = new Set(runtimeCustomTools.map(t => t.name));
341
+ let filteredTools = allTools.filter(t => !runtimeCustomToolNames.has(t.name));
342
+ // 1. Apply preset filter if set
343
+ if (this.options.toolPreset) {
344
+ const presetTools = new Set(TOOL_PRESETS[this.options.toolPreset]);
345
+ filteredTools = filteredTools.filter(t => presetTools.has(t.name));
346
+ }
347
+ // 2. Apply allowlist filter if set
348
+ if (this.options.allowedTools) {
349
+ filteredTools = filteredTools.filter(t => this.options.allowedTools.includes(t.name));
350
+ }
351
+ // 3. Apply denylist filter if set
352
+ const denied = this.options.disallowedTools
353
+ ? new Set(this.options.disallowedTools)
354
+ : undefined;
355
+ if (denied) {
356
+ filteredTools = filteredTools.filter(t => !denied.has(t.name));
357
+ }
358
+ // 4. Apply framework-level safety rails
359
+ const frameworkDenied = new Set(AGENT_FRAMEWORK_DISALLOWED);
360
+ filteredTools = filteredTools.filter(t => !frameworkDenied.has(t.name));
361
+ // Runtime-added custom tools bypass preset / allowlist but respect denylist.
362
+ const finalRuntime = denied
363
+ ? runtimeCustomTools.filter(t => !denied.has(t.name))
364
+ : runtimeCustomTools;
365
+ return [...filteredTools, ...finalRuntime];
366
+ }
367
+ // -------------------------------------------------------------------------
368
+ // Public API
369
+ // -------------------------------------------------------------------------
370
+ /**
371
+ * Run a complete conversation starting from `messages`.
372
+ *
373
+ * The call may internally make multiple LLM requests (one per tool-call
374
+ * round-trip). It returns only when:
375
+ * - The LLM emits `end_turn` with no tool-use blocks, or
376
+ * - `maxTurns` is exceeded, or
377
+ * - The abort signal is triggered.
378
+ */
379
+ async run(messages, options = {}) {
380
+ // Collect everything yielded by the internal streaming loop.
381
+ const accumulated = {
382
+ messages: [],
383
+ output: '',
384
+ toolCalls: [],
385
+ tokenUsage: ZERO_USAGE,
386
+ turns: 0,
387
+ };
388
+ for await (const event of this.stream(messages, options)) {
389
+ if (event.type === 'done') {
390
+ Object.assign(accumulated, event.data);
391
+ }
392
+ else if (event.type === 'error') {
393
+ throw event.data;
394
+ }
395
+ }
396
+ return accumulated;
397
+ }
398
+ /**
399
+ * Run the conversation and yield {@link StreamEvent}s incrementally.
400
+ *
401
+ * Callers receive:
402
+ * - `{ type: 'text', data: string }` for each text delta
403
+ * - `{ type: 'tool_use', data: ToolUseBlock }` when the model requests a tool
404
+ * - `{ type: 'tool_result', data: ToolResultBlock }` after each execution
405
+ * - `{ type: 'budget_exceeded', data: TokenBudgetExceededError }` on budget trip
406
+ * - `{ type: 'done', data: RunResult }` at the very end
407
+ * - `{ type: 'error', data: Error }` on unrecoverable failure
408
+ */
409
+ async *stream(initialMessages, options = {}) {
410
+ // Working copy of the conversation — mutated as turns progress.
411
+ let conversationMessages = [...initialMessages];
412
+ const newMessages = [];
413
+ // Accumulated state across all turns.
414
+ let totalUsage = ZERO_USAGE;
415
+ const allToolCalls = [];
416
+ let finalOutput = '';
417
+ let turns = 0;
418
+ let budgetExceeded = false;
419
+ // Build the stable LLM options once; model / tokens / temp don't change.
420
+ // resolveTools() returns LLMToolDef[] with three-layer filtering applied.
421
+ const toolDefs = this.resolveTools();
422
+ // Per-call abortSignal takes precedence over the static one.
423
+ const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal;
424
+ const baseChatOptions = {
425
+ model: this.options.model,
426
+ tools: toolDefs.length > 0 ? toolDefs : undefined,
427
+ maxTokens: this.options.maxTokens,
428
+ temperature: this.options.temperature,
429
+ topP: this.options.topP,
430
+ topK: this.options.topK,
431
+ minP: this.options.minP,
432
+ parallelToolCalls: this.options.parallelToolCalls,
433
+ frequencyPenalty: this.options.frequencyPenalty,
434
+ presencePenalty: this.options.presencePenalty,
435
+ extraBody: this.options.extraBody,
436
+ thinking: this.options.thinking,
437
+ systemPrompt: this.options.systemPrompt,
438
+ abortSignal: effectiveAbortSignal,
439
+ };
440
+ // Loop detection state — only allocated when configured.
441
+ const detector = this.options.loopDetection
442
+ ? new LoopDetector(this.options.loopDetection)
443
+ : null;
444
+ let loopDetected = false;
445
+ let loopWarned = false;
446
+ const loopAction = this.options.loopDetection?.onLoopDetected ?? 'warn';
447
+ try {
448
+ // -----------------------------------------------------------------
449
+ // Main agentic loop — `while (true)` until end_turn or maxTurns
450
+ // -----------------------------------------------------------------
451
+ while (true) {
452
+ // Respect abort before each LLM call.
453
+ if (effectiveAbortSignal?.aborted) {
454
+ break;
455
+ }
456
+ // Guard against unbounded loops.
457
+ if (turns >= this.maxTurns) {
458
+ break;
459
+ }
460
+ turns++;
461
+ // Compress consumed tool results before context strategy (lightweight,
462
+ // no LLM calls) so the strategy operates on already-reduced messages.
463
+ if (this.options.compressToolResults && turns > 1) {
464
+ conversationMessages = this.compressConsumedToolResults(conversationMessages);
465
+ }
466
+ // Optionally compact context before each LLM call.
467
+ if (this.options.contextStrategy) {
468
+ const compacted = await this.applyContextStrategy(conversationMessages, this.options.contextStrategy, baseChatOptions, turns, options);
469
+ conversationMessages = compacted.messages;
470
+ totalUsage = addTokenUsage(totalUsage, compacted.usage);
471
+ }
472
+ // ------------------------------------------------------------------
473
+ // Step 1: Call the LLM and collect the full response for this turn.
474
+ // ------------------------------------------------------------------
475
+ const llmStartMs = Date.now();
476
+ const response = await this.adapter.chat(conversationMessages, baseChatOptions);
477
+ if (options.onTrace) {
478
+ const llmEndMs = Date.now();
479
+ emitTrace(options.onTrace, {
480
+ type: 'llm_call',
481
+ runId: options.runId ?? '',
482
+ taskId: options.taskId,
483
+ agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
484
+ model: this.options.model,
485
+ phase: 'turn',
486
+ turn: turns,
487
+ tokens: response.usage,
488
+ startMs: llmStartMs,
489
+ endMs: llmEndMs,
490
+ durationMs: llmEndMs - llmStartMs,
491
+ });
492
+ }
493
+ totalUsage = addTokenUsage(totalUsage, response.usage);
494
+ // ------------------------------------------------------------------
495
+ // Step 2: Build the assistant message from the response content.
496
+ // ------------------------------------------------------------------
497
+ const assistantMessage = {
498
+ role: 'assistant',
499
+ content: response.content,
500
+ };
501
+ conversationMessages.push(assistantMessage);
502
+ newMessages.push(assistantMessage);
503
+ options.onMessage?.(assistantMessage);
504
+ // Yield text deltas so streaming callers can display them promptly.
505
+ const turnText = extractText(response.content);
506
+ if (turnText.length > 0) {
507
+ yield { type: 'text', data: turnText };
508
+ }
509
+ const totalTokens = totalUsage.input_tokens + totalUsage.output_tokens;
510
+ if (this.options.maxTokenBudget !== undefined && totalTokens > this.options.maxTokenBudget) {
511
+ budgetExceeded = true;
512
+ finalOutput = turnText;
513
+ yield {
514
+ type: 'budget_exceeded',
515
+ data: new TokenBudgetExceededError(this.options.agentName ?? 'unknown', totalTokens, this.options.maxTokenBudget),
516
+ };
517
+ break;
518
+ }
519
+ // Extract tool-use blocks for detection and execution.
520
+ const toolUseBlocks = extractToolUseBlocks(response.content);
521
+ // ------------------------------------------------------------------
522
+ // Step 2.5: Loop detection — check before yielding tool_use events
523
+ // so that terminate mode doesn't emit orphaned tool_use without
524
+ // matching tool_result.
525
+ // ------------------------------------------------------------------
526
+ let injectWarning = false;
527
+ let injectWarningKind = 'tool_repetition';
528
+ if (detector && toolUseBlocks.length > 0) {
529
+ const toolInfo = detector.recordToolCalls(toolUseBlocks);
530
+ const textInfo = turnText.length > 0 ? detector.recordText(turnText) : null;
531
+ const info = toolInfo ?? textInfo;
532
+ if (info) {
533
+ yield { type: 'loop_detected', data: info };
534
+ options.onWarning?.(info.detail);
535
+ const action = typeof loopAction === 'function'
536
+ ? await loopAction(info)
537
+ : loopAction;
538
+ if (action === 'terminate') {
539
+ loopDetected = true;
540
+ finalOutput = turnText;
541
+ break;
542
+ }
543
+ else if (action === 'warn' || action === 'inject') {
544
+ if (loopWarned) {
545
+ // Second detection after a warning — force terminate.
546
+ loopDetected = true;
547
+ finalOutput = turnText;
548
+ break;
549
+ }
550
+ loopWarned = true;
551
+ injectWarning = true;
552
+ injectWarningKind = info.kind;
553
+ // Fall through to execute tools, then inject warning.
554
+ }
555
+ // 'continue' — do nothing, let the loop proceed normally.
556
+ }
557
+ else {
558
+ // No loop detected this turn — agent has recovered, so reset
559
+ // the warning state. A future loop gets a fresh warning cycle.
560
+ loopWarned = false;
561
+ }
562
+ }
563
+ // ------------------------------------------------------------------
564
+ // Step 3: Decide whether to continue looping.
565
+ // ------------------------------------------------------------------
566
+ if (toolUseBlocks.length === 0) {
567
+ // Warn on first turn if tools were provided but model didn't use them.
568
+ if (turns === 1 && toolDefs.length > 0 && options.onWarning) {
569
+ const agentName = this.options.agentName ?? 'unknown';
570
+ options.onWarning(`Agent "${agentName}" has ${toolDefs.length} tool(s) available but the model ` +
571
+ `returned no tool calls. If using a local model, verify it supports tool calling ` +
572
+ `(see https://ollama.com/search?c=tools).`);
573
+ }
574
+ // No tools requested — this is the terminal assistant turn.
575
+ finalOutput = turnText;
576
+ break;
577
+ }
578
+ // Announce each tool-use block the model requested (after loop
579
+ // detection, so terminate mode never emits unpaired events).
580
+ for (const block of toolUseBlocks) {
581
+ yield { type: 'tool_use', data: block };
582
+ }
583
+ // ------------------------------------------------------------------
584
+ // Step 4: Execute all tool calls in PARALLEL.
585
+ //
586
+ // Parallel execution is critical for multi-tool responses where the
587
+ // tools are independent (e.g. reading several files at once).
588
+ // ------------------------------------------------------------------
589
+ const toolContext = this.buildToolContext(options);
590
+ const executionPromises = toolUseBlocks.map(async (block) => {
591
+ options.onToolCall?.(block.name, block.input);
592
+ const startTime = Date.now();
593
+ let result;
594
+ try {
595
+ result = await this.toolExecutor.execute(block.name, block.input, toolContext);
596
+ }
597
+ catch (err) {
598
+ // Tool executor errors become error results — the loop continues.
599
+ const message = err instanceof Error ? err.message : String(err);
600
+ result = { data: message, isError: true };
601
+ }
602
+ const endTime = Date.now();
603
+ const duration = endTime - startTime;
604
+ options.onToolResult?.(block.name, result);
605
+ if (options.onTrace) {
606
+ emitTrace(options.onTrace, {
607
+ type: 'tool_call',
608
+ runId: options.runId ?? '',
609
+ taskId: options.taskId,
610
+ agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
611
+ tool: block.name,
612
+ isError: result.isError ?? false,
613
+ input: block.input,
614
+ output: result.data,
615
+ startMs: startTime,
616
+ endMs: endTime,
617
+ durationMs: duration,
618
+ });
619
+ }
620
+ const record = {
621
+ toolName: block.name,
622
+ input: block.input,
623
+ output: result.data,
624
+ duration,
625
+ };
626
+ const resultBlock = {
627
+ type: 'tool_result',
628
+ tool_use_id: block.id,
629
+ content: result.data,
630
+ is_error: result.isError,
631
+ };
632
+ return {
633
+ resultBlock,
634
+ record,
635
+ ...(result.metadata?.tokenUsage !== undefined
636
+ ? { delegationUsage: result.metadata.tokenUsage }
637
+ : {}),
638
+ };
639
+ });
640
+ // Wait for every tool in this turn to finish.
641
+ const executions = await Promise.all(executionPromises);
642
+ // Roll up any nested-run token usage surfaced via ToolResult.metadata
643
+ // (e.g. from delegate_to_agent) so it counts against this agent's budget.
644
+ let delegationTurnUsage;
645
+ for (const ex of executions) {
646
+ if (ex.delegationUsage !== undefined) {
647
+ totalUsage = addTokenUsage(totalUsage, ex.delegationUsage);
648
+ delegationTurnUsage = delegationTurnUsage === undefined
649
+ ? ex.delegationUsage
650
+ : addTokenUsage(delegationTurnUsage, ex.delegationUsage);
651
+ }
652
+ }
653
+ // ------------------------------------------------------------------
654
+ // Step 5: Accumulate results and build the user message that carries
655
+ // them back to the LLM in the next turn.
656
+ // ------------------------------------------------------------------
657
+ const toolResultBlocks = executions.map(e => e.resultBlock);
658
+ for (const { record, resultBlock } of executions) {
659
+ allToolCalls.push(record);
660
+ yield { type: 'tool_result', data: resultBlock };
661
+ }
662
+ // Inject a loop-detection warning into the tool-result message so
663
+ // the LLM sees it alongside the results (avoids two consecutive user
664
+ // messages which violates the alternating-role constraint).
665
+ if (injectWarning) {
666
+ const warningText = injectWarningKind === 'text_repetition'
667
+ ? 'WARNING: You appear to be generating the same response repeatedly. ' +
668
+ 'This suggests you are stuck in a loop. Please try a different approach ' +
669
+ 'or provide new information.'
670
+ : 'WARNING: You appear to be repeating the same tool calls with identical arguments. ' +
671
+ 'This suggests you are stuck in a loop. Please try a different approach, use different ' +
672
+ 'parameters, or explain what you are trying to accomplish.';
673
+ toolResultBlocks.push({ type: 'text', text: warningText });
674
+ }
675
+ const toolResultMessage = {
676
+ role: 'user',
677
+ content: toolResultBlocks,
678
+ };
679
+ conversationMessages.push(toolResultMessage);
680
+ newMessages.push(toolResultMessage);
681
+ options.onMessage?.(toolResultMessage);
682
+ // Budget check is deferred until tool_result events have been yielded
683
+ // and the tool_result user message has been appended, so stream
684
+ // consumers see matched tool_use/tool_result pairs and the returned
685
+ // `messages` remain resumable against the Anthropic/OpenAI APIs.
686
+ if (delegationTurnUsage !== undefined && this.options.maxTokenBudget !== undefined) {
687
+ const totalAfterDelegation = totalUsage.input_tokens + totalUsage.output_tokens;
688
+ if (totalAfterDelegation > this.options.maxTokenBudget) {
689
+ budgetExceeded = true;
690
+ finalOutput = turnText;
691
+ yield {
692
+ type: 'budget_exceeded',
693
+ data: new TokenBudgetExceededError(this.options.agentName ?? 'unknown', totalAfterDelegation, this.options.maxTokenBudget),
694
+ };
695
+ break;
696
+ }
697
+ }
698
+ // Loop back to Step 1 — send updated conversation to the LLM.
699
+ }
700
+ }
701
+ catch (err) {
702
+ const error = err instanceof Error ? err : new Error(String(err));
703
+ yield { type: 'error', data: error };
704
+ return;
705
+ }
706
+ // If the loop exited due to maxTurns, use whatever text was last emitted.
707
+ if (finalOutput === '' && conversationMessages.length > 0) {
708
+ const lastAssistant = [...conversationMessages]
709
+ .reverse()
710
+ .find(m => m.role === 'assistant');
711
+ if (lastAssistant !== undefined) {
712
+ finalOutput = extractText(lastAssistant.content);
713
+ }
714
+ }
715
+ const runResult = {
716
+ // Return only the messages added during this run (not the initial seed).
717
+ messages: newMessages,
718
+ output: finalOutput,
719
+ toolCalls: allToolCalls,
720
+ tokenUsage: totalUsage,
721
+ turns,
722
+ ...(loopDetected ? { loopDetected: true } : {}),
723
+ ...(budgetExceeded ? { budgetExceeded: true } : {}),
724
+ };
725
+ yield { type: 'done', data: runResult };
726
+ }
727
+ // -------------------------------------------------------------------------
728
+ // Private helpers
729
+ // -------------------------------------------------------------------------
730
+ /**
731
+ * Rule-based selective context compaction (no LLM calls).
732
+ *
733
+ * Compresses old turns while preserving the conversation skeleton:
734
+ * - tool_use blocks (decisions) are always kept
735
+ * - Long tool_result content is replaced with a compact marker
736
+ * - Long assistant text blocks are truncated with an excerpt
737
+ * - Error tool_results are never compressed
738
+ * - Recent turns (within `preserveRecentTurns`) are kept intact
739
+ */
740
+ compactMessages(messages, strategy) {
741
+ const estimated = estimateTokens(messages);
742
+ if (estimated <= strategy.maxTokens) {
743
+ return messages;
744
+ }
745
+ const preserveRecent = strategy.preserveRecentTurns ?? 4;
746
+ const minToolResultChars = strategy.minToolResultChars ?? 200;
747
+ const minTextBlockChars = strategy.minTextBlockChars ?? 2000;
748
+ const textBlockExcerptChars = strategy.textBlockExcerptChars ?? 200;
749
+ // Find the first user message — it is always preserved as-is.
750
+ const firstUserIndex = messages.findIndex(m => m.role === 'user');
751
+ if (firstUserIndex < 0 || firstUserIndex === messages.length - 1) {
752
+ return messages;
753
+ }
754
+ // Walk backward to find the boundary between old and recent turns.
755
+ // A "turn pair" is an assistant message followed by a user message.
756
+ let boundary = messages.length;
757
+ let pairsFound = 0;
758
+ for (let i = messages.length - 1; i > firstUserIndex && pairsFound < preserveRecent; i--) {
759
+ if (messages[i].role === 'user' && i > 0 && messages[i - 1].role === 'assistant') {
760
+ pairsFound++;
761
+ boundary = i - 1;
762
+ }
763
+ }
764
+ // If all turns fit within the recent window, nothing to compact.
765
+ if (boundary <= firstUserIndex + 1) {
766
+ return messages;
767
+ }
768
+ // Build a tool_use_id → tool name lookup from old assistant messages.
769
+ const toolNameMap = new Map();
770
+ for (let i = firstUserIndex + 1; i < boundary; i++) {
771
+ const msg = messages[i];
772
+ if (msg.role !== 'assistant')
773
+ continue;
774
+ for (const block of msg.content) {
775
+ if (block.type === 'tool_use') {
776
+ toolNameMap.set(block.id, block.name);
777
+ }
778
+ }
779
+ }
780
+ // Process old messages (between first user and boundary).
781
+ let anyChanged = false;
782
+ const result = [];
783
+ for (let i = 0; i < messages.length; i++) {
784
+ // First user message and recent messages: keep intact.
785
+ if (i <= firstUserIndex || i >= boundary) {
786
+ result.push(messages[i]);
787
+ continue;
788
+ }
789
+ const msg = messages[i];
790
+ let msgChanged = false;
791
+ const newContent = msg.content.map((block) => {
792
+ if (msg.role === 'assistant') {
793
+ // tool_use blocks: always preserve (decisions).
794
+ if (block.type === 'tool_use')
795
+ return block;
796
+ // Long text blocks: truncate with excerpt.
797
+ if (block.type === 'text' && block.text.length >= minTextBlockChars) {
798
+ msgChanged = true;
799
+ return {
800
+ type: 'text',
801
+ text: `${block.text.slice(0, textBlockExcerptChars)}... [truncated — ${block.text.length} chars total]`,
802
+ };
803
+ }
804
+ // Image blocks in old turns: replace with marker.
805
+ if (block.type === 'image') {
806
+ msgChanged = true;
807
+ return { type: 'text', text: '[Image compacted]' };
808
+ }
809
+ return block;
810
+ }
811
+ // User messages in old zone.
812
+ if (block.type === 'tool_result') {
813
+ // Error results: always preserve.
814
+ if (block.is_error)
815
+ return block;
816
+ // Already compressed by compressToolResults or a prior compact pass.
817
+ if (block.content.startsWith('[Tool output compressed') ||
818
+ block.content.startsWith('[Tool result:')) {
819
+ return block;
820
+ }
821
+ // Short results: preserve.
822
+ if (block.content.length < minToolResultChars)
823
+ return block;
824
+ const toolName = toolNameMap.get(block.tool_use_id) ?? 'unknown';
825
+ // Delegation results: preserve — parent agent may still reason over them.
826
+ if (toolName === 'delegate_to_agent')
827
+ return block;
828
+ // Compress.
829
+ msgChanged = true;
830
+ return {
831
+ type: 'tool_result',
832
+ tool_use_id: block.tool_use_id,
833
+ content: `[Tool result: ${toolName} — ${block.content.length} chars, compacted]`,
834
+ };
835
+ }
836
+ return block;
837
+ });
838
+ if (msgChanged) {
839
+ anyChanged = true;
840
+ result.push({ role: msg.role, content: newContent });
841
+ }
842
+ else {
843
+ result.push(msg);
844
+ }
845
+ }
846
+ return anyChanged ? result : messages;
847
+ }
848
+ /**
849
+ * Replace consumed tool results with compact markers.
850
+ *
851
+ * A tool_result is "consumed" when the assistant has produced a response
852
+ * after seeing it (i.e. there is an assistant message following the user
853
+ * message that contains the tool_result). The most recent user message
854
+ * with tool results is always kept intact — the LLM is about to see it.
855
+ *
856
+ * Error results and results shorter than `minChars` are never compressed.
857
+ */
858
+ compressConsumedToolResults(messages) {
859
+ const config = this.options.compressToolResults;
860
+ if (!config)
861
+ return messages;
862
+ const minChars = typeof config === 'object'
863
+ ? (config.minChars ?? DEFAULT_MIN_COMPRESS_CHARS)
864
+ : DEFAULT_MIN_COMPRESS_CHARS;
865
+ // Find the last user message that carries tool_result blocks.
866
+ let lastToolResultUserIdx = -1;
867
+ for (let i = messages.length - 1; i >= 0; i--) {
868
+ if (messages[i].role === 'user' &&
869
+ messages[i].content.some(b => b.type === 'tool_result')) {
870
+ lastToolResultUserIdx = i;
871
+ break;
872
+ }
873
+ }
874
+ // Nothing to compress if there's at most one tool-result user message.
875
+ if (lastToolResultUserIdx <= 0)
876
+ return messages;
877
+ // Build a tool_use_id → tool name map so we can exempt delegation results,
878
+ // whose full output the parent agent may need to re-read in later turns.
879
+ const toolNameMap = new Map();
880
+ for (const msg of messages) {
881
+ if (msg.role !== 'assistant')
882
+ continue;
883
+ for (const block of msg.content) {
884
+ if (block.type === 'tool_use')
885
+ toolNameMap.set(block.id, block.name);
886
+ }
887
+ }
888
+ let anyChanged = false;
889
+ const result = messages.map((msg, idx) => {
890
+ // Only compress user messages that appear before the last one.
891
+ if (msg.role !== 'user' || idx >= lastToolResultUserIdx)
892
+ return msg;
893
+ const hasToolResult = msg.content.some(b => b.type === 'tool_result');
894
+ if (!hasToolResult)
895
+ return msg;
896
+ let msgChanged = false;
897
+ const newContent = msg.content.map((block) => {
898
+ if (block.type !== 'tool_result')
899
+ return block;
900
+ // Never compress error results — they carry diagnostic value.
901
+ if (block.is_error)
902
+ return block;
903
+ // Never compress delegation results — the parent agent relies on the full sub-agent output.
904
+ if (toolNameMap.get(block.tool_use_id) === 'delegate_to_agent')
905
+ return block;
906
+ // Skip already-compressed results — avoid re-compression with wrong char count.
907
+ if (block.content.startsWith('[Tool output compressed'))
908
+ return block;
909
+ // Skip short results — the marker itself has overhead.
910
+ if (block.content.length < minChars)
911
+ return block;
912
+ msgChanged = true;
913
+ return {
914
+ type: 'tool_result',
915
+ tool_use_id: block.tool_use_id,
916
+ content: `[Tool output compressed — ${block.content.length} chars, already processed]`,
917
+ };
918
+ });
919
+ if (msgChanged) {
920
+ anyChanged = true;
921
+ return { role: msg.role, content: newContent };
922
+ }
923
+ return msg;
924
+ });
925
+ return anyChanged ? result : messages;
926
+ }
927
+ /**
928
+ * Build the {@link ToolUseContext} passed to every tool execution.
929
+ * Identifies this runner as the invoking agent.
930
+ */
931
+ buildToolContext(options = {}) {
932
+ return {
933
+ agent: {
934
+ name: this.options.agentName ?? 'runner',
935
+ role: this.options.agentRole ?? 'assistant',
936
+ model: this.options.model,
937
+ },
938
+ abortSignal: options.abortSignal ?? this.options.abortSignal,
939
+ ...(options.team !== undefined ? { team: options.team } : {}),
940
+ };
941
+ }
942
+ }
943
+ //# sourceMappingURL=runner.js.map