@oh-my-pi/pi-agent-core 18.16.0 → 18.17.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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.17.0] - 2026-11-06
6
+
7
+ ### Added
8
+
9
+ - Added `Agent.queuedMessageGate` to filter queued messages before delivery and observe claimed messages returned undelivered.
10
+ - Added `wireToolsFingerprint()`, returning a hash of the tool specs sent with a request.
11
+
5
12
  ## [18.16.0] - 2026-11-02
6
13
 
7
14
  ### Changed
@@ -102,6 +102,8 @@ export declare function normalizeMessagesForProvider(messages: Context["messages
102
102
  export interface NormalizeToolsOptions {
103
103
  /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
104
104
  injectIntent: boolean;
105
+ /** Schema description of the injected `i` field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
106
+ intentDescription?: string;
105
107
  /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
106
108
  pruneDescriptions?: boolean;
107
109
  }
@@ -4,7 +4,7 @@ import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context.js";
5
5
  import { SentToolDefinitions } from "./sent-tool-definitions.js";
6
6
  import { Tokenizer } from "./tokenizer.js";
7
- import type { AgentBeforeModelCall, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideBoundary, AsideMessage, PrepareQueuedMessages, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
7
+ import type { AgentBeforeModelCall, AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideBoundary, AsideMessage, PrepareQueuedMessages, QueuedMessageGate, SpeculativeToolExecutionConfig, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
8
8
  export declare class AgentBusyError extends Error {
9
9
  constructor(message?: string);
10
10
  }
@@ -157,6 +157,8 @@ export interface AgentOptions {
157
157
  suggestFallbackToolNames?: () => Iterable<string>;
158
158
  /** Enable intent tracing schema injection/stripping in the harness. */
159
159
  intentTracing?: boolean;
160
+ /** Schema description of the injected intent field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
161
+ intentDescription?: string;
160
162
  /**
161
163
  * Strip tool descriptions from provider-bound tool specs (top-level + nested
162
164
  * schema annotations). Use when the full catalog is rendered into the system
@@ -252,6 +254,12 @@ export declare class Agent {
252
254
  getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
253
255
  /** Prepare actual queue deliveries after dequeue gates; commit runs only while ownership remains valid. */
254
256
  prepareQueuedMessages?: PrepareQueuedMessages;
257
+ /**
258
+ * Consulted synchronously whenever queued messages are removed for delivery and
259
+ * told about claimed messages that went back undelivered (see {@link QueuedMessageGate}).
260
+ * Unset, batches are taken as they are.
261
+ */
262
+ queuedMessageGate?: QueuedMessageGate;
255
263
  /**
256
264
  * Hook invoked after tool arguments are validated and before execution.
257
265
  * Reassign at any time to swap the implementation (e.g. on extension reload).
@@ -405,6 +413,8 @@ export declare class Agent {
405
413
  /** Inject/strip the intent field on tool calls; applies from the next prompt run. */
406
414
  get intentTracing(): boolean;
407
415
  set intentTracing(value: boolean);
416
+ /** The intent field's schema description this agent was built with (`PI_INTENT_DESC` still overrides it). */
417
+ get intentDescription(): string | undefined;
408
418
  /** Abort the provider request on a fabricated tool result; applies from the next prompt run. */
409
419
  get abortOnFabricatedToolResult(): boolean | undefined;
410
420
  set abortOnFabricatedToolResult(value: boolean | undefined);
@@ -25,6 +25,8 @@ export interface StablePrefixSnapshot {
25
25
  export interface BuildOptions {
26
26
  /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
27
27
  intentTracing: boolean;
28
+ /** Schema description of the injected `i` field (must match agent-loop's normalizeTools). */
29
+ intentDescription?: string;
28
30
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
29
31
  pruneToolDescriptions?: boolean;
30
32
  }
@@ -125,3 +127,10 @@ export declare class AppendOnlyContextManager {
125
127
  invalidate(): void;
126
128
  reset(context: AgentContext, options: BuildOptions): void;
127
129
  }
130
+ /**
131
+ * Fingerprint of the tool specs a request built with `options` sends: the
132
+ * tools half of the prefix fingerprint, for a caller that compares tool sets
133
+ * across requests. A same-name tool whose description, schema, examples,
134
+ * `strict`, `customFormat` or `customWireName` changed makes another one.
135
+ */
136
+ export declare function wireToolsFingerprint(tools: AgentContext["tools"], options: BuildOptions): string;
@@ -558,7 +558,9 @@ export interface InstrumentedChatSpanOptions {
558
558
  * Short-circuits when `telemetry` is `undefined` so cost / overhead stays at
559
559
  * zero for installations without an OTEL SDK.
560
560
  */
561
- export declare function instrumentedCompleteSimple<TApi extends Api>(model: Model<TApi>, ctx: Context, options: SimpleStreamOptions, span: InstrumentedChatSpanOptions): Promise<AssistantMessage>;
561
+ export declare function instrumentedCompleteSimple<TApi extends Api>(model: Model<TApi>, ctx: Context, options: SimpleStreamOptions & {
562
+ onAttempt?: (message: AssistantMessage) => void;
563
+ }, span: InstrumentedChatSpanOptions): Promise<AssistantMessage>;
562
564
  /**
563
565
  * Start an `execute_tool` span representing one tool invocation. Parented
564
566
  * under the supplied `invoke_agent` span by default — pass `parent` to
@@ -16,6 +16,21 @@ export interface QueuedMessagePreparation {
16
16
  export type QueuedMessageQueue = "steering" | "followUp";
17
17
  /** Prepare an exclusively claimed batch. Undefined delivers unchanged; the signal also aborts when the claim is cancelled. */
18
18
  export type PrepareQueuedMessages = (messages: readonly AgentMessage[], signal: AbortSignal, queue: QueuedMessageQueue) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
19
+ /**
20
+ * Synchronous arbiter over queued messages leaving a queue for delivery, for a
21
+ * queue whose messages someone else may withdraw concurrently (a session homed
22
+ * on another thread whose view pops them).
23
+ */
24
+ export interface QueuedMessageGate {
25
+ /**
26
+ * Called with the batch about to be removed from `queue` for delivery; returns
27
+ * the candidates that must be dropped from the queue instead. The others are
28
+ * claimed for delivery.
29
+ */
30
+ claim(queue: QueuedMessageQueue, candidates: readonly AgentMessage[]): readonly AgentMessage[];
31
+ /** Called with claimed messages that left delivery undelivered (requeued, withdrawn, or cancelled). */
32
+ release(messages: readonly AgentMessage[]): void;
33
+ }
19
34
  /** Called once an aside has been inserted into the agent's live context. */
20
35
  export declare const ASIDE_MESSAGE_COMMIT: unique symbol;
21
36
  /** Symbol-keyed handoff for one finalized, tool-owned stream speculation session. */
@@ -218,6 +233,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
218
233
  * never consumes the queue.
219
234
  */
220
235
  getSteeringMessages?: (signal?: AbortSignal) => Promise<AgentMessage[]>;
236
+ /** Whether input is still owned by a queue and must be restored if a model call is aborted before it starts. */
237
+ isUndeliveredQueuedMessage?: (message: AgentMessage) => boolean;
221
238
  /**
222
239
  * Peeks whether steering messages are queued, without consuming them.
223
240
  *
@@ -390,6 +407,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
390
407
  * then strips from arguments before executing tools.
391
408
  */
392
409
  intentTracing?: boolean;
410
+ /** Schema description of the injected intent field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
411
+ intentDescription?: string;
393
412
  /**
394
413
  * Strip tool descriptions (top-level + nested schema annotations) from the
395
414
  * provider-bound tool specs. Use when the full catalog is rendered into the
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": "18.16.0",
4
+ "version": "18.17.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -31,25 +31,25 @@
31
31
  "types": "./dist/types/index.d.ts",
32
32
  "scripts": {
33
33
  "check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
34
- "check:types": "tsgo -p tsconfig.json --noEmit",
34
+ "check:types": "bun check --no-script -p tsconfig.json",
35
35
  "lint": "oxlint .",
36
36
  "test": "bun test --parallel",
37
37
  "fix": "oxlint --fix --fix-suggestions . && bun run fmt",
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.16.0",
42
- "@oh-my-pi/pi-catalog": "18.16.0",
43
- "@oh-my-pi/pi-natives": "18.16.0",
44
- "@oh-my-pi/pi-utils": "18.16.0",
45
- "@oh-my-pi/pi-wire": "18.16.0",
46
- "@oh-my-pi/snapcompact": "18.16.0",
41
+ "@oh-my-pi/pi-ai": "18.17.0",
42
+ "@oh-my-pi/pi-catalog": "18.17.0",
43
+ "@oh-my-pi/pi-natives": "18.17.0",
44
+ "@oh-my-pi/pi-utils": "18.17.0",
45
+ "@oh-my-pi/pi-wire": "18.17.0",
46
+ "@oh-my-pi/snapcompact": "18.17.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.16.0",
51
- "@opentelemetry/context-async-hooks": "^2.9.0",
52
- "@opentelemetry/sdk-trace-base": "^2.9.0",
50
+ "@oh-my-pi/omptype": "18.17.0",
51
+ "@opentelemetry/context-async-hooks": "2.9.0",
52
+ "@opentelemetry/sdk-trace-base": "2.9.0",
53
53
  "@types/bun": "^1.3.14"
54
54
  },
55
55
  "engines": {
package/src/agent-loop.ts CHANGED
@@ -943,7 +943,7 @@ export function normalizeMessagesForProvider(
943
943
  });
944
944
  }
945
945
 
946
- /** Schema description of the injected `i` field; `PI_INTENT_DESC` replaces it. */
946
+ /** Schema description of the injected `i` field when neither `PI_INTENT_DESC` nor the caller sets one. */
947
947
  const DEFAULT_INTENT_FIELD_DESCRIPTION = "concise intent";
948
948
  const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
949
949
 
@@ -954,26 +954,30 @@ const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
954
954
  // the whole catalog per request). The injected object is shared across
955
955
  // requests — the same profile as the intent-off path, where parameters IS the
956
956
  // shared memoized wire schema (see schema-immutability.test.ts). One stamp
957
- // key per (mode, describeIntent) variant; index 0 = bare, 1 = described.
958
- const INTENT_STAMPS = {
959
- require: [Symbol("intent:require"), Symbol("intent:require:described")],
960
- optional: [Symbol("intent:optional"), Symbol("intent:optional:described")],
961
- } as const;
957
+ // key per (mode, description) variant, "" keying the bare one: agents in one
958
+ // process may describe `i` differently over the same shared tool schemas.
959
+ const INTENT_STAMPS = { require: new Map<string, symbol>(), optional: new Map<string, symbol>() } as const;
962
960
 
963
961
  function memoizedInjectIntentIntoSchema(
964
962
  schema: Record<string, unknown>,
965
963
  mode: "require" | "optional",
966
- describeIntent: boolean,
964
+ description: string | undefined,
967
965
  ): unknown {
968
- return stamp(schema, INTENT_STAMPS[mode][describeIntent ? 1 : 0], host =>
969
- injectIntentIntoSchema(host, mode, describeIntent),
970
- );
966
+ const stamps = INTENT_STAMPS[mode];
967
+ const variant = description ?? "";
968
+ let key = stamps.get(variant);
969
+ if (!key) {
970
+ key = Symbol(`intent:${mode}`);
971
+ stamps.set(variant, key);
972
+ }
973
+ return stamp(schema, key, host => injectIntentIntoSchema(host, mode, description));
971
974
  }
972
975
 
976
+ /** `schema` with the `i` field first (and required in `require` mode); `description` unset injects it bare. */
973
977
  function injectIntentIntoSchema(
974
978
  schema: unknown,
975
- mode: "require" | "optional" = "require",
976
- describeIntent = true,
979
+ mode: "require" | "optional",
980
+ description: string | undefined,
977
981
  ): unknown {
978
982
  if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
979
983
  const schemaRecord = schema as Record<string, unknown>;
@@ -996,7 +1000,7 @@ function injectIntentIntoSchema(
996
1000
  if (!Array.isArray(variants)) continue;
997
1001
  return {
998
1002
  ...schemaRecord,
999
- [key]: variants.map(variant => injectIntentIntoSchema(variant, mode, describeIntent)),
1003
+ [key]: variants.map(variant => injectIntentIntoSchema(variant, mode, description)),
1000
1004
  };
1001
1005
  }
1002
1006
  }
@@ -1020,9 +1024,7 @@ function injectIntentIntoSchema(
1020
1024
  return {
1021
1025
  ...schemaRecord,
1022
1026
  properties: {
1023
- [INTENT_FIELD]: describeIntent
1024
- ? { type: "string", description: Bun.env.PI_INTENT_DESC || DEFAULT_INTENT_FIELD_DESCRIPTION }
1025
- : { type: "string" },
1027
+ [INTENT_FIELD]: description === undefined ? { type: "string" } : { type: "string", description },
1026
1028
  ...properties,
1027
1029
  },
1028
1030
  ...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}),
@@ -1032,6 +1034,8 @@ function injectIntentIntoSchema(
1032
1034
  export interface NormalizeToolsOptions {
1033
1035
  /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
1034
1036
  injectIntent: boolean;
1037
+ /** Schema description of the injected `i` field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
1038
+ intentDescription?: string;
1035
1039
  /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
1036
1040
  pruneDescriptions?: boolean;
1037
1041
  }
@@ -1039,6 +1043,7 @@ export interface NormalizeToolsOptions {
1039
1043
  export function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"] {
1040
1044
  const pruneDescriptions = options.pruneDescriptions === true;
1041
1045
  const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
1046
+ const intentDescription = Bun.env.PI_INTENT_DESC || options.intentDescription || DEFAULT_INTENT_FIELD_DESCRIPTION;
1042
1047
  return tools?.map(t => {
1043
1048
  const intentMode = resolveIntentMode(t.intent);
1044
1049
  const doInjectIntent =
@@ -1047,17 +1052,19 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
1047
1052
  // specs without their descriptions (top-level + nested schema annotations)
1048
1053
  // so they are not duplicated on the wire. Strip the STABLE wire schema (the
1049
1054
  // memoized `stripSchemaDescriptions` result is reused across requests), then
1050
- // re-inject `i` (without its hint, which `describeIntent: false` omits) so
1055
+ // re-inject `i` (without its hint, which an unset description omits) so
1051
1056
  // intent tracing keeps the field while no descriptions ride the wire.
1052
1057
  if (pruneDescriptions) {
1053
1058
  const stripped = stripSchemaDescriptions(toolWireSchema(t));
1054
1059
  const parameters = (
1055
- doInjectIntent ? memoizedInjectIntentIntoSchema(stripped, intentMode, false) : stripped
1060
+ doInjectIntent ? memoizedInjectIntentIntoSchema(stripped, intentMode, undefined) : stripped
1056
1061
  ) as TSchema;
1057
1062
  return { ...t, parameters, description: "" };
1058
1063
  }
1059
1064
  const wire = toolWireSchema(t);
1060
- const parameters = (doInjectIntent ? memoizedInjectIntentIntoSchema(wire, intentMode, true) : wire) as TSchema;
1065
+ const parameters = (
1066
+ doInjectIntent ? memoizedInjectIntentIntoSchema(wire, intentMode, intentDescription) : wire
1067
+ ) as TSchema;
1061
1068
  const description = t.description ?? "";
1062
1069
  const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
1063
1070
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
@@ -1240,6 +1247,25 @@ async function runLoopBody(
1240
1247
  // `liveAccepted` reached the model inside it, `liveDeferred` did not.
1241
1248
  let liveAccepted: AgentMessage[] = [];
1242
1249
  let liveDeferred: AgentMessage[] = [];
1250
+ const endAbortedBeforeModel = async (messages: AgentMessage[], turnOpen: boolean): Promise<boolean> => {
1251
+ if (!signal?.aborted || !config.isUndeliveredQueuedMessage) return false;
1252
+ const undelivered = new Set(messages.filter(message => config.isUndeliveredQueuedMessage?.(message)));
1253
+ for (const list of [currentContext.messages, newMessages]) {
1254
+ for (let i = list.length - 1; i >= 0; i--) {
1255
+ if (undelivered.has(list[i])) list.splice(i, 1);
1256
+ }
1257
+ }
1258
+ if (!turnOpen) stream.push({ type: "turn_start" });
1259
+ emitInputMessages(
1260
+ stream,
1261
+ messages.filter(message => !undelivered.has(message)),
1262
+ );
1263
+ const aborted = emitAbortedAssistantMessage(null, false, new Set(), currentContext, config, stream, signal);
1264
+ newMessages.push(aborted);
1265
+ await emitTurnEnd(stream, currentContext, aborted, [], config, signal, { willContinue: false });
1266
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1267
+ return true;
1268
+ };
1243
1269
  try {
1244
1270
  let messagesToEmit = [...initialMessages];
1245
1271
  if (isDeadlineExceeded(config.deadline)) {
@@ -1253,6 +1279,7 @@ async function runLoopBody(
1253
1279
  try {
1254
1280
  pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1255
1281
  } catch (error) {
1282
+ if (signal?.aborted && (await endAbortedBeforeModel(messagesToEmit, false))) return;
1256
1283
  stream.push({ type: "turn_start" });
1257
1284
  emitInputMessages(stream, messagesToEmit);
1258
1285
  throw error;
@@ -1351,10 +1378,19 @@ async function runLoopBody(
1351
1378
 
1352
1379
  let preparedProviderCall: PreparedProviderCall;
1353
1380
  let gateResult: AgentPreModelCallResult;
1381
+ const emitTurnInputs = () => {
1382
+ emitInputMessages(
1383
+ stream,
1384
+ signal?.aborted
1385
+ ? turnMessages.filter(message => !config.isUndeliveredQueuedMessage?.(message))
1386
+ : turnMessages,
1387
+ );
1388
+ };
1354
1389
  try {
1355
1390
  if (config.syncContextBeforeModelCall) {
1356
1391
  await config.syncContextBeforeModelCall(currentContext, signal);
1357
1392
  }
1393
+ if (signal?.aborted && (await endAbortedBeforeModel(turnMessages, turnOpen))) return;
1358
1394
 
1359
1395
  if (!directiveResolvedForTurn) {
1360
1396
  const directive = signal?.aborted ? undefined : config.getToolChoice?.();
@@ -1383,16 +1419,19 @@ async function runLoopBody(
1383
1419
  }
1384
1420
 
1385
1421
  preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1422
+ if (signal?.aborted && (await endAbortedBeforeModel(turnMessages, turnOpen))) return;
1386
1423
  preparedProviderCall.liveSteering = openLiveSteering(config, signal, preparedProviderCall);
1387
1424
  gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1388
1425
  } catch (error) {
1426
+ if (signal?.aborted && (await endAbortedBeforeModel(turnMessages, turnOpen))) return;
1389
1427
  if (!turnOpen) {
1390
1428
  stream.push({ type: "turn_start" });
1391
- emitInputMessages(stream, turnMessages);
1429
+ emitTurnInputs();
1392
1430
  turnOpen = true;
1393
1431
  }
1394
1432
  throw error;
1395
1433
  }
1434
+ if (signal?.aborted && (await endAbortedBeforeModel(turnMessages, turnOpen))) return;
1396
1435
  if (config.beforeModelCall && signal?.aborted) {
1397
1436
  gateResult = { stop: true };
1398
1437
  }
@@ -1436,13 +1475,13 @@ async function runLoopBody(
1436
1475
 
1437
1476
  if (!turnOpen) {
1438
1477
  stream.push({ type: "turn_start" });
1439
- emitInputMessages(stream, turnMessages);
1440
1478
  turnOpen = true;
1441
1479
  }
1442
1480
 
1443
1481
  // Stream assistant response
1444
1482
  let recovered: HarmonyRecoveredToolCall | undefined;
1445
1483
  let message: AssistantMessage;
1484
+ let inputsEmitted = false;
1446
1485
  try {
1447
1486
  if (agentPauseGate.paused) await waitForProcessPause(config, signal);
1448
1487
  message = await streamAssistantResponse(
@@ -1458,6 +1497,11 @@ async function runLoopBody(
1458
1497
  hostToolChoice,
1459
1498
  softRequirementState.forcedToolChoice,
1460
1499
  preparedProviderCall,
1500
+ () => {
1501
+ if (config.isUndeliveredQueuedMessage) signal?.throwIfAborted();
1502
+ emitTurnInputs();
1503
+ inputsEmitted = true;
1504
+ },
1461
1505
  message => {
1462
1506
  const finalToolCalls = message.content.filter(
1463
1507
  (content): content is Extract<AssistantMessage["content"][number], { type: "toolCall" }> =>
@@ -1484,6 +1528,10 @@ async function runLoopBody(
1484
1528
  harmonyRetryAttempt = 0;
1485
1529
  harmonyTruncateResumeCount = 0;
1486
1530
  } catch (err) {
1531
+ if (!inputsEmitted) {
1532
+ if (signal?.aborted && (await endAbortedBeforeModel(turnMessages, turnOpen))) return;
1533
+ emitTurnInputs();
1534
+ }
1487
1535
  if (!(err instanceof HarmonyLeakInterruption)) throw err;
1488
1536
  if (err.recovered) {
1489
1537
  if (harmonyTruncateResumeCount >= 2) {
@@ -1958,6 +2006,7 @@ async function prepareProviderCall(
1958
2006
  config.appendOnlyContext.syncMessages(normalizedMessages);
1959
2007
  llmContext = config.appendOnlyContext.build(context, {
1960
2008
  intentTracing: !!config.intentTracing,
2009
+ intentDescription: config.intentDescription,
1961
2010
  pruneToolDescriptions,
1962
2011
  });
1963
2012
  } else {
@@ -1966,6 +2015,7 @@ async function prepareProviderCall(
1966
2015
  messages: normalizedMessages,
1967
2016
  tools: normalizeTools(context.tools, {
1968
2017
  injectIntent: !!config.intentTracing,
2018
+ intentDescription: config.intentDescription,
1969
2019
  pruneDescriptions: pruneToolDescriptions,
1970
2020
  }),
1971
2021
  };
@@ -2012,6 +2062,7 @@ async function streamAssistantResponse(
2012
2062
  hostToolChoice?: ToolChoice,
2013
2063
  forcedToolChoice?: ToolChoice,
2014
2064
  prepared?: PreparedProviderCall,
2065
+ onProviderStart?: () => void,
2015
2066
  canDispatchFinalToolCalls?: (message: AssistantMessage) => boolean,
2016
2067
  ): Promise<AssistantMessage> {
2017
2068
  const providerCall = prepared ?? (await prepareProviderCall(context, config, signal));
@@ -2156,6 +2207,7 @@ async function streamAssistantResponse(
2156
2207
 
2157
2208
  try {
2158
2209
  return await runInActiveSpan(chatSpan, async () => {
2210
+ onProviderStart?.();
2159
2211
  let response = await streamFunction(model, llmContext, {
2160
2212
  ...config,
2161
2213
  apiKey,
package/src/agent.ts CHANGED
@@ -62,6 +62,7 @@ import type {
62
62
  AsideBoundary,
63
63
  AsideMessage,
64
64
  PrepareQueuedMessages,
65
+ QueuedMessageGate,
65
66
  QueuedMessageQueue,
66
67
  SpeculativeToolExecutionConfig,
67
68
  StreamFn,
@@ -279,6 +280,8 @@ export interface AgentOptions {
279
280
 
280
281
  /** Enable intent tracing schema injection/stripping in the harness. */
281
282
  intentTracing?: boolean;
283
+ /** Schema description of the injected intent field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
284
+ intentDescription?: string;
282
285
  /**
283
286
  * Strip tool descriptions from provider-bound tool specs (top-level + nested
284
287
  * schema annotations). Use when the full catalog is rendered into the system
@@ -493,6 +496,7 @@ export class Agent {
493
496
  #resolveFallbackTool?: (name: string, advertised: readonly AgentTool<any>[]) => AgentTool<any> | undefined;
494
497
  #suggestFallbackToolNames?: () => Iterable<string>;
495
498
  #intentTracing: boolean;
499
+ #intentDescription?: string;
496
500
  #pruneToolDescriptions: boolean;
497
501
  #dialect?: Dialect;
498
502
  #dialectResolver?: (model: Model) => Dialect | undefined;
@@ -525,6 +529,12 @@ export class Agent {
525
529
  getApiKey?: (model: Model) => Promise<ApiKey | undefined> | ApiKey | undefined;
526
530
  /** Prepare actual queue deliveries after dequeue gates; commit runs only while ownership remains valid. */
527
531
  prepareQueuedMessages?: PrepareQueuedMessages;
532
+ /**
533
+ * Consulted synchronously whenever queued messages are removed for delivery and
534
+ * told about claimed messages that went back undelivered (see {@link QueuedMessageGate}).
535
+ * Unset, batches are taken as they are.
536
+ */
537
+ queuedMessageGate?: QueuedMessageGate;
528
538
  /**
529
539
  * Hook invoked after tool arguments are validated and before execution.
530
540
  * Reassign at any time to swap the implementation (e.g. on extension reload).
@@ -602,6 +612,7 @@ export class Agent {
602
612
  this.#resolveFallbackTool = opts.resolveFallbackTool;
603
613
  this.#suggestFallbackToolNames = opts.suggestFallbackToolNames;
604
614
  this.#intentTracing = opts.intentTracing === true;
615
+ this.#intentDescription = opts.intentDescription;
605
616
  this.#pruneToolDescriptions = opts.pruneToolDescriptions === true;
606
617
  this.#dialect = opts.dialect;
607
618
  this.#dialectResolver = opts.dialectResolver;
@@ -864,6 +875,11 @@ export class Agent {
864
875
  this.#intentTracing = value;
865
876
  }
866
877
 
878
+ /** The intent field's schema description this agent was built with (`PI_INTENT_DESC` still overrides it). */
879
+ get intentDescription(): string | undefined {
880
+ return this.#intentDescription;
881
+ }
882
+
867
883
  /** Abort the provider request on a fabricated tool result; applies from the next prompt run. */
868
884
  get abortOnFabricatedToolResult(): boolean | undefined {
869
885
  return this.#abortOnFabricatedToolResult;
@@ -970,6 +986,7 @@ export class Agent {
970
986
  ? []
971
987
  : (normalizeTools(this.#toolsForModel(model), {
972
988
  injectIntent: this.#intentTracing,
989
+ intentDescription: this.#intentDescription,
973
990
  pruneDescriptions: this.#pruneToolDescriptions,
974
991
  }) ?? []);
975
992
  let context: Context = { systemPrompt, messages, tools };
@@ -1042,12 +1059,14 @@ export class Agent {
1042
1059
  const runController = this.#abortController;
1043
1060
  if (!prepare) {
1044
1061
  this.#queuedMessageDeliveries.add({ queue, controller: runController, messages, next: 0, additional: [] });
1062
+ this.#emitQueueChanged();
1045
1063
  return messages;
1046
1064
  }
1047
1065
 
1048
1066
  const claim: QueuedMessageClaim = { messages, controller: new AbortController() };
1049
1067
  this.#queuedMessageClaims[queue] = claim;
1050
1068
  const preparationSignal = AbortSignal.any([signal, claim.controller.signal]);
1069
+ this.#emitQueueChanged();
1051
1070
  try {
1052
1071
  const preparation = await prepare(messages, preparationSignal, queue);
1053
1072
  signal.throwIfAborted();
@@ -1067,6 +1086,7 @@ export class Agent {
1067
1086
  next: 0,
1068
1087
  additional: additional ?? [],
1069
1088
  });
1089
+ this.#emitQueueChanged();
1070
1090
  return additional?.length ? [...messages, ...additional] : messages;
1071
1091
  } catch (error) {
1072
1092
  if (signal.aborted) throw error;
@@ -1086,6 +1106,7 @@ export class Agent {
1086
1106
  const claim = this.#queuedMessageClaims[queue];
1087
1107
  if (!claim) return;
1088
1108
  delete this.#queuedMessageClaims[queue];
1109
+ this.queuedMessageGate?.release(claim.messages);
1089
1110
  if (restore) {
1090
1111
  if (queue === "steering") {
1091
1112
  this.#steeringQueue = [...claim.messages, ...this.#steeringQueue];
@@ -1104,6 +1125,7 @@ export class Agent {
1104
1125
  // Live steering was taken before anything the run still holds undelivered.
1105
1126
  this.#liveSteered = this.#liveSteered.filter(entry => {
1106
1127
  if (entry.controller !== controller) return true;
1128
+ if (entry.message.role === "user") delete entry.message.liveSteered;
1107
1129
  restored.steering.push(entry.message);
1108
1130
  return false;
1109
1131
  });
@@ -1114,12 +1136,14 @@ export class Agent {
1114
1136
  restored[delivery.queue].push(delivery.messages[i]);
1115
1137
  }
1116
1138
  }
1139
+ if (restored.steering.length === 0 && restored.followUp.length === 0) return;
1140
+ this.queuedMessageGate?.release([...restored.steering, ...restored.followUp]);
1117
1141
  if (restored.steering.length > 0) {
1118
1142
  this.#steeringQueue = [...restored.steering, ...this.#steeringQueue];
1119
1143
  this.#notifySteeringWaiters();
1120
1144
  }
1121
1145
  if (restored.followUp.length > 0) this.#followUpQueue = [...restored.followUp, ...this.#followUpQueue];
1122
- if (restored.steering.length > 0 || restored.followUp.length > 0) this.#emitQueueChanged();
1146
+ this.#emitQueueChanged();
1123
1147
  }
1124
1148
 
1125
1149
  /** Move steering live steering took out of the queue-delivery records into {@link #liveSteered}. */
@@ -1177,6 +1201,9 @@ export class Agent {
1177
1201
  for (const message of [...pending, ...delivery.additional]) suppress(message, delivery.controller);
1178
1202
  }
1179
1203
  this.#queuedMessageDeliveries.clear();
1204
+ if (withdrawn.steering.length > 0 || withdrawn.followUp.length > 0) {
1205
+ this.queuedMessageGate?.release([...withdrawn.steering, ...withdrawn.followUp]);
1206
+ }
1180
1207
  if (hadLiveSteered) this.#emitQueueChanged();
1181
1208
  return withdrawn;
1182
1209
  }
@@ -1336,11 +1363,9 @@ export class Agent {
1336
1363
  this.#state.messages = ms.slice();
1337
1364
  }
1338
1365
 
1339
- /** Signal that the steering/follow-up queue contents may have changed. Every
1340
- * queue mutator below calls this once after mutating, so external layers
1341
- * (AgentSession's `queue_update` coalescing) have a single seam to observe
1342
- * enqueue, dequeue-on-delivery, clear, and restore without emits scattered
1343
- * across every call site that queues or dequeues a message. */
1366
+ /** Signal after a queue transition has settled its pending, claim, and
1367
+ * delivery state, so AgentSession's `queue_update` coalescing observes
1368
+ * a consistent snapshot across enqueue, preparation, delivery, and restore. */
1344
1369
  #emitQueueChanged(): void {
1345
1370
  for (const listener of this.#queueListeners) {
1346
1371
  try {
@@ -1507,6 +1532,19 @@ export class Agent {
1507
1532
  return claim ? [...claim.messages, ...this.#steeringQueue] : this.#steeringQueue;
1508
1533
  }
1509
1534
 
1535
+ #isUndeliveredQueuedMessage(message: AgentMessage): boolean {
1536
+ for (const entry of this.#liveSteered) {
1537
+ if (entry.message === message || entry.additional?.includes(message)) return true;
1538
+ }
1539
+ for (const delivery of this.#queuedMessageDeliveries) {
1540
+ if (delivery.additional.includes(message)) return true;
1541
+ for (let i = delivery.next; i < delivery.messages.length; i++) {
1542
+ if (delivery.messages[i] === message) return true;
1543
+ }
1544
+ }
1545
+ return false;
1546
+ }
1547
+
1510
1548
  /** Dequeued messages not yet in the transcript, e.g. steering a provider
1511
1549
  * took into its in-flight response via live steering. Aborting the run
1512
1550
  * requeues them, so the session's empty-submit interrupt counts them as
@@ -1553,17 +1591,50 @@ export class Agent {
1553
1591
  return queue.slice(0, count);
1554
1592
  }
1555
1593
 
1594
+ /**
1595
+ * The next delivery batch of `queue`. With a {@link queuedMessageGate}, the
1596
+ * gate sees each candidate batch first; what it vetoes leaves the queue and
1597
+ * the batch is recomputed, and candidates it claimed that the final batch no
1598
+ * longer holds are released again.
1599
+ */
1600
+ #nextDeliveryBatch(queue: QueuedMessageQueue): AgentMessage[] {
1601
+ const mode = queue === "steering" ? this.#steeringMode : this.#followUpMode;
1602
+ let batch = this.#dequeueMessages(queue === "steering" ? this.#steeringQueue : this.#followUpQueue, mode);
1603
+ const gate = this.queuedMessageGate;
1604
+ if (!gate || batch.length === 0) return batch;
1605
+ let claimedEarlier: AgentMessage[] | undefined;
1606
+ let dropped = false;
1607
+ while (batch.length > 0) {
1608
+ const veto = gate.claim(queue, batch);
1609
+ if (veto.length === 0) break;
1610
+ const drop = new Set(veto);
1611
+ const pending = queue === "steering" ? this.#steeringQueue : this.#followUpQueue;
1612
+ const kept = pending.filter(message => !drop.has(message));
1613
+ if (kept.length === pending.length) break;
1614
+ for (const message of batch) if (!drop.has(message)) (claimedEarlier ??= []).push(message);
1615
+ if (queue === "steering") this.#steeringQueue = kept;
1616
+ else this.#followUpQueue = kept;
1617
+ dropped = true;
1618
+ batch = this.#dequeueMessages(kept, mode);
1619
+ }
1620
+ if (claimedEarlier) {
1621
+ const final = new Set(batch);
1622
+ const stale = claimedEarlier.filter(message => !final.has(message));
1623
+ if (stale.length > 0) gate.release(stale);
1624
+ }
1625
+ if (dropped && batch.length === 0) this.#emitQueueChanged();
1626
+ return batch;
1627
+ }
1628
+
1556
1629
  #dequeueSteeringMessages(): AgentMessage[] {
1557
- const messages = this.#dequeueMessages(this.#steeringQueue, this.#steeringMode);
1630
+ const messages = this.#nextDeliveryBatch("steering");
1558
1631
  this.#steeringQueue = this.#steeringQueue.slice(messages.length);
1559
- if (messages.length > 0) this.#emitQueueChanged();
1560
1632
  return messages;
1561
1633
  }
1562
1634
 
1563
1635
  #dequeueFollowUpMessages(): AgentMessage[] {
1564
- const messages = this.#dequeueMessages(this.#followUpQueue, this.#followUpMode);
1636
+ const messages = this.#nextDeliveryBatch("followUp");
1565
1637
  this.#followUpQueue = this.#followUpQueue.slice(messages.length);
1566
- if (messages.length > 0) this.#emitQueueChanged();
1567
1638
  return messages;
1568
1639
  }
1569
1640
 
@@ -1991,6 +2062,7 @@ export class Agent {
1991
2062
  resolveFallbackTool: this.#resolveFallbackTool,
1992
2063
  suggestFallbackToolNames: this.#suggestFallbackToolNames,
1993
2064
  intentTracing: this.#intentTracing,
2065
+ intentDescription: this.#intentDescription,
1994
2066
  pruneToolDescriptions: this.#pruneToolDescriptions,
1995
2067
  dialect: this.#dialect,
1996
2068
  getDialect: this.#dialectResolver,
@@ -2024,6 +2096,7 @@ export class Agent {
2024
2096
  }
2025
2097
  return this.#dequeueSteeringMessagesAfterHooks(signal ?? loopSignal);
2026
2098
  },
2099
+ isUndeliveredQueuedMessage: message => this.#isUndeliveredQueuedMessage(message),
2027
2100
  hasSteeringMessages: () =>
2028
2101
  steeringQueueState(
2029
2102
  this.#steeringQueue,
@@ -82,6 +82,8 @@ export interface StablePrefixSnapshot {
82
82
  export interface BuildOptions {
83
83
  /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
84
84
  intentTracing: boolean;
85
+ /** Schema description of the injected `i` field (must match agent-loop's normalizeTools). */
86
+ intentDescription?: string;
85
87
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
86
88
  pruneToolDescriptions?: boolean;
87
89
  }
@@ -136,6 +138,7 @@ export class StablePrefix {
136
138
  // but changes any of these must miss the fast path.
137
139
  #lastToolKey: readonly string[] | undefined;
138
140
  #lastIntentTracing: boolean | undefined;
141
+ #lastIntentDescription: string | undefined;
139
142
  #lastPruneToolDescriptions: boolean | undefined;
140
143
 
141
144
  build(context: AgentContext, options: BuildOptions): boolean {
@@ -166,6 +169,7 @@ export class StablePrefix {
166
169
  #fastPathHit(context: AgentContext, options: BuildOptions, toolKeys: readonly string[]): boolean {
167
170
  if (
168
171
  this.#lastIntentTracing !== options.intentTracing ||
172
+ this.#lastIntentDescription !== options.intentDescription ||
169
173
  this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
170
174
  ) {
171
175
  return false;
@@ -196,6 +200,7 @@ export class StablePrefix {
196
200
  #recordFastPathKey(context: AgentContext, options: BuildOptions, renderedToolKeys: readonly string[]): void {
197
201
  this.#lastPromptSegments = [...context.systemPrompt];
198
202
  this.#lastIntentTracing = options.intentTracing;
203
+ this.#lastIntentDescription = options.intentDescription;
199
204
  this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
200
205
  this.#lastToolKey = renderedToolKeys;
201
206
  }
@@ -470,11 +475,7 @@ export class AppendOnlyContextManager {
470
475
 
471
476
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
472
477
  const systemPrompt = [...context.systemPrompt];
473
- const tools =
474
- normalizeTools(context.tools, {
475
- injectIntent: options.intentTracing,
476
- pruneDescriptions: options.pruneToolDescriptions,
477
- }) ?? [];
478
+ const tools = normalizeWireTools(context.tools, options);
478
479
  return {
479
480
  systemPrompt,
480
481
  tools,
@@ -482,20 +483,50 @@ function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefi
482
483
  };
483
484
  }
484
485
 
486
+ function normalizeWireTools(tools: AgentContext["tools"], options: BuildOptions): Tool[] {
487
+ return (
488
+ normalizeTools(tools, {
489
+ injectIntent: options.intentTracing,
490
+ intentDescription: options.intentDescription,
491
+ pruneDescriptions: options.pruneToolDescriptions,
492
+ }) ?? []
493
+ );
494
+ }
495
+
496
+ /** The fields of a provider-bound tool spec the wire carries; `normalizeTools` has rendered `examples` into the description. */
497
+ function wireToolFields(t: Tool) {
498
+ return {
499
+ n: t.name,
500
+ d: t.description,
501
+ p: t.parameters,
502
+ s: t.strict,
503
+ cf: t.customFormat,
504
+ cw: t.customWireName,
505
+ };
506
+ }
507
+
508
+ /**
509
+ * Fingerprint of the tool specs a request built with `options` sends: the
510
+ * tools half of the prefix fingerprint, for a caller that compares tool sets
511
+ * across requests. A same-name tool whose description, schema, examples,
512
+ * `strict`, `customFormat` or `customWireName` changed makes another one.
513
+ */
514
+ export function wireToolsFingerprint(tools: AgentContext["tools"], options: BuildOptions): string {
515
+ return hashPayload(JSON.stringify(normalizeWireTools(tools, options).map(wireToolFields)));
516
+ }
517
+
485
518
  function computeFingerprint(systemPrompt: string[], tools: Tool[], options: BuildOptions): string {
486
- const payload = JSON.stringify({
487
- s: systemPrompt,
488
- t: tools.map(t => ({
489
- n: t.name,
490
- d: t.description,
491
- p: t.parameters,
492
- s: t.strict,
493
- cf: t.customFormat,
494
- cw: t.customWireName,
495
- })),
496
- i: options.intentTracing,
497
- pd: options.pruneToolDescriptions,
498
- });
519
+ return hashPayload(
520
+ JSON.stringify({
521
+ s: systemPrompt,
522
+ t: tools.map(wireToolFields),
523
+ i: options.intentTracing,
524
+ pd: options.pruneToolDescriptions,
525
+ }),
526
+ );
527
+ }
528
+
529
+ function hashPayload(payload: string): string {
499
530
  let hash = 0;
500
531
  for (let i = 0; i < payload.length; i++) {
501
532
  hash = ((hash << 5) - hash + payload.charCodeAt(i)) | 0;
@@ -472,7 +472,6 @@ function buildCompactionV2Headers(
472
472
  headers[OPENAI_HEADERS.SESSION_ID] = routingSessionId;
473
473
  headers["x-client-request-id"] = routingSessionId;
474
474
  }
475
- headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
476
475
  headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
477
476
  headers[OPENAI_HEADERS.CODEX_BETA_FEATURES] = OPENAI_HEADER_VALUES.REMOTE_COMPACTION_V2;
478
477
  headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.body.model, undefined);
@@ -965,7 +965,6 @@ export async function requestOpenAiRemoteCompaction(
965
965
  if (attestation) {
966
966
  headers[OPENAI_HEADERS.ATTESTATION] = attestation;
967
967
  }
968
- headers[OPENAI_HEADERS.BETA] = OPENAI_HEADER_VALUES.BETA_RESPONSES;
969
968
  headers[OPENAI_HEADERS.ORIGINATOR] = OPENAI_HEADER_VALUES.ORIGINATOR_CODEX;
970
969
  // This compaction request sends no `service_tier`, so the hint is model-only.
971
970
  headers[OPENAI_HEADERS.ROUTING_HINT] = codexRoutingHint(request.model, undefined);
package/src/telemetry.ts CHANGED
@@ -1836,7 +1836,7 @@ export interface InstrumentedChatSpanOptions {
1836
1836
  export async function instrumentedCompleteSimple<TApi extends Api>(
1837
1837
  model: Model<TApi>,
1838
1838
  ctx: Context,
1839
- options: SimpleStreamOptions,
1839
+ options: SimpleStreamOptions & { onAttempt?: (message: AssistantMessage) => void },
1840
1840
  span: InstrumentedChatSpanOptions,
1841
1841
  ): Promise<AssistantMessage> {
1842
1842
  const { telemetry, parent, oneshotKind } = span;
package/src/types.ts CHANGED
@@ -48,6 +48,22 @@ export type PrepareQueuedMessages = (
48
48
  queue: QueuedMessageQueue,
49
49
  ) => QueuedMessagePreparation | undefined | Promise<QueuedMessagePreparation | undefined>;
50
50
 
51
+ /**
52
+ * Synchronous arbiter over queued messages leaving a queue for delivery, for a
53
+ * queue whose messages someone else may withdraw concurrently (a session homed
54
+ * on another thread whose view pops them).
55
+ */
56
+ export interface QueuedMessageGate {
57
+ /**
58
+ * Called with the batch about to be removed from `queue` for delivery; returns
59
+ * the candidates that must be dropped from the queue instead. The others are
60
+ * claimed for delivery.
61
+ */
62
+ claim(queue: QueuedMessageQueue, candidates: readonly AgentMessage[]): readonly AgentMessage[];
63
+ /** Called with claimed messages that left delivery undelivered (requeued, withdrawn, or cancelled). */
64
+ release(messages: readonly AgentMessage[]): void;
65
+ }
66
+
51
67
  /** Called once an aside has been inserted into the agent's live context. */
52
68
  export const ASIDE_MESSAGE_COMMIT = Symbol("aside-message-commit");
53
69
  /** Symbol-keyed handoff for one finalized, tool-owned stream speculation session. */
@@ -278,6 +294,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
278
294
  */
279
295
  getSteeringMessages?: (signal?: AbortSignal) => Promise<AgentMessage[]>;
280
296
 
297
+ /** Whether input is still owned by a queue and must be restored if a model call is aborted before it starts. */
298
+ isUndeliveredQueuedMessage?: (message: AgentMessage) => boolean;
299
+
281
300
  /**
282
301
  * Peeks whether steering messages are queued, without consuming them.
283
302
  *
@@ -457,6 +476,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
457
476
  * then strips from arguments before executing tools.
458
477
  */
459
478
  intentTracing?: boolean;
479
+ /** Schema description of the injected intent field; `PI_INTENT_DESC` overrides it. Default `concise intent`. */
480
+ intentDescription?: string;
460
481
  /**
461
482
  * Strip tool descriptions (top-level + nested schema annotations) from the
462
483
  * provider-bound tool specs. Use when the full catalog is rendered into the