@f5-sales-demo/pi-agent-core 22.4.11 → 22.5.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/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.11",
4
+ "version": "22.5.0",
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.4.11",
39
- "@f5-sales-demo/pi-utils": "22.4.11"
38
+ "@f5-sales-demo/pi-ai": "22.5.0",
39
+ "@f5-sales-demo/pi-utils": "22.5.0"
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
- // Stream assistant response
348
- const message = await streamAssistantResponse(currentContext, newMessages, config, signal, stream, streamFn);
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 (hasMoreToolCalls) {
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 = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
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>;