@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 +5 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +62 -40
- package/src/agent.ts +28 -2
- package/src/harmony-leak.ts +0 -1
- package/src/types.ts +98 -0
package/CHANGELOG.md
CHANGED
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.
|
|
4
|
+
"version": "15.0.2",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
|
-
"homepage": "https://
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-natives": "15.0.
|
|
40
|
-
"@oh-my-pi/pi-utils": "15.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.
|
|
44
|
+
"@types/bun": "^1.3.14"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
|
-
"bun": ">=1.3.
|
|
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
|
-
|
|
626
|
+
currentContext: AgentContext,
|
|
659
627
|
assistantMessage: AssistantMessage,
|
|
660
628
|
signal: AbortSignal | undefined,
|
|
661
629
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
662
|
-
|
|
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:
|
|
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) ||
|
package/src/harmony-leak.ts
CHANGED
|
@@ -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:
|