@animalabs/agent-framework 0.26.0 → 0.26.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/README.md +1 -1
- package/dist/src/framework.d.ts +141 -24
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +781 -133
- package/dist/src/framework.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +24 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +34 -11
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +61 -27
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +11 -5
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +21 -6
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +25 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +44 -3
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/events.d.ts +7 -2
- package/dist/src/types/events.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/framework.ts +781 -135
- package/src/mcpl/channel-registry.ts +23 -4
- package/src/mcpl/push-coalescer.ts +83 -34
- package/src/mcpl/references.ts +22 -7
- package/src/tool-result-history.ts +43 -3
- package/src/types/events.ts +7 -2
package/src/framework.ts
CHANGED
|
@@ -116,6 +116,8 @@ import type { WorkspaceModule } from './modules/workspace/index.js';
|
|
|
116
116
|
import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
|
|
117
117
|
import {
|
|
118
118
|
nonErrorResultText,
|
|
119
|
+
shownToScript,
|
|
120
|
+
shownToScriptFromServer,
|
|
119
121
|
toolResultDataToHistoryString,
|
|
120
122
|
truncateForHistory,
|
|
121
123
|
DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
|
|
@@ -989,6 +991,71 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
|
|
|
989
991
|
return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry';
|
|
990
992
|
}
|
|
991
993
|
|
|
994
|
+
/**
|
|
995
|
+
* Marks a held turn's retry: the request a provider hold keeps for a turn
|
|
996
|
+
* whose call failed into it, or whose compile it held with nothing sent.
|
|
997
|
+
* That turn has begun and not ended: it starts again when the hold releases
|
|
998
|
+
* (a new start of its open turn, as 0.26.0 started it). So, as a
|
|
999
|
+
* continuation on its way does (continuesTurn), it keeps its agent's turn
|
|
1000
|
+
* alive meanwhile, passes a quiesce, and goes with an abandon, a stop() or
|
|
1001
|
+
* its agent's removal. A symbol, not a field: hosts build requests too, and
|
|
1002
|
+
* only the framework may say one is a turn's own retry. Spreads and rests
|
|
1003
|
+
* copy it, as every copy of a request the scheduler makes is one.
|
|
1004
|
+
*/
|
|
1005
|
+
const TURN_RETRY: unique symbol = Symbol('turn-retry');
|
|
1006
|
+
/**
|
|
1007
|
+
* The batch a start that carries a turn on (continuesTurn) was merged from,
|
|
1008
|
+
* when the scheduler merged wakes that aren't the turn's into it: every
|
|
1009
|
+
* request it took in, as each was. Until the start takes its turn token it
|
|
1010
|
+
* stands for them all. A scheduler pass that takes it back puts the batch in
|
|
1011
|
+
* its place, each request with its own identity and flags; a quiesce at the
|
|
1012
|
+
* auxiliary park sends the batch back to the scheduler; and a caller's stop
|
|
1013
|
+
* or an abandon that drops the start puts back the batch's wakes, the
|
|
1014
|
+
* requests that don't carry the turn on (dropContinuations), where they run
|
|
1015
|
+
* as a turn of their own, as wakes queued while a turn streams do. Once the
|
|
1016
|
+
* start takes its token the turn reads them, and beginAgentTurn drops the
|
|
1017
|
+
* record. A symbol, as TURN_RETRY is, so spreads and rests copy it.
|
|
1018
|
+
*/
|
|
1019
|
+
const MERGED_BATCH: unique symbol = Symbol('merged-batch');
|
|
1020
|
+
type MarkedRequest = InferenceRequest & { [TURN_RETRY]?: true; [MERGED_BATCH]?: InferenceRequest[] };
|
|
1021
|
+
|
|
1022
|
+
function mergedBatch(request: InferenceRequest): InferenceRequest[] | undefined {
|
|
1023
|
+
return (request as MarkedRequest)[MERGED_BATCH];
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** The wakes of a merged start's batch (MERGED_BATCH): its requests that
|
|
1027
|
+
* don't carry the turn on. */
|
|
1028
|
+
function mergedWakes(request: InferenceRequest): InferenceRequest[] {
|
|
1029
|
+
return (mergedBatch(request) ?? []).filter((r) => !continuesTurn(r));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function isTurnRetry(request: InferenceRequest): boolean {
|
|
1033
|
+
return (request as MarkedRequest)[TURN_RETRY] === true;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** How to go on sooner than a hold keeping a turn's retry lets the turn go
|
|
1037
|
+
* on: what surgery's and the puppet's refusals advise. */
|
|
1038
|
+
function heldTurnWayOn(statedWait: boolean): string {
|
|
1039
|
+
return 'a quiesce lets a held turn start when its hold ends and finish, so to go on sooner, end it with quiesce ' +
|
|
1040
|
+
'with abandon: true, which ends it once the quiesce\'s wait for the drain runs out (at once on a host ' +
|
|
1041
|
+
'already quiesced)' +
|
|
1042
|
+
(statedWait
|
|
1043
|
+
? ", or lift a stated wait with release-provider-wait, so the turn tries again once no host's or " +
|
|
1044
|
+
'acceleration hold binds it'
|
|
1045
|
+
: '');
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** A copy of a held turn's trigger, marked as its retry (TURN_RETRY). */
|
|
1049
|
+
function asTurnRetry(trigger: InferenceRequest): InferenceRequest {
|
|
1050
|
+
return { ...trigger, [TURN_RETRY]: true } as MarkedRequest;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** A request that carries its agent's turn on: a continuation of it, or the
|
|
1054
|
+
* retry a provider hold keeps for it (TURN_RETRY). */
|
|
1055
|
+
function continuesTurn(request: InferenceRequest): boolean {
|
|
1056
|
+
return resumesLogicalTurn(request.reason) || isTurnRetry(request);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
992
1059
|
/**
|
|
993
1060
|
* The answers dispatchScriptToolCall's safety timer made, when no result came
|
|
994
1061
|
* for a script's call: the tool may still be running, or its result was lost,
|
|
@@ -997,6 +1064,20 @@ function resumesLogicalTurn(reason: string | undefined): boolean {
|
|
|
997
1064
|
*/
|
|
998
1065
|
const SCRIPT_CALL_NO_RESULT = new WeakSet<ToolResult>();
|
|
999
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* An MCPL peer's results whose blocks couldn't be given RFC-005's treatment
|
|
1069
|
+
* for a script, because JSON couldn't hold them (dispatchMcplToolCall's
|
|
1070
|
+
* reading), each with why. The failure is the script lane's alone: the
|
|
1071
|
+
* model's lane reads the same answer with no such round trip. So such a
|
|
1072
|
+
* result keeps the peer's own outcome, its blocks dropped, and the trace and
|
|
1073
|
+
* the call's terminal report what the peer answered, as on v0.26.1, while
|
|
1074
|
+
* the script lane's exit answers it as a result it can't convert to JSON
|
|
1075
|
+
* (scriptResultAnswer). A module's result that JSON can't hold differs: no
|
|
1076
|
+
* route can store or send it, so its dispatch makes it an error result for
|
|
1077
|
+
* everyone (checkedToolResult).
|
|
1078
|
+
*/
|
|
1079
|
+
const SCRIPT_RESULT_UNCONVERTIBLE = new WeakMap<ToolResult, unknown>();
|
|
1080
|
+
|
|
1000
1081
|
/**
|
|
1001
1082
|
* One logical turn's routing state and receipt tallies
|
|
1002
1083
|
* (AgentFramework.turnRouting): everything the turn has decided about where
|
|
@@ -1230,8 +1311,10 @@ function ephemeralRunStopped(name: string, observed?: string): Error {
|
|
|
1230
1311
|
|
|
1231
1312
|
/** The reason on the inference:aborted that ends a turn whose retry stop()
|
|
1232
1313
|
* cancelled, whichever frame was waiting to retry: beginAgentTurn's, when
|
|
1233
|
-
* the stream couldn't start, or driveStream's, when it failed once started
|
|
1234
|
-
*
|
|
1314
|
+
* the stream couldn't start, or driveStream's, when it failed once started;
|
|
1315
|
+
* or whose failure a provider hold would have kept for its retry once stop()
|
|
1316
|
+
* had closed admission (endFailureAtStop). Its inference:failed came first,
|
|
1317
|
+
* and this says no retry follows it. */
|
|
1235
1318
|
const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything';
|
|
1236
1319
|
|
|
1237
1320
|
/** The reason on the inference:aborted that ends a turn whose start waited to
|
|
@@ -1239,6 +1322,10 @@ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stoppin
|
|
|
1239
1322
|
* revive the earlier lifecycle's turn in the next one. */
|
|
1240
1323
|
const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
|
|
1241
1324
|
|
|
1325
|
+
/** The reason on the inference:aborted that ends a turn whose continuation
|
|
1326
|
+
* was dropped before it started, other than by an abandon or a stop. */
|
|
1327
|
+
const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
|
|
1328
|
+
|
|
1242
1329
|
/** One frame's window while it holds a turn with no stream and one of its
|
|
1243
1330
|
* checks is ahead (AgentFramework.checkAhead). `abandoned` is an operator's
|
|
1244
1331
|
* quiesce abandon that reached the turn there, which the frame's check
|
|
@@ -1255,6 +1342,12 @@ function isTurnContinuation(reason: string): boolean {
|
|
|
1255
1342
|
|| reason === 'tool_result_guard_retry';
|
|
1256
1343
|
}
|
|
1257
1344
|
|
|
1345
|
+
/** A request a quiesce lets through, so a turn already alive can finish:
|
|
1346
|
+
* a continuation of the turn, or a held turn's retry (TURN_RETRY). */
|
|
1347
|
+
function passesQuiesce(request: InferenceRequest | undefined): boolean {
|
|
1348
|
+
return request !== undefined && (isTurnContinuation(request.reason) || isTurnRetry(request));
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1258
1351
|
/** Wake sources the framework generates for itself (bounce wakes,
|
|
1259
1352
|
* continuations, retries, unsticks, admission requeues). A wake from any
|
|
1260
1353
|
* other source — a channel message, a heartbeat or other server event, a
|
|
@@ -1603,19 +1696,35 @@ export interface HostModeStatus {
|
|
|
1603
1696
|
quiesced: boolean;
|
|
1604
1697
|
reason?: string;
|
|
1605
1698
|
since?: number;
|
|
1606
|
-
/** True when no turn is alive
|
|
1607
|
-
*
|
|
1699
|
+
/** True when no turn is alive: no turn token (the token spans dequeue →
|
|
1700
|
+
* settled teardown, strictly wider than activeStreams), no continuation
|
|
1701
|
+
* of a turn on its way to its start (a budget restart's or a tool-result
|
|
1702
|
+
* guard's retry, queued, held by a provider hold, parked or in a scheduler
|
|
1703
|
+
* pass), no turn whose call failed into a provider hold waiting for the
|
|
1704
|
+
* hold to release its retry (wherever that retry then waits), and no wake
|
|
1705
|
+
* parked on provider admission. It covers the
|
|
1706
|
+
* framework's own turns: a caller of the direct Agent API
|
|
1707
|
+
* (`Agent.runInference`) owns its turns' timing. */
|
|
1608
1708
|
drained: boolean;
|
|
1609
1709
|
activeTurns: number;
|
|
1710
|
+
/** The agents with a turn alive, the ones `activeTurns` counts, sorted:
|
|
1711
|
+
* each from its turn's dequeue to its settled teardown, so a turn in its
|
|
1712
|
+
* hooks or compile, one finishing its deliveries, one whose continuation
|
|
1713
|
+
* is on its way to its start, or one a provider hold keeps for its retry,
|
|
1714
|
+
* is listed. These are the agents an abandon or
|
|
1715
|
+
* a stop() made now would find mid-turn. */
|
|
1716
|
+
activeTurnAgents: string[];
|
|
1610
1717
|
/** Wakes that own provider admission while waiting for an in-flight
|
|
1611
1718
|
* auxiliary call — no turn token yet, but a turn is one settle away. The
|
|
1612
1719
|
* admission continuation rechecks quiesce and requeues them; `drained`
|
|
1613
|
-
* is false until it has.
|
|
1720
|
+
* is false until it has. A parked continuation of a turn is counted here
|
|
1721
|
+
* and, as a turn alive, in `activeTurns` and `activeTurnAgents`. */
|
|
1614
1722
|
parkedAdmissions: number;
|
|
1615
1723
|
/** Inference requests parked by the quiesce wake gate (while quiesced), or
|
|
1616
1724
|
* simply queued (while serving — non-zero right after a resume until the
|
|
1617
1725
|
* scheduler's next pass consumes them). Continuations of a held turn
|
|
1618
|
-
* (budget restart, tool results)
|
|
1726
|
+
* (budget restart, tool results) and a held turn's retry, which a quiesce
|
|
1727
|
+
* lets through, are never counted. */
|
|
1619
1728
|
gatedRequests: number;
|
|
1620
1729
|
/** code_execution scripts still running that no call is waiting on: left
|
|
1621
1730
|
* running after a yield, outside any turn. Quiesce does NOT stop them —
|
|
@@ -1832,6 +1941,9 @@ interface ProviderAccelerationCooldown {
|
|
|
1832
1941
|
/** Armed by an auxiliary (compression) failure with no primary involved:
|
|
1833
1942
|
* release reopens auxiliary admission without synthesising an inference. */
|
|
1834
1943
|
auxiliaryOrigin?: boolean;
|
|
1944
|
+
/** Its held requests were taken out by dropContinuations: release reopens
|
|
1945
|
+
* admission without synthesising an inference in their place. */
|
|
1946
|
+
emptiedByDrop?: boolean;
|
|
1835
1947
|
/** Set when this hold is a provider wait (a stated retry-after) for the
|
|
1836
1948
|
* agent's primary model: it holds primary turns only. Auxiliary calls are
|
|
1837
1949
|
* admitted per model by the provider waits themselves, so a compression
|
|
@@ -2028,12 +2140,14 @@ function scriptResultBrief(content: unknown[]): string {
|
|
|
2028
2140
|
|
|
2029
2141
|
/**
|
|
2030
2142
|
* What a script gets for a tool result that can't be converted to JSON,
|
|
2031
|
-
* such as a cyclic object or a BigInt that an in-process module returned
|
|
2143
|
+
* such as a cyclic object or a BigInt that an in-process module returned,
|
|
2144
|
+
* or an MCPL peer's blocks nested deeper than JSON.stringify can take.
|
|
2032
2145
|
* It names the tool, and says the call itself completed, so a script that
|
|
2033
2146
|
* reports it doesn't invite a blind retry. The host's log gets the same
|
|
2034
|
-
* line, since the tool
|
|
2035
|
-
* script reports it. The reason and the
|
|
2036
|
-
* VALUE_CHARS each, so the failure reaches the
|
|
2147
|
+
* line, since the tool, a module's or a server's, returns what a script
|
|
2148
|
+
* can't receive, whether or not the script reports it. The reason and the
|
|
2149
|
+
* tool's own error are cut to VALUE_CHARS each, so the failure reaches the
|
|
2150
|
+
* script whatever its memory.
|
|
2037
2151
|
*/
|
|
2038
2152
|
function unconvertibleScriptResult(toolName: string, err: unknown, ownError?: string): string {
|
|
2039
2153
|
let reason: string;
|
|
@@ -2065,13 +2179,15 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(
|
|
|
2065
2179
|
* memoryMaxBytes. So the figure moves with memoryMaxBytes. What it can't
|
|
2066
2180
|
* count: a result's `structured` value, which the measure leaves out and the
|
|
2067
2181
|
* answer carries as JSON beside the content, and that result's blocks past
|
|
2068
|
-
* their text, which the answer carries as
|
|
2069
|
-
* keys, an image's data); nesting past 2,000 levels; and a
|
|
2182
|
+
* their text, which the answer carries as the script lane gives them (each
|
|
2183
|
+
* further block's keys, an image's data); nesting past 2,000 levels; and a
|
|
2070
2184
|
* parse that needs more of the script's memory than is free, as for JSON
|
|
2071
2185
|
* dense in small objects or a script already holding most of its memory.
|
|
2072
2186
|
* Such a result, or one past the figure that doesn't fit, reaches the script
|
|
2073
2187
|
* as ResultTooLarge, saying the call took effect, and nothing of it is cut or
|
|
2074
|
-
* saved.
|
|
2188
|
+
* saved. One the host can't convert to JSON at all, such as one nested deeper
|
|
2189
|
+
* than its runtime's JSON.stringify can take, is answered instead as a
|
|
2190
|
+
* failure naming the tool and saying the call itself completed.
|
|
2075
2191
|
*/
|
|
2076
2192
|
const scriptResultMaxChars = (memoryMaxBytes: number): number =>
|
|
2077
2193
|
Math.max(0, Math.floor((Math.min(maxResultBytes(memoryMaxBytes), bufferConstants.MAX_STRING_LENGTH) - SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES) / 6));
|
|
@@ -2378,6 +2494,15 @@ type McpRestartClaim = { readonly restarting: string; endedBy?: string };
|
|
|
2378
2494
|
/** Why the framework cancelled a stream it owns: the mark it records, by
|
|
2379
2495
|
* agent and stream, before the cancel, for the stream's terminal to read. */
|
|
2380
2496
|
type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned' | 'shutdown';
|
|
2497
|
+
/** How a turn whose continuation is dropped before it starts ends: an
|
|
2498
|
+
* operator's quiesce abandon or stop() settles it as a stop ends a turn's
|
|
2499
|
+
* start; any other drop ends it as a start dropped before it sends anything.
|
|
2500
|
+
* `why` is said in the line its end logs. */
|
|
2501
|
+
type ContinuationDrop =
|
|
2502
|
+
| { kind: 'quiesce_abandoned' | 'shutdown' | 'dropped'; why: string }
|
|
2503
|
+
/** A caller's stop (abortInference) of a turn whose continuation or retry
|
|
2504
|
+
* is on its way, with the reason it gave. */
|
|
2505
|
+
| { kind: 'caller'; reason?: string; why: string };
|
|
2381
2506
|
|
|
2382
2507
|
/** What opened a channel the agent didn't ask to open
|
|
2383
2508
|
* (recordChannelAutoOpenNotice). */
|
|
@@ -2808,6 +2933,15 @@ export class AgentFramework {
|
|
|
2808
2933
|
// and its settle except that turn's own blocks, and, before its checkpoint,
|
|
2809
2934
|
// the inputs its start writes (flushForStart).
|
|
2810
2935
|
private activeTurnTokens: Map<string, number> = new Map();
|
|
2936
|
+
/** Each running scheduler pass's requests, which it took from
|
|
2937
|
+
* pendingRequests: it holds those of the agents it hasn't taken up yet
|
|
2938
|
+
* while it awaits an earlier agent's start, so a continuation among them
|
|
2939
|
+
* is still on its way (pendingContinuationAgents). */
|
|
2940
|
+
private passRequests: Set<Map<string, InferenceRequest[]>> = new Set();
|
|
2941
|
+
/** Continuations whose start waits on provider admission behind an
|
|
2942
|
+
* auxiliary call, in no queue meanwhile. A drop takes one out and gives
|
|
2943
|
+
* its admission back; the park, woken, then finds it gone. */
|
|
2944
|
+
private parkedContinuations: Set<InferenceRequest> = new Set();
|
|
2811
2945
|
/** Each turn whose settle has ended it (settleAgent, endDroppedTurn), for
|
|
2812
2946
|
* its agent's scripts: its start's teardown can still hold the agent's
|
|
2813
2947
|
* token, and a turn that has ended isn't live (liveTurns). A later start
|
|
@@ -2855,12 +2989,14 @@ export class AgentFramework {
|
|
|
2855
2989
|
* is still the agent's, so a start that ends after a later frame opened
|
|
2856
2990
|
* one, such as its retry's stream waiting to retry, leaves that one open.
|
|
2857
2991
|
* An operator's abandon marks the window, and the frame's check collects
|
|
2858
|
-
* the mark, which goes with the window.
|
|
2859
|
-
*
|
|
2860
|
-
*
|
|
2861
|
-
*
|
|
2862
|
-
*
|
|
2863
|
-
*
|
|
2992
|
+
* the mark, which goes with the window. A stop of a turn with no stream
|
|
2993
|
+
* lands here, or, for a turn whose continuation or retry is on its way (a
|
|
2994
|
+
* provider hold's, a park's, the queue's or a pass's), through
|
|
2995
|
+
* abortInference(), an abandon or stop(), which drop that continuation or
|
|
2996
|
+
* retry (dropContinuations); a stop made anywhere else, as while an agent
|
|
2997
|
+
* is idle or a turn finishes its last delivery, has no check to end it,
|
|
2998
|
+
* and the next start drops it. Keyed by the Agent, so a replacement agent
|
|
2999
|
+
* under the same name never shares one.
|
|
2864
3000
|
*/
|
|
2865
3001
|
private checkAhead: Map<Agent, CheckWindow> = new Map();
|
|
2866
3002
|
|
|
@@ -2951,9 +3087,17 @@ export class AgentFramework {
|
|
|
2951
3087
|
// config.codeExecution.enabled: its settings, and every agent's scripts,
|
|
2952
3088
|
// each in a process of its own on the engine (AgentScripts). Waiters
|
|
2953
3089
|
// resolve script-inner tool calls dispatched through the normal
|
|
2954
|
-
// dispatchToolCall path.
|
|
3090
|
+
// dispatchToolCall path. A waiter's route is where the dispatch sent its
|
|
3091
|
+
// call: `toServer` is set when it went to a server's own tool (an MCPL or
|
|
3092
|
+
// MCP-only peer's), so the script lane's exit treats every other result as
|
|
3093
|
+
// a module's (scriptResultAnswer), by where the call went rather than by a
|
|
3094
|
+
// name lookup a connect or disconnect can change while it runs. A
|
|
3095
|
+
// framework tool that talks to a server itself, such as a channel tool,
|
|
3096
|
+
// builds its own result, which counts as a module's. A caller of
|
|
3097
|
+
// dispatchScriptToolCall that passes no route gets one left false, the
|
|
3098
|
+
// reading that withholds more.
|
|
2955
3099
|
private codeExecution: { settings: IsolateSettings; scripts: AgentScripts } | null = null;
|
|
2956
|
-
private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
|
|
3100
|
+
private scriptToolWaiters: Map<string, { resolve: (result: ToolResult) => void; route: { toServer: boolean } }> = new Map();
|
|
2957
3101
|
/** Per agent, a round that called code_execution and waits for those calls'
|
|
2958
3102
|
* results before its prose is routed (ScriptRound). */
|
|
2959
3103
|
private scriptRounds: Map<string, ScriptRound> = new Map();
|
|
@@ -4005,6 +4149,16 @@ export class AgentFramework {
|
|
|
4005
4149
|
// framework must not keep up to the whole ledger budget referenced.
|
|
4006
4150
|
this.toolImageLedgers.clear();
|
|
4007
4151
|
|
|
4152
|
+
// A turn whose continuation is still on its way ends with the stop, as
|
|
4153
|
+
// a turn's start a stop reaches does: the continuation would start only
|
|
4154
|
+
// after a start(), in a lifecycle where its logical turn is gone
|
|
4155
|
+
// (openLogical, below). Before the holds and the parks go, so the
|
|
4156
|
+
// continuation leaves them with its turn.
|
|
4157
|
+
step(() => {
|
|
4158
|
+
for (const agentName of this.pendingContinuationAgents()) {
|
|
4159
|
+
this.dropContinuations(agentName, { kind: 'shutdown', why: 'the framework is stopping' });
|
|
4160
|
+
}
|
|
4161
|
+
});
|
|
4008
4162
|
// A stopped host must never hang behind its own cooldown.
|
|
4009
4163
|
step(() => this.cancelProviderAdmission());
|
|
4010
4164
|
// An open logical turn doesn't survive a stop: a start after a restart
|
|
@@ -4593,6 +4747,21 @@ export class AgentFramework {
|
|
|
4593
4747
|
a.coalescingSubject === b.coalescingSubject && batchSubjects(a) === batchSubjects(b) &&
|
|
4594
4748
|
a.readsDeferredWrite === b.readsDeferredWrite;
|
|
4595
4749
|
}
|
|
4750
|
+
/** Keep `request` among a hold's `held` requests, once by
|
|
4751
|
+
* sameInferenceRequest. A held turn's retry (TURN_RETRY) whose equal is
|
|
4752
|
+
* already held marks that one, so the hold still keeps the turn alive. */
|
|
4753
|
+
private holdRequest(held: InferenceRequest[], request: InferenceRequest): void {
|
|
4754
|
+
const same = held.find((r) => this.sameInferenceRequest(r, request));
|
|
4755
|
+
if (!same) {
|
|
4756
|
+
held.push(request);
|
|
4757
|
+
return;
|
|
4758
|
+
}
|
|
4759
|
+
if (isTurnRetry(request)) (same as MarkedRequest)[TURN_RETRY] = true;
|
|
4760
|
+
// A merged start's batch (MERGED_BATCH) stays recorded on the request
|
|
4761
|
+
// kept in its place, which then stands for it, as the start did.
|
|
4762
|
+
const batch = mergedBatch(request);
|
|
4763
|
+
if (batch) (same as MarkedRequest)[MERGED_BATCH] = [...new Set([...(mergedBatch(same) ?? []), ...batch])];
|
|
4764
|
+
}
|
|
4596
4765
|
private consultProviderHold(error: Error, agentName: string): { holdMs: number; reason: string } | undefined {
|
|
4597
4766
|
if (!this.providerHoldHook) return undefined;
|
|
4598
4767
|
try {
|
|
@@ -4644,14 +4813,19 @@ export class AgentFramework {
|
|
|
4644
4813
|
// failure streak), and the wait applies to the calls that follow it.
|
|
4645
4814
|
if (!acceleration && !hostHold) {
|
|
4646
4815
|
return wait !== undefined && isMembraneError(error) && error.retryable === true
|
|
4647
|
-
&& this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true);
|
|
4816
|
+
&& this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true);
|
|
4648
4817
|
}
|
|
4818
|
+
// As in holdForProviderWait: once stop() has closed admission, nothing is
|
|
4819
|
+
// held, and nothing started.
|
|
4820
|
+
if (this.providerAdmissionClosed) return true;
|
|
4649
4821
|
const now = Date.now();
|
|
4650
4822
|
const pacing = hostHold ? undefined : this.accelerationCooldownMs(agent.name, error as MembraneError);
|
|
4651
4823
|
const delayMs = hostHold ? hostHold.holdMs : pacing!.delayMs;
|
|
4652
4824
|
const existing = this.providerAccelerationCooldowns.get(agent.name);
|
|
4653
4825
|
const held = existing?.heldRequests ?? [];
|
|
4654
|
-
|
|
4826
|
+
// The failed turn's own request, kept as its retry (TURN_RETRY). An
|
|
4827
|
+
// auxiliary call's failure passes no trigger: it holds no turn of its own.
|
|
4828
|
+
if (trigger) this.holdRequest(held, asTurnRetry(trigger));
|
|
4655
4829
|
if (existing) clearTimeout(existing.timer);
|
|
4656
4830
|
const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), Math.min(delayMs, MAX_TIMER_DELAY_MS)); timer.unref?.();
|
|
4657
4831
|
this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
|
|
@@ -4660,7 +4834,8 @@ export class AgentFramework {
|
|
|
4660
4834
|
// The stated wait (recorded above for agent.model) set this hold's
|
|
4661
4835
|
// length: an operator release of it returns the hold to AF's own pacing.
|
|
4662
4836
|
...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
|
|
4663
|
-
...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {})
|
|
4837
|
+
...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
|
|
4838
|
+
...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
|
|
4664
4839
|
if (auxiliary && !existing) {
|
|
4665
4840
|
console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` +
|
|
4666
4841
|
`holding primary/auxiliary for ${delayMs}ms`);
|
|
@@ -4699,7 +4874,7 @@ export class AgentFramework {
|
|
|
4699
4874
|
if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined;
|
|
4700
4875
|
if (this.conversationAgentHomes.has(agent.name)) {
|
|
4701
4876
|
const wait = this.recordProviderWait(agent.name, agent.model, error);
|
|
4702
|
-
return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true)
|
|
4877
|
+
return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true)
|
|
4703
4878
|
? 'held'
|
|
4704
4879
|
: undefined;
|
|
4705
4880
|
}
|
|
@@ -4767,9 +4942,12 @@ export class AgentFramework {
|
|
|
4767
4942
|
? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
|
|
4768
4943
|
: `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
|
|
4769
4944
|
}
|
|
4770
|
-
if (cooldown.auxiliaryOrigin && cooldown.heldRequests.length === 0) {
|
|
4771
|
-
// Nobody asked for an inference while parked
|
|
4772
|
-
|
|
4945
|
+
if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
|
|
4946
|
+
// Nobody asked for an inference while parked, or what was held was a
|
|
4947
|
+
// continuation since dropped with its turn: just reopen the gate. A
|
|
4948
|
+
// primary the hold left pending won't run, so its recovery ends.
|
|
4949
|
+
console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
|
|
4950
|
+
if (cooldown.emptiedByDrop) this.endPendingPrimary(agentName);
|
|
4773
4951
|
this.flushAuxiliaryAdmission(agentName);
|
|
4774
4952
|
return;
|
|
4775
4953
|
}
|
|
@@ -4846,17 +5024,30 @@ export class AgentFramework {
|
|
|
4846
5024
|
* Hold this agent's primary turns for a provider wait on its model. The
|
|
4847
5025
|
* requests are kept and released together, into one fresh compile, when
|
|
4848
5026
|
* the wait ends: the same path an acceleration cooldown takes, without
|
|
4849
|
-
* parking auxiliary calls to other models.
|
|
5027
|
+
* parking auxiliary calls to other models. `report`, once the wait is
|
|
5028
|
+
* found to bind, runs before anything is held: a caller that reports its
|
|
5029
|
+
* turn's end there (inference:failed) does so before the hold takes the
|
|
5030
|
+
* turn's retry, as every failure site does.
|
|
4850
5031
|
*/
|
|
4851
|
-
private holdForProviderWait(agent: Agent, wait: ProviderWait, requests: InferenceRequest[], failed = false): boolean {
|
|
5032
|
+
private holdForProviderWait(agent: Agent, wait: ProviderWait, requests: InferenceRequest[], failed = false, report?: () => void): boolean {
|
|
4852
5033
|
const now = Date.now();
|
|
4853
5034
|
const until = wait.until ?? Number.POSITIVE_INFINITY;
|
|
4854
5035
|
if (until <= now) return false;
|
|
5036
|
+
report?.();
|
|
5037
|
+
// stop() has cleared every hold and dropped every turn on its way: a
|
|
5038
|
+
// failure or start reaching here after it holds nothing, so no retry
|
|
5039
|
+
// outlives the stop as a turn still alive. Nothing is started either.
|
|
5040
|
+
if (this.providerAdmissionClosed) return true;
|
|
5041
|
+
// A listener of the report may have released the wait
|
|
5042
|
+
// (releaseProviderWait) before anything was held: nothing binds now, so
|
|
5043
|
+
// the requests start, as the release of an installed hold starts them.
|
|
5044
|
+
if (report && !this.providerWaits?.active(agent.name, wait.model)) {
|
|
5045
|
+
this.pendingRequests.push(...requests);
|
|
5046
|
+
return true;
|
|
5047
|
+
}
|
|
4855
5048
|
const existing = this.providerAccelerationCooldowns.get(agent.name);
|
|
4856
5049
|
const held = existing?.heldRequests ?? [];
|
|
4857
|
-
for (const request of requests)
|
|
4858
|
-
if (!held.some((r) => this.sameInferenceRequest(r, request))) held.push(request);
|
|
4859
|
-
}
|
|
5050
|
+
for (const request of requests) this.holdRequest(held, request);
|
|
4860
5051
|
// An acceleration or host hold already parks the whole agent; its release
|
|
4861
5052
|
// re-checks this wait at admission.
|
|
4862
5053
|
if (existing && existing.waitModel === undefined) return true;
|
|
@@ -5461,9 +5652,11 @@ export class AgentFramework {
|
|
|
5461
5652
|
|
|
5462
5653
|
/**
|
|
5463
5654
|
* Abort an in-flight inference for an agent. True when there was one to
|
|
5464
|
-
* stop: a stream
|
|
5655
|
+
* stop: a stream; a turn with no stream and one of its checks ahead (its
|
|
5465
5656
|
* hooks or compile, a wait to retry, the tool-result guard's wait), which
|
|
5466
|
-
* ends at that check, before it sends anything more
|
|
5657
|
+
* ends at that check, before it sends anything more; or a turn whose
|
|
5658
|
+
* continuation or retry is on its way (queued, kept by a provider hold,
|
|
5659
|
+
* parked at admission or in a scheduler pass), which ends here.
|
|
5467
5660
|
*/
|
|
5468
5661
|
abortInference(agentName: string, reason?: string): boolean {
|
|
5469
5662
|
const agent = this.agents.get(agentName);
|
|
@@ -5493,6 +5686,20 @@ export class AgentFramework {
|
|
|
5493
5686
|
checkWindow.wake?.();
|
|
5494
5687
|
return true;
|
|
5495
5688
|
}
|
|
5689
|
+
// A turn whose continuation or retry is on its way (a budget restart or
|
|
5690
|
+
// a guard retry, or a failed turn's retry a provider hold keeps:
|
|
5691
|
+
// pendingContinuationAgents) ends here, as a stop of a turn's start ends
|
|
5692
|
+
// one, whether or not the frame that handed it on has let go of its
|
|
5693
|
+
// token yet (that frame ends on its own and settles nothing more): the
|
|
5694
|
+
// continuation or retry is dropped, with one inference:aborted carrying
|
|
5695
|
+
// the stop's reason. Wakes that arrived meanwhile aren't the turn's, and
|
|
5696
|
+
// still run; a hold of such wakes alone keeps no turn, and has nothing to
|
|
5697
|
+
// stop. Every failure site reports inference:failed before a hold takes
|
|
5698
|
+
// the retry, so a listener of it finds nothing held, and reports false.
|
|
5699
|
+
if (!result && this.pendingContinuationAgents().has(agentName)) {
|
|
5700
|
+
this.dropContinuations(agentName, { kind: 'caller', reason, why: 'a stop ended the turn' });
|
|
5701
|
+
return true;
|
|
5702
|
+
}
|
|
5496
5703
|
return !!result;
|
|
5497
5704
|
}
|
|
5498
5705
|
|
|
@@ -6446,16 +6653,77 @@ export class AgentFramework {
|
|
|
6446
6653
|
return n;
|
|
6447
6654
|
}
|
|
6448
6655
|
|
|
6656
|
+
/**
|
|
6657
|
+
* A continuation the framework makes for a turn (a budget restart's, which
|
|
6658
|
+
* it queues, or a tool-result guard's retry, which its frame starts at
|
|
6659
|
+
* once) holds no turn token until its start takes one. Once its turn's
|
|
6660
|
+
* frame has let go of its token, its start can wait in the queue, a
|
|
6661
|
+
* provider hold, a park on admission or a scheduler pass's hands. Its turn
|
|
6662
|
+
* is alive meanwhile. So is a turn whose call failed into a provider hold,
|
|
6663
|
+
* which keeps the turn's retry (TURN_RETRY), until that retry starts,
|
|
6664
|
+
* wherever it waits. These are the agents with one on its way. A
|
|
6665
|
+
* `tool_results_ready` continuation follows a tool round left waiting with
|
|
6666
|
+
* no stream, which only the direct Agent API makes (Agent's
|
|
6667
|
+
* runInferenceWithInjections; the framework's own rounds wait with their
|
|
6668
|
+
* stream, enterWaitingForTools). Its caller owns that turn's timing, so it
|
|
6669
|
+
* isn't counted.
|
|
6670
|
+
*/
|
|
6671
|
+
private pendingContinuationAgents(): Set<string> {
|
|
6672
|
+
const agents = new Set<string>();
|
|
6673
|
+
const add = (r: InferenceRequest): void => { if (continuesTurn(r)) agents.add(r.agentName); };
|
|
6674
|
+
// Read defensively: a framework built from the prototype, as the
|
|
6675
|
+
// operator tests build one, sets none of these.
|
|
6676
|
+
for (const r of this.pendingRequests ?? []) add(r);
|
|
6677
|
+
for (const cooldown of this.providerAccelerationCooldowns?.values() ?? []) for (const r of cooldown.heldRequests) add(r);
|
|
6678
|
+
for (const r of this.parkedContinuations ?? []) add(r);
|
|
6679
|
+
for (const pass of this.passRequests ?? []) for (const requests of pass.values()) for (const r of requests) add(r);
|
|
6680
|
+
return agents;
|
|
6681
|
+
}
|
|
6682
|
+
|
|
6683
|
+
/** The agents with a turn alive: holding a turn token, or with a
|
|
6684
|
+
* continuation of their turn, or its retry, on its way. */
|
|
6685
|
+
private turnHolders(): Set<string> {
|
|
6686
|
+
const holders = this.pendingContinuationAgents();
|
|
6687
|
+
for (const name of this.activeTurnTokens.keys()) holders.add(name);
|
|
6688
|
+
return holders;
|
|
6689
|
+
}
|
|
6690
|
+
|
|
6691
|
+
private hasTurn(agentName: string): boolean {
|
|
6692
|
+
return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
|
|
6693
|
+
}
|
|
6694
|
+
|
|
6695
|
+
/** The provider hold keeping `agentName`'s turn for its retry (TURN_RETRY),
|
|
6696
|
+
* in words naming its end and its reason, and whether a stated wait holds
|
|
6697
|
+
* it; undefined when no hold keeps one. */
|
|
6698
|
+
private heldTurnRetry(agentName: string): { says: string; statedWait: boolean } | undefined {
|
|
6699
|
+
// Optional, as pendingContinuationAgents reads it: a framework built from
|
|
6700
|
+
// its prototype, as some suites build one, has no map of holds.
|
|
6701
|
+
const hold = this.providerAccelerationCooldowns?.get(agentName);
|
|
6702
|
+
if (!hold?.heldRequests.some(isTurnRetry)) return undefined;
|
|
6703
|
+
// A deadline past what a Date can name (an infinite wait, or a stated
|
|
6704
|
+
// one too long for an instant) is one only a release ends.
|
|
6705
|
+
const end = new Date(hold.until);
|
|
6706
|
+
const until = Number.isNaN(end.getTime()) ? 'until released' : `until ${end.toISOString()}`;
|
|
6707
|
+
return {
|
|
6708
|
+
says: `a provider hold keeps its turn to start again, ${until}: ${hold.reason}`,
|
|
6709
|
+
// release-provider-wait lifts a stated wait's hold, not a host's or an
|
|
6710
|
+
// acceleration cooldown's own pacing.
|
|
6711
|
+
statedWait: hold.waitModel !== undefined || hold.statedWait !== undefined,
|
|
6712
|
+
};
|
|
6713
|
+
}
|
|
6714
|
+
|
|
6449
6715
|
getHostModeStatus(): HostModeStatus {
|
|
6450
6716
|
const parkedAdmissions = this.parkedAdmissionCount();
|
|
6717
|
+
const holders = this.turnHolders();
|
|
6451
6718
|
return {
|
|
6452
6719
|
quiesced: this.quiesced,
|
|
6453
6720
|
...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
|
|
6454
6721
|
...(this.quiescedAt !== undefined ? { since: this.quiescedAt } : {}),
|
|
6455
|
-
drained:
|
|
6456
|
-
activeTurns:
|
|
6722
|
+
drained: holders.size === 0 && parkedAdmissions === 0,
|
|
6723
|
+
activeTurns: holders.size,
|
|
6724
|
+
activeTurnAgents: [...holders].sort(),
|
|
6457
6725
|
parkedAdmissions,
|
|
6458
|
-
gatedRequests: this.pendingRequests.filter((r) => !
|
|
6726
|
+
gatedRequests: this.pendingRequests.filter((r) => !passesQuiesce(r)).length,
|
|
6459
6727
|
backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
|
|
6460
6728
|
deferredWrites: this.deferredMessages.length,
|
|
6461
6729
|
...(this.lastUnabandonable.length > 0 ? { unabandonable: [...this.lastUnabandonable] } : {}),
|
|
@@ -6472,8 +6740,10 @@ export class AgentFramework {
|
|
|
6472
6740
|
/**
|
|
6473
6741
|
* Enter quiesce mode: write the deferred-write queue, persist the flag,
|
|
6474
6742
|
* suppress gate deliveries, pause all MCPL data planes, and wait for
|
|
6475
|
-
* in-flight turns to settle (turn-alive is
|
|
6476
|
-
* dequeue → settled teardown
|
|
6743
|
+
* in-flight turns to settle (turn-alive is a turn token, which spans
|
|
6744
|
+
* dequeue → settled teardown, a continuation of the turn on its way to its
|
|
6745
|
+
* start, or the turn's retry, which a provider hold keeps or which waits
|
|
6746
|
+
* to start: turnHolders). Idempotent, but for the queue's write, which
|
|
6477
6747
|
* every call makes first: when the queue can't reach disk, because its
|
|
6478
6748
|
* write fails or it's over the cap, the call rejects with a
|
|
6479
6749
|
* QuiesceBlockedError and the host's mode as it was, unless it has
|
|
@@ -6483,7 +6753,10 @@ export class AgentFramework {
|
|
|
6483
6753
|
* On drain timeout the host STAYS quiesced (`drained: false` in the
|
|
6484
6754
|
* result); with `abandon: true` the undrained streams are cancelled via a
|
|
6485
6755
|
* dedicated cancel kind that settles the turn without feeding the
|
|
6486
|
-
* inference-failure accounting
|
|
6756
|
+
* inference-failure accounting, and a turn whose continuation or retry
|
|
6757
|
+
* hasn't started ends with it dropped. Drain covers the framework's
|
|
6758
|
+
* own turns: a caller of the direct Agent API (`Agent.runInference`) owns
|
|
6759
|
+
* its turns' timing.
|
|
6487
6760
|
*/
|
|
6488
6761
|
async quiesce(opts?: {
|
|
6489
6762
|
reason?: string;
|
|
@@ -6509,13 +6782,13 @@ export class AgentFramework {
|
|
|
6509
6782
|
// out undrained must be escalatable with a second quiesce({abandon})
|
|
6510
6783
|
// without resume()+re-quiesce (which would reopen data planes and
|
|
6511
6784
|
// release parked wakes mid-surgery).
|
|
6512
|
-
if (opts?.abandon && this.
|
|
6785
|
+
if (opts?.abandon && this.turnHolders().size > 0) {
|
|
6513
6786
|
const unabandonable = await this.abandonActiveTurns();
|
|
6514
6787
|
this.emitTrace({
|
|
6515
6788
|
type: 'host:quiesce',
|
|
6516
6789
|
...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
|
|
6517
|
-
drained: this.
|
|
6518
|
-
activeTurns: this.
|
|
6790
|
+
drained: this.turnHolders().size === 0,
|
|
6791
|
+
activeTurns: this.turnHolders().size,
|
|
6519
6792
|
abandoned: true,
|
|
6520
6793
|
...(unabandonable.length > 0 ? { unabandonable } : {}),
|
|
6521
6794
|
});
|
|
@@ -6541,7 +6814,7 @@ export class AgentFramework {
|
|
|
6541
6814
|
this.lastUnabandonable = [];
|
|
6542
6815
|
console.error(
|
|
6543
6816
|
`[host-mode] quiescing${opts?.reason ? ` (${opts.reason})` : ''}: parking wakes, ` +
|
|
6544
|
-
`pausing MCPL data planes, draining ${this.
|
|
6817
|
+
`pausing MCPL data planes, draining ${this.turnHolders().size} in-flight turn(s)`,
|
|
6545
6818
|
);
|
|
6546
6819
|
this.eventGate?.setQuiesced(true);
|
|
6547
6820
|
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
@@ -6567,7 +6840,7 @@ export class AgentFramework {
|
|
|
6567
6840
|
while (
|
|
6568
6841
|
this.quiesced &&
|
|
6569
6842
|
!this.queue?.isClosed &&
|
|
6570
|
-
(this.
|
|
6843
|
+
(this.turnHolders().size > 0 || this.parkedAdmissionCount() > 0) &&
|
|
6571
6844
|
Date.now() < deadline
|
|
6572
6845
|
) {
|
|
6573
6846
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -6591,7 +6864,7 @@ export class AgentFramework {
|
|
|
6591
6864
|
|
|
6592
6865
|
let abandoned = false;
|
|
6593
6866
|
let unabandonable: string[] = [];
|
|
6594
|
-
if (this.
|
|
6867
|
+
if (this.turnHolders().size > 0 && opts?.abandon) {
|
|
6595
6868
|
abandoned = true;
|
|
6596
6869
|
unabandonable = await this.abandonActiveTurns();
|
|
6597
6870
|
if (!this.quiesced) return this.getHostModeStatus();
|
|
@@ -6624,7 +6897,18 @@ export class AgentFramework {
|
|
|
6624
6897
|
* the already-quiesced escalation path. */
|
|
6625
6898
|
private async abandonActiveTurns(): Promise<string[]> {
|
|
6626
6899
|
const unabandonable: string[] = [];
|
|
6900
|
+
// A turn whose continuation is still on its way ends with the
|
|
6901
|
+
// continuation dropped. Its stream has ended; a frame still letting go of
|
|
6902
|
+
// its token ends on its own, and settles nothing, since it handed the turn
|
|
6903
|
+
// to the continuation.
|
|
6904
|
+
const handedOff = new Set<string>();
|
|
6905
|
+
for (const agentName of this.pendingContinuationAgents()) {
|
|
6906
|
+
console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation or held retry starts: it is dropped`);
|
|
6907
|
+
this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
|
|
6908
|
+
handedOff.add(agentName);
|
|
6909
|
+
}
|
|
6627
6910
|
for (const agentName of [...this.activeTurnTokens.keys()]) {
|
|
6911
|
+
if (handedOff.has(agentName)) continue;
|
|
6628
6912
|
const agent = this.agents.get(agentName);
|
|
6629
6913
|
const state = agent?.state;
|
|
6630
6914
|
const stream = state && 'stream' in state ? state.stream : undefined;
|
|
@@ -8698,6 +8982,9 @@ export class AgentFramework {
|
|
|
8698
8982
|
this.activeTurnTokens.delete(agent.name);
|
|
8699
8983
|
this.activeTurnTriggers.delete(agent.name);
|
|
8700
8984
|
this.logicalTurnToolCalls.delete(agent);
|
|
8985
|
+
// A continuation of its turn still on its way goes too: the run, and
|
|
8986
|
+
// with it the turn, has ended above.
|
|
8987
|
+
this.dropContinuations(agent.name, null);
|
|
8701
8988
|
}
|
|
8702
8989
|
}
|
|
8703
8990
|
|
|
@@ -10249,7 +10536,8 @@ export class AgentFramework {
|
|
|
10249
10536
|
* by stop() while it runs.
|
|
10250
10537
|
*
|
|
10251
10538
|
* The branch moves under every agent sharing the store, so the switch
|
|
10252
|
-
* reserves the store as a surgery does: every agent must be idle
|
|
10539
|
+
* reserves the store as a surgery does: every agent must be idle with no
|
|
10540
|
+
* turn alive (no continuation or held retry of one on its way), or it is
|
|
10253
10541
|
* refused (OperatorActionError 'agent-busy'), and no turn starts until the
|
|
10254
10542
|
* switch is done.
|
|
10255
10543
|
*/
|
|
@@ -10501,9 +10789,21 @@ export class AgentFramework {
|
|
|
10501
10789
|
);
|
|
10502
10790
|
}
|
|
10503
10791
|
const busy: string[] = [];
|
|
10792
|
+
let heldTurns = false;
|
|
10793
|
+
let statedWaits = false;
|
|
10504
10794
|
for (const [name, a] of this.agents) {
|
|
10505
|
-
if (a.state.status !== 'idle') busy.push(`${name} is ${a.state.status}`);
|
|
10506
|
-
|
|
10795
|
+
if (a.state.status !== 'idle') { busy.push(`${name} is ${a.state.status}`); continue; }
|
|
10796
|
+
if (!this.hasTurn(name)) continue;
|
|
10797
|
+
// A provider hold keeping the turn's retry is named: a quiesce alone
|
|
10798
|
+
// doesn't end that turn.
|
|
10799
|
+
const held = this.heldTurnRetry(name);
|
|
10800
|
+
if (held) {
|
|
10801
|
+
heldTurns = true;
|
|
10802
|
+
if (held.statedWait) statedWaits = true;
|
|
10803
|
+
busy.push(`${name} is idle+turn-alive (${held.says})`);
|
|
10804
|
+
} else {
|
|
10805
|
+
busy.push(`${name} is idle+turn-alive`);
|
|
10806
|
+
}
|
|
10507
10807
|
}
|
|
10508
10808
|
for (const name of this.activeTurnTokens.keys()) {
|
|
10509
10809
|
if (!this.agents.has(name)) busy.push(`${name} is turn-alive`);
|
|
@@ -10511,7 +10811,8 @@ export class AgentFramework {
|
|
|
10511
10811
|
if (busy.length > 0) {
|
|
10512
10812
|
throw new OperatorActionError(
|
|
10513
10813
|
'agent-busy',
|
|
10514
|
-
`Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)
|
|
10814
|
+
`Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)` +
|
|
10815
|
+
(heldTurns ? `; ${heldTurnWayOn(statedWaits)}` : ''),
|
|
10515
10816
|
);
|
|
10516
10817
|
}
|
|
10517
10818
|
const reserved = new Map<string, number>();
|
|
@@ -11445,10 +11746,11 @@ export class AgentFramework {
|
|
|
11445
11746
|
// Direct inference requests (e.g. runEphemeralToCompletion) bypass the
|
|
11446
11747
|
// event queue — without this the loop can exit before they're drained.
|
|
11447
11748
|
// While quiesced, gate-parked requests are NOT progress and must not
|
|
11448
|
-
// block idle
|
|
11449
|
-
//
|
|
11749
|
+
// block idle; what a quiesce lets through (passesQuiesce: a turn's
|
|
11750
|
+
// continuation, or a held turn's retry) can still run, and is.
|
|
11751
|
+
// Identity-equivalent to `.length > 0` when not quiesced.
|
|
11450
11752
|
this.pendingRequests.some(
|
|
11451
|
-
(r) => !this.quiesced ||
|
|
11753
|
+
(r) => !this.quiesced || passesQuiesce(r),
|
|
11452
11754
|
) ||
|
|
11453
11755
|
this.activeStreams.size > 0 ||
|
|
11454
11756
|
Array.from(this.agents.values()).some((a) => a.state.status !== 'idle')
|
|
@@ -11660,7 +11962,7 @@ export class AgentFramework {
|
|
|
11660
11962
|
// was killed while the call ran: the result reaches no one, but the
|
|
11661
11963
|
// call did finish.
|
|
11662
11964
|
this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
|
|
11663
|
-
scriptWaiter(event.result);
|
|
11965
|
+
scriptWaiter.resolve(event.result);
|
|
11664
11966
|
return;
|
|
11665
11967
|
}
|
|
11666
11968
|
|
|
@@ -12105,7 +12407,25 @@ export class AgentFramework {
|
|
|
12105
12407
|
this.streamBoundaryEnds.get(currentState.stream)?.('turn_ended');
|
|
12106
12408
|
}
|
|
12107
12409
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
12410
|
+
} else if ((overBudget || overPhysical) && this.providerAdmissionClosed) {
|
|
12411
|
+
// A stopping framework starts no continuation: the turn ends with
|
|
12412
|
+
// its stream, as stop() ends a live stream's turn.
|
|
12413
|
+
if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown');
|
|
12414
|
+
agent.cancelStream();
|
|
12108
12415
|
} else if (overBudget || overPhysical) {
|
|
12416
|
+
// The continuation is queued, and the stream cancelled, before the
|
|
12417
|
+
// boundary's usage report and the restart's trace, so a listener
|
|
12418
|
+
// that stops or quiesces the host on either finds the turn waiting
|
|
12419
|
+
// for its continuation, with nothing left of the stream to end.
|
|
12420
|
+
const continuation: InferenceRequest = {
|
|
12421
|
+
agentName: agent.name,
|
|
12422
|
+
reason: 'context_budget_restart',
|
|
12423
|
+
source: 'framework',
|
|
12424
|
+
timestamp: Date.now(),
|
|
12425
|
+
suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
|
|
12426
|
+
silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
|
|
12427
|
+
};
|
|
12428
|
+
this.pendingRequests.push(continuation);
|
|
12109
12429
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
12110
12430
|
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
12111
12431
|
// membrane delivers `aborted` before the restart bumps streamId,
|
|
@@ -12113,13 +12433,19 @@ export class AgentFramework {
|
|
|
12113
12433
|
// a terminal failure — rejecting an ephemeral's promise mid-run
|
|
12114
12434
|
// and emitting a spurious inference:exhausted (same race shape as
|
|
12115
12435
|
// endTurn above).
|
|
12116
|
-
if (currentState.stream) {
|
|
12117
|
-
|
|
12118
|
-
|
|
12119
|
-
|
|
12120
|
-
|
|
12436
|
+
if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
12437
|
+
try {
|
|
12438
|
+
agent.cancelStream();
|
|
12439
|
+
} catch (error) {
|
|
12440
|
+
// The cancel failed and the stream is still live, so the turn
|
|
12441
|
+
// has no continuation: take it back out of the queue.
|
|
12442
|
+
this.pendingRequests = this.pendingRequests.filter((r) => r !== continuation);
|
|
12443
|
+
throw error;
|
|
12444
|
+
} finally {
|
|
12445
|
+
// Its rounds were billed, even when the cancel throws; the
|
|
12446
|
+
// restart opens a fresh stream with its own usage.
|
|
12447
|
+
if (currentState.stream) this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
|
|
12121
12448
|
}
|
|
12122
|
-
agent.cancelStream();
|
|
12123
12449
|
this.emitTrace({
|
|
12124
12450
|
type: 'inference:stream_restarted',
|
|
12125
12451
|
agentName: agent.name,
|
|
@@ -12130,14 +12456,6 @@ export class AgentFramework {
|
|
|
12130
12456
|
inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
|
|
12131
12457
|
budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
|
|
12132
12458
|
});
|
|
12133
|
-
this.pendingRequests.push({
|
|
12134
|
-
agentName: agent.name,
|
|
12135
|
-
reason: 'context_budget_restart',
|
|
12136
|
-
source: 'framework',
|
|
12137
|
-
timestamp: Date.now(),
|
|
12138
|
-
suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
|
|
12139
|
-
silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
|
|
12140
|
-
});
|
|
12141
12459
|
} else if (currentState.stream) {
|
|
12142
12460
|
// What this round appends is in the next call's prompt. If that
|
|
12143
12461
|
// call reports no prompt, the next boundary projects it from the
|
|
@@ -12987,6 +13305,8 @@ export class AgentFramework {
|
|
|
12987
13305
|
*/
|
|
12988
13306
|
private disposeConversationAgent(agentName: string): void {
|
|
12989
13307
|
this.closingConversationAgents.delete(agentName);
|
|
13308
|
+
// A continuation of its turn still on its way goes with it.
|
|
13309
|
+
this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
|
|
12990
13310
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
12991
13311
|
const agent = this.agents.get(agentName);
|
|
12992
13312
|
// A fork stop() ends can still be running: its stream, its tool round.
|
|
@@ -13116,10 +13436,13 @@ export class AgentFramework {
|
|
|
13116
13436
|
// (request dropped as stale, inference policy veto) would otherwise stay
|
|
13117
13437
|
// registered forever. No turn, no active stream and no pending request =
|
|
13118
13438
|
// done. A turn holds its token from dequeue, through its hooks and
|
|
13119
|
-
// compile, before its stream registers, so a fork mid-compile is busy
|
|
13439
|
+
// compile, before its stream registers, so a fork mid-compile is busy;
|
|
13440
|
+
// and a turn is alive while its continuation or a provider hold's retry
|
|
13441
|
+
// of it is on its way (hasTurn), so a closure turn held for its retry
|
|
13442
|
+
// finishes when the hold ends.
|
|
13120
13443
|
for (const agentName of [...this.closingConversationAgents]) {
|
|
13121
13444
|
if (
|
|
13122
|
-
!this.
|
|
13445
|
+
!this.hasTurn(agentName) &&
|
|
13123
13446
|
!this.activeStreams.has(agentName) &&
|
|
13124
13447
|
!this.pendingRequests.some((r) => r.agentName === agentName)
|
|
13125
13448
|
) {
|
|
@@ -13519,9 +13842,18 @@ export class AgentFramework {
|
|
|
13519
13842
|
recordReceipt: (record) => {
|
|
13520
13843
|
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
13521
13844
|
// must not lose its receipt, the subject's birth, or (deferred) the
|
|
13522
|
-
// accepted work itself to a crash. A failure here fails the
|
|
13845
|
+
// accepted work itself to a crash. A failure here fails the answer:
|
|
13846
|
+
// the coalescer keeps the receipt, and the producer's retry writes it
|
|
13847
|
+
// again, with the subject's state as it is then. So a record whose
|
|
13848
|
+
// write failed leaves the list: a later write would carry it after
|
|
13849
|
+
// the batch it holds had moved on.
|
|
13523
13850
|
this.coalescingRecentReceipts.push(record);
|
|
13524
|
-
|
|
13851
|
+
try {
|
|
13852
|
+
this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
|
|
13853
|
+
} catch (error) {
|
|
13854
|
+
this.coalescingRecentReceipts.pop();
|
|
13855
|
+
throw error;
|
|
13856
|
+
}
|
|
13525
13857
|
},
|
|
13526
13858
|
// The acceptance of coalesced work (deferred included) is observed
|
|
13527
13859
|
// here, once; its later delivery reuses the frozen envelope.
|
|
@@ -13628,6 +13960,9 @@ export class AgentFramework {
|
|
|
13628
13960
|
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
13629
13961
|
} catch (err) {
|
|
13630
13962
|
console.error('[coalescing] could not persist coalescing state:', err);
|
|
13963
|
+
// Still unsaved: the next persist, or the stop's flush, writes it. No
|
|
13964
|
+
// timer of its own, so a store that keeps failing isn't retried here.
|
|
13965
|
+
this.coalescingSnapshotDirty ??= snapshot;
|
|
13631
13966
|
}
|
|
13632
13967
|
}
|
|
13633
13968
|
|
|
@@ -13927,6 +14262,19 @@ export class AgentFramework {
|
|
|
13927
14262
|
for (const cooldown of this.providerAccelerationCooldowns.values()) {
|
|
13928
14263
|
cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject);
|
|
13929
14264
|
}
|
|
14265
|
+
// A wake of the subject's merged into a start that carries a turn on
|
|
14266
|
+
// (MERGED_BATCH) is withdrawn from the start's batch, so no stop puts
|
|
14267
|
+
// it back and no pass unpacks it.
|
|
14268
|
+
const unrecord = (r: InferenceRequest): void => {
|
|
14269
|
+
const batch = mergedBatch(r);
|
|
14270
|
+
if (batch?.some((w) => w.coalescingSubject === subject)) {
|
|
14271
|
+
(r as MarkedRequest)[MERGED_BATCH] = batch.filter((w) => w.coalescingSubject !== subject);
|
|
14272
|
+
}
|
|
14273
|
+
};
|
|
14274
|
+
for (const r of this.pendingRequests) unrecord(r);
|
|
14275
|
+
for (const cooldown of this.providerAccelerationCooldowns.values()) cooldown.heldRequests.forEach(unrecord);
|
|
14276
|
+
for (const r of this.parkedContinuations ?? []) unrecord(r);
|
|
14277
|
+
for (const pass of this.passRequests ?? []) for (const requests of pass.values()) requests.forEach(unrecord);
|
|
13930
14278
|
}
|
|
13931
14279
|
|
|
13932
14280
|
/**
|
|
@@ -14253,17 +14601,28 @@ export class AgentFramework {
|
|
|
14253
14601
|
return undefined;
|
|
14254
14602
|
}
|
|
14255
14603
|
|
|
14604
|
+
/** One scheduler pass, whose requests stay visible as on their way until
|
|
14605
|
+
* it takes their agent up (passRequests). */
|
|
14256
14606
|
private async processInferenceRequests(): Promise<void> {
|
|
14257
14607
|
if (this.pendingRequests.length === 0) {
|
|
14258
14608
|
return;
|
|
14259
14609
|
}
|
|
14610
|
+
const requestsByAgent = new Map<string, InferenceRequest[]>();
|
|
14611
|
+
this.passRequests?.add(requestsByAgent);
|
|
14612
|
+
try {
|
|
14613
|
+
await this.runInferencePass(requestsByAgent);
|
|
14614
|
+
} finally {
|
|
14615
|
+
this.passRequests?.delete(requestsByAgent);
|
|
14616
|
+
}
|
|
14617
|
+
}
|
|
14618
|
+
|
|
14619
|
+
private async runInferencePass(requestsByAgent: Map<string, InferenceRequest[]>): Promise<void> {
|
|
14260
14620
|
|
|
14261
14621
|
const STALE_REQUEST_MS = 30_000;
|
|
14262
14622
|
const now = Date.now();
|
|
14263
14623
|
const state = this.createFrameworkState();
|
|
14264
14624
|
|
|
14265
14625
|
// Group requests by agent
|
|
14266
|
-
const requestsByAgent = new Map<string, InferenceRequest[]>();
|
|
14267
14626
|
for (const req of this.pendingRequests) {
|
|
14268
14627
|
const existing = requestsByAgent.get(req.agentName) ?? [];
|
|
14269
14628
|
existing.push(req);
|
|
@@ -14276,6 +14635,13 @@ export class AgentFramework {
|
|
|
14276
14635
|
// Check each agent
|
|
14277
14636
|
for (const [agentName, groupedRequests] of requestsByAgent) {
|
|
14278
14637
|
let requests = groupedRequests;
|
|
14638
|
+
// Taken up now: from here to its start's turn token (or its hold or
|
|
14639
|
+
// park) nothing awaits, so the requests leave the pass's map, which
|
|
14640
|
+
// shows only what the pass still holds while it awaits a start.
|
|
14641
|
+
requestsByAgent.delete(agentName);
|
|
14642
|
+
// A drop while this pass awaited an earlier agent's start can have
|
|
14643
|
+
// taken every request of this agent's (dropContinuations).
|
|
14644
|
+
if (requests.length === 0) continue;
|
|
14279
14645
|
const agent = this.agents.get(agentName);
|
|
14280
14646
|
if (!agent) {
|
|
14281
14647
|
// Agent not found — request is orphaned. Emit warning and drop.
|
|
@@ -14288,6 +14654,10 @@ export class AgentFramework {
|
|
|
14288
14654
|
oldestRequestAge: now - oldest,
|
|
14289
14655
|
});
|
|
14290
14656
|
console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
|
|
14657
|
+
// A continuation among them leaves its turn to end here.
|
|
14658
|
+
if (requests.some(continuesTurn)) {
|
|
14659
|
+
this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
|
|
14660
|
+
}
|
|
14291
14661
|
continue;
|
|
14292
14662
|
}
|
|
14293
14663
|
|
|
@@ -14297,11 +14667,7 @@ export class AgentFramework {
|
|
|
14297
14667
|
// selected now, the turn proceeds (the wait stays recorded).
|
|
14298
14668
|
const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model;
|
|
14299
14669
|
if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) {
|
|
14300
|
-
for (const req of requests)
|
|
14301
|
-
if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
|
|
14302
|
-
providerCooldown.heldRequests.push(req);
|
|
14303
|
-
}
|
|
14304
|
-
}
|
|
14670
|
+
for (const req of requests) this.holdRequest(providerCooldown.heldRequests, req);
|
|
14305
14671
|
const recovery = this.providerAccelerationRecoveries.get(agentName);
|
|
14306
14672
|
if (recovery) recovery.heldRequests = providerCooldown.heldRequests.length;
|
|
14307
14673
|
// The timer owns release. Do not hot-poll these requests.
|
|
@@ -14315,6 +14681,17 @@ export class AgentFramework {
|
|
|
14315
14681
|
if (recovery) recovery.releasedAt = now;
|
|
14316
14682
|
requests = [...providerCooldown.heldRequests, ...requests];
|
|
14317
14683
|
}
|
|
14684
|
+
// A start that carries a turn on and came back before it took its
|
|
14685
|
+
// token stands for the batch it was merged from (MERGED_BATCH),
|
|
14686
|
+
// including one a hold released just now. The batch takes its place,
|
|
14687
|
+
// each request as it was, judged one by one from here: a wait holds
|
|
14688
|
+
// them apart, a quiesce parks the wakes while the turn's request
|
|
14689
|
+
// passes, and the read check and the policy decide on them all; the
|
|
14690
|
+
// merge below records the batch anew.
|
|
14691
|
+
if (requests.some((r) => mergedBatch(r) !== undefined)) {
|
|
14692
|
+
requests = requests.flatMap((r) => mergedBatch(r) ?? [r]);
|
|
14693
|
+
}
|
|
14694
|
+
|
|
14318
14695
|
// A provider's stated wait on this agent's primary model, including one
|
|
14319
14696
|
// recorded before a restart, holds the turn until it passes. An
|
|
14320
14697
|
// ephemeral run records nothing, its name being single-use, so no wait,
|
|
@@ -14349,9 +14726,10 @@ export class AgentFramework {
|
|
|
14349
14726
|
// bounds a multi-day maintenance window's accumulation.
|
|
14350
14727
|
if (this.quiesced) {
|
|
14351
14728
|
// Continuations of a held turn (budget restart, non-streaming tool
|
|
14352
|
-
// results) pass
|
|
14353
|
-
|
|
14354
|
-
const
|
|
14729
|
+
// results) pass, and so does a held turn's retry (passesQuiesce), so
|
|
14730
|
+
// a turn alive at the quiesce can finish; everything else parks.
|
|
14731
|
+
const passThrough = requests.filter((r) => passesQuiesce(r));
|
|
14732
|
+
const parked = requests.filter((r) => !passesQuiesce(r));
|
|
14355
14733
|
// Coalesce per (reason, addressed): every channel wake shares one
|
|
14356
14734
|
// reason, so keying on reason alone would let an ambient message
|
|
14357
14735
|
// parked later evict a DM/mention parked earlier — and the
|
|
@@ -14377,7 +14755,10 @@ export class AgentFramework {
|
|
|
14377
14755
|
requests.push(...passThrough);
|
|
14378
14756
|
}
|
|
14379
14757
|
|
|
14380
|
-
|
|
14758
|
+
// Turn-alive includes a continuation of the agent's turn still on its
|
|
14759
|
+
// way elsewhere (a park, another pass's hands): a fresh wake waits for
|
|
14760
|
+
// it, as for a turn holding its token.
|
|
14761
|
+
const turnAlive = !budgetRestart && this.hasTurn(agentName);
|
|
14381
14762
|
const providerGate = this.providerGates?.get(agentName);
|
|
14382
14763
|
// A primary can own provider admission while yielding to an already in-flight
|
|
14383
14764
|
// auxiliary call. Keep this agent's later wakes queued, but do not block the
|
|
@@ -14455,9 +14836,12 @@ export class AgentFramework {
|
|
|
14455
14836
|
`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
14456
14837
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
|
|
14457
14838
|
);
|
|
14458
|
-
// A
|
|
14459
|
-
// If policy drops
|
|
14460
|
-
|
|
14839
|
+
// A continuation inherits its predecessor's EventGate liveness and
|
|
14840
|
+
// logical turn. If policy drops it, no driveStream remains to end them,
|
|
14841
|
+
// so its turn ends here (endContinuedTurn).
|
|
14842
|
+
if (requests.some(continuesTurn)) {
|
|
14843
|
+
this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
|
|
14844
|
+
}
|
|
14461
14845
|
this.endPendingPrimary(agentName);
|
|
14462
14846
|
continue;
|
|
14463
14847
|
}
|
|
@@ -14533,7 +14917,7 @@ export class AgentFramework {
|
|
|
14533
14917
|
// exists only once assembly renders it. Recorded for the whole batch
|
|
14534
14918
|
// (not inherited from requests[0]), so a requeued trigger stays honest.
|
|
14535
14919
|
const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject);
|
|
14536
|
-
|
|
14920
|
+
const merged: InferenceRequest = {
|
|
14537
14921
|
...trigger,
|
|
14538
14922
|
coalescingBatch: batchOnly || undefined,
|
|
14539
14923
|
coalescingBatchSubjects: batchOnly
|
|
@@ -14561,7 +14945,17 @@ export class AgentFramework {
|
|
|
14561
14945
|
counterparty: budgetRestart ? undefined : provReq?.counterparty,
|
|
14562
14946
|
wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId,
|
|
14563
14947
|
wakeAt: budgetRestart ? undefined : provReq?.wakeAt,
|
|
14564
|
-
}
|
|
14948
|
+
};
|
|
14949
|
+
// A held turn's retry merged with other wakes leaves the start its
|
|
14950
|
+
// turn's retry (TURN_RETRY), whichever request leads the merge: held
|
|
14951
|
+
// again before it takes a token, it keeps the turn alive as before.
|
|
14952
|
+
const start: MarkedRequest = requests.some(isTurnRetry) ? asTurnRetry(merged) : merged;
|
|
14953
|
+
// A start that carries a turn on and took in wakes that don't records
|
|
14954
|
+
// the batch it was merged from (MERGED_BATCH): until it takes its
|
|
14955
|
+
// token it stands for them all.
|
|
14956
|
+
if (continuesTurn(start) && requests.some((r) => !continuesTurn(r))) start[MERGED_BATCH] = [...requests];
|
|
14957
|
+
else delete start[MERGED_BATCH];
|
|
14958
|
+
await this.startAgentStream(agent, start);
|
|
14565
14959
|
}
|
|
14566
14960
|
}
|
|
14567
14961
|
|
|
@@ -16912,7 +17306,12 @@ export class AgentFramework {
|
|
|
16912
17306
|
// Do not await inside processInferenceRequests: one slow auxiliary call for
|
|
16913
17307
|
// this resident must not block events or other residents. Admission stays
|
|
16914
17308
|
// owned and the continuation receives it after the in-flight call settles.
|
|
17309
|
+
const parkedContinuation = trigger !== undefined && continuesTurn(trigger);
|
|
17310
|
+
if (parkedContinuation) this.parkedContinuations.add(trigger);
|
|
16915
17311
|
void this.waitForAuxiliaryIdle(agent.name).then(async () => {
|
|
17312
|
+
// A continuation dropped while it was parked left the set then, with
|
|
17313
|
+
// its admission given back.
|
|
17314
|
+
if (parkedContinuation && !this.parkedContinuations.delete(trigger)) return;
|
|
16916
17315
|
if (this.providerAdmissionClosed) {
|
|
16917
17316
|
this.releasePrimaryProviderGate(agent.name);
|
|
16918
17317
|
return;
|
|
@@ -16939,8 +17338,9 @@ export class AgentFramework {
|
|
|
16939
17338
|
// park; starting here would bypass it and run a turn against a
|
|
16940
17339
|
// context the operator believes frozen. Give admission back and
|
|
16941
17340
|
// requeue — the scheduler re-parks it (coalesced) until resume.
|
|
16942
|
-
// Continuations of a held turn
|
|
16943
|
-
|
|
17341
|
+
// Continuations of a held turn, and a held turn's retry, pass, as
|
|
17342
|
+
// they do at the gate (passesQuiesce).
|
|
17343
|
+
if (this.quiesced && !passesQuiesce(trigger)) {
|
|
16944
17344
|
this.releasePrimaryProviderGate(agent.name);
|
|
16945
17345
|
this.pendingRequests.push(trigger ?? {
|
|
16946
17346
|
agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(),
|
|
@@ -16948,6 +17348,16 @@ export class AgentFramework {
|
|
|
16948
17348
|
console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`);
|
|
16949
17349
|
return;
|
|
16950
17350
|
}
|
|
17351
|
+
// A start that stands for a merged batch (MERGED_BATCH) goes back
|
|
17352
|
+
// to the scheduler as that batch: its gate lets the turn's request
|
|
17353
|
+
// through alone and parks the wakes until resume.
|
|
17354
|
+
const batch = trigger && mergedBatch(trigger);
|
|
17355
|
+
if (this.quiesced && batch) {
|
|
17356
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
17357
|
+
this.pendingRequests.push(...batch);
|
|
17358
|
+
console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`);
|
|
17359
|
+
return;
|
|
17360
|
+
}
|
|
16951
17361
|
await this.startAgentStream(agent, trigger, attempt, true);
|
|
16952
17362
|
}).catch((error) => {
|
|
16953
17363
|
this.releasePrimaryProviderGate(agent.name);
|
|
@@ -17072,6 +17482,137 @@ export class AgentFramework {
|
|
|
17072
17482
|
}
|
|
17073
17483
|
}
|
|
17074
17484
|
|
|
17485
|
+
/**
|
|
17486
|
+
* Drop every continuation of `agentName`'s turn still on its way, wherever
|
|
17487
|
+
* it waits: the queue, a provider hold (which, left empty, then releases
|
|
17488
|
+
* with nothing to run), a park on admission (whose admission it gives
|
|
17489
|
+
* back), or a scheduler pass's hands (which then never reach it). If one
|
|
17490
|
+
* was there, the turn ends (endContinuedTurn), unless `drop` is null: the
|
|
17491
|
+
* caller has ended the turn already.
|
|
17492
|
+
*/
|
|
17493
|
+
private dropContinuations(agentName: string, drop: ContinuationDrop | null): void {
|
|
17494
|
+
const isContinuation = (r: InferenceRequest): boolean => r.agentName === agentName && continuesTurn(r);
|
|
17495
|
+
// A caller's stop and an abandon end the turn, not the wakes merged into
|
|
17496
|
+
// a start of it that hasn't taken its token (MERGED_BATCH): they go back
|
|
17497
|
+
// in the queue. A stop() or a disposal ends the agent's work, and puts
|
|
17498
|
+
// nothing back.
|
|
17499
|
+
const putsBack = drop?.kind === 'caller' || drop?.kind === 'quiesce_abandoned';
|
|
17500
|
+
const back: InferenceRequest[] = [];
|
|
17501
|
+
let dropped = false;
|
|
17502
|
+
const take = (requests: InferenceRequest[]): InferenceRequest[] => {
|
|
17503
|
+
const left: InferenceRequest[] = [];
|
|
17504
|
+
for (const r of requests) {
|
|
17505
|
+
if (!isContinuation(r)) left.push(r);
|
|
17506
|
+
else {
|
|
17507
|
+
dropped = true;
|
|
17508
|
+
if (putsBack) back.push(...mergedWakes(r));
|
|
17509
|
+
}
|
|
17510
|
+
}
|
|
17511
|
+
return left;
|
|
17512
|
+
};
|
|
17513
|
+
this.pendingRequests = take(this.pendingRequests);
|
|
17514
|
+
const hold = this.providerAccelerationCooldowns.get(agentName);
|
|
17515
|
+
if (hold) {
|
|
17516
|
+
const held = hold.heldRequests.length;
|
|
17517
|
+
hold.heldRequests = take(hold.heldRequests);
|
|
17518
|
+
if (held > 0 && hold.heldRequests.length === 0) hold.emptiedByDrop = true;
|
|
17519
|
+
}
|
|
17520
|
+
for (const parked of [...this.parkedContinuations]) {
|
|
17521
|
+
if (!isContinuation(parked)) continue;
|
|
17522
|
+
this.parkedContinuations.delete(parked);
|
|
17523
|
+
this.releasePrimaryProviderGate(agentName);
|
|
17524
|
+
dropped = true;
|
|
17525
|
+
if (putsBack) back.push(...mergedWakes(parked));
|
|
17526
|
+
}
|
|
17527
|
+
for (const pass of this.passRequests) {
|
|
17528
|
+
const requests = pass.get(agentName);
|
|
17529
|
+
if (!requests) continue;
|
|
17530
|
+
const left = take(requests);
|
|
17531
|
+
requests.length = 0;
|
|
17532
|
+
requests.push(...left);
|
|
17533
|
+
}
|
|
17534
|
+
this.pendingRequests.push(...back);
|
|
17535
|
+
if (!dropped || !drop) return;
|
|
17536
|
+
// A primary a provider hold left pending for what it released won't run
|
|
17537
|
+
// now, when nothing else of the agent's waits (endPendingPrimary).
|
|
17538
|
+
if (!this.pendingRequests.some((r) => r.agentName === agentName) && !this.providerAccelerationCooldowns.get(agentName)?.heldRequests.length) {
|
|
17539
|
+
this.endPendingPrimary(agentName);
|
|
17540
|
+
}
|
|
17541
|
+
this.endContinuedTurn(agentName, drop);
|
|
17542
|
+
}
|
|
17543
|
+
|
|
17544
|
+
/**
|
|
17545
|
+
* End a turn whose continuation won't run. The frame that handed the turn
|
|
17546
|
+
* to it kept the turn open for it: its logical turn, the EventGate's
|
|
17547
|
+
* window, the tool-result guard's batch and recovery, its typing. An
|
|
17548
|
+
* abandon or a stop of a registered agent settles the turn as it settles a
|
|
17549
|
+
* turn's start (endStoppedStart: one inference:aborted, the turn settled,
|
|
17550
|
+
* the gate released). Any other drop ends it as a start dropped before it
|
|
17551
|
+
* sends anything ends. That frame, if it hasn't let go of its token yet,
|
|
17552
|
+
* ends on its own and settles nothing. What was deferred for the agent
|
|
17553
|
+
* lands now, or at that frame's end.
|
|
17554
|
+
*/
|
|
17555
|
+
private endContinuedTurn(agentName: string, drop: ContinuationDrop): void {
|
|
17556
|
+
console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`);
|
|
17557
|
+
const agent = this.agents.get(agentName);
|
|
17558
|
+
const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
|
|
17559
|
+
if (agent && drop.kind !== 'dropped') {
|
|
17560
|
+
const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
|
|
17561
|
+
this.endStoppedStart(agent, drop.kind === 'caller' ? { kind: 'caller', reason: drop.reason } : { kind: drop.kind },
|
|
17562
|
+
routing, startedAt, undefined, false);
|
|
17563
|
+
} else {
|
|
17564
|
+
const owner = this.activeTurnTokens.get(agentName);
|
|
17565
|
+
this.abortDroppedTurn(agentName, owner ?? -1,
|
|
17566
|
+
drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt);
|
|
17567
|
+
this.channelRegistry?.stopTyping();
|
|
17568
|
+
if (agent) {
|
|
17569
|
+
agent.toolResultGuard.recovering = false;
|
|
17570
|
+
if (agent.toolResultGuard.hasPending) {
|
|
17571
|
+
try {
|
|
17572
|
+
agent.toolResultGuard.abandon('aborted');
|
|
17573
|
+
} catch (error) {
|
|
17574
|
+
console.error(`[tool-result-guard] agent=${agentName} failed to record aborted batch:`, error);
|
|
17575
|
+
}
|
|
17576
|
+
}
|
|
17577
|
+
}
|
|
17578
|
+
const routing = this.openLogical.get(agentName) ?? this.turnRouting.get(agentName);
|
|
17579
|
+
if (routing) this.endDroppedTurn(agentName, routing);
|
|
17580
|
+
this.eventGate?.onInferenceEnded(agentName);
|
|
17581
|
+
}
|
|
17582
|
+
if (!this.activeTurnTokens.has(agentName) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agentName)) {
|
|
17583
|
+
this.readdDrained(this.drainDeferredFor(agentName), 'continuation-drop flush');
|
|
17584
|
+
this.ackDeferredWrites();
|
|
17585
|
+
}
|
|
17586
|
+
}
|
|
17587
|
+
|
|
17588
|
+
/**
|
|
17589
|
+
* After a failure a provider hold would take: once stop() has closed
|
|
17590
|
+
* admission, the hold installs nothing (holdForProviderWait,
|
|
17591
|
+
* holdProviderAcceleration), so no retry follows the turn's
|
|
17592
|
+
* inference:failed, and the turn ends as a retry a stop cancelled ends, with
|
|
17593
|
+
* an inference:aborted saying so. Nothing is written. True when it ended
|
|
17594
|
+
* the turn.
|
|
17595
|
+
*/
|
|
17596
|
+
private endFailureAtStop(agentName: string, turnToken: number, startedAt: number): boolean {
|
|
17597
|
+
if (!this.providerAdmissionClosed) return false;
|
|
17598
|
+
this.abortDroppedTurn(agentName, turnToken, RETRY_CANCELLED_BY_STOP, startedAt);
|
|
17599
|
+
return true;
|
|
17600
|
+
}
|
|
17601
|
+
|
|
17602
|
+
/**
|
|
17603
|
+
* A turn's start whose failure a provider hold took (or, once stop() has
|
|
17604
|
+
* closed admission, would have: endFailureAtStop) ends here. Its window
|
|
17605
|
+
* closes now, not at the start's finally a few microtasks on: no check of
|
|
17606
|
+
* this start is ahead, and a stop made after the failure's report must
|
|
17607
|
+
* find the held retry (abortInference's drop), not a window nothing will
|
|
17608
|
+
* collect.
|
|
17609
|
+
*/
|
|
17610
|
+
private endHeldStart(agent: Agent, checkWindow: CheckWindow, turnToken: number, startedAt: number): void {
|
|
17611
|
+
if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
|
|
17612
|
+
this.endFailureAtStop(agent.name, turnToken, startedAt);
|
|
17613
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
17614
|
+
}
|
|
17615
|
+
|
|
17075
17616
|
/**
|
|
17076
17617
|
* End a turn that beginAgentTurn drops before it sends anything. The turn
|
|
17077
17618
|
* emitted inference:started, and listeners that bracket a turn, such as a
|
|
@@ -17122,7 +17663,7 @@ export class AgentFramework {
|
|
|
17122
17663
|
*/
|
|
17123
17664
|
private endStoppedStart(
|
|
17124
17665
|
agent: Agent,
|
|
17125
|
-
stop: { kind: 'quiesce_abandoned' } | { kind: 'caller'; reason?: string },
|
|
17666
|
+
stop: { kind: 'quiesce_abandoned' | 'shutdown' } | { kind: 'caller'; reason?: string },
|
|
17126
17667
|
routing: TurnRouting,
|
|
17127
17668
|
turnStartedAt: number,
|
|
17128
17669
|
compiledRequest: NormalizedRequest | undefined,
|
|
@@ -17132,7 +17673,7 @@ export class AgentFramework {
|
|
|
17132
17673
|
* so the note says it was stopped before it finished. */
|
|
17133
17674
|
opensTurn: boolean,
|
|
17134
17675
|
): void {
|
|
17135
|
-
console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`);
|
|
17676
|
+
console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : stop.kind === 'shutdown' ? "the framework's stop" : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`);
|
|
17136
17677
|
this.channelRegistry?.stopTyping();
|
|
17137
17678
|
// A start that continues a turn (a budget or guard restart) follows a
|
|
17138
17679
|
// frame that kept the turn's tool-result batch, and the guard's recovery,
|
|
@@ -17160,7 +17701,7 @@ export class AgentFramework {
|
|
|
17160
17701
|
if (stop.kind === 'caller') {
|
|
17161
17702
|
this.settleDeliberateCancel({ ...common, reason: stop.reason ?? 'user', before: opensTurn ? 'sending' : 'finishing' });
|
|
17162
17703
|
} else {
|
|
17163
|
-
this.settleFrameworkCancel({ ...common, kind:
|
|
17704
|
+
this.settleFrameworkCancel({ ...common, kind: stop.kind });
|
|
17164
17705
|
}
|
|
17165
17706
|
const last = this.lastInferenceAt.get(agent.name);
|
|
17166
17707
|
if (last?.startedAt === turnStartedAt) this.lastInferenceAt.set(agent.name, { ...last, endedAt: Date.now() });
|
|
@@ -17303,6 +17844,12 @@ export class AgentFramework {
|
|
|
17303
17844
|
const { coalescingSubject: _subject, coalescingBatch: _batch, coalescingBatchSubjects: _subjects, readsDeferredWrite: _read, ...retry } = trigger;
|
|
17304
17845
|
trigger = retry;
|
|
17305
17846
|
}
|
|
17847
|
+
// The wakes merged into this start are the turn's now: it reads them,
|
|
17848
|
+
// so a later drop of its retry puts none of them back (MERGED_BATCH).
|
|
17849
|
+
if (trigger && mergedBatch(trigger)) {
|
|
17850
|
+
const { [MERGED_BATCH]: _batch, ...own } = trigger as MarkedRequest;
|
|
17851
|
+
trigger = own;
|
|
17852
|
+
}
|
|
17306
17853
|
|
|
17307
17854
|
// Record turn checkpoint before inference (only on first attempt, not retries)
|
|
17308
17855
|
if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') {
|
|
@@ -17598,9 +18145,8 @@ export class AgentFramework {
|
|
|
17598
18145
|
// hold the turn as the boundary would rather than send it into the wait.
|
|
17599
18146
|
if (this.conversationAgentHomes.has(agent.name)) {
|
|
17600
18147
|
const wait = this.providerWaits?.active(agent.name, agent.model);
|
|
17601
|
-
|
|
17602
|
-
|
|
17603
|
-
`(recorded while the turn compiled); turn held, nothing sent`);
|
|
18148
|
+
// The turn has begun (inference:started), so the hold keeps its retry.
|
|
18149
|
+
if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], false, () => {
|
|
17604
18150
|
// The stream the compile made won't be sent: it's cancelled, and
|
|
17605
18151
|
// this start's window closes, before the hold is reported, so a stop
|
|
17606
18152
|
// made while inference:failed is delivered finds neither, and the
|
|
@@ -17611,8 +18157,15 @@ export class AgentFramework {
|
|
|
17611
18157
|
if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
|
|
17612
18158
|
// The turn emitted inference:started before its compile; every turn
|
|
17613
18159
|
// that does gets one terminal event, which listeners (a typing loop)
|
|
17614
|
-
// rely on
|
|
18160
|
+
// rely on. It's reported before the hold takes the turn's retry, as
|
|
18161
|
+
// at every failure site, so a listener's stop finds nothing held.
|
|
17615
18162
|
this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: providerWaitText(agent.name, agent.model, wait) });
|
|
18163
|
+
})) {
|
|
18164
|
+
console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` +
|
|
18165
|
+
`(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held'
|
|
18166
|
+
: this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held'
|
|
18167
|
+
: 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`);
|
|
18168
|
+
this.endFailureAtStop(agent.name, turnToken, turnStartedAt);
|
|
17616
18169
|
this.activeStreams.delete(agent.name);
|
|
17617
18170
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
17618
18171
|
this.channelRegistry?.stopTyping();
|
|
@@ -17697,14 +18250,16 @@ export class AgentFramework {
|
|
|
17697
18250
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
17698
18251
|
// Capacity scheduling is not poisoned history. The cooldown timer owns
|
|
17699
18252
|
// one later fresh compile; no immediate retry or exhaustion marker.
|
|
17700
|
-
|
|
18253
|
+
// (Once stop() has closed admission, nothing is held, and the turn
|
|
18254
|
+
// ends here: endFailureAtStop.)
|
|
18255
|
+
this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
|
|
17701
18256
|
return false;
|
|
17702
18257
|
}
|
|
17703
18258
|
const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
|
|
17704
18259
|
if (statedWait === 'held') {
|
|
17705
18260
|
// A conversation agent held by the provider's stated wait, as a
|
|
17706
18261
|
// resident is: the hold's release owns the later compile.
|
|
17707
|
-
this.
|
|
18262
|
+
this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt);
|
|
17708
18263
|
return false;
|
|
17709
18264
|
}
|
|
17710
18265
|
|
|
@@ -17739,7 +18294,15 @@ export class AgentFramework {
|
|
|
17739
18294
|
}
|
|
17740
18295
|
// The retry re-enters startAgentStream, which replaces this frame's
|
|
17741
18296
|
// turn token with its own; cleanup belongs to the innermost frame.
|
|
17742
|
-
|
|
18297
|
+
// The retry is its turn's (TURN_RETRY): this frame has begun the turn,
|
|
18298
|
+
// and once the retry's start is held at the last admission boundary
|
|
18299
|
+
// (a conversation agent's own compression can record a wait during
|
|
18300
|
+
// the policy's delay), this frame lets go of its token. No check of
|
|
18301
|
+
// this start is ahead now, so its window closes first: a stop made
|
|
18302
|
+
// once the retry is held finds the held retry (abortInference's
|
|
18303
|
+
// drop), not a window nothing will collect.
|
|
18304
|
+
if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent);
|
|
18305
|
+
await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1);
|
|
17743
18306
|
} else {
|
|
17744
18307
|
// Terminal failure with no driveStream: this frame owns teardown.
|
|
17745
18308
|
// Token-matched (like the finally in driveStream) so a stale frame
|
|
@@ -17889,8 +18452,8 @@ export class AgentFramework {
|
|
|
17889
18452
|
if (turnEnded) this.recordProviderAccelerationRecovery(agent, compiledRequest, end);
|
|
17890
18453
|
// The accounting writes to the store, and a write that fails must not
|
|
17891
18454
|
// skip what the boundary does after this call: the rest of a turn's
|
|
17892
|
-
// end, or a restart's cancel and its request
|
|
17893
|
-
// dispatch to modules.
|
|
18455
|
+
// end, or a restart's trace (its cancel and its request come before
|
|
18456
|
+
// this call), and then the event's dispatch to modules.
|
|
17894
18457
|
try {
|
|
17895
18458
|
if (turnEnded) {
|
|
17896
18459
|
completeTurn(latestUsage, durationMs);
|
|
@@ -19313,12 +19876,16 @@ export class AgentFramework {
|
|
|
19313
19876
|
|
|
19314
19877
|
this.abandonAgentScripts(agent.name, 'stream error', streamRouting);
|
|
19315
19878
|
|
|
19879
|
+
// A hold takes the turn's retry, or, once stop() has closed
|
|
19880
|
+
// admission, the turn ends here (endFailureAtStop).
|
|
19316
19881
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
19882
|
+
this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
|
|
19317
19883
|
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
19318
19884
|
break;
|
|
19319
19885
|
}
|
|
19320
19886
|
const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
|
|
19321
19887
|
if (statedWait === 'held') {
|
|
19888
|
+
this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
|
|
19322
19889
|
if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
19323
19890
|
break;
|
|
19324
19891
|
}
|
|
@@ -19355,7 +19922,8 @@ export class AgentFramework {
|
|
|
19355
19922
|
}
|
|
19356
19923
|
return;
|
|
19357
19924
|
}
|
|
19358
|
-
|
|
19925
|
+
// Marked as its turn's retry, as at the start's failure.
|
|
19926
|
+
await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1);
|
|
19359
19927
|
} else {
|
|
19360
19928
|
// The turn ends here, and its inference:failed doesn't say so,
|
|
19361
19929
|
// since a retry usually follows one: an inference:aborted
|
|
@@ -19676,10 +20244,16 @@ export class AgentFramework {
|
|
|
19676
20244
|
const durationMs = Date.now() - startTime;
|
|
19677
20245
|
// The stream won't go on: the agent stops holding it before the
|
|
19678
20246
|
// failure is reported, as at the stream's error event, so a stop made
|
|
19679
|
-
// while inference:failed is delivered finds no stream.
|
|
20247
|
+
// while inference:failed is delivered finds no stream. It's reported
|
|
20248
|
+
// once, before a provider hold takes the turn's retry, as at the
|
|
20249
|
+
// stream's error event and at a start's failure: a listener's stop
|
|
20250
|
+
// finds nothing held, and one made after it ends the held turn.
|
|
19680
20251
|
agent.reset();
|
|
20252
|
+
this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
|
|
19681
20253
|
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
19682
|
-
|
|
20254
|
+
// Once stop() has closed admission, nothing is held, and the turn
|
|
20255
|
+
// ends here (endFailureAtStop).
|
|
20256
|
+
this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
|
|
19683
20257
|
logStream({ success: false,
|
|
19684
20258
|
error: `Provider acceleration cooldown: ${err.message}`,
|
|
19685
20259
|
request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
|
|
@@ -19693,7 +20267,7 @@ export class AgentFramework {
|
|
|
19693
20267
|
// says why it ends.
|
|
19694
20268
|
const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger);
|
|
19695
20269
|
if (statedWait === 'held') {
|
|
19696
|
-
this.
|
|
20270
|
+
this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime);
|
|
19697
20271
|
logStream({ success: false,
|
|
19698
20272
|
error: `Provider wait: ${err.message}`,
|
|
19699
20273
|
request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
|
|
@@ -19701,12 +20275,6 @@ export class AgentFramework {
|
|
|
19701
20275
|
lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name);
|
|
19702
20276
|
return;
|
|
19703
20277
|
}
|
|
19704
|
-
this.emitTrace({
|
|
19705
|
-
type: 'inference:failed',
|
|
19706
|
-
agentName: agent.name,
|
|
19707
|
-
error: err.message,
|
|
19708
|
-
stack: err.stack,
|
|
19709
|
-
});
|
|
19710
20278
|
this.settleAgent(agent.name, {
|
|
19711
20279
|
stopReason: 'exhausted',
|
|
19712
20280
|
speech: '',
|
|
@@ -19891,7 +20459,11 @@ export class AgentFramework {
|
|
|
19891
20459
|
// its ids when a successor turn has already taken the agent. A fork or
|
|
19892
20460
|
// run deregistered while the host is quiesced leaves its queue to its
|
|
19893
20461
|
// window's release at resume (drainDeferredFor).
|
|
19894
|
-
|
|
20462
|
+
// A frame that handed its turn to a continuation since dropped
|
|
20463
|
+
// (endContinuedTurn, which couldn't land it while this frame held the
|
|
20464
|
+
// token) lands it too: the turn is over.
|
|
20465
|
+
const handedOffTurnOver = preserveEventGateForSuccessor && !this.hasTurn(agent.name);
|
|
20466
|
+
if ((frameReachedTerminal || handedOffTurnOver) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
|
|
19895
20467
|
this.readdDrained(this.drainDeferredFor(agent.name), 'turn-end flush');
|
|
19896
20468
|
this.ackDeferredWrites();
|
|
19897
20469
|
}
|
|
@@ -19914,8 +20486,11 @@ export class AgentFramework {
|
|
|
19914
20486
|
// frame that ends the turn disposes it, after the flush above, so what
|
|
19915
20487
|
// was held for the turn's end lands in the fork's store instead of
|
|
19916
20488
|
// being dropped as addressed to no agent. A frame that hands the turn
|
|
19917
|
-
// to a successor (a guard or budget restart) leaves the fork to it
|
|
19918
|
-
|
|
20489
|
+
// to a successor (a guard or budget restart) leaves the fork to it, and
|
|
20490
|
+
// so does one whose failure a provider hold keeps for its retry: the
|
|
20491
|
+
// turn isn't over (pendingContinuationAgents).
|
|
20492
|
+
if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)
|
|
20493
|
+
&& !this.pendingContinuationAgents().has(agent.name)) {
|
|
19919
20494
|
this.disposeConversationAgent(agent.name);
|
|
19920
20495
|
}
|
|
19921
20496
|
}
|
|
@@ -20007,11 +20582,16 @@ export class AgentFramework {
|
|
|
20007
20582
|
`puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`,
|
|
20008
20583
|
);
|
|
20009
20584
|
}
|
|
20010
|
-
if (agent.state.status !== 'idle' || this.
|
|
20585
|
+
if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
|
|
20011
20586
|
const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
|
|
20012
|
-
|
|
20013
|
-
|
|
20014
|
-
|
|
20587
|
+
// A turn a provider hold keeps for its retry has no stream: the refusal
|
|
20588
|
+
// names the hold, and how to go on sooner, as surgery's does.
|
|
20589
|
+
const held = agent.state.status === 'idle' ? this.heldTurnRetry(agentName) : undefined;
|
|
20590
|
+
throw new Error(held
|
|
20591
|
+
? `puppet refused: agent ${agentName} is idle+turn-alive (${held.says}; requires idle); ` +
|
|
20592
|
+
heldTurnWayOn(held.statedWait)
|
|
20593
|
+
: `puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
|
|
20594
|
+
`injecting a turn under an active stream corrupts wire ordering)`,
|
|
20015
20595
|
);
|
|
20016
20596
|
}
|
|
20017
20597
|
const onSurface = this.getToolsForAgent(agentName)
|
|
@@ -20958,9 +21538,10 @@ export class AgentFramework {
|
|
|
20958
21538
|
if (!roundCalls.some((c) => c.name === counted.name && c.cause === counted.cause && c.refused === counted.refused)) roundCalls.push(counted);
|
|
20959
21539
|
}
|
|
20960
21540
|
if (refusal !== undefined) return refused(refusal);
|
|
21541
|
+
const route = { toServer: false };
|
|
20961
21542
|
const result = await this.dispatchScriptToolCall(
|
|
20962
21543
|
agentName, toolName, input as Record<string, unknown>,
|
|
20963
|
-
context.origin as ScriptCallOrigin | undefined, context.watch?.turnId,
|
|
21544
|
+
context.origin as ScriptCallOrigin | undefined, context.watch?.turnId, route,
|
|
20964
21545
|
);
|
|
20965
21546
|
// Deferred: applied to the result of the code_execution call watching
|
|
20966
21547
|
// the script — ending the turn mid-script would cancel the stream while
|
|
@@ -20971,7 +21552,7 @@ export class AgentFramework {
|
|
|
20971
21552
|
const watching = context.watching ? context.watching() : context.watch;
|
|
20972
21553
|
if (result.endTurn && watching) watching.endTurn = true;
|
|
20973
21554
|
if (result.refused) return refused(result.error ?? `${toolName} was refused; nothing was done`);
|
|
20974
|
-
const answer = await this.scriptResultAnswer(result, toolName);
|
|
21555
|
+
const answer = await this.scriptResultAnswer(result, toolName, route.toServer);
|
|
20975
21556
|
return SCRIPT_CALL_NO_RESULT.has(result) ? { ...answer, held: true } : answer;
|
|
20976
21557
|
}
|
|
20977
21558
|
|
|
@@ -20980,14 +21561,20 @@ export class AgentFramework {
|
|
|
20980
21561
|
* isError }`, one shape for every tool. `content` is
|
|
20981
21562
|
* what the model would read: a structured result's blocks as the dispatch
|
|
20982
21563
|
* gave them (the direct reading, with RFC-005's treatment of an MCPL peer's
|
|
20983
|
-
* blocks
|
|
20984
|
-
*
|
|
21564
|
+
* blocks there, bound to the server that sent them), a module's under
|
|
21565
|
+
* history's reading of RFC-005 at every depth (shownToScript), and
|
|
21566
|
+
* otherwise one text block of the model's text, which for a failure is its
|
|
21567
|
+
* error. A module's is any result whose call the dispatch didn't send to
|
|
21568
|
+
* a server (`toServer`, from the call's waiter). `structuredContent` is the
|
|
21569
|
+
* tool's own structured
|
|
20985
21570
|
* content where it sent one, `false`, `0` and `null` included, else null.
|
|
20986
21571
|
* A result that can't be converted to JSON (a cyclic object, a BigInt),
|
|
20987
21572
|
* which the dispatch's own check makes unlikely here, is a failure naming
|
|
20988
|
-
* the tool and saying the call itself completed.
|
|
21573
|
+
* the tool and saying the call itself completed. So is an MCPL peer's
|
|
21574
|
+
* result whose blocks were too deep for the dispatch's treatment, which
|
|
21575
|
+
* the dispatch marks (SCRIPT_RESULT_UNCONVERTIBLE).
|
|
20989
21576
|
*/
|
|
20990
|
-
private async scriptResultAnswer(result: ToolResult, toolName: string): Promise<ToolAnswer> {
|
|
21577
|
+
private async scriptResultAnswer(result: ToolResult, toolName: string, toServer: boolean): Promise<ToolAnswer> {
|
|
20991
21578
|
const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
|
|
20992
21579
|
const isError = result.isError === true;
|
|
20993
21580
|
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
|
|
@@ -21004,7 +21591,10 @@ export class AgentFramework {
|
|
|
21004
21591
|
// which isn't fetched.
|
|
21005
21592
|
// Whenever the result's blocks reach the script: a structured failure's
|
|
21006
21593
|
// included, as a module's error can carry content.
|
|
21007
|
-
|
|
21594
|
+
// Not a server's result: a module's, whose blocks no dispatch has given
|
|
21595
|
+
// RFC-005's treatment.
|
|
21596
|
+
const fromModule = !toServer;
|
|
21597
|
+
if ((!isError || structured) && Array.isArray(result.data) && fromModule) {
|
|
21008
21598
|
const fetcher = this.getReferenceFetcher();
|
|
21009
21599
|
for (const block of result.data) {
|
|
21010
21600
|
const c = classifyBlock(block);
|
|
@@ -21024,6 +21614,9 @@ export class AgentFramework {
|
|
|
21024
21614
|
brief: error,
|
|
21025
21615
|
};
|
|
21026
21616
|
};
|
|
21617
|
+
// An MCPL peer's result whose blocks JSON couldn't hold, so that RFC-005's
|
|
21618
|
+
// treatment couldn't be given them at dispatch.
|
|
21619
|
+
if (SCRIPT_RESULT_UNCONVERTIBLE.has(result)) return failed(SCRIPT_RESULT_UNCONVERTIBLE.get(result));
|
|
21027
21620
|
let content: unknown[];
|
|
21028
21621
|
let text: string | undefined;
|
|
21029
21622
|
// A structured result's blocks, as #538 gives them: none when its content
|
|
@@ -21031,6 +21624,20 @@ export class AgentFramework {
|
|
|
21031
21624
|
// no blocks reads as its error text.
|
|
21032
21625
|
if (structured && (!isError || (Array.isArray(result.data) && result.data.length > 0))) {
|
|
21033
21626
|
content = Array.isArray(result.data) ? result.data : [];
|
|
21627
|
+
// A module's blocks, such as an MCPL peer's it got from ctx.callTool
|
|
21628
|
+
// and returns whole, are read as history reads a module's data: what
|
|
21629
|
+
// RFC-005 governs, at any depth, as the script lane shows it, and
|
|
21630
|
+
// anything else as sent. What a script prints comes back to the model,
|
|
21631
|
+
// so no uri the model's lanes withhold reaches it. What this gives is
|
|
21632
|
+
// a stub, a notice or the value itself, so a module that returns
|
|
21633
|
+
// blocks it already gave has them left as they are.
|
|
21634
|
+
if (fromModule) {
|
|
21635
|
+
try {
|
|
21636
|
+
content = JSON.parse(JSON.stringify(content, (_key, value: unknown) => shownToScript(value))) as unknown[];
|
|
21637
|
+
} catch (err) {
|
|
21638
|
+
return failed(err);
|
|
21639
|
+
}
|
|
21640
|
+
}
|
|
21034
21641
|
} else {
|
|
21035
21642
|
// As in history, a reference is its stub at any depth and inline data
|
|
21036
21643
|
// claiming a uri or a disposition is withheld (RFC-005).
|
|
@@ -21067,7 +21674,9 @@ export class AgentFramework {
|
|
|
21067
21674
|
* RFC-007: with an `origin`, observers see the call as they see a model
|
|
21068
21675
|
* call (register, dispatch with its refusal sites, open; the terminal at
|
|
21069
21676
|
* the waiter intercept), under the inner tool's own name and class.
|
|
21070
|
-
* `turnId` is the turn the call was made in (dispatchToolCall).
|
|
21677
|
+
* `turnId` is the turn the call was made in (dispatchToolCall). `route` is
|
|
21678
|
+
* the waiter's: the dispatch sets `toServer` when it sends the call to a
|
|
21679
|
+
* server.
|
|
21071
21680
|
*/
|
|
21072
21681
|
private dispatchScriptToolCall(
|
|
21073
21682
|
agentName: string,
|
|
@@ -21075,6 +21684,7 @@ export class AgentFramework {
|
|
|
21075
21684
|
args: Record<string, unknown>,
|
|
21076
21685
|
origin?: ScriptCallOrigin,
|
|
21077
21686
|
turnId?: number,
|
|
21687
|
+
route: { toServer: boolean } = { toServer: false },
|
|
21078
21688
|
): Promise<ToolResult> {
|
|
21079
21689
|
return new Promise<ToolResult>((resolve) => {
|
|
21080
21690
|
const callId = `pytc-${randomUUID()}`;
|
|
@@ -21102,9 +21712,12 @@ export class AgentFramework {
|
|
|
21102
21712
|
}, safetyMs);
|
|
21103
21713
|
safety.unref?.();
|
|
21104
21714
|
|
|
21105
|
-
this.scriptToolWaiters.set(callId,
|
|
21106
|
-
|
|
21107
|
-
|
|
21715
|
+
this.scriptToolWaiters.set(callId, {
|
|
21716
|
+
resolve: (result) => {
|
|
21717
|
+
clearTimeout(safety);
|
|
21718
|
+
resolve(result);
|
|
21719
|
+
},
|
|
21720
|
+
route,
|
|
21108
21721
|
});
|
|
21109
21722
|
|
|
21110
21723
|
const call = { id: callId, name: toolName, input: args };
|
|
@@ -21663,6 +22276,10 @@ export class AgentFramework {
|
|
|
21663
22276
|
// Route MCPL tool calls to the appropriate server via prefix map
|
|
21664
22277
|
const mcplMatch = this.resolveMcplTool(enrichedCall.name);
|
|
21665
22278
|
if (mcplMatch && this.mcplServerRegistry) {
|
|
22279
|
+
// A script's call: its waiter keeps that it went to a server, for the
|
|
22280
|
+
// script lane's exit (scriptToolWaiters).
|
|
22281
|
+
const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
|
|
22282
|
+
if (scriptWaiter) scriptWaiter.route.toServer = true;
|
|
21666
22283
|
this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
|
|
21667
22284
|
return;
|
|
21668
22285
|
}
|
|
@@ -26769,8 +27386,8 @@ export class AgentFramework {
|
|
|
26769
27386
|
}
|
|
26770
27387
|
|
|
26771
27388
|
/** An MCP tool call's trace: a result the server marked an error (or one
|
|
26772
|
-
* the reading
|
|
26773
|
-
* paths; anything else is tool:completed. */
|
|
27389
|
+
* the reading takes as one, such as malformed content) is tool:failed, as
|
|
27390
|
+
* on the other tool paths; anything else is tool:completed. */
|
|
26774
27391
|
private traceMcpToolOutcome(serverId: string, tool: string, callId: string, result: ToolResult, durationMs: number): void {
|
|
26775
27392
|
if (result.isError) {
|
|
26776
27393
|
this.emitTrace({ type: 'tool:failed', module: `mcpl:${serverId}`, tool, callId, durationMs, error: result.error ?? 'tool returned an error' });
|
|
@@ -27190,14 +27807,42 @@ export class AgentFramework {
|
|
|
27190
27807
|
// generated JSON) stands in for what the server sent. An MCPL peer's
|
|
27191
27808
|
// content blocks are the exception: what a script prints comes back to
|
|
27192
27809
|
// the model, so each gets the treatment RFC-005 gives it on every lane
|
|
27193
|
-
// the model reads
|
|
27810
|
+
// the model reads, here, where the server that sent them is known, and
|
|
27811
|
+
// so does what RFC-005 governs at any depth within them, by the same
|
|
27812
|
+
// peer reading, bound to that server (shownToScriptFromServer). A
|
|
27813
|
+
// module's blocks get it at
|
|
27814
|
+
// the script lane's exit (scriptResultAnswer). A standard peer's blocks
|
|
27815
|
+
// reach the script as sent.
|
|
27194
27816
|
const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
|
|
27195
27817
|
const reading = async (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean): Promise<ToolResult> => {
|
|
27196
27818
|
if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {
|
|
27197
27819
|
return this.mcpToolResult(result, call.id, mcpl, serverId, call.name);
|
|
27198
27820
|
}
|
|
27199
27821
|
const direct = AgentFramework.directToolResult(result, serverId, call.name);
|
|
27200
|
-
if (mcpl)
|
|
27822
|
+
if (mcpl) {
|
|
27823
|
+
if (!Array.isArray(direct.data)) return direct;
|
|
27824
|
+
// Each block by the classifier, then what's nested in each, at any
|
|
27825
|
+
// depth. The blocks came from the wire as JSON, so the round trip
|
|
27826
|
+
// changes nothing else. But a value that parsed can still fail to
|
|
27827
|
+
// go back: past some depth JSON.stringify overflows the stack where
|
|
27828
|
+
// JSON.parse didn't (on Node 24.19 an array 5,000 deep does, on Bun
|
|
27829
|
+
// 1.4.4 one 200,000 deep). That fails the script lane's treatment
|
|
27830
|
+
// alone, and the peer answered: the result keeps the peer's outcome
|
|
27831
|
+
// for the trace and the call's terminal, and the script lane's exit
|
|
27832
|
+
// answers it as a result it can't convert, not as a failed dispatch
|
|
27833
|
+
// (SCRIPT_RESULT_UNCONVERTIBLE). Its blocks are dropped, since they
|
|
27834
|
+
// are untreated and the result travels on, to the waiter and to the
|
|
27835
|
+
// listeners of its event's trace. Its structured content is kept, as
|
|
27836
|
+
// every result keeps it, since RFC-005's treatment doesn't reach it.
|
|
27837
|
+
const blocks = direct.data.map((block: unknown) => referenceBlockForScript(block, serverId));
|
|
27838
|
+
try {
|
|
27839
|
+
return { ...direct, data: JSON.parse(JSON.stringify(blocks, (_key, value: unknown) => shownToScriptFromServer(value, serverId))) as unknown[] };
|
|
27840
|
+
} catch (err) {
|
|
27841
|
+
const unconvertible: ToolResult = { ...direct, data: [] };
|
|
27842
|
+
SCRIPT_RESULT_UNCONVERTIBLE.set(unconvertible, err);
|
|
27843
|
+
return unconvertible;
|
|
27844
|
+
}
|
|
27845
|
+
}
|
|
27201
27846
|
// A standard peer's content that isn't a list of blocks is a malformed
|
|
27202
27847
|
// result on the model's reading (normalizeStandardToolResult), so the
|
|
27203
27848
|
// script reads it that way too, its structured content kept.
|
|
@@ -27422,7 +28067,8 @@ export class AgentFramework {
|
|
|
27422
28067
|
* direct path (a module's callTool, a channel's tool call) doesn't come
|
|
27423
28068
|
* here: it returns the server's result itself (directToolResult), for
|
|
27424
28069
|
* either engine, as does a script's call whose result is structured, with
|
|
27425
|
-
* an MCPL peer's blocks given RFC-005's treatment (referenceBlockForScript)
|
|
28070
|
+
* an MCPL peer's blocks given RFC-005's treatment (referenceBlockForScript),
|
|
28071
|
+
* and what's nested in them at any depth (shownToScriptFromServer).
|
|
27426
28072
|
*/
|
|
27427
28073
|
private async mcpToolResult(
|
|
27428
28074
|
result: { content?: unknown; isError?: boolean; structuredContent?: unknown },
|