@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 +10 -1
- package/dist/types/append-only-context.d.ts +1 -1
- package/dist/types/types.d.ts +6 -6
- package/package.json +7 -7
- package/src/agent-loop.ts +70 -41
- package/src/append-only-context.ts +1 -1
- package/src/types.ts +6 -6
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 `
|
|
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). */
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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 (`
|
|
506
|
-
* - `"require"` (default): `
|
|
507
|
-
* - `"optional"`: `
|
|
508
|
-
* - `"omit"`: `
|
|
509
|
-
* - function: `
|
|
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.
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.1.
|
|
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:
|
|
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
|
-
|
|
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 `
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 `
|
|
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
|
-
* `
|
|
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 (`
|
|
588
|
-
* - `"require"` (default): `
|
|
589
|
-
* - `"optional"`: `
|
|
590
|
-
* - `"omit"`: `
|
|
591
|
-
* - function: `
|
|
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
|
|