@oh-my-pi/pi-agent-core 17.1.4 → 17.1.6
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 +16 -0
- package/dist/types/agent.d.ts +21 -1
- package/dist/types/proxy.d.ts +2 -0
- package/dist/types/types.d.ts +42 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +188 -93
- package/src/agent.ts +82 -1
- package/src/proxy.ts +4 -0
- package/src/types.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.1.6] - 2026-07-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added a pre-model-call gate: `AgentLoopConfig.beforeModelCall` receives the finalized provider context and run abort signal, and may return `{ stop: true, reason? }` to end the run before the provider is called, so a host can refuse a request it has decided not to pay for (prompt no longer fits, budget boundary crossed, session should hand off). `Agent.setBeforeModelCall` installs the host callback; `Agent.addBeforeModelCall` registers an additional one without displacing it and returns a disposer. A gate-stopped run retains pending soft tool reminders/escalations and an unserved hard tool choice for the next admitted request; deferred choices are revalidated against active tools and cleared with queued session state ([#6543](https://github.com/can1357/oh-my-pi/pull/6543) by [@paralin](https://github.com/paralin)).
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Input message events (prompt, steering, soft reminders) are now emitted once provider-context preparation succeeds, so a pre-model gate can veto the request before any turn opens; gate-stopped and failed runs still commit their accepted inputs ([#6543](https://github.com/can1357/oh-my-pi/pull/6543) by [@paralin](https://github.com/paralin)).
|
|
14
|
+
|
|
15
|
+
## [17.1.5] - 2026-07-27
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Fixed proxy-stream clients dropping finalized provider-only content blocks, including Anthropic native web-search history, by allowing `done` and `error` events to carry terminal assistant content while retaining delta-reconstructed content from older proxy servers that omit it ([#6703](https://github.com/can1357/oh-my-pi/issues/6703)).
|
|
20
|
+
|
|
5
21
|
## [17.1.4] - 2026-07-26
|
|
6
22
|
|
|
7
23
|
### Changed
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ApiKey, type AssistantMessage, type AssistantMessageEvent, type Co
|
|
|
2
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.js";
|
|
5
|
-
import type { AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
|
|
5
|
+
import type { AgentBeforeModelCall, AgentEvent, AgentLoopConfig, AgentMessage, AgentState, AgentTool, AgentToolContext, AgentTurnEndContext, AsideMessage, StreamFn, ToolCallContext, ToolChoiceDirective } from "./types.js";
|
|
6
6
|
export declare class AgentBusyError extends Error {
|
|
7
7
|
constructor(message?: string);
|
|
8
8
|
}
|
|
@@ -159,6 +159,8 @@ export interface AgentOptions {
|
|
|
159
159
|
abortOnFabricatedToolResult?: boolean;
|
|
160
160
|
/** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */
|
|
161
161
|
getToolChoice?: () => ToolChoiceDirective | undefined;
|
|
162
|
+
/** Reject a deferred hard choice when its named tool is no longer active. */
|
|
163
|
+
onToolChoiceUnavailable?: () => void;
|
|
162
164
|
/**
|
|
163
165
|
* Cursor exec handlers for local tool execution.
|
|
164
166
|
*/
|
|
@@ -374,6 +376,19 @@ export declare class Agent {
|
|
|
374
376
|
setAssistantMessageEventInterceptor(fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined): void;
|
|
375
377
|
setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void;
|
|
376
378
|
setOnTurnEnd(fn: ((messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void) | undefined): void;
|
|
379
|
+
/**
|
|
380
|
+
* Install or replace the host pre-model-call gate; pass `undefined` to
|
|
381
|
+
* remove it. Gates are sampled when a run starts: installing the first
|
|
382
|
+
* gate while a run is in flight takes effect on the next run.
|
|
383
|
+
*/
|
|
384
|
+
setBeforeModelCall(fn: AgentBeforeModelCall | undefined): void;
|
|
385
|
+
/**
|
|
386
|
+
* Add a pre-model callback without replacing callbacks owned by the host.
|
|
387
|
+
* Returns a disposer that removes only this callback. Like
|
|
388
|
+
* {@link setBeforeModelCall}, the first gate installed while a run is in
|
|
389
|
+
* flight takes effect on the next run.
|
|
390
|
+
*/
|
|
391
|
+
addBeforeModelCall(fn: AgentBeforeModelCall): () => void;
|
|
377
392
|
/**
|
|
378
393
|
* Provide a source of non-interrupting "aside" messages (e.g. background-job
|
|
379
394
|
* completions, late LSP diagnostics) drained at each step boundary. Never
|
|
@@ -408,6 +423,11 @@ export declare class Agent {
|
|
|
408
423
|
followUp(m: AgentMessage): void;
|
|
409
424
|
clearSteeringQueue(): void;
|
|
410
425
|
clearFollowUpQueue(): void;
|
|
426
|
+
/**
|
|
427
|
+
* Drop tool-directive state retained across a gate-stopped run: the
|
|
428
|
+
* deferred hard choice and the soft-requirement lifecycle.
|
|
429
|
+
*/
|
|
430
|
+
clearDeferredToolDirectives(): void;
|
|
411
431
|
clearAllQueues(): void;
|
|
412
432
|
hasQueuedMessages(): boolean;
|
|
413
433
|
/** Non-consuming view of the pending steering queue (insertion order, newest
|
package/dist/types/proxy.d.ts
CHANGED
|
@@ -53,11 +53,13 @@ export type ProxyAssistantMessageEvent = {
|
|
|
53
53
|
type: "done";
|
|
54
54
|
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
|
|
55
55
|
usage: AssistantMessage["usage"];
|
|
56
|
+
content?: AssistantMessage["content"];
|
|
56
57
|
} | {
|
|
57
58
|
type: "error";
|
|
58
59
|
reason: Extract<StopReason, "aborted" | "error">;
|
|
59
60
|
errorMessage?: string;
|
|
60
61
|
usage: AssistantMessage["usage"];
|
|
62
|
+
content?: AssistantMessage["content"];
|
|
61
63
|
};
|
|
62
64
|
export interface ProxyStreamOptions extends SimpleStreamOptions {
|
|
63
65
|
/** Auth token for the proxy server */
|
package/dist/types/types.d.ts
CHANGED
|
@@ -21,6 +21,18 @@ export interface AgentTurnEndContext {
|
|
|
21
21
|
/** True when the current tool-loop batch is continuing without yielding to post-turn steering. */
|
|
22
22
|
willContinue: boolean;
|
|
23
23
|
}
|
|
24
|
+
export interface AgentPreModelCallStop {
|
|
25
|
+
/** Stop the agent loop before sending the next provider request. */
|
|
26
|
+
stop: true;
|
|
27
|
+
/** Optional owner-facing reason, logged by the loop when it stops. */
|
|
28
|
+
reason?: string;
|
|
29
|
+
}
|
|
30
|
+
export type AgentPreModelCallResult = AgentPreModelCallStop | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* A pre-model-call gate. Return {@link AgentPreModelCallStop} to refuse the
|
|
33
|
+
* request, or nothing to proceed; the signal aborts with the run.
|
|
34
|
+
*/
|
|
35
|
+
export type AgentBeforeModelCall = (context: Context, signal?: AbortSignal) => AgentPreModelCallResult | void | Promise<AgentPreModelCallResult | void>;
|
|
24
36
|
/**
|
|
25
37
|
* A soft tool requirement: the host wants `toolName` called before the loop
|
|
26
38
|
* runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
|
|
@@ -61,6 +73,12 @@ export interface SoftToolRequirement {
|
|
|
61
73
|
* (applied verbatim) or a {@link SoftToolRequirement} (remind-then-escalate).
|
|
62
74
|
*/
|
|
63
75
|
export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
|
|
76
|
+
/** Mutable soft-requirement lifecycle retained across stopped agent runs. */
|
|
77
|
+
export interface SoftToolRequirementState {
|
|
78
|
+
id?: string;
|
|
79
|
+
forcedToolChoice?: ToolChoice;
|
|
80
|
+
escalations: number;
|
|
81
|
+
}
|
|
64
82
|
/** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
|
|
65
83
|
export declare function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement;
|
|
66
84
|
/** Source category for a queued steering interrupt observed without consuming the queue. */
|
|
@@ -230,8 +248,21 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
230
248
|
/**
|
|
231
249
|
* Refreshes prompt/tool context from live session state before each model call.
|
|
232
250
|
* Use this when tool availability or the system prompt can change mid-turn.
|
|
251
|
+
*
|
|
252
|
+
* Runs after pending messages are folded in and before provider conversion.
|
|
253
|
+
* Mutate the agent context here; use `beforeModelCall` to inspect the
|
|
254
|
+
* provider-bound context.
|
|
233
255
|
*/
|
|
234
256
|
syncContextBeforeModelCall?: (context: AgentContext) => void | Promise<void>;
|
|
257
|
+
/**
|
|
258
|
+
* Asked after the complete provider context has been built, including
|
|
259
|
+
* message conversion, provider transforms, normalized tools, and owned
|
|
260
|
+
* dialect prompt injection. Returning {@link AgentPreModelCallStop} ends
|
|
261
|
+
* the stream without emitting `turn_start`, so no turn is left open and no
|
|
262
|
+
* request is billed. Return nothing to proceed. The signal aborts when
|
|
263
|
+
* the run is canceled or its deadline expires.
|
|
264
|
+
*/
|
|
265
|
+
beforeModelCall?: AgentBeforeModelCall;
|
|
235
266
|
/**
|
|
236
267
|
* Optional transform applied to tool call arguments before execution.
|
|
237
268
|
* Use for deobfuscating secrets or rewriting arguments.
|
|
@@ -304,6 +335,17 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
304
335
|
* to the static `toolChoice`.
|
|
305
336
|
*/
|
|
306
337
|
getToolChoice?: () => ToolChoiceDirective | undefined;
|
|
338
|
+
/**
|
|
339
|
+
* Soft-requirement lifecycle retained by the host when a pre-model-call
|
|
340
|
+
* gate stops a run before its pending reminder or escalation is served.
|
|
341
|
+
*/
|
|
342
|
+
softToolRequirementState?: SoftToolRequirementState;
|
|
343
|
+
/**
|
|
344
|
+
* Notifies the host that the pre-model-call gate stopped the run after a
|
|
345
|
+
* hard tool choice was obtained from {@link getToolChoice} but before it
|
|
346
|
+
* was served, so the host can retain it for the next admitted request.
|
|
347
|
+
*/
|
|
348
|
+
onToolChoiceRejected?: () => void;
|
|
307
349
|
/**
|
|
308
350
|
* Dynamic reasoning effort override, resolved per LLM call.
|
|
309
351
|
* When set and returns a value, overrides the static `reasoning` captured
|
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": "17.1.
|
|
4
|
+
"version": "17.1.6",
|
|
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,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "17.1.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "17.1.
|
|
40
|
-
"@oh-my-pi/pi-natives": "17.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "17.1.
|
|
42
|
-
"@oh-my-pi/pi-wire": "17.1.
|
|
43
|
-
"@oh-my-pi/snapcompact": "17.1.
|
|
38
|
+
"@oh-my-pi/pi-ai": "17.1.6",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "17.1.6",
|
|
40
|
+
"@oh-my-pi/pi-natives": "17.1.6",
|
|
41
|
+
"@oh-my-pi/pi-utils": "17.1.6",
|
|
42
|
+
"@oh-my-pi/pi-wire": "17.1.6",
|
|
43
|
+
"@oh-my-pi/snapcompact": "17.1.6",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type Context,
|
|
11
11
|
EventStream,
|
|
12
12
|
isApiKeyResolver,
|
|
13
|
+
type Model,
|
|
13
14
|
resolveApiKeyOnce,
|
|
14
15
|
seedApiKeyResolver,
|
|
15
16
|
streamSimple,
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
signalListLabel,
|
|
43
44
|
} from "@oh-my-pi/pi-ai/utils/harmony-leak";
|
|
44
45
|
import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
|
|
45
|
-
import { sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
46
|
+
import { logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
|
|
46
47
|
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
|
|
47
48
|
import { agentPauseGate } from "./pause";
|
|
48
49
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
@@ -67,6 +68,7 @@ import type {
|
|
|
67
68
|
AgentEvent,
|
|
68
69
|
AgentLoopConfig,
|
|
69
70
|
AgentMessage,
|
|
71
|
+
AgentPreModelCallResult,
|
|
70
72
|
AgentTool,
|
|
71
73
|
AgentToolResult,
|
|
72
74
|
AgentTurnEndContext,
|
|
@@ -526,14 +528,9 @@ export function agentLoop(
|
|
|
526
528
|
};
|
|
527
529
|
|
|
528
530
|
stream.push({ type: "agent_start" });
|
|
529
|
-
stream.push({ type: "turn_start" });
|
|
530
|
-
for (const prompt of prompts) {
|
|
531
|
-
stream.push({ type: "message_start", message: prompt });
|
|
532
|
-
stream.push({ type: "message_end", message: prompt });
|
|
533
|
-
}
|
|
534
531
|
|
|
535
532
|
try {
|
|
536
|
-
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
533
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, prompts);
|
|
537
534
|
} catch (err) {
|
|
538
535
|
stream.fail(err);
|
|
539
536
|
}
|
|
@@ -571,7 +568,6 @@ export function agentLoopContinue(
|
|
|
571
568
|
const currentContext: AgentContext = { ...context, messages: [...context.messages] };
|
|
572
569
|
|
|
573
570
|
stream.push({ type: "agent_start" });
|
|
574
|
-
stream.push({ type: "turn_start" });
|
|
575
571
|
|
|
576
572
|
try {
|
|
577
573
|
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
@@ -621,14 +617,36 @@ async function emitTurnEnd(
|
|
|
621
617
|
config: AgentLoopConfig,
|
|
622
618
|
signal?: AbortSignal,
|
|
623
619
|
context?: Omit<AgentTurnEndContext, "message" | "toolResults">,
|
|
620
|
+
runHookOnAbortedMessage = false,
|
|
624
621
|
): Promise<void> {
|
|
625
622
|
stream.push({ type: "turn_end", message, toolResults });
|
|
626
623
|
const isAbortedOrError =
|
|
627
624
|
message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
|
|
628
|
-
if (signal?.aborted || isAbortedOrError) return;
|
|
625
|
+
if (signal?.aborted || (isAbortedOrError && !runHookOnAbortedMessage)) return;
|
|
629
626
|
await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
|
|
630
627
|
}
|
|
631
628
|
|
|
629
|
+
function createGateStopMessage(model: Model, reason: string | undefined): AssistantMessage {
|
|
630
|
+
return {
|
|
631
|
+
role: "assistant",
|
|
632
|
+
content: [{ type: "text", text: "" }],
|
|
633
|
+
api: model.api,
|
|
634
|
+
provider: model.provider,
|
|
635
|
+
model: model.id,
|
|
636
|
+
usage: {
|
|
637
|
+
input: 0,
|
|
638
|
+
output: 0,
|
|
639
|
+
cacheRead: 0,
|
|
640
|
+
cacheWrite: 0,
|
|
641
|
+
totalTokens: 0,
|
|
642
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
643
|
+
},
|
|
644
|
+
stopReason: "aborted",
|
|
645
|
+
errorMessage: reason ?? "Stopped before model call",
|
|
646
|
+
timestamp: Date.now(),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
632
650
|
/**
|
|
633
651
|
* Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
|
|
634
652
|
* run-level telemetry/coverage rollup to the existing `AgentMessage[]`
|
|
@@ -866,6 +884,7 @@ async function runLoop(
|
|
|
866
884
|
signal: AbortSignal | undefined,
|
|
867
885
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
868
886
|
streamFn?: StreamFn,
|
|
887
|
+
initialMessages: AgentMessage[] = [],
|
|
869
888
|
): Promise<void> {
|
|
870
889
|
const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
|
|
871
890
|
const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
|
|
@@ -882,6 +901,7 @@ async function runLoop(
|
|
|
882
901
|
telemetry,
|
|
883
902
|
invokeAgentSpan,
|
|
884
903
|
stepCounter,
|
|
904
|
+
initialMessages,
|
|
885
905
|
streamFn,
|
|
886
906
|
),
|
|
887
907
|
);
|
|
@@ -913,6 +933,12 @@ function endAgentStream(
|
|
|
913
933
|
stream.push(buildAgentEndEvent(newMessages, telemetry, stepCount));
|
|
914
934
|
stream.end(newMessages);
|
|
915
935
|
}
|
|
936
|
+
function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, messages: readonly AgentMessage[]): void {
|
|
937
|
+
for (const message of messages) {
|
|
938
|
+
stream.push({ type: "message_start", message });
|
|
939
|
+
stream.push({ type: "message_end", message });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
916
942
|
|
|
917
943
|
/**
|
|
918
944
|
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
|
@@ -940,6 +966,7 @@ async function runLoopBody(
|
|
|
940
966
|
telemetry: AgentTelemetry | undefined,
|
|
941
967
|
invokeAgentSpan: Span | undefined,
|
|
942
968
|
stepCounter: StepCounter,
|
|
969
|
+
initialMessages: AgentMessage[],
|
|
943
970
|
streamFn?: StreamFn,
|
|
944
971
|
): Promise<void> {
|
|
945
972
|
let deadlineTimer: Timer | undefined;
|
|
@@ -957,26 +984,33 @@ async function runLoopBody(
|
|
|
957
984
|
signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
|
|
958
985
|
}
|
|
959
986
|
|
|
987
|
+
const softRequirementState = config.softToolRequirementState ?? { escalations: 0 };
|
|
988
|
+
let preserveSoftRequirementState = false;
|
|
989
|
+
|
|
960
990
|
try {
|
|
961
|
-
let
|
|
991
|
+
let messagesToEmit = [...initialMessages];
|
|
962
992
|
if (isDeadlineExceeded(config.deadline)) {
|
|
993
|
+
emitInputMessages(stream, messagesToEmit);
|
|
963
994
|
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
964
995
|
return;
|
|
965
996
|
}
|
|
966
997
|
// Check for steering messages at start (user may have typed while waiting).
|
|
967
998
|
// Skip when the run is already externally aborted — dequeuing would strand
|
|
968
999
|
// the messages in a run that is about to die.
|
|
969
|
-
let pendingMessages: AgentMessage[]
|
|
1000
|
+
let pendingMessages: AgentMessage[];
|
|
1001
|
+
try {
|
|
1002
|
+
pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
|
|
1003
|
+
} catch (error) {
|
|
1004
|
+
stream.push({ type: "turn_start" });
|
|
1005
|
+
emitInputMessages(stream, messagesToEmit);
|
|
1006
|
+
throw error;
|
|
1007
|
+
}
|
|
970
1008
|
let harmonyRetryAttempt = 0;
|
|
971
1009
|
let harmonyTruncateResumeCount = 0;
|
|
972
1010
|
let pausedTurnContinuations = 0;
|
|
973
1011
|
|
|
974
|
-
// Soft tool requirement lifecycle (reminder
|
|
975
|
-
//
|
|
976
|
-
// overrides the static toolChoice but NEVER the host's hard getToolChoice().
|
|
977
|
-
let softRequirementId: string | undefined;
|
|
978
|
-
let forcedToolChoice: ToolChoice | undefined;
|
|
979
|
-
let softEscalations = 0;
|
|
1012
|
+
// Soft tool requirement lifecycle (reminder then escalation; see SoftToolRequirement).
|
|
1013
|
+
// The host-owned state survives only a gate stop between Agent.prompt calls.
|
|
980
1014
|
// Resolved once per logical turn at the fetch site below and reused across
|
|
981
1015
|
// Harmony-leak re-samples (which re-enter the same turn) so the consuming
|
|
982
1016
|
// getToolChoice is never advanced twice; the flag resets at the message boundary.
|
|
@@ -984,6 +1018,7 @@ async function runLoopBody(
|
|
|
984
1018
|
let softRequiredTool: string | undefined;
|
|
985
1019
|
let softSatisfies: SoftToolRequirement["satisfies"];
|
|
986
1020
|
let directiveResolvedForTurn = false;
|
|
1021
|
+
let turnOpen = false;
|
|
987
1022
|
|
|
988
1023
|
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
989
1024
|
while (true) {
|
|
@@ -992,6 +1027,7 @@ async function runLoopBody(
|
|
|
992
1027
|
// Inner loop: process tool calls and steering messages
|
|
993
1028
|
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
|
994
1029
|
if (isDeadlineExceeded(config.deadline)) {
|
|
1030
|
+
emitInputMessages(stream, messagesToEmit);
|
|
995
1031
|
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
996
1032
|
return;
|
|
997
1033
|
}
|
|
@@ -1002,62 +1038,109 @@ async function runLoopBody(
|
|
|
1002
1038
|
// engaged (host /pause). An external abort releases the park so a
|
|
1003
1039
|
// cancelled run still unwinds while everything else stays frozen.
|
|
1004
1040
|
if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(signal);
|
|
1005
|
-
if (!firstTurn) {
|
|
1006
|
-
stream.push({ type: "turn_start" });
|
|
1007
|
-
} else {
|
|
1008
|
-
firstTurn = false;
|
|
1009
|
-
}
|
|
1010
1041
|
|
|
1011
|
-
//
|
|
1042
|
+
// Build the provider-bound context before opening the turn. Queue
|
|
1043
|
+
// messages are added now but their events remain deferred until
|
|
1044
|
+
// provider preparation either succeeds or opens an error turn.
|
|
1045
|
+
const turnMessages = messagesToEmit;
|
|
1046
|
+
messagesToEmit = [];
|
|
1012
1047
|
if (pendingMessages.length > 0) {
|
|
1013
1048
|
for (const message of pendingMessages) {
|
|
1014
|
-
stream.push({ type: "message_start", message });
|
|
1015
|
-
stream.push({ type: "message_end", message });
|
|
1016
1049
|
currentContext.messages.push(message);
|
|
1017
1050
|
newMessages.push(message);
|
|
1051
|
+
turnMessages.push(message);
|
|
1018
1052
|
}
|
|
1019
1053
|
pendingMessages = [];
|
|
1020
1054
|
}
|
|
1021
1055
|
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1056
|
+
let preparedProviderCall: PreparedProviderCall;
|
|
1057
|
+
let gateResult: AgentPreModelCallResult;
|
|
1058
|
+
try {
|
|
1059
|
+
if (config.syncContextBeforeModelCall) {
|
|
1060
|
+
await config.syncContextBeforeModelCall(currentContext);
|
|
1061
|
+
}
|
|
1026
1062
|
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
softSatisfies = softReq?.satisfies;
|
|
1045
|
-
if (softReq !== undefined) {
|
|
1046
|
-
if (softReq.id !== softRequirementId) {
|
|
1047
|
-
softRequirementId = softReq.id;
|
|
1048
|
-
softEscalations = 0;
|
|
1049
|
-
for (const reminder of softReq.reminder) {
|
|
1050
|
-
stream.push({ type: "message_start", message: reminder });
|
|
1051
|
-
stream.push({ type: "message_end", message: reminder });
|
|
1052
|
-
currentContext.messages.push(reminder);
|
|
1053
|
-
newMessages.push(reminder);
|
|
1063
|
+
if (!directiveResolvedForTurn) {
|
|
1064
|
+
const directive = signal?.aborted ? undefined : config.getToolChoice?.();
|
|
1065
|
+
const softReq = isSoftToolRequirement(directive) ? directive : undefined;
|
|
1066
|
+
hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
|
|
1067
|
+
softRequiredTool = softReq?.toolName;
|
|
1068
|
+
softSatisfies = softReq?.satisfies;
|
|
1069
|
+
const softRequirementId = softRequirementState.id;
|
|
1070
|
+
if (softReq !== undefined) {
|
|
1071
|
+
if (softReq.id !== softRequirementId) {
|
|
1072
|
+
softRequirementState.id = softReq.id;
|
|
1073
|
+
softRequirementState.forcedToolChoice = undefined;
|
|
1074
|
+
softRequirementState.escalations = 0;
|
|
1075
|
+
for (const reminder of softReq.reminder) {
|
|
1076
|
+
currentContext.messages.push(reminder);
|
|
1077
|
+
newMessages.push(reminder);
|
|
1078
|
+
turnMessages.push(reminder);
|
|
1079
|
+
}
|
|
1054
1080
|
}
|
|
1081
|
+
} else {
|
|
1082
|
+
softRequirementState.id = undefined;
|
|
1083
|
+
softRequirementState.forcedToolChoice = undefined;
|
|
1084
|
+
softRequirementState.escalations = 0;
|
|
1055
1085
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1086
|
+
directiveResolvedForTurn = true;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
|
|
1090
|
+
gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
|
|
1091
|
+
} catch (error) {
|
|
1092
|
+
if (!turnOpen) {
|
|
1093
|
+
stream.push({ type: "turn_start" });
|
|
1094
|
+
emitInputMessages(stream, turnMessages);
|
|
1095
|
+
turnOpen = true;
|
|
1096
|
+
}
|
|
1097
|
+
throw error;
|
|
1098
|
+
}
|
|
1099
|
+
if (config.beforeModelCall && signal?.aborted) {
|
|
1100
|
+
gateResult = { stop: true };
|
|
1101
|
+
}
|
|
1102
|
+
if (gateResult?.stop) {
|
|
1103
|
+
if (gateResult.reason) {
|
|
1104
|
+
logger.debug("Agent loop stopped before the model call", { reason: gateResult.reason });
|
|
1059
1105
|
}
|
|
1060
|
-
|
|
1106
|
+
if (!turnOpen && !signal?.aborted) {
|
|
1107
|
+
try {
|
|
1108
|
+
config.onToolChoiceRejected?.();
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
stream.push({ type: "turn_start" });
|
|
1111
|
+
emitInputMessages(stream, turnMessages);
|
|
1112
|
+
turnOpen = true;
|
|
1113
|
+
throw error;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
emitInputMessages(stream, turnMessages);
|
|
1117
|
+
if (turnOpen) {
|
|
1118
|
+
const stopMessage = createGateStopMessage(preparedProviderCall.model, gateResult.reason);
|
|
1119
|
+
currentContext.messages.push(stopMessage);
|
|
1120
|
+
newMessages.push(stopMessage);
|
|
1121
|
+
stream.push({ type: "message_start", message: stopMessage });
|
|
1122
|
+
stream.push({ type: "message_end", message: stopMessage });
|
|
1123
|
+
await emitTurnEnd(
|
|
1124
|
+
stream,
|
|
1125
|
+
currentContext,
|
|
1126
|
+
stopMessage,
|
|
1127
|
+
[],
|
|
1128
|
+
config,
|
|
1129
|
+
signal,
|
|
1130
|
+
{ willContinue: false },
|
|
1131
|
+
true,
|
|
1132
|
+
);
|
|
1133
|
+
turnOpen = false;
|
|
1134
|
+
}
|
|
1135
|
+
preserveSoftRequirementState = !signal?.aborted;
|
|
1136
|
+
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (!turnOpen) {
|
|
1141
|
+
stream.push({ type: "turn_start" });
|
|
1142
|
+
emitInputMessages(stream, turnMessages);
|
|
1143
|
+
turnOpen = true;
|
|
1061
1144
|
}
|
|
1062
1145
|
|
|
1063
1146
|
// Stream assistant response
|
|
@@ -1075,7 +1158,8 @@ async function runLoopBody(
|
|
|
1075
1158
|
streamFn,
|
|
1076
1159
|
harmonyRetryAttempt,
|
|
1077
1160
|
hostToolChoice,
|
|
1078
|
-
forcedToolChoice,
|
|
1161
|
+
softRequirementState.forcedToolChoice,
|
|
1162
|
+
preparedProviderCall,
|
|
1079
1163
|
);
|
|
1080
1164
|
harmonyRetryAttempt = 0;
|
|
1081
1165
|
harmonyTruncateResumeCount = 0;
|
|
@@ -1118,7 +1202,7 @@ async function runLoopBody(
|
|
|
1118
1202
|
|
|
1119
1203
|
// The escalation choice (if any) applied to the call above; clear it so
|
|
1120
1204
|
// only the single escalation turn carries the forced choice.
|
|
1121
|
-
forcedToolChoice = undefined;
|
|
1205
|
+
softRequirementState.forcedToolChoice = undefined;
|
|
1122
1206
|
|
|
1123
1207
|
// A fresh logical turn re-resolves the directive next iteration; a Harmony
|
|
1124
1208
|
// retry `continue`s before this line and keeps the cached value.
|
|
@@ -1161,6 +1245,7 @@ async function runLoopBody(
|
|
|
1161
1245
|
});
|
|
1162
1246
|
}
|
|
1163
1247
|
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, { willContinue: false });
|
|
1248
|
+
turnOpen = false;
|
|
1164
1249
|
|
|
1165
1250
|
stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
|
|
1166
1251
|
stream.end(newMessages);
|
|
@@ -1212,7 +1297,7 @@ async function runLoopBody(
|
|
|
1212
1297
|
|
|
1213
1298
|
const toolResults: ToolResultMessage[] = [];
|
|
1214
1299
|
if (softNonCompliant && softRequiredTool !== undefined) {
|
|
1215
|
-
if (
|
|
1300
|
+
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
|
|
1216
1301
|
throw new Error(
|
|
1217
1302
|
`Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
|
|
1218
1303
|
);
|
|
@@ -1239,8 +1324,8 @@ async function runLoopBody(
|
|
|
1239
1324
|
status: "skipped",
|
|
1240
1325
|
});
|
|
1241
1326
|
}
|
|
1242
|
-
forcedToolChoice = { type: "tool", name: softRequiredTool };
|
|
1243
|
-
|
|
1327
|
+
softRequirementState.forcedToolChoice = { type: "tool", name: softRequiredTool };
|
|
1328
|
+
softRequirementState.escalations++;
|
|
1244
1329
|
hasMoreToolCalls = true;
|
|
1245
1330
|
} else if (hasMoreToolCalls) {
|
|
1246
1331
|
const executionResult = await executeToolCalls(
|
|
@@ -1306,6 +1391,7 @@ async function runLoopBody(
|
|
|
1306
1391
|
await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
|
|
1307
1392
|
willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
|
|
1308
1393
|
});
|
|
1394
|
+
turnOpen = false;
|
|
1309
1395
|
|
|
1310
1396
|
if (isDeadlineExceeded(config.deadline)) {
|
|
1311
1397
|
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
@@ -1361,6 +1447,11 @@ async function runLoopBody(
|
|
|
1361
1447
|
|
|
1362
1448
|
endAgentStream(stream, newMessages, telemetry, stepCounter.count);
|
|
1363
1449
|
} finally {
|
|
1450
|
+
if (!preserveSoftRequirementState) {
|
|
1451
|
+
softRequirementState.id = undefined;
|
|
1452
|
+
softRequirementState.forcedToolChoice = undefined;
|
|
1453
|
+
softRequirementState.escalations = 0;
|
|
1454
|
+
}
|
|
1364
1455
|
if (deadlineTimer) {
|
|
1365
1456
|
clearTimeout(deadlineTimer);
|
|
1366
1457
|
}
|
|
@@ -1384,45 +1475,29 @@ async function emitHarmonyAudit(
|
|
|
1384
1475
|
);
|
|
1385
1476
|
}
|
|
1386
1477
|
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1478
|
+
interface PreparedProviderCall {
|
|
1479
|
+
model: Model;
|
|
1480
|
+
context: Context;
|
|
1481
|
+
promptToolWireTools: Context["tools"];
|
|
1482
|
+
ownedDialect: Dialect | undefined;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
async function prepareProviderCall(
|
|
1392
1486
|
context: AgentContext,
|
|
1393
1487
|
config: AgentLoopConfig,
|
|
1394
1488
|
signal: AbortSignal | undefined,
|
|
1395
|
-
|
|
1396
|
-
telemetry: AgentTelemetry | undefined,
|
|
1397
|
-
invokeAgentSpan: Span | undefined,
|
|
1398
|
-
stepCounter: StepCounter,
|
|
1399
|
-
streamFn?: StreamFn,
|
|
1400
|
-
harmonyRetryAttempt = 0,
|
|
1401
|
-
hostToolChoice?: ToolChoice,
|
|
1402
|
-
forcedToolChoice?: ToolChoice,
|
|
1403
|
-
): Promise<AssistantMessage> {
|
|
1404
|
-
// Re-resolve the model per provider call (like `getReasoning`): mid-run
|
|
1405
|
-
// model switches — context promotion, retry fallback — must apply on the
|
|
1406
|
-
// next call instead of the run silently finishing on the stale model
|
|
1407
|
-
// captured at run start.
|
|
1489
|
+
): Promise<PreparedProviderCall> {
|
|
1408
1490
|
const model = config.getModel?.() ?? config.model;
|
|
1409
|
-
// Apply context transform if configured (AgentMessage[] → AgentMessage[])
|
|
1410
1491
|
let messages = context.messages;
|
|
1411
1492
|
if (config.transformContext) {
|
|
1412
1493
|
messages = await config.transformContext(messages, signal);
|
|
1413
1494
|
}
|
|
1414
1495
|
|
|
1415
|
-
// Convert to LLM-compatible messages (AgentMessage[] → Message[])
|
|
1416
1496
|
const llmMessages = await config.convertToLlm(messages);
|
|
1417
1497
|
const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
|
|
1418
|
-
|
|
1419
1498
|
const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
|
|
1420
1499
|
const exampleDialect = ownedDialect ?? preferredDialect(model.id);
|
|
1421
|
-
// Owned/in-band dialects carry the catalog in the prompt as text and send no
|
|
1422
|
-
// native `tools`, so description pruning only applies to native tool calling.
|
|
1423
1500
|
const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
|
|
1424
|
-
// Build LLM context — append-only mode caches system prompt + tools
|
|
1425
|
-
// AND keeps an append-only message log so prior-turn bytes are stable.
|
|
1426
1501
|
let llmContext: Context;
|
|
1427
1502
|
if (config.appendOnlyContext) {
|
|
1428
1503
|
config.appendOnlyContext.syncMessages(normalizedMessages);
|
|
@@ -1442,9 +1517,6 @@ async function streamAssistantResponse(
|
|
|
1442
1517
|
llmContext = await config.transformProviderContext(llmContext, model);
|
|
1443
1518
|
}
|
|
1444
1519
|
|
|
1445
|
-
// Owned tool calling: take tool calls away from the provider and run them
|
|
1446
|
-
// through the selected in-band prompt dialect. `PI_DIALECT=1` still
|
|
1447
|
-
// force-enables GLM; `PI_DIALECT=<dialect>` force-enables that dialect.
|
|
1448
1520
|
let promptToolWireTools: Context["tools"];
|
|
1449
1521
|
if (ownedDialect && llmContext.tools && llmContext.tools.length > 0) {
|
|
1450
1522
|
promptToolWireTools = llmContext.tools;
|
|
@@ -1455,6 +1527,29 @@ async function streamAssistantResponse(
|
|
|
1455
1527
|
tools: undefined,
|
|
1456
1528
|
};
|
|
1457
1529
|
}
|
|
1530
|
+
return { model, context: llmContext, promptToolWireTools, ownedDialect };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Stream an assistant response from the LLM.
|
|
1535
|
+
* This is where AgentMessage[] gets transformed to Message[] for the LLM.
|
|
1536
|
+
*/
|
|
1537
|
+
async function streamAssistantResponse(
|
|
1538
|
+
context: AgentContext,
|
|
1539
|
+
config: AgentLoopConfig,
|
|
1540
|
+
signal: AbortSignal | undefined,
|
|
1541
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
1542
|
+
telemetry: AgentTelemetry | undefined,
|
|
1543
|
+
invokeAgentSpan: Span | undefined,
|
|
1544
|
+
stepCounter: StepCounter,
|
|
1545
|
+
streamFn?: StreamFn,
|
|
1546
|
+
harmonyRetryAttempt = 0,
|
|
1547
|
+
hostToolChoice?: ToolChoice,
|
|
1548
|
+
forcedToolChoice?: ToolChoice,
|
|
1549
|
+
prepared?: PreparedProviderCall,
|
|
1550
|
+
): Promise<AssistantMessage> {
|
|
1551
|
+
const providerCall = prepared ?? (await prepareProviderCall(context, config, signal));
|
|
1552
|
+
const { model, context: llmContext, promptToolWireTools, ownedDialect } = providerCall;
|
|
1458
1553
|
|
|
1459
1554
|
const streamFunction = streamFn || streamSimple;
|
|
1460
1555
|
|
package/src/agent.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import type { AppendOnlyContextManager } from "./append-only-context";
|
|
40
40
|
import { isProviderRefusalMessage } from "./replay-policy";
|
|
41
41
|
import type {
|
|
42
|
+
AgentBeforeModelCall,
|
|
42
43
|
AgentContext,
|
|
43
44
|
AgentEvent,
|
|
44
45
|
AgentLoopConfig,
|
|
@@ -267,6 +268,8 @@ export interface AgentOptions {
|
|
|
267
268
|
abortOnFabricatedToolResult?: boolean;
|
|
268
269
|
/** Dynamic tool-choice directive (hard {@link ToolChoice} or {@link SoftToolRequirement}), resolved once per turn. */
|
|
269
270
|
getToolChoice?: () => ToolChoiceDirective | undefined;
|
|
271
|
+
/** Reject a deferred hard choice when its named tool is no longer active. */
|
|
272
|
+
onToolChoiceUnavailable?: () => void;
|
|
270
273
|
|
|
271
274
|
/**
|
|
272
275
|
* Cursor exec handlers for local tool execution.
|
|
@@ -409,6 +412,9 @@ export class Agent {
|
|
|
409
412
|
#dialect?: Dialect;
|
|
410
413
|
#abortOnFabricatedToolResult?: boolean;
|
|
411
414
|
#getToolChoice?: () => ToolChoiceDirective | undefined;
|
|
415
|
+
#onToolChoiceUnavailable?: () => void;
|
|
416
|
+
#softToolRequirementState: NonNullable<AgentLoopConfig["softToolRequirementState"]> = { escalations: 0 };
|
|
417
|
+
#deferredToolChoice?: ToolChoice;
|
|
412
418
|
#onPayload?: SimpleStreamOptions["onPayload"];
|
|
413
419
|
#onResponse?: SimpleStreamOptions["onResponse"];
|
|
414
420
|
#onSseEvent?: SimpleStreamOptions["onSseEvent"];
|
|
@@ -416,6 +422,8 @@ export class Agent {
|
|
|
416
422
|
#onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
|
|
417
423
|
#onBeforeYield?: () => Promise<void> | void;
|
|
418
424
|
#onTurnEnd?: (messages: AgentMessage[], signal?: AbortSignal, context?: AgentTurnEndContext) => Promise<void> | void;
|
|
425
|
+
#beforeModelCall?: AgentBeforeModelCall;
|
|
426
|
+
#additionalBeforeModelCalls = new Set<AgentBeforeModelCall>();
|
|
419
427
|
#asideMessageProvider?: () => AsideMessage[] | Promise<AsideMessage[]>;
|
|
420
428
|
#telemetry?: AgentLoopConfig["telemetry"];
|
|
421
429
|
#appendOnlyContext?: AppendOnlyContextManager;
|
|
@@ -490,6 +498,7 @@ export class Agent {
|
|
|
490
498
|
this.#dialect = opts.dialect;
|
|
491
499
|
this.#abortOnFabricatedToolResult = opts.abortOnFabricatedToolResult;
|
|
492
500
|
this.#getToolChoice = opts.getToolChoice;
|
|
501
|
+
this.#onToolChoiceUnavailable = opts.onToolChoiceUnavailable;
|
|
493
502
|
this.#onAssistantMessageEvent = opts.onAssistantMessageEvent;
|
|
494
503
|
this.#onHarmonyLeak = opts.onHarmonyLeak;
|
|
495
504
|
this.beforeToolCall = opts.beforeToolCall;
|
|
@@ -803,6 +812,28 @@ export class Agent {
|
|
|
803
812
|
this.#onTurnEnd = fn;
|
|
804
813
|
}
|
|
805
814
|
|
|
815
|
+
/**
|
|
816
|
+
* Install or replace the host pre-model-call gate; pass `undefined` to
|
|
817
|
+
* remove it. Gates are sampled when a run starts: installing the first
|
|
818
|
+
* gate while a run is in flight takes effect on the next run.
|
|
819
|
+
*/
|
|
820
|
+
setBeforeModelCall(fn: AgentBeforeModelCall | undefined): void {
|
|
821
|
+
this.#beforeModelCall = fn;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Add a pre-model callback without replacing callbacks owned by the host.
|
|
826
|
+
* Returns a disposer that removes only this callback. Like
|
|
827
|
+
* {@link setBeforeModelCall}, the first gate installed while a run is in
|
|
828
|
+
* flight takes effect on the next run.
|
|
829
|
+
*/
|
|
830
|
+
addBeforeModelCall(fn: AgentBeforeModelCall): () => void {
|
|
831
|
+
this.#additionalBeforeModelCalls.add(fn);
|
|
832
|
+
return () => {
|
|
833
|
+
this.#additionalBeforeModelCalls.delete(fn);
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
806
837
|
/**
|
|
807
838
|
* Provide a source of non-interrupting "aside" messages (e.g. background-job
|
|
808
839
|
* completions, late LSP diagnostics) drained at each step boundary. Never
|
|
@@ -928,10 +959,20 @@ export class Agent {
|
|
|
928
959
|
this.#followUpQueue = [];
|
|
929
960
|
}
|
|
930
961
|
|
|
962
|
+
/**
|
|
963
|
+
* Drop tool-directive state retained across a gate-stopped run: the
|
|
964
|
+
* deferred hard choice and the soft-requirement lifecycle.
|
|
965
|
+
*/
|
|
966
|
+
clearDeferredToolDirectives() {
|
|
967
|
+
this.#deferredToolChoice = undefined;
|
|
968
|
+
this.#softToolRequirementState = { escalations: 0 };
|
|
969
|
+
}
|
|
970
|
+
|
|
931
971
|
clearAllQueues() {
|
|
932
972
|
this.#steeringQueue = [];
|
|
933
973
|
this.#followUpQueue = [];
|
|
934
974
|
this.#notifySteeringWaiters();
|
|
975
|
+
this.clearDeferredToolDirectives();
|
|
935
976
|
}
|
|
936
977
|
|
|
937
978
|
hasQueuedMessages(): boolean {
|
|
@@ -1044,6 +1085,7 @@ export class Agent {
|
|
|
1044
1085
|
this.#steeringQueue = [];
|
|
1045
1086
|
this.#followUpQueue = [];
|
|
1046
1087
|
this.#notifySteeringWaiters();
|
|
1088
|
+
this.clearDeferredToolDirectives();
|
|
1047
1089
|
}
|
|
1048
1090
|
|
|
1049
1091
|
/** Send a prompt with an AgentMessage */
|
|
@@ -1219,13 +1261,28 @@ export class Agent {
|
|
|
1219
1261
|
return entry.toolResult;
|
|
1220
1262
|
};
|
|
1221
1263
|
|
|
1264
|
+
let claimedToolChoice: ToolChoice | undefined;
|
|
1222
1265
|
const getToolChoice = (): ToolChoiceDirective | undefined => {
|
|
1266
|
+
claimedToolChoice = undefined;
|
|
1267
|
+
const deferred = this.#deferredToolChoice;
|
|
1268
|
+
if (deferred !== undefined) {
|
|
1269
|
+
this.#deferredToolChoice = undefined;
|
|
1270
|
+
const active = refreshToolChoiceForActiveTools(deferred, this.#state.tools);
|
|
1271
|
+
if (active !== undefined) {
|
|
1272
|
+
claimedToolChoice = deferred;
|
|
1273
|
+
return active;
|
|
1274
|
+
}
|
|
1275
|
+
this.#onToolChoiceUnavailable?.();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1223
1278
|
const queued = this.#getToolChoice?.();
|
|
1224
1279
|
if (queued !== undefined) {
|
|
1225
1280
|
if (isSoftToolRequirement(queued)) {
|
|
1226
1281
|
return (this.#state.tools ?? []).some(tool => tool.name === queued.toolName) ? queued : undefined;
|
|
1227
1282
|
}
|
|
1228
|
-
|
|
1283
|
+
const active = refreshToolChoiceForActiveTools(queued, this.#state.tools);
|
|
1284
|
+
if (active !== undefined) claimedToolChoice = queued;
|
|
1285
|
+
return active;
|
|
1229
1286
|
}
|
|
1230
1287
|
return refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
|
|
1231
1288
|
};
|
|
@@ -1268,6 +1325,18 @@ export class Agent {
|
|
|
1268
1325
|
context.systemPrompt = this.#state.systemPrompt;
|
|
1269
1326
|
context.tools = this.#toolsForModel(this.#state.model ?? model);
|
|
1270
1327
|
},
|
|
1328
|
+
beforeModelCall:
|
|
1329
|
+
this.#beforeModelCall || this.#additionalBeforeModelCalls.size > 0
|
|
1330
|
+
? async (context, signal) => {
|
|
1331
|
+
const result = (await this.#beforeModelCall?.(context, signal)) || undefined;
|
|
1332
|
+
if (result?.stop) return result;
|
|
1333
|
+
for (const callback of this.#additionalBeforeModelCalls) {
|
|
1334
|
+
const callbackResult = (await callback(context, signal)) || undefined;
|
|
1335
|
+
if (callbackResult?.stop) return callbackResult;
|
|
1336
|
+
}
|
|
1337
|
+
return undefined;
|
|
1338
|
+
}
|
|
1339
|
+
: undefined,
|
|
1271
1340
|
cursorExecHandlers: this.#cursorExecHandlers,
|
|
1272
1341
|
cursorOnToolResult,
|
|
1273
1342
|
cwd: this.#cwd,
|
|
@@ -1288,6 +1357,10 @@ export class Agent {
|
|
|
1288
1357
|
onHarmonyLeak: this.#onHarmonyLeak,
|
|
1289
1358
|
onTurnEnd: (messages, signal, context) => this.#onTurnEnd?.(messages, signal, context),
|
|
1290
1359
|
getToolChoice,
|
|
1360
|
+
softToolRequirementState: this.#softToolRequirementState,
|
|
1361
|
+
onToolChoiceRejected: () => {
|
|
1362
|
+
if (claimedToolChoice !== undefined) this.#deferredToolChoice = claimedToolChoice;
|
|
1363
|
+
},
|
|
1291
1364
|
getModel: () => this.#state.model ?? model,
|
|
1292
1365
|
getReasoning: () => this.#state.thinkingLevel,
|
|
1293
1366
|
getDisableReasoning: () => this.#state.disableReasoning,
|
|
@@ -1322,6 +1395,7 @@ export class Agent {
|
|
|
1322
1395
|
|
|
1323
1396
|
let partial: AgentMessage | null = null;
|
|
1324
1397
|
const completedToolCallIds = new Set<string>();
|
|
1398
|
+
let turnOpen = false;
|
|
1325
1399
|
|
|
1326
1400
|
try {
|
|
1327
1401
|
const stream = messages
|
|
@@ -1329,6 +1403,8 @@ export class Agent {
|
|
|
1329
1403
|
: agentLoopContinue(context, config, this.#abortController.signal, this.streamFn);
|
|
1330
1404
|
|
|
1331
1405
|
for await (const event of stream) {
|
|
1406
|
+
if (event.type === "turn_start") turnOpen = true;
|
|
1407
|
+
if (event.type === "turn_end") turnOpen = false;
|
|
1332
1408
|
// Update internal state based on events
|
|
1333
1409
|
switch (event.type) {
|
|
1334
1410
|
case "message_start":
|
|
@@ -1448,6 +1524,10 @@ export class Agent {
|
|
|
1448
1524
|
};
|
|
1449
1525
|
|
|
1450
1526
|
if (shouldEmitVisibleError) {
|
|
1527
|
+
if (!turnOpen) {
|
|
1528
|
+
this.#emit({ type: "turn_start" });
|
|
1529
|
+
turnOpen = true;
|
|
1530
|
+
}
|
|
1451
1531
|
if (!hadAssistantStart) {
|
|
1452
1532
|
this.#state.streamMessage = errorMsg;
|
|
1453
1533
|
this.#emit({ type: "message_start", message: errorMsg });
|
|
@@ -1489,6 +1569,7 @@ export class Agent {
|
|
|
1489
1569
|
toolResults.push(toolResult);
|
|
1490
1570
|
}
|
|
1491
1571
|
this.#emit({ type: "turn_end", message: errorMsg, toolResults });
|
|
1572
|
+
turnOpen = false;
|
|
1492
1573
|
this.#emit({ type: "agent_end", messages: [errorMsg, ...toolResults] });
|
|
1493
1574
|
} else {
|
|
1494
1575
|
this.appendMessage(errorMsg);
|
package/src/proxy.ts
CHANGED
|
@@ -56,12 +56,14 @@ export type ProxyAssistantMessageEvent =
|
|
|
56
56
|
type: "done";
|
|
57
57
|
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
|
|
58
58
|
usage: AssistantMessage["usage"];
|
|
59
|
+
content?: AssistantMessage["content"];
|
|
59
60
|
}
|
|
60
61
|
| {
|
|
61
62
|
type: "error";
|
|
62
63
|
reason: Extract<StopReason, "aborted" | "error">;
|
|
63
64
|
errorMessage?: string;
|
|
64
65
|
usage: AssistantMessage["usage"];
|
|
66
|
+
content?: AssistantMessage["content"];
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
export interface ProxyStreamOptions extends SimpleStreamOptions {
|
|
@@ -372,6 +374,7 @@ function processProxyEvent(
|
|
|
372
374
|
case "done":
|
|
373
375
|
partial.stopReason = proxyEvent.reason;
|
|
374
376
|
partial.usage = proxyEvent.usage;
|
|
377
|
+
if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
|
|
375
378
|
calculateCost(model, partial.usage);
|
|
376
379
|
scrubPartialJson(partial);
|
|
377
380
|
return { type: "done", reason: proxyEvent.reason, message: partial };
|
|
@@ -380,6 +383,7 @@ function processProxyEvent(
|
|
|
380
383
|
partial.stopReason = proxyEvent.reason;
|
|
381
384
|
partial.errorMessage = proxyEvent.errorMessage;
|
|
382
385
|
partial.usage = proxyEvent.usage;
|
|
386
|
+
if (proxyEvent.content !== undefined) partial.content = proxyEvent.content;
|
|
383
387
|
calculateCost(model, partial.usage);
|
|
384
388
|
scrubPartialJson(partial);
|
|
385
389
|
return { type: "error", reason: proxyEvent.reason, error: partial };
|
package/src/types.ts
CHANGED
|
@@ -48,6 +48,24 @@ export interface AgentTurnEndContext {
|
|
|
48
48
|
willContinue: boolean;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
export interface AgentPreModelCallStop {
|
|
52
|
+
/** Stop the agent loop before sending the next provider request. */
|
|
53
|
+
stop: true;
|
|
54
|
+
/** Optional owner-facing reason, logged by the loop when it stops. */
|
|
55
|
+
reason?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type AgentPreModelCallResult = AgentPreModelCallStop | undefined;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A pre-model-call gate. Return {@link AgentPreModelCallStop} to refuse the
|
|
62
|
+
* request, or nothing to proceed; the signal aborts with the run.
|
|
63
|
+
*/
|
|
64
|
+
export type AgentBeforeModelCall = (
|
|
65
|
+
context: Context,
|
|
66
|
+
signal?: AbortSignal,
|
|
67
|
+
) => AgentPreModelCallResult | void | Promise<AgentPreModelCallResult | void>;
|
|
68
|
+
|
|
51
69
|
/**
|
|
52
70
|
* A soft tool requirement: the host wants `toolName` called before the loop
|
|
53
71
|
* runs other tools or yields, but WITHOUT paying the forced-`toolChoice` cost
|
|
@@ -87,6 +105,13 @@ export interface SoftToolRequirement {
|
|
|
87
105
|
*/
|
|
88
106
|
export type ToolChoiceDirective = ToolChoice | SoftToolRequirement;
|
|
89
107
|
|
|
108
|
+
/** Mutable soft-requirement lifecycle retained across stopped agent runs. */
|
|
109
|
+
export interface SoftToolRequirementState {
|
|
110
|
+
id?: string;
|
|
111
|
+
forcedToolChoice?: ToolChoice;
|
|
112
|
+
escalations: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
90
115
|
/** True when a {@link ToolChoiceDirective} is a soft requirement, not a hard choice. */
|
|
91
116
|
export function isSoftToolRequirement(directive: ToolChoiceDirective | undefined): directive is SoftToolRequirement {
|
|
92
117
|
return typeof directive === "object" && directive !== null && (directive as SoftToolRequirement).soft === true;
|
|
@@ -276,9 +301,23 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
276
301
|
/**
|
|
277
302
|
* Refreshes prompt/tool context from live session state before each model call.
|
|
278
303
|
* Use this when tool availability or the system prompt can change mid-turn.
|
|
304
|
+
*
|
|
305
|
+
* Runs after pending messages are folded in and before provider conversion.
|
|
306
|
+
* Mutate the agent context here; use `beforeModelCall` to inspect the
|
|
307
|
+
* provider-bound context.
|
|
279
308
|
*/
|
|
280
309
|
syncContextBeforeModelCall?: (context: AgentContext) => void | Promise<void>;
|
|
281
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Asked after the complete provider context has been built, including
|
|
313
|
+
* message conversion, provider transforms, normalized tools, and owned
|
|
314
|
+
* dialect prompt injection. Returning {@link AgentPreModelCallStop} ends
|
|
315
|
+
* the stream without emitting `turn_start`, so no turn is left open and no
|
|
316
|
+
* request is billed. Return nothing to proceed. The signal aborts when
|
|
317
|
+
* the run is canceled or its deadline expires.
|
|
318
|
+
*/
|
|
319
|
+
beforeModelCall?: AgentBeforeModelCall;
|
|
320
|
+
|
|
282
321
|
/**
|
|
283
322
|
* Optional transform applied to tool call arguments before execution.
|
|
284
323
|
* Use for deobfuscating secrets or rewriting arguments.
|
|
@@ -356,6 +395,19 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
356
395
|
*/
|
|
357
396
|
getToolChoice?: () => ToolChoiceDirective | undefined;
|
|
358
397
|
|
|
398
|
+
/**
|
|
399
|
+
* Soft-requirement lifecycle retained by the host when a pre-model-call
|
|
400
|
+
* gate stops a run before its pending reminder or escalation is served.
|
|
401
|
+
*/
|
|
402
|
+
softToolRequirementState?: SoftToolRequirementState;
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Notifies the host that the pre-model-call gate stopped the run after a
|
|
406
|
+
* hard tool choice was obtained from {@link getToolChoice} but before it
|
|
407
|
+
* was served, so the host can retain it for the next admitted request.
|
|
408
|
+
*/
|
|
409
|
+
onToolChoiceRejected?: () => void;
|
|
410
|
+
|
|
359
411
|
/**
|
|
360
412
|
* Dynamic reasoning effort override, resolved per LLM call.
|
|
361
413
|
* When set and returns a value, overrides the static `reasoning` captured
|