@oh-my-pi/pi-agent-core 18.2.4 → 18.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.5] - 2026-09-17
6
+
7
+ ### Fixed
8
+
9
+ - Improved agent performance by reducing redundant tool-schema processing during repeated model calls and optimizing streamed tool-call argument parsing.
10
+
5
11
  ## [18.2.1] - 2026-09-15
6
12
 
7
13
  ### Added
@@ -41,10 +41,6 @@ export declare class StablePrefix {
41
41
  get fingerprint(): string;
42
42
  get version(): number;
43
43
  get built(): boolean;
44
- /**
45
- * Build or rebuild from live context.
46
- * Returns `true` if the prefix actually changed (cache miss imminent).
47
- */
48
44
  build(context: AgentContext, options: BuildOptions): boolean;
49
45
  /** Force rebuild on the next `build()` call. */
50
46
  invalidate(): void;
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.2.4",
4
+ "version": "18.2.6",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -35,16 +35,16 @@
35
35
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "18.2.4",
39
- "@oh-my-pi/pi-catalog": "18.2.4",
40
- "@oh-my-pi/pi-natives": "18.2.4",
41
- "@oh-my-pi/pi-utils": "18.2.4",
42
- "@oh-my-pi/pi-wire": "18.2.4",
43
- "@oh-my-pi/snapcompact": "18.2.4",
38
+ "@oh-my-pi/pi-ai": "18.2.6",
39
+ "@oh-my-pi/pi-catalog": "18.2.6",
40
+ "@oh-my-pi/pi-natives": "18.2.6",
41
+ "@oh-my-pi/pi-utils": "18.2.6",
42
+ "@oh-my-pi/pi-wire": "18.2.6",
43
+ "@oh-my-pi/snapcompact": "18.2.6",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
47
- "@oh-my-pi/omptype": "18.2.4",
47
+ "@oh-my-pi/omptype": "18.2.6",
48
48
  "@opentelemetry/context-async-hooks": "^2.9.0",
49
49
  "@opentelemetry/sdk-trace-base": "^2.9.0",
50
50
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  getStreamingPartialJson,
38
38
  kCursorExecResolved,
39
39
  } from "@oh-my-pi/pi-ai/utils/block-symbols";
40
+ import { stamp } from "@oh-my-pi/pi-ai/utils/schema/stamps";
40
41
  import {
41
42
  createHarmonyAuditEvent,
42
43
  detectHarmonyLeakInAssistantMessage,
@@ -886,6 +887,29 @@ export function normalizeMessagesForProvider(
886
887
  const INTENT_FIELD_DESCRIPTION = "concise intent";
887
888
  const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
888
889
 
890
+ // Memoize injection per input schema identity: normalizeTools runs on every
891
+ // model call and injectIntentIntoSchema mints a fresh root object each time,
892
+ // which defeats the stamp-keyed schema memos downstream (toolWireSchema,
893
+ // stripSchemaDescriptions, tryEnforceStrictSchema each deep-clone + re-walk
894
+ // the whole catalog per request). The injected object is shared across
895
+ // requests — the same profile as the intent-off path, where parameters IS the
896
+ // shared memoized wire schema (see schema-immutability.test.ts). One stamp
897
+ // key per (mode, describeIntent) variant; index 0 = bare, 1 = described.
898
+ const INTENT_STAMPS = {
899
+ require: [Symbol("intent:require"), Symbol("intent:require:described")],
900
+ optional: [Symbol("intent:optional"), Symbol("intent:optional:described")],
901
+ } as const;
902
+
903
+ function memoizedInjectIntentIntoSchema(
904
+ schema: Record<string, unknown>,
905
+ mode: "require" | "optional",
906
+ describeIntent: boolean,
907
+ ): unknown {
908
+ return stamp(schema, INTENT_STAMPS[mode][describeIntent ? 1 : 0], host =>
909
+ injectIntentIntoSchema(host, mode, describeIntent),
910
+ );
911
+ }
912
+
889
913
  function injectIntentIntoSchema(
890
914
  schema: unknown,
891
915
  mode: "require" | "optional" = "require",
@@ -965,12 +989,14 @@ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeT
965
989
  // re-inject `i` (without its hint, which `describeIntent: false` omits) so
966
990
  // intent tracing keeps the field while no descriptions ride the wire.
967
991
  if (pruneDescriptions) {
968
- let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
969
- if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema;
992
+ const stripped = stripSchemaDescriptions(toolWireSchema(t));
993
+ const parameters = (
994
+ doInjectIntent ? memoizedInjectIntentIntoSchema(stripped, intentMode, false) : stripped
995
+ ) as TSchema;
970
996
  return { ...t, parameters, description: "" };
971
997
  }
972
- let parameters = toolWireSchema(t) as TSchema;
973
- if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
998
+ const wire = toolWireSchema(t);
999
+ const parameters = (doInjectIntent ? memoizedInjectIntentIntoSchema(wire, intentMode, true) : wire) as TSchema;
974
1000
  const description = t.description ?? "";
975
1001
  const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
976
1002
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
@@ -14,7 +14,9 @@
14
14
  * message delta is a cache miss each turn.
15
15
  */
16
16
 
17
+ import type { AgentTool } from "./types";
17
18
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
19
+ import { toolWireSchema } from "@oh-my-pi/pi-ai";
18
20
  import { normalizeTools } from "./agent-loop";
19
21
  import { messageEstimateVersion } from "./compaction/message-cache";
20
22
  import type { AgentContext, AgentMessage } from "./types";
@@ -23,6 +25,52 @@ import type { AgentContext, AgentMessage } from "./types";
23
25
  // StablePrefix (formerly ImmutablePrefix)
24
26
  // ---------------------------------------------------------------------------
25
27
 
28
+ /**
29
+ * Cheap per-tool wire-identity key: every field normalizeTools and
30
+ * computeFingerprint read. The resolved parameters contribute by object
31
+ * identity — the wire-schema memo is stamp-keyed on the parameters object,
32
+ * so identity equals normalized bytes (modulo intent injection, a pure
33
+ * function of the mode flags compared separately). `customFormat` and
34
+ * `examples` contribute by reference: both are treated as immutable config
35
+ * (a registry swap replaces the object), matching how the snapshot consumes
36
+ * them. `intent` functions are compared by reference — same closure means
37
+ * same mode resolution.
38
+ */
39
+ function toolKeyForPrefix(tool: AgentTool): string {
40
+ const params = toolWireSchema(tool);
41
+ const customFormat = tool.customFormat;
42
+ const examples = (tool as { examples?: unknown }).examples;
43
+ // Identity strings for objects (Map-assigned ids, no string-build cost
44
+ // on the hot path beyond the first sighting per object).
45
+ return [
46
+ tool.name ?? "",
47
+ tool.description ?? "",
48
+ String(tool.strict ?? ""),
49
+ tool.customWireName ?? "",
50
+ typeof tool.intent === "function" ? `fn:${objectId(tool.intent)}` : `mode:${tool.intent ?? "require"}`,
51
+ objectId(params),
52
+ objectId(customFormat),
53
+ objectId(examples),
54
+ ].join("\u0000");
55
+ }
56
+
57
+ const objectIds = new WeakMap<object, number>();
58
+ let nextObjectId = 1;
59
+
60
+ function objectId(obj: unknown): number {
61
+ if (obj === null || obj === undefined || (typeof obj !== "object" && typeof obj !== "function")) {
62
+ // Primitives contribute by value below via String(); the id path is
63
+ // only for reference-identity comparison of config objects.
64
+ return 0;
65
+ }
66
+ let id = objectIds.get(obj);
67
+ if (id === undefined) {
68
+ id = nextObjectId++;
69
+ objectIds.set(obj, id);
70
+ }
71
+ return id;
72
+ }
73
+
26
74
  /** Frozen system prompt + tool spec snapshot. */
27
75
  export interface StablePrefixSnapshot {
28
76
  systemPrompt: string[];
@@ -63,10 +111,42 @@ export class StablePrefix {
63
111
  /**
64
112
  * Build or rebuild from live context.
65
113
  * Returns `true` if the prefix actually changed (cache miss imminent).
114
+ *
115
+ * Steady-state fast path: when the live prompt reference is unchanged
116
+ * AND every tool resolves to the same normalized parameters identity as
117
+ * last build, the fingerprint cannot have changed, so the full snapshot
118
+ * + stringify is skipped. Comparing resolved parameters (not the tool
119
+ * container) is load-bearing: tools like ReadTool expose `parameters` as
120
+ * a getter over live settings (`skillful`, `memory.backend`), so the
121
+ * schema can swap under stable tool references when a setting toggles.
122
+ * Any other in-place mutation must go through `invalidate()`.
66
123
  */
124
+ #lastPrompt: readonly string[] | undefined;
125
+ // Joined prompt bytes snapshot: the prompt array is caller-owned and
126
+ // mutable in place (Agent.setSystemPrompt stores the caller's array;
127
+ // anyone holding it can push/splice), so reference equality alone cannot
128
+ // prove the bytes are unchanged. Compared by value on the fast path.
129
+ #lastPromptText: string | undefined;
130
+ // Per-tool wire-identity snapshot: every field normalizeTools and
131
+ // computeFingerprint read (name, description, resolved parameters,
132
+ // strict, customFormat, customWireName, intent mode, examples
133
+ // reference). A registry swap that keeps name/description/parameters
134
+ // but changes any of these must miss the fast path.
135
+ #lastToolKey: readonly string[] | undefined;
136
+ #lastIntentTracing: boolean | undefined;
137
+ #lastPruneToolDescriptions: boolean | undefined;
138
+
67
139
  build(context: AgentContext, options: BuildOptions): boolean {
140
+ const prev = this.#snapshot;
141
+ if (prev !== null && this.#fastPathHit(context, options)) {
142
+ return false;
143
+ }
68
144
  const snapshot = takeSnapshot(context, options);
69
- if (this.#snapshot && this.#snapshot.fingerprint === snapshot.fingerprint) {
145
+ this.#recordFastPathKey(context, options, snapshot.tools);
146
+ if (prev && prev.fingerprint === snapshot.fingerprint) {
147
+ // Identity changed but bytes did not (e.g. equivalent rebuild):
148
+ // keep serving the cached snapshot so downstream memo identity
149
+ // (stamp-keyed schema caches) stays stable.
70
150
  return false;
71
151
  }
72
152
  this.#snapshot = snapshot;
@@ -74,9 +154,50 @@ export class StablePrefix {
74
154
  return true;
75
155
  }
76
156
 
157
+ /** True when the cheap key matches: prompt + per-tool resolved identity. */
158
+ #fastPathHit(context: AgentContext, options: BuildOptions): boolean {
159
+ if (
160
+ this.#lastIntentTracing !== options.intentTracing ||
161
+ this.#lastPruneToolDescriptions !== options.pruneToolDescriptions
162
+ ) {
163
+ return false;
164
+ }
165
+ // Prompt by reference first (steady state), then by joined bytes so
166
+ // an in-place push/splice of the same array still misses.
167
+ if (this.#lastPrompt !== context.systemPrompt) return false;
168
+ if (this.#lastPromptText !== undefined) {
169
+ const text = context.systemPrompt.join("\u0000");
170
+ if (text !== this.#lastPromptText) return false;
171
+ }
172
+ const tools = context.tools ?? [];
173
+ if (this.#lastToolKey === undefined || this.#lastToolKey.length !== tools.length) {
174
+ return false;
175
+ }
176
+ for (let i = 0; i < tools.length; i++) {
177
+ if (this.#lastToolKey[i] !== toolKeyForPrefix(tools[i]!)) {
178
+ return false;
179
+ }
180
+ }
181
+ return true;
182
+ }
183
+
184
+ #recordFastPathKey(context: AgentContext, options: BuildOptions, normalized: Tool[]): void {
185
+ this.#lastPrompt = context.systemPrompt;
186
+ this.#lastPromptText = context.systemPrompt.join("\u0000");
187
+ this.#lastIntentTracing = options.intentTracing;
188
+ this.#lastPruneToolDescriptions = options.pruneToolDescriptions;
189
+ this.#lastToolKey = (context.tools ?? []).map(tool => toolKeyForPrefix(tool));
190
+ void normalized;
191
+ }
192
+
77
193
  /** Force rebuild on the next `build()` call. */
78
194
  invalidate(): void {
79
195
  this.#snapshot = null;
196
+ this.#lastPrompt = undefined;
197
+ this.#lastPromptText = undefined;
198
+ this.#lastToolKey = undefined;
199
+ this.#lastIntentTracing = undefined;
200
+ this.#lastPruneToolDescriptions = undefined;
80
201
  }
81
202
 
82
203
  /**
package/src/proxy.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  type StreamingPartialJsonCarrier,
21
21
  setStreamingPartialJson,
22
22
  } from "@oh-my-pi/pi-ai/utils/block-symbols";
23
- import { parseStreamingJson, readSseJson } from "@oh-my-pi/pi-utils";
23
+ import { parseStreamingJson, parseStreamingJsonThrottled, readSseJson } from "@oh-my-pi/pi-utils";
24
24
 
25
25
  // Event stream adapter for proxy SSE events
26
26
  export class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -117,6 +117,10 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
117
117
  };
118
118
 
119
119
  let response: Response | null = null;
120
+ // Declared outside try so the disconnect path (catch) can finalize
121
+ // still-buffered tool-call arguments before emitting the error event.
122
+ const partialJsonByIndex = new Map<number, string>();
123
+ const parsedLenByIndex = new Map<number, number>();
120
124
  const abortHandler = () => {
121
125
  const body = response?.body;
122
126
  if (body) {
@@ -165,12 +169,11 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
165
169
  }
166
170
 
167
171
  let sawTerminalEvent = false;
168
- const partialJsonByIndex = new Map<number, string>();
169
172
  for await (const event of readSseJson<ProxyAssistantMessageEvent>(
170
173
  response.body as ReadableStream<Uint8Array>,
171
174
  options.signal,
172
175
  )) {
173
- const parsedEvent = processProxyEvent(event, partial, partialJsonByIndex);
176
+ const parsedEvent = processProxyEvent(event, partial, partialJsonByIndex, parsedLenByIndex);
174
177
  if (parsedEvent) {
175
178
  if (parsedEvent.type === "done" || parsedEvent.type === "error") {
176
179
  sawTerminalEvent = true;
@@ -193,6 +196,10 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
193
196
  const reason = options.signal?.aborted ? "aborted" : "error";
194
197
  partial.stopReason = reason;
195
198
  partial.errorMessage = errorMessage;
199
+ // Disconnect/abort path: no terminal event ran, so trailing
200
+ // throttled deltas never reached content.arguments. Finalize
201
+ // them here so the persisted/retried transcript is complete.
202
+ finalizeBufferedArguments(partial, partialJsonByIndex);
196
203
  scrubPartialJson(partial);
197
204
  stream.push({
198
205
  type: "error",
@@ -235,6 +242,7 @@ function processProxyEvent(
235
242
  proxyEvent: ProxyAssistantMessageEvent,
236
243
  partial: AssistantMessage,
237
244
  partialJsonByIndex: Map<number, string>,
245
+ parsedLenByIndex: Map<number, number>,
238
246
  ): AssistantMessageEvent | undefined {
239
247
  switch (proxyEvent.type) {
240
248
  case "start":
@@ -335,13 +343,22 @@ function processProxyEvent(
335
343
  [kStreamingPartialJson]: "",
336
344
  } as ToolCall & StreamingPartialJsonCarrier;
337
345
  partialJsonByIndex.set(proxyEvent.contentIndex, "");
346
+ parsedLenByIndex.set(proxyEvent.contentIndex, 0);
338
347
  return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
339
348
  case "toolcall_delta": {
340
349
  const content = partial.content[proxyEvent.contentIndex];
341
350
  if (content?.type === "toolCall") {
342
351
  const acc = (partialJsonByIndex.get(proxyEvent.contentIndex) ?? "") + proxyEvent.delta;
343
352
  partialJsonByIndex.set(proxyEvent.contentIndex, acc);
344
- content.arguments = parseStreamingJson(acc) || {};
353
+ // Geometric throttle (same contract as native providers): the
354
+ // authoritative parse lands at toolcall_end; mid-stream
355
+ // previews refresh at most ~3% late on large buffers.
356
+ const lastLen = parsedLenByIndex.get(proxyEvent.contentIndex) ?? 0;
357
+ const parsed = parseStreamingJsonThrottled(acc, lastLen);
358
+ if (parsed !== null) {
359
+ content.arguments = parsed.value || {};
360
+ parsedLenByIndex.set(proxyEvent.contentIndex, parsed.parsedLen);
361
+ }
345
362
  setStreamingPartialJson(content, acc);
346
363
  partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
347
364
  return {
@@ -357,7 +374,13 @@ function processProxyEvent(
357
374
  case "toolcall_end": {
358
375
  const content = partial.content[proxyEvent.contentIndex];
359
376
  if (content?.type === "toolCall") {
377
+ // Authoritative final parse (mirrors native providers): the
378
+ // throttle may have skipped trailing deltas, so the last
379
+ // parsed arguments can lag the accumulated buffer.
380
+ const acc = partialJsonByIndex.get(proxyEvent.contentIndex);
381
+ if (acc !== undefined && acc.length > 0) content.arguments = parseStreamingJson(acc) || {};
360
382
  partialJsonByIndex.delete(proxyEvent.contentIndex);
383
+ parsedLenByIndex.delete(proxyEvent.contentIndex);
361
384
  clearStreamingPartialJson(content);
362
385
  return {
363
386
  type: "toolcall_end",
@@ -373,7 +396,10 @@ function processProxyEvent(
373
396
  partial.stopReason = proxyEvent.reason;
374
397
  partial.usage = proxyEvent.usage;
375
398
  if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
399
+ else finalizeBufferedArguments(partial, partialJsonByIndex);
376
400
  scrubPartialJson(partial);
401
+ partialJsonByIndex.clear();
402
+ parsedLenByIndex.clear();
377
403
  return { type: "done", reason: proxyEvent.reason, message: partial };
378
404
 
379
405
  case "error":
@@ -381,7 +407,26 @@ function processProxyEvent(
381
407
  partial.errorMessage = proxyEvent.errorMessage;
382
408
  partial.usage = proxyEvent.usage;
383
409
  if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
410
+ else finalizeBufferedArguments(partial, partialJsonByIndex);
384
411
  scrubPartialJson(partial);
412
+ partialJsonByIndex.clear();
413
+ parsedLenByIndex.clear();
385
414
  return { type: "error", reason: proxyEvent.reason, error: partial };
386
415
  }
387
416
  }
417
+
418
+ /**
419
+ * Authoritatively parse every still-buffered tool-call argument accumulator
420
+ * into its content block. Terminal `done`/`error` events without a preceding
421
+ * `toolcall_end` (and with `content` omitted) finalize the partial message as
422
+ *-is; without this, trailing deltas below the throttle gate never reach
423
+ * `content.arguments` and the finalized call carries stale arguments.
424
+ */
425
+ function finalizeBufferedArguments(partial: AssistantMessage, partialJsonByIndex: Map<number, string>): void {
426
+ if (partialJsonByIndex.size === 0) return;
427
+ for (const [index, acc] of partialJsonByIndex) {
428
+ if (acc.length === 0) continue;
429
+ const content = partial.content[index];
430
+ if (content?.type === "toolCall") content.arguments = parseStreamingJson(acc) || {};
431
+ }
432
+ }