@f5-sales-demo/pi-agent-core 21.29.0 → 21.29.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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/agent-loop.ts +168 -1
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": "21.29.0",
4
+ "version": "21.29.2",
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": "21.29.0",
39
- "@f5-sales-demo/pi-utils": "21.29.0"
38
+ "@f5-sales-demo/pi-ai": "21.29.2",
39
+ "@f5-sales-demo/pi-utils": "21.29.2"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@sinclair/typebox": "0.34.52",
package/src/agent-loop.ts CHANGED
@@ -127,6 +127,125 @@ function normalizeMessagesForProvider(
127
127
  return changed ? normalized : messages;
128
128
  }
129
129
 
130
+ function exactToolName(choice: AgentLoopConfig["toolChoice"]): string | undefined {
131
+ if (!choice || typeof choice === "string") return undefined;
132
+ if (choice.type === "tool") return choice.name;
133
+ return "function" in choice ? choice.function.name : choice.name;
134
+ }
135
+
136
+ function forcedToolRetryContext(context: Context, toolName: string): Context {
137
+ return {
138
+ ...context,
139
+ messages: [
140
+ ...context.messages,
141
+ {
142
+ role: "developer",
143
+ content: `The previous response ended before the required tool call. Call ${toolName} now without explanatory text.`,
144
+ attribution: "agent",
145
+ timestamp: Date.now(),
146
+ },
147
+ ],
148
+ };
149
+ }
150
+
151
+ interface BufferedAssistantResponse {
152
+ message: AssistantMessage;
153
+ events: Array<Extract<AgentEvent, { type: "message_start" | "message_update" | "message_end" }>>;
154
+ }
155
+
156
+ function emptyUsage(): AssistantMessage["usage"] {
157
+ return {
158
+ input: 0,
159
+ output: 0,
160
+ cacheRead: 0,
161
+ cacheWrite: 0,
162
+ totalTokens: 0,
163
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
164
+ };
165
+ }
166
+
167
+ function sanitizedForcedToolMessage(
168
+ model: AgentLoopConfig["model"],
169
+ stopReason: "aborted" | "error",
170
+ ): AssistantMessage {
171
+ return {
172
+ role: "assistant",
173
+ content: [],
174
+ api: model.api,
175
+ provider: model.provider,
176
+ model: model.id,
177
+ usage: emptyUsage(),
178
+ stopReason,
179
+ ...(stopReason === "error"
180
+ ? { errorMessage: "Required tool invocation failed." }
181
+ : { errorMessage: "Request was aborted" }),
182
+ timestamp: Date.now(),
183
+ };
184
+ }
185
+
186
+ async function bufferAssistantResponse(
187
+ response: Awaited<ReturnType<StreamFn>>,
188
+ model: AgentLoopConfig["model"],
189
+ signal: AbortSignal | undefined,
190
+ ): Promise<BufferedAssistantResponse> {
191
+ const events: BufferedAssistantResponse["events"] = [];
192
+ let partialMessage: AssistantMessage | null = null;
193
+ let addedPartial = false;
194
+
195
+ for await (const event of response) {
196
+ if (signal?.aborted) {
197
+ const message = sanitizedForcedToolMessage(model, "aborted");
198
+ return {
199
+ message,
200
+ events: [
201
+ { type: "message_start", message: { ...message } },
202
+ { type: "message_end", message },
203
+ ],
204
+ };
205
+ }
206
+
207
+ switch (event.type) {
208
+ case "start":
209
+ partialMessage = event.partial;
210
+ addedPartial = true;
211
+ events.push({ type: "message_start", message: { ...partialMessage } as AssistantMessage });
212
+ break;
213
+ case "text_start":
214
+ case "text_delta":
215
+ case "text_end":
216
+ case "thinking_start":
217
+ case "thinking_delta":
218
+ case "thinking_end":
219
+ case "toolcall_start":
220
+ case "toolcall_delta":
221
+ case "toolcall_end":
222
+ case "server_tool_start":
223
+ case "server_tool_end":
224
+ if (partialMessage) {
225
+ partialMessage = event.partial;
226
+ events.push({
227
+ type: "message_update",
228
+ assistantMessageEvent: event,
229
+ message: { ...partialMessage } as AssistantMessage,
230
+ });
231
+ }
232
+ break;
233
+ case "done":
234
+ case "error": {
235
+ const message = await response.result();
236
+ if (!addedPartial) events.push({ type: "message_start", message: { ...message } });
237
+ events.push({ type: "message_end", message });
238
+ return { message, events };
239
+ }
240
+ }
241
+ }
242
+
243
+ const message = await response.result();
244
+ if (!addedPartial) events.push({ type: "message_start", message: { ...message } });
245
+ if (!events.some(event => event.type === "message_end")) events.push({ type: "message_end", message });
246
+ return { message, events };
247
+ }
248
+
130
249
  export const INTENT_FIELD = "_i";
