@animalabs/agent-framework 0.25.0 → 0.26.1
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 +98 -9
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +422 -91
- 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-image-ledger.d.ts +26 -3
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +31 -4
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +12 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +23 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/events.d.ts +3 -1
- package/dist/src/types/events.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/framework.ts +427 -93
- 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-image-ledger.ts +31 -4
- package/src/tool-result-history.ts +22 -1
- package/src/types/events.ts +3 -1
package/src/framework.ts
CHANGED
|
@@ -116,6 +116,7 @@ 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,
|
|
119
120
|
toolResultDataToHistoryString,
|
|
120
121
|
truncateForHistory,
|
|
121
122
|
DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS,
|
|
@@ -1239,6 +1240,10 @@ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stoppin
|
|
|
1239
1240
|
* revive the earlier lifecycle's turn in the next one. */
|
|
1240
1241
|
const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything';
|
|
1241
1242
|
|
|
1243
|
+
/** The reason on the inference:aborted that ends a turn whose continuation
|
|
1244
|
+
* was dropped before it started, other than by an abandon or a stop. */
|
|
1245
|
+
const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started";
|
|
1246
|
+
|
|
1242
1247
|
/** One frame's window while it holds a turn with no stream and one of its
|
|
1243
1248
|
* checks is ahead (AgentFramework.checkAhead). `abandoned` is an operator's
|
|
1244
1249
|
* quiesce abandon that reached the turn there, which the frame's check
|
|
@@ -1603,14 +1608,26 @@ export interface HostModeStatus {
|
|
|
1603
1608
|
quiesced: boolean;
|
|
1604
1609
|
reason?: string;
|
|
1605
1610
|
since?: number;
|
|
1606
|
-
/** True when no turn is alive
|
|
1607
|
-
*
|
|
1611
|
+
/** True when no turn is alive: no turn token (the token spans dequeue →
|
|
1612
|
+
* settled teardown, strictly wider than activeStreams) and no continuation
|
|
1613
|
+
* of a turn on its way to its start (a budget restart's or a tool-result
|
|
1614
|
+
* guard's retry, queued, held by a provider hold, parked or in a scheduler
|
|
1615
|
+
* pass), and no wake parked on provider admission. It covers the
|
|
1616
|
+
* framework's own turns: a caller of the direct Agent API
|
|
1617
|
+
* (`Agent.runInference`) owns its turns' timing. */
|
|
1608
1618
|
drained: boolean;
|
|
1609
1619
|
activeTurns: number;
|
|
1620
|
+
/** The agents with a turn alive, the ones `activeTurns` counts, sorted:
|
|
1621
|
+
* each from its turn's dequeue to its settled teardown, so a turn in its
|
|
1622
|
+
* hooks or compile, one finishing its deliveries, or one whose continuation
|
|
1623
|
+
* is on its way to its start, is listed. These are the agents an abandon or
|
|
1624
|
+
* a stop() made now would find mid-turn. */
|
|
1625
|
+
activeTurnAgents: string[];
|
|
1610
1626
|
/** Wakes that own provider admission while waiting for an in-flight
|
|
1611
1627
|
* auxiliary call — no turn token yet, but a turn is one settle away. The
|
|
1612
1628
|
* admission continuation rechecks quiesce and requeues them; `drained`
|
|
1613
|
-
* is false until it has.
|
|
1629
|
+
* is false until it has. A parked continuation of a turn is counted here
|
|
1630
|
+
* and, as a turn alive, in `activeTurns` and `activeTurnAgents`. */
|
|
1614
1631
|
parkedAdmissions: number;
|
|
1615
1632
|
/** Inference requests parked by the quiesce wake gate (while quiesced), or
|
|
1616
1633
|
* simply queued (while serving — non-zero right after a resume until the
|
|
@@ -1832,6 +1849,9 @@ interface ProviderAccelerationCooldown {
|
|
|
1832
1849
|
/** Armed by an auxiliary (compression) failure with no primary involved:
|
|
1833
1850
|
* release reopens auxiliary admission without synthesising an inference. */
|
|
1834
1851
|
auxiliaryOrigin?: boolean;
|
|
1852
|
+
/** Its held requests were taken out by dropContinuations: release reopens
|
|
1853
|
+
* admission without synthesising an inference in their place. */
|
|
1854
|
+
emptiedByDrop?: boolean;
|
|
1835
1855
|
/** Set when this hold is a provider wait (a stated retry-after) for the
|
|
1836
1856
|
* agent's primary model: it holds primary turns only. Auxiliary calls are
|
|
1837
1857
|
* admitted per model by the provider waits themselves, so a compression
|
|
@@ -2065,8 +2085,8 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength(
|
|
|
2065
2085
|
* memoryMaxBytes. So the figure moves with memoryMaxBytes. What it can't
|
|
2066
2086
|
* count: a result's `structured` value, which the measure leaves out and the
|
|
2067
2087
|
* 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
|
|
2088
|
+
* their text, which the answer carries as the script lane gives them (each
|
|
2089
|
+
* further block's keys, an image's data); nesting past 2,000 levels; and a
|
|
2070
2090
|
* parse that needs more of the script's memory than is free, as for JSON
|
|
2071
2091
|
* dense in small objects or a script already holding most of its memory.
|
|
2072
2092
|
* Such a result, or one past the figure that doesn't fit, reaches the script
|
|
@@ -2378,6 +2398,11 @@ type McpRestartClaim = { readonly restarting: string; endedBy?: string };
|
|
|
2378
2398
|
/** Why the framework cancelled a stream it owns: the mark it records, by
|
|
2379
2399
|
* agent and stream, before the cancel, for the stream's terminal to read. */
|
|
2380
2400
|
type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned' | 'shutdown';
|
|
2401
|
+
/** How a turn whose continuation is dropped before it starts ends: an
|
|
2402
|
+
* operator's quiesce abandon or stop() settles it as a stop ends a turn's
|
|
2403
|
+
* start; any other drop ends it as a start dropped before it sends anything.
|
|
2404
|
+
* `why` is said in the line its end logs. */
|
|
2405
|
+
type ContinuationDrop = { kind: 'quiesce_abandoned' | 'shutdown' | 'dropped'; why: string };
|
|
2381
2406
|
|
|
2382
2407
|
/** What opened a channel the agent didn't ask to open
|
|
2383
2408
|
* (recordChannelAutoOpenNotice). */
|
|
@@ -2808,6 +2833,15 @@ export class AgentFramework {
|
|
|
2808
2833
|
// and its settle except that turn's own blocks, and, before its checkpoint,
|
|
2809
2834
|
// the inputs its start writes (flushForStart).
|
|
2810
2835
|
private activeTurnTokens: Map<string, number> = new Map();
|
|
2836
|
+
/** Each running scheduler pass's requests, which it took from
|
|
2837
|
+
* pendingRequests: it holds those of the agents it hasn't taken up yet
|
|
2838
|
+
* while it awaits an earlier agent's start, so a continuation among them
|
|
2839
|
+
* is still on its way (pendingContinuationAgents). */
|
|
2840
|
+
private passRequests: Set<Map<string, InferenceRequest[]>> = new Set();
|
|
2841
|
+
/** Continuations whose start waits on provider admission behind an
|
|
2842
|
+
* auxiliary call, in no queue meanwhile. A drop takes one out and gives
|
|
2843
|
+
* its admission back; the park, woken, then finds it gone. */
|
|
2844
|
+
private parkedContinuations: Set<InferenceRequest> = new Set();
|
|
2811
2845
|
/** Each turn whose settle has ended it (settleAgent, endDroppedTurn), for
|
|
2812
2846
|
* its agent's scripts: its start's teardown can still hold the agent's
|
|
2813
2847
|
* token, and a turn that has ended isn't live (liveTurns). A later start
|
|
@@ -2951,9 +2985,17 @@ export class AgentFramework {
|
|
|
2951
2985
|
// config.codeExecution.enabled: its settings, and every agent's scripts,
|
|
2952
2986
|
// each in a process of its own on the engine (AgentScripts). Waiters
|
|
2953
2987
|
// resolve script-inner tool calls dispatched through the normal
|
|
2954
|
-
// dispatchToolCall path.
|
|
2988
|
+
// dispatchToolCall path. A waiter's route is where the dispatch sent its
|
|
2989
|
+
// call: `toServer` is set when it went to a server's own tool (an MCPL or
|
|
2990
|
+
// MCP-only peer's), so the script lane's exit treats every other result as
|
|
2991
|
+
// a module's (scriptResultAnswer), by where the call went rather than by a
|
|
2992
|
+
// name lookup a connect or disconnect can change while it runs. A
|
|
2993
|
+
// framework tool that talks to a server itself, such as a channel tool,
|
|
2994
|
+
// builds its own result, which counts as a module's. A caller of
|
|
2995
|
+
// dispatchScriptToolCall that passes no route gets one left false, the
|
|
2996
|
+
// reading that withholds more.
|
|
2955
2997
|
private codeExecution: { settings: IsolateSettings; scripts: AgentScripts } | null = null;
|
|
2956
|
-
private scriptToolWaiters: Map<string, (result: ToolResult) => void> = new Map();
|
|
2998
|
+
private scriptToolWaiters: Map<string, { resolve: (result: ToolResult) => void; route: { toServer: boolean } }> = new Map();
|
|
2957
2999
|
/** Per agent, a round that called code_execution and waits for those calls'
|
|
2958
3000
|
* results before its prose is routed (ScriptRound). */
|
|
2959
3001
|
private scriptRounds: Map<string, ScriptRound> = new Map();
|
|
@@ -4005,6 +4047,16 @@ export class AgentFramework {
|
|
|
4005
4047
|
// framework must not keep up to the whole ledger budget referenced.
|
|
4006
4048
|
this.toolImageLedgers.clear();
|
|
4007
4049
|
|
|
4050
|
+
// A turn whose continuation is still on its way ends with the stop, as
|
|
4051
|
+
// a turn's start a stop reaches does: the continuation would start only
|
|
4052
|
+
// after a start(), in a lifecycle where its logical turn is gone
|
|
4053
|
+
// (openLogical, below). Before the holds and the parks go, so the
|
|
4054
|
+
// continuation leaves them with its turn.
|
|
4055
|
+
step(() => {
|
|
4056
|
+
for (const agentName of this.pendingContinuationAgents()) {
|
|
4057
|
+
this.dropContinuations(agentName, { kind: 'shutdown', why: 'the framework is stopping' });
|
|
4058
|
+
}
|
|
4059
|
+
});
|
|
4008
4060
|
// A stopped host must never hang behind its own cooldown.
|
|
4009
4061
|
step(() => this.cancelProviderAdmission());
|
|
4010
4062
|
// An open logical turn doesn't survive a stop: a start after a restart
|
|
@@ -4660,7 +4712,8 @@ export class AgentFramework {
|
|
|
4660
4712
|
// The stated wait (recorded above for agent.model) set this hold's
|
|
4661
4713
|
// length: an operator release of it returns the hold to AF's own pacing.
|
|
4662
4714
|
...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}),
|
|
4663
|
-
...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {})
|
|
4715
|
+
...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}),
|
|
4716
|
+
...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) });
|
|
4664
4717
|
if (auxiliary && !existing) {
|
|
4665
4718
|
console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` +
|
|
4666
4719
|
`holding primary/auxiliary for ${delayMs}ms`);
|
|
@@ -4767,9 +4820,12 @@ export class AgentFramework {
|
|
|
4767
4820
|
? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)`
|
|
4768
4821
|
: `primary hold ended after ${Date.now() - cooldown.startedAt}ms`));
|
|
4769
4822
|
}
|
|
4770
|
-
if (cooldown.auxiliaryOrigin && cooldown.heldRequests.length === 0) {
|
|
4771
|
-
// Nobody asked for an inference while parked
|
|
4772
|
-
|
|
4823
|
+
if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) {
|
|
4824
|
+
// Nobody asked for an inference while parked, or what was held was a
|
|
4825
|
+
// continuation since dropped with its turn: just reopen the gate. A
|
|
4826
|
+
// primary the hold left pending won't run, so its recovery ends.
|
|
4827
|
+
console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`);
|
|
4828
|
+
if (cooldown.emptiedByDrop) this.endPendingPrimary(agentName);
|
|
4773
4829
|
this.flushAuxiliaryAdmission(agentName);
|
|
4774
4830
|
return;
|
|
4775
4831
|
}
|
|
@@ -6446,14 +6502,54 @@ export class AgentFramework {
|
|
|
6446
6502
|
return n;
|
|
6447
6503
|
}
|
|
6448
6504
|
|
|
6505
|
+
/**
|
|
6506
|
+
* A continuation the framework makes for a turn (a budget restart's, which
|
|
6507
|
+
* it queues, or a tool-result guard's retry, which its frame starts at
|
|
6508
|
+
* once) holds no turn token until its start takes one. Once its turn's
|
|
6509
|
+
* frame has let go of its token, its start can wait in the queue, a
|
|
6510
|
+
* provider hold (a retry too, when a wait holds it), a park on admission
|
|
6511
|
+
* or a scheduler pass's hands. Its turn is alive meanwhile. These are the
|
|
6512
|
+
* agents with one on its way. A
|
|
6513
|
+
* `tool_results_ready` continuation follows a tool round left waiting with
|
|
6514
|
+
* no stream, which only the direct Agent API makes (Agent's
|
|
6515
|
+
* runInferenceWithInjections; the framework's own rounds wait with their
|
|
6516
|
+
* stream, enterWaitingForTools). Its caller owns that turn's timing, so it
|
|
6517
|
+
* isn't counted.
|
|
6518
|
+
*/
|
|
6519
|
+
private pendingContinuationAgents(): Set<string> {
|
|
6520
|
+
const agents = new Set<string>();
|
|
6521
|
+
const add = (r: InferenceRequest): void => { if (resumesLogicalTurn(r.reason)) agents.add(r.agentName); };
|
|
6522
|
+
// Read defensively: a framework built from the prototype, as the
|
|
6523
|
+
// operator tests build one, sets none of these.
|
|
6524
|
+
for (const r of this.pendingRequests ?? []) add(r);
|
|
6525
|
+
for (const cooldown of this.providerAccelerationCooldowns?.values() ?? []) for (const r of cooldown.heldRequests) add(r);
|
|
6526
|
+
for (const r of this.parkedContinuations ?? []) add(r);
|
|
6527
|
+
for (const pass of this.passRequests ?? []) for (const requests of pass.values()) for (const r of requests) add(r);
|
|
6528
|
+
return agents;
|
|
6529
|
+
}
|
|
6530
|
+
|
|
6531
|
+
/** The agents with a turn alive: holding a turn token, or with a
|
|
6532
|
+
* continuation of their turn on its way. */
|
|
6533
|
+
private turnHolders(): Set<string> {
|
|
6534
|
+
const holders = this.pendingContinuationAgents();
|
|
6535
|
+
for (const name of this.activeTurnTokens.keys()) holders.add(name);
|
|
6536
|
+
return holders;
|
|
6537
|
+
}
|
|
6538
|
+
|
|
6539
|
+
private hasTurn(agentName: string): boolean {
|
|
6540
|
+
return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName);
|
|
6541
|
+
}
|
|
6542
|
+
|
|
6449
6543
|
getHostModeStatus(): HostModeStatus {
|
|
6450
6544
|
const parkedAdmissions = this.parkedAdmissionCount();
|
|
6545
|
+
const holders = this.turnHolders();
|
|
6451
6546
|
return {
|
|
6452
6547
|
quiesced: this.quiesced,
|
|
6453
6548
|
...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
|
|
6454
6549
|
...(this.quiescedAt !== undefined ? { since: this.quiescedAt } : {}),
|
|
6455
|
-
drained:
|
|
6456
|
-
activeTurns:
|
|
6550
|
+
drained: holders.size === 0 && parkedAdmissions === 0,
|
|
6551
|
+
activeTurns: holders.size,
|
|
6552
|
+
activeTurnAgents: [...holders].sort(),
|
|
6457
6553
|
parkedAdmissions,
|
|
6458
6554
|
gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length,
|
|
6459
6555
|
backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0,
|
|
@@ -6472,8 +6568,9 @@ export class AgentFramework {
|
|
|
6472
6568
|
/**
|
|
6473
6569
|
* Enter quiesce mode: write the deferred-write queue, persist the flag,
|
|
6474
6570
|
* suppress gate deliveries, pause all MCPL data planes, and wait for
|
|
6475
|
-
* in-flight turns to settle (turn-alive is
|
|
6476
|
-
* dequeue → settled teardown
|
|
6571
|
+
* in-flight turns to settle (turn-alive is a turn token, which spans
|
|
6572
|
+
* dequeue → settled teardown, or a continuation of the turn on its way to
|
|
6573
|
+
* its start: turnHolders). Idempotent, but for the queue's write, which
|
|
6477
6574
|
* every call makes first: when the queue can't reach disk, because its
|
|
6478
6575
|
* write fails or it's over the cap, the call rejects with a
|
|
6479
6576
|
* QuiesceBlockedError and the host's mode as it was, unless it has
|
|
@@ -6483,7 +6580,10 @@ export class AgentFramework {
|
|
|
6483
6580
|
* On drain timeout the host STAYS quiesced (`drained: false` in the
|
|
6484
6581
|
* result); with `abandon: true` the undrained streams are cancelled via a
|
|
6485
6582
|
* dedicated cancel kind that settles the turn without feeding the
|
|
6486
|
-
* inference-failure accounting
|
|
6583
|
+
* inference-failure accounting, and a turn whose continuation hasn't
|
|
6584
|
+
* started ends with the continuation dropped. Drain covers the framework's
|
|
6585
|
+
* own turns: a caller of the direct Agent API (`Agent.runInference`) owns
|
|
6586
|
+
* its turns' timing.
|
|
6487
6587
|
*/
|
|
6488
6588
|
async quiesce(opts?: {
|
|
6489
6589
|
reason?: string;
|
|
@@ -6509,13 +6609,13 @@ export class AgentFramework {
|
|
|
6509
6609
|
// out undrained must be escalatable with a second quiesce({abandon})
|
|
6510
6610
|
// without resume()+re-quiesce (which would reopen data planes and
|
|
6511
6611
|
// release parked wakes mid-surgery).
|
|
6512
|
-
if (opts?.abandon && this.
|
|
6612
|
+
if (opts?.abandon && this.turnHolders().size > 0) {
|
|
6513
6613
|
const unabandonable = await this.abandonActiveTurns();
|
|
6514
6614
|
this.emitTrace({
|
|
6515
6615
|
type: 'host:quiesce',
|
|
6516
6616
|
...(this.quiesceReason ? { reason: this.quiesceReason } : {}),
|
|
6517
|
-
drained: this.
|
|
6518
|
-
activeTurns: this.
|
|
6617
|
+
drained: this.turnHolders().size === 0,
|
|
6618
|
+
activeTurns: this.turnHolders().size,
|
|
6519
6619
|
abandoned: true,
|
|
6520
6620
|
...(unabandonable.length > 0 ? { unabandonable } : {}),
|
|
6521
6621
|
});
|
|
@@ -6541,7 +6641,7 @@ export class AgentFramework {
|
|
|
6541
6641
|
this.lastUnabandonable = [];
|
|
6542
6642
|
console.error(
|
|
6543
6643
|
`[host-mode] quiescing${opts?.reason ? ` (${opts.reason})` : ''}: parking wakes, ` +
|
|
6544
|
-
`pausing MCPL data planes, draining ${this.
|
|
6644
|
+
`pausing MCPL data planes, draining ${this.turnHolders().size} in-flight turn(s)`,
|
|
6545
6645
|
);
|
|
6546
6646
|
this.eventGate?.setQuiesced(true);
|
|
6547
6647
|
for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) {
|
|
@@ -6567,7 +6667,7 @@ export class AgentFramework {
|
|
|
6567
6667
|
while (
|
|
6568
6668
|
this.quiesced &&
|
|
6569
6669
|
!this.queue?.isClosed &&
|
|
6570
|
-
(this.
|
|
6670
|
+
(this.turnHolders().size > 0 || this.parkedAdmissionCount() > 0) &&
|
|
6571
6671
|
Date.now() < deadline
|
|
6572
6672
|
) {
|
|
6573
6673
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
@@ -6591,7 +6691,7 @@ export class AgentFramework {
|
|
|
6591
6691
|
|
|
6592
6692
|
let abandoned = false;
|
|
6593
6693
|
let unabandonable: string[] = [];
|
|
6594
|
-
if (this.
|
|
6694
|
+
if (this.turnHolders().size > 0 && opts?.abandon) {
|
|
6595
6695
|
abandoned = true;
|
|
6596
6696
|
unabandonable = await this.abandonActiveTurns();
|
|
6597
6697
|
if (!this.quiesced) return this.getHostModeStatus();
|
|
@@ -6624,7 +6724,18 @@ export class AgentFramework {
|
|
|
6624
6724
|
* the already-quiesced escalation path. */
|
|
6625
6725
|
private async abandonActiveTurns(): Promise<string[]> {
|
|
6626
6726
|
const unabandonable: string[] = [];
|
|
6727
|
+
// A turn whose continuation is still on its way ends with the
|
|
6728
|
+
// continuation dropped. Its stream has ended; a frame still letting go of
|
|
6729
|
+
// its token ends on its own, and settles nothing, since it handed the turn
|
|
6730
|
+
// to the continuation.
|
|
6731
|
+
const handedOff = new Set<string>();
|
|
6732
|
+
for (const agentName of this.pendingContinuationAgents()) {
|
|
6733
|
+
console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation starts: the continuation is dropped`);
|
|
6734
|
+
this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' });
|
|
6735
|
+
handedOff.add(agentName);
|
|
6736
|
+
}
|
|
6627
6737
|
for (const agentName of [...this.activeTurnTokens.keys()]) {
|
|
6738
|
+
if (handedOff.has(agentName)) continue;
|
|
6628
6739
|
const agent = this.agents.get(agentName);
|
|
6629
6740
|
const state = agent?.state;
|
|
6630
6741
|
const stream = state && 'stream' in state ? state.stream : undefined;
|
|
@@ -8698,6 +8809,9 @@ export class AgentFramework {
|
|
|
8698
8809
|
this.activeTurnTokens.delete(agent.name);
|
|
8699
8810
|
this.activeTurnTriggers.delete(agent.name);
|
|
8700
8811
|
this.logicalTurnToolCalls.delete(agent);
|
|
8812
|
+
// A continuation of its turn still on its way goes too: the run, and
|
|
8813
|
+
// with it the turn, has ended above.
|
|
8814
|
+
this.dropContinuations(agent.name, null);
|
|
8701
8815
|
}
|
|
8702
8816
|
}
|
|
8703
8817
|
|
|
@@ -10503,7 +10617,7 @@ export class AgentFramework {
|
|
|
10503
10617
|
const busy: string[] = [];
|
|
10504
10618
|
for (const [name, a] of this.agents) {
|
|
10505
10619
|
if (a.state.status !== 'idle') busy.push(`${name} is ${a.state.status}`);
|
|
10506
|
-
else if (this.
|
|
10620
|
+
else if (this.hasTurn(name)) busy.push(`${name} is idle+turn-alive`);
|
|
10507
10621
|
}
|
|
10508
10622
|
for (const name of this.activeTurnTokens.keys()) {
|
|
10509
10623
|
if (!this.agents.has(name)) busy.push(`${name} is turn-alive`);
|
|
@@ -11660,7 +11774,7 @@ export class AgentFramework {
|
|
|
11660
11774
|
// was killed while the call ran: the result reaches no one, but the
|
|
11661
11775
|
// call did finish.
|
|
11662
11776
|
this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result);
|
|
11663
|
-
scriptWaiter(event.result);
|
|
11777
|
+
scriptWaiter.resolve(event.result);
|
|
11664
11778
|
return;
|
|
11665
11779
|
}
|
|
11666
11780
|
|
|
@@ -12105,7 +12219,25 @@ export class AgentFramework {
|
|
|
12105
12219
|
this.streamBoundaryEnds.get(currentState.stream)?.('turn_ended');
|
|
12106
12220
|
}
|
|
12107
12221
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
12222
|
+
} else if ((overBudget || overPhysical) && this.providerAdmissionClosed) {
|
|
12223
|
+
// A stopping framework starts no continuation: the turn ends with
|
|
12224
|
+
// its stream, as stop() ends a live stream's turn.
|
|
12225
|
+
if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown');
|
|
12226
|
+
agent.cancelStream();
|
|
12108
12227
|
} else if (overBudget || overPhysical) {
|
|
12228
|
+
// The continuation is queued, and the stream cancelled, before the
|
|
12229
|
+
// boundary's usage report and the restart's trace, so a listener
|
|
12230
|
+
// that stops or quiesces the host on either finds the turn waiting
|
|
12231
|
+
// for its continuation, with nothing left of the stream to end.
|
|
12232
|
+
const continuation: InferenceRequest = {
|
|
12233
|
+
agentName: agent.name,
|
|
12234
|
+
reason: 'context_budget_restart',
|
|
12235
|
+
source: 'framework',
|
|
12236
|
+
timestamp: Date.now(),
|
|
12237
|
+
suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse,
|
|
12238
|
+
silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat,
|
|
12239
|
+
};
|
|
12240
|
+
this.pendingRequests.push(continuation);
|
|
12109
12241
|
// Context budget exceeded: break the stream, let compile() compress.
|
|
12110
12242
|
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
12111
12243
|
// membrane delivers `aborted` before the restart bumps streamId,
|
|
@@ -12113,13 +12245,19 @@ export class AgentFramework {
|
|
|
12113
12245
|
// a terminal failure — rejecting an ephemeral's promise mid-run
|
|
12114
12246
|
// and emitting a spurious inference:exhausted (same race shape as
|
|
12115
12247
|
// endTurn above).
|
|
12116
|
-
if (currentState.stream) {
|
|
12117
|
-
|
|
12118
|
-
|
|
12119
|
-
|
|
12120
|
-
|
|
12248
|
+
if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
12249
|
+
try {
|
|
12250
|
+
agent.cancelStream();
|
|
12251
|
+
} catch (error) {
|
|
12252
|
+
// The cancel failed and the stream is still live, so the turn
|
|
12253
|
+
// has no continuation: take it back out of the queue.
|
|
12254
|
+
this.pendingRequests = this.pendingRequests.filter((r) => r !== continuation);
|
|
12255
|
+
throw error;
|
|
12256
|
+
} finally {
|
|
12257
|
+
// Its rounds were billed, even when the cancel throws; the
|
|
12258
|
+
// restart opens a fresh stream with its own usage.
|
|
12259
|
+
if (currentState.stream) this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window');
|
|
12121
12260
|
}
|
|
12122
|
-
agent.cancelStream();
|
|
12123
12261
|
this.emitTrace({
|
|
12124
12262
|
type: 'inference:stream_restarted',
|
|
12125
12263
|
agentName: agent.name,
|
|
@@ -12130,14 +12268,6 @@ export class AgentFramework {
|
|
|
12130
12268
|
inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens,
|
|
12131
12269
|
budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0),
|
|
12132
12270
|
});
|
|
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
12271
|
} else if (currentState.stream) {
|
|
12142
12272
|
// What this round appends is in the next call's prompt. If that
|
|
12143
12273
|
// call reports no prompt, the next boundary projects it from the
|
|
@@ -12987,6 +13117,8 @@ export class AgentFramework {
|
|
|
12987
13117
|
*/
|
|
12988
13118
|
private disposeConversationAgent(agentName: string): void {
|
|
12989
13119
|
this.closingConversationAgents.delete(agentName);
|
|
13120
|
+
// A continuation of its turn still on its way goes with it.
|
|
13121
|
+
this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' });
|
|
12990
13122
|
const channelId = this.conversationAgentHomes.get(agentName);
|
|
12991
13123
|
const agent = this.agents.get(agentName);
|
|
12992
13124
|
// A fork stop() ends can still be running: its stream, its tool round.
|
|
@@ -13519,9 +13651,18 @@ export class AgentFramework {
|
|
|
13519
13651
|
recordReceipt: (record) => {
|
|
13520
13652
|
// Written now, not at the throttled flush: an acknowledged acceptance
|
|
13521
13653
|
// must not lose its receipt, the subject's birth, or (deferred) the
|
|
13522
|
-
// accepted work itself to a crash. A failure here fails the
|
|
13654
|
+
// accepted work itself to a crash. A failure here fails the answer:
|
|
13655
|
+
// the coalescer keeps the receipt, and the producer's retry writes it
|
|
13656
|
+
// again, with the subject's state as it is then. So a record whose
|
|
13657
|
+
// write failed leaves the list: a later write would carry it after
|
|
13658
|
+
// the batch it holds had moved on.
|
|
13523
13659
|
this.coalescingRecentReceipts.push(record);
|
|
13524
|
-
|
|
13660
|
+
try {
|
|
13661
|
+
this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts);
|
|
13662
|
+
} catch (error) {
|
|
13663
|
+
this.coalescingRecentReceipts.pop();
|
|
13664
|
+
throw error;
|
|
13665
|
+
}
|
|
13525
13666
|
},
|
|
13526
13667
|
// The acceptance of coalesced work (deferred included) is observed
|
|
13527
13668
|
// here, once; its later delivery reuses the frozen envelope.
|
|
@@ -13628,6 +13769,9 @@ export class AgentFramework {
|
|
|
13628
13769
|
this.store.setStateJson(COALESCING_RECENT_ID, []);
|
|
13629
13770
|
} catch (err) {
|
|
13630
13771
|
console.error('[coalescing] could not persist coalescing state:', err);
|
|
13772
|
+
// Still unsaved: the next persist, or the stop's flush, writes it. No
|
|
13773
|
+
// timer of its own, so a store that keeps failing isn't retried here.
|
|
13774
|
+
this.coalescingSnapshotDirty ??= snapshot;
|
|
13631
13775
|
}
|
|
13632
13776
|
}
|
|
13633
13777
|
|
|
@@ -14253,17 +14397,28 @@ export class AgentFramework {
|
|
|
14253
14397
|
return undefined;
|
|
14254
14398
|
}
|
|
14255
14399
|
|
|
14400
|
+
/** One scheduler pass, whose requests stay visible as on their way until
|
|
14401
|
+
* it takes their agent up (passRequests). */
|
|
14256
14402
|
private async processInferenceRequests(): Promise<void> {
|
|
14257
14403
|
if (this.pendingRequests.length === 0) {
|
|
14258
14404
|
return;
|
|
14259
14405
|
}
|
|
14406
|
+
const requestsByAgent = new Map<string, InferenceRequest[]>();
|
|
14407
|
+
this.passRequests?.add(requestsByAgent);
|
|
14408
|
+
try {
|
|
14409
|
+
await this.runInferencePass(requestsByAgent);
|
|
14410
|
+
} finally {
|
|
14411
|
+
this.passRequests?.delete(requestsByAgent);
|
|
14412
|
+
}
|
|
14413
|
+
}
|
|
14414
|
+
|
|
14415
|
+
private async runInferencePass(requestsByAgent: Map<string, InferenceRequest[]>): Promise<void> {
|
|
14260
14416
|
|
|
14261
14417
|
const STALE_REQUEST_MS = 30_000;
|
|
14262
14418
|
const now = Date.now();
|
|
14263
14419
|
const state = this.createFrameworkState();
|
|
14264
14420
|
|
|
14265
14421
|
// Group requests by agent
|
|
14266
|
-
const requestsByAgent = new Map<string, InferenceRequest[]>();
|
|
14267
14422
|
for (const req of this.pendingRequests) {
|
|
14268
14423
|
const existing = requestsByAgent.get(req.agentName) ?? [];
|
|
14269
14424
|
existing.push(req);
|
|
@@ -14276,6 +14431,13 @@ export class AgentFramework {
|
|
|
14276
14431
|
// Check each agent
|
|
14277
14432
|
for (const [agentName, groupedRequests] of requestsByAgent) {
|
|
14278
14433
|
let requests = groupedRequests;
|
|
14434
|
+
// Taken up now: from here to its start's turn token (or its hold or
|
|
14435
|
+
// park) nothing awaits, so the requests leave the pass's map, which
|
|
14436
|
+
// shows only what the pass still holds while it awaits a start.
|
|
14437
|
+
requestsByAgent.delete(agentName);
|
|
14438
|
+
// A drop while this pass awaited an earlier agent's start can have
|
|
14439
|
+
// taken every request of this agent's (dropContinuations).
|
|
14440
|
+
if (requests.length === 0) continue;
|
|
14279
14441
|
const agent = this.agents.get(agentName);
|
|
14280
14442
|
if (!agent) {
|
|
14281
14443
|
// Agent not found — request is orphaned. Emit warning and drop.
|
|
@@ -14288,6 +14450,10 @@ export class AgentFramework {
|
|
|
14288
14450
|
oldestRequestAge: now - oldest,
|
|
14289
14451
|
});
|
|
14290
14452
|
console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
|
|
14453
|
+
// A continuation among them leaves its turn to end here.
|
|
14454
|
+
if (requests.some((r) => resumesLogicalTurn(r.reason))) {
|
|
14455
|
+
this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' });
|
|
14456
|
+
}
|
|
14291
14457
|
continue;
|
|
14292
14458
|
}
|
|
14293
14459
|
|
|
@@ -14377,7 +14543,10 @@ export class AgentFramework {
|
|
|
14377
14543
|
requests.push(...passThrough);
|
|
14378
14544
|
}
|
|
14379
14545
|
|
|
14380
|
-
|
|
14546
|
+
// Turn-alive includes a continuation of the agent's turn still on its
|
|
14547
|
+
// way elsewhere (a park, another pass's hands): a fresh wake waits for
|
|
14548
|
+
// it, as for a turn holding its token.
|
|
14549
|
+
const turnAlive = !budgetRestart && this.hasTurn(agentName);
|
|
14381
14550
|
const providerGate = this.providerGates?.get(agentName);
|
|
14382
14551
|
// A primary can own provider admission while yielding to an already in-flight
|
|
14383
14552
|
// auxiliary call. Keep this agent's later wakes queued, but do not block the
|
|
@@ -14455,9 +14624,12 @@ export class AgentFramework {
|
|
|
14455
14624
|
`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
14456
14625
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
|
|
14457
14626
|
);
|
|
14458
|
-
// A
|
|
14459
|
-
// If policy drops
|
|
14460
|
-
|
|
14627
|
+
// A continuation inherits its predecessor's EventGate liveness and
|
|
14628
|
+
// logical turn. If policy drops it, no driveStream remains to end them,
|
|
14629
|
+
// so its turn ends here (endContinuedTurn).
|
|
14630
|
+
if (requests.some((r) => resumesLogicalTurn(r.reason))) {
|
|
14631
|
+
this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' });
|
|
14632
|
+
}
|
|
14461
14633
|
this.endPendingPrimary(agentName);
|
|
14462
14634
|
continue;
|
|
14463
14635
|
}
|
|
@@ -16912,7 +17084,12 @@ export class AgentFramework {
|
|
|
16912
17084
|
// Do not await inside processInferenceRequests: one slow auxiliary call for
|
|
16913
17085
|
// this resident must not block events or other residents. Admission stays
|
|
16914
17086
|
// owned and the continuation receives it after the in-flight call settles.
|
|
17087
|
+
const parkedContinuation = trigger !== undefined && resumesLogicalTurn(trigger.reason);
|
|
17088
|
+
if (parkedContinuation) this.parkedContinuations.add(trigger);
|
|
16915
17089
|
void this.waitForAuxiliaryIdle(agent.name).then(async () => {
|
|
17090
|
+
// A continuation dropped while it was parked left the set then, with
|
|
17091
|
+
// its admission given back.
|
|
17092
|
+
if (parkedContinuation && !this.parkedContinuations.delete(trigger)) return;
|
|
16916
17093
|
if (this.providerAdmissionClosed) {
|
|
16917
17094
|
this.releasePrimaryProviderGate(agent.name);
|
|
16918
17095
|
return;
|
|
@@ -17072,6 +17249,93 @@ export class AgentFramework {
|
|
|
17072
17249
|
}
|
|
17073
17250
|
}
|
|
17074
17251
|
|
|
17252
|
+
/**
|
|
17253
|
+
* Drop every continuation of `agentName`'s turn still on its way, wherever
|
|
17254
|
+
* it waits: the queue, a provider hold (which, left empty, then releases
|
|
17255
|
+
* with nothing to run), a park on admission (whose admission it gives
|
|
17256
|
+
* back), or a scheduler pass's hands (which then never reach it). If one
|
|
17257
|
+
* was there, the turn ends (endContinuedTurn), unless `drop` is null: the
|
|
17258
|
+
* caller has ended the turn already.
|
|
17259
|
+
*/
|
|
17260
|
+
private dropContinuations(agentName: string, drop: ContinuationDrop | null): void {
|
|
17261
|
+
const isContinuation = (r: InferenceRequest): boolean => r.agentName === agentName && resumesLogicalTurn(r.reason);
|
|
17262
|
+
let dropped = false;
|
|
17263
|
+
const take = (requests: InferenceRequest[]): InferenceRequest[] => {
|
|
17264
|
+
const left = requests.filter((r) => !isContinuation(r));
|
|
17265
|
+
if (left.length < requests.length) dropped = true;
|
|
17266
|
+
return left;
|
|
17267
|
+
};
|
|
17268
|
+
this.pendingRequests = take(this.pendingRequests);
|
|
17269
|
+
const hold = this.providerAccelerationCooldowns.get(agentName);
|
|
17270
|
+
if (hold) {
|
|
17271
|
+
const held = hold.heldRequests.length;
|
|
17272
|
+
hold.heldRequests = take(hold.heldRequests);
|
|
17273
|
+
if (held > 0 && hold.heldRequests.length === 0) hold.emptiedByDrop = true;
|
|
17274
|
+
}
|
|
17275
|
+
for (const parked of [...this.parkedContinuations]) {
|
|
17276
|
+
if (!isContinuation(parked)) continue;
|
|
17277
|
+
this.parkedContinuations.delete(parked);
|
|
17278
|
+
this.releasePrimaryProviderGate(agentName);
|
|
17279
|
+
dropped = true;
|
|
17280
|
+
}
|
|
17281
|
+
for (const pass of this.passRequests) {
|
|
17282
|
+
const requests = pass.get(agentName);
|
|
17283
|
+
if (!requests) continue;
|
|
17284
|
+
const left = take(requests);
|
|
17285
|
+
requests.length = 0;
|
|
17286
|
+
requests.push(...left);
|
|
17287
|
+
}
|
|
17288
|
+
if (!dropped || !drop) return;
|
|
17289
|
+
// A primary a provider hold left pending for what it released won't run
|
|
17290
|
+
// now, when nothing else of the agent's waits (endPendingPrimary).
|
|
17291
|
+
if (!this.pendingRequests.some((r) => r.agentName === agentName) && !this.providerAccelerationCooldowns.get(agentName)?.heldRequests.length) {
|
|
17292
|
+
this.endPendingPrimary(agentName);
|
|
17293
|
+
}
|
|
17294
|
+
this.endContinuedTurn(agentName, drop);
|
|
17295
|
+
}
|
|
17296
|
+
|
|
17297
|
+
/**
|
|
17298
|
+
* End a turn whose continuation won't run. The frame that handed the turn
|
|
17299
|
+
* to it kept the turn open for it: its logical turn, the EventGate's
|
|
17300
|
+
* window, the tool-result guard's batch and recovery, its typing. An
|
|
17301
|
+
* abandon or a stop of a registered agent settles the turn as it settles a
|
|
17302
|
+
* turn's start (endStoppedStart: one inference:aborted, the turn settled,
|
|
17303
|
+
* the gate released). Any other drop ends it as a start dropped before it
|
|
17304
|
+
* sends anything ends. That frame, if it hasn't let go of its token yet,
|
|
17305
|
+
* ends on its own and settles nothing. What was deferred for the agent
|
|
17306
|
+
* lands now, or at that frame's end.
|
|
17307
|
+
*/
|
|
17308
|
+
private endContinuedTurn(agentName: string, drop: ContinuationDrop): void {
|
|
17309
|
+
console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`);
|
|
17310
|
+
const agent = this.agents.get(agentName);
|
|
17311
|
+
const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now();
|
|
17312
|
+
if (agent && drop.kind !== 'dropped') {
|
|
17313
|
+
const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName);
|
|
17314
|
+
this.endStoppedStart(agent, { kind: drop.kind }, routing, startedAt, undefined, false);
|
|
17315
|
+
} else {
|
|
17316
|
+
const owner = this.activeTurnTokens.get(agentName);
|
|
17317
|
+
this.abortDroppedTurn(agentName, owner ?? -1, drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind, startedAt);
|
|
17318
|
+
this.channelRegistry?.stopTyping();
|
|
17319
|
+
if (agent) {
|
|
17320
|
+
agent.toolResultGuard.recovering = false;
|
|
17321
|
+
if (agent.toolResultGuard.hasPending) {
|
|
17322
|
+
try {
|
|
17323
|
+
agent.toolResultGuard.abandon('aborted');
|
|
17324
|
+
} catch (error) {
|
|
17325
|
+
console.error(`[tool-result-guard] agent=${agentName} failed to record aborted batch:`, error);
|
|
17326
|
+
}
|
|
17327
|
+
}
|
|
17328
|
+
}
|
|
17329
|
+
const routing = this.openLogical.get(agentName) ?? this.turnRouting.get(agentName);
|
|
17330
|
+
if (routing) this.endDroppedTurn(agentName, routing);
|
|
17331
|
+
this.eventGate?.onInferenceEnded(agentName);
|
|
17332
|
+
}
|
|
17333
|
+
if (!this.activeTurnTokens.has(agentName) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agentName)) {
|
|
17334
|
+
this.readdDrained(this.drainDeferredFor(agentName), 'continuation-drop flush');
|
|
17335
|
+
this.ackDeferredWrites();
|
|
17336
|
+
}
|
|
17337
|
+
}
|
|
17338
|
+
|
|
17075
17339
|
/**
|
|
17076
17340
|
* End a turn that beginAgentTurn drops before it sends anything. The turn
|
|
17077
17341
|
* emitted inference:started, and listeners that bracket a turn, such as a
|
|
@@ -17122,7 +17386,7 @@ export class AgentFramework {
|
|
|
17122
17386
|
*/
|
|
17123
17387
|
private endStoppedStart(
|
|
17124
17388
|
agent: Agent,
|
|
17125
|
-
stop: { kind: 'quiesce_abandoned' } | { kind: 'caller'; reason?: string },
|
|
17389
|
+
stop: { kind: 'quiesce_abandoned' | 'shutdown' } | { kind: 'caller'; reason?: string },
|
|
17126
17390
|
routing: TurnRouting,
|
|
17127
17391
|
turnStartedAt: number,
|
|
17128
17392
|
compiledRequest: NormalizedRequest | undefined,
|
|
@@ -17132,7 +17396,7 @@ export class AgentFramework {
|
|
|
17132
17396
|
* so the note says it was stopped before it finished. */
|
|
17133
17397
|
opensTurn: boolean,
|
|
17134
17398
|
): 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`);
|
|
17399
|
+
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
17400
|
this.channelRegistry?.stopTyping();
|
|
17137
17401
|
// A start that continues a turn (a budget or guard restart) follows a
|
|
17138
17402
|
// frame that kept the turn's tool-result batch, and the guard's recovery,
|
|
@@ -17160,7 +17424,7 @@ export class AgentFramework {
|
|
|
17160
17424
|
if (stop.kind === 'caller') {
|
|
17161
17425
|
this.settleDeliberateCancel({ ...common, reason: stop.reason ?? 'user', before: opensTurn ? 'sending' : 'finishing' });
|
|
17162
17426
|
} else {
|
|
17163
|
-
this.settleFrameworkCancel({ ...common, kind:
|
|
17427
|
+
this.settleFrameworkCancel({ ...common, kind: stop.kind });
|
|
17164
17428
|
}
|
|
17165
17429
|
const last = this.lastInferenceAt.get(agent.name);
|
|
17166
17430
|
if (last?.startedAt === turnStartedAt) this.lastInferenceAt.set(agent.name, { ...last, endedAt: Date.now() });
|
|
@@ -17889,8 +18153,8 @@ export class AgentFramework {
|
|
|
17889
18153
|
if (turnEnded) this.recordProviderAccelerationRecovery(agent, compiledRequest, end);
|
|
17890
18154
|
// The accounting writes to the store, and a write that fails must not
|
|
17891
18155
|
// 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.
|
|
18156
|
+
// end, or a restart's trace (its cancel and its request come before
|
|
18157
|
+
// this call), and then the event's dispatch to modules.
|
|
17894
18158
|
try {
|
|
17895
18159
|
if (turnEnded) {
|
|
17896
18160
|
completeTurn(latestUsage, durationMs);
|
|
@@ -19891,7 +20155,11 @@ export class AgentFramework {
|
|
|
19891
20155
|
// its ids when a successor turn has already taken the agent. A fork or
|
|
19892
20156
|
// run deregistered while the host is quiesced leaves its queue to its
|
|
19893
20157
|
// window's release at resume (drainDeferredFor).
|
|
19894
|
-
|
|
20158
|
+
// A frame that handed its turn to a continuation since dropped
|
|
20159
|
+
// (endContinuedTurn, which couldn't land it while this frame held the
|
|
20160
|
+
// token) lands it too: the turn is over.
|
|
20161
|
+
const handedOffTurnOver = preserveEventGateForSuccessor && !this.hasTurn(agent.name);
|
|
20162
|
+
if ((frameReachedTerminal || handedOffTurnOver) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) {
|
|
19895
20163
|
this.readdDrained(this.drainDeferredFor(agent.name), 'turn-end flush');
|
|
19896
20164
|
this.ackDeferredWrites();
|
|
19897
20165
|
}
|
|
@@ -20007,7 +20275,7 @@ export class AgentFramework {
|
|
|
20007
20275
|
`puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`,
|
|
20008
20276
|
);
|
|
20009
20277
|
}
|
|
20010
|
-
if (agent.state.status !== 'idle' || this.
|
|
20278
|
+
if (agent.state.status !== 'idle' || this.hasTurn(agentName)) {
|
|
20011
20279
|
const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status;
|
|
20012
20280
|
throw new Error(
|
|
20013
20281
|
`puppet refused: agent ${agentName} is ${shown} (requires idle — ` +
|
|
@@ -20616,7 +20884,7 @@ export class AgentFramework {
|
|
|
20616
20884
|
for (const tc of toolResults) {
|
|
20617
20885
|
const { text, shown, stored } = alone.get(tc.id)!;
|
|
20618
20886
|
spilled.set(tc.id, byShare
|
|
20619
|
-
? await this.spillOrTruncate(text, share!.cap, `${dateLabel}-${tc.id}`, shown, share!.bound, stored.written)
|
|
20887
|
+
? await this.spillOrTruncate(text, share!.cap, `${dateLabel}-${tc.id}`, shown, share!.bound, stored.written, { readCap: maxChars! })
|
|
20620
20888
|
: stored);
|
|
20621
20889
|
}
|
|
20622
20890
|
const blocks: ContentBlock[] = toolResults.map(tc => ({
|
|
@@ -20832,6 +21100,11 @@ export class AgentFramework {
|
|
|
20832
21100
|
/** The spill file an earlier cut of this text wrote: reused, not written
|
|
20833
21101
|
* again. Undefined when none was cut. */
|
|
20834
21102
|
written?: SpillWrite,
|
|
21103
|
+
/** Set where `cap` and `bound` are this text's share of one turn's
|
|
21104
|
+
* bound (buildStoredToolResultContent): `readCap` is the inline cap,
|
|
21105
|
+
* which a later read of the file comes back under alone in its own turn
|
|
21106
|
+
* and its paging advice is sized to. */
|
|
21107
|
+
share?: { readCap: number },
|
|
20835
21108
|
): Promise<{ text: string; filePath: string | null; written?: SpillWrite }> {
|
|
20836
21109
|
if (!cap || content.length <= cap) return { text: content, filePath: null };
|
|
20837
21110
|
// The stored text is the only place save_recent_image discovers tool
|
|
@@ -20841,45 +21114,67 @@ export class AgentFramework {
|
|
|
20841
21114
|
// newest slots past the cut that the live copy showed are re-appended
|
|
20842
21115
|
// after the notice. Otherwise the live copy is this text, and nothing
|
|
20843
21116
|
// past the cut is kept (#403). The stored copy, notice and kept slots
|
|
20844
|
-
// included, fits `bound`, the most a reader's strategy keeps of it,
|
|
20845
|
-
// no reader's cut takes the
|
|
20846
|
-
// spill notices name the head
|
|
20847
|
-
|
|
21117
|
+
// included, fits `bound`, the most a reader's strategy keeps of it,
|
|
21118
|
+
// where `bound` leaves the notice its room, so no reader's cut takes the
|
|
21119
|
+
// notice (cutStoredToolResultToFit), and the spill notices name the head
|
|
21120
|
+
// they kept. Under a share too small for that, a cut keeps no head and
|
|
21121
|
+
// stores its notice alone, and a text no longer than that notice is
|
|
21122
|
+
// stored whole, with no file written: each result at its least.
|
|
21123
|
+
const readCap = share?.readCap ?? cap;
|
|
21124
|
+
const cut = cutStoredToolResultToFit(content, cap, shown, bound,
|
|
21125
|
+
share ? this.spillNoticeLength(label, content, shown, bound, readCap) : undefined);
|
|
21126
|
+
if (cut.head === content) return { text: content, filePath: null };
|
|
20848
21127
|
const { head } = cut;
|
|
20849
21128
|
const kept = formatKeptImageSlots(cut);
|
|
20850
21129
|
|
|
20851
21130
|
if (written === undefined) written = await this.writeToolResultFile(label, content);
|
|
21131
|
+
const notice = this.spillNotice(head.length, content, shown, bound, readCap, written);
|
|
21132
|
+
// The notice was measured as a written file's reads. A write that failed
|
|
21133
|
+
// keeps nothing, and its notice, naming the failure, can be longer: under
|
|
21134
|
+
// a share, a cut that keeps no head and would store no less than the
|
|
21135
|
+
// text is stored as the text itself, where the text holds no image slot.
|
|
21136
|
+
// One that does is stored as it was measured, cut, since save_recent_image
|
|
21137
|
+
// counted it so before the write.
|
|
21138
|
+
if (share && head.length === 0 && notice.length >= content.length && parseImagePlaceholders(content).length === 0) {
|
|
21139
|
+
return { text: content, filePath: null, written };
|
|
21140
|
+
}
|
|
21141
|
+
return { text: head + notice + kept, filePath: written && written.error === undefined ? written.path : null, written };
|
|
21142
|
+
}
|
|
21143
|
+
|
|
21144
|
+
/**
|
|
21145
|
+
* The notice a cut copy carries after its head (spillOrTruncate): the file
|
|
21146
|
+
* holding the whole result and how to read it back, or why there is none.
|
|
21147
|
+
*/
|
|
21148
|
+
private spillNotice(headLength: number, content: string, shown: ShownRanges | null, bound: number | undefined,
|
|
21149
|
+
readCap: number, written: SpillWrite): string {
|
|
20852
21150
|
if (written && written.error === undefined) {
|
|
20853
|
-
const
|
|
20854
|
-
return {
|
|
20855
|
-
|
|
20856
|
-
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; full content: workspace file ${path}`
|
|
20857
|
-
+ `. ${this.spillPageAdvice(path, cap, bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > head.length)}]`
|
|
20858
|
-
+ kept,
|
|
20859
|
-
filePath: path,
|
|
20860
|
-
written,
|
|
20861
|
-
};
|
|
21151
|
+
const canRaise = bound === undefined || cutStoredToolResultToFit(content, bound, shown, bound).head.length > headLength;
|
|
21152
|
+
return `\n\n[truncated — showing ${headLength} of ${content.length} chars; full content: workspace file ${written.path}`
|
|
21153
|
+
+ `. ${this.spillPageAdvice(written.path, readCap, canRaise)}]`;
|
|
20862
21154
|
}
|
|
20863
21155
|
if (written) {
|
|
20864
21156
|
// A workspace was there and the write FAILED — say that, loudly and
|
|
20865
21157
|
// distinctly. Telling the agent "no writable workspace" would teach
|
|
20866
21158
|
// them their residence lacks a capability it actually has.
|
|
20867
|
-
return {
|
|
20868
|
-
|
|
20869
|
-
+ `\n\n[truncated — showing ${head.length} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
|
|
20870
|
-
+ ` (${written.error}); content over the cap was not retained]`
|
|
20871
|
-
+ kept,
|
|
20872
|
-
filePath: null,
|
|
20873
|
-
written,
|
|
20874
|
-
};
|
|
21159
|
+
return `\n\n[truncated — showing ${headLength} of ${content.length} chars; spill to workspace file ${written.path} FAILED`
|
|
21160
|
+
+ ` (${written.error}); content over the cap was not retained]`;
|
|
20875
21161
|
}
|
|
20876
|
-
return
|
|
20877
|
-
|
|
20878
|
-
|
|
20879
|
-
|
|
20880
|
-
|
|
20881
|
-
|
|
20882
|
-
|
|
21162
|
+
return '\n\n[truncated — original was ' + content.length + ' chars; no writable workspace mount without an include, full content not retained]';
|
|
21163
|
+
}
|
|
21164
|
+
|
|
21165
|
+
/**
|
|
21166
|
+
* The length of the notice a cut of `content` that keeps no head would
|
|
21167
|
+
* carry, before any file is written, as it reads once its file is
|
|
21168
|
+
* (toolResultFilePlace, where any earlier cut of the text wrote it too), so
|
|
21169
|
+
* the cut and save_recent_image's count of the result before it measure
|
|
21170
|
+
* alike. For keeping whole, under a share, a text no longer than its notice
|
|
21171
|
+
* (cutStoredToolResultToFit). A write that fails carries a notice naming
|
|
21172
|
+
* the failure instead (spillOrTruncate).
|
|
21173
|
+
*/
|
|
21174
|
+
private spillNoticeLength(label: string, content: string, shown: ShownRanges | null, bound: number | undefined,
|
|
21175
|
+
readCap: number): number {
|
|
21176
|
+
const place = this.toolResultFilePlace(label);
|
|
21177
|
+
return this.spillNotice(0, content, shown, bound, readCap, place ? { path: place.path } : null).length;
|
|
20883
21178
|
}
|
|
20884
21179
|
|
|
20885
21180
|
/**
|
|
@@ -20931,9 +21226,10 @@ export class AgentFramework {
|
|
|
20931
21226
|
if (!roundCalls.some((c) => c.name === counted.name && c.cause === counted.cause && c.refused === counted.refused)) roundCalls.push(counted);
|
|
20932
21227
|
}
|
|
20933
21228
|
if (refusal !== undefined) return refused(refusal);
|
|
21229
|
+
const route = { toServer: false };
|
|
20934
21230
|
const result = await this.dispatchScriptToolCall(
|
|
20935
21231
|
agentName, toolName, input as Record<string, unknown>,
|
|
20936
|
-
context.origin as ScriptCallOrigin | undefined, context.watch?.turnId,
|
|
21232
|
+
context.origin as ScriptCallOrigin | undefined, context.watch?.turnId, route,
|
|
20937
21233
|
);
|
|
20938
21234
|
// Deferred: applied to the result of the code_execution call watching
|
|
20939
21235
|
// the script — ending the turn mid-script would cancel the stream while
|
|
@@ -20944,7 +21240,7 @@ export class AgentFramework {
|
|
|
20944
21240
|
const watching = context.watching ? context.watching() : context.watch;
|
|
20945
21241
|
if (result.endTurn && watching) watching.endTurn = true;
|
|
20946
21242
|
if (result.refused) return refused(result.error ?? `${toolName} was refused; nothing was done`);
|
|
20947
|
-
const answer = await this.scriptResultAnswer(result, toolName);
|
|
21243
|
+
const answer = await this.scriptResultAnswer(result, toolName, route.toServer);
|
|
20948
21244
|
return SCRIPT_CALL_NO_RESULT.has(result) ? { ...answer, held: true } : answer;
|
|
20949
21245
|
}
|
|
20950
21246
|
|
|
@@ -20953,14 +21249,18 @@ export class AgentFramework {
|
|
|
20953
21249
|
* isError }`, one shape for every tool. `content` is
|
|
20954
21250
|
* what the model would read: a structured result's blocks as the dispatch
|
|
20955
21251
|
* gave them (the direct reading, with RFC-005's treatment of an MCPL peer's
|
|
20956
|
-
* blocks
|
|
20957
|
-
*
|
|
21252
|
+
* blocks there, bound to the server that sent them), a module's under
|
|
21253
|
+
* history's reading of RFC-005 at every depth (shownToScript), and
|
|
21254
|
+
* otherwise one text block of the model's text, which for a failure is its
|
|
21255
|
+
* error. A module's is any result whose call the dispatch didn't send to
|
|
21256
|
+
* a server (`toServer`, from the call's waiter). `structuredContent` is the
|
|
21257
|
+
* tool's own structured
|
|
20958
21258
|
* content where it sent one, `false`, `0` and `null` included, else null.
|
|
20959
21259
|
* A result that can't be converted to JSON (a cyclic object, a BigInt),
|
|
20960
21260
|
* which the dispatch's own check makes unlikely here, is a failure naming
|
|
20961
21261
|
* the tool and saying the call itself completed.
|
|
20962
21262
|
*/
|
|
20963
|
-
private async scriptResultAnswer(result: ToolResult, toolName: string): Promise<ToolAnswer> {
|
|
21263
|
+
private async scriptResultAnswer(result: ToolResult, toolName: string, toServer: boolean): Promise<ToolAnswer> {
|
|
20964
21264
|
const structured = Object.prototype.hasOwnProperty.call(result, 'structured');
|
|
20965
21265
|
const isError = result.isError === true;
|
|
20966
21266
|
// RFC-005 "eagerly lazy": a script receiving a result is the strongest
|
|
@@ -20977,7 +21277,10 @@ export class AgentFramework {
|
|
|
20977
21277
|
// which isn't fetched.
|
|
20978
21278
|
// Whenever the result's blocks reach the script: a structured failure's
|
|
20979
21279
|
// included, as a module's error can carry content.
|
|
20980
|
-
|
|
21280
|
+
// Not a server's result: a module's, whose blocks no dispatch has given
|
|
21281
|
+
// RFC-005's treatment.
|
|
21282
|
+
const fromModule = !toServer;
|
|
21283
|
+
if ((!isError || structured) && Array.isArray(result.data) && fromModule) {
|
|
20981
21284
|
const fetcher = this.getReferenceFetcher();
|
|
20982
21285
|
for (const block of result.data) {
|
|
20983
21286
|
const c = classifyBlock(block);
|
|
@@ -21004,6 +21307,20 @@ export class AgentFramework {
|
|
|
21004
21307
|
// no blocks reads as its error text.
|
|
21005
21308
|
if (structured && (!isError || (Array.isArray(result.data) && result.data.length > 0))) {
|
|
21006
21309
|
content = Array.isArray(result.data) ? result.data : [];
|
|
21310
|
+
// A module's blocks, such as an MCPL peer's it got from ctx.callTool
|
|
21311
|
+
// and returns whole, are read as history reads a module's data: what
|
|
21312
|
+
// RFC-005 governs, at any depth, as the script lane shows it, and
|
|
21313
|
+
// anything else as sent. What a script prints comes back to the model,
|
|
21314
|
+
// so no uri the model's lanes withhold reaches it. What this gives is
|
|
21315
|
+
// a stub, a notice or the value itself, so a module that returns
|
|
21316
|
+
// blocks it already gave has them left as they are.
|
|
21317
|
+
if (fromModule) {
|
|
21318
|
+
try {
|
|
21319
|
+
content = JSON.parse(JSON.stringify(content, (_key, value: unknown) => shownToScript(value))) as unknown[];
|
|
21320
|
+
} catch (err) {
|
|
21321
|
+
return failed(err);
|
|
21322
|
+
}
|
|
21323
|
+
}
|
|
21007
21324
|
} else {
|
|
21008
21325
|
// As in history, a reference is its stub at any depth and inline data
|
|
21009
21326
|
// claiming a uri or a disposition is withheld (RFC-005).
|
|
@@ -21040,7 +21357,9 @@ export class AgentFramework {
|
|
|
21040
21357
|
* RFC-007: with an `origin`, observers see the call as they see a model
|
|
21041
21358
|
* call (register, dispatch with its refusal sites, open; the terminal at
|
|
21042
21359
|
* the waiter intercept), under the inner tool's own name and class.
|
|
21043
|
-
* `turnId` is the turn the call was made in (dispatchToolCall).
|
|
21360
|
+
* `turnId` is the turn the call was made in (dispatchToolCall). `route` is
|
|
21361
|
+
* the waiter's: the dispatch sets `toServer` when it sends the call to a
|
|
21362
|
+
* server.
|
|
21044
21363
|
*/
|
|
21045
21364
|
private dispatchScriptToolCall(
|
|
21046
21365
|
agentName: string,
|
|
@@ -21048,6 +21367,7 @@ export class AgentFramework {
|
|
|
21048
21367
|
args: Record<string, unknown>,
|
|
21049
21368
|
origin?: ScriptCallOrigin,
|
|
21050
21369
|
turnId?: number,
|
|
21370
|
+
route: { toServer: boolean } = { toServer: false },
|
|
21051
21371
|
): Promise<ToolResult> {
|
|
21052
21372
|
return new Promise<ToolResult>((resolve) => {
|
|
21053
21373
|
const callId = `pytc-${randomUUID()}`;
|
|
@@ -21075,9 +21395,12 @@ export class AgentFramework {
|
|
|
21075
21395
|
}, safetyMs);
|
|
21076
21396
|
safety.unref?.();
|
|
21077
21397
|
|
|
21078
|
-
this.scriptToolWaiters.set(callId,
|
|
21079
|
-
|
|
21080
|
-
|
|
21398
|
+
this.scriptToolWaiters.set(callId, {
|
|
21399
|
+
resolve: (result) => {
|
|
21400
|
+
clearTimeout(safety);
|
|
21401
|
+
resolve(result);
|
|
21402
|
+
},
|
|
21403
|
+
route,
|
|
21081
21404
|
});
|
|
21082
21405
|
|
|
21083
21406
|
const call = { id: callId, name: toolName, input: args };
|
|
@@ -21636,6 +21959,10 @@ export class AgentFramework {
|
|
|
21636
21959
|
// Route MCPL tool calls to the appropriate server via prefix map
|
|
21637
21960
|
const mcplMatch = this.resolveMcplTool(enrichedCall.name);
|
|
21638
21961
|
if (mcplMatch && this.mcplServerRegistry) {
|
|
21962
|
+
// A script's call: its waiter keeps that it went to a server, for the
|
|
21963
|
+
// script lane's exit (scriptToolWaiters).
|
|
21964
|
+
const scriptWaiter = this.scriptToolWaiters.get(enrichedCall.id);
|
|
21965
|
+
if (scriptWaiter) scriptWaiter.route.toServer = true;
|
|
21639
21966
|
this.dispatchMcplToolCall(agentName, enrichedCall, mcplMatch[0], mcplMatch[1]);
|
|
21640
21967
|
return;
|
|
21641
21968
|
}
|
|
@@ -27163,7 +27490,9 @@ export class AgentFramework {
|
|
|
27163
27490
|
// generated JSON) stands in for what the server sent. An MCPL peer's
|
|
27164
27491
|
// content blocks are the exception: what a script prints comes back to
|
|
27165
27492
|
// the model, so each gets the treatment RFC-005 gives it on every lane
|
|
27166
|
-
// the model reads
|
|
27493
|
+
// the model reads, here, where the server that sent them is known. A
|
|
27494
|
+
// module's blocks get it at the script lane's exit (scriptResultAnswer).
|
|
27495
|
+
// A standard peer's blocks reach the script as sent.
|
|
27167
27496
|
const forScript = this.scriptToolWaiters?.has(call.id) ?? false;
|
|
27168
27497
|
const reading = async (result: { content?: unknown; isError?: boolean; structuredContent?: unknown }, mcpl: boolean): Promise<ToolResult> => {
|
|
27169
27498
|
if (!forScript || !Object.prototype.hasOwnProperty.call(result, 'structuredContent')) {
|
|
@@ -28373,11 +28702,16 @@ export class AgentFramework {
|
|
|
28373
28702
|
// the round's calls, finished or not.
|
|
28374
28703
|
const { cap, bound } = this.resolveToolResultInlineCap(agent);
|
|
28375
28704
|
const share = toolResultShare(cap, bound, state.pending.size + state.completed.length);
|
|
28705
|
+
const dateLabel = new Date().toISOString().slice(0, 10);
|
|
28376
28706
|
for (let i = state.completed.length - 1; i >= 0 && collecting(); i--) {
|
|
28377
28707
|
const tc = state.completed[i]!;
|
|
28378
28708
|
const { text, shown } = this.storedToolResultText(agentName, tc, cap);
|
|
28379
28709
|
const alone = storedImageSlots(text, cap, shown, bound);
|
|
28380
|
-
|
|
28710
|
+
// By its share, with the notice the commit's cut would carry, so a
|
|
28711
|
+
// result no longer than its notice counts as kept whole, as the
|
|
28712
|
+
// commit stores it.
|
|
28713
|
+
const byShare = storedImageSlots(text, share.cap, shown, share.bound,
|
|
28714
|
+
this.spillNoticeLength(`${dateLabel}-${tc.id}`, text, shown, share.bound, cap));
|
|
28381
28715
|
if (alone.length !== byShare.length || alone.some((slot, j) => slot.text !== byShare[j]!.text)) {
|
|
28382
28716
|
this.toolRoundsByShare.add(state.completed);
|
|
28383
28717
|
}
|