@oh-my-pi/pi-agent-core 17.1.5 → 17.1.7

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/src/agent.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  import type { AppendOnlyContextManager } from "./append-only-context";
40
40
  import { isProviderRefusalMessage } from "./replay-policy";
41
41
  import type {
42
+ AgentBeforeModelCall,
42
43
  AgentContext,
43
44
  AgentEvent,
44
45
  AgentLoopConfig,
@@ -267,6 +268,8 @@ export interface AgentOptions {
267
268
  abortOnFabricatedToolResult?: boolean;
268
269
  /** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */
269
270
  getToolChoice?: () => ToolChoiceDirective | undefined;
271
+ /** Reject a deferred hard choice when its named tool is no longer active. */
272
+ onToolChoiceUnavailable?: () => void;
270
273
 
271
274
  /**
272
275
  * Cursor exec handlers for local tool execution.
@@ -409,6 +412,9 @@ export class Agent {
409
412
  #dialect?: Dialect;
410
413
  #abortOnFabricatedToolResult?: boolean;
411
414
  #getToolChoice?: () => ToolChoiceDirective | undefined;
415
+ #onToolChoiceUnavailable?: () => void;
416
+ #softToolRequirementState: NonNullable<AgentLoopConfig["softToolRequirementState"]> = { escalations: 0 };
417
+ #deferredToolChoice?: ToolChoice;
412
418
  #onPayload?: SimpleStreamOptions["onPayload"];
413
419
  #onResponse?: SimpleStreamOptions["onResponse"];
414
420
  #onSseEvent?: SimpleStreamOptions["onSseEvent"];
@@ -416,6 +422,8 @@ export class Agent {
416
422
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
417
423
  #onBeforeYield?: () => Promise<void> | void;
418
424
  #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
425
+ #beforeModelCall?: AgentBeforeModelCall;
426
+ #additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
419
427
  #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
420
428
  #telemetry?: AgentLoopConfig["telemetry"];
421
429
  #appendOnlyContext?: AppendOnlyContextManager;
@@ -490,6 +498,7 @@ export class Agent {
490
498
  this.#dialect = opts.dialect;
491
499
  this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
492
500
  this.#getToolChoice = opts.getToolChoice;
501
+ this.#onToolChoiceUnavailable = opts.onToolChoiceUnavailable;
493
502
  this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
494
503
  this.#onHarmonyLeak = opts.onHarmonyLeak;
495
504
  this.beforeToolCall = opts.beforeToolCall;
@@ -803,6 +812,28 @@ export class Agent {
803
812
  this.#onTurnEnd = fn;
804
813
  }
805
814
 
815
+ /**
816
+ * Install or replace the host pre-model-call gate; pass `undefined` to
817
+ * remove it. Gates are sampled when a run starts: installing the first
818
+ * gate while a run is in flight takes effect on the next run.
819
+ */
820
+ setBeforeModelCall(fn: AgentBeforeModelCall | undefined): void {
821
+ this.#beforeModelCall = fn;
822
+ }
823
+
824
+ /**
825
+ * Add a pre-model callback without replacing callbacks owned by the host.
826
+ * Returns a disposer that removes only this callback. Like
827
+ * {@link setBeforeModelCall}, the first gate installed while a run is in
828
+ * flight takes effect on the next run.
829
+ */
830
+ addBeforeModelCall(fn: AgentBeforeModelCall): () => void {
831
+ this.#additionalBeforeModelCalls.add(fn);
832
+ return () => {
833
+ this.#additionalBeforeModelCalls.delete(fn);
834
+ };
835
+ }
836
+
806
837
  /**
807
838
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
808
839
  * completions, late LSP diagnostics) drained at each step boundary. Never
@@ -928,10 +959,20 @@ export class Agent {
928
959
  this.#followUpQueue = [];
929
960
  }
930
961
 
962
+ /**
963
+ * Drop tool-directive state retained across a gate-stopped run: the
964
+ * deferred hard choice and the soft-requirement lifecycle.
965
+ */
966
+ clearDeferredToolDirectives() {
967
+ this.#deferredToolChoice = undefined;
968
+ this.#softToolRequirementState = { escalations: 0 };
969
+ }
970
+
931
971
  clearAllQueues() {
932
972
  this.#steeringQueue = [];
933
973
  this.#followUpQueue = [];
934
974
  this.#notifySteeringWaiters();
975
+ this.clearDeferredToolDirectives();
935
976
  }
936
977
 
937
978
  hasQueuedMessages(): boolean {
@@ -1044,6 +1085,7 @@ export class Agent {
1044
1085
  this.#steeringQueue = [];
1045
1086
  this.#followUpQueue = [];
1046
1087
  this.#notifySteeringWaiters();
1088
+ this.clearDeferredToolDirectives();
1047
1089
  }
1048
1090
 
1049
1091
  /** Send a prompt with an AgentMessage */
@@ -1219,13 +1261,28 @@ export class Agent {
1219
1261
  return entry.toolResult;
1220
1262
  };
1221
1263
 
1264
+ let claimedToolChoice: ToolChoice | undefined;
1222
1265
  const getToolChoice = (): ToolChoiceDirective | undefined => {
1266
+ claimedToolChoice = undefined;
1267
+ const deferred = this.#deferredToolChoice;
1268
+ if (deferred !== undefined) {
1269
+ this.#deferredToolChoice = undefined;
1270
+ const active = refreshToolChoiceForActiveTools(deferred, this.#state.tools);
1271
+ if (active !== undefined) {
1272
+ claimedToolChoice = deferred;
1273
+ return active;
1274
+ }
1275
+ this.#onToolChoiceUnavailable?.();
1276
+ }
1277
+
1223
1278
  const queued = this.#getToolChoice?.();
1224
1279
  if (queued !== undefined) {
1225
1280
  if (isSoftToolRequirement(queued)) {
1226
1281
  return (this.#state.tools ?? []).some(tool => tool.name === queued.toolName) ? queued : undefined;
1227
1282
  }
1228
- return refreshToolChoiceForActiveTools(queued, this.#state.tools);
1283
+ const active = refreshToolChoiceForActiveTools(queued, this.#state.tools);
1284
+ if (active !== undefined) claimedToolChoice = queued;
1285
+ return active;
1229
1286
  }
1230
1287
  return refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
1231
1288
  };
@@ -1268,6 +1325,18 @@ export class Agent {
1268
1325
  context.systemPrompt = this.#state.systemPrompt;
1269
1326
  context.tools = this.#toolsForModel(this.#state.model ?? model);
1270
1327
  },
1328
+ beforeModelCall:
1329
+ this.#beforeModelCall || this.#additionalBeforeModelCalls.size > 0
1330
+ ? async (context, signal) => {
1331
+ const result = (await this.#beforeModelCall?.(context, signal)) || undefined;
1332
+ if (result?.stop) return result;
1333
+ for (const callback of this.#additionalBeforeModelCalls) {
1334
+ const callbackResult = (await callback(context, signal)) || undefined;
1335
+ if (callbackResult?.stop) return callbackResult;
1336
+ }
1337
+ return undefined;
1338
+ }
1339
+ : undefined,
1271
1340
  cursorExecHandlers: this.#cursorExecHandlers,
1272
1341
  cursorOnToolResult,
1273
1342
  cwd: this.#cwd,
@@ -1288,6 +1357,10 @@ export class Agent {
1288
1357
  onHarmonyLeak: this.#onHarmonyLeak,
1289
1358
  onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
1290
1359
  getToolChoice,
1360
+ softToolRequirementState: this.#softToolRequirementState,
1361
+ onToolChoiceRejected: () => {
1362
+ if (claimedToolChoice !== undefined) this.#deferredToolChoice = claimedToolChoice;
1363
+ },
1291
1364
  getModel: () => this.#state.model ?? model,
1292
1365
  getReasoning: () => this.#state.thinkingLevel,
1293
1366
  getDisableReasoning: () => this.#state.disableReasoning,
@@ -1322,6 +1395,7 @@ export class Agent {
1322
1395
 
1323
1396
  let partial: AgentMessage | null = null;
1324
1397
  const completedToolCallIds = new Set<string>();
1398
+ let turnOpen = false;
1325
1399
 
1326
1400
  try {
1327
1401
  const stream = messages
@@ -1329,6 +1403,8 @@ export class Agent {
1329
1403
  : agentLoopContinue(context, config, this.#abortController.signal, this.streamFn);
1330
1404
 
1331
1405
  for await (const event of stream) {
1406
+ if (event.type === "turn_start") turnOpen = true;
1407
+ if (event.type === "turn_end") turnOpen = false;
1332
1408
  // Update internal state based on events
1333
1409
  switch (event.type) {
1334
1410
  case "message_start":
@@ -1448,6 +1524,10 @@ export class Agent {
1448
1524
  };
1449
1525
 
1450
1526
  if (shouldEmitVisibleError) {
1527
+ if (!turnOpen) {
1528
+ this.#emit({ type: "turn_start" });
1529
+ turnOpen = true;
1530
+ }
1451
1531
  if (!hadAssistantStart) {
1452
1532
  this.#state.streamMessage = errorMsg;
1453
1533
  this.#emit({ type: "message_start", message: errorMsg });
@@ -1489,6 +1569,7 @@ export class Agent {
1489
1569
  toolResults.push(toolResult);
1490
1570
  }
1491
1571
  this.#emit({ type: "turn_end", message: errorMsg, toolResults });
1572
+ turnOpen = false;
1492
1573
  this.#emit({ type: "agent_end", messages: [errorMsg, ...toolResults] });
1493
1574
  } else {
1494
1575
  this.appendMessage(errorMsg);
package/src/types.ts CHANGED
@@ -48,6 +48,24 @@ export interface AgentTurnEndContext {
48
48
  willContinue: boolean;
49
49
  }
50
50
 
51
+ export interface AgentPreModelCallStop {
52
+ /** Stop the agent loop before sending the next provider request. */
53
+ stop: true;
54
+ /** Optional owner-facing reason, logged by the loop when it stops. */
55
+ reason?: string;
56
+ }
57
+
58
+ export type AgentPreModelCallResult = AgentPreModelCallStop | undefined;
59
+
60
+ /**
61
+ * A pre-model-call gate. Return {@link AgentPreModelCallStop} to refuse the
62
+ * request, or nothing to proceed; the signal aborts with the run.
63
+ */
64
+ export type AgentBeforeModelCall = (
65
+ context: Context,
66
+ signal?: AbortSignal,
67
+ ) => AgentPreModelCallResult | void | Promise<AgentPreModelCallResult | void>;
68
+
51
69
  /**
52
70
  * A soft tool requirement: the host wants `toolName` called before the loop
53
71
  * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
@@ -87,6 +105,13 @@ export interface SoftToolRequirement {
87
105
  */
88
106
  export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
89
107
 
108
+ /** Mutable soft-requirement lifecycle retained across stopped agent runs. */
109
+ export interface SoftToolRequirementState {
110
+ id?: string;
111
+ forcedToolChoice?: ToolChoice;
112
+ escalations: number;
113
+ }
114
+
90
115
  /** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
91
116
  export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement {
92
117
  return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true;
@@ -276,9 +301,23 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
276
301
  /**
277
302
  * Refreshes prompt/tool context from live session state before each model call.
278
303
  * Use this when tool availability or the system prompt can change mid-turn.
304
+ *
305
+ * Runs after pending messages are folded in and before provider conversion.
306
+ * Mutate the agent context here; use `beforeModelCall` to inspect the
307
+ * provider-bound context.
279
308
  */
280
309
  syncContextBeforeModelCall?: (context: AgentContext) => void | Promise<void>;
281
310
 
311
+ /**
312
+ * Asked after the complete provider context has been built, including
313
+ * message conversion, provider transforms, normalized tools, and owned
314
+ * dialect prompt injection. Returning {@link AgentPreModelCallStop} ends
315
+ * the stream without emitting `turn_start`, so no turn is left open and no
316
+ * request is billed. Return nothing to proceed. The signal aborts when
317
+ * the run is canceled or its deadline expires.
318
+ */
319
+ beforeModelCall?: AgentBeforeModelCall;
320
+
282
321
  /**
283
322
  * Optional transform applied to tool call arguments before execution.
284
323
  * Use for deobfuscating secrets or rewriting arguments.
@@ -356,6 +395,19 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
356
395
  */
357
396
  getToolChoice?: () => ToolChoiceDirective | undefined;
358
397
 
398
+ /**
399
+ * Soft-requirement lifecycle retained by the host when a pre-model-call
400
+ * gate stops a run before its pending reminder or escalation is served.
401
+ */
402
+ softToolRequirementState?: SoftToolRequirementState;
403
+
404
+ /**
405
+ * Notifies the host that the pre-model-call gate stopped the run after a
406
+ * hard tool choice was obtained from {@link getToolChoice} but before it
407
+ * was served, so the host can retain it for the next admitted request.
408
+ */
409
+ onToolChoiceRejected?: () => void;
410
+
359
411
  /**
360
412
  * Dynamic reasoning effort override, resolved per LLM call.
361
413
  * When set and returns a value, overrides the static `reasoning` captured
@@ -404,15 +456,22 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
404
456
  getCwd?: () => string | undefined;
405
457
 
406
458
  /**
407
- * Called after a tool call has been validated and is about to execute.
459
+ * Called once per tool call after argument validation, in call order, before
460
+ * the call is scheduled — ahead of concurrency resolution,
461
+ * `tool_execution_start`, and `tool.execute`. On the streamed path it runs
462
+ * before the assistant message's `message_start`/`message_end` are emitted.
408
463
  *
409
464
  * Return `{ block: true }` to prevent execution. The loop emits an error tool
410
465
  * result instead (using `reason` as the error text, or a default if omitted).
411
466
  *
412
- * Mutating `context.args` in place changes the arguments passed to `tool.execute`
413
- * the loop does **not** re-validate after this hook runs.
467
+ * Return `{ args }` to replace the arguments the call runs with. The
468
+ * replacement is revalidated against the tool schema and written back to the
469
+ * tool-call block, making it the single source of truth: history, execution
470
+ * events, persistence, provider replay, concurrency scheduling, and
471
+ * `tool.execute` all see the revised arguments. Mutating `context.args` in
472
+ * place also survives into execution, but a returned `args` object wins.
414
473
  *
415
- * The hook receives the tool abort signal (`signal`) and is responsible for
474
+ * The hook receives the run's request abort signal and is responsible for
416
475
  * honoring it. Throwing surfaces as a tool-error result and does not abort the
417
476
  * rest of the batch.
418
477
  */
@@ -497,12 +556,16 @@ export type AgentToolCall = Extract<AssistantMessage["content"][number], { type:
497
556
  * Set `block: true` to prevent the tool from executing. The loop emits an error tool
498
557
  * result instead, using `reason` as the error text (or a default if omitted).
499
558
  *
500
- * Mutating the `args` reference passed in `BeforeToolCallContext` is supported and
501
- * survives into execution the loop does **not** re-validate after this hook runs.
559
+ * Set `args` to replace the tool-call arguments. The replacement is revalidated
560
+ * against the tool schema (a failure surfaces as a validation-error tool result),
561
+ * written back to the tool-call block on the assistant message, and seen by
562
+ * history, scheduling, execution events, and `tool.execute` alike. It is
563
+ * ignored when `block` is true.
502
564
  */
503
565
  export interface BeforeToolCallResult {
504
566
  block?: boolean;
505
567
  reason?: string;
568
+ args?: Record<string, unknown>;
506
569
  }
507
570
 
508
571
  /**
@@ -530,9 +593,12 @@ export interface BeforeToolCallContext {
530
593
  assistantMessage: AssistantMessage;
531
594
  /** The raw tool call block from `assistantMessage.content`. */
532
595
  toolCall: AgentToolCall;
596
+ /** The resolved tool the call dispatches to. */
597
+ tool: AgentTool<any>;
533
598
  /**
534
599
  * Validated tool arguments. The same reference is forwarded to `tool.execute`
535
- * (after any `transformToolCallArguments` pass), so in-place mutations stick.
600
+ * (after any `transformToolCallArguments` pass), so in-place mutations stick;
601
+ * a returned `BeforeToolCallResult.args` replaces them entirely.
536
602
  */
537
603
  args: Record<string, unknown>;
538
604
  /** Current agent context at the time the tool call is prepared. */