@oh-my-pi/pi-agent-core 15.13.0 → 15.13.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/src/agent-loop.ts CHANGED
@@ -15,7 +15,13 @@ import {
15
15
  validateToolArguments,
16
16
  zodToWireSchema,
17
17
  } from "@oh-my-pi/pi-ai";
18
- import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
18
+ import {
19
+ encodeInbandToolHistory,
20
+ renderInbandToolPrompt,
21
+ renderToolExamples,
22
+ type ToolCallSyntax,
23
+ wrapInbandToolStream,
24
+ } from "@oh-my-pi/pi-ai/grammar";
19
25
  import {
20
26
  createHarmonyAuditEvent,
21
27
  detectHarmonyLeakInAssistantMessage,
@@ -25,7 +31,9 @@ import {
25
31
  isHarmonyLeakMitigationTarget,
26
32
  recoverHarmonyToolCall,
27
33
  signalListLabel,
28
- } from "./harmony-leak";
34
+ } from "@oh-my-pi/pi-ai/utils/harmony-leak";
35
+ import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
36
+ import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
29
37
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
30
38
  import {
31
39
  type AgentTelemetry,
@@ -76,6 +84,25 @@ class HarmonyLeakInterruption extends Error {
76
84
  this.name = "HarmonyLeakInterruption";
77
85
  }
78
86
  }
87
+ function resolveOwnedToolSyntaxFromEnv(value: string | undefined): ToolCallSyntax | undefined {
88
+ switch (value) {
89
+ case "1":
90
+ case "true":
91
+ return "glm";
92
+ case "glm":
93
+ case "hermes":
94
+ case "kimi":
95
+ case "xml":
96
+ case "anthropic":
97
+ case "deepseek":
98
+ case "harmony":
99
+ case "pi":
100
+ case "qwen3":
101
+ return value;
102
+ default:
103
+ return undefined;
104
+ }
105
+ }
79
106
 
80
107
  type AssistantContentBlock = AssistantMessage["content"][number];
81
108
  type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
@@ -483,6 +510,7 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
483
510
  properties: {
484
511
  [INTENT_FIELD]: {
485
512
  type: "string",
513
+ description: "Concise intent in present participle form (2-6 words) strictly on a single line, no newlines",
486
514
  },
487
515
  ...properties,
488
516
  },
@@ -490,7 +518,11 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
490
518
  };
491
519
  }
492
520
 
493
- export function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean): Context["tools"] {
521
+ export function normalizeTools(
522
+ tools: AgentContext["tools"],
523
+ injectIntent: boolean,
524
+ exampleSyntax?: ToolCallSyntax,
525
+ ): Context["tools"] {
494
526
  injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
495
527
  return tools?.map(t => {
496
528
  const intentMode = resolveIntentMode(t.intent);
@@ -504,7 +536,12 @@ export function normalizeTools(tools: AgentContext["tools"], injectIntent: boole
504
536
  }
505
537
  }
506
538
  const description = t.description ?? "";
507
- return { ...t, parameters, description };
539
+ const injectExampleIntent = injectIntent && intentMode !== "omit";
540
+ const examplesBlock = exampleSyntax
541
+ ? renderToolExamples({ ...t, parameters }, exampleSyntax, injectExampleIntent ? INTENT_FIELD : undefined)
542
+ : "";
543
+ const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
544
+ return { ...t, parameters, description: finalDescription };
508
545
  });
509
546
  }
510
547
 
