@oh-my-pi/pi-agent-core 15.13.3 → 16.0.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,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.0] - 2026-06-15
6
+
7
+ ### Breaking Changes
8
+
9
+ - Renamed owned tool-calling options from `toolCallSyntax`/`exampleSyntax` to `dialect`/`exampleDialect`.
10
+ - Changed compaction conversation serialization to use the target model's native dialect turn, thinking, tool-call, and tool-result envelopes when a dialect is selected.
11
+ - Renamed the owned dialect environment variable from `PI_OWNED_TOOLS` to `PI_DIALECT`.
12
+
13
+ ### Added
14
+
15
+ - Added `onTurnEnd` hook support (`setOnTurnEnd`/`onTurnEnd`) to run awaited per-turn bookkeeping with current messages before the next model request and skip callback execution for aborted or error turns
16
+
17
+ ### Changed
18
+
19
+ - Renamed `toolCallSyntax` option to `dialect` in AgentOptions and AgentLoopConfig
20
+ - Updated conversation serialization to use dialect's native transcript rendering when a dialect is selected
21
+ - Changed internal references from `ToolCallSyntax` type to `Dialect` type across agent loop and compaction modules
22
+
5
23
  ## [15.13.3] - 2026-06-15
6
24
 
7
25
  ### Added
@@ -3,7 +3,7 @@
3
3
  * Transforms to Message[] only at the LLM call boundary.
4
4
  */
5
5
  import { type Context, EventStream } from "@oh-my-pi/pi-ai";
