@f5-sales-demo/pi-agent-core 22.4.11 → 22.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/agent-loop.ts +80 -5
- package/src/agent.ts +17 -0
- package/src/live-steering.ts +89 -0
- package/src/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-agent-core",
|
|
4
|
-
"version": "22.
|
|
4
|
+
"version": "22.5.1",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@f5-sales-demo/pi-ai": "22.
|
|
39
|
-
"@f5-sales-demo/pi-utils": "22.
|
|
38
|
+
"@f5-sales-demo/pi-ai": "22.5.1",
|
|
39
|
+
"@f5-sales-demo/pi-utils": "22.5.1"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@sinclair/typebox": "0.34.52",
|
package/src/agent-loop.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
validateToolArguments,
|
|
12
12
|
} from "@f5-sales-demo/pi-ai";
|
|
13
13
|
import { logger } from "@f5-sales-demo/pi-utils";
|
|
14
|
+
import { LiveSteeringChannel } from "./live-steering";
|
|
14
15
|
import emptyToolSuccess from "./prompts/empty-tool-success.md" with { type: "text" };
|
|
15
16
|
import { AgentToolError } from "./tool-error";
|
|
16
17
|
import type {
|
|
@@ -312,6 +313,23 @@ async function runLoop(
|
|
|
312
313
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
313
314
|
streamFn?: StreamFn,
|
|
314
315
|
): Promise<void> {
|
|
316
|
+
const completedTools = new Map<string, ToolResultMessage>();
|
|
317
|
+
const toolKey = (call: Extract<AssistantMessage["content"][number], { type: "toolCall" }>) =>
|
|
318
|
+
JSON.stringify([config.model.provider, config.model.api, config.model.id, call.id, call.name, call.arguments]);
|
|
319
|
+
const priorCalls = new Map<string, string>();
|
|
320
|
+
for (const message of currentContext.messages) {
|
|
321
|
+
if (
|
|
322
|
+
message.role === "assistant" &&
|
|
323
|
+
message.provider === config.model.provider &&
|
|
324
|
+
message.api === config.model.api &&
|
|
325
|
+
message.model === config.model.id
|
|
326
|
+
)
|
|
327
|
+
for (const call of message.content) if (call.type === "toolCall") priorCalls.set(call.id, toolKey(call));
|
|
328
|
+
if (message.role === "toolResult" && !message.isError) {
|
|
329
|
+
const key = priorCalls.get(message.toolCallId);
|
|
330
|
+
if (key) completedTools.set(key, message);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
315
333
|
let firstTurn = true;
|
|
316
334
|
// Check for steering messages at start (user may have typed while waiting)
|
|
317
335
|
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
|
|
@@ -344,9 +362,45 @@ async function runLoop(
|
|
|
344
362
|
await logger.ttftAttr("ttft.sync-context", () => config.syncContextBeforeModelCall!(currentContext));
|
|
345
363
|
}
|
|
346
364
|
|
|
347
|
-
//
|
|
348
|
-
const
|
|
365
|
+
// Offer opt-in, plain user steering to the active provider without consuming other message kinds.
|
|
366
|
+
const live =
|
|
367
|
+
(config.model.compat as import("@f5-sales-demo/pi-ai").OpenAIResponsesCompat | undefined)
|
|
368
|
+
?.supportsWebSocketSteering &&
|
|
369
|
+
config.waitForSteeringMessages &&
|
|
370
|
+
config.getSteeringMessages &&
|
|
371
|
+
!exactToolName(config.getToolChoice?.() ?? config.toolChoice)
|
|
372
|
+
? new LiveSteeringChannel({
|
|
373
|
+
wait: config.waitForSteeringMessages,
|
|
374
|
+
take: () => config.getSteeringMessages!(),
|
|
375
|
+
toProvider: async messages => {
|
|
376
|
+
const converted = await config.convertToLlm(messages);
|
|
377
|
+
if (converted.some(message => message.role !== "user")) return undefined;
|
|
378
|
+
return converted as import("@f5-sales-demo/pi-ai").UserMessage[];
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
: undefined;
|
|
382
|
+
const message = await streamAssistantResponse(
|
|
383
|
+
currentContext,
|
|
384
|
+
newMessages,
|
|
385
|
+
live ? { ...config, liveSteering: live } : config,
|
|
386
|
+
signal,
|
|
387
|
+
stream,
|
|
388
|
+
streamFn,
|
|
389
|
+
);
|
|
349
390
|
newMessages.push(message);
|
|
391
|
+
if (live) {
|
|
392
|
+
if (message.stopReason === "error" || message.stopReason === "aborted")
|
|
393
|
+
config.restoreSteeringMessages?.([...live.accepted, ...live.deferred]);
|
|
394
|
+
else {
|
|
395
|
+
for (const accepted of live.accepted) {
|
|
396
|
+
currentContext.messages.push(accepted);
|
|
397
|
+
newMessages.push(accepted);
|
|
398
|
+
stream.push({ type: "message_start", message: accepted });
|
|
399
|
+
stream.push({ type: "message_end", message: accepted });
|
|
400
|
+
}
|
|
401
|
+
config.restoreSteeringMessages?.(live.deferred);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
350
404
|
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
351
405
|
|
|
352
406
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
@@ -369,10 +423,10 @@ async function runLoop(
|
|
|
369
423
|
|
|
370
424
|
// Check for tool calls
|
|
371
425
|
const toolCalls = message.content.filter(c => c.type === "toolCall");
|
|
372
|
-
hasMoreToolCalls = toolCalls.length > 0;
|
|
426
|
+
hasMoreToolCalls = toolCalls.length > 0 || Boolean(live?.accepted.length);
|
|
373
427
|
|
|
374
428
|
const toolResults: ToolResultMessage[] = [];
|
|
375
|
-
if (
|
|
429
|
+
if (toolCalls.length > 0) {
|
|
376
430
|
const executionResult = await executeToolCalls(
|
|
377
431
|
currentContext.tools,
|
|
378
432
|
message,
|
|
@@ -383,12 +437,15 @@ async function runLoop(
|
|
|
383
437
|
config.getToolContext,
|
|
384
438
|
config.transformToolCallArguments,
|
|
385
439
|
config.intentTracing,
|
|
440
|
+
completedTools,
|
|
441
|
+
toolKey,
|
|
386
442
|
);
|
|
387
443
|
|
|
388
444
|
toolResults.push(...executionResult.toolResults);
|
|
389
445
|
steeringMessagesFromExecution = executionResult.steeringMessages;
|
|
390
446
|
|
|
391
447
|
for (const result of toolResults) {
|
|
448
|
+
if (currentContext.messages.includes(result)) continue;
|
|
392
449
|
currentContext.messages.push(result);
|
|
393
450
|
newMessages.push(result);
|
|
394
451
|
}
|
|
@@ -629,9 +686,17 @@ async function executeToolCalls(
|
|
|
629
686
|
getToolContext?: AgentLoopConfig["getToolContext"],
|
|
630
687
|
transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
|
|
631
688
|
intentTracing?: AgentLoopConfig["intentTracing"],
|
|
689
|
+
completedTools = new Map<string, ToolResultMessage>(),
|
|
690
|
+
toolKey: (call: Extract<AssistantMessage["content"][number], { type: "toolCall" }>) => string = call => call.id,
|
|
632
691
|
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
|
|
633
692
|
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
|
|
634
|
-
const toolCalls =
|
|
693
|
+
const toolCalls = [
|
|
694
|
+
...new Map(
|
|
695
|
+
assistantMessage.content
|
|
696
|
+
.filter((c): c is ToolCallContent => c.type === "toolCall")
|
|
697
|
+
.map(call => [toolKey(call), call]),
|
|
698
|
+
).values(),
|
|
699
|
+
];
|
|
635
700
|
const emittedToolResults: ToolResultMessage[] = [];
|
|
636
701
|
const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
|
|
637
702
|
const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
|
|
@@ -703,6 +768,8 @@ async function executeToolCalls(
|
|
|
703
768
|
});
|
|
704
769
|
|
|
705
770
|
const toolResultMessage: ToolResultMessage = {
|
|
771
|
+
...(toolCall.customInputProperty ? { customTool: true } : {}),
|
|
772
|
+
...(toolCall.toolSearch ? { toolSearch: true, tools: normalizedResult.tools ?? [] } : {}),
|
|
706
773
|
role: "toolResult",
|
|
707
774
|
toolCallId: toolCall.id,
|
|
708
775
|
toolName: toolCall.name,
|
|
@@ -716,6 +783,7 @@ async function executeToolCalls(
|
|
|
716
783
|
record.isError = isError;
|
|
717
784
|
record.toolResultMessage = toolResultMessage;
|
|
718
785
|
record.resultEmitted = true;
|
|
786
|
+
if (!isError) completedTools.set(toolKey(toolCall), toolResultMessage);
|
|
719
787
|
emittedToolResults.push(toolResultMessage);
|
|
720
788
|
|
|
721
789
|
stream.push({ type: "message_start", message: toolResultMessage });
|
|
@@ -723,6 +791,13 @@ async function executeToolCalls(
|
|
|
723
791
|
};
|
|
724
792
|
|
|
725
793
|
const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
|
|
794
|
+
const completed = completedTools.get(toolKey(record.toolCall));
|
|
795
|
+
if (completed) {
|
|
796
|
+
record.toolResultMessage = completed;
|
|
797
|
+
record.resultEmitted = true;
|
|
798
|
+
emittedToolResults.push(completed);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
726
801
|
if (interruptState.triggered) {
|
|
727
802
|
record.skipped = true;
|
|
728
803
|
return;
|
package/src/agent.ts
CHANGED
|
@@ -260,6 +260,7 @@ export class Agent {
|
|
|
260
260
|
#contextMessages?: AgentLoopConfig["getContextMessages"];
|
|
261
261
|
#transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
|
|
262
262
|
#steeringQueue: AgentMessage[] = [];
|
|
263
|
+
#steeringWaiters = new Set<() => void>();
|
|
263
264
|
#followUpQueue: AgentMessage[] = [];
|
|
264
265
|
#steeringMode: "all" | "one-at-a-time";
|
|
265
266
|
#followUpMode: "all" | "one-at-a-time";
|
|
@@ -565,6 +566,7 @@ export class Agent {
|
|
|
565
566
|
*/
|
|
566
567
|
steer(m: AgentMessage) {
|
|
567
568
|
this.#steeringQueue.push(m);
|
|
569
|
+
for (const wake of this.#steeringWaiters) wake();
|
|
568
570
|
}
|
|
569
571
|
|
|
570
572
|
/**
|
|
@@ -839,6 +841,21 @@ export class Agent {
|
|
|
839
841
|
intentTracing: this.#intentTracing,
|
|
840
842
|
onAssistantMessageEvent: this.#onAssistantMessageEvent,
|
|
841
843
|
getToolChoice,
|
|
844
|
+
waitForSteeringMessages: signal => {
|
|
845
|
+
if (this.#steeringQueue.length || signal.aborted) return Promise.resolve();
|
|
846
|
+
return new Promise<void>(resolve => {
|
|
847
|
+
const wake = () => {
|
|
848
|
+
this.#steeringWaiters.delete(wake);
|
|
849
|
+
signal.removeEventListener("abort", wake);
|
|
850
|
+
resolve();
|
|
851
|
+
};
|
|
852
|
+
this.#steeringWaiters.add(wake);
|
|
853
|
+
signal.addEventListener("abort", wake, { once: true });
|
|
854
|
+
});
|
|
855
|
+
},
|
|
856
|
+
restoreSteeringMessages: messages => {
|
|
857
|
+
this.#steeringQueue.unshift(...messages);
|
|
858
|
+
},
|
|
842
859
|
getSteeringMessages: async () => {
|
|
843
860
|
if (skipInitialSteeringPoll) {
|
|
844
861
|
skipInitialSteeringPoll = false;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent side of provider live steering ({@link LiveSteering}).
|
|
3
|
+
*
|
|
4
|
+
* A provider that can put user input into the response it is streaming (OpenAI
|
|
5
|
+
* Responses `response.steer`) pulls queued steering through a
|
|
6
|
+
* {@link LiveSteeringChannel}. The loop records what the provider accepted right
|
|
7
|
+
* after that response, so the transcript matches what the model saw; anything
|
|
8
|
+
* it declined is injected at the next boundary like ordinary steering.
|
|
9
|
+
*/
|
|
10
|
+
import type { LiveSteerClaim, LiveSteering, UserMessage } from "@f5-sales-demo/pi-ai";
|
|
11
|
+
import { logger } from "@f5-sales-demo/pi-utils";
|
|
12
|
+
import type { AgentMessage } from "./types";
|
|
13
|
+
|
|
14
|
+
/** Steering-queue access for one provider call, supplied by the agent loop. */
|
|
15
|
+
export interface LiveSteeringQueue {
|
|
16
|
+
/** Resolves once steering is queued or `signal` aborts; never consumes. */
|
|
17
|
+
wait(signal: AbortSignal): Promise<void>;
|
|
18
|
+
/** Dequeues the next steering batch. */
|
|
19
|
+
take(signal: AbortSignal): Promise<AgentMessage[]>;
|
|
20
|
+
/**
|
|
21
|
+
* Provider view of `messages` appended to the in-flight call's context, or
|
|
22
|
+
* `undefined` when that view is not purely user messages.
|
|
23
|
+
*/
|
|
24
|
+
toProvider(messages: AgentMessage[], signal: AbortSignal): Promise<UserMessage[] | undefined>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One provider call's {@link LiveSteering} source. */
|
|
28
|
+
export class LiveSteeringChannel implements LiveSteering {
|
|
29
|
+
/** Steering the provider delivered into the in-flight response, in queue order. */
|
|
30
|
+
readonly accepted: AgentMessage[] = [];
|
|
31
|
+
/** Steering taken from the queue but not delivered; always queued after {@link accepted}. */
|
|
32
|
+
readonly deferred: AgentMessage[] = [];
|
|
33
|
+
readonly #queue: LiveSteeringQueue;
|
|
34
|
+
|
|
35
|
+
constructor(queue: LiveSteeringQueue) {
|
|
36
|
+
this.#queue = queue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
wait(signal: AbortSignal): Promise<void> {
|
|
40
|
+
// Once input is deferred, later input must follow it at the boundary;
|
|
41
|
+
// delivering it live would reorder the user's messages.
|
|
42
|
+
if (this.deferred.length === 0) return this.#queue.wait(signal);
|
|
43
|
+
if (signal.aborted) return Promise.resolve();
|
|
44
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
45
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
46
|
+
return promise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async claim(signal: AbortSignal): Promise<LiveSteerClaim | undefined> {
|
|
50
|
+
if (this.deferred.length > 0 || signal.aborted) return undefined;
|
|
51
|
+
let messages: AgentMessage[];
|
|
52
|
+
try {
|
|
53
|
+
messages = await this.#queue.take(signal);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// The queue restores what it could not hand over.
|
|
56
|
+
logger.debug("Live steering dequeue failed", {
|
|
57
|
+
error: error instanceof Error ? error.message : String(error),
|
|
58
|
+
});
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (messages.length === 0) return undefined;
|
|
62
|
+
let providerMessages: UserMessage[] | undefined;
|
|
63
|
+
try {
|
|
64
|
+
providerMessages = await this.#queue.toProvider(messages, signal);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.debug("Live steering conversion failed", {
|
|
67
|
+
error: error instanceof Error ? error.message : String(error),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
if (!providerMessages) {
|
|
71
|
+
this.deferred.push(...messages);
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
let settled = false;
|
|
75
|
+
return {
|
|
76
|
+
messages: providerMessages,
|
|
77
|
+
accept: () => {
|
|
78
|
+
if (settled) return;
|
|
79
|
+
settled = true;
|
|
80
|
+
this.accepted.push(...messages);
|
|
81
|
+
},
|
|
82
|
+
reject: () => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
this.deferred.push(...messages);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -102,6 +102,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
102
102
|
* these messages are added to the context before the next LLM call.
|
|
103
103
|
*/
|
|
104
104
|
getSteeringMessages?: () => Promise<AgentMessage[]>;
|
|
105
|
+
waitForSteeringMessages?: (signal: AbortSignal) => Promise<void>;
|
|
106
|
+
restoreSteeringMessages?: (messages: AgentMessage[]) => void;
|
|
105
107
|
|
|
106
108
|
/**
|
|
107
109
|
* Returns follow-up messages to process after the agent would otherwise stop.
|
|
@@ -198,6 +200,8 @@ export interface AgentState {
|
|
|
198
200
|
}
|
|
199
201
|
|
|
200
202
|
export interface AgentToolResult<T = any, _TInput = unknown> {
|
|
203
|
+
/** Trusted schemas loaded by client tool search. */
|
|
204
|
+
tools?: import("@f5-sales-demo/pi-ai").Tool[];
|
|
201
205
|
// Content blocks supporting text and images
|
|
202
206
|
content: (TextContent | ImageContent)[];
|
|
203
207
|
// Details to be displayed in a UI or logged
|
|
@@ -261,6 +265,8 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
|
|
|
261
265
|
* - "exclusive": runs alone; other tools wait until it finishes
|
|
262
266
|
*/
|
|
263
267
|
concurrency?: "shared" | "exclusive";
|
|
268
|
+
/** Opt-in host-managed asynchronous execution. Ordinary tools remain synchronous. */
|
|
269
|
+
async?: boolean;
|
|
264
270
|
/** If true, argument validation errors are non-fatal: raw args are passed to execute() instead of returning an error to the LLM. */
|
|
265
271
|
lenientArgValidation?: boolean;
|
|
266
272
|
execute: AgentToolExecFn<TParameters, TDetails, TTheme>;
|