@@ -883,18 +920,37 @@ async function streamAssistantResponse(
883
920
  let llmContext: Context;
884
921
  if (config.appendOnlyContext) {
885
922
  config.appendOnlyContext.syncMessages(normalizedMessages);
886
- llmContext = config.appendOnlyContext.build(context, { intentTracing: !!config.intentTracing });
923
+ llmContext = config.appendOnlyContext.build(context, {
924
+ intentTracing: !!config.intentTracing,
925
+ exampleSyntax: preferredToolSyntax(config.model.id),
926
+ });
887
927
  } else {
888
928
  llmContext = {
889
929
  systemPrompt: context.systemPrompt,
890
930
  messages: normalizedMessages,
891
- tools: normalizeTools(context.tools, !!config.intentTracing),
931
+ tools: normalizeTools(context.tools, !!config.intentTracing, preferredToolSyntax(config.model.id)),
892
932
  };
893
933
  }
894
934
  if (config.transformProviderContext) {
895
935
  llmContext = config.transformProviderContext(llmContext, config.model);
896
936
  }
897
937
 
938
+ // Owned tool calling: take tool calls away from the provider and run them
939
+ // through the selected in-band prompt syntax. `PI_OWNED_TOOLS=1` still
940
+ // force-enables GLM; `PI_OWNED_TOOLS=<syntax>` force-enables that syntax.
941
+ const ownedSyntax: ToolCallSyntax | undefined =
942
+ config.toolCallSyntax ?? resolveOwnedToolSyntaxFromEnv(Bun.env.PI_OWNED_TOOLS);
943
+ let promptToolWireTools: Context["tools"];
944
+ if (ownedSyntax && llmContext.tools && llmContext.tools.length > 0) {
945
+ promptToolWireTools = llmContext.tools;
946
+ llmContext = {
947
+ ...llmContext,
948
+ systemPrompt: [...(llmContext.systemPrompt ?? []), renderInbandToolPrompt(promptToolWireTools, ownedSyntax)],
949
+ messages: encodeInbandToolHistory(llmContext.messages, ownedSyntax, promptToolWireTools),
950
+ tools: undefined,
951
+ };
952
+ }
953
+
898
954
  const streamFunction = streamFn || streamSimple;
899
955
 
900
956
  // Resolve API key (important for expiring tokens) — do this before resolving
@@ -919,12 +975,22 @@ async function streamAssistantResponse(
919
975
  : harmonyAbortController.signal
920
976
  : signal;
921
977
  const repetitionAbortController = new AbortController();
922
- const finalRequestSignal = requestSignal
923
- ? AbortSignal.any([requestSignal, repetitionAbortController.signal])
924
- : repetitionAbortController.signal;
978
+ // Owned tool calling: aborted by the stream wrapper when the model starts
979
+ // fabricating a `<tool_response>`, so the provider stops generating the rest of
980
+ // the hallucinated turn. Merged into the provider signal ONLY (not
981
+ // `requestSignal`), so it cancels the request without tripping the loop's
982
+ // external-abort handling (`abortRacePromise` / `requestSignal.aborted`).
983
+ const promptToolAbortController = ownedSyntax ? new AbortController() : undefined;
984
+ const providerAbortSignals: AbortSignal[] = [];
985
+ if (requestSignal) providerAbortSignals.push(requestSignal);
986
+ providerAbortSignals.push(repetitionAbortController.signal);
987
+ if (promptToolAbortController) providerAbortSignals.push(promptToolAbortController.signal);
988
+ const finalRequestSignal =
989
+ providerAbortSignals.length === 1 ? providerAbortSignals[0]! : AbortSignal.any(providerAbortSignals);
925
990
  const effectiveTemperature =
926
991
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
927
- const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
992
+ // Owned tool calling sends no native tools, so any tool_choice would error.
993
+ const effectiveToolChoice = ownedSyntax ? undefined : (dynamicToolChoice ?? config.toolChoice);
928
994
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
929
995
  const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
930
996
 
@@ -969,7 +1035,7 @@ async function streamAssistantResponse(
969
1035
 
970
1036
  try {
971
1037
  return await runInActiveSpan(chatSpan, async () => {
972
- const response = await streamFunction(config.model, llmContext, {
1038
+ let response = await streamFunction(config.model, llmContext, {
973
1039
  ...config,
974
1040
  // Hand streamSimple a resolver so its central auth-retry policy can
975
1041
  // re-resolve on 401 / usage-limit: the initial step reuses the key
@@ -992,6 +1058,20 @@ async function streamAssistantResponse(
992
1058
  signal: finalRequestSignal,
993
1059
  onResponse: captureOnResponse,
994
1060
  });
1061
+ if (promptToolWireTools && ownedSyntax) {
1062
+ // Re-materialize in-band tool-call text as native toolCall content blocks
1063
+ // so the rest of the loop executes them unchanged. When the model starts
1064
+ // fabricating tool results, the abort callback cancels the provider — unless
1065
+ // `abortOnFabricatedToolResult` is false, in which case the stream drains and
1066
+ // the fabricated continuation is discarded without aborting.
1067
+ response = wrapInbandToolStream(
1068
+ response,
1069
+ promptToolWireTools,
1070
+ ownedSyntax,
1071
+ () => promptToolAbortController?.abort(),
1072
+ config.abortOnFabricatedToolResult ?? true,
1073
+ );
1074
+ }
995
1075
 
996
1076
  let partialMessage: AssistantMessage | null = null;
997
1077
  let addedPartial = false;
package/src/agent.ts CHANGED
@@ -22,11 +22,12 @@ import {
22
22
  type ToolChoice,
23
23
  type ToolResultMessage,
24
24
  } from "@oh-my-pi/pi-ai";
25
+ import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
26
+ import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
25
27
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
26
28
  import { logger } from "@oh-my-pi/pi-utils";
27
29
  import { abortReasonText, agentLoop, agentLoopContinue } from "./agent-loop";
28
30
  import type { AppendOnlyContextManager } from "./append-only-context";
29
- import type { HarmonyAuditEvent } from "./harmony-leak";
30
31
  import type {
31
32
  AgentContext,
32
33
  AgentEvent,
@@ -220,6 +221,15 @@ export interface AgentOptions {
220
221
 
221
222
  /** Enable intent tracing schema injection/stripping in the harness. */
222
223
  intentTracing?: boolean;
224
+ /** Owned tool-calling syntax. Undefined keeps provider-native tool calling. */
225
+ toolCallSyntax?: ToolCallSyntax;
226
+ /**
227
+ * When owned tool calling is active and the model fabricates a tool result
228
+ * mid-turn: `true` (default) aborts the provider request immediately; `false`
229
+ * drains the request and discards the fabricated continuation. Forwarded to
230
+ * the loop's {@link AgentLoopConfig.abortOnFabricatedToolResult}.
231
+ */
232
+ abortOnFabricatedToolResult?: boolean;
223
233
  /** Dynamic tool choice override, resolved per LLM call. */
224
234
  getToolChoice?: () => ToolChoice | undefined;
225
235
 
@@ -316,6 +326,8 @@ export class Agent {
316
326
  #preferWebsockets?: boolean;
317
327
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
318
328
  #intentTracing: boolean;
329
+ #toolCallSyntax?: ToolCallSyntax;
330
+ #abortOnFabricatedToolResult?: boolean;
319
331
  #getToolChoice?: () => ToolChoice | undefined;
320
332
  #onPayload?: SimpleStreamOptions["onPayload"];
321
333
  #onResponse?: SimpleStreamOptions["onResponse"];
@@ -378,6 +390,8 @@ export class Agent {
378
390
  this.#preferWebsockets = opts.preferWebsockets;
379
391
  this.#transformToolCallArguments = opts.transformToolCallArguments;
380
392
  this.#intentTracing = opts.intentTracing === true;
393
+ this.#toolCallSyntax = opts.toolCallSyntax;
394
+ this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
381
395
  this.#getToolChoice = opts.getToolChoice;
382
396
  this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
383
397
  this.#onHarmonyLeak = opts.onHarmonyLeak;
@@ -657,8 +671,8 @@ export class Agent {
657
671
  }
658
672
 
659
673
  // State mutators
660
- setSystemPrompt(v: string[]) {
661
- this.#state.systemPrompt = v;
674
+ setSystemPrompt(v: string[] | string) {
675
+ this.#state.systemPrompt = typeof v === "string" ? [v] : v;
662
676
  }
663
677
 
664
678
  setModel(m: Model) {
@@ -1023,6 +1037,8 @@ export class Agent {
1023
1037
  cursorOnToolResult,
1024
1038
  transformToolCallArguments: this.#transformToolCallArguments,
1025
1039
  intentTracing: this.#intentTracing,
1040
+ toolCallSyntax: this.#toolCallSyntax,
1041
+ abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
1026
1042
  appendOnlyContext: this.#appendOnlyContext,
1027
1043
  beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
1028
1044
  afterToolCall: this.afterToolCall ? (ctx, signal) => this.afterToolCall?.(ctx, signal) : undefined,
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
18
+ import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
18
19
  import { normalizeTools } from "./agent-loop";
19
20
  import type { AgentContext } from "./types";
20
21
 
@@ -33,6 +34,7 @@ export interface StablePrefixSnapshot {
33
34
  export interface BuildOptions {
34
35
  /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
35
36
  intentTracing: boolean;
37
+ exampleSyntax?: ToolCallSyntax;
36
38
  }
37
39
 
38
40
  /**
@@ -268,7 +270,7 @@ export class AppendOnlyContextManager {
268
270
 
269
271
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
270
272
  const systemPrompt = [...context.systemPrompt];
271
- const tools = normalizeTools(context.tools, options.intentTracing) ?? [];
273
+ const tools = normalizeTools(context.tools, options.intentTracing, options.exampleSyntax) ?? [];
272
274
  return {
273
275
  systemPrompt,
274
276
  tools,
@@ -288,6 +290,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
288
290
  cw: t.customWireName,
289
291
  })),
290
292
  i: options.intentTracing,
293
+ ex: options.exampleSyntax,
291
294
  });
292
295
  let hash = 0;
293
296
  for (let i = 0; i < payload.length; i++) {
package/src/index.ts CHANGED
@@ -6,7 +6,6 @@ export * from "./agent-loop";
6
6
  export * from "./append-only-context";
7
7
  // Compaction
8
8
  export * from "./compaction";
9
- export * from "./harmony-leak";
10
9
  // Proxy utilities
11
10
  export * from "./proxy";
12
11
  // Run-level telemetry collector + aggregators
package/src/types.ts CHANGED
@@ -17,8 +17,9 @@ import type {
17
17
  ToolResultMessage,
18
18
  TSchema,
19
19
  } from "@oh-my-pi/pi-ai";
20
+ import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
21
+ import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
20
22
  import type { AppendOnlyContextManager } from "./append-only-context";
21
- import type { HarmonyAuditEvent } from "./harmony-leak";
22
23
  import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
23
24
  import type { AgentTelemetryConfig } from "./telemetry";
24
25
 
@@ -199,6 +200,27 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
199
200
  * then strips from arguments before executing tools.
200
201
  */
201
202
  intentTracing?: boolean;
203
+ /**
204
+ * Owned tool calling syntax.
205
+ *
206
+ * Undefined keeps provider-native tool calling. A syntax value sends no
207
+ * native `tools`, forces `toolChoice` off, appends that syntax's tool catalog
208
+ * instructions, re-encodes prior tool calls/results as text, and parses the
209
+ * model's text output back into canonical `toolCall` blocks.
210
+ */
211
+ toolCallSyntax?: ToolCallSyntax;
212
+ /**
213
+ * When owned (in-band) tool calling is active and the model starts
214
+ * fabricating a tool result inside its own turn, control how the loop reacts:
215
+ * - `true` (default): abort the provider request immediately so it stops
216
+ * generating the hallucinated continuation (cheaper, lower latency).
217
+ * - `false`: let the request finish and silently discard everything past the
218
+ * fabrication boundary (keeps the connection alive but pays for the tokens
219
+ * the model spends on the discarded tail).
220
+ * Only meaningful when {@link toolCallSyntax} (or `PI_OWNED_TOOLS`) selects an
221
+ * owned syntax; native tool calling never fabricates results in text.
222
+ */
223
+ abortOnFabricatedToolResult?: boolean;
202
224
  /**
203
225
  * Append-only context mode — stabilizes system prompt + tool spec bytes
204
226
  * across turns so provider prefix caches hit at maximum rate.
@@ -1,118 +0,0 @@
1
- /**
2
- * GPT-5 Harmony-header leakage detection and recovery.
3
- *
4
- * Background and policy: see `docs/ERRATA-GPT5-HARMONY.md`. This module
5
- * implements §3 of that document: detection by signal fusion, plus a
6
- * truncate-and-resume primitive for the `edit` tool when its input is in
7
- * hashline DSL form. Other tools and surfaces fall through to
8
- * abort-and-retry handled by the agent loop.
9
- */
10
- import type { AssistantMessage, Model, ToolCall } from "@oh-my-pi/pi-ai";
11
- declare const SIGNAL_ORDER: readonly ["M", "C", "G", "S", "B", "R", "T"];
12
- export type HarmonySignalClass = "H" | (typeof SIGNAL_ORDER)[number];
13
- export type HarmonySurface = "assistant_text" | "assistant_thinking" | "tool_arg";
14
- export interface HarmonySignal {
15
- classes: HarmonySignalClass[];
16
- start: number;
17
- end: number;
18
- text: string;
19
- }
20
- export interface HarmonyDetection {
21
- surface: HarmonySurface;
22
- contentIndex?: number;
23
- toolName?: string;
24
- toolCallId?: string;
25
- signals: HarmonySignal[];
26
- }
27
- export interface HarmonyAuditEvent {
28
- action: "truncate_resume" | "abort_retry" | "escalated";
29
- surface: HarmonySurface;
30
- signal: string;
31
- retryN: number;
32
- model: string;
33
- provider: string;
34
- toolName?: string;
35
- removedLen: number;
36
- removedSha8: string;
37
- removedPreview: string;
38
- removedBlob?: string;
39
- }
40
- export interface HarmonyRecoveredToolCall {
41
- message: AssistantMessage;
42
- removed: string;
43
- }
44
- /**
45
- * Whether to run leak detection on responses from this model. We default-on
46
- * for every openai-codex model rather than enumerating ids, so a future
47
- * gpt-5.6 (or whatever) doesn't silently bypass the mitigation. Detection
48
- * itself is cheap; the cost of missing a leak on a new model is not.
49
- */
50
- export declare function isHarmonyLeakMitigationTarget(model: Model): boolean;
51
- export declare function signalListLabel(signals: readonly HarmonySignal[]): string;
52
- /**
53
- * Detect harmony-protocol leakage in `text`. Returns undefined if clean.
54
- *
55
- * Trip rule: `H` alone, or `M` paired with at least one co-signal
56
- * (`C`/`G`/`S`/`B`/`R`/`T`). Bare `M` does not trip — this document, its
57
- * tests, and bug reports legitimately carry the marker.
58
- *
59
- * The `tool_arg` surface is held to a stricter rule. A tool argument is
60
- * arbitrary file/data content that can legitimately carry the marker, a
61
- * channel word, harmony control tokens, or a non-Latin script run (editing
62
- * these very fixtures does exactly that). The only robust leak signal there
63
- * is content trailing the structurally-valid parse, so a `tool_arg` detection
64
- * additionally requires the `T` co-signal. Absent a `parsedEnd` boundary `T`
65
- * is never set, so `tool_arg` scanning stays inert and a legitimate codex tool
66
- * call is never hard-aborted. `assistant_text`/`assistant_thinking` keep the
67
- * base rule.
68
- *
69
- * `parsedEnd`, when supplied, marks the byte at which a structurally valid
70
- * tool-argument parse ends; markers at or past it set the `T` co-signal.
71
- * `contentIndex`/`toolName`/`toolCallId` flow through to the returned
72
- * detection for downstream auditing.
73
- */
74
- export declare function detectHarmonyLeak(text: string, surface: HarmonySurface, options?: {
75
- parsedEnd?: number;
76
- contentIndex?: number;
77
- toolName?: string;
78
- toolCallId?: string;
79
- }): HarmonyDetection | undefined;
80
- /**
81
- * Scan an assistant message's content blocks; return the first detection.
82
- *
83
- * `toolArgParseEnd`, when supplied, resolves the byte offset at which a tool
84
- * call's structurally-valid argument parse ends (the `T` co-signal in
85
- * {@link detectHarmonyLeak}). Callers that can parse a tool's argument DSL pass
86
- * it to enable `tool_arg` leak detection; omitting it keeps that surface inert
87
- * — the safe default the agent loop relies on, since it cannot bound a streamed
88
- * tool DSL and must never hard-abort a legitimate tool call.
89
- */
90
- export declare function detectHarmonyLeakInAssistantMessage(message: AssistantMessage, toolArgParseEnd?: (toolCall: ToolCall) => number | undefined): HarmonyDetection | undefined;
91
- /**
92
- * Truncate a contaminated tool call at the start of the contaminated line and
93
- * append the tool's recovery sentinel. Returns a recovered AssistantMessage
94
- * (containing only the cleaned tool call), a synthetic continuation user
95
- * message asking the model to re-issue the rest, and the removed substring
96
- * for auditing. Returns undefined when the tool is not recovery-eligible or
97
- * the truncation would leave nothing meaningful to dispatch.
98
- *
99
- * `providerPayload` is dropped from the recovered message: for Codex the
100
- * encrypted reasoning blob is opaque/signed and we cannot validate that it is
101
- * uncontaminated. The model re-reasons on the next turn.
102
- */
103
- export declare function recoverHarmonyToolCall(message: AssistantMessage, detection: HarmonyDetection): HarmonyRecoveredToolCall | undefined;
104
- /**
105
- * Return the contaminated substring from `message` for audit purposes when
106
- * recovery is not applicable (abort path). Walks from the first detected
107
- * signal to end-of-content within the relevant block. Returns "" if the
108
- * detection cannot be resolved against the message.
109
- */
110
- export declare function extractHarmonyRemoved(message: AssistantMessage, detection: HarmonyDetection): string;
111
- export declare function createHarmonyAuditEvent(params: {
112
- action: HarmonyAuditEvent["action"];
113
- detection: HarmonyDetection;
114
- model: Model;
115
- retryN: number;
116
- removed: string;
117
- }): HarmonyAuditEvent;
118
- export {};