@oh-my-pi/pi-agent-core 18.15.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,28 @@
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
+
12
+ ## [18.16.0] - 2026-11-02
13
+
14
+ ### Changed
15
+
16
+ - Deprecated `createFileOps`, `computeFileLists`, `formatFileOperations`, `upsertFileOperations`, `isUrlSchemePath` and `FileOperations` in `@oh-my-pi/pi-agent-core/compaction`; they now re-export `@oh-my-pi/snapcompact`, which callers should import directly
17
+
18
+ ### Fixed
19
+
20
+ - Context estimates for a model without a known tokenizer family (Gemini, Grok and the like), and token counts made without a model (compress metrics, the legacy `estimateTokens` export), count text in o200k_base instead of guessing 4 bytes per token, which undercounted what those providers bill by 11–48% on code-heavy sessions and let compaction fire late after a switch to them ([#17607](https://github.com/can1357/oh-my-pi/pull/17607) by [@will-bogusz](https://github.com/will-bogusz))
21
+ - Context estimates no longer charge a reader another model's thinking signatures or redacted thinking, which the request strips: a Claude session switched to Grok or Gemini, including Gemini through Factory, counted them as text ([#17607](https://github.com/can1357/oh-my-pi/pull/17607) by [@will-bogusz](https://github.com/will-bogusz))
22
+
23
+ ### Removed
24
+
25
+ - Removed `PI_TOKENIZER_ACCURATE`: every model is now counted with a native tokenizer ([#17607](https://github.com/can1357/oh-my-pi/pull/17607) by [@will-bogusz](https://github.com/will-bogusz))
26
+
5
27
  ## [18.15.0] - 2026-11-01
6
28
 
7
29
  ### Fixed
@@ -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;
@@ -5,12 +5,12 @@
5
5
  * a summary of the branch being left so context isn't lost.
6
6
  */
7
7
  import type { Api, ApiKey, AssistantMessage, Context, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
8
+ import * as snapcompact from "@oh-my-pi/snapcompact";
8
9
  import { type AgentTelemetry } from "../telemetry.js";
9
10
  import { Tokenizer } from "../tokenizer.js";
10
11
  import type { AgentMessage } from "../types.js";
11
12
  import type { ReadonlySessionManager, SessionEntry } from "./entries.js";
12
13
  import { type ConvertToLlm } from "./messages.js";
13
- import { type FileOperations } from "./utils.js";
14
14
  export interface BranchSummaryResult {
15
15
  summary?: string;
16
16
  readFiles?: string[];
@@ -23,12 +23,11 @@ export interface BranchSummaryDetails {
23
23
  readFiles: string[];
24
24
  modifiedFiles: string[];
25
25
  }
26
- export type { FileOperations } from "./utils.js";
27
26
  export interface BranchPreparation {
28
27
  /** Messages extracted for summarization, in chronological order */
29
28
  messages: AgentMessage[];
30
29
  /** File operations extracted from tool calls */
31
- fileOps: FileOperations;
30
+ fileOps: snapcompact.FileOperations;
32
31
  /** Total estimated tokens in messages */
33
32
  totalTokens: number;
34
33
  }
@@ -5,13 +5,13 @@
5
5
  * and after compaction the session is reloaded.
6
6
  */
7
7
  import { type Api, type ApiKey, type AssistantMessage, type CodexCompactionContext, type Context, type FetchImpl, type MessageAttribution, type Model, type OneshotRetryOptions, type ProviderSessionState, type SimpleStreamOptions, type Tool, type Usage } from "@oh-my-pi/pi-ai";
8
+ import * as snapcompact from "@oh-my-pi/snapcompact";
8
9
  import { type AgentTelemetry } from "../telemetry.js";
9
10
  import { ThinkingLevel } from "../thinking.js";
10
11
  import { Tokenizer } from "../tokenizer.js";
11
12
  import type { AgentMessage } from "../types.js";
12
13
  import type { SessionEntry } from "./entries.js";
13
14
  import { type ConvertToLlm } from "./messages.js";
14
- import { type FileOperations } from "./utils.js";
15
15
  /** Details stored in CompactionEntry.details for file tracking */
16
16
  export interface CompactionDetails {
17
17
  readFiles: string[];
@@ -368,7 +368,7 @@ export interface CompactionPreparation {
368
368
  /** Preserved opaque compaction payload from the previous compaction, if any. */
369
369
  previousPreserveData?: Record<string, unknown>;
370
370
  /** File operations extracted from messagesToSummarize */
371
- fileOps: FileOperations;
371
+ fileOps: snapcompact.FileOperations;
372
372
  /** Compaction settions from settings.jsonl */
373
373
  settings: CompactionSettings;
374
374
  }
@@ -3,13 +3,12 @@
3
3
  */
4
4
  import type { Message } from "@oh-my-pi/pi-ai";
5
5
  import { type Dialect } from "@oh-my-pi/pi-ai/dialect";
6
+ import * as snapcompact from "@oh-my-pi/snapcompact";
6
7
  import type { AgentMessage } from "../types.js";
7
- export interface FileOperations {
8
- read: Set<string>;
9
- written: Set<string>;
10
- edited: Set<string>;
11
- }
12
- export declare function createFileOps(): FileOperations;
8
+ /** @deprecated Import these from `@oh-my-pi/snapcompact`. */
9
+ export { computeFileLists, createFileOps, type FileOperations, isUrlSchemePath, upsertFileOperations, } from "@oh-my-pi/snapcompact";
10
+ /** @deprecated Use `upsertFileOperations("", readFiles, modifiedFiles, readSet)` from `@oh-my-pi/snapcompact`. */
11
+ export declare function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
13
12
  /** Error text for a summarization call that hit its output limit (`stopReason: "length"`): partial text is never used. */
14
13
  export declare function incompleteOutputError(prefix: string): string;
15
14
  /**
@@ -33,25 +32,10 @@ export declare function stripReadSelector(path: string): string;
33
32
  * than its plain lines: verbatim `raw` bytes or the merge `conflicts` view.
34
33
  */
35
34
  export declare function isAlternateFormReadSelector(sel: string): boolean;
36
- /**
37
- * Whether `path` references a `scheme://` URL (internal URI or web URL) rather
38
- * than a filesystem path that belongs in the compaction `<files>` summary.
39
- */
40
- export declare function isUrlSchemePath(path: string): boolean;
41
35
  /**
42
36
  * Extract file operations from tool calls in an assistant message.
43
37
  */
44
- export declare function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void;
45
- /**
46
- * Compute final file lists from file operations.
47
- * Returns readFiles (files only read, not modified) and modifiedFiles.
48
- */
49
- export declare function computeFileLists(fileOps: FileOperations): {
50
- readFiles: string[];
51
- modifiedFiles: string[];
52
- };
53
- export declare function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
54
- export declare function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
38
+ export declare function extractFileOpsFromMessage(message: AgentMessage, fileOps: snapcompact.FileOperations): void;
55
39
  /**
56
40
  * Truncate tool results to the same representation used in summarization prompts.
57
41
  */
@@ -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
@@ -10,6 +10,7 @@ type TokenizerModel = Pick<Model, "tokenizer"> & snapcompact.ShapeTarget & {
10
10
  compat?: Model["compat"];
11
11
  thinking?: Model["thinking"];
12
12
  reasoning?: boolean;
13
+ requestModelId?: string;
13
14
  };
14
15
  /** Identity of the reader whose replayed reasoning a {@link Tokenizer} counts; equal keys count it alike. */
15
16
  export declare function reasoningReplayKey(model: TokenizerModel | null | undefined): string;
@@ -24,13 +25,12 @@ export interface TokenizerOptions {
24
25
  nativeHistoryReplay?: boolean;
25
26
  }
26
27
  /**
27
- * `strict` always tries an exact native count (the catalog-resolved tokenizer
28
- * when known, o200k_base otherwise), falling back to the byte upper bound when
29
- * the loaded addon does not recognize that encoding. `approximate` and
30
- * `upperbound` prefer the same exact count for known tokenizer families or
31
- * when `PI_TOKENIZER_ACCURATE=1` is set; otherwise they use a cheap heuristic:
32
- * `approximate` a bytes/4 guess, `upperbound` the raw byte length (never
33
- * undercounts).
28
+ * Every mode counts with the native tokenizer: the catalog-resolved family
29
+ * when known, o200k_base otherwise. They differ only in the fallback when the
30
+ * loaded addon does not recognize that encoding: `strict` and `upperbound`
31
+ * the raw byte length (never undercounts), `approximate` a bytes/4 guess.
32
+ * Tests (`NODE_ENV=test`) skip the native count outside `strict` and use that
33
+ * fallback.
34
34
  */
35
35
  export type TokenCountMode = "strict" | "approximate" | "upperbound";
36
36
  /** Options for {@link Tokenizer.countMessage} / {@link Tokenizer.countMessages}. */
@@ -69,8 +69,8 @@ export interface TokenBudgetCheck {
69
69
  * when the model's encoding, image or thinking-replay pricing, replay
70
70
  * identity or native-history replay state changes); one-shot flows construct
71
71
  * their own for the model that will be billed. Known tokenizer families use
72
- * exact native counts; unknown models keep the fast byte estimate (or o200k
73
- * when `PI_TOKENIZER_ACCURATE=1`).
72
+ * exact native counts; other models are counted in o200k_base, which bills
73
+ * unknown readers closer than a bytes/4 guess.
74
74
  */
75
75
  export declare class Tokenizer {
76
76
  #private;
@@ -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.15.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.15.0",
42
- "@oh-my-pi/pi-catalog": "18.15.0",
43
- "@oh-my-pi/pi-natives": "18.15.0",
44
- "@oh-my-pi/pi-utils": "18.15.0",
45
- "@oh-my-pi/pi-wire": "18.15.0",
46
- "@oh-my-pi/snapcompact": "18.15.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.15.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
@@ -53,7 +53,7 @@ import {
53
53
  signalListLabel,
54
54
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
55
55
  import { isDsmlLeakRecoveryTarget, removeDsmlToolMarkupLeak } from "@oh-my-pi/pi-ai/utils/dsml-leak";
56
- import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
56
+ import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON, toError } from "@oh-my-pi/pi-utils";
57
57
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
58
58
  import { LiveSteeringChannel } from "./live-steering";
59
59
  import { agentPauseGate } from "./pause";
@@ -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;
@@ -1188,7 +1195,7 @@ function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
1188
1195
  if (message) out.push(message);
1189
1196
  }
1190
1197
  } catch (error) {
1191
- discardAsides(out, error instanceof Error ? error : new Error(String(error)));
1198
+ discardAsides(out, toError(error));
1192
1199
  throw error;
1193
1200
  }
1194
1201
  return out;
@@ -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,