6
- import { type ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
6
+ import { type Dialect } from "@oh-my-pi/pi-ai/dialect";
7
7
  import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector";
8
8
  import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types";
9
9
  /**
@@ -53,7 +53,7 @@ export declare function agentLoopContinueDetailed(context: AgentContext, config:
53
53
  readonly detailed: () => Promise<AgentLoopDetailedResult>;
54
54
  };
55
55
  export declare const INTENT_FIELD = "_i";
56
- export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean, exampleSyntax?: ToolCallSyntax): Context["tools"];
56
+ export declare function normalizeTools(tools: AgentContext["tools"], injectIntent: boolean, exampleDialect?: Dialect): Context["tools"];
57
57
  /** Resolve the human-readable reason an abort carried. A caller that aborts via
58
58
  * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
59
59
  * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
@@ -1,5 +1,5 @@
1
1
  import { type ApiKeyResolveContext, type AssistantMessage, type AssistantMessageEvent, type Context, type CursorExecHandlers, type CursorToolResultHandler, type Effort, type ImageContent, type Message, type Model, type ProviderSessionState, type ServiceTier, type SimpleStreamOptions, type ThinkingBudgets, type ToolChoice } from "@oh-my-pi/pi-ai";
2
- import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
2
+ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context";
5
5
  import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AsideMessage, StreamFn, ToolCallContext } from "./types";
@@ -127,8 +127,8 @@ export interface AgentOptions {
127
127
  transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
128
128
  /** Enable intent tracing schema injection/stripping in the harness. */
129
129
  intentTracing?: boolean;
130
- /** Owned tool-calling syntax. Undefined keeps provider-native tool calling. */
131
- toolCallSyntax?: ToolCallSyntax;
130
+ /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
131
+ dialect?: Dialect;
132
132
  /**
133
133
  * When owned tool calling is active and the model fabricates a tool result
134
134
  * mid-turn: `true` (default) aborts the provider request immediately; `false`
@@ -299,6 +299,7 @@ export declare class Agent {
299
299
  setRawSseEventInterceptor(fn: SimpleStreamOptions["onSseEvent"] | undefined): void;
300
300
  setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
301
301
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
302
+ setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void) | undefined): void;
302
303
  /**
303
304
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
304
305
  * completions, late LSP diagnostics) drained at each step boundary. Never
@@ -14,7 +14,7 @@
14
14
  * message delta is a cache miss each turn.
15
15
  */
16
16
  import type { Context, Message, Tool } from "@oh-my-pi/pi-ai";
17
- import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
17
+ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
18
18
  import type { AgentContext } from "./types";
19
19
  /** Frozen system prompt + tool spec snapshot. */
20
20
  export interface StablePrefixSnapshot {
@@ -26,7 +26,7 @@ export interface StablePrefixSnapshot {
26
26
  export interface BuildOptions {
27
27
  /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
28
28
  intentTracing: boolean;
29
- exampleSyntax?: ToolCallSyntax;
29
+ exampleDialect?: Dialect;
30
30
  }
31
31
  /**
32
32
  * A frozen prefix (system prompt + tools) that produces stable byte
@@ -2,7 +2,7 @@
2
2
  * Shared utilities for compaction and branch summarization.
3
3
  */
4
4
  import type { Message } from "@oh-my-pi/pi-ai";
5
- import { type ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
5
+ import { type Dialect } from "@oh-my-pi/pi-ai/dialect";
6
6
  import type { AgentMessage } from "../types";
7
7
  export interface FileOperations {
8
8
  read: Set<string>;
@@ -45,5 +45,5 @@ export declare function upsertFileOperations(summary: string, readFiles: string[
45
45
  * This prevents the model from treating it as a conversation to continue.
46
46
  * Call convertToLlm() first to handle custom message types.
47
47
  */
48
- export declare function serializeConversation(messages: Message[], syntax?: ToolCallSyntax): string;
48
+ export declare function serializeConversation(messages: Message[], dialect?: Dialect): string;
49
49
  export declare const SUMMARIZATION_SYSTEM_PROMPT: string;
@@ -1,5 +1,5 @@
1
1
  import type { ApiKeyResolveContext, AssistantMessage, AssistantMessageEvent, AssistantMessageEventStream, Context, Effort, ImageContent, Message, Model, SimpleStreamOptions, Static, streamSimple, TextContent, Tool, ToolChoice, ToolResultMessage, TSchema } from "@oh-my-pi/pi-ai";
2
- import type { ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
2
+ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
3
3
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
4
4
  import type { AppendOnlyContextManager } from "./append-only-context";
5
5
  import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
@@ -164,14 +164,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
164
164
  */
165
165
  intentTracing?: boolean;
166
166
  /**
167
- * Owned tool calling syntax.
167
+ * Owned tool calling dialect.
168
168
  *
169
- * Undefined keeps provider-native tool calling. A syntax value sends no
170
- * native `tools`, forces `toolChoice` off, appends that syntax's tool catalog
169
+ * Undefined keeps provider-native tool calling. A dialect value sends no
170
+ * native `tools`, forces `toolChoice` off, appends that dialect's tool catalog
171
171
  * instructions, re-encodes prior tool calls/results as text, and parses the
172
172
  * model's text output back into canonical `toolCall` blocks.
173
173
  */
174
- toolCallSyntax?: ToolCallSyntax;
174
+ dialect?: Dialect;
175
175
  /**
176
176
  * When owned (in-band) tool calling is active and the model starts
177
177
  * fabricating a tool result inside its own turn, control how the loop reacts:
@@ -180,8 +180,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
180
180
  * - `false`: let the request finish and silently discard everything past the
181
181
  * fabrication boundary (keeps the connection alive but pays for the tokens
182
182
  * the model spends on the discarded tail).
183
- * Only meaningful when {@link toolCallSyntax} (or `PI_OWNED_TOOLS`) selects an
184
- * owned syntax; native tool calling never fabricates results in text.
183
+ * Only meaningful when {@link dialect} (or `PI_DIALECT`) selects an
184
+ * owned dialect; native tool calling never fabricates results in text.
185
185
  */
186
186
  abortOnFabricatedToolResult?: boolean;
187
187
  /**
@@ -236,6 +236,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
236
236
  * rest of the batch.
237
237
  */
238
238
  beforeToolCall?: (context: BeforeToolCallContext, signal?: AbortSignal) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
239
+ /**
240
+ * Called after a turn ends and before the loop polls steering/asides for the
241
+ * next iteration. Use this for awaited per-turn bookkeeping that must be
242
+ * visible before the next model request (e.g. synchronizing an advisor's
243
+ * backlog so advice produced during the wait is injected as an aside).
244
+ */
245
+ onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
239
246
  /**
240
247
  * Called after a tool finishes executing, before `tool_execution_end` and the
241
248
  * tool-result message are emitted.
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": "15.13.3",
4
+ "version": "16.0.0",
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,11 +35,11 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.13.3",
39
- "@oh-my-pi/pi-catalog": "15.13.3",
40
- "@oh-my-pi/pi-natives": "15.13.3",
41
- "@oh-my-pi/pi-utils": "15.13.3",
42
- "@oh-my-pi/snapcompact": "15.13.3",
38
+ "@oh-my-pi/pi-ai": "16.0.0",
39
+ "@oh-my-pi/pi-catalog": "16.0.0",
40
+ "@oh-my-pi/pi-natives": "16.0.0",
41
+ "@oh-my-pi/pi-utils": "16.0.0",
42
+ "@oh-my-pi/snapcompact": "16.0.0",
43
43
  "@opentelemetry/api": "^1.9.1"
44
44
  },
45
45
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -16,12 +16,12 @@ import {
16
16
  zodToWireSchema,
17
17
  } from "@oh-my-pi/pi-ai";
18
18
  import {
19
+ type Dialect,
19
20
  encodeInbandToolHistory,
20
21
  renderInbandToolPrompt,
21
22
  renderToolExamples,
22
- type ToolCallSyntax,
23
23
  wrapInbandToolStream,
24
- } from "@oh-my-pi/pi-ai/grammar";
24
+ } from "@oh-my-pi/pi-ai/dialect";
25
25
  import {
26
26
  createHarmonyAuditEvent,
27
27
  detectHarmonyLeakInAssistantMessage,
@@ -32,7 +32,7 @@ import {
32
32
  recoverHarmonyToolCall,
33
33
  signalListLabel,
34
34
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
35
- import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
35
+ import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
36
36
  import { logger, sanitizeText } from "@oh-my-pi/pi-utils";
37
37
  import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
38
38
  import {
@@ -92,7 +92,7 @@ class HarmonyLeakInterruption extends Error {
92
92
  this.name = "HarmonyLeakInterruption";
93
93
  }
94
94
  }
95
- function resolveOwnedToolSyntaxFromEnv(value: string | undefined): ToolCallSyntax | undefined {
95
+ function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
96
96
  switch (value) {
97
97
  case "1":
98
98
  case "true":
@@ -371,6 +371,25 @@ function buildAgentEndEvent(
371
371
  }
372
372
  return { type: "agent_end", messages, telemetry: snapshot.summary, coverage: snapshot.coverage };
373
373
  }
374
+ /**
375
+ * Push a `turn_end` event and run the awaited per-turn hook when the run is
376
+ * still healthy. The hook is skipped for externally aborted or errored turns so
377
+ * a user interrupt does not hang on a background backlog wait.
378
+ */
379
+ async function emitTurnEnd(
380
+ stream: EventStream<AgentEvent, AgentMessage[]>,
381
+ currentContext: AgentContext,
382
+ message: AgentMessage,
383
+ toolResults: ToolResultMessage[],
384
+ config: AgentLoopConfig,
385
+ signal?: AbortSignal,
386
+ ): Promise<void> {
387
+ stream.push({ type: "turn_end", message, toolResults });
388
+ const isAbortedOrError =
389
+ message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
390
+ if (signal?.aborted || isAbortedOrError) return;
391
+ await config.onTurnEnd?.(currentContext.messages, signal);
392
+ }
374
393
 
375
394
  /**
376
395
  * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
@@ -531,7 +550,7 @@ function injectIntentIntoSchema(schema: unknown, mode: "require" | "optional" =
531
550
  export function normalizeTools(
532
551
  tools: AgentContext["tools"],
533
552
  injectIntent: boolean,
534
- exampleSyntax?: ToolCallSyntax,
553
+ exampleDialect?: Dialect,
535
554
  ): Context["tools"] {
536
555
  injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
537
556
  return tools?.map(t => {
@@ -547,8 +566,8 @@ export function normalizeTools(
547
566
  }
548
567
  const description = t.description ?? "";
549
568
  const injectExampleIntent = injectIntent && intentMode !== "omit";
550
- const examplesBlock = exampleSyntax
551
- ? renderToolExamples({ ...t, parameters }, exampleSyntax, injectExampleIntent ? INTENT_FIELD : undefined)
569
+ const examplesBlock = exampleDialect
570
+ ? renderToolExamples({ ...t, parameters }, exampleDialect, injectExampleIntent ? INTENT_FIELD : undefined)
552
571
  : "";
553
572
  const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
554
573
  return { ...t, parameters, description: finalDescription };
@@ -754,7 +773,7 @@ async function runLoopBody(
754
773
  status: message.stopReason === "aborted" ? "aborted" : "error",
755
774
  });
756
775
  }
757
- stream.push({ type: "turn_end", message, toolResults });
776
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
758
777
 
759
778
  stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
760
779
  stream.end(newMessages);
@@ -839,7 +858,7 @@ async function runLoopBody(
839
858
  hasMoreToolCalls = true;
840
859
  }
841
860
 
842
- stream.push({ type: "turn_end", message, toolResults });
861
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal);
843
862
 
844
863
  // On external abort (user interrupt), leave the steering queue intact: the
845
864
  // session aborts then continues, delivering the queue into a fresh run.
@@ -925,6 +944,8 @@ async function streamAssistantResponse(
925
944
  const llmMessages = await config.convertToLlm(messages);
926
945
  const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
927
946
 
947
+ const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
948
+ const exampleDialect = ownedDialect ?? preferredDialect(config.model.id);
928
949
  // Build LLM context — append-only mode caches system prompt + tools
929
950
  // AND keeps an append-only message log so prior-turn bytes are stable.
930
951
  let llmContext: Context;
@@ -932,13 +953,13 @@ async function streamAssistantResponse(
932
953
  config.appendOnlyContext.syncMessages(normalizedMessages);
933
954
  llmContext = config.appendOnlyContext.build(context, {
934
955
  intentTracing: !!config.intentTracing,
935
- exampleSyntax: preferredToolSyntax(config.model.id),
956
+ exampleDialect,
936
957
  });
937
958
  } else {
938
959
  llmContext = {
939
960
  systemPrompt: context.systemPrompt,
940
961
  messages: normalizedMessages,
941
- tools: normalizeTools(context.tools, !!config.intentTracing, preferredToolSyntax(config.model.id)),
962
+ tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect),
942
963
  };
943
964
  }
944
965
  if (config.transformProviderContext) {
@@ -946,17 +967,15 @@ async function streamAssistantResponse(
946
967
  }
947
968
 
948
969
  // Owned tool calling: take tool calls away from the provider and run them
949
- // through the selected in-band prompt syntax. `PI_OWNED_TOOLS=1` still
950
- // force-enables GLM; `PI_OWNED_TOOLS=<syntax>` force-enables that syntax.
951
- const ownedSyntax: ToolCallSyntax | undefined =
952
- config.toolCallSyntax ?? resolveOwnedToolSyntaxFromEnv(Bun.env.PI_OWNED_TOOLS);
970
+ // through the selected in-band prompt dialect. `PI_DIALECT=1` still
971
+ // force-enables GLM; `PI_DIALECT=<dialect>` force-enables that dialect.
953
972
  let promptToolWireTools: Context["tools"];
954
- if (ownedSyntax && llmContext.tools && llmContext.tools.length > 0) {
973
+ if (ownedDialect && llmContext.tools && llmContext.tools.length > 0) {
955
974
  promptToolWireTools = llmContext.tools;
956
975
  llmContext = {
957
976
  ...llmContext,
958
- systemPrompt: [...(llmContext.systemPrompt ?? []), renderInbandToolPrompt(promptToolWireTools, ownedSyntax)],
959
- messages: encodeInbandToolHistory(llmContext.messages, ownedSyntax, promptToolWireTools),
977
+ systemPrompt: [...(llmContext.systemPrompt ?? []), renderInbandToolPrompt(promptToolWireTools, ownedDialect)],
978
+ messages: encodeInbandToolHistory(llmContext.messages, ownedDialect, promptToolWireTools),
960
979
  tools: undefined,
961
980
  };
962
981
  }
@@ -990,7 +1009,7 @@ async function streamAssistantResponse(
990
1009
  // the hallucinated turn. Merged into the provider signal ONLY (not
991
1010
  // `requestSignal`), so it cancels the request without tripping the loop's
992
1011
  // external-abort handling (`abortRacePromise` / `requestSignal.aborted`).
993
- const promptToolAbortController = ownedSyntax ? new AbortController() : undefined;
1012
+ const promptToolAbortController = ownedDialect ? new AbortController() : undefined;
994
1013
  const providerAbortSignals: AbortSignal[] = [];
995
1014
  if (requestSignal) providerAbortSignals.push(requestSignal);
996
1015
  providerAbortSignals.push(repetitionAbortController.signal);
@@ -1000,7 +1019,7 @@ async function streamAssistantResponse(
1000
1019
  const effectiveTemperature =
1001
1020
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1002
1021
  // Owned tool calling sends no native tools, so any tool_choice would error.
1003
- const effectiveToolChoice = ownedSyntax ? undefined : (dynamicToolChoice ?? config.toolChoice);
1022
+ const effectiveToolChoice = ownedDialect ? undefined : (dynamicToolChoice ?? config.toolChoice);
1004
1023
  const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1005
1024
  const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
1006
1025
 
@@ -1068,7 +1087,7 @@ async function streamAssistantResponse(
1068
1087
  signal: finalRequestSignal,
1069
1088
  onResponse: captureOnResponse,
1070
1089
  });
1071
- if (promptToolWireTools && ownedSyntax) {
1090
+ if (promptToolWireTools && ownedDialect) {
1072
1091
  // Re-materialize in-band tool-call text as native toolCall content blocks
1073
1092
  // so the rest of the loop executes them unchanged. When the model starts
1074
1093
  // fabricating tool results, the abort callback cancels the provider — unless
@@ -1077,7 +1096,7 @@ async function streamAssistantResponse(
1077
1096
  response = wrapInbandToolStream(
1078
1097
  response,
1079
1098
  promptToolWireTools,
1080
- ownedSyntax,
1099
+ ownedDialect,
1081
1100
  () => promptToolAbortController?.abort(),
1082
1101
  config.abortOnFabricatedToolResult ?? true,
1083
1102
  );
package/src/agent.ts CHANGED
@@ -22,7 +22,7 @@ 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";
25
+ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
26
26
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
27
27
  import { getBundledModel } from "@oh-my-pi/pi-catalog/models";
28
28
  import { logger } from "@oh-my-pi/pi-utils";
@@ -221,8 +221,8 @@ export interface AgentOptions {
221
221
 
222
222
  /** Enable intent tracing schema injection/stripping in the harness. */
223
223
  intentTracing?: boolean;
224
- /** Owned tool-calling syntax. Undefined keeps provider-native tool calling. */
225
- toolCallSyntax?: ToolCallSyntax;
224
+ /** Owned tool-calling dialect. Undefined keeps provider-native tool calling. */
225
+ dialect?: Dialect;
226
226
  /**
227
227
  * When owned tool calling is active and the model fabricates a tool result
228
228
  * mid-turn: `true` (default) aborts the provider request immediately; `false`
@@ -326,7 +326,7 @@ export class Agent {
326
326
  #preferWebsockets?: boolean;
327
327
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
328
328
  #intentTracing: boolean;
329
- #toolCallSyntax?: ToolCallSyntax;
329
+ #dialect?: Dialect;
330
330
  #abortOnFabricatedToolResult?: boolean;
331
331
  #getToolChoice?: () => ToolChoice | undefined;
332
332
  #onPayload?: SimpleStreamOptions["onPayload"];
@@ -335,6 +335,7 @@ export class Agent {
335
335
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
336
336
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
337
337
  #onBeforeYield?: () => Promise<void> | void;
338
+ #onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
338
339
  #asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
339
340
  #telemetry?: AgentLoopConfig["telemetry"];
340
341
  #appendOnlyContext?: AppendOnlyContextManager;
@@ -390,7 +391,7 @@ export class Agent {
390
391
  this.#preferWebsockets = opts.preferWebsockets;
391
392
  this.#transformToolCallArguments = opts.transformToolCallArguments;
392
393
  this.#intentTracing = opts.intentTracing === true;
393
- this.#toolCallSyntax = opts.toolCallSyntax;
394
+ this.#dialect = opts.dialect;
394
395
  this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
395
396
  this.#getToolChoice = opts.getToolChoice;
396
397
  this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
@@ -639,6 +640,9 @@ export class Agent {
639
640
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void {
640
641
  this.#onBeforeYield = fn;
641
642
  }
643
+ setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void) | undefined): void {
644
+ this.#onTurnEnd = fn;
645
+ }
642
646
 
643
647
  /**
644
648
  * Provide a source of non-interrupting "aside" messages (e.g. background-job
@@ -1037,13 +1041,14 @@ export class Agent {
1037
1041
  cursorOnToolResult,
1038
1042
  transformToolCallArguments: this.#transformToolCallArguments,
1039
1043
  intentTracing: this.#intentTracing,
1040
- toolCallSyntax: this.#toolCallSyntax,
1044
+ dialect: this.#dialect,
1041
1045
  abortOnFabricatedToolResult: this.#abortOnFabricatedToolResult,
1042
1046
  appendOnlyContext: this.#appendOnlyContext,
1043
1047
  beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
1044
1048
  afterToolCall: this.afterToolCall ? (ctx, signal) => this.afterToolCall?.(ctx, signal) : undefined,
1045
1049
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
1046
1050
  onHarmonyLeak: this.#onHarmonyLeak,
1051
+ onTurnEnd: (messages, signal) => this.#onTurnEnd?.(messages, signal),
1047
1052
  getToolChoice,
1048
1053
  getReasoning: () => this.#state.thinkingLevel,
1049
1054
  getDisableReasoning: () => this.#state.disableReasoning,
@@ -15,7 +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
+ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
19
19
  import { normalizeTools } from "./agent-loop";
20
20
  import type { AgentContext } from "./types";
21
21
 
@@ -34,7 +34,7 @@ export interface StablePrefixSnapshot {
34
34
  export interface BuildOptions {
35
35
  /** Inject the `_i` intent field into tool schemas (must match agent-loop's normalizeTools). */
36
36
  intentTracing: boolean;
37
- exampleSyntax?: ToolCallSyntax;
37
+ exampleDialect?: Dialect;
38
38
  }
39
39
 
40
40
  /**
@@ -270,7 +270,7 @@ export class AppendOnlyContextManager {
270
270
 
271
271
  function takeSnapshot(context: AgentContext, options: BuildOptions): StablePrefixSnapshot {
272
272
  const systemPrompt = [...context.systemPrompt];
273
- const tools = normalizeTools(context.tools, options.intentTracing, options.exampleSyntax) ?? [];
273
+ const tools = normalizeTools(context.tools, options.intentTracing, options.exampleDialect) ?? [];
274
274
  return {
275
275
  systemPrompt,
276
276
  tools,
@@ -290,7 +290,7 @@ function computeFingerprint(systemPrompt: string[], tools: Tool[], options: Buil
290
290
  cw: t.customWireName,
291
291
  })),
292
292
  i: options.intentTracing,
293
- ex: options.exampleSyntax,
293
+ ex: options.exampleDialect,
294
294
  });
295
295
  let hash = 0;
296
296
  for (let i = 0; i < payload.length; i++) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ApiKey, Model } from "@oh-my-pi/pi-ai";
9
- import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
9
+ import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
10
10
  import { prompt } from "@oh-my-pi/pi-utils";
11
11
  import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
12
12
  import type { AgentMessage } from "../types";
@@ -291,7 +291,7 @@ export async function generateBranchSummary(
291
291
  // Transform to LLM-compatible messages, then serialize to text
292
292
  // Serialization prevents the model from treating it as a conversation to continue
293
293
  const llmMessages = (options.convertToLlm ?? defaultConvertToLlm)(messages);
294
- const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
294
+ const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
295
295
 
296
296
  // Build prompt
297
297
  const instructions = customInstructions || BRANCH_SUMMARY_PROMPT;
@@ -18,7 +18,7 @@ import {
18
18
  type Usage,
19
19
  withAuth,
20
20
  } from "@oh-my-pi/pi-ai";
21
- import { preferredToolSyntax } from "@oh-my-pi/pi-catalog/identity";
21
+ import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
22
22
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
23
23
  import { countTokens } from "@oh-my-pi/pi-natives";
24
24
  import { logger, prompt } from "@oh-my-pi/pi-utils";
@@ -643,7 +643,7 @@ export async function generateSummary(
643
643
  // Serialize conversation to text so model doesn't try to continue it
644
644
  // Convert to LLM messages first (handles custom app messages when caller provides a transformer).
645
645
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(currentMessages);
646
- const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
646
+ const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
647
647
 
648
648
  // Build the prompt with conversation wrapped in tags
649
649
  let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
@@ -791,7 +791,7 @@ async function generateShortSummary(
791
791
  ): Promise<string> {
792
792
  const maxTokens = Math.min(512, Math.floor(0.2 * reserveTokens));
793
793
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(recentMessages);
794
- const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
794
+ const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
795
795
 
796
796
  let promptText = `<conversation>\n${conversationText}\n</conversation>\n\n`;
797
797
  if (historySummary) {
@@ -1156,7 +1156,7 @@ async function generateTurnPrefixSummary(
1156
1156
  const maxTokens = Math.floor(0.5 * reserveTokens); // Smaller budget for turn prefix
1157
1157
 
1158
1158
  const llmMessages = (options?.convertToLlm ?? defaultConvertToLlm)(messages);
1159
- const conversationText = serializeConversation(llmMessages, preferredToolSyntax(model.id));
1159
+ const conversationText = serializeConversation(llmMessages, preferredDialect(model.id));
1160
1160
  const promptText = `<conversation>\n${conversationText}\n</conversation>\n\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
1161
1161
  const summarizationMessages = [
1162
1162
  {
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { Message, ToolCall } from "@oh-my-pi/pi-ai";
6
- import { type Grammar, type GrammarToolResult, getInbandGrammar, type ToolCallSyntax } from "@oh-my-pi/pi-ai/grammar";
6
+ import { type Dialect, getDialectDefinition } from "@oh-my-pi/pi-ai/dialect";
7
7
  import { formatGroupedPaths, prompt } from "@oh-my-pi/pi-utils";
8
8
  import type { AgentMessage } from "../types";
9
9
  import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
@@ -189,10 +189,7 @@ function truncateForSummary(text: string, maxChars: number): string {
189
189
  * This prevents the model from treating it as a conversation to continue.
190
190
  * Call convertToLlm() first to handle custom message types.
191
191
  */
192
- export function serializeConversation(messages: Message[], syntax?: ToolCallSyntax): string {
193
- const grammar = syntax ? getInbandGrammar(syntax) : undefined;
194
- const parts: string[] = [];
195
-
192
+ export function serializeConversation(messages: Message[], dialect?: Dialect): string {
196
193
  // Tool results flagged contextually useless (and their paired calls) are
197
194
  // dropped from the serialized text: the source region is discarded after
198
195
  // summarization anyway, so excluding them costs nothing and keeps garbage
@@ -203,7 +200,33 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
203
200
  uselessCallIds.add(msg.toolCallId);
204
201
  }
205
202
  }
203
+ if (dialect) {
204
+ const processed: Message[] = [];
205
+ for (const msg of messages) {
206
+ if (msg.role === "assistant") {
207
+ const content = msg.content.filter(block => block.type !== "toolCall" || !uselessCallIds.has(block.id));
208
+ if (content.length > 0) processed.push(content.length === msg.content.length ? msg : { ...msg, content });
209
+ continue;
210
+ }
211
+ if (msg.role === "toolResult") {
212
+ if (uselessCallIds.has(msg.toolCallId)) continue;
213
+ const text = msg.content
214
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
215
+ .map(c => c.text)
216
+ .join("");
217
+ if (!text) continue;
218
+ processed.push({
219
+ ...msg,
220
+ content: [{ type: "text", text: truncateForSummary(text, TOOL_RESULT_MAX_CHARS) }],
221
+ });
222
+ continue;
223
+ }
224
+ processed.push(msg);
225
+ }
226
+ return getDialectDefinition(dialect).renderTranscript(processed);
227
+ }
206
228
 
229
+ const parts: string[] = [];
207
230
  for (const msg of messages) {
208
231
  if (msg.role === "user") {
209
232
  const content =
@@ -237,7 +260,7 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
237
260
  parts.push(`[Assistant]: ${textParts.join("\n")}`);
238
261
  }
239
262
  if (toolCalls.length > 0) {
240
- parts.push(`[Tool Call]: ${renderToolCalls(toolCalls, grammar)}`);
263
+ parts.push(`[Tool Call]: ${renderToolCalls(toolCalls)}`);
241
264
  }
242
265
  } else if (msg.role === "toolResult") {
243
266
  if (uselessCallIds.has(msg.toolCallId)) continue;
@@ -247,9 +270,7 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
247
270
  .join("");
248
271
  if (content) {
249
272
  const text = truncateForSummary(content, TOOL_RESULT_MAX_CHARS);
250
- parts.push(
251
- `[Tool Result]: ${renderToolResult(msg.toolCallId, msg.toolName, msg.isError === true, text, grammar)}`,
252
- );
273
+ parts.push(`[Tool Result]: ${text}`);
253
274
  }
254
275
  }
255
276
  }
@@ -258,11 +279,10 @@ export function serializeConversation(messages: Message[], syntax?: ToolCallSynt
258
279
  }
259
280
 
260
281
  /**
261
- * Render an assistant turn's tool calls. With a grammar, emit the model's
262
- * native invocation block; otherwise fall back to a compact `name(args)` list.
282
+ * Render an assistant turn's tool calls as a compact `name(args)` list for the
283
+ * legacy serializer.
263
284
  */
264
- function renderToolCalls(calls: ToolCall[], grammar: Grammar | undefined): string {
265
- if (grammar) return grammar.renderAssistantToolCalls(calls);
285
+ function renderToolCalls(calls: ToolCall[]): string {
266
286
  return calls
267
287
  .map(call => {
268
288
  const argsStr = Object.entries(call.arguments as Record<string, unknown>)
@@ -273,22 +293,6 @@ function renderToolCalls(calls: ToolCall[], grammar: Grammar | undefined): strin
273
293
  .join("; ");
274
294
  }
275
295
 
276
- /**
277
- * Render a single tool result. With a grammar, emit the model's native
278
- * tool-result envelope; otherwise return the (already truncated) text verbatim.
279
- */
280
- function renderToolResult(
281
- id: string,
282
- name: string,
283
- isError: boolean,
284
- text: string,
285
- grammar: Grammar | undefined,
286
- ): string {
287
- if (!grammar) return text;
288
- const result: GrammarToolResult = { id, name, index: 0, text, isError };
289
- return grammar.renderToolResults([result]);
290
- }
291
-
292
296
  // ============================================================================
293
297
  // Summarization System Prompt
294
298
  // ============================================================================
package/src/types.ts CHANGED
@@ -17,7 +17,7 @@ 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";
20
+ import type { Dialect } from "@oh-my-pi/pi-ai/dialect";
21
21
  import type { HarmonyAuditEvent } from "@oh-my-pi/pi-ai/utils/harmony-leak";
22
22
  import type { AppendOnlyContextManager } from "./append-only-context";
23
23
  import type { AgentRunCoverage, AgentRunSummary } from "./run-collector";
@@ -201,14 +201,14 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
201
201
  */
202
202
  intentTracing?: boolean;
203
203
  /**
204
- * Owned tool calling syntax.
204
+ * Owned tool calling dialect.
205
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
206
+ * Undefined keeps provider-native tool calling. A dialect value sends no
207
+ * native `tools`, forces `toolChoice` off, appends that dialect's tool catalog
208
208
  * instructions, re-encodes prior tool calls/results as text, and parses the
209
209
  * model's text output back into canonical `toolCall` blocks.
210
210
  */
211
- toolCallSyntax?: ToolCallSyntax;
211
+ dialect?: Dialect;
212
212
  /**
213
213
  * When owned (in-band) tool calling is active and the model starts
214
214
  * fabricating a tool result inside its own turn, control how the loop reacts:
@@ -217,8 +217,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
217
217
  * - `false`: let the request finish and silently discard everything past the
218
218
  * fabrication boundary (keeps the connection alive but pays for the tokens
219
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.
220
+ * Only meaningful when {@link dialect} (or `PI_DIALECT`) selects an
221
+ * owned dialect; native tool calling never fabricates results in text.
222
222
  */
223
223
  abortOnFabricatedToolResult?: boolean;
224
224
  /**
@@ -282,6 +282,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
282
282
  context: BeforeToolCallContext,
283
283
  signal?: AbortSignal,
284
284
  ) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
285
+ /**
286
+ * Called after a turn ends and before the loop polls steering/asides for the
287
+ * next iteration. Use this for awaited per-turn bookkeeping that must be
288
+ * visible before the next model request (e.g. synchronizing an advisor's
289
+ * backlog so advice produced during the wait is injected as an aside).
290
+ */
291
+ onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<void> | void;
285
292
 
286
293
  /**
287
294
  * Called after a tool finishes executing, before `tool_execution_end` and the