@oh-my-pi/pi-agent-core 15.0.0 → 15.0.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,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.0.1] - 2026-05-14
6
+ ### Breaking Changes
7
+
8
+ - Raised the minimum required Bun version from >=1.3.7 to >=1.3.14
9
+
5
10
  ## [14.9.5] - 2026-05-12
6
11
 
7
12
  ### Added
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "15.0.0",
4
+ "version": "15.0.2",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
- "homepage": "https://github.com/can1357/oh-my-pi",
6
+ "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
8
8
  "contributors": [
9
9
  "Mario Zechner"
@@ -35,16 +35,16 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.0.0",
39
- "@oh-my-pi/pi-natives": "15.0.0",
40
- "@oh-my-pi/pi-utils": "15.0.0"
38
+ "@oh-my-pi/pi-ai": "15.0.2",
39
+ "@oh-my-pi/pi-natives": "15.0.2",
40
+ "@oh-my-pi/pi-utils": "15.0.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@sinclair/typebox": "^0.34.49",
44
- "@types/bun": "^1.3.13"
44
+ "@types/bun": "^1.3.14"
45
45
  },
46
46
  "engines": {
47
- "bun": ">=1.3.7"
47
+ "bun": ">=1.3.14"
48
48
  },
49
49
  "files": [
50
50
  "src",
package/src/agent-loop.ts CHANGED
@@ -14,11 +14,9 @@ import {
14
14
  import { sanitizeText } from "@oh-my-pi/pi-natives";
15
15
  import {
16
16
  createHarmonyAuditEvent,
17
- extractHarmonyRemoved,
18
17
  type HarmonyDetection,
19
18
  type HarmonyRecoveredToolCall,
20
19
  isHarmonyLeakMitigationTarget,
21
- recoverHarmonyToolCall,
22
20
  signalListLabel,
23
21
  } from "./harmony-leak";
24
22
  import type {
@@ -377,17 +375,7 @@ async function runLoop(
377
375
 
378
376
  const toolResults: ToolResultMessage[] = [];
379
377
  if (hasMoreToolCalls) {
380
- const executionResult = await executeToolCalls(
381
- currentContext.tools,
382
- message,
383
- signal,
384
- stream,
385
- config.getSteeringMessages,
386
- config.interruptMode,
387
- config.getToolContext,
388
- config.transformToolCallArguments,
389
- config.intentTracing,
390
- );
378
+ const executionResult = await executeToolCalls(currentContext, message, signal, stream, config);
391
379
 
392
380
  toolResults.push(...executionResult.toolResults);
393
381
  steeringMessagesFromExecution = executionResult.steeringMessages;
@@ -502,26 +490,6 @@ async function streamAssistantResponse(
502
490
 
503
491
  const responseIterator = response[Symbol.asyncIterator]();
504
492
 
505
- const _interruptForHarmonyLeak = (message: AssistantMessage, detection: HarmonyDetection): never => {
506
- const recovered = recoverHarmonyToolCall(message, detection);
507
- const removed = recovered?.removed ?? extractHarmonyRemoved(message, detection);
508
- harmonyAbortController?.abort();
509
- responseIterator.return?.()?.catch(() => {});
510
- if (recovered) {
511
- if (addedPartial) {
512
- context.messages[context.messages.length - 1] = recovered.message;
513
- } else {
514
- context.messages.push(recovered.message);
515
- stream.push({ type: "message_start", message: { ...recovered.message } });
516
- }
517
- stream.push({ type: "message_end", message: recovered.message });
518
- throw new HarmonyLeakInterruption(detection, removed, recovered);
519
- }
520
- if (addedPartial) {
521
- context.messages.pop();
522
- }
523
- throw new HarmonyLeakInterruption(detection, removed);
524
- };
525
493
  // Set up a single abort race: register the abort listener once for the whole
526
494
  // stream and reuse the same race promise for every iterator.next() instead of
527
495
  // allocating Promise.withResolvers and add/removeEventListener per event.
@@ -655,16 +623,22 @@ function emitAbortedAssistantMessage(
655
623
  * Execute tool calls from an assistant message.
656
624
  */
657
625
  async function executeToolCalls(
658
- tools: AgentTool<any>[] | undefined,
626
+ currentContext: AgentContext,
659
627
  assistantMessage: AssistantMessage,
660
628
  signal: AbortSignal | undefined,
661
629
  stream: EventStream<AgentEvent, AgentMessage[]>,
662
- getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
663
- interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
664
- getToolContext?: AgentLoopConfig["getToolContext"],
665
- transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
666
- intentTracing?: AgentLoopConfig["intentTracing"],
630
+ config: AgentLoopConfig,
667
631
  ): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
632
+ const tools = currentContext.tools;
633
+ const {
634
+ getSteeringMessages,
635
+ interruptMode = "immediate",
636
+ getToolContext,
637
+ transformToolCallArguments,
638
+ intentTracing,
639
+ beforeToolCall,
640
+ afterToolCall,
641
+ } = config;
668
642
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
669
643
  const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
670
644
  const emittedToolResults: ToolResultMessage[] = [];
@@ -807,6 +781,24 @@ async function executeToolCalls(
807
781
  throw validationError;
808
782
  }
809
783
  }
784
+
785
+ if (beforeToolCall) {
786
+ const beforeResult = await beforeToolCall(
787
+ {
788
+ assistantMessage,
789
+ toolCall,
790
+ args: effectiveArgs,
791
+ context: currentContext,
792
+ },
793
+ toolSignal,
794
+ );
795
+ if (beforeResult?.block) {
796
+ throw new Error(beforeResult.reason || "Tool execution was blocked");
797
+ }
798
+ }
799
+ // Reflect post-hook args so emitted tool results / afterToolCall see what actually executed.
800
+ record.args = effectiveArgs;
801
+
810
802
  const toolContext = getToolContext
811
803
  ? getToolContext({
812
804
  batchId,
@@ -824,7 +816,7 @@ async function executeToolCalls(
824
816
  type: "tool_execution_update",
825
817
  toolCallId: toolCall.id,
826
818
  toolName: toolCall.name,
827
- args: argsForExecution,
819
+ args: effectiveArgs,
828
820
  partialResult: coerceToolResult(partialResult).result,
829
821
  });
830
822
  },
@@ -841,6 +833,36 @@ async function executeToolCalls(
841
833
  isError = true;
842
834
  }
843
835
 
836
+ if (afterToolCall) {
837
+ try {
838
+ const after = await afterToolCall(
839
+ {
840
+ assistantMessage,
841
+ toolCall,
842
+ args: record.args,
843
+ result,
844
+ isError,
845
+ context: currentContext,
846
+ },
847
+ toolSignal,
848
+ );
849
+ if (after) {
850
+ result = {
851
+ content: after.content ?? result.content,
852
+ details: after.details ?? result.details,
853
+ isError: after.isError ?? result.isError,
854
+ };
855
+ isError = after.isError ?? isError;
856
+ }
857
+ } catch (e) {
858
+ result = {
859
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
860
+ details: {},
861
+ };
862
+ isError = true;
863
+ }
864
+ }
865
+
844
866
  if (interruptState.triggered) {
845
867
  record.skipped = true;
846
868
  emitToolResult(record, createSkippedToolResult(), true);
package/src/agent.ts CHANGED
@@ -38,7 +38,7 @@ import type {
38
38
  * Default convertToLlm: Keep only LLM-compatible messages, convert attachments.
39
39
  */
40
40
  function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
41
- return messages.filter(m => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
41
+ return messages.filter((m): m is Message => m.role === "user" || m.role === "assistant" || m.role === "toolResult");
42
42
  }
43
43
 
44
44
  function refreshToolChoiceForActiveTools(
@@ -208,6 +208,18 @@ export interface AgentOptions {
208
208
  * Cursor tool result callback for exec tool responses.
209
209
  */
210
210
  cursorOnToolResult?: CursorToolResultHandler;
211
+
212
+ /**
213
+ * Called after a tool call has been validated and is about to execute.
214
+ * See {@link AgentLoopConfig.beforeToolCall} for full semantics.
215
+ */
216
+ beforeToolCall?: AgentLoopConfig["beforeToolCall"];
217
+
218
+ /**
219
+ * Called after a tool finishes executing, before `tool_execution_end` and the tool-result
220
+ * message are emitted. See {@link AgentLoopConfig.afterToolCall} for full semantics.
221
+ */
222
+ afterToolCall?: AgentLoopConfig["afterToolCall"];
211
223
  }
212
224
 
213
225
  export interface AgentPromptOptions {
@@ -277,6 +289,16 @@ export class Agent {
277
289
 
278
290
  streamFn: StreamFn;
279
291
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
292
+ /**
293
+ * Hook invoked after tool arguments are validated and before execution.
294
+ * Reassign at any time to swap the implementation (e.g. on extension reload).
295
+ */
296
+ beforeToolCall?: AgentLoopConfig["beforeToolCall"];
297
+ /**
298
+ * Hook invoked after tool execution and before `tool_execution_end` / tool-result
299
+ * message emission. Reassign at any time to swap the implementation.
300
+ */
301
+ afterToolCall?: AgentLoopConfig["afterToolCall"];
280
302
 
281
303
  constructor(opts: AgentOptions = {}) {
282
304
  this.#state = { ...this.#state, ...opts.initialState };
@@ -312,6 +334,8 @@ export class Agent {
312
334
  this.#getToolChoice = opts.getToolChoice;
313
335
  this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
314
336
  this.#onHarmonyLeak = opts.onHarmonyLeak;
337
+ this.beforeToolCall = opts.beforeToolCall;
338
+ this.afterToolCall = opts.afterToolCall;
315
339
  }
316
340
 
317
341
  /**
@@ -868,6 +892,8 @@ export class Agent {
868
892
  cursorOnToolResult,
869
893
  transformToolCallArguments: this.#transformToolCallArguments,
870
894
  intentTracing: this.#intentTracing,
895
+ beforeToolCall: this.beforeToolCall ? (ctx, signal) => this.beforeToolCall?.(ctx, signal) : undefined,
896
+ afterToolCall: this.afterToolCall ? (ctx, signal) => this.afterToolCall?.(ctx, signal) : undefined,
871
897
  onAssistantMessageEvent: this.#onAssistantMessageEvent,
872
898
  onHarmonyLeak: this.#onHarmonyLeak,
873
899
  getToolChoice,
@@ -945,7 +971,7 @@ export class Agent {
945
971
  }
946
972
 
947
973
  // Handle any remaining partial message
948
- if (partial && partial.role === "assistant" && partial.content.length > 0) {
974
+ if (partial && partial.role === "assistant" && Array.isArray(partial.content) && partial.content.length > 0) {
949
975
  const onlyEmpty = !partial.content.some(
950
976
  c =>
951
977
  (c.type === "thinking" && c.thinking.trim().length > 0) ||
@@ -36,7 +36,6 @@ const FENCE_RE = /^\s*(?:```+|~~~+)/;
36
36
  const SCRIPT_CLASS =
37
37
  "\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u0400-\u04FF\u0E00-\u0E7F\u10A0-\u10FF\u0530-\u058F\u0C80-\u0CFF\u0C00-\u0C7F\u0900-\u097F\u0600-\u06FF\u0D00-\u0D7F";
38
38
  const SCRIPT_RUN_RE = new RegExp(`[${SCRIPT_CLASS}]{2,}`, "u");
39
- const _SCRIPT_CHAR_RE = new RegExp(`[${SCRIPT_CLASS}]`, "u");
40
39
 
41
40
  // Recovery registry. Each entry's parser must recognize the configured
42
41
  // sentinel (per-tool, see eval/parse.ts and hashline/parser.ts) and surface
package/src/types.ts CHANGED
@@ -169,8 +169,43 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
169
169
  * the next model call instead of waiting for the next prompt.
170
170
  */
171
171
  getReasoning?: () => Effort | undefined;
172
+
173
+ /**
174
+ * Called after a tool call has been validated and is about to execute.
175
+ *
176
+ * Return `{ block: true }` to prevent execution. The loop emits an error tool
177
+ * result instead (using `reason` as the error text, or a default if omitted).
178
+ *
179
+ * Mutating `context.args` in place changes the arguments passed to `tool.execute`
180
+ * — the loop does **not** re-validate after this hook runs.
181
+ *
182
+ * The hook receives the tool abort signal (`signal`) and is responsible for
183
+ * honoring it. Throwing surfaces as a tool-error result and does not abort the
184
+ * rest of the batch.
185
+ */
186
+ beforeToolCall?: (
187
+ context: BeforeToolCallContext,
188
+ signal?: AbortSignal,
189
+ ) => Promise<BeforeToolCallResult | undefined> | BeforeToolCallResult | undefined;
190
+
191
+ /**
192
+ * Called after a tool finishes executing, before `tool_execution_end` and the
193
+ * tool-result message are emitted.
194
+ *
195
+ * Return an `AfterToolCallResult` to override individual fields of the executed
196
+ * tool result. Omitted fields keep their original values; there is no deep merge.
197
+ *
198
+ * Throwing surfaces as a tool-error result and does not abort the rest of the batch.
199
+ */
200
+ afterToolCall?: (
201
+ context: AfterToolCallContext,
202
+ signal?: AbortSignal,
203
+ ) => Promise<AfterToolCallResult | undefined> | AfterToolCallResult | undefined;
172
204
  }
173
205
 
206
+ /**
207
+ * Batch/sequencing metadata for the tool call currently being processed.
208
+ */
174
209
  export interface ToolCallContext {
175
210
  batchId: string;
176
211
  index: number;
@@ -178,6 +213,69 @@ export interface ToolCallContext {
178
213
  toolCalls: Array<{ id: string; name: string }>;
179
214
  }
180
215
 
216
+ /** A single tool-call content block emitted by an assistant message. */
217
+ export type AgentToolCall = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
218
+
219
+ /**
220
+ * Result returned from `beforeToolCall`.
221
+ *
222
+ * Set `block: true` to prevent the tool from executing. The loop emits an error tool
223
+ * result instead, using `reason` as the error text (or a default if omitted).
224
+ *
225
+ * Mutating the `args` reference passed in `BeforeToolCallContext` is supported and
226
+ * survives into execution — the loop does **not** re-validate after this hook runs.
227
+ */
228
+ export interface BeforeToolCallResult {
229
+ block?: boolean;
230
+ reason?: string;
231
+ }
232
+
233
+ /**
234
+ * Partial override returned from `afterToolCall`.
235
+ *
236
+ * Merge semantics are field-by-field; omitted fields keep the executed values.
237
+ * No deep merge is performed.
238
+ */
239
+ export interface AfterToolCallResult {
240
+ /** If provided, replaces the tool result content array in full. */
241
+ content?: (TextContent | ImageContent)[];
242
+ /** If provided, replaces the tool result details payload in full. */
243
+ details?: unknown;
244
+ /** If provided, replaces the error flag carried with the tool result. */
245
+ isError?: boolean;
246
+ }
247
+
248
+ /** Context passed to `beforeToolCall`. */
249
+ export interface BeforeToolCallContext {
250
+ /** The assistant message that requested the tool call. */
251
+ assistantMessage: AssistantMessage;
252
+ /** The raw tool call block from `assistantMessage.content`. */
253
+ toolCall: AgentToolCall;
254
+ /**
255
+ * Validated tool arguments. The same reference is forwarded to `tool.execute`
256
+ * (after any `transformToolCallArguments` pass), so in-place mutations stick.
257
+ */
258
+ args: Record<string, unknown>;
259
+ /** Current agent context at the time the tool call is prepared. */
260
+ context: AgentContext;
261
+ }
262
+
263
+ /** Context passed to `afterToolCall`. */
264
+ export interface AfterToolCallContext {
265
+ /** The assistant message that requested the tool call. */
266
+ assistantMessage: AssistantMessage;
267
+ /** The raw tool call block from `assistantMessage.content`. */
268
+ toolCall: AgentToolCall;
269
+ /** Validated tool arguments used for execution (post `beforeToolCall` mutations). */
270
+ args: Record<string, unknown>;
271
+ /** The executed tool result before any `afterToolCall` overrides are applied. */
272
+ result: AgentToolResult<any>;
273
+ /** Whether the executed tool result is currently treated as an error. */
274
+ isError: boolean;
275
+ /** Current agent context at the time the tool call is finalized. */
276
+ context: AgentContext;
277
+ }
278
+
181
279
  /**
182
280
  * Extensible interface for custom app messages.
183
281
  * Apps can extend via declaration merging: