@oh-my-pi/pi-agent-core 17.0.9 → 17.1.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.1.0] - 2026-07-24
6
+
7
+ ### Added
8
+
9
+ - Added support for tracking Cloudflare AI Gateway cache status (hit, miss, bypass, unknown) on chat spans.
10
+
11
+ ### Changed
12
+
13
+ - Improved tool execution steering behavior: queued steering now cooperatively signals long-running, non-interruptible tools (via ToolCallContext.steeringSignal) to allow graceful early termination or backgrounding, rather than hard-aborting them.
14
+
15
+ ### Fixed
16
+
17
+ - Fixed an out-of-memory (OOM) crash caused by an infinite loop when a steer or follow-up message was queued on an agent session with an empty transcript.
18
+ - Fixed an issue where switching providers or models on a session could lose compacted history; the agent now correctly falls back to a portable local summary if the new model cannot replay the prior provider's remote-compaction payload.
19
+ - Fixed a compaction failure with Anthropic models where serializing prior assistant reasoning inside <thinking> tags triggered reasoning_extraction refusals.
20
+
5
21
  ## [17.0.8] - 2026-07-22
6
22
 
7
23
  ### Fixed
@@ -280,7 +280,7 @@ export interface CompactionPreparation {
280
280
  /** Compaction settions from settings.jsonl */
281
281
  settings: CompactionSettings;
282
282
  }
283
- export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings, compactionModels?: readonly Model[]): CompactionPreparation | undefined;
283
+ export declare function prepareCompaction(pathEntries: SessionEntry[], settings: CompactionSettings, activeModel?: Model): CompactionPreparation | undefined;
284
284
  /**
285
285
  * Generate summaries for compaction using prepared data.
286
286
  * Returns CompactionResult - SessionManager adds id/parentId when saving.
@@ -104,7 +104,9 @@ export declare const enum PiGenAIAttr {
104
104
  GatewayName = "pi.gen_ai.gateway.name",
105
105
  GatewayEndpoint = "pi.gen_ai.gateway.endpoint",
106
106
  GatewayCallId = "pi.gen_ai.gateway.call_id",
107
- GatewayRoutedTo = "pi.gen_ai.gateway.routed_to"
107
+ GatewayRoutedTo = "pi.gen_ai.gateway.routed_to",
108
+ /** Cloudflare AI Gateway response-cache status (`cf-aig-cache-status`), never prompt-cache. */
109
+ GatewayResponseCacheStatus = "pi.gen_ai.gateway.response_cache.status"
108
110
  }
109
111
  /** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
110
112
  export declare const GenAIOperation: {
@@ -413,6 +415,22 @@ export interface GatewayHeaderDetection {
413
415
  * {@link ProviderResponseMetadata.headers} produces.
414
416
  */
415
417
  export declare function detectGatewayFromHeaders(headers: Readonly<Record<string, string>> | undefined): GatewayHeaderDetection | undefined;
418
+ /**
419
+ * Bounded Cloudflare AI Gateway response-cache statuses emitted on
420
+ * {@link PiGenAIAttr.GatewayResponseCacheStatus}. Distinct from provider
421
+ * prompt-cache token counters (`gen_ai.usage.cache_*`).
422
+ *
423
+ * Cloudflare documents `HIT` / `MISS` on `cf-aig-cache-status`; `bypass` covers
424
+ * skip-cache / CDN-aligned BYPASS values. Any other present value is `unknown`.
425
+ */
426
+ export type GatewayResponseCacheStatus = "hit" | "miss" | "bypass" | "unknown";
427
+ /**
428
+ * Classify Cloudflare AI Gateway `cf-aig-cache-status` into a bounded
429
+ * response-cache status. Returns `undefined` when the allow-listed header is
430
+ * absent so non-Cloudflare traffic stays unaffected. Does not read TTL,
431
+ * skip-cache, custom-key, or other Cloudflare headers.
432
+ */
433
+ export declare function classifyGatewayResponseCacheStatus(headers: Readonly<Record<string, string>> | undefined): GatewayResponseCacheStatus | undefined;
416
434
  export interface ManualChatToolCallTelemetry {
417
435
  readonly toolCallId: string;
418
436
  readonly toolName: string;
@@ -399,6 +399,15 @@ export interface ToolCallContext {
399
399
  id: string;
400
400
  name: string;
401
401
  }>;
402
+ /**
403
+ * Cooperative steering signal: aborted when a queued user/steering message
404
+ * (or an interrupting peer IRC) is detected while this tool batch runs.
405
+ * Unlike the hard abort signal it NEVER kills the tool — long-running
406
+ * tools MAY observe it (via `ctx.toolCall.steeringSignal`) to finish early
407
+ * or background themselves so the message injects promptly; ignoring it is
408
+ * always safe (the message injects at the next batch boundary).
409
+ */
410
+ steeringSignal?: AbortSignal;
402
411
  }
403
412
  /** A single tool-call content block emitted by an assistant message. */
404
413
  export type AgentToolCall = Extract<AssistantMessage["content"][number], {
@@ -533,6 +542,7 @@ export type ToolLoadMode = "essential" | "discoverable";
533
542
  * - bare tier ("read" / "write" / "exec") — static classification.
534
543
  * - object form — adds a `reason` (shown in the prompt) and/or `override: true`
535
544
  * (force-prompt even in modes that would otherwise auto-approve this tier).
545
+ * `policy: "deny"` blocks the call at the approval gate.
536
546
  * - function — dynamic, given parsed args. Returns either form above.
537
547
  *
538
548
  * Omitted approvals are treated as "exec" by callers that enforce approvals.
@@ -541,6 +551,7 @@ export type ToolApprovalDecision = ToolTier | {
541
551
  tier: ToolTier;
542
552
  reason?: string;
543
553
  override?: boolean;
554
+ policy?: "allow" | "deny" | "prompt";
544
555
  };
545
556
  export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
546
557
  /**
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.0.9",
4
+ "version": "17.1.0",
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.0.9",
39
- "@oh-my-pi/pi-catalog": "17.0.9",
40
- "@oh-my-pi/pi-natives": "17.0.9",
41
- "@oh-my-pi/pi-utils": "17.0.9",
42
- "@oh-my-pi/pi-wire": "17.0.9",
43
- "@oh-my-pi/snapcompact": "17.0.9",
38
+ "@oh-my-pi/pi-ai": "17.1.0",
39
+ "@oh-my-pi/pi-catalog": "17.1.0",
40
+ "@oh-my-pi/pi-natives": "17.1.0",
41
+ "@oh-my-pi/pi-utils": "17.1.0",
42
+ "@oh-my-pi/pi-wire": "17.1.0",
43
+ "@oh-my-pi/snapcompact": "17.1.0",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -1817,13 +1817,18 @@ async function executeToolCalls(
1817
1817
  const shouldInterruptImmediately = interruptMode !== "wait";
1818
1818
  const steeringAbortController = new AbortController();
1819
1819
  const ircAbortController = new AbortController();
1820
- // Interruptible tools observe steering + external + IRC aborts; every other
1821
- // tool only sees steering + external, so an IRC-only interrupt never kills a
1822
- // partially side-effecting foreground tool (e.g. `bash`) running alongside a
1823
- // pure wait (e.g. `job` poll).
1824
- const nonInterruptibleSignal: AbortSignal = signal
1825
- ? AbortSignal.any([signal, steeringAbortController.signal])
1826
- : steeringAbortController.signal;
1820
+ // Cooperative channel: aborted when queued steering (or an interrupting
1821
+ // peer IRC) is detected mid-batch. Tools receive it via tool context
1822
+ // (`ctx.steeringSignal`) and MAY react e.g. an auto-backgroundable bash
1823
+ // backgrounds itself so the message injects promptly — but it never kills
1824
+ // anything; ignoring it is always safe.
1825
+ const steeringSoftController = new AbortController();
1826
+ // Interruptible tools (pure waits: hub wait, vibe) observe steering +
1827
+ // external + IRC aborts. Every other tool sees ONLY the external signal:
1828
+ // neither queued steering nor a peer IRC ever hard-kills a partially
1829
+ // side-effecting foreground tool (e.g. `bash`) — those get the cooperative
1830
+ // steeringSignal above, and the message injects at the next boundary.
1831
+ const nonInterruptibleSignal: AbortSignal = signal ?? new AbortController().signal;
1827
1832
  const interruptibleSignal: AbortSignal = signal
1828
1833
  ? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
1829
1834
  : AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
@@ -1889,13 +1894,16 @@ async function executeToolCalls(
1889
1894
  }
1890
1895
  }
1891
1896
  if (steeringQueued) {
1892
- // Queued steering upgrades an in-flight IRC interrupt: it aborts the
1893
- // shared signal so foreground tools stop as they do for a user Esc.
1897
+ // Queued steering hard-aborts only interruptible waits and raises the
1898
+ // cooperative soft signal for everything else: the boundary dequeue
1899
+ // below injects the message as soon as running tools finish (or
1900
+ // background themselves), and not-yet-started tools are skipped.
1894
1901
  // Idempotent — a second steer poll after the abort is a no-op.
1895
1902
  if (!steeringAbortController.signal.aborted) {
1896
1903
  interruptState.triggered = true;
1897
1904
  interruptState.source = steeringSource ?? "unknown";
1898
1905
  steeringAbortController.abort();
1906
+ steeringSoftController.abort();
1899
1907
  }
1900
1908
  return;
1901
1909
  }
@@ -1903,11 +1911,13 @@ async function executeToolCalls(
1903
1911
  // must not re-abort, and (unlike steering above) never re-consume a queue.
1904
1912
  if (interruptState.triggered) return;
1905
1913
  if (hasIrcInterrupts && (await hasIrcInterrupts())) {
1906
- // Peer IRC only aborts interruptible waits: a foreground bash / write
1907
- // mid-execution keeps running so we never leave partial side effects.
1914
+ // Peer IRC hard-aborts interruptible waits only; foreground tools keep
1915
+ // running (no partial side effects) but get the cooperative soft
1916
+ // signal so backgroundable work can step aside for the peer message.
1908
1917
  interruptState.triggered = true;
1909
1918
  interruptState.source = "irc";
1910
1919
  ircAbortController.abort();
1920
+ steeringSoftController.abort();
1911
1921
  }
1912
1922
  };
1913
1923
 
@@ -2090,12 +2100,17 @@ async function executeToolCalls(
2090
2100
  : effectiveArgs;
2091
2101
  record.args = executionArgs;
2092
2102
 
2103
+ // The cooperative steering signal rides the loop-owned
2104
+ // ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
2105
+ // AgentToolContext itself is app-built via declaration merging, so
2106
+ // the loop cannot construct or extend one structurally.
2093
2107
  const toolContext = getToolContext
2094
2108
  ? getToolContext({
2095
2109
  batchId,
2096
2110
  index,
2097
2111
  total: toolCalls.length,
2098
2112
  toolCalls: toolCallInfos,
2113
+ steeringSignal: steeringSoftController.signal,
2099
2114
  })
2100
2115
  : undefined;
2101
2116
  const rawResult = await tool.execute(
@@ -2237,16 +2252,14 @@ async function executeToolCalls(
2237
2252
  }
2238
2253
  }
2239
2254
 
2240
- // While an interruptible tool call is in flight (e.g. a `hub` wait blocking
2241
- // on external work), queued steering or interrupting IRC would otherwise
2242
- // wait out the tool's own window. Poll only non-consuming queues and abort
2243
- // the shared tool signal so the boundary dequeue below injects the message
2244
- // promptly. Gated on immediate-interrupt mode + an interruptible call;
2245
- // checkSteering is idempotent (no-op once triggered).
2255
+ // While tool calls are in flight, queued steering or interrupting IRC would
2256
+ // otherwise wait out the tools' own window. Poll only non-consuming queues:
2257
+ // detection hard-aborts interruptible waits, soft-signals cooperative tools
2258
+ // (auto-background bash), and skips not-yet-started tools, so the boundary
2259
+ // dequeue below injects the message promptly. Gated on immediate-interrupt
2260
+ // mode; checkSteering is idempotent (no-op once triggered).
2246
2261
  const watchSteeringWhileRunning =
2247
- shouldInterruptImmediately &&
2248
- (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined) &&
2249
- records.some(record => record.interruptible);
2262
+ shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined);
2250
2263
  const steeringWatchTimer = watchSteeringWhileRunning
2251
2264
  ? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
2252
2265
  : undefined;
package/src/agent.ts CHANGED
@@ -1047,6 +1047,22 @@ export class Agent {
1047
1047
 
1048
1048
  const messages = this.#state.messages;
1049
1049
  if (messages.length === 0) {
1050
+ // An empty transcript has nothing to resume, but a queued steer/follow-up
1051
+ // must still be delivered as the opening turn — mirroring the assistant-tail
1052
+ // branch below. Throwing here leaves the message undeliverable, and idle-drain
1053
+ // callers (AgentSession#scheduleQueuedMessageDrain) re-arm continue() on every
1054
+ // microtask because hasQueuedMessages() never clears, spinning an unbounded
1055
+ // allocation loop until OOM (issue #6344).
1056
+ const queuedSteering = this.#dequeueSteeringMessages();
1057
+ if (queuedSteering.length > 0) {
1058
+ await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true });
1059
+ return;
1060
+ }
1061
+ const queuedFollowUp = this.#dequeueFollowUpMessages();
1062
+ if (queuedFollowUp.length > 0) {
1063
+ await this.#runLoop(queuedFollowUp);
1064
+ return;
1065
+ }
1050
1066
  throw new Error("No messages to continue from");
1051
1067
  }
1052
1068
  if (messages[messages.length - 1].role === "assistant") {
@@ -1130,33 +1130,37 @@ export interface CompactionPreparation {
1130
1130
  }
1131
1131
 
1132
1132
  /**
1133
- * Whether a prior compaction's preserve data can be carried forward by the
1134
- * upcoming compaction. A local compaction (no remote preserve) always can it
1135
- * holds a real textual summary. A remote compaction (V2 or V1) only can when
1136
- * some candidate model shares its provider AND remote replay is still enabled;
1137
- * otherwise its provider-native replay is dead weight and only the opaque
1138
- * placeholder summary survives, so the caller must re-expand the originals.
1133
+ * Whether a prior remote compaction's provider-native replay can still be read
1134
+ * by the active model the model that assembles the request context on every
1135
+ * turn. A local compaction (no remote preserve) always can: it holds a real
1136
+ * textual summary. A remote compaction (V2 or V1) only can when the active model
1137
+ * shares the blob's provider AND remote replay is still enabled; otherwise the
1138
+ * active model's encoder drops the payload (see `getOpenAIResponsesHistoryPayload`)
1139
+ * and only the opaque placeholder summary survives, so the caller must re-expand
1140
+ * the originals into a portable local summary rather than strand that history.
1141
+ *
1142
+ * Judged against the ACTIVE model, not the compaction candidate set: a role
1143
+ * model (e.g. `modelRoles.smol`) that still maps to the blob's provider does not
1144
+ * let the active model replay it, so keying reuse on "any candidate shares the
1145
+ * provider" left a provider-switched session permanently context-less (#6343).
1139
1146
  */
1140
- function remotePreserveReusableByAny(
1147
+ function remotePreserveReusable(
1141
1148
  preserveData: Record<string, unknown> | undefined,
1142
- models: readonly Model[],
1149
+ activeModel: Model,
1143
1150
  settings: CompactionSettings,
1144
1151
  ): boolean {
1145
1152
  const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
1146
1153
  if (!remote) return true;
1147
1154
  if (settings.remoteEnabled === false) return false;
1148
- for (const model of models) {
1149
- if (remote.provider !== model.provider) continue;
1150
- const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model);
1151
- if (v2Ok || shouldUseOpenAiRemoteCompaction(model)) return true;
1152
- }
1153
- return false;
1155
+ if (remote.provider !== activeModel.provider) return false;
1156
+ const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(activeModel);
1157
+ return v2Ok || shouldUseOpenAiRemoteCompaction(activeModel);
1154
1158
  }
1155
1159
 
1156
1160
  export function prepareCompaction(
1157
1161
  pathEntries: SessionEntry[],
1158
1162
  settings: CompactionSettings,
1159
- compactionModels: readonly Model[] = [],
1163
+ activeModel?: Model,
1160
1164
  ): CompactionPreparation | undefined {
1161
1165
  if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
1162
1166
  return undefined;
@@ -1165,13 +1169,13 @@ export function prepareCompaction(
1165
1169
  let prevCompactionIndex = -1;
1166
1170
  for (let i = pathEntries.length - 1; i >= 0; i--) {
1167
1171
  if (pathEntries[i].type !== "compaction") continue;
1168
- // Skip a prior remote compaction (V2 or V1) whose provider-native replay
1169
- // none of the upcoming compaction candidates can reuse: its summary is only
1170
- // an opaque placeholder, so re-expand its original messages and summarize
1171
- // them locally rather than stranding that history. compact() still reuses it
1172
- // when a candidate can (same provider, remote enabled).
1172
+ // Skip a prior remote compaction (V2 or V1) whose provider-native replay the
1173
+ // active model cannot read: its summary is only an opaque placeholder, so
1174
+ // re-expand its original messages and summarize them locally rather than
1175
+ // stranding that history. compact() still reuses the payload when the active
1176
+ // model can replay it (same provider, remote enabled).
1173
1177
  const entry = pathEntries[i] as CompactionEntry;
1174
- if (compactionModels.length > 0 && !remotePreserveReusableByAny(entry.preserveData, compactionModels, settings)) {
1178
+ if (activeModel && !remotePreserveReusable(entry.preserveData, activeModel, settings)) {
1175
1179
  continue;
1176
1180
  }
1177
1181
  prevCompactionIndex = i;
@@ -1502,7 +1506,7 @@ export async function compact(
1502
1506
  // summarization so a successful remote compaction never pays for a second,
1503
1507
  // redundant LLM round. If a LATER compaction cannot reuse this payload,
1504
1508
  // prepareCompaction re-expands the original messages and summarizes them
1505
- // locally then (see remotePreserveReusableByAny).
1509
+ // locally then (see remotePreserveReusable).
1506
1510
  const usedTokens = getCompactionV2PreserveData(preserveData)?.usedTokens ?? 0;
1507
1511
  summary =
1508
1512
  "Remote compaction preserved provider-native history for this session." +
@@ -234,10 +234,21 @@ export function serializeConversation(messages: Message[], dialect?: Dialect): s
234
234
  }
235
235
  }
236
236
  if (dialect) {
237
+ // Claude's classifier refuses inputs that reproduce the model's own
238
+ // reasoning as text ("reasoning_extraction"), and the anthropic dialect
239
+ // otherwise renders thinking verbatim inside <thinking> tags. Reasoning is
240
+ // ephemeral and low-signal for a summary, so drop it from Anthropic-target
241
+ // summary input. Other dialects (e.g. Harmony) carry reasoning natively in
242
+ // their transcript format and keep it.
243
+ const dropThinking = dialect === "anthropic";
237
244
  const processed: Message[] = [];
238
245
  for (const msg of messages) {
239
246
  if (msg.role === "assistant") {
240
- const content = msg.content.filter(block => block.type !== "toolCall" || !uselessCallIds.has(block.id));
247
+ const content = msg.content.filter(
248
+ block =>
249
+ (block.type !== "toolCall" || !uselessCallIds.has(block.id)) &&
250
+ (!dropThinking || block.type !== "thinking"),
251
+ );
241
252
  if (content.length > 0) processed.push(content.length === msg.content.length ? msg : { ...msg, content });
242
253
  continue;
243
254
  }
package/src/telemetry.ts CHANGED
@@ -157,6 +157,8 @@ export const enum PiGenAIAttr {
157
157
  GatewayEndpoint = "pi.gen_ai.gateway.endpoint",
158
158
  GatewayCallId = "pi.gen_ai.gateway.call_id",
159
159
  GatewayRoutedTo = "pi.gen_ai.gateway.routed_to",
160
+ /** Cloudflare AI Gateway response-cache status (`cf-aig-cache-status`), never prompt-cache. */
161
+ GatewayResponseCacheStatus = "pi.gen_ai.gateway.response_cache.status",
160
162
  }
161
163
 
162
164
  /** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
@@ -1278,17 +1280,56 @@ export function detectGatewayFromHeaders(
1278
1280
  return undefined;
1279
1281
  }
1280
1282
 
1283
+ /**
1284
+ * Bounded Cloudflare AI Gateway response-cache statuses emitted on
1285
+ * {@link PiGenAIAttr.GatewayResponseCacheStatus}. Distinct from provider
1286
+ * prompt-cache token counters (`gen_ai.usage.cache_*`).
1287
+ *
1288
+ * Cloudflare documents `HIT` / `MISS` on `cf-aig-cache-status`; `bypass` covers
1289
+ * skip-cache / CDN-aligned BYPASS values. Any other present value is `unknown`.
1290
+ */
1291
+ export type GatewayResponseCacheStatus = "hit" | "miss" | "bypass" | "unknown";
1292
+
1293
+ /**
1294
+ * Classify Cloudflare AI Gateway `cf-aig-cache-status` into a bounded
1295
+ * response-cache status. Returns `undefined` when the allow-listed header is
1296
+ * absent so non-Cloudflare traffic stays unaffected. Does not read TTL,
1297
+ * skip-cache, custom-key, or other Cloudflare headers.
1298
+ */
1299
+ export function classifyGatewayResponseCacheStatus(
1300
+ headers: Readonly<Record<string, string>> | undefined,
1301
+ ): GatewayResponseCacheStatus | undefined {
1302
+ if (!headers) return undefined;
1303
+ const raw = headers["cf-aig-cache-status"];
1304
+ if (raw == null) return undefined;
1305
+ switch (raw.trim().toLowerCase()) {
1306
+ case "hit":
1307
+ return "hit";
1308
+ case "miss":
1309
+ return "miss";
1310
+ case "bypass":
1311
+ return "bypass";
1312
+ default:
1313
+ return "unknown";
1314
+ }
1315
+ }
1316
+
1281
1317
  function applyGatewayAttributes(
1282
1318
  span: Span,
1283
1319
  headers: Readonly<Record<string, string>> | undefined,
1284
1320
  baseUrl: string | undefined,
1285
1321
  ): void {
1286
1322
  const gateway = detectGatewayFromHeaders(headers);
1287
- if (!gateway) return;
1288
- span.setAttribute(PiGenAIAttr.GatewayName, gateway.name);
1289
- if (baseUrl) span.setAttribute(PiGenAIAttr.GatewayEndpoint, baseUrl);
1290
- if (gateway.callId) span.setAttribute(PiGenAIAttr.GatewayCallId, gateway.callId);
1291
- if (gateway.routedTo) span.setAttribute(PiGenAIAttr.GatewayRoutedTo, gateway.routedTo);
1323
+ if (gateway) {
1324
+ span.setAttribute(PiGenAIAttr.GatewayName, gateway.name);
1325
+ if (baseUrl) span.setAttribute(PiGenAIAttr.GatewayEndpoint, baseUrl);
1326
+ if (gateway.callId) span.setAttribute(PiGenAIAttr.GatewayCallId, gateway.callId);
1327
+ if (gateway.routedTo) span.setAttribute(PiGenAIAttr.GatewayRoutedTo, gateway.routedTo);
1328
+ }
1329
+ const responseCacheStatus = classifyGatewayResponseCacheStatus(headers);
1330
+ if (responseCacheStatus) {
1331
+ span.setAttribute(PiGenAIAttr.GatewayResponseCacheStatus, responseCacheStatus);
1332
+ }
1292
1333
  }
1293
1334
 
1294
1335
  interface AppliedCostEstimate {
package/src/types.ts CHANGED
@@ -458,6 +458,15 @@ export interface ToolCallContext {
458
458
  index: number;
459
459
  total: number;
460
460
  toolCalls: Array<{ id: string; name: string }>;
461
+ /**
462
+ * Cooperative steering signal: aborted when a queued user/steering message
463
+ * (or an interrupting peer IRC) is detected while this tool batch runs.
464
+ * Unlike the hard abort signal it NEVER kills the tool — long-running
465
+ * tools MAY observe it (via `ctx.toolCall.steeringSignal`) to finish early
466
+ * or background themselves so the message injects promptly; ignoring it is
467
+ * always safe (the message injects at the next batch boundary).
468
+ */
469
+ steeringSignal?: AbortSignal;
461
470
  }
462
471
 
463
472
  /** A single tool-call content block emitted by an assistant message. */
@@ -610,11 +619,14 @@ export type ToolLoadMode = "essential" | "discoverable";
610
619
  * - bare tier ("read" / "write" / "exec") — static classification.
611
620
  * - object form — adds a `reason` (shown in the prompt) and/or `override: true`
612
621
  * (force-prompt even in modes that would otherwise auto-approve this tier).
622
+ * `policy: "deny"` blocks the call at the approval gate.
613
623
  * - function — dynamic, given parsed args. Returns either form above.
614
624
  *
615
625
  * Omitted approvals are treated as "exec" by callers that enforce approvals.
616
626
  */
617
- export type ToolApprovalDecision = ToolTier | { tier: ToolTier; reason?: string; override?: boolean };
627
+ export type ToolApprovalDecision =
628
+ | ToolTier
629
+ | { tier: ToolTier; reason?: string; override?: boolean; policy?: "allow" | "deny" | "prompt" };
618
630
  export type ToolApproval = ToolApprovalDecision | ((args: unknown) => ToolApprovalDecision);
619
631
 
620
632
  /**