@oh-my-pi/pi-agent-core 17.1.4 → 17.1.6

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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.6] - 2026-07-27
6
+
7
+ ### Added
8
+
9
+ - Added a pre-model-call gate: `AgentLoopConfig.beforeModelCall` receives the finalized provider context and run abort signal, and may return `{ stop: true, reason? }` to end the run before the provider is called, so a host can refuse a request it has decided not to pay for (prompt no longer fits, budget boundary crossed, session should hand off). `Agent.setBeforeModelCall` installs the host callback; `Agent.addBeforeModelCall` registers an additional one without displacing it and returns a disposer. A gate-stopped run retains pending soft tool reminders/escalations and an unserved hard tool choice for the next admitted request; deferred choices are revalidated against active tools and cleared with queued session state ([#6543](https://github.com/can1357/oh-my-pi/pull/6543) by [@paralin](https://github.com/paralin)).
10
+
11
+ ### Changed
12
+
13
+ - Input message events (prompt, steering, soft reminders) are now emitted once provider-context preparation succeeds, so a pre-model gate can veto the request before any turn opens; gate-stopped and failed runs still commit their accepted inputs ([#6543](https://github.com/can1357/oh-my-pi/pull/6543) by [@paralin](https://github.com/paralin)).
14
+
15
+ ## [17.1.5] - 2026-07-27
16
+
17
+ ### Fixed
18
+
19
+ - Fixed proxy-stream clients dropping finalized provider-only content blocks, including Anthropic native web-search history, by allowing `done` and `error` events to carry terminal assistant content while retaining delta-reconstructed content from older proxy servers that omit it ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)).
20
+
5
21
  ## [17.1.4] - 2026-07-26
6
22
 
7
23
  ### Changed
@@ -2,7 +2,7 @@ import { type ApiKey, type AssistantMessage, type AssistantMessageEvent, type Co
2
2
  import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context.js";
5
- import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
5
+ import type { AgentBeforeModelCall, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
6
6
  export declare class AgentBusyError extends Error {
7
7
  constructor(message?: string);
8
8
  }
@@ -159,6 +159,8 @@ export interface AgentOptions {
159
159
  abortOnFabricatedToolResult?: boolean;
160
160
  /** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */
161
161
  getToolChoice?: () => ToolChoiceDirective | undefined;
162
+ /** Reject a deferred hard choice when its named tool is no longer active. */
163
+ onToolChoiceUnavailable?: () => void;
162
164
  /**
163
165
  * Cursor exec handlers for local tool execution.
164
166
  */
@@ -374,6 +376,19 @@ export declare class Agent {
374
376
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
375
377
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
376
378
  setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void) | undefined): void;
379
+ /**
380
+ * Install or replace the host pre-model-call gate; pass `undefined` to
381
+ * remove it. Gates are sampled when a run starts: installing the first
382
+ * gate while a run is in flight takes effect on the next run.
383
+ */
384
+ setBeforeModelCall(fn: AgentBeforeModelCall | undefined): void;
385
+ /**
386
+ * Add a pre-model callback without replacing callbacks owned by the host.
387
+ * Returns a disposer that removes only this callback. Like
388
+ * {@link setBeforeModelCall}, the first gate installed while a run is in
389
+ * flight takes effect on the next run.
390
+ */
391
+ addBeforeModelCall(fn: AgentBeforeModelCall): () => void;
377
392
  /**
378
393
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
379
394
  * completions, late LSP diagnostics) drained at each step boundary. Never
@@ -408,6 +423,11 @@ export declare class Agent {
408
423
  followUp(m: AgentMessage): void;
409
424
  clearSteeringQueue(): void;
410
425
  clearFollowUpQueue(): void;
426
+ /**
427
+ * Drop tool-directive state retained across a gate-stopped run: the
428
+ * deferred hard choice and the soft-requirement lifecycle.
429
+ */
430
+ clearDeferredToolDirectives(): void;
411
431
  clearAllQueues(): void;
412
432
  hasQueuedMessages(): boolean;
413
433
  /** Non-consuming view of the pending steering queue (insertion order, newest
@@ -53,11 +53,13 @@ export type ProxyAssistantMessageEvent = {
53
53
  type: "done";
54
54
  reason: Extract<StopReason, "stop" | "length" | "toolUse">;
55
55
  usage: AssistantMessage["usage"];
56
+ content?: AssistantMessage["content"];
56
57
  } | {
57
58
  type: "error";
58
59
  reason: Extract<StopReason, "aborted" | "error">;
59
60
  errorMessage?: string;
60
61
  usage: AssistantMessage["usage"];
62
+ content?: AssistantMessage["content"];
61
63
  };
62
64
  export interface ProxyStreamOptions extends SimpleStreamOptions {
63
65
  /** Auth token for the proxy server */
@@ -21,6 +21,18 @@ export interface AgentTurnEndContext {
21
21
  /** True when the current tool-loop batch is continuing without yielding to post-turn steering. */
22
22
  willContinue: boolean;
23
23
  }
24
+ export interface AgentPreModelCallStop {
25
+ /** Stop the agent loop before sending the next provider request. */
26
+ stop: true;
27
+ /** Optional owner-facing reason, logged by the loop when it stops. */
28
+ reason?: string;
29
+ }
30
+ export type AgentPreModelCallResult = AgentPreModelCallStop | undefined;
31
+ /**
32
+ * A pre-model-call gate. Return {@link AgentPreModelCallStop} to refuse the
33
+ * request, or nothing to proceed; the signal aborts with the run.
34
+ */
35
+ export type AgentBeforeModelCall = (context: Context, signal?: AbortSignal) => AgentPreModelCallResult | void | Promise<AgentPreModelCallResult | void>;
24
36
  /**
25
37
  * A soft tool requirement: the host wants `toolName` called before the loop
26
38
  * runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
@@ -61,6 +73,12 @@ export interface SoftToolRequirement {
61
73
  * (applied verbatim) or a {@link SoftToolRequirement} (remind-then-escalate).
62
74
  */
63
75
  export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
76
+ /** Mutable soft-requirement lifecycle retained across stopped agent runs. */
77
+ export interface SoftToolRequirementState {
78
+ id?: string;
79
+ forcedToolChoice?: ToolChoice;
80
+ escalations: number;
81
+ }
64
82
  /** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
65
83
  export declare function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement;
66
84
  /** Source category for a queued steering interrupt observed without consuming the queue. */
@@ -230,8 +248,21 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
230
248
  /**
231
249
  * Refreshes prompt/tool context from live session state before each model call.
232
250
  * Use this when tool availability or the system prompt can change mid-turn.
251
+ *
252
+ * Runs after pending messages are folded in and before provider conversion.
253
+ * Mutate the agent context here; use `beforeModelCall` to inspect the
254
+ * provider-bound context.
233
255
  */
234
256
  syncContextBeforeModelCall?: (context: AgentContext) => void | Promise<void>;
257
+ /**
258
+ * Asked after the complete provider context has been built, including
259
+ * message conversion, provider transforms, normalized tools, and owned
260
+ * dialect prompt injection. Returning {@link AgentPreModelCallStop} ends
261
+ * the stream without emitting `turn_start`, so no turn is left open and no
262
+ * request is billed. Return nothing to proceed. The signal aborts when
263
+ * the run is canceled or its deadline expires.
264
+ */
265
+ beforeModelCall?: AgentBeforeModelCall;
235
266
  /**
236
267
  * Optional transform applied to tool call arguments before execution.
237
268
  * Use for deobfuscating secrets or rewriting arguments.
@@ -304,6 +335,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
304
335
  * to the static `toolChoice`.
305
336
  */
306
337
  getToolChoice?: () => ToolChoiceDirective | undefined;
338
+ /**
339
+ * Soft-requirement lifecycle retained by the host when a pre-model-call
340
+ * gate stops a run before its pending reminder or escalation is served.
341
+ */
342
+ softToolRequirementState?: SoftToolRequirementState;
343
+ /**
344
+ * Notifies the host that the pre-model-call gate stopped the run after a
345
+ * hard tool choice was obtained from {@link getToolChoice} but before it
346
+ * was served, so the host can retain it for the next admitted request.
347
+ */
348
+ onToolChoiceRejected?: () => void;
307
349
  /**
308
350
  * Dynamic reasoning effort override, resolved per LLM call.
309
351
  * When set and returns a value, overrides the static `reasoning` captured
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "17.1.4",
4
+ "version": "17.1.6",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "17.1.4",
39
- "@oh-my-pi/pi-catalog": "17.1.4",
40
- "@oh-my-pi/pi-natives": "17.1.4",
41
- "@oh-my-pi/pi-utils": "17.1.4",
42
- "@oh-my-pi/pi-wire": "17.1.4",
43
- "@oh-my-pi/snapcompact": "17.1.4",
38
+ "@oh-my-pi/pi-ai": "17.1.6",
39
+ "@oh-my-pi/pi-catalog": "17.1.6",
40
+ "@oh-my-pi/pi-natives": "17.1.6",
41
+ "@oh-my-pi/pi-utils": "17.1.6",
42
+ "@oh-my-pi/pi-wire": "17.1.6",
43
+ "@oh-my-pi/snapcompact": "17.1.6",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type Context,
11
11
  EventStream,
12
12
  isApiKeyResolver,
13
+ type Model,
13
14
  resolveApiKeyOnce,
14
15
  seedApiKeyResolver,
15
16
  streamSimple,
@@ -42,7 +43,7 @@ import {
42
43
  signalListLabel,
43
44
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
44
45
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
45
- import { sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
46
+ import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
46
47
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
47
48
  import { agentPauseGate } from "./pause";
48
49
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
@@ -67,6 +68,7 @@ import type {
67
68
  AgentEvent,
68
69
  AgentLoopConfig,
69
70
  AgentMessage,
71
+ AgentPreModelCallResult,
70
72
  AgentTool,
71
73
  AgentToolResult,
72
74
  AgentTurnEndContext,
@@ -526,14 +528,9 @@ export function agentLoop(
526
528
  };
527
529
 
528
530
  stream.push({ type: "agent_start" });
529
- stream.push({ type: "turn_start" });
530
- for (const prompt of prompts) {
531
- stream.push({ type: "message_start", message: prompt });
532
- stream.push({ type: "message_end", message: prompt });
533
- }
534
531
 
535
532
  try {
536
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
533
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, prompts);
537
534
  } catch (err) {
538
535
  stream.fail(err);
539
536
  }
@@ -571,7 +568,6 @@ export function agentLoopContinue(
571
568
  const currentContext: AgentContext = { ...context, messages: [...context.messages] };
572
569
 
573
570
  stream.push({ type: "agent_start" });
574
- stream.push({ type: "turn_start" });
575
571
 
576
572
  try {
577
573
  await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
@@ -621,14 +617,36 @@ async function emitTurnEnd(
621
617
  config: AgentLoopConfig,
622
618
  signal?: AbortSignal,
623
619
  context?: Omit<AgentTurnEndContext, "message" | "toolResults">,
620
+ runHookOnAbortedMessage = false,
624
621
  ): Promise<void> {
625
622
  stream.push({ type: "turn_end", message, toolResults });
626
623
  const isAbortedOrError =
627
624
  message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
628
- if (signal?.aborted || isAbortedOrError) return;
625
+ if (signal?.aborted || (isAbortedOrError && !runHookOnAbortedMessage)) return;
629
626
  await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
630
627
  }
631
628
 
629
+ function createGateStopMessage(model: Model, reason: string | undefined): AssistantMessage {
630
+ return {
631
+ role: "assistant",
632
+ content: [{ type: "text", text: "" }],
633
+ api: model.api,
634
+ provider: model.provider,
635
+ model: model.id,
636
+ usage: {
637
+ input: 0,
638
+ output: 0,
639
+ cacheRead: 0,
640
+ cacheWrite: 0,
641
+ totalTokens: 0,
642
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
643
+ },
644
+ stopReason: "aborted",
645
+ errorMessage: reason ?? "Stopped before model call",
646
+ timestamp: Date.now(),
647
+ };
648
+ }
649
+
632
650
  /**
633
651
  * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
634
652
  * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
@@ -866,6 +884,7 @@ async function runLoop(
866
884
  signal: AbortSignal | undefined,
867
885
  stream: EventStream<AgentEvent, AgentMessage[]>,
868
886
  streamFn?: StreamFn,
887
+ initialMessages: AgentMessage[] = [],
869
888
  ): Promise<void> {
870
889
  const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
871
890
  const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
@@ -882,6 +901,7 @@ async function runLoop(
882
901
  telemetry,
883
902
  invokeAgentSpan,
884
903
  stepCounter,
904
+ initialMessages,
885
905
  streamFn,
886
906
  ),
887
907
  );
@@ -913,6 +933,12 @@ function endAgentStream(
913
933
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCount));
914
934
  stream.end(newMessages);
915
935
  }
936
+ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, messages: readonly AgentMessage[]): void {
937
+ for (const message of messages) {
938
+ stream.push({ type: "message_start", message });
939
+ stream.push({ type: "message_end", message });
940
+ }
941
+ }
916
942
 
917
943
  /**
918
944
  * Resolve aside entries at the moment the loop is about to inject them. Each entry
@@ -940,6 +966,7 @@ async function runLoopBody(
940
966
  telemetry: AgentTelemetry | undefined,
941
967
  invokeAgentSpan: Span | undefined,
942
968
  stepCounter: StepCounter,
969
+ initialMessages: AgentMessage[],
943
970
  streamFn?: StreamFn,
944
971
  ): Promise<void> {
945
972
  let deadlineTimer: Timer | undefined;
@@ -957,26 +984,33 @@ async function runLoopBody(
957
984
  signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
958
985
  }
959
986
 
987
+ const softRequirementState = config.softToolRequirementState ?? { escalations: 0 };
988
+ let preserveSoftRequirementState = false;
989
+
960
990
  try {
961
- let firstTurn = true;
991
+ let messagesToEmit = [...initialMessages];
962
992
  if (isDeadlineExceeded(config.deadline)) {
993
+ emitInputMessages(stream, messagesToEmit);
963
994
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
964
995
  return;
965
996
  }
966
997
  // Check for steering messages at start (user may have typed while waiting).
967
998
  // Skip when the run is already externally aborted — dequeuing would strand
968
999
  // the messages in a run that is about to die.
969
- let pendingMessages: AgentMessage[] = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
1000
+ let pendingMessages: AgentMessage[];
1001
+ try {
1002
+ pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
1003
+ } catch (error) {
1004
+ stream.push({ type: "turn_start" });
1005
+ emitInputMessages(stream, messagesToEmit);
1006
+ throw error;
1007
+ }
970
1008
  let harmonyRetryAttempt = 0;
971
1009
  let harmonyTruncateResumeCount = 0;
972
1010
  let pausedTurnContinuations = 0;
973
1011
 
974
- // Soft tool requirement lifecycle (reminder escalate; see SoftToolRequirement).
975
- // `forcedToolChoice` carries a one-turn escalation into the next model call. It
976
- // overrides the static toolChoice but NEVER the host's hard getToolChoice().
977
- let softRequirementId: string | undefined;
978
- let forcedToolChoice: ToolChoice | undefined;
979
- let softEscalations = 0;
1012
+ // Soft tool requirement lifecycle (reminder then escalation; see SoftToolRequirement).
1013
+ // The host-owned state survives only a gate stop between Agent.prompt calls.
980
1014
  // Resolved once per logical turn at the fetch site below and reused across
981
1015
  // Harmony-leak re-samples (which re-enter the same turn) so the consuming
982
1016
  // getToolChoice is never advanced twice; the flag resets at the message boundary.
@@ -984,6 +1018,7 @@ async function runLoopBody(
984
1018
  let softRequiredTool: string | undefined;
985
1019
  let softSatisfies: SoftToolRequirement["satisfies"];
986
1020
  let directiveResolvedForTurn = false;
1021
+ let turnOpen = false;
987
1022
 
988
1023
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
989
1024
  while (true) {
@@ -992,6 +1027,7 @@ async function runLoopBody(
992
1027
  // Inner loop: process tool calls and steering messages
993
1028
  while (hasMoreToolCalls || pendingMessages.length > 0) {
994
1029
  if (isDeadlineExceeded(config.deadline)) {
1030
+ emitInputMessages(stream, messagesToEmit);
995
1031
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
996
1032
  return;
997
1033
  }
@@ -1002,62 +1038,109 @@ async function runLoopBody(
1002
1038
  // engaged (host /pause). An external abort releases the park so a
1003
1039
  // cancelled run still unwinds while everything else stays frozen.
1004
1040
  if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(signal);
1005
- if (!firstTurn) {
1006
- stream.push({ type: "turn_start" });
1007
- } else {
1008
- firstTurn = false;
1009
- }
1010
1041
 
1011
- // Process pending messages (inject before next assistant response)
1042
+ // Build the provider-bound context before opening the turn. Queue
1043
+ // messages are added now but their events remain deferred until
1044
+ // provider preparation either succeeds or opens an error turn.
1045
+ const turnMessages = messagesToEmit;
1046
+ messagesToEmit = [];
1012
1047
  if (pendingMessages.length > 0) {
1013
1048
  for (const message of pendingMessages) {
1014
- stream.push({ type: "message_start", message });
1015
- stream.push({ type: "message_end", message });
1016
1049
  currentContext.messages.push(message);
1017
1050
  newMessages.push(message);
1051
+ turnMessages.push(message);
1018
1052
  }
1019
1053
  pendingMessages = [];
1020
1054
  }
1021
1055
 
1022
- // Refresh prompt/tool context from live state before each model call
1023
- if (config.syncContextBeforeModelCall) {
1024
- await config.syncContextBeforeModelCall(currentContext);
1025
- }
1056
+ let preparedProviderCall: PreparedProviderCall;
1057
+ let gateResult: AgentPreModelCallResult;
1058
+ try {
1059
+ if (config.syncContextBeforeModelCall) {
1060
+ await config.syncContextBeforeModelCall(currentContext);
1061
+ }
1026
1062
 
1027
- // Resolve the per-turn tool-choice directive ONCE per logical turn. The
1028
- // host hard-choice path (getToolChoice nextToolChoice) is CONSUMING — it
1029
- // advances a generator on every call — so Harmony-leak retries, which
1030
- // re-sample the same turn via `continue` without a turn_end, must reuse the
1031
- // values fetched on the first attempt rather than double-advancing it.
1032
- // Fetched here (after pending-message flush + context sync, immediately
1033
- // before the call) so a throw in between cannot wedge an in-flight
1034
- // directive. A hard ToolChoice is applied verbatim; a SoftToolRequirement
1035
- // triggers the remind-then-escalate lifecycle: inject its reminder inline
1036
- // once per new id (toolChoice stays auto), and the gate below escalates to
1037
- // a forced choice only if the model declines. The host wrapper already
1038
- // dropped a soft requirement whose tool is inactive.
1039
- if (!directiveResolvedForTurn) {
1040
- const directive = signal?.aborted ? undefined : config.getToolChoice?.();
1041
- const softReq = isSoftToolRequirement(directive) ? directive : undefined;
1042
- hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
1043
- softRequiredTool = softReq?.toolName;
1044
- softSatisfies = softReq?.satisfies;
1045
- if (softReq !== undefined) {
1046
- if (softReq.id !== softRequirementId) {
1047
- softRequirementId = softReq.id;
1048
- softEscalations = 0;
1049
- for (const reminder of softReq.reminder) {
1050
- stream.push({ type: "message_start", message: reminder });
1051
- stream.push({ type: "message_end", message: reminder });
1052
- currentContext.messages.push(reminder);
1053
- newMessages.push(reminder);
1063
+ if (!directiveResolvedForTurn) {
1064
+ const directive = signal?.aborted ? undefined : config.getToolChoice?.();
1065
+ const softReq = isSoftToolRequirement(directive) ? directive : undefined;
1066
+ hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
1067
+ softRequiredTool = softReq?.toolName;
1068
+ softSatisfies = softReq?.satisfies;
1069
+ const softRequirementId = softRequirementState.id;
1070
+ if (softReq !== undefined) {
1071
+ if (softReq.id !== softRequirementId) {
1072
+ softRequirementState.id = softReq.id;
1073
+ softRequirementState.forcedToolChoice = undefined;
1074
+ softRequirementState.escalations = 0;
1075
+ for (const reminder of softReq.reminder) {
1076
+ currentContext.messages.push(reminder);
1077
+ newMessages.push(reminder);
1078
+ turnMessages.push(reminder);
1079
+ }
1054
1080
  }
1081
+ } else {
1082
+ softRequirementState.id = undefined;
1083
+ softRequirementState.forcedToolChoice = undefined;
1084
+ softRequirementState.escalations = 0;
1055
1085
  }
1056
- } else {
1057
- softRequirementId = undefined;
1058
- softEscalations = 0;
1086
+ directiveResolvedForTurn = true;
1087
+ }
1088
+
1089
+ preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1090
+ gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1091
+ } catch (error) {
1092
+ if (!turnOpen) {
1093
+ stream.push({ type: "turn_start" });
1094
+ emitInputMessages(stream, turnMessages);
1095
+ turnOpen = true;
1096
+ }
1097
+ throw error;
1098
+ }
1099
+ if (config.beforeModelCall && signal?.aborted) {
1100
+ gateResult = { stop: true };
1101
+ }
1102
+ if (gateResult?.stop) {
1103
+ if (gateResult.reason) {
1104
+ logger.debug("Agent loop stopped before the model call", { reason: gateResult.reason });
1059
1105
  }
1060
- directiveResolvedForTurn = true;
1106
+ if (!turnOpen && !signal?.aborted) {
1107
+ try {
1108
+ config.onToolChoiceRejected?.();
1109
+ } catch (error) {
1110
+ stream.push({ type: "turn_start" });
1111
+ emitInputMessages(stream, turnMessages);
1112
+ turnOpen = true;
1113
+ throw error;
1114
+ }
1115
+ }
1116
+ emitInputMessages(stream, turnMessages);
1117
+ if (turnOpen) {
1118
+ const stopMessage = createGateStopMessage(preparedProviderCall.model, gateResult.reason);
1119
+ currentContext.messages.push(stopMessage);
1120
+ newMessages.push(stopMessage);
1121
+ stream.push({ type: "message_start", message: stopMessage });
1122
+ stream.push({ type: "message_end", message: stopMessage });
1123
+ await emitTurnEnd(
1124
+ stream,
1125
+ currentContext,
1126
+ stopMessage,
1127
+ [],
1128
+ config,
1129
+ signal,
1130
+ { willContinue: false },
1131
+ true,
1132
+ );
1133
+ turnOpen = false;
1134
+ }
1135
+ preserveSoftRequirementState = !signal?.aborted;
1136
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1137
+ return;
1138
+ }
1139
+
1140
+ if (!turnOpen) {
1141
+ stream.push({ type: "turn_start" });
1142
+ emitInputMessages(stream, turnMessages);
1143
+ turnOpen = true;
1061
1144
  }
1062
1145
 
1063
1146
  // Stream assistant response
@@ -1075,7 +1158,8 @@ async function runLoopBody(
1075
1158
  streamFn,
1076
1159
  harmonyRetryAttempt,
1077
1160
  hostToolChoice,
1078
- forcedToolChoice,
1161
+ softRequirementState.forcedToolChoice,
1162
+ preparedProviderCall,
1079
1163
  );
1080
1164
  harmonyRetryAttempt = 0;
1081
1165
  harmonyTruncateResumeCount = 0;
@@ -1118,7 +1202,7 @@ async function runLoopBody(
1118
1202
 
1119
1203
  // The escalation choice (if any) applied to the call above; clear it so
1120
1204
  // only the single escalation turn carries the forced choice.
1121
- forcedToolChoice = undefined;
1205
+ softRequirementState.forcedToolChoice = undefined;
1122
1206
 
1123
1207
  // A fresh logical turn re-resolves the directive next iteration; a Harmony
1124
1208
  // retry `continue`s before this line and keeps the cached value.
@@ -1161,6 +1245,7 @@ async function runLoopBody(
1161
1245
  });
1162
1246
  }
1163
1247
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, { willContinue: false });
1248
+ turnOpen = false;
1164
1249
 
1165
1250
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
1166
1251
  stream.end(newMessages);
@@ -1212,7 +1297,7 @@ async function runLoopBody(
1212
1297
 
1213
1298
  const toolResults: ToolResultMessage[] = [];
1214
1299
  if (softNonCompliant && softRequiredTool !== undefined) {
1215
- if (softEscalations >= MAX_SOFT_TOOL_ESCALATIONS) {
1300
+ if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
1216
1301
  throw new Error(
1217
1302
  `Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
1218
1303
  );
@@ -1239,8 +1324,8 @@ async function runLoopBody(
1239
1324
  status: "skipped",
1240
1325
  });
1241
1326
  }
1242
- forcedToolChoice = { type: "tool", name: softRequiredTool };
1243
- softEscalations++;
1327
+ softRequirementState.forcedToolChoice = { type: "tool", name: softRequiredTool };
1328
+ softRequirementState.escalations++;
1244
1329
  hasMoreToolCalls = true;
1245
1330
  } else if (hasMoreToolCalls) {
1246
1331
  const executionResult = await executeToolCalls(
@@ -1306,6 +1391,7 @@ async function runLoopBody(
1306
1391
  await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1307
1392
  willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1308
1393
  });
1394
+ turnOpen = false;
1309
1395
 
1310
1396
  if (isDeadlineExceeded(config.deadline)) {
1311
1397
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
@@ -1361,6 +1447,11 @@ async function runLoopBody(
1361
1447
 
1362
1448
  endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1363
1449
  } finally {
1450
+ if (!preserveSoftRequirementState) {
1451
+ softRequirementState.id = undefined;
1452
+ softRequirementState.forcedToolChoice = undefined;
1453
+ softRequirementState.escalations = 0;
1454
+ }
1364
1455
  if (deadlineTimer) {
1365
1456
  clearTimeout(deadlineTimer);
1366
1457
  }
@@ -1384,45 +1475,29 @@ async function emitHarmonyAudit(
1384
1475
  );
1385
1476
  }
1386
1477
 
1387
- /**
1388
- * Stream an assistant response from the LLM.
1389
- * This is where AgentMessage[] gets transformed to Message[] for the LLM.
1390
- */
1391
- async function streamAssistantResponse(
1478
+ interface PreparedProviderCall {
1479
+ model: Model;
1480
+ context: Context;
1481
+ promptToolWireTools: Context["tools"];
1482
+ ownedDialect: Dialect | undefined;
1483
+ }
1484
+
1485
+ async function prepareProviderCall(
1392
1486
  context: AgentContext,
1393
1487
  config: AgentLoopConfig,
1394
1488
  signal: AbortSignal | undefined,
1395
- stream: EventStream<AgentEvent, AgentMessage[]>,
1396
- telemetry: AgentTelemetry | undefined,
1397
- invokeAgentSpan: Span | undefined,
1398
- stepCounter: StepCounter,
1399
- streamFn?: StreamFn,
1400
- harmonyRetryAttempt = 0,
1401
- hostToolChoice?: ToolChoice,
1402
- forcedToolChoice?: ToolChoice,
1403
- ): Promise<AssistantMessage> {
1404
- // Re-resolve the model per provider call (like `getReasoning`): mid-run
1405
- // model switches — context promotion, retry fallback — must apply on the
1406
- // next call instead of the run silently finishing on the stale model
1407
- // captured at run start.
1489
+ ): Promise<PreparedProviderCall> {
1408
1490
  const model = config.getModel?.() ?? config.model;
1409
- // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1410
1491
  let messages = context.messages;
1411
1492
  if (config.transformContext) {
1412
1493
  messages = await config.transformContext(messages, signal);
1413
1494
  }
1414
1495
 
1415
- // Convert to LLM-compatible messages (AgentMessage[] → Message[])
1416
1496
  const llmMessages = await config.convertToLlm(messages);
1417
1497
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1418
-
1419
1498
  const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1420
1499
  const exampleDialect = ownedDialect ?? preferredDialect(model.id);
1421
- // Owned/in-band dialects carry the catalog in the prompt as text and send no
1422
- // native `tools`, so description pruning only applies to native tool calling.
1423
1500
  const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1424
- // Build LLM context — append-only mode caches system prompt + tools
1425
- // AND keeps an append-only message log so prior-turn bytes are stable.
1426
1501
  let llmContext: Context;
1427
1502
  if (config.appendOnlyContext) {
1428
1503
  config.appendOnlyContext.syncMessages(normalizedMessages);
@@ -1442,9 +1517,6 @@ async function streamAssistantResponse(
1442
1517
  llmContext = await config.transformProviderContext(llmContext, model);
1443
1518
  }
1444
1519
 
1445
- // Owned tool calling: take tool calls away from the provider and run them
1446
- // through the selected in-band prompt dialect. `PI_DIALECT=1` still
1447
- // force-enables GLM; `PI_DIALECT=<dialect>` force-enables that dialect.
1448
1520
  let promptToolWireTools: Context["tools"];
1449
1521
  if (ownedDialect && llmContext.tools && llmContext.tools.length > 0) {
1450
1522
  promptToolWireTools = llmContext.tools;
@@ -1455,6 +1527,29 @@ async function streamAssistantResponse(
1455
1527
  tools: undefined,
1456
1528
  };
1457
1529
  }
1530
+ return { model, context: llmContext, promptToolWireTools, ownedDialect };
1531
+ }
1532
+
1533
+ /**
1534
+ * Stream an assistant response from the LLM.
1535
+ * This is where AgentMessage[] gets transformed to Message[] for the LLM.
1536
+ */
1537
+ async function streamAssistantResponse(
1538
+ context: AgentContext,
1539
+ config: AgentLoopConfig,
1540
+ signal: AbortSignal | undefined,
1541
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1542
+ telemetry: AgentTelemetry | undefined,
1543
+ invokeAgentSpan: Span | undefined,
1544
+ stepCounter: StepCounter,
1545
+ streamFn?: StreamFn,
1546
+ harmonyRetryAttempt = 0,
1547
+ hostToolChoice?: ToolChoice,
1548
+ forcedToolChoice?: ToolChoice,
1549
+ prepared?: PreparedProviderCall,
1550
+ ): Promise<AssistantMessage> {
1551
+ const providerCall = prepared ?? (await prepareProviderCall(context, config, signal));
1552
+ const { model, context: llmContext, promptToolWireTools, ownedDialect } = providerCall;
1458
1553
 
1459
1554
  const streamFunction = streamFn || streamSimple;
1460
1555
 
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/proxy.ts CHANGED
@@ -56,12 +56,14 @@ export type ProxyAssistantMessageEvent =
56
56
  type: "done";
57
57
  reason: Extract<StopReason, "stop" | "length" | "toolUse">;
58
58
  usage: AssistantMessage["usage"];
59
+ content?: AssistantMessage["content"];
59
60
  }
60
61
  | {
61
62
  type: "error";
62
63
  reason: Extract<StopReason, "aborted" | "error">;
63
64
  errorMessage?: string;
64
65
  usage: AssistantMessage["usage"];
66
+ content?: AssistantMessage["content"];
65
67
  };
66
68
 
67
69
  export interface ProxyStreamOptions extends SimpleStreamOptions {
@@ -372,6 +374,7 @@ function processProxyEvent(
372
374
  case "done":
373
375
  partial.stopReason = proxyEvent.reason;
374
376
  partial.usage = proxyEvent.usage;
377
+ if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
375
378
  calculateCost(model, partial.usage);
376
379
  scrubPartialJson(partial);
377
380
  return { type: "done", reason: proxyEvent.reason, message: partial };
@@ -380,6 +383,7 @@ function processProxyEvent(
380
383
  partial.stopReason = proxyEvent.reason;
381
384
  partial.errorMessage = proxyEvent.errorMessage;
382
385
  partial.usage = proxyEvent.usage;
386
+ if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
383
387
  calculateCost(model, partial.usage);
384
388
  scrubPartialJson(partial);
385
389
  return { type: "error", reason: proxyEvent.reason, error: partial };
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