@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.
- package/LICENSE +21 -0
- package/README.md +373 -0
- package/dist/agent/agent.d.ts +153 -0
- package/dist/agent/agent.d.ts.map +1 -0
- package/dist/agent/agent.js +559 -0
- package/dist/agent/agent.js.map +1 -0
- package/dist/agent/loop-detector.d.ts +39 -0
- package/dist/agent/loop-detector.d.ts.map +1 -0
- package/dist/agent/loop-detector.js +122 -0
- package/dist/agent/loop-detector.js.map +1 -0
- package/dist/agent/pool.d.ts +158 -0
- package/dist/agent/pool.d.ts.map +1 -0
- package/dist/agent/pool.js +320 -0
- package/dist/agent/pool.js.map +1 -0
- package/dist/agent/runner.d.ts +242 -0
- package/dist/agent/runner.d.ts.map +1 -0
- package/dist/agent/runner.js +943 -0
- package/dist/agent/runner.js.map +1 -0
- package/dist/agent/structured-output.d.ts +33 -0
- package/dist/agent/structured-output.d.ts.map +1 -0
- package/dist/agent/structured-output.js +116 -0
- package/dist/agent/structured-output.js.map +1 -0
- package/dist/cli/oma.d.ts +30 -0
- package/dist/cli/oma.d.ts.map +1 -0
- package/dist/cli/oma.js +433 -0
- package/dist/cli/oma.js.map +1 -0
- package/dist/dashboard/layout-tasks.d.ts +23 -0
- package/dist/dashboard/layout-tasks.d.ts.map +1 -0
- package/dist/dashboard/layout-tasks.js +79 -0
- package/dist/dashboard/layout-tasks.js.map +1 -0
- package/dist/dashboard/render-team-run-dashboard.d.ts +11 -0
- package/dist/dashboard/render-team-run-dashboard.d.ts.map +1 -0
- package/dist/dashboard/render-team-run-dashboard.js +456 -0
- package/dist/dashboard/render-team-run-dashboard.js.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +20 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +92 -0
- package/dist/index.js.map +1 -0
- package/dist/llm/adapter.d.ts +54 -0
- package/dist/llm/adapter.d.ts.map +1 -0
- package/dist/llm/adapter.js +101 -0
- package/dist/llm/adapter.js.map +1 -0
- package/dist/llm/anthropic.d.ts +57 -0
- package/dist/llm/anthropic.d.ts.map +1 -0
- package/dist/llm/anthropic.js +432 -0
- package/dist/llm/anthropic.js.map +1 -0
- package/dist/llm/azure-openai.d.ts +74 -0
- package/dist/llm/azure-openai.d.ts.map +1 -0
- package/dist/llm/azure-openai.js +267 -0
- package/dist/llm/azure-openai.js.map +1 -0
- package/dist/llm/bedrock.d.ts +41 -0
- package/dist/llm/bedrock.d.ts.map +1 -0
- package/dist/llm/bedrock.js +345 -0
- package/dist/llm/bedrock.js.map +1 -0
- package/dist/llm/copilot.d.ts +92 -0
- package/dist/llm/copilot.d.ts.map +1 -0
- package/dist/llm/copilot.js +433 -0
- package/dist/llm/copilot.js.map +1 -0
- package/dist/llm/deepseek.d.ts +21 -0
- package/dist/llm/deepseek.d.ts.map +1 -0
- package/dist/llm/deepseek.js +24 -0
- package/dist/llm/deepseek.js.map +1 -0
- package/dist/llm/gemini.d.ts +65 -0
- package/dist/llm/gemini.d.ts.map +1 -0
- package/dist/llm/gemini.js +427 -0
- package/dist/llm/gemini.js.map +1 -0
- package/dist/llm/grok.d.ts +21 -0
- package/dist/llm/grok.d.ts.map +1 -0
- package/dist/llm/grok.js +24 -0
- package/dist/llm/grok.js.map +1 -0
- package/dist/llm/minimax.d.ts +21 -0
- package/dist/llm/minimax.d.ts.map +1 -0
- package/dist/llm/minimax.js +24 -0
- package/dist/llm/minimax.js.map +1 -0
- package/dist/llm/openai-common.d.ts +65 -0
- package/dist/llm/openai-common.d.ts.map +1 -0
- package/dist/llm/openai-common.js +286 -0
- package/dist/llm/openai-common.js.map +1 -0
- package/dist/llm/openai.d.ts +63 -0
- package/dist/llm/openai.d.ts.map +1 -0
- package/dist/llm/openai.js +256 -0
- package/dist/llm/openai.js.map +1 -0
- package/dist/llm/qiniu.d.ts +21 -0
- package/dist/llm/qiniu.d.ts.map +1 -0
- package/dist/llm/qiniu.js +24 -0
- package/dist/llm/qiniu.js.map +1 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +2 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory/shared.d.ts +162 -0
- package/dist/memory/shared.d.ts.map +1 -0
- package/dist/memory/shared.js +294 -0
- package/dist/memory/shared.js.map +1 -0
- package/dist/memory/store.d.ts +72 -0
- package/dist/memory/store.d.ts.map +1 -0
- package/dist/memory/store.js +121 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/orchestrator/orchestrator.d.ts +245 -0
- package/dist/orchestrator/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator/orchestrator.js +1400 -0
- package/dist/orchestrator/orchestrator.js.map +1 -0
- package/dist/orchestrator/scheduler.d.ts +112 -0
- package/dist/orchestrator/scheduler.d.ts.map +1 -0
- package/dist/orchestrator/scheduler.js +256 -0
- package/dist/orchestrator/scheduler.js.map +1 -0
- package/dist/task/queue.d.ts +191 -0
- package/dist/task/queue.d.ts.map +1 -0
- package/dist/task/queue.js +408 -0
- package/dist/task/queue.js.map +1 -0
- package/dist/task/task.d.ts +90 -0
- package/dist/task/task.d.ts.map +1 -0
- package/dist/task/task.js +206 -0
- package/dist/task/task.js.map +1 -0
- package/dist/team/messaging.d.ts +106 -0
- package/dist/team/messaging.d.ts.map +1 -0
- package/dist/team/messaging.js +183 -0
- package/dist/team/messaging.js.map +1 -0
- package/dist/team/team.d.ts +141 -0
- package/dist/team/team.d.ts.map +1 -0
- package/dist/team/team.js +293 -0
- package/dist/team/team.js.map +1 -0
- package/dist/tool/built-in/bash.d.ts +12 -0
- package/dist/tool/built-in/bash.d.ts.map +1 -0
- package/dist/tool/built-in/bash.js +133 -0
- package/dist/tool/built-in/bash.js.map +1 -0
- package/dist/tool/built-in/delegate.d.ts +29 -0
- package/dist/tool/built-in/delegate.d.ts.map +1 -0
- package/dist/tool/built-in/delegate.js +92 -0
- package/dist/tool/built-in/delegate.js.map +1 -0
- package/dist/tool/built-in/file-edit.d.ts +14 -0
- package/dist/tool/built-in/file-edit.d.ts.map +1 -0
- package/dist/tool/built-in/file-edit.js +130 -0
- package/dist/tool/built-in/file-edit.js.map +1 -0
- package/dist/tool/built-in/file-read.d.ts +12 -0
- package/dist/tool/built-in/file-read.d.ts.map +1 -0
- package/dist/tool/built-in/file-read.js +82 -0
- package/dist/tool/built-in/file-read.js.map +1 -0
- package/dist/tool/built-in/file-write.d.ts +11 -0
- package/dist/tool/built-in/file-write.d.ts.map +1 -0
- package/dist/tool/built-in/file-write.js +70 -0
- package/dist/tool/built-in/file-write.js.map +1 -0
- package/dist/tool/built-in/fs-walk.d.ts +23 -0
- package/dist/tool/built-in/fs-walk.d.ts.map +1 -0
- package/dist/tool/built-in/fs-walk.js +78 -0
- package/dist/tool/built-in/fs-walk.js.map +1 -0
- package/dist/tool/built-in/glob.d.ts +12 -0
- package/dist/tool/built-in/glob.d.ts.map +1 -0
- package/dist/tool/built-in/glob.js +82 -0
- package/dist/tool/built-in/glob.js.map +1 -0
- package/dist/tool/built-in/grep.d.ts +15 -0
- package/dist/tool/built-in/grep.d.ts.map +1 -0
- package/dist/tool/built-in/grep.js +218 -0
- package/dist/tool/built-in/grep.js.map +1 -0
- package/dist/tool/built-in/index.d.ts +48 -0
- package/dist/tool/built-in/index.d.ts.map +1 -0
- package/dist/tool/built-in/index.js +56 -0
- package/dist/tool/built-in/index.js.map +1 -0
- package/dist/tool/executor.d.ts +100 -0
- package/dist/tool/executor.d.ts.map +1 -0
- package/dist/tool/executor.js +184 -0
- package/dist/tool/executor.js.map +1 -0
- package/dist/tool/framework.d.ts +167 -0
- package/dist/tool/framework.d.ts.map +1 -0
- package/dist/tool/framework.js +402 -0
- package/dist/tool/framework.js.map +1 -0
- package/dist/tool/mcp.d.ts +31 -0
- package/dist/tool/mcp.d.ts.map +1 -0
- package/dist/tool/mcp.js +175 -0
- package/dist/tool/mcp.js.map +1 -0
- package/dist/tool/text-tool-extractor.d.ts +32 -0
- package/dist/tool/text-tool-extractor.d.ts.map +1 -0
- package/dist/tool/text-tool-extractor.js +195 -0
- package/dist/tool/text-tool-extractor.js.map +1 -0
- package/dist/types.d.ts +916 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/keywords.d.ts +18 -0
- package/dist/utils/keywords.d.ts.map +1 -0
- package/dist/utils/keywords.js +32 -0
- package/dist/utils/keywords.js.map +1 -0
- package/dist/utils/semaphore.d.ts +49 -0
- package/dist/utils/semaphore.d.ts.map +1 -0
- package/dist/utils/semaphore.js +89 -0
- package/dist/utils/semaphore.js.map +1 -0
- package/dist/utils/tokens.d.ts +7 -0
- package/dist/utils/tokens.d.ts.map +1 -0
- package/dist/utils/tokens.js +30 -0
- package/dist/utils/tokens.js.map +1 -0
- package/dist/utils/trace.d.ts +12 -0
- package/dist/utils/trace.d.ts.map +1 -0
- package/dist/utils/trace.js +30 -0
- package/dist/utils/trace.js.map +1 -0
- package/docs/DECISIONS.md +49 -0
- package/docs/cli.md +265 -0
- package/docs/context-management.md +24 -0
- package/docs/featured-partner.md +28 -0
- package/docs/observability.md +56 -0
- package/docs/providers.md +78 -0
- package/docs/shared-memory.md +27 -0
- package/docs/tool-configuration.md +152 -0
- 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
|