@f5-sales-demo/pi-agent-core 19.51.2
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/CHANGELOG.md +319 -0
- package/README.md +375 -0
- package/package.json +63 -0
- package/src/agent-loop.ts +693 -0
- package/src/agent.ts +1019 -0
- package/src/index.ts +10 -0
- package/src/proxy.ts +322 -0
- package/src/thinking.ts +19 -0
- package/src/types.ts +305 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent loop that works with AgentMessage throughout.
|
|
3
|
+
* Transforms to Message[] only at the LLM call boundary.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
type AssistantMessage,
|
|
7
|
+
type Context,
|
|
8
|
+
EventStream,
|
|
9
|
+
streamSimple,
|
|
10
|
+
type ToolResultMessage,
|
|
11
|
+
validateToolArguments,
|
|
12
|
+
} from "@f5xc-salesdemos/pi-ai";
|
|
13
|
+
import type {
|
|
14
|
+
AgentContext,
|
|
15
|
+
AgentEvent,
|
|
16
|
+
AgentLoopConfig,
|
|
17
|
+
AgentMessage,
|
|
18
|
+
AgentTool,
|
|
19
|
+
AgentToolResult,
|
|
20
|
+
StreamFn,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Start an agent loop with a new prompt message.
|
|
25
|
+
* The prompt is added to the context and events are emitted for it.
|
|
26
|
+
*/
|
|
27
|
+
export function agentLoop(
|
|
28
|
+
prompts: AgentMessage[],
|
|
29
|
+
context: AgentContext,
|
|
30
|
+
config: AgentLoopConfig,
|
|
31
|
+
signal?: AbortSignal,
|
|
32
|
+
streamFn?: StreamFn,
|
|
33
|
+
): EventStream<AgentEvent, AgentMessage[]> {
|
|
34
|
+
const stream = createAgentStream();
|
|
35
|
+
|
|
36
|
+
(async () => {
|
|
37
|
+
const newMessages: AgentMessage[] = [...prompts];
|
|
38
|
+
const currentContext: AgentContext = {
|
|
39
|
+
...context,
|
|
40
|
+
messages: [...context.messages, ...prompts],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
stream.push({ type: "agent_start" });
|
|
44
|
+
stream.push({ type: "turn_start" });
|
|
45
|
+
for (const prompt of prompts) {
|
|
46
|
+
stream.push({ type: "message_start", message: prompt });
|
|
47
|
+
stream.push({ type: "message_end", message: prompt });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
return stream;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Continue an agent loop from the current context without adding a new message.
|
|
58
|
+
* Used for retries - context already has user message or tool results.
|
|
59
|
+
*
|
|
60
|
+
* **Important:** The last message in context must convert to a `user` or `toolResult` message
|
|
61
|
+
* via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
|
|
62
|
+
* This cannot be validated here since `convertToLlm` is only called once per turn.
|
|
63
|
+
*/
|
|
64
|
+
export function agentLoopContinue(
|
|
65
|
+
context: AgentContext,
|
|
66
|
+
config: AgentLoopConfig,
|
|
67
|
+
signal?: AbortSignal,
|
|
68
|
+
streamFn?: StreamFn,
|
|
69
|
+
): EventStream<AgentEvent, AgentMessage[]> {
|
|
70
|
+
if (context.messages.length === 0) {
|
|
71
|
+
throw new Error("Cannot continue: no messages in context");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (context.messages[context.messages.length - 1].role === "assistant") {
|
|
75
|
+
throw new Error("Cannot continue from message role: assistant");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const stream = createAgentStream();
|
|
79
|
+
|
|
80
|
+
(async () => {
|
|
81
|
+
const newMessages: AgentMessage[] = [];
|
|
82
|
+
const currentContext: AgentContext = { ...context };
|
|
83
|
+
|
|
84
|
+
stream.push({ type: "agent_start" });
|
|
85
|
+
stream.push({ type: "turn_start" });
|
|
86
|
+
|
|
87
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
88
|
+
})();
|
|
89
|
+
|
|
90
|
+
return stream;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
|
|
94
|
+
return new EventStream<AgentEvent, AgentMessage[]>(
|
|
95
|
+
(event: AgentEvent) => event.type === "agent_end",
|
|
96
|
+
(event: AgentEvent) => (event.type === "agent_end" ? event.messages : []),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeMessagesForProvider(
|
|
101
|
+
messages: Context["messages"],
|
|
102
|
+
model: AgentLoopConfig["model"],
|
|
103
|
+
): Context["messages"] {
|
|
104
|
+
if (model.provider !== "cerebras") {
|
|
105
|
+
return messages;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let changed = false;
|
|
109
|
+
const normalized = messages.map(message => {
|
|
110
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
111
|
+
return message;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const filtered = message.content.filter(block => block.type !== "thinking");
|
|
115
|
+
if (filtered.length === message.content.length) {
|
|
116
|
+
return message;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
changed = true;
|
|
120
|
+
return { ...message, content: filtered };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return changed ? normalized : messages;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const INTENT_FIELD = "_i";
|
|
127
|
+
|
|
128
|
+
function injectIntentIntoSchema(schema: unknown): unknown {
|
|
129
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
|
|
130
|
+
const schemaRecord = schema as Record<string, unknown>;
|
|
131
|
+
const propertiesValue = schemaRecord.properties;
|
|
132
|
+
const properties =
|
|
133
|
+
propertiesValue && typeof propertiesValue === "object" && !Array.isArray(propertiesValue)
|
|
134
|
+
? (propertiesValue as Record<string, unknown>)
|
|
135
|
+
: {};
|
|
136
|
+
const requiredValue = schemaRecord.required;
|
|
137
|
+
const required = Array.isArray(requiredValue)
|
|
138
|
+
? requiredValue.filter((item): item is string => typeof item === "string")
|
|
139
|
+
: [];
|
|
140
|
+
if (INTENT_FIELD in properties) {
|
|
141
|
+
const { [INTENT_FIELD]: intentProp, ...rest } = properties;
|
|
142
|
+
const needsReorder = Object.keys(properties)[0] !== INTENT_FIELD;
|
|
143
|
+
const needsRequired = !required.includes(INTENT_FIELD);
|
|
144
|
+
if (!needsReorder && !needsRequired) return schema;
|
|
145
|
+
return {
|
|
146
|
+
...schemaRecord,
|
|
147
|
+
...(needsReorder ? { properties: { [INTENT_FIELD]: intentProp, ...rest } } : {}),
|
|
148
|
+
...(needsRequired ? { required: [...required, INTENT_FIELD] } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
...schemaRecord,
|
|
153
|
+
properties: {
|
|
154
|
+
[INTENT_FIELD]: {
|
|
155
|
+
type: "string",
|
|
156
|
+
},
|
|
157
|
+
...properties,
|
|
158
|
+
},
|
|
159
|
+
required: [...required, INTENT_FIELD],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function normalizeTools(tools: Context["tools"], injectIntent: boolean): Context["tools"] {
|
|
164
|
+
return tools?.map(tool => ({
|
|
165
|
+
...tool,
|
|
166
|
+
description: tool.description || "",
|
|
167
|
+
...(injectIntent && { parameters: injectIntentIntoSchema(tool.parameters) as typeof tool.parameters }),
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
|
|
172
|
+
const intent = args[INTENT_FIELD];
|
|
173
|
+
if (typeof intent !== "string") {
|
|
174
|
+
return { strippedArgs: args };
|
|
175
|
+
}
|
|
176
|
+
const { [INTENT_FIELD]: _ignored, ...strippedArgs } = args;
|
|
177
|
+
const trimmed = intent.trim();
|
|
178
|
+
return { intent: trimmed.length > 0 ? trimmed : undefined, strippedArgs };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Main loop logic shared by agentLoop and agentLoopContinue.
|
|
183
|
+
*/
|
|
184
|
+
async function runLoop(
|
|
185
|
+
currentContext: AgentContext,
|
|
186
|
+
newMessages: AgentMessage[],
|
|
187
|
+
config: AgentLoopConfig,
|
|
188
|
+
signal: AbortSignal | undefined,
|
|
189
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
190
|
+
streamFn?: StreamFn,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
let firstTurn = true;
|
|
193
|
+
// Check for steering messages at start (user may have typed while waiting)
|
|
194
|
+
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
|
|
195
|
+
|
|
196
|
+
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
197
|
+
while (true) {
|
|
198
|
+
let hasMoreToolCalls = true;
|
|
199
|
+
|
|
200
|
+
// Inner loop: process tool calls and steering messages
|
|
201
|
+
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
|
202
|
+
if (!firstTurn) {
|
|
203
|
+
stream.push({ type: "turn_start" });
|
|
204
|
+
} else {
|
|
205
|
+
firstTurn = false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Process pending messages (inject before next assistant response)
|
|
209
|
+
if (pendingMessages.length > 0) {
|
|
210
|
+
for (const message of pendingMessages) {
|
|
211
|
+
stream.push({ type: "message_start", message });
|
|
212
|
+
stream.push({ type: "message_end", message });
|
|
213
|
+
currentContext.messages.push(message);
|
|
214
|
+
newMessages.push(message);
|
|
215
|
+
}
|
|
216
|
+
pendingMessages = [];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Refresh prompt/tool context from live state before each model call
|
|
220
|
+
if (config.syncContextBeforeModelCall) {
|
|
221
|
+
await config.syncContextBeforeModelCall(currentContext);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Stream assistant response
|
|
225
|
+
const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn);
|
|
226
|
+
newMessages.push(message);
|
|
227
|
+
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
228
|
+
|
|
229
|
+
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
230
|
+
// Create placeholder tool results for any tool calls in the aborted message
|
|
231
|
+
// This maintains the tool_use/tool_result pairing that the API requires
|
|
232
|
+
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
233
|
+
const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
|
|
234
|
+
const toolResults: ToolResultMessage[] = [];
|
|
235
|
+
for (const toolCall of toolCalls) {
|
|
236
|
+
const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
|
|
237
|
+
currentContext.messages.push(result);
|
|
238
|
+
newMessages.push(result);
|
|
239
|
+
toolResults.push(result);
|
|
240
|
+
}
|
|
241
|
+
stream.push({ type: "turn_end", message, toolResults });
|
|
242
|
+
stream.push({ type: "agent_end", messages: newMessages });
|
|
243
|
+
stream.end(newMessages);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Check for tool calls
|
|
248
|
+
const toolCalls = message.content.filter(c => c.type === "toolCall");
|
|
249
|
+
hasMoreToolCalls = toolCalls.length > 0;
|
|
250
|
+
|
|
251
|
+
const toolResults: ToolResultMessage[] = [];
|
|
252
|
+
if (hasMoreToolCalls) {
|
|
253
|
+
const executionResult = await executeToolCalls(
|
|
254
|
+
currentContext.tools,
|
|
255
|
+
message,
|
|
256
|
+
signal,
|
|
257
|
+
stream,
|
|
258
|
+
config.getSteeringMessages,
|
|
259
|
+
config.interruptMode,
|
|
260
|
+
config.getToolContext,
|
|
261
|
+
config.transformToolCallArguments,
|
|
262
|
+
config.intentTracing,
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
toolResults.push(...executionResult.toolResults);
|
|
266
|
+
steeringMessagesFromExecution = executionResult.steeringMessages;
|
|
267
|
+
|
|
268
|
+
for (const result of toolResults) {
|
|
269
|
+
currentContext.messages.push(result);
|
|
270
|
+
newMessages.push(result);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
stream.push({ type: "turn_end", message, toolResults });
|
|
275
|
+
|
|
276
|
+
pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Agent would stop here. Check for follow-up messages.
|
|
280
|
+
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
|
|
281
|
+
if (followUpMessages.length > 0) {
|
|
282
|
+
// Set as pending so inner loop processes them
|
|
283
|
+
pendingMessages = followUpMessages;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// No more messages, exit
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
stream.push({ type: "agent_end", messages: newMessages });
|
|
292
|
+
stream.end(newMessages);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Stream an assistant response from the LLM.
|
|
297
|
+
* This is where AgentMessage[] gets transformed to Message[] for the LLM.
|
|
298
|
+
*/
|
|
299
|
+
async function streamAssistantResponse(
|
|
300
|
+
context: AgentContext,
|
|
301
|
+
config: AgentLoopConfig,
|
|
302
|
+
signal: AbortSignal | undefined,
|
|
303
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
304
|
+
streamFn?: StreamFn,
|
|
305
|
+
): Promise<AssistantMessage> {
|
|
306
|
+
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
|
|
307
|
+
let messages = context.messages;
|
|
308
|
+
if (config.transformContext) {
|
|
309
|
+
messages = await config.transformContext(messages, signal);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
|
|
313
|
+
const llmMessages = await config.convertToLlm(messages);
|
|
314
|
+
const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
|
|
315
|
+
|
|
316
|
+
// Build LLM context
|
|
317
|
+
const llmContext: Context = {
|
|
318
|
+
systemPrompt: context.systemPrompt,
|
|
319
|
+
messages: normalizedMessages,
|
|
320
|
+
tools: normalizeTools(context.tools, !!config.intentTracing),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const streamFunction = streamFn || streamSimple;
|
|
324
|
+
|
|
325
|
+
// Resolve API key (important for expiring tokens)
|
|
326
|
+
const resolvedApiKey =
|
|
327
|
+
(config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
|
|
328
|
+
|
|
329
|
+
const dynamicToolChoice = config.getToolChoice?.();
|
|
330
|
+
const response = await streamFunction(config.model, llmContext, {
|
|
331
|
+
...config,
|
|
332
|
+
apiKey: resolvedApiKey,
|
|
333
|
+
toolChoice: dynamicToolChoice ?? config.toolChoice,
|
|
334
|
+
signal,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
let partialMessage: AssistantMessage | null = null;
|
|
338
|
+
let addedPartial = false;
|
|
339
|
+
|
|
340
|
+
for await (const event of response) {
|
|
341
|
+
// Check for abort signal before processing each event
|
|
342
|
+
if (signal?.aborted) {
|
|
343
|
+
const errorMessage = "Request was aborted";
|
|
344
|
+
const abortedMessage: AssistantMessage = partialMessage
|
|
345
|
+
? { ...partialMessage, stopReason: "aborted", errorMessage }
|
|
346
|
+
: {
|
|
347
|
+
role: "assistant",
|
|
348
|
+
content: [],
|
|
349
|
+
api: config.model.api,
|
|
350
|
+
provider: config.model.provider,
|
|
351
|
+
model: config.model.id,
|
|
352
|
+
usage: {
|
|
353
|
+
input: 0,
|
|
354
|
+
output: 0,
|
|
355
|
+
cacheRead: 0,
|
|
356
|
+
cacheWrite: 0,
|
|
357
|
+
totalTokens: 0,
|
|
358
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
359
|
+
},
|
|
360
|
+
stopReason: "aborted",
|
|
361
|
+
errorMessage,
|
|
362
|
+
timestamp: Date.now(),
|
|
363
|
+
};
|
|
364
|
+
if (addedPartial) {
|
|
365
|
+
context.messages[context.messages.length - 1] = abortedMessage;
|
|
366
|
+
} else {
|
|
367
|
+
context.messages.push(abortedMessage);
|
|
368
|
+
stream.push({ type: "message_start", message: { ...abortedMessage } });
|
|
369
|
+
}
|
|
370
|
+
stream.push({ type: "message_end", message: abortedMessage });
|
|
371
|
+
return abortedMessage;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
switch (event.type) {
|
|
375
|
+
case "start":
|
|
376
|
+
partialMessage = event.partial;
|
|
377
|
+
context.messages.push(partialMessage);
|
|
378
|
+
addedPartial = true;
|
|
379
|
+
stream.push({ type: "message_start", message: { ...partialMessage } });
|
|
380
|
+
break;
|
|
381
|
+
|
|
382
|
+
case "text_start":
|
|
383
|
+
case "text_delta":
|
|
384
|
+
case "text_end":
|
|
385
|
+
case "thinking_start":
|
|
386
|
+
case "thinking_delta":
|
|
387
|
+
case "thinking_end":
|
|
388
|
+
case "toolcall_start":
|
|
389
|
+
case "toolcall_delta":
|
|
390
|
+
case "toolcall_end":
|
|
391
|
+
if (partialMessage) {
|
|
392
|
+
partialMessage = event.partial;
|
|
393
|
+
context.messages[context.messages.length - 1] = partialMessage;
|
|
394
|
+
config.onAssistantMessageEvent?.(partialMessage, event);
|
|
395
|
+
if (signal?.aborted) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
stream.push({
|
|
399
|
+
type: "message_update",
|
|
400
|
+
assistantMessageEvent: event,
|
|
401
|
+
message: { ...partialMessage },
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
|
|
406
|
+
case "done":
|
|
407
|
+
case "error": {
|
|
408
|
+
const finalMessage = await response.result();
|
|
409
|
+
if (addedPartial) {
|
|
410
|
+
context.messages[context.messages.length - 1] = finalMessage;
|
|
411
|
+
} else {
|
|
412
|
+
context.messages.push(finalMessage);
|
|
413
|
+
}
|
|
414
|
+
if (!addedPartial) {
|
|
415
|
+
stream.push({ type: "message_start", message: { ...finalMessage } });
|
|
416
|
+
}
|
|
417
|
+
stream.push({ type: "message_end", message: finalMessage });
|
|
418
|
+
return finalMessage;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return await response.result();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Execute tool calls from an assistant message.
|
|
428
|
+
*/
|
|
429
|
+
async function executeToolCalls(
|
|
430
|
+
tools: AgentTool<any>[] | undefined,
|
|
431
|
+
assistantMessage: AssistantMessage,
|
|
432
|
+
signal: AbortSignal | undefined,
|
|
433
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
434
|
+
getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
|
|
435
|
+
interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
|
|
436
|
+
getToolContext?: AgentLoopConfig["getToolContext"],
|
|
437
|
+
transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
|
|
438
|
+
intentTracing?: AgentLoopConfig["intentTracing"],
|
|
439
|
+
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
|
|
440
|
+
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
441
|
+
const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
|
|
442
|
+
const emittedToolResults: ToolResultMessage[] = [];
|
|
443
|
+
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
|
|
444
|
+
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
|
|
445
|
+
const shouldInterruptImmediately = interruptMode !== "wait";
|
|
446
|
+
const steeringAbortController = new AbortController();
|
|
447
|
+
const toolSignal = signal
|
|
448
|
+
? AbortSignal.any([signal, steeringAbortController.signal])
|
|
449
|
+
: steeringAbortController.signal;
|
|
450
|
+
const interruptState = { triggered: false };
|
|
451
|
+
let steeringMessages: AgentMessage[] | undefined;
|
|
452
|
+
let steeringCheck: Promise<void> | null = null;
|
|
453
|
+
|
|
454
|
+
const records = toolCalls.map(toolCall => ({
|
|
455
|
+
toolCall,
|
|
456
|
+
tool: tools?.find(t => t.name === toolCall.name),
|
|
457
|
+
args: toolCall.arguments as Record<string, unknown>,
|
|
458
|
+
started: false,
|
|
459
|
+
result: undefined as AgentToolResult<any> | undefined,
|
|
460
|
+
isError: false,
|
|
461
|
+
skipped: false,
|
|
462
|
+
toolResultMessage: undefined as ToolResultMessage | undefined,
|
|
463
|
+
resultEmitted: false,
|
|
464
|
+
}));
|
|
465
|
+
|
|
466
|
+
const checkSteering = async (): Promise<void> => {
|
|
467
|
+
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (steeringCheck) {
|
|
471
|
+
await steeringCheck;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
steeringCheck = (async () => {
|
|
475
|
+
const steering = await getSteeringMessages();
|
|
476
|
+
if (steering.length > 0) {
|
|
477
|
+
steeringMessages = steering;
|
|
478
|
+
interruptState.triggered = true;
|
|
479
|
+
steeringAbortController.abort();
|
|
480
|
+
}
|
|
481
|
+
})().finally(() => {
|
|
482
|
+
steeringCheck = null;
|
|
483
|
+
});
|
|
484
|
+
await steeringCheck;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
|
488
|
+
if (record.resultEmitted) return;
|
|
489
|
+
const { toolCall } = record;
|
|
490
|
+
if (!record.started) {
|
|
491
|
+
stream.push({
|
|
492
|
+
type: "tool_execution_start",
|
|
493
|
+
toolCallId: toolCall.id,
|
|
494
|
+
toolName: toolCall.name,
|
|
495
|
+
args: record.args,
|
|
496
|
+
intent: toolCall.intent,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
const isWarning = Boolean(result.isWarning);
|
|
500
|
+
stream.push({
|
|
501
|
+
type: "tool_execution_end",
|
|
502
|
+
toolCallId: toolCall.id,
|
|
503
|
+
toolName: toolCall.name,
|
|
504
|
+
result,
|
|
505
|
+
isError,
|
|
506
|
+
isWarning,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const toolResultMessage: ToolResultMessage = {
|
|
510
|
+
role: "toolResult",
|
|
511
|
+
toolCallId: toolCall.id,
|
|
512
|
+
toolName: toolCall.name,
|
|
513
|
+
content: result.content,
|
|
514
|
+
details: result.details,
|
|
515
|
+
isError,
|
|
516
|
+
isWarning,
|
|
517
|
+
timestamp: Date.now(),
|
|
518
|
+
};
|
|
519
|
+
record.result = result;
|
|
520
|
+
record.isError = isError;
|
|
521
|
+
record.toolResultMessage = toolResultMessage;
|
|
522
|
+
record.resultEmitted = true;
|
|
523
|
+
emittedToolResults.push(toolResultMessage);
|
|
524
|
+
|
|
525
|
+
stream.push({ type: "message_start", message: toolResultMessage });
|
|
526
|
+
stream.push({ type: "message_end", message: toolResultMessage });
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
|
|
530
|
+
if (interruptState.triggered) {
|
|
531
|
+
record.skipped = true;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const { toolCall, tool } = record;
|
|
536
|
+
let argsForExecution = toolCall.arguments as Record<string, unknown>;
|
|
537
|
+
if (intentTracing) {
|
|
538
|
+
const { intent, strippedArgs } = extractIntent(toolCall.arguments);
|
|
539
|
+
argsForExecution = strippedArgs;
|
|
540
|
+
if (intent) {
|
|
541
|
+
toolCall.intent = intent;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
record.args = argsForExecution;
|
|
545
|
+
record.started = true;
|
|
546
|
+
stream.push({
|
|
547
|
+
type: "tool_execution_start",
|
|
548
|
+
toolCallId: toolCall.id,
|
|
549
|
+
toolName: toolCall.name,
|
|
550
|
+
args: argsForExecution,
|
|
551
|
+
intent: toolCall.intent,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
let result: AgentToolResult<any>;
|
|
555
|
+
let isError = false;
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
|
|
559
|
+
|
|
560
|
+
let effectiveArgs: Record<string, unknown>;
|
|
561
|
+
try {
|
|
562
|
+
effectiveArgs = validateToolArguments(tool, { ...toolCall, arguments: argsForExecution });
|
|
563
|
+
} catch (validationError) {
|
|
564
|
+
if (tool.lenientArgValidation) {
|
|
565
|
+
effectiveArgs = argsForExecution;
|
|
566
|
+
} else {
|
|
567
|
+
throw validationError;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const toolContext = getToolContext
|
|
571
|
+
? getToolContext({
|
|
572
|
+
batchId,
|
|
573
|
+
index,
|
|
574
|
+
total: toolCalls.length,
|
|
575
|
+
toolCalls: toolCallInfos,
|
|
576
|
+
})
|
|
577
|
+
: undefined;
|
|
578
|
+
result = await tool.execute(
|
|
579
|
+
toolCall.id,
|
|
580
|
+
transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
|
|
581
|
+
tool.nonAbortable ? undefined : toolSignal,
|
|
582
|
+
partialResult => {
|
|
583
|
+
stream.push({
|
|
584
|
+
type: "tool_execution_update",
|
|
585
|
+
toolCallId: toolCall.id,
|
|
586
|
+
toolName: toolCall.name,
|
|
587
|
+
args: argsForExecution,
|
|
588
|
+
partialResult,
|
|
589
|
+
});
|
|
590
|
+
},
|
|
591
|
+
toolContext,
|
|
592
|
+
);
|
|
593
|
+
} catch (e) {
|
|
594
|
+
result = {
|
|
595
|
+
content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
|
|
596
|
+
details: {},
|
|
597
|
+
};
|
|
598
|
+
isError = true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (interruptState.triggered) {
|
|
602
|
+
record.skipped = true;
|
|
603
|
+
emitToolResult(record, createSkippedToolResult(), true);
|
|
604
|
+
} else {
|
|
605
|
+
emitToolResult(record, result, isError);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
await checkSteering();
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
let lastExclusive: Promise<void> = Promise.resolve();
|
|
612
|
+
let sharedTasks: Promise<void>[] = [];
|
|
613
|
+
const tasks: Promise<void>[] = [];
|
|
614
|
+
|
|
615
|
+
for (let index = 0; index < records.length; index++) {
|
|
616
|
+
const record = records[index];
|
|
617
|
+
const concurrency = record.tool?.concurrency ?? "shared";
|
|
618
|
+
const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
|
|
619
|
+
const task = start.then(() => runTool(record, index));
|
|
620
|
+
tasks.push(task);
|
|
621
|
+
if (concurrency === "exclusive") {
|
|
622
|
+
lastExclusive = task;
|
|
623
|
+
sharedTasks = [];
|
|
624
|
+
} else {
|
|
625
|
+
sharedTasks.push(task);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
await Promise.allSettled(tasks);
|
|
630
|
+
|
|
631
|
+
for (const record of records) {
|
|
632
|
+
if (!record.toolResultMessage) {
|
|
633
|
+
record.skipped = true;
|
|
634
|
+
emitToolResult(record, createSkippedToolResult(), true);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return { toolResults: emittedToolResults, steeringMessages };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Create a tool result for a tool call that was aborted or errored before execution.
|
|
643
|
+
* Maintains the tool_use/tool_result pairing required by the API.
|
|
644
|
+
*/
|
|
645
|
+
function createAbortedToolResult(
|
|
646
|
+
toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
|
|
647
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
648
|
+
reason: "aborted" | "error",
|
|
649
|
+
errorMessage?: string,
|
|
650
|
+
): ToolResultMessage {
|
|
651
|
+
const message = reason === "aborted" ? "Tool execution was aborted" : "Tool execution failed due to an error";
|
|
652
|
+
const result: AgentToolResult<any> = {
|
|
653
|
+
content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
|
|
654
|
+
details: {},
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
stream.push({
|
|
658
|
+
type: "tool_execution_start",
|
|
659
|
+
toolCallId: toolCall.id,
|
|
660
|
+
toolName: toolCall.name,
|
|
661
|
+
args: toolCall.arguments,
|
|
662
|
+
intent: toolCall.intent,
|
|
663
|
+
});
|
|
664
|
+
stream.push({
|
|
665
|
+
type: "tool_execution_end",
|
|
666
|
+
toolCallId: toolCall.id,
|
|
667
|
+
toolName: toolCall.name,
|
|
668
|
+
result,
|
|
669
|
+
isError: true,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const toolResultMessage: ToolResultMessage = {
|
|
673
|
+
role: "toolResult",
|
|
674
|
+
toolCallId: toolCall.id,
|
|
675
|
+
toolName: toolCall.name,
|
|
676
|
+
content: result.content,
|
|
677
|
+
details: {},
|
|
678
|
+
isError: true,
|
|
679
|
+
timestamp: Date.now(),
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
stream.push({ type: "message_start", message: toolResultMessage });
|
|
683
|
+
stream.push({ type: "message_end", message: toolResultMessage });
|
|
684
|
+
|
|
685
|
+
return toolResultMessage;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function createSkippedToolResult(): AgentToolResult<any> {
|
|
689
|
+
return {
|
|
690
|
+
content: [{ type: "text", text: "Skipped due to queued user message." }],
|
|
691
|
+
details: {},
|
|
692
|
+
};
|
|
693
|
+
}
|