131
250
 
132
251
  function injectIntentIntoSchema(schema: unknown): unknown {
@@ -340,11 +459,59 @@ async function streamAssistantResponse(
340
459
  (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
341
460
 
342
461
  const dynamicToolChoice = config.getToolChoice?.();
462
+ const selectedToolChoice = dynamicToolChoice ?? config.toolChoice;
463
+ const requiredToolName = exactToolName(selectedToolChoice);
464
+ if (requiredToolName) {
465
+ // A named choice is a safety boundary, not a hint. Keep each attempt private until the
466
+ // requested call is complete so truncated prose and substituted tools cannot reach state,
467
+ // subscribers, or execution. A provider may exhaust its output budget before emitting the
468
+ // call; retry that pre-invocation failure once with the exact same choice.
469
+ for (let attempt = 0; attempt < 2; attempt++) {
470
+ const attemptContext = attempt === 0 ? llmContext : forcedToolRetryContext(llmContext, requiredToolName);
471
+ const response = await logger.ttftAttr("ttft.stream-fn", () =>
472
+ streamFunction(config.model, attemptContext, {
473
+ ...config,
474
+ apiKey: resolvedApiKey,
475
+ toolChoice: selectedToolChoice,
476
+ signal,
477
+ }),
478
+ );
479
+ const buffered = await bufferAssistantResponse(response, config.model, signal);
480
+ if (buffered.message.stopReason === "aborted") {
481
+ context.messages.push(buffered.message);
482
+ for (const event of buffered.events) stream.push(event);
483
+ return buffered.message;
484
+ }
485
+ if (buffered.message.stopReason === "error") break;
486
+
487
+ const toolCalls = buffered.message.content.filter(content => content.type === "toolCall");
488
+ const exactInvocation = toolCalls.length === 1 && toolCalls[0]?.name === requiredToolName;
489
+ if (exactInvocation) {
490
+ context.messages.push(buffered.message);
491
+ for (const event of buffered.events) {
492
+ if (event.type === "message_update") {
493
+ config.onAssistantMessageEvent?.(event.message as AssistantMessage, event.assistantMessageEvent);
494
+ }
495
+ stream.push(event);
496
+ }
497
+ return buffered.message;
498
+ }
499
+
500
+ if (attempt === 0 && buffered.message.stopReason === "length" && toolCalls.length === 0) continue;
501
+ break;
502
+ }
503
+
504
+ const failure = sanitizedForcedToolMessage(config.model, signal?.aborted ? "aborted" : "error");
505
+ context.messages.push(failure);
506
+ stream.push({ type: "message_start", message: { ...failure } });
507
+ stream.push({ type: "message_end", message: failure });
508
+ return failure;
509
+ }
343
510
  const response = await logger.ttftAttr("ttft.stream-fn", () =>
344
511
  streamFunction(config.model, llmContext, {
345
512
  ...config,
346
513
  apiKey: resolvedApiKey,
347
- toolChoice: dynamicToolChoice ?? config.toolChoice,
514
+ toolChoice: selectedToolChoice,
348
515
  signal,
349
516
  }),
350
517
  );