@oh-my-pi/pi-agent-core 15.0.1 → 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/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.1",
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,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.0.1",
39
- "@oh-my-pi/pi-natives": "15.0.1",
40
- "@oh-my-pi/pi-utils": "15.0.1"
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",
package/src/agent-loop.ts CHANGED
@@ -375,17 +375,7 @@ async function runLoop(
375
375
 
376
376
  const toolResults: ToolResultMessage[] = [];
377
377
  if (hasMoreToolCalls) {
378
- const executionResult = await executeToolCalls(
379
- currentContext.tools,
380
- message,
381
- signal,
382
- stream,
383
- config.getSteeringMessages,
384
- config.interruptMode,
385
- config.getToolContext,
386
- config.transformToolCallArguments,
387
- config.intentTracing,
388
- );
378
+ const executionResult = await executeToolCalls(currentContext, message, signal, stream, config);
389
379
 
390
380
  toolResults.push(...executionResult.toolResults);
391
381
  steeringMessagesFromExecution = executionResult.steeringMessages;
@@ -633,16 +623,22 @@ function emitAbortedAssistantMessage(
633
623
  * Execute tool calls from an assistant message.
634
624
  */
635
625
  async function executeToolCalls(
636
- tools: AgentTool<any>[] | undefined,
626
+ currentContext: AgentContext,
637
627
  assistantMessage: AssistantMessage,
638
628
  signal: AbortSignal | undefined,
639
629
  stream: EventStream<AgentEvent, AgentMessage[]>,
640
- getSteeringMessages?: AgentLoopConfig["getSteeringMessages"],
641
- interruptMode: AgentLoopConfig["interruptMode"] = "immediate",
642
- getToolContext?: AgentLoopConfig["getToolContext"],
643
- transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"],
644
- intentTracing?: AgentLoopConfig["intentTracing"],
630
+ config: AgentLoopConfig,
645
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;
646
642
  type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
647
643
  const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
648
644
  const emittedToolResults: ToolResultMessage[] = [];
@@ -785,6 +781,24 @@ async function executeToolCalls(
785
781
  throw validationError;
786
782
  }
787
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
+
788
802
  const toolContext = getToolContext
789
803
  ? getToolContext({
790
804
  batchId,
@@ -802,7 +816,7 @@ async function executeToolCalls(
802
816
  type: "tool_execution_update",
803
817
  toolCallId: toolCall.id,
804
818
  toolName: toolCall.name,
805
- args: argsForExecution,
819
+ args: effectiveArgs,
806
820
  partialResult: coerceToolResult(partialResult).result,
807
821
  });
808
822
  },
@@ -819,6 +833,36 @@ async function executeToolCalls(
819
833
  isError = true;
820
834
  }
821
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
+
822
866
  if (interruptState.triggered) {
823
867
  record.skipped = true;
824
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) ||
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: