@oh-my-pi/pi-agent-core 16.1.0 → 16.1.2

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,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.2] - 2026-06-19
6
+
7
+ ### Fixed
8
+
9
+ - Prevented sensitive raw JSON payloads from leaking into agent events during tool validation
10
+ - Ensured tool validation errors are handled correctly for malformed JSON parse inputs
11
+ - Ensure deep-cloning of tool-call arguments respects own enumerable properties
12
+ - Prevent direct object references between agent message snapshots and streaming events
13
+
5
14
  ## [16.1.0] - 2026-06-19
6
15
 
7
16
  ### Added
@@ -886,4 +895,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
886
895
  ### Changed
887
896
 
888
897
  - `Agent` constructor now has all options optional (empty options use defaults).
889
- - `queueMessage()` is now synchronous (no longer returns a Promise).
898
+ - `queueMessage()` is now synchronous (no longer returns a Promise).
@@ -24,7 +24,7 @@ export interface StablePrefixSnapshot {
24
24
  }
25
25
  /** Options threaded through `build()` so the snapshot reflects loop-time settings. */
26
26
  export interface BuildOptions {
27
- /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
27
+ /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
28
28
  intentTracing: boolean;
29
29
  exampleDialect?: Dialect;
30
30
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
@@ -230,7 +230,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
230
230
  *
231
231
  * When set, the loop reads messages from the append-only log (stable
232
232
  * byte prefix) and caches system prompt + tools. Tools exclude per-turn
233
- * `_i` intent fields.
233
+ * `i` intent fields.
234
234
  */
235
235
  appendOnlyContext?: AppendOnlyContextManager;
236
236
  /**
@@ -502,11 +502,11 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
502
502
  */
503
503
  interruptible?: boolean;
504
504
  /**
505
- * Controls how the INTENT_FIELD (`_i`) is handled for this tool.
506
- * - `"require"` (default): `_i` is injected and required in the parameter schema.
507
- * - `"optional"`: `_i` is injected as an optional/nullable field.
508
- * - `"omit"`: `_i` is NOT injected. Use for tools where intent is obvious (yield, resolve, todo, …).
509
- * - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
505
+ * Controls how the INTENT_FIELD (`i`) is handled for this tool.
506
+ * - `"require"` (default): `i` is injected and required in the parameter schema.
507
+ * - `"optional"`: `i` is injected as an optional/nullable field.
508
+ * - `"omit"`: `i` is NOT injected. Use for tools where intent is obvious (yield, resolve, todo, …).
509
+ * - function: `i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
510
510
  */
511
511
  intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
512
512
  /**
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": "16.1.0",
4
+ "version": "16.1.2",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.1.0",
39
- "@oh-my-pi/pi-catalog": "16.1.0",
40
- "@oh-my-pi/pi-natives": "16.1.0",
41
- "@oh-my-pi/pi-utils": "16.1.0",
42
- "@oh-my-pi/pi-wire": "16.1.0",
43
- "@oh-my-pi/snapcompact": "16.1.0",
38
+ "@oh-my-pi/pi-ai": "16.1.2",
39
+ "@oh-my-pi/pi-catalog": "16.1.2",
40
+ "@oh-my-pi/pi-natives": "16.1.2",
41
+ "@oh-my-pi/pi-utils": "16.1.2",
42
+ "@oh-my-pi/pi-wire": "16.1.2",
43
+ "@oh-my-pi/snapcompact": "16.1.2",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -40,7 +40,7 @@ import {
40
40
  signalListLabel,
41
41
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
42
42
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
43
- import { sanitizeText } from "@oh-my-pi/pi-utils";
43
+ import { sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
44
44
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
45
45
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
46
46
  import {
@@ -150,22 +150,6 @@ function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefi
150
150
 
151
151
  type AssistantContentBlock = AssistantMessage["content"][number];
152
152
  type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
153
- type CloneableRecord = Record<string, unknown>;
154
-
155
- function cloneUnknown(value: unknown): unknown {
156
- if (Array.isArray(value)) return value.map(cloneUnknown);
157
- if (!value || typeof value !== "object") return value;
158
- const source = value as CloneableRecord;
159
- const out: CloneableRecord = {};
160
- for (const [key, child] of Object.entries(source)) {
161
- out[key] = cloneUnknown(child);
162
- }
163
- return out;
164
- }
165
-
166
- function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] {
167
- return cloneUnknown(args) as AssistantToolCallBlock["arguments"];
168
- }
169
153
 
170
154
  function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
171
155
  switch (block.type) {
@@ -176,7 +160,7 @@ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantC
176
160
  case "redactedThinking":
177
161
  return { ...block };
178
162
  case "toolCall":
179
- return { ...block, arguments: cloneToolArguments(block.arguments) };
163
+ return { ...block, arguments: structuredCloneJSON(block.arguments) };
180
164
  }
181
165
  }
182
166
 
@@ -192,10 +176,19 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
192
176
  };
193
177
  }
194
178
 
195
- function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent {
179
+ /**
180
+ * Deep-clone an assistant streaming event so subscribers get an immutable view.
181
+ * Pass `partialSnapshot` when the caller has already snapshotted `event.partial`
182
+ * (the `message_update` push sites alias it as the event's `message`) so the
183
+ * identical partial is not deep-cloned twice per streaming delta.
184
+ */
185
+ function snapshotAssistantMessageEvent(
186
+ event: AssistantMessageEvent,
187
+ partialSnapshot?: AssistantMessage,
188
+ ): AssistantMessageEvent {
196
189
  switch (event.type) {
197
190
  case "start":
198
- return { ...event, partial: snapshotAssistantMessage(event.partial) };
191
+ return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
199
192
  case "text_start":
200
193
  case "text_delta":
201
194
  case "text_end":
@@ -204,12 +197,12 @@ function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantM
204
197
  case "thinking_end":
205
198
  case "toolcall_start":
206
199
  case "toolcall_delta":
207
- return { ...event, partial: snapshotAssistantMessage(event.partial) };
200
+ return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
208
201
  case "toolcall_end":
209
202
  return {
210
203
  ...event,
211
204
  toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
212
- partial: snapshotAssistantMessage(event.partial),
205
+ partial: partialSnapshot ?? snapshotAssistantMessage(event.partial),
213
206
  };
214
207
  case "done":
215
208
  return { ...event, message: snapshotAssistantMessage(event.message) };
@@ -598,7 +591,7 @@ export function normalizeTools(
598
591
  // specs without their descriptions (top-level + nested schema annotations)
599
592
  // so they are not duplicated on the wire. Strip the STABLE wire schema (the
600
593
  // memoized `stripSchemaDescriptions` result is reused across requests), then
601
- // re-inject `_i` (without its hint, which `describeIntent: false` omits) so
594
+ // re-inject `i` (without its hint, which `describeIntent: false` omits) so
602
595
  // intent tracing keeps the field while no descriptions ride the wire.
603
596
  if (pruneDescriptions) {
604
597
  let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
@@ -1412,10 +1405,15 @@ async function streamAssistantResponse(
1412
1405
  if (addedPartial) {
1413
1406
  context.messages[context.messages.length - 1] = partialMessage;
1414
1407
  completedToolCallIds.clear();
1408
+ // `message` and `assistantMessageEvent.partial` intentionally share one
1409
+ // immutable snapshot of the streaming partial: every message_update
1410
+ // consumer treats both as read-only, so cloning the identical partial
1411
+ // twice per delta was pure waste.
1412
+ const messageSnapshot = snapshotAssistantMessage(partialMessage);
1415
1413
  stream.push({
1416
1414
  type: "message_update",
1417
- assistantMessageEvent: snapshotAssistantMessageEvent(event),
1418
- message: snapshotAssistantMessage(partialMessage),
1415
+ assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
1416
+ message: messageSnapshot,
1419
1417
  });
1420
1418
  } else {
1421
1419
  context.messages.push(partialMessage);
@@ -1440,10 +1438,15 @@ async function streamAssistantResponse(
1440
1438
  partialMessage = event.partial;
1441
1439
  context.messages[context.messages.length - 1] = partialMessage;
1442
1440
  config.onAssistantMessageEvent?.(partialMessage, event);
1441
+ // `message` and `assistantMessageEvent.partial` intentionally share one
1442
+ // immutable snapshot of the streaming partial: every message_update
1443
+ // consumer treats both as read-only, so cloning the identical partial
1444
+ // twice per delta was pure waste.
1445
+ const messageSnapshot = snapshotAssistantMessage(partialMessage);
1443
1446
  stream.push({
1444
1447
  type: "message_update",
1445
- assistantMessageEvent: snapshotAssistantMessageEvent(event),
1446
- message: snapshotAssistantMessage(partialMessage),
1448
+ assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
1449
+ message: messageSnapshot,
1447
1450
  });
1448
1451
  }
1449
1452
  break;
@@ -1753,7 +1756,44 @@ async function executeToolCalls(
1753
1756
  }
1754
1757
  }
1755
1758
  }
1756
- record.args = argsForExecution;
1759
+ let effectiveArgs: Record<string, unknown>;
1760
+ try {
1761
+ if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
1762
+ effectiveArgs = validateToolArguments(tool, { ...toolCall, arguments: argsForExecution });
1763
+ } catch (validationError) {
1764
+ if (tool?.lenientArgValidation) {
1765
+ effectiveArgs = { ...argsForExecution };
1766
+ delete effectiveArgs.__parseError;
1767
+ delete effectiveArgs.__rawJson;
1768
+ } else {
1769
+ if ("__parseError" in argsForExecution) {
1770
+ record.args = {
1771
+ __parseError: argsForExecution.__parseError,
1772
+ };
1773
+ } else {
1774
+ record.args = argsForExecution;
1775
+ }
1776
+ emitToolResult(
1777
+ record,
1778
+ {
1779
+ content: [
1780
+ {
1781
+ type: "text" as const,
1782
+ text: validationError instanceof Error ? validationError.message : String(validationError),
1783
+ },
1784
+ ],
1785
+ details: {
1786
+ isError: true,
1787
+ error: validationError instanceof Error ? validationError.message : String(validationError),
1788
+ },
1789
+ },
1790
+ true,
1791
+ );
1792
+ return;
1793
+ }
1794
+ }
1795
+
1796
+ record.args = effectiveArgs;
1757
1797
  if (toolSignal.aborted) {
1758
1798
  record.skipped = true;
1759
1799
  recordSkippedTool(telemetry, {
@@ -1769,7 +1809,7 @@ async function executeToolCalls(
1769
1809
  type: "tool_execution_start",
1770
1810
  toolCallId: toolCall.id,
1771
1811
  toolName: toolCall.name,
1772
- args: argsForExecution,
1812
+ args: effectiveArgs,
1773
1813
  intent: toolCall.intent,
1774
1814
  });
1775
1815
 
@@ -1777,7 +1817,7 @@ async function executeToolCalls(
1777
1817
  tool,
1778
1818
  toolName: toolCall.name,
1779
1819
  toolCallId: toolCall.id,
1780
- args: argsForExecution,
1820
+ args: effectiveArgs,
1781
1821
  parent: invokeAgentSpan,
1782
1822
  });
1783
1823
  if (toolSpan && toolCall.intent) {
@@ -1798,17 +1838,6 @@ async function executeToolCalls(
1798
1838
  return;
1799
1839
  }
1800
1840
 
1801
- let effectiveArgs: Record<string, unknown>;
1802
- try {
1803
- effectiveArgs = validateToolArguments(tool, { ...toolCall, arguments: argsForExecution });
1804
- } catch (validationError) {
1805
- if (tool.lenientArgValidation) {
1806
- effectiveArgs = argsForExecution;
1807
- } else {
1808
- throw validationError;
1809
- }
1810
- }
1811
-
1812
1841
  if (beforeToolCall) {
1813
1842
  const beforeResult = await beforeToolCall(
1814
1843
  {
@@ -32,7 +32,7 @@ export interface StablePrefixSnapshot {
32
32
 
33
33
  /** Options threaded through `build()` so the snapshot reflects loop-time settings. */
34
34
  export interface BuildOptions {
35
- /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
35
+ /** Inject the `i` intent field into tool schemas (must match agent-loop's normalizeTools). */
36
36
  intentTracing: boolean;
37
37
  exampleDialect?: Dialect;
38
38
  /** Strip tool descriptions from the provider-bound specs (must match normalizeTools). */
package/src/types.ts CHANGED
@@ -273,7 +273,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
273
273
  *
274
274
  * When set, the loop reads messages from the append-only log (stable
275
275
  * byte prefix) and caches system prompt + tools. Tools exclude per-turn
276
- * `_i` intent fields.
276
+ * `i` intent fields.
277
277
  */
278
278
  appendOnlyContext?: AppendOnlyContextManager;
279
279
 
@@ -584,11 +584,11 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
584
584
  */
585
585
  interruptible?: boolean;
586
586
  /**
587
- * Controls how the INTENT_FIELD (`_i`) is handled for this tool.
588
- * - `"require"` (default): `_i` is injected and required in the parameter schema.
589
- * - `"optional"`: `_i` is injected as an optional/nullable field.
590
- * - `"omit"`: `_i` is NOT injected. Use for tools where intent is obvious (yield, resolve, todo, …).
591
- * - function: `_i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
587
+ * Controls how the INTENT_FIELD (`i`) is handled for this tool.
588
+ * - `"require"` (default): `i` is injected and required in the parameter schema.
589
+ * - `"optional"`: `i` is injected as an optional/nullable field.
590
+ * - `"omit"`: `i` is NOT injected. Use for tools where intent is obvious (yield, resolve, todo, …).
591
+ * - function: `i` is NOT injected; intent is derived dynamically from (potentially partial / streaming) args.
592
592
  */
593
593
  intent?: "omit" | "optional" | "require" | ((args: Partial<Static<TParameters>>) => string | undefined);
594
594