@f5-sales-demo/pi-agent-core 22.13.2 → 22.14.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.13.2",
4
+ "version": "22.14.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.13.2",
39
- "@f5-sales-demo/pi-utils": "22.13.2"
38
+ "@f5-sales-demo/pi-ai": "22.14.0",
39
+ "@f5-sales-demo/pi-utils": "22.14.0"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@sinclair/typebox": "0.34.52",
package/src/agent-loop.ts CHANGED
@@ -379,231 +379,243 @@ async function runLoop(
379
379
  // Check for steering messages at start (user may have typed while waiting)
380
380
  let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
381
381
 
382
+ let followUpTurn = false;
382
383
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
383
384
  while (true) {
384
- let hasMoreToolCalls = true;
385
-
386
- // Inner loop: process tool calls and steering messages
387
- while (hasMoreToolCalls || pendingMessages.length > 0) {
388
- if (!firstTurn) {
389
- stream.push({ type: "turn_start" });
390
- } else {
391
- firstTurn = false;
392
- }
385
+ const runTurn = config.runConversationTurn ?? (async <T>(action: () => Promise<T>) => action());
386
+ const stopped = await runTurn(async () => {
387
+ let hasMoreToolCalls = true;
388
+
389
+ // Inner loop: process tool calls and steering messages
390
+ while (hasMoreToolCalls || pendingMessages.length > 0) {
391
+ if (!firstTurn) {
392
+ stream.push({ type: "turn_start" });
393
+ } else {
394
+ firstTurn = false;
395
+ }
393
396
 
394
- // Process pending messages (inject before next assistant response)
395
- if (pendingMessages.length > 0) {
396
- for (const message of pendingMessages) {
397
- stream.push({ type: "message_start", message });
398
- stream.push({ type: "message_end", message });
399
- currentContext.messages.push(message);
400
- newMessages.push(message);
397
+ // Process pending messages (inject before next assistant response)
398
+ if (pendingMessages.length > 0) {
399
+ for (const message of pendingMessages) {
400
+ stream.push({ type: "message_start", message });
401
+ stream.push({ type: "message_end", message });
402
+ currentContext.messages.push(message);
403
+ newMessages.push(message);
404
+ }
405
+ pendingMessages = [];
401
406
  }
402
- pendingMessages = [];
403
- }
404
407
 
405
- // Refresh prompt/tool context from live state before each model call
406
- if (config.syncContextBeforeModelCall) {
407
- await logger.ttftAttr("ttft.sync-context", () => config.syncContextBeforeModelCall!(currentContext));
408
- }
408
+ // Refresh prompt/tool context from live state before each model call
409
+ if (config.syncContextBeforeModelCall) {
410
+ await logger.ttftAttr("ttft.sync-context", () => config.syncContextBeforeModelCall!(currentContext));
411
+ }
409
412
 
410
- const selectedToolChoice = recoveryToolChoice
411
- ? recoveryToolChoice.value
412
- : (config.getToolChoice?.() ?? config.toolChoice);
413
-
414
- // Offer opt-in, plain user steering to the active provider without consuming other message kinds.
415
- const live =
416
- (config.model.compat as import("@f5-sales-demo/pi-ai").OpenAIResponsesCompat | undefined)
417
- ?.supportsWebSocketSteering &&
418
- config.waitForSteeringMessages &&
419
- config.getSteeringMessages &&
420
- !exactToolName(selectedToolChoice)
421
- ? new LiveSteeringChannel({
422
- wait: config.waitForSteeringMessages,
423
- take: () => config.getSteeringMessages!(),
424
- toProvider: async messages => {
425
- const converted = await config.convertToLlm(messages);
426
- // Correlated question replies must enter at a model boundary. They
427
- // resolve an existing item, rather than steering its in-flight text.
428
- if (converted.some(message => message.role !== "user" || isStructuredInputReply(message)))
429
- return undefined;
430
- return converted as import("@f5-sales-demo/pi-ai").UserMessage[];
431
- },
432
- })
433
- : undefined;
434
- const scheduler = createToolScheduler(
435
- currentContext.tools,
436
- signal,
437
- stream,
438
- config.getSteeringMessages,
439
- config.interruptMode,
440
- config.getToolContext,
441
- config.transformToolCallArguments,
442
- config.intentTracing,
443
- completedTools,
444
- toolKey,
445
- );
446
- const streamingDispatch = config.model.api === "openai-codex-responses";
447
- const responseController = new AbortController();
448
- const responseSignal = signal
449
- ? AbortSignal.any([signal, responseController.signal])
450
- : responseController.signal;
451
- const responseStartIndex = currentContext.messages.length;
452
- let message: AssistantMessage;
453
- let lastCheckpoint: AssistantMessage | undefined;
454
- try {
455
- message = await streamAssistantResponse(
456
- currentContext,
457
- newMessages,
458
- {
459
- ...config,
460
- ...(live ? { liveSteering: live } : {}),
461
- onAssistantCheckpoint: async checkpoint => {
462
- await config.onAssistantCheckpoint?.(checkpoint);
463
- lastCheckpoint = checkpoint;
464
- },
465
- getToolChoice: undefined,
466
- toolChoice: selectedToolChoice,
467
- ...(streamingDispatch ? { maxRetries: Math.max(0, 2 - recoveryAttempts) } : {}),
468
- },
469
- responseSignal,
413
+ const selectedToolChoice = recoveryToolChoice
414
+ ? recoveryToolChoice.value
415
+ : (config.getToolChoice?.() ?? config.toolChoice);
416
+
417
+ // Offer opt-in, plain user steering to the active provider without consuming other message kinds.
418
+ const live =
419
+ (config.model.compat as import("@f5-sales-demo/pi-ai").OpenAIResponsesCompat | undefined)
420
+ ?.supportsWebSocketSteering &&
421
+ config.waitForSteeringMessages &&
422
+ config.getSteeringMessages &&
423
+ !exactToolName(selectedToolChoice)
424
+ ? new LiveSteeringChannel({
425
+ wait: config.waitForSteeringMessages,
426
+ take: () => config.getSteeringMessages!(),
427
+ toProvider: async messages => {
428
+ const converted = await config.convertToLlm(messages);
429
+ // Correlated question replies must enter at a model boundary. They
430
+ // resolve an existing item, rather than steering its in-flight text.
431
+ if (converted.some(message => message.role !== "user" || isStructuredInputReply(message)))
432
+ return undefined;
433
+ return converted as import("@f5-sales-demo/pi-ai").UserMessage[];
434
+ },
435
+ })
436
+ : undefined;
437
+ const scheduler = createToolScheduler(
438
+ currentContext.tools,
439
+ signal,
470
440
  stream,
471
- streamFn,
472
- streamingDispatch ? (partial, call) => scheduler.admit(partial, [call]) : undefined,
473
- );
474
- } catch (error) {
475
- responseController.abort();
476
- const prior =
477
- lastCheckpoint ?? currentContext.messages.slice(responseStartIndex).find(m => m.role === "assistant");
478
- message =
479
- prior?.role === "assistant"
480
- ? {
481
- ...prior,
482
- stopReason: signal?.aborted ? "aborted" : "error",
483
- errorMessage: error instanceof Error ? error.message : String(error),
484
- }
485
- : {
486
- ...sanitizedForcedToolMessage(config.model, signal?.aborted ? "aborted" : "error"),
487
- errorMessage: String(error),
488
- };
489
- const ownedIndex = currentContext.messages.findIndex(
490
- (m, index) => index >= responseStartIndex && m.role === "assistant" && m.timestamp === message.timestamp,
441
+ config.getSteeringMessages,
442
+ config.interruptMode,
443
+ config.getToolContext,
444
+ config.transformToolCallArguments,
445
+ config.intentTracing,
446
+ completedTools,
447
+ toolKey,
491
448
  );
492
- if (ownedIndex >= 0) currentContext.messages[ownedIndex] = message;
493
- else currentContext.messages.push(message);
494
- stream.push({ type: "message_end", message });
495
- }
496
- newMessages.push(retainedAssistant(message));
497
- if (message.stopReason !== "error" && message.stopReason !== "aborted") {
498
- recoveryAttempts = 0;
499
- recoveryToolChoice = undefined;
500
- recoveryToolChoiceServed = false;
501
- const required = exactToolName(selectedToolChoice);
502
- scheduler.admit(
503
- message,
504
- message.content.filter(
505
- (c): c is Extract<AssistantMessage["content"][number], { type: "toolCall" }> =>
506
- c.type === "toolCall" && (!required || c.name === required),
507
- ),
508
- );
509
- }
510
- const executionResult = await scheduler.drain();
511
- const dispatchedResults = executionResult.toolResults;
512
- for (const result of dispatchedResults) {
513
- if (currentContext.messages.includes(result)) continue;
514
- currentContext.messages.push(result);
515
- newMessages.push(result);
516
- }
517
- if (live) {
518
- if (message.stopReason === "error" || message.stopReason === "aborted")
519
- config.restoreSteeringMessages?.([...live.accepted, ...live.deferred]);
520
- else {
521
- for (const accepted of live.accepted) {
522
- currentContext.messages.push(accepted);
523
- newMessages.push(accepted);
524
- stream.push({ type: "message_start", message: accepted });
525
- stream.push({ type: "message_end", message: accepted });
526
- }
527
- config.restoreSteeringMessages?.(live.deferred);
449
+ const streamingDispatch = config.model.api === "openai-codex-responses";
450
+ const responseController = new AbortController();
451
+ const responseSignal = signal
452
+ ? AbortSignal.any([signal, responseController.signal])
453
+ : responseController.signal;
454
+ const responseStartIndex = currentContext.messages.length;
455
+ let message: AssistantMessage;
456
+ let lastCheckpoint: AssistantMessage | undefined;
457
+ try {
458
+ message = await streamAssistantResponse(
459
+ currentContext,
460
+ newMessages,
461
+ {
462
+ ...config,
463
+ ...(live ? { liveSteering: live } : {}),
464
+ onAssistantCheckpoint: async checkpoint => {
465
+ await config.onAssistantCheckpoint?.(checkpoint);
466
+ lastCheckpoint = checkpoint;
467
+ },
468
+ getToolChoice: undefined,
469
+ toolChoice: selectedToolChoice,
470
+ ...(streamingDispatch ? { maxRetries: Math.max(0, 2 - recoveryAttempts) } : {}),
471
+ },
472
+ responseSignal,
473
+ stream,
474
+ streamFn,
475
+ streamingDispatch ? (partial, call) => scheduler.admit(partial, [call]) : undefined,
476
+ );
477
+ } catch (error) {
478
+ responseController.abort();
479
+ const prior =
480
+ lastCheckpoint ?? currentContext.messages.slice(responseStartIndex).find(m => m.role === "assistant");
481
+ message =
482
+ prior?.role === "assistant"
483
+ ? {
484
+ ...prior,
485
+ stopReason: signal?.aborted ? "aborted" : "error",
486
+ errorMessage: error instanceof Error ? error.message : String(error),
487
+ }
488
+ : {
489
+ ...sanitizedForcedToolMessage(config.model, signal?.aborted ? "aborted" : "error"),
490
+ errorMessage: String(error),
491
+ };
492
+ const ownedIndex = currentContext.messages.findIndex(
493
+ (m, index) =>
494
+ index >= responseStartIndex && m.role === "assistant" && m.timestamp === message.timestamp,
495
+ );
496
+ if (ownedIndex >= 0) currentContext.messages[ownedIndex] = message;
497
+ else currentContext.messages.push(message);
498
+ stream.push({ type: "message_end", message });
528
499
  }
529
- }
530
- const steeringMessagesFromExecution = executionResult.steeringMessages;
531
-
532
- if (streamingDispatch && message.interruption && message.stopReason === "error" && !signal?.aborted) {
533
- recoveryToolChoiceServed ||= !!exactToolName(selectedToolChoice) && dispatchedResults.length > 0;
534
- if (recoveryToolChoiceServed) message.interruption.toolChoiceServed = true;
535
- recoveryAttempts += message.interruption.providerRetriesConsumed;
536
- if (recoveryAttempts < 2) {
537
- recoveryAttempts += 1;
538
- try {
539
- await recoveryBackoff(500 * recoveryAttempts, signal);
540
- } catch {
541
- message.stopReason = "aborted";
542
- const retained = retainedAssistant(message);
543
- const index = newMessages.findIndex(m => m.role === "assistant" && m.timestamp === message.timestamp);
544
- if (index >= 0) newMessages[index] = retained;
545
- const contextIndex = currentContext.messages.findIndex(
546
- m => m.role === "assistant" && m.timestamp === message.timestamp,
547
- );
548
- if (contextIndex >= 0) currentContext.messages[contextIndex] = retained;
549
- stream.push({ type: "message_end", message: retained });
550
- }
551
- if (!signal?.aborted) {
552
- pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
553
- hasMoreToolCalls = true;
554
- recoveryToolChoice = {
555
- value:
556
- exactToolName(selectedToolChoice) && dispatchedResults.length > 0 ? "none" : selectedToolChoice,
557
- };
558
- continue;
559
- }
500
+ newMessages.push(retainedAssistant(message));
501
+ if (message.stopReason !== "error" && message.stopReason !== "aborted") {
502
+ recoveryAttempts = 0;
503
+ recoveryToolChoice = undefined;
504
+ recoveryToolChoiceServed = false;
505
+ const required = exactToolName(selectedToolChoice);
506
+ scheduler.admit(
507
+ message,
508
+ message.content.filter(
509
+ (c): c is Extract<AssistantMessage["content"][number], { type: "toolCall" }> =>
510
+ c.type === "toolCall" && (!required || c.name === required),
511
+ ),
512
+ );
560
513
  }
561
- }
562
- if (message.stopReason === "error" || message.stopReason === "aborted") {
563
- // Create placeholder tool results for any tool calls in the aborted message
564
- // This maintains the tool_use/tool_result pairing that the API requires
565
- type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
566
- const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
567
- const toolResults: ToolResultMessage[] = [...dispatchedResults];
568
- for (const toolCall of toolCalls) {
569
- if (scheduler.has(toolCall) || message.interruption) continue;
570
- const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
514
+ const executionResult = await scheduler.drain();
515
+ const dispatchedResults = executionResult.toolResults;
516
+ for (const result of dispatchedResults) {
517
+ if (currentContext.messages.includes(result)) continue;
571
518
  currentContext.messages.push(result);
572
519
  newMessages.push(result);
573
- toolResults.push(result);
574
520
  }
521
+ if (live) {
522
+ if (message.stopReason === "error" || message.stopReason === "aborted")
523
+ config.restoreSteeringMessages?.([...live.accepted, ...live.deferred]);
524
+ else {
525
+ for (const accepted of live.accepted) {
526
+ currentContext.messages.push(accepted);
527
+ newMessages.push(accepted);
528
+ stream.push({ type: "message_start", message: accepted });
529
+ stream.push({ type: "message_end", message: accepted });
530
+ }
531
+ config.restoreSteeringMessages?.(live.deferred);
532
+ }
533
+ }
534
+ const steeringMessagesFromExecution = executionResult.steeringMessages;
535
+
536
+ if (streamingDispatch && message.interruption && message.stopReason === "error" && !signal?.aborted) {
537
+ recoveryToolChoiceServed ||= !!exactToolName(selectedToolChoice) && dispatchedResults.length > 0;
538
+ if (recoveryToolChoiceServed) message.interruption.toolChoiceServed = true;
539
+ recoveryAttempts += message.interruption.providerRetriesConsumed;
540
+ if (recoveryAttempts < 2) {
541
+ recoveryAttempts += 1;
542
+ try {
543
+ await recoveryBackoff(500 * recoveryAttempts, signal);
544
+ } catch {
545
+ message.stopReason = "aborted";
546
+ const retained = retainedAssistant(message);
547
+ const index = newMessages.findIndex(
548
+ m => m.role === "assistant" && m.timestamp === message.timestamp,
549
+ );
550
+ if (index >= 0) newMessages[index] = retained;
551
+ const contextIndex = currentContext.messages.findIndex(
552
+ m => m.role === "assistant" && m.timestamp === message.timestamp,
553
+ );
554
+ if (contextIndex >= 0) currentContext.messages[contextIndex] = retained;
555
+ stream.push({ type: "message_end", message: retained });
556
+ }
557
+ if (!signal?.aborted) {
558
+ pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
559
+ hasMoreToolCalls = true;
560
+ recoveryToolChoice = {
561
+ value:
562
+ exactToolName(selectedToolChoice) && dispatchedResults.length > 0
563
+ ? "none"
564
+ : selectedToolChoice,
565
+ };
566
+ continue;
567
+ }
568
+ }
569
+ }
570
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
571
+ // Create placeholder tool results for any tool calls in the aborted message
572
+ // This maintains the tool_use/tool_result pairing that the API requires
573
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
574
+ const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
575
+ const toolResults: ToolResultMessage[] = [...dispatchedResults];
576
+ for (const toolCall of toolCalls) {
577
+ if (scheduler.has(toolCall) || message.interruption) continue;
578
+ const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
579
+ currentContext.messages.push(result);
580
+ newMessages.push(result);
581
+ toolResults.push(result);
582
+ }
583
+ stream.push({
584
+ type: "turn_end",
585
+ message,
586
+ toolResults,
587
+ ...(recoveryToolChoiceServed ? { toolChoiceServed: true } : {}),
588
+ });
589
+ stream.push({ type: "agent_end", messages: newMessages });
590
+ stream.end(newMessages);
591
+ return true;
592
+ }
593
+
594
+ // Check for tool calls
595
+ const toolCalls = message.content.filter(c => c.type === "toolCall");
596
+ hasMoreToolCalls = toolCalls.length > 0 || Boolean(live?.accepted.length);
597
+
598
+ const toolResults = dispatchedResults;
599
+
575
600
  stream.push({
576
601
  type: "turn_end",
577
602
  message,
578
603
  toolResults,
579
604
  ...(recoveryToolChoiceServed ? { toolChoiceServed: true } : {}),
580
605
  });
581
- stream.push({ type: "agent_end", messages: newMessages });
582
- stream.end(newMessages);
583
- return;
584
- }
585
-
586
- // Check for tool calls
587
- const toolCalls = message.content.filter(c => c.type === "toolCall");
588
- hasMoreToolCalls = toolCalls.length > 0 || Boolean(live?.accepted.length);
589
606
 
590
- const toolResults = dispatchedResults;
591
-
592
- stream.push({
593
- type: "turn_end",
594
- message,
595
- toolResults,
596
- ...(recoveryToolChoiceServed ? { toolChoiceServed: true } : {}),
597
- });
598
-
599
- pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
600
- }
607
+ pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
608
+ }
601
609
 
610
+ return false;
611
+ }, followUpTurn);
612
+ if (stopped) return;
602
613
  // Agent would stop here. Check for follow-up messages.
603
614
  const followUpMessages = (await config.getFollowUpMessages?.()) || [];
604
615
  if (followUpMessages.length > 0) {
605
616
  // Set as pending so inner loop processes them
606
617
  pendingMessages = followUpMessages;
618
+ followUpTurn = true;
607
619
  continue;
608
620
  }
609
621
 
package/src/agent.ts CHANGED
@@ -259,6 +259,7 @@ export class Agent {
259
259
  #abortController?: AbortController;
260
260
  #convertToLlm: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
261
261
  #contextMessages?: AgentLoopConfig["getContextMessages"];
262
+ #runConversationTurn?: AgentLoopConfig["runConversationTurn"];
262
263
  #transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
263
264
  #steeringQueue: AgentMessage[] = [];
264
265
  #steeringWaiters = new Set<() => void>();
@@ -564,6 +565,11 @@ export class Agent {
564
565
  }
565
566
 
566
567
  /** Install the owning session's model-context provider. It must not start work. */
568
+
569
+ setConversationTurnScope(scope: AgentLoopConfig["runConversationTurn"]): void {
570
+ this.#runConversationTurn = scope;
571
+ }
572
+
567
573
  setContextMessagesProvider(provider?: AgentLoopConfig["getContextMessages"]): () => void {
568
574
  this.#contextMessages = provider;
569
575
  return () => {
@@ -892,6 +898,7 @@ export class Agent {
892
898
  return this.#dequeueSteeringMessages();
893
899
  },
894
900
  getFollowUpMessages: async () => this.#dequeueFollowUpMessages(),
901
+ runConversationTurn: this.#runConversationTurn,
895
902
  };
896
903
 
897
904
  return { context, config };
package/src/types.ts CHANGED
@@ -113,6 +113,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
113
113
  * continues with another turn.
114
114
  */
115
115
  getFollowUpMessages?: () => Promise<AgentMessage[]>;
116
+ /** Scope one admitted user turn, including all tool continuations and retries. */
117
+ runConversationTurn?: <T>(action: () => Promise<T>, followUp: boolean) => Promise<T>;
116
118
 
117
119
  /**
118
120
  * Provides tool execution context, resolved per tool call.