@aragon-agent/cli 0.6.38 → 0.6.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +263 -0
- package/README.md +544 -11
- package/dist/agent/controller.d.ts +245 -10
- package/dist/agent/controller.js +367 -22
- package/dist/agent/model-profile-settings.js +12 -1
- package/dist/agent/plan-prompt.d.ts +1 -3
- package/dist/agent/plan-prompt.js +13 -7
- package/dist/agent/reducer.d.ts +70 -2
- package/dist/agent/reducer.js +59 -6
- package/dist/agent/skill-triage-prompt.d.ts +42 -0
- package/dist/agent/skill-triage-prompt.js +74 -0
- package/dist/agent/system-prompt.d.ts +28 -2
- package/dist/agent/system-prompt.js +12 -4
- package/dist/cli.js +106 -32
- package/dist/commands/builtins.js +24 -3
- package/dist/commands/keys-command.d.ts +23 -0
- package/dist/commands/keys-command.js +38 -0
- package/dist/commands/registry.d.ts +39 -0
- package/dist/commands/skills.js +86 -1
- package/dist/commands/subagent-command.d.ts +25 -0
- package/dist/commands/subagent-command.js +242 -0
- package/dist/commands/task-command.d.ts +39 -0
- package/dist/commands/task-command.js +102 -0
- package/dist/compaction/command.js +16 -3
- package/dist/compaction/compactor.d.ts +25 -0
- package/dist/compaction/compactor.js +111 -10
- package/dist/compaction/digest.d.ts +24 -1
- package/dist/compaction/digest.js +139 -17
- package/dist/compaction/limits.d.ts +21 -0
- package/dist/compaction/limits.js +21 -0
- package/dist/compaction/reason-text.d.ts +36 -0
- package/dist/compaction/reason-text.js +78 -0
- package/dist/compaction/types.d.ts +82 -14
- package/dist/compaction/wiring.d.ts +13 -8
- package/dist/compaction/wiring.js +1 -0
- package/dist/config/cli-commands.d.ts +23 -0
- package/dist/config/cli-commands.js +36 -0
- package/dist/config/env.js +17 -4
- package/dist/config/load.js +55 -3
- package/dist/config/schema.d.ts +125 -10
- package/dist/config/schema.js +138 -14
- package/dist/config/store.js +23 -3
- package/dist/diagnostics/doctor.d.ts +9 -0
- package/dist/diagnostics/doctor.js +34 -0
- package/dist/diagnostics/info.d.ts +71 -0
- package/dist/diagnostics/info.js +91 -5
- package/dist/exec/bridge/caller-tools.d.ts +96 -0
- package/dist/exec/bridge/caller-tools.js +213 -0
- package/dist/exec/bridge/decisions.d.ts +78 -0
- package/dist/exec/bridge/decisions.js +116 -0
- package/dist/exec/bridge/human-answers.d.ts +38 -0
- package/dist/exec/bridge/human-answers.js +164 -0
- package/dist/exec/bridge/limits.d.ts +68 -0
- package/dist/exec/bridge/limits.js +68 -0
- package/dist/exec/bridge/ndjson.d.ts +17 -0
- package/dist/exec/bridge/ndjson.js +83 -0
- package/dist/exec/bridge/pending.d.ts +70 -0
- package/dist/exec/bridge/pending.js +132 -0
- package/dist/exec/bridge/protocol.d.ts +346 -0
- package/dist/exec/bridge/protocol.js +256 -0
- package/dist/exec/bridge/server.d.ts +142 -0
- package/dist/exec/bridge/server.js +502 -0
- package/dist/exec/bridge/source.d.ts +58 -0
- package/dist/exec/bridge/source.js +145 -0
- package/dist/exec/bridge/writer.d.ts +26 -0
- package/dist/exec/bridge/writer.js +156 -0
- package/dist/exec/cli-commands.js +30 -2
- package/dist/exec/emitter.d.ts +16 -2
- package/dist/exec/emitter.js +26 -6
- package/dist/exec/events.d.ts +465 -10
- package/dist/exec/events.js +47 -1
- package/dist/exec/hooks/dispatch.d.ts +76 -0
- package/dist/exec/hooks/dispatch.js +169 -0
- package/dist/exec/hooks/index.d.ts +83 -0
- package/dist/exec/hooks/index.js +205 -0
- package/dist/exec/hooks/limits.d.ts +56 -0
- package/dist/exec/hooks/limits.js +49 -0
- package/dist/exec/hooks/registry.d.ts +49 -0
- package/dist/exec/hooks/registry.js +91 -0
- package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
- package/dist/exec/hooks/tool-wrapper.js +154 -0
- package/dist/exec/hooks/turn.d.ts +61 -0
- package/dist/exec/hooks/turn.js +59 -0
- package/dist/exec/hooks/validate.d.ts +85 -0
- package/dist/exec/hooks/validate.js +310 -0
- package/dist/exec/index.d.ts +130 -4
- package/dist/exec/index.js +501 -10
- package/dist/exec/options.d.ts +124 -2
- package/dist/exec/options.js +413 -6
- package/dist/exec/permission.d.ts +9 -1
- package/dist/exec/permission.js +7 -1
- package/dist/exec/request/limits.d.ts +51 -0
- package/dist/exec/request/limits.js +49 -0
- package/dist/exec/request/scope.d.ts +106 -0
- package/dist/exec/request/scope.js +149 -0
- package/dist/exec/result/limits.d.ts +74 -0
- package/dist/exec/result/limits.js +72 -0
- package/dist/exec/result/prompt.d.ts +28 -0
- package/dist/exec/result/prompt.js +74 -0
- package/dist/exec/result/schema.d.ts +99 -0
- package/dist/exec/result/schema.js +464 -0
- package/dist/exec/result/sink.d.ts +56 -0
- package/dist/exec/result/sink.js +50 -0
- package/dist/exec/result/tool.d.ts +37 -0
- package/dist/exec/result/tool.js +118 -0
- package/dist/exec/runner.d.ts +395 -1
- package/dist/exec/runner.js +860 -8
- package/dist/exec/threads/aggregate.d.ts +34 -0
- package/dist/exec/threads/aggregate.js +30 -0
- package/dist/exec/threads/capacity.d.ts +23 -0
- package/dist/exec/threads/capacity.js +38 -0
- package/dist/exec/threads/dispatch-source.d.ts +60 -0
- package/dist/exec/threads/dispatch-source.js +106 -0
- package/dist/exec/threads/execute-threads.d.ts +53 -0
- package/dist/exec/threads/execute-threads.js +234 -0
- package/dist/exec/threads/gate.d.ts +42 -0
- package/dist/exec/threads/gate.js +81 -0
- package/dist/exec/threads/identity.d.ts +36 -0
- package/dist/exec/threads/identity.js +67 -0
- package/dist/exec/threads/interrupt.d.ts +29 -0
- package/dist/exec/threads/interrupt.js +79 -0
- package/dist/exec/threads/limits.d.ts +58 -0
- package/dist/exec/threads/limits.js +35 -0
- package/dist/exec/threads/outcome.d.ts +47 -0
- package/dist/exec/threads/outcome.js +26 -0
- package/dist/exec/threads/registry.d.ts +81 -0
- package/dist/exec/threads/registry.js +427 -0
- package/dist/exec/threads/runtime.d.ts +107 -0
- package/dist/exec/threads/runtime.js +523 -0
- package/dist/exec/threads/thread-source.d.ts +20 -0
- package/dist/exec/threads/thread-source.js +134 -0
- package/dist/fast/limits.d.ts +54 -0
- package/dist/fast/limits.js +54 -0
- package/dist/fast/summary-call.d.ts +72 -0
- package/dist/fast/summary-call.js +133 -0
- package/dist/fast/summary-prompt.d.ts +106 -0
- package/dist/fast/summary-prompt.js +320 -0
- package/dist/fast/task-summary-types.d.ts +66 -0
- package/dist/fast/task-summary-types.js +17 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/limits.d.ts +7 -0
- package/dist/proc/limits.js +7 -0
- package/dist/proc/log-mirror.d.ts +21 -4
- package/dist/proc/log-mirror.js +29 -7
- package/dist/proc/output-streams.d.ts +50 -0
- package/dist/proc/output-streams.js +54 -0
- package/dist/skills/service.d.ts +41 -0
- package/dist/skills/service.js +62 -0
- package/dist/skills/triage.d.ts +194 -0
- package/dist/skills/triage.js +393 -0
- package/dist/team/forced-dispatch.d.ts +95 -0
- package/dist/team/forced-dispatch.js +208 -0
- package/dist/team/limits.d.ts +21 -1
- package/dist/team/limits.js +21 -1
- package/dist/team/normalize.d.ts +15 -0
- package/dist/team/normalize.js +21 -4
- package/dist/team/prompt.d.ts +6 -3
- package/dist/team/prompt.js +9 -5
- package/dist/team/runtime.d.ts +9 -0
- package/dist/team/runtime.js +8 -0
- package/dist/team/subagent.d.ts +15 -0
- package/dist/team/subagent.js +45 -1
- package/dist/team/task-tool.js +11 -7
- package/dist/tools/bash-tool.d.ts +10 -0
- package/dist/tools/bash-tool.js +71 -6
- package/dist/tools/human-input.d.ts +35 -2
- package/dist/tools/human-input.js +38 -0
- package/dist/tools/index.d.ts +93 -1
- package/dist/tools/index.js +112 -3
- package/dist/tools/plan-tools.d.ts +20 -12
- package/dist/tools/plan-tools.js +24 -17
- package/dist/tools/tool-gate.d.ts +100 -0
- package/dist/tools/tool-gate.js +87 -0
- package/dist/ui/App.js +238 -12
- package/dist/ui/Header.d.ts +28 -16
- package/dist/ui/Header.js +21 -19
- package/dist/ui/StatusBar.d.ts +1 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/entries/CompactionCard.d.ts +11 -1
- package/dist/ui/entries/CompactionCard.js +65 -4
- package/dist/ui/interaction-hints.js +6 -0
- package/dist/ui/layout/OverlayFrame.d.ts +18 -0
- package/dist/ui/layout/OverlayFrame.js +19 -4
- package/dist/ui/layout/header-hints.d.ts +55 -62
- package/dist/ui/layout/header-hints.js +59 -166
- package/dist/ui/layout/header-left.d.ts +115 -0
- package/dist/ui/layout/header-left.js +206 -0
- package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
- package/dist/ui/layout/shortcut-panel-layout.js +321 -0
- package/dist/ui/layout/status-detail-layout.js +5 -1
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +18 -1
- package/dist/ui/layout/task-summary-cell.d.ts +61 -0
- package/dist/ui/layout/task-summary-cell.js +65 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
- package/dist/ui/overlays/HelpOverlay.js +29 -2
- package/dist/ui/overlays/SettingsScreen.js +29 -5
- package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
- package/dist/ui/overlays/ShortcutPanel.js +106 -0
- package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
- package/dist/ui/overlays/TeamFanoutPage.js +78 -0
- package/dist/ui/overlays/model-profile-state.d.ts +19 -1
- package/dist/ui/overlays/model-profile-state.js +12 -0
- package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
- package/dist/ui/overlays/team-fanout-state.js +97 -0
- package/dist/ui/shortcuts.d.ts +81 -0
- package/dist/ui/shortcuts.js +221 -0
- package/package.json +2 -2
package/dist/exec/runner.js
CHANGED
|
@@ -17,17 +17,24 @@
|
|
|
17
17
|
import { formatStreamError } from '../agent/reducer.js';
|
|
18
18
|
import { setSignalTerminator } from '../logging/install.js';
|
|
19
19
|
import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
|
|
20
|
-
import { EXEC_SCHEMA_VERSION } from './events.js';
|
|
20
|
+
import { EXEC_SCHEMA_VERSION, buildRequestEndEvent } from './events.js';
|
|
21
21
|
import { ExecProgressReporter } from './progress-reporter.js';
|
|
22
|
+
import { REQUEST_LIMITS } from './request/limits.js';
|
|
23
|
+
import { createRequestScope } from './request/scope.js';
|
|
24
|
+
import { RESULT_LIMITS } from './result/limits.js';
|
|
25
|
+
import { buildResultRepairMessage } from './result/prompt.js';
|
|
22
26
|
export function singlePrompt(text) {
|
|
23
27
|
let served = false;
|
|
28
|
+
let last;
|
|
24
29
|
return {
|
|
25
30
|
next: async () => {
|
|
26
31
|
if (served)
|
|
27
32
|
return null;
|
|
28
33
|
served = true;
|
|
34
|
+
last = { text };
|
|
29
35
|
return text;
|
|
30
36
|
},
|
|
37
|
+
lastPrompt: () => last,
|
|
31
38
|
};
|
|
32
39
|
}
|
|
33
40
|
const DEFAULT_SIGNAL_PORT = {
|
|
@@ -44,6 +51,11 @@ export class ExecRunner {
|
|
|
44
51
|
now;
|
|
45
52
|
progress;
|
|
46
53
|
controller = null;
|
|
54
|
+
/** Set by `attachBridge`; absent in every non-bridge run (I-1). */
|
|
55
|
+
bridge = null;
|
|
56
|
+
bridgeSource = null;
|
|
57
|
+
/** The caller's id for the turn currently running, if there is one. */
|
|
58
|
+
requestId;
|
|
47
59
|
unsubscribers = [];
|
|
48
60
|
/** Per-job progress throttle state: the last text emitted, and when. */
|
|
49
61
|
jobProgressAt = new Map();
|
|
@@ -60,10 +72,45 @@ export class ExecRunner {
|
|
|
60
72
|
errorMessage = null;
|
|
61
73
|
signalNumber = null;
|
|
62
74
|
thinkingBuffer = '';
|
|
75
|
+
/**
|
|
76
|
+
* An injection that has been queued but whose acceptance has not been observed.
|
|
77
|
+
*
|
|
78
|
+
* ITS ONLY JOB IS TO MAKE I-H12 TRUE: a `turn_start` injection queued for a request that
|
|
79
|
+
* then fails to start must be REPORTED, not silently left in the steering queue where the
|
|
80
|
+
* next request's loop top would push it into history.
|
|
81
|
+
*/
|
|
82
|
+
pendingInjection = null;
|
|
83
|
+
/**
|
|
84
|
+
* How many `steering_accepted` receipts have arrived.
|
|
85
|
+
*
|
|
86
|
+
* IT EXISTS TO CLOSE A ONE-TURN RACE: a stub (or a very fast engine) can confirm the
|
|
87
|
+
* injection INSIDE `queueUserMessage`, in which case setting the pending marker afterwards
|
|
88
|
+
* would leave a stale one and report a healthy injection as stranded.
|
|
89
|
+
*/
|
|
90
|
+
steeringReceipts = 0;
|
|
63
91
|
budgetTimer = null;
|
|
64
92
|
timerStarted = false;
|
|
65
93
|
detached = false;
|
|
66
94
|
lastSignalAt = 0;
|
|
95
|
+
/**
|
|
96
|
+
* The one owner of every request-level fact (section 2.4.3).
|
|
97
|
+
*
|
|
98
|
+
* CREATED UNCONDITIONALLY, even outside bridge mode, and that is a deliberate
|
|
99
|
+
* departure from the design's "only in bridge mode" (implementation note): the
|
|
100
|
+
* acceptance latch and the per-request reset have to exist for a single-request run
|
|
101
|
+
* too, or `checkResultComplete` would need a second latch of its own - and two
|
|
102
|
+
* latches for one fact is how "the second request never stops" happens. It emits
|
|
103
|
+
* nothing and starts no timer unless a request budget was configured, so a
|
|
104
|
+
* non-bridge run is byte-identical.
|
|
105
|
+
*/
|
|
106
|
+
requestScope;
|
|
107
|
+
/** True between the request's `ack` and its `request_end`. */
|
|
108
|
+
requestInFlight = false;
|
|
109
|
+
/** Repair continuations used by the request in flight. */
|
|
110
|
+
repairsUsed = 0;
|
|
111
|
+
resultUnsatisfied = false;
|
|
112
|
+
/** The last value any request accepted, for the process-level `result` event. */
|
|
113
|
+
lastStructured;
|
|
67
114
|
constructor(options) {
|
|
68
115
|
this.emitter = options.emitter;
|
|
69
116
|
this.sessionId = options.sessionId;
|
|
@@ -71,6 +118,30 @@ export class ExecRunner {
|
|
|
71
118
|
this.signals = options.signals ?? DEFAULT_SIGNAL_PORT;
|
|
72
119
|
this.now = options.now ?? Date.now;
|
|
73
120
|
this.progress = new ExecProgressReporter(this.sessionId, (event) => this.emit(event));
|
|
121
|
+
this.requestScope = createRequestScope({
|
|
122
|
+
...(options.requestMaxTurns !== undefined ? { maxTurns: options.requestMaxTurns } : {}),
|
|
123
|
+
...(options.requestMaxDurationMs !== undefined
|
|
124
|
+
? { maxDurationMs: options.requestMaxDurationMs }
|
|
125
|
+
: {}),
|
|
126
|
+
now: this.now,
|
|
127
|
+
// THE TIMER'S ONLY ACTION. It arrives with the generation it was armed under, so
|
|
128
|
+
// a callback that lost the race with `end()` cannot reach this line at all.
|
|
129
|
+
onDurationExpired: () => {
|
|
130
|
+
this.requestScope.cancel('timeout');
|
|
131
|
+
this.bridge?.abortInFlight();
|
|
132
|
+
this.controller?.abort();
|
|
133
|
+
},
|
|
134
|
+
totals: () => ({
|
|
135
|
+
usage: { inputTokens: this.usage.inputTokens, outputTokens: this.usage.outputTokens },
|
|
136
|
+
cost: this.costOf(this.usage, this.compactionBilling),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** `buildCost` when the composition root supplied it; zero-and-unknown otherwise. */
|
|
141
|
+
costOf(usage, compaction) {
|
|
142
|
+
if (this.opts.cost)
|
|
143
|
+
return this.opts.cost(usage, compaction);
|
|
144
|
+
return { amount: 0, currency: 'USD', known: false };
|
|
74
145
|
}
|
|
75
146
|
// -----------------------------------------------------------------------
|
|
76
147
|
// Attach / detach
|
|
@@ -245,7 +316,186 @@ export class ExecRunner {
|
|
|
245
316
|
const proc = controller.subscribeProc?.((event) => this.onProcEvent(event));
|
|
246
317
|
if (proc)
|
|
247
318
|
this.unsubscribers.push(proc);
|
|
248
|
-
|
|
319
|
+
// THREAD MODE INSTALLS ITS OWN (section 2.3.8a). One terminator per PROCESS, owned by
|
|
320
|
+
// `exec/threads/interrupt.ts`; a runner that installed a second one would silently
|
|
321
|
+
// replace it and Ctrl-C would stop exactly one tenant.
|
|
322
|
+
if (this.opts.installSignalTerminator !== false)
|
|
323
|
+
this.installTerminator();
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Wire the protocol channel. Called AFTER `attach(controller)`, never before:
|
|
327
|
+
* the turn ports below reach the controller.
|
|
328
|
+
*
|
|
329
|
+
* THREE LINES OF STATE AND ONE INSTALLATION, so that everything bridge-shaped in
|
|
330
|
+
* this file is reachable from one place. `steer` and `interrupt` belong here
|
|
331
|
+
* rather than in the server because they need the RUNNER's idea of what is in
|
|
332
|
+
* flight - and routing them through the server keeps the source's frame router
|
|
333
|
+
* a pure demultiplexer.
|
|
334
|
+
*/
|
|
335
|
+
attachBridge(server, source) {
|
|
336
|
+
this.bridge = server;
|
|
337
|
+
this.bridgeSource = source ?? null;
|
|
338
|
+
server.attachTurnPorts({
|
|
339
|
+
steer: (text, id) => this.onSteerFrame(text, id),
|
|
340
|
+
interrupt: (requestId, id) => this.onInterruptFrame(requestId, id),
|
|
341
|
+
// EXACTLY THE SHAPE OF `interrupt`, AND DELIBERATELY NOT THE SAME MEANING:
|
|
342
|
+
// `interrupt` stops the RUN, `cancelRequest` stops ONE REQUEST and leaves the
|
|
343
|
+
// process serving the next one (section 2.4.7).
|
|
344
|
+
cancelRequest: (requestId, id) => this.cancelRequest(requestId, id),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `steer` over the wire.
|
|
349
|
+
*
|
|
350
|
+
* THE CHECK AND THE CALL ARE SEPARATE, AND BOTH ARE NEEDED: `queueUserMessage()`
|
|
351
|
+
* throws when the agent is not running, and "it was running when I looked" is
|
|
352
|
+
* not the same claim as "it accepted the message". Both failures map to the same
|
|
353
|
+
* receipt, because a caller cannot act on the difference and a lie ("accepted")
|
|
354
|
+
* would make it wait for text that never enters the conversation.
|
|
355
|
+
*/
|
|
356
|
+
onSteerFrame(text, id) {
|
|
357
|
+
const receipt = (extra) => {
|
|
358
|
+
this.writeToBridge({ type: 'ack', ...(id ? { id } : {}), ...extra });
|
|
359
|
+
};
|
|
360
|
+
if (this.controller?.isRunning?.() !== true) {
|
|
361
|
+
receipt({ accepted: false, code: 'not_running' });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
const queueUserMessage = this.controller.queueUserMessage;
|
|
366
|
+
if (!queueUserMessage) {
|
|
367
|
+
receipt({ accepted: false, code: 'not_running' });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const steeringId = queueUserMessage.call(this.controller, text);
|
|
371
|
+
receipt({ accepted: true, ...(steeringId ? { steeringId } : {}) });
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
receipt({ accepted: false, code: 'not_running' });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* `interrupt` over the wire, and the three things it can mean.
|
|
379
|
+
*
|
|
380
|
+
* A NAMED QUEUED TURN IS DELETED, NOT ABORTED: the user removed a message that
|
|
381
|
+
* had not started, and aborting the run in flight would cancel somebody else's
|
|
382
|
+
* work. An unnamed (or in-flight) request is the ordinary signal path, which
|
|
383
|
+
* already settles, persists and reports - the runner's own `requestInterrupt`.
|
|
384
|
+
*/
|
|
385
|
+
onInterruptFrame(requestId, id) {
|
|
386
|
+
const receipt = (extra) => {
|
|
387
|
+
this.writeToBridge({ type: 'ack', ...(id ? { id } : {}), ...extra });
|
|
388
|
+
};
|
|
389
|
+
if (requestId && this.bridgeSource) {
|
|
390
|
+
const verdict = this.bridgeSource.cancelQueued(requestId);
|
|
391
|
+
if (verdict === 'cancelled') {
|
|
392
|
+
receipt({ accepted: true, cancelled: 'queued' });
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (verdict === 'unknown') {
|
|
396
|
+
receipt({ accepted: false, code: 'unknown_turn' });
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
this.requestInterrupt();
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* `cancel_request` over the wire: stop ONE request, never the process.
|
|
404
|
+
*
|
|
405
|
+
* THREE OUTCOMES, and the third one is the important one (section 2.4.7):
|
|
406
|
+
*
|
|
407
|
+
* QUEUED the source deletes it and THIS method writes its `request_end`,
|
|
408
|
+
* because `run()`'s loop never saw that prompt and never will.
|
|
409
|
+
* The `ack` echoes the requestId, since a queued prompt has no
|
|
410
|
+
* `ack` of its own before it is served.
|
|
411
|
+
* IN FLIGHT cancel the scope and abort. The request's own `finally` writes the
|
|
412
|
+
* `request_end`, so there is still exactly one writer per fact.
|
|
413
|
+
* UNKNOWN `ack{accepted:false}` and a non-fatal error. A FINISHED REQUEST IS
|
|
414
|
+
* `unknown`, NEVER "in flight" - aborting a healthy run because a
|
|
415
|
+
* caller cancelled something it already got an answer for is the
|
|
416
|
+
* exact defect the source's `cancelQueued` comment records.
|
|
417
|
+
*/
|
|
418
|
+
cancelRequest(requestId, frameId) {
|
|
419
|
+
const receipt = (extra) => {
|
|
420
|
+
this.writeToBridge({ type: 'ack', ...(frameId ? { id: frameId } : {}), ...extra });
|
|
421
|
+
};
|
|
422
|
+
// THE SOURCE IS ASKED FIRST, because it is the only object that can tell a QUEUED
|
|
423
|
+
// prompt from a SERVED one. Its three-way verdict is exactly the three outcomes
|
|
424
|
+
// this frame has, which is why it was written that way in the first round.
|
|
425
|
+
if (requestId && this.bridgeSource) {
|
|
426
|
+
const verdict = this.bridgeSource.cancelQueued(requestId);
|
|
427
|
+
if (verdict === 'cancelled') {
|
|
428
|
+
receipt({ accepted: true, requestId, code: 'queued_cancelled' });
|
|
429
|
+
this.writeCancelledRequestEnd(requestId);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
if (verdict === 'unknown') {
|
|
433
|
+
// A FINISHED REQUEST IS `unknown`, NEVER "in flight": answering anything else
|
|
434
|
+
// would make a late cancel abort a healthy run.
|
|
435
|
+
this.reportUnknownRequest(requestId, frameId);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// `in_flight` falls through: the named request is the one running.
|
|
439
|
+
}
|
|
440
|
+
const target = requestId ?? this.requestScope.requestId();
|
|
441
|
+
if (this.requestInFlight && target !== undefined && target === this.requestScope.requestId()) {
|
|
442
|
+
this.requestScope.cancel('cancelled');
|
|
443
|
+
receipt({ accepted: true, requestId: target });
|
|
444
|
+
this.bridge?.abortInFlight();
|
|
445
|
+
this.controller?.abort();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
this.reportUnknownRequest(requestId, frameId);
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* The refusal, written where the caller can act on it.
|
|
452
|
+
*
|
|
453
|
+
* TWO FRAMES, DELIBERATELY: the `ack` says "not accepted" and the non-fatal `error`
|
|
454
|
+
* says why. A caller that only watches `ack` is not left guessing, and one that only
|
|
455
|
+
* watches `error` is not left without a receipt.
|
|
456
|
+
*/
|
|
457
|
+
reportUnknownRequest(requestId, frameId) {
|
|
458
|
+
this.writeToBridge({
|
|
459
|
+
type: 'ack',
|
|
460
|
+
...(frameId ? { id: frameId } : {}),
|
|
461
|
+
accepted: false,
|
|
462
|
+
code: 'unknown_turn',
|
|
463
|
+
...(requestId ? { requestId } : {}),
|
|
464
|
+
});
|
|
465
|
+
this.writeToBridge({
|
|
466
|
+
type: 'error',
|
|
467
|
+
sessionId: this.sessionId,
|
|
468
|
+
fatal: false,
|
|
469
|
+
code: 'bridge_unknown_request',
|
|
470
|
+
message: `cancel_request named "${requestId ?? 'the in-flight request'}", which is not running.`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* The `request_end` for a request that was cancelled BEFORE it was served.
|
|
475
|
+
*
|
|
476
|
+
* `requestSeq` IS ABSENT, NOT ZERO: the number is minted when a prompt is served,
|
|
477
|
+
* this one never was, and inventing one would collide with a later real request.
|
|
478
|
+
* Everything else is honestly zero, including `cost` - which is `known: true` with
|
|
479
|
+
* `amount: 0` because nothing was spent and that is a fact, not a missing price.
|
|
480
|
+
*/
|
|
481
|
+
writeCancelledRequestEnd(requestId) {
|
|
482
|
+
if (!this.bridge)
|
|
483
|
+
return;
|
|
484
|
+
if (!this.requestScope.remember(requestId))
|
|
485
|
+
return;
|
|
486
|
+
this.writeToBridge(buildRequestEndEvent({
|
|
487
|
+
sessionId: this.sessionId,
|
|
488
|
+
requestId,
|
|
489
|
+
stopReason: 'cancelled',
|
|
490
|
+
isError: false,
|
|
491
|
+
turns: 0,
|
|
492
|
+
durationMs: 0,
|
|
493
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
494
|
+
cost: { amount: 0, currency: 'USD', known: true },
|
|
495
|
+
result: '',
|
|
496
|
+
...(this.opts.resultSink ? { structured: null } : {}),
|
|
497
|
+
error: null,
|
|
498
|
+
}));
|
|
249
499
|
}
|
|
250
500
|
/**
|
|
251
501
|
* Map a supervisor event onto the `job` channel.
|
|
@@ -335,6 +585,10 @@ export class ExecRunner {
|
|
|
335
585
|
clearTimeout(this.budgetTimer);
|
|
336
586
|
this.budgetTimer = null;
|
|
337
587
|
}
|
|
588
|
+
// THE REQUEST TIMER TOO (R-13). `detach()` runs after the last request, so a
|
|
589
|
+
// surviving request timer could only ever fire against a disposed controller - the
|
|
590
|
+
// same class of late callback the scope's own generation latch exists for.
|
|
591
|
+
this.requestScope.detach();
|
|
338
592
|
}
|
|
339
593
|
stats() {
|
|
340
594
|
return {
|
|
@@ -344,6 +598,8 @@ export class ExecRunner {
|
|
|
344
598
|
...this.compactionBilling, usage: { ...this.compactionBilling.usage },
|
|
345
599
|
} } : {}),
|
|
346
600
|
lastAssistantText: this.lastAssistantText,
|
|
601
|
+
...(this.lastStructured ? { lastStructured: { ...this.lastStructured } } : {}),
|
|
602
|
+
resultUnsatisfied: this.resultUnsatisfied,
|
|
347
603
|
errored: this.errored,
|
|
348
604
|
stopReason: this.stopReason,
|
|
349
605
|
errorCode: this.errorCode,
|
|
@@ -389,10 +645,75 @@ export class ExecRunner {
|
|
|
389
645
|
// nothing more to say exits 0 with `end_turn`, not 3 with `max_turns`.
|
|
390
646
|
if (next === null)
|
|
391
647
|
break;
|
|
648
|
+
const prompt = source.lastPrompt?.();
|
|
649
|
+
this.requestId = prompt?.requestId;
|
|
392
650
|
const requestSeq = ++this.requestSeq;
|
|
393
651
|
this.requestHadError = false;
|
|
652
|
+
this.resultUnsatisfied = false;
|
|
653
|
+
this.repairsUsed = 0;
|
|
654
|
+
// THE REQUEST BEGINS HERE, BEFORE THE ACK, because `requestSeq` is part of the
|
|
655
|
+
// request's identity and the ack promises it.
|
|
656
|
+
this.requestScope.begin({
|
|
657
|
+
requestSeq,
|
|
658
|
+
...(prompt?.requestId !== undefined ? { requestId: prompt.requestId } : {}),
|
|
659
|
+
});
|
|
660
|
+
this.requestInFlight = true;
|
|
661
|
+
// THE ONE RESET POINT (P1-11). AFTER `begin()`, BEFORE `prompt()`: the previous
|
|
662
|
+
// request's tool calls have all settled by now (its `finally` is on the await
|
|
663
|
+
// chain), and this request's first turn has not been sent. Resetting at the END
|
|
664
|
+
// of a request instead would let a late tool result be judged as the next
|
|
665
|
+
// request's answer.
|
|
666
|
+
this.opts.resultSink?.reset();
|
|
394
667
|
this.progress.begin(requestSeq);
|
|
395
|
-
|
|
668
|
+
// `prompt_pre`, BETWEEN `begin()` AND THE ACK (section 2.4.7, review P1-11).
|
|
669
|
+
//
|
|
670
|
+
// THE POSITION IS THE CONTRACT. Before `begin()` there is no `requestId` to put on
|
|
671
|
+
// the `hook_request`; after the ack a denial would arrive as `ack{accepted:true}`
|
|
672
|
+
// followed by `request_end{cancelled}` - one frame saying "I took it" and one saying
|
|
673
|
+
// "I did not". Here both facts are honest, and the denial reuses the shape
|
|
674
|
+
// `writeCancelledRequestEnd` already established.
|
|
675
|
+
// CONDITIONAL, NOT AN UNCONDITIONAL AWAIT (I-C1). A run with no hook channel must
|
|
676
|
+
// reach `prompt()` after EXACTLY as many microtasks as it did before this feature
|
|
677
|
+
// existed: an extra `await` here reorders observable timing for callers that drive
|
|
678
|
+
// `run()` a tick at a time (this package's own progress tests found it).
|
|
679
|
+
const hookVerdict = this.opts.hookPort
|
|
680
|
+
? await this.applyPromptPre(next, prompt, requestSeq)
|
|
681
|
+
: next;
|
|
682
|
+
if (hookVerdict === null)
|
|
683
|
+
continue;
|
|
684
|
+
// THE ACK IS EMITTED HERE, NOT BY THE SOURCE, because `requestSeq` is the
|
|
685
|
+
// runner's own counter and a receipt that guessed at it would be worse than
|
|
686
|
+
// one that omitted it. `queued` was captured at arrival, where it means
|
|
687
|
+
// something; the order of these acks is the order the source served, which
|
|
688
|
+
// is the order the caller wrote.
|
|
689
|
+
if (this.bridge && prompt?.requestId) {
|
|
690
|
+
this.writeToBridge({
|
|
691
|
+
type: 'ack',
|
|
692
|
+
id: prompt.requestId,
|
|
693
|
+
accepted: true,
|
|
694
|
+
queued: prompt.queued === true,
|
|
695
|
+
requestSeq,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
// THE TURN BOUNDARY, WHICH IS THE ONLY PLACE A DEFERRED `tool_register` MAY
|
|
699
|
+
// BE APPLIED (I-8): the engine is idle here and `prompt()` is about to be
|
|
700
|
+
// called, so the new definition reaches the very next LLM request.
|
|
701
|
+
this.bridge?.beginTurn(this.turns + 1);
|
|
702
|
+
// CONDITIONAL AWAIT, NOT `await this.drainBridge()`. With no bridge there is
|
|
703
|
+
// no promise to await, and an unconditional `await` would still cost a
|
|
704
|
+
// microtask per turn - enough to change the observable ordering for callers
|
|
705
|
+
// that drive `run()` a tick at a time (and for this package's own runner
|
|
706
|
+
// tests, which is how this was found).
|
|
707
|
+
const drain = this.bridge?.drainIfNeeded();
|
|
708
|
+
if (drain)
|
|
709
|
+
await drain;
|
|
710
|
+
this.emit({
|
|
711
|
+
type: 'turn_state',
|
|
712
|
+
sessionId: this.sessionId,
|
|
713
|
+
requestSeq,
|
|
714
|
+
phase: 'started',
|
|
715
|
+
...this.correlation(),
|
|
716
|
+
});
|
|
396
717
|
this.progress.started(requestSeq);
|
|
397
718
|
try {
|
|
398
719
|
if (this.isRequestCancelled())
|
|
@@ -401,7 +722,7 @@ export class ExecRunner {
|
|
|
401
722
|
this.stopReason = 'max_turns';
|
|
402
723
|
break;
|
|
403
724
|
}
|
|
404
|
-
const finished = await this.runFollowThroughChain(controller,
|
|
725
|
+
const finished = await this.runFollowThroughChain(controller, hookVerdict, followThrough);
|
|
405
726
|
if (finished)
|
|
406
727
|
break;
|
|
407
728
|
}
|
|
@@ -412,13 +733,242 @@ export class ExecRunner {
|
|
|
412
733
|
}
|
|
413
734
|
finally {
|
|
414
735
|
this.progress.finish(requestSeq);
|
|
415
|
-
const phase = this.isRequestCancelled()
|
|
736
|
+
const phase = this.isRequestCancelled() || this.requestScope.cancelledReason() !== null
|
|
416
737
|
? 'cancelled'
|
|
417
738
|
: this.requestHadError ? 'failed' : 'completed';
|
|
418
|
-
|
|
739
|
+
// THE `turn_state` GOES FIRST, AND THE `request_end` RIGHT AFTER IT: same
|
|
740
|
+
// `finally`, same writer, so "the request's last state precedes its account"
|
|
741
|
+
// is structural rather than remembered (I-R3).
|
|
742
|
+
this.emit({
|
|
743
|
+
type: 'turn_state',
|
|
744
|
+
sessionId: this.sessionId,
|
|
745
|
+
requestSeq,
|
|
746
|
+
phase,
|
|
747
|
+
...this.correlation(),
|
|
748
|
+
});
|
|
749
|
+
this.requestInFlight = false;
|
|
750
|
+
// BEFORE `settleRequest`, so the account it writes can carry `injection_stranded`
|
|
751
|
+
// in `error.code` (I-H12). Reporting it after would mean the caller reads a clean
|
|
752
|
+
// `request_end` and never learns the injected context was dropped.
|
|
753
|
+
this.reportStrandedInjection();
|
|
754
|
+
this.settleRequest(requestSeq);
|
|
755
|
+
// THE GATE'S RELEASE POINT: after the account is written, in the request's own
|
|
756
|
+
// `finally`, so a slot is never freed before the request it was holding is over.
|
|
757
|
+
this.opts.onRequestSettled?.(requestSeq);
|
|
419
758
|
}
|
|
420
759
|
}
|
|
421
760
|
}
|
|
761
|
+
/**
|
|
762
|
+
* Run `prompt_pre` for the request that just began, and settle it if a hook refuses.
|
|
763
|
+
*
|
|
764
|
+
* RETURNS THE TEXT TO SEND, or `null` when the request has already been settled and the
|
|
765
|
+
* caller's loop must continue with the next one. A caller hook's denial is a FAILED
|
|
766
|
+
* request - the caller did not get what it asked for - which is why `isError` is true
|
|
767
|
+
* here and false for a caller's own `cancel_request`.
|
|
768
|
+
*/
|
|
769
|
+
async applyPromptPre(text, prompt, requestSeq) {
|
|
770
|
+
const port = this.opts.hookPort;
|
|
771
|
+
if (!port)
|
|
772
|
+
return text;
|
|
773
|
+
// A REQUEST ID ALWAYS EXISTS BY THIS POINT, and if the caller did not supply one the
|
|
774
|
+
// runner's own `request_<n>` stands in: `scope.begin()` has already run, so a hook request
|
|
775
|
+
// without an id would be the one case the caller could not correlate.
|
|
776
|
+
const requestId = prompt?.requestId ?? `request_${requestSeq}`;
|
|
777
|
+
const decision = await port.promptPre({
|
|
778
|
+
text,
|
|
779
|
+
queued: prompt?.queued === true,
|
|
780
|
+
requestId,
|
|
781
|
+
});
|
|
782
|
+
if (decision.action === 'deny') {
|
|
783
|
+
this.writeDeniedRequestEnd(requestId, requestSeq, decision);
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
return decision.action === 'rewrite' && decision.text !== undefined ? decision.text : text;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* The `request_end` for a request a hook refused.
|
|
790
|
+
*
|
|
791
|
+
* ONE DELIBERATE DIFFERENCE FROM `writeCancelledRequestEnd`: `requestSeq` IS PRESENT.
|
|
792
|
+
* That method serves a prompt that was withdrawn while queued, before `begin()` ever ran,
|
|
793
|
+
* so it has no number to give; this one HAS begun, and inventing a denial without the
|
|
794
|
+
* number would make "which request was refused" a guessing game.
|
|
795
|
+
*/
|
|
796
|
+
writeDeniedRequestEnd(requestId, requestSeq, decision) {
|
|
797
|
+
const code = decision.code ?? 'hook_denied';
|
|
798
|
+
const message = decision.message ?? 'A caller hook refused this prompt.';
|
|
799
|
+
this.writeToBridge({
|
|
800
|
+
type: 'ack',
|
|
801
|
+
id: requestId,
|
|
802
|
+
accepted: false,
|
|
803
|
+
code,
|
|
804
|
+
requestSeq,
|
|
805
|
+
});
|
|
806
|
+
this.writeToBridge({
|
|
807
|
+
type: 'error',
|
|
808
|
+
sessionId: this.sessionId,
|
|
809
|
+
fatal: false,
|
|
810
|
+
code,
|
|
811
|
+
message,
|
|
812
|
+
});
|
|
813
|
+
if (!this.requestScope.remember(requestId))
|
|
814
|
+
return;
|
|
815
|
+
this.requestInFlight = false;
|
|
816
|
+
this.requestScope.cancel('cancelled');
|
|
817
|
+
this.requestScope.end({ now: this.now(), error: { code, message } });
|
|
818
|
+
this.writeToBridge(buildRequestEndEvent({
|
|
819
|
+
sessionId: this.sessionId,
|
|
820
|
+
requestId,
|
|
821
|
+
requestSeq,
|
|
822
|
+
stopReason: 'cancelled',
|
|
823
|
+
isError: decision.isError !== false,
|
|
824
|
+
turns: 0,
|
|
825
|
+
durationMs: 0,
|
|
826
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
827
|
+
cost: { amount: 0, currency: 'USD', known: true },
|
|
828
|
+
result: '',
|
|
829
|
+
...(this.opts.resultSink ? { structured: null } : {}),
|
|
830
|
+
error: { code, message },
|
|
831
|
+
}));
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Record a hook-caused request failure (section 2.4.8's `error` policy).
|
|
835
|
+
*
|
|
836
|
+
* ALL THREE FIELDS, because three different readers need them: `errorCode` and
|
|
837
|
+
* `errorMessage` become `request_end.error`, and `requestHadError` is what makes
|
|
838
|
+
* `requestError()` return anything at all - setting the first two alone leaves the frame
|
|
839
|
+
* looking clean, which is exactly the failure `--on-hook-timeout error` exists to prevent.
|
|
840
|
+
*/
|
|
841
|
+
markHookError(code) {
|
|
842
|
+
if (this.errorCode === null) {
|
|
843
|
+
this.errorCode = code ?? 'hook_timeout';
|
|
844
|
+
this.errorMessage = 'A caller lifecycle hook did not answer; --on-hook-timeout error.';
|
|
845
|
+
}
|
|
846
|
+
this.requestHadError = true;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Report an injection that was queued for a request that never accepted it (I-H12).
|
|
850
|
+
*
|
|
851
|
+
* TWO FRAMES, because there are two facts: the hook round trip ended as `aborted` with
|
|
852
|
+
* `injection_stranded` (visible in the hook channel), and this request's `error.code`
|
|
853
|
+
* carries the same string so a caller reading only `request_end` learns it too.
|
|
854
|
+
*/
|
|
855
|
+
reportStrandedInjection(hookId) {
|
|
856
|
+
const stranded = this.pendingInjection;
|
|
857
|
+
if (!stranded && hookId === undefined)
|
|
858
|
+
return;
|
|
859
|
+
this.pendingInjection = null;
|
|
860
|
+
this.writeToBridge({
|
|
861
|
+
type: 'hook_resolved',
|
|
862
|
+
sessionId: this.sessionId,
|
|
863
|
+
requestId: this.requestScope.requestId() ?? 'unknown',
|
|
864
|
+
hookId: stranded?.hookId ?? hookId ?? 'h?',
|
|
865
|
+
point: 'turn_start',
|
|
866
|
+
outcome: 'aborted',
|
|
867
|
+
code: 'injection_stranded',
|
|
868
|
+
});
|
|
869
|
+
if (this.errorCode === null) {
|
|
870
|
+
this.errorCode = 'injection_stranded';
|
|
871
|
+
this.errorMessage =
|
|
872
|
+
'A caller hook injected context for a turn that never started, so it was not added.';
|
|
873
|
+
}
|
|
874
|
+
// `requestHadError` TOO, not just `errorCode`: `requestError()` is what `request_end.error`
|
|
875
|
+
// reads, and it returns null unless the request is marked as failed - so setting only the
|
|
876
|
+
// code would leave the frame clean and the fact invisible.
|
|
877
|
+
this.requestHadError = true;
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Write this request's `request_end` - the FIRST of the two writers (I-R9).
|
|
881
|
+
*
|
|
882
|
+
* The second is `cancelRequest`, for a prompt that was cancelled while queued and
|
|
883
|
+
* therefore never reached this method at all. Both go through
|
|
884
|
+
* `RequestScope.remember()`, which is what makes "exactly one per request" true
|
|
885
|
+
* instead of merely intended.
|
|
886
|
+
*/
|
|
887
|
+
settleRequest(requestSeq) {
|
|
888
|
+
const requestId = this.requestScope.requestId();
|
|
889
|
+
if (!this.requestScope.remember(requestId ?? `request_${requestSeq}`))
|
|
890
|
+
return;
|
|
891
|
+
const sink = this.opts.resultSink;
|
|
892
|
+
const accepted = sink?.accepted() ?? null;
|
|
893
|
+
if (accepted) {
|
|
894
|
+
// PROCESS-LEVEL MEMORY FOR A PROCESS-LEVEL EVENT. `result` is one event for the
|
|
895
|
+
// whole run; a later request that submits nothing must not erase the fact that
|
|
896
|
+
// an answer WAS produced.
|
|
897
|
+
this.lastStructured = { value: accepted.value, attempts: accepted.attempts };
|
|
898
|
+
}
|
|
899
|
+
if (!this.bridge) {
|
|
900
|
+
this.requestScope.end(this.settlementInput());
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const settlement = this.requestScope.end(this.settlementInput());
|
|
904
|
+
this.writeToBridge(buildRequestEndEvent({
|
|
905
|
+
sessionId: this.sessionId,
|
|
906
|
+
requestId: settlement.requestId,
|
|
907
|
+
...(settlement.requestSeq !== undefined ? { requestSeq: settlement.requestSeq } : {}),
|
|
908
|
+
stopReason: settlement.stopReason,
|
|
909
|
+
isError: settlement.isError,
|
|
910
|
+
turns: settlement.turns,
|
|
911
|
+
durationMs: settlement.durationMs,
|
|
912
|
+
usage: settlement.usage,
|
|
913
|
+
cost: settlement.cost,
|
|
914
|
+
result: clipText(this.lastAssistantText, REQUEST_LIMITS.maxResultChars),
|
|
915
|
+
...(sink
|
|
916
|
+
? { structured: accepted
|
|
917
|
+
? { value: accepted.value, schemaName: this.opts.resultSchemaName ?? 'result', attempts: accepted.attempts }
|
|
918
|
+
: null }
|
|
919
|
+
: {}),
|
|
920
|
+
error: settlement.error,
|
|
921
|
+
}));
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* What `RequestScope.end()` needs: the clock, the failure, and - when the PROCESS
|
|
925
|
+
* stopped underneath this request - the process reason (section 2.4.2).
|
|
926
|
+
*
|
|
927
|
+
* SEPARATE FROM `requestError()` BECAUSE THE TWO ANSWER DIFFERENT QUESTIONS: the
|
|
928
|
+
* error says "you did not get what you asked for", the reason says "why this request
|
|
929
|
+
* ended". A process `interrupt` sets the second and not the first, which is exactly
|
|
930
|
+
* the case a caller reading only `stopReason` has to see as `cancelled` rather than
|
|
931
|
+
* as a finished answer.
|
|
932
|
+
*/
|
|
933
|
+
settlementInput() {
|
|
934
|
+
const processReason = this.processStopForRequest();
|
|
935
|
+
return {
|
|
936
|
+
now: this.now(),
|
|
937
|
+
error: this.requestError(),
|
|
938
|
+
...(processReason ? { stopReason: processReason } : {}),
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* The process-level stop, translated into the REQUEST vocabulary (section 2.4.2).
|
|
943
|
+
*
|
|
944
|
+
* `undefined` for `end_turn` and for everything a request already speaks for itself.
|
|
945
|
+
* Only the three cases where the RUN ended underneath a live request are mapped, and
|
|
946
|
+
* the mapping is one-way on purpose: `ExecStopReason` and `ExecRequestStopReason` are
|
|
947
|
+
* different unions over different questions and must stay different.
|
|
948
|
+
*/
|
|
949
|
+
processStopForRequest() {
|
|
950
|
+
switch (this.stopReason) {
|
|
951
|
+
case 'max_turns':
|
|
952
|
+
return 'max_turns';
|
|
953
|
+
case 'timeout':
|
|
954
|
+
return 'timeout';
|
|
955
|
+
case 'interrupted':
|
|
956
|
+
return 'cancelled';
|
|
957
|
+
case 'error':
|
|
958
|
+
return 'error';
|
|
959
|
+
default:
|
|
960
|
+
return undefined;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/** The request-level error, or `null`. One place so `result` and `request_end` agree. */
|
|
964
|
+
requestError() {
|
|
965
|
+
if (!this.requestHadError && !this.resultUnsatisfied)
|
|
966
|
+
return null;
|
|
967
|
+
return {
|
|
968
|
+
code: this.errorCode ?? 'agent_error',
|
|
969
|
+
message: this.errorMessage ?? 'The run failed.',
|
|
970
|
+
};
|
|
971
|
+
}
|
|
422
972
|
onCompactionUsage(event) {
|
|
423
973
|
const usage = event.usage;
|
|
424
974
|
this.usage.inputTokens += usage.inputTokens;
|
|
@@ -461,11 +1011,114 @@ export class ExecRunner {
|
|
|
461
1011
|
turn: this.turns + 1,
|
|
462
1012
|
text: message,
|
|
463
1013
|
source,
|
|
1014
|
+
// AN AUTO-CONTINUATION HAS NO `requestId`: it is the CLI's own message,
|
|
1015
|
+
// not the caller's request, and `source: 'todo_continue'` already says so.
|
|
1016
|
+
...(source === 'caller' ? this.correlation() : {}),
|
|
464
1017
|
});
|
|
1018
|
+
// THE FOLLOW-THROUGH CHAIN'S LATER ITERATIONS ARE NEW TURNS, so the registry
|
|
1019
|
+
// boundary is re-announced: a `tool_register` that arrived during iteration N
|
|
1020
|
+
// must be live for iteration N+1, and `prompt()` below is where that matters.
|
|
1021
|
+
this.bridge?.beginTurn(this.turns + 1);
|
|
465
1022
|
this.startBudgetTimer();
|
|
1023
|
+
// THE SAME JUDGEMENT THE POST-TURN CHECK MAKES, ASKED BEFORE THE TURN STARTS:
|
|
1024
|
+
// `stopRequestOnBudget()` is the ONE place a request budget is decided, and it is
|
|
1025
|
+
// asked here as well as after each turn (a `--request-max-duration` that expires
|
|
1026
|
+
// between two continuations must not be given another turn). Prompting an already
|
|
1027
|
+
// aborted controller would also emit an `agent_end` with no assistant turn, which
|
|
1028
|
+
// the silent-failure detector below would report as an `agent_error` - a request
|
|
1029
|
+
// budget must never make the process look broken (I-R1).
|
|
1030
|
+
// `turn_start`, AND ITS POSITION IS NOT NEGOTIABLE (section 2.4.6, review P1-9):
|
|
1031
|
+
// the hook is ASKED here, but the injection is QUEUED only after the budget check
|
|
1032
|
+
// below. `Agent.steer()` does not throw when the engine is idle - it queues the text
|
|
1033
|
+
// for the next `acceptSteering`, which is the NEXT request's loop top - so an
|
|
1034
|
+
// injection made before a budget stop would surface in somebody else's conversation.
|
|
1035
|
+
// CONDITIONAL, FOR THE SAME REASON `prompt_pre` IS (I-C1): `await undefined` still costs
|
|
1036
|
+
// a microtask, and a runner with no hook channel must reach `prompt()` on exactly the
|
|
1037
|
+
// turn count it did before this feature existed.
|
|
1038
|
+
const injection = this.opts.beforeTurn
|
|
1039
|
+
? ((await this.opts.beforeTurn({
|
|
1040
|
+
turn: this.turns + 1,
|
|
1041
|
+
source,
|
|
1042
|
+
// `requestScope.requestId()` IS the id by now: `scope.begin()` runs before the
|
|
1043
|
+
// first turn, and a request without a caller-supplied id gets `request_<n>` from
|
|
1044
|
+
// the scope itself, so the fallback here never fires (it satisfies the type).
|
|
1045
|
+
requestId: this.requestScope.requestId() ?? 'unknown',
|
|
1046
|
+
})) ?? undefined)
|
|
1047
|
+
: undefined;
|
|
1048
|
+
if (this.haltRequestOnBudget()) {
|
|
1049
|
+
// NOTHING WAS QUEUED, SO NOTHING IS STRANDED (section 2.4.6). `beforeTurn` produced
|
|
1050
|
+
// text that never reached the steering queue, and the hook round trip already wrote
|
|
1051
|
+
// its own `hook_resolved` - so there is no missing frame to close. Reporting
|
|
1052
|
+
// `injection_stranded` here would invent a frame for an injection that never
|
|
1053
|
+
// existed AND mark a request budget stop as a hook failure, which is the one thing
|
|
1054
|
+
// I-R1 forbids (`--request-max-turns` must never make the process look broken).
|
|
1055
|
+
return false;
|
|
1056
|
+
}
|
|
1057
|
+
if (injection?.injected) {
|
|
1058
|
+
const receiptsBefore = this.steeringReceipts;
|
|
1059
|
+
const queued = controller.queueUserMessage?.(injection.injected);
|
|
1060
|
+
// RECORDED BEFORE `prompt()` SO A FAILED START CAN STILL REPORT IT; the
|
|
1061
|
+
// `steering_accepted` receipt is the only thing that clears it - including one that
|
|
1062
|
+
// arrives synchronously, which the counter detects.
|
|
1063
|
+
const confirmed = this.steeringReceipts > receiptsBefore;
|
|
1064
|
+
this.pendingInjection =
|
|
1065
|
+
queued === undefined || confirmed ? null : { hookId: injection.hookId ?? 'h?' };
|
|
1066
|
+
}
|
|
466
1067
|
await controller.prompt(message);
|
|
1068
|
+
if (injection?.isError === true)
|
|
1069
|
+
this.markHookError(injection.code);
|
|
467
1070
|
if (this.stopReason !== 'end_turn')
|
|
468
1071
|
return true;
|
|
1072
|
+
// A REQUEST-LEVEL STOP ENDS *THIS REQUEST*, NEVER THE PROCESS (I-R1).
|
|
1073
|
+
const cancelled = this.requestScope.cancelledReason();
|
|
1074
|
+
if (cancelled !== null) {
|
|
1075
|
+
// A CALLER'S OWN CANCELLATION IS NOT A CONTRACT FAILURE. A BUDGET STOP IS, and
|
|
1076
|
+
// the caller has to learn both facts (AC-51): `stopReason` answers "why did
|
|
1077
|
+
// this stop", `error.code` answers "did I get what I asked for".
|
|
1078
|
+
if (cancelled !== 'cancelled' && this.opts.resultSink && !this.opts.resultSink.accepted()) {
|
|
1079
|
+
this.markResultUnsatisfied();
|
|
1080
|
+
}
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
// BUDGET BEFORE REWORK (P1-9). A repair attempt that the budget cannot pay for
|
|
1084
|
+
// would report `max_turns` while the real problem is an unsubmitted result.
|
|
1085
|
+
if (this.haltRequestOnBudget())
|
|
1086
|
+
return false;
|
|
1087
|
+
const contract = this.resultContractOutcome();
|
|
1088
|
+
// "ACCEPT THEN STOP" (I-S4): the abort in `checkResultComplete` already ended the
|
|
1089
|
+
// model's loop; this is what refuses a follow-through continuation on the far
|
|
1090
|
+
// side of it.
|
|
1091
|
+
if (contract === 'accepted')
|
|
1092
|
+
return false;
|
|
1093
|
+
// THE PROCESS BUDGET IS JUDGED BEFORE THE CONTRACT'S REMAINING BRANCHES, AND ONLY
|
|
1094
|
+
// WHEN A SCHEMA FAILED (section 2.3.5). A repair is an ordinary turn, so a repair
|
|
1095
|
+
// the caller's `--max-turns` cannot pay for must report `max_turns` rather than be
|
|
1096
|
+
// started and immediately aborted. The `contract === 'none'` case never reaches
|
|
1097
|
+
// here, which is what keeps a successful non-schema run ending on `end_turn` even
|
|
1098
|
+
// when it used exactly its whole budget (IF-3).
|
|
1099
|
+
// `contract === 'none'` IS EXCLUDED ON PURPOSE (implementation note I-6): a run with
|
|
1100
|
+
// no schema that uses exactly its whole budget must still report `end_turn`, which is
|
|
1101
|
+
// the IF-3 defect the block budget's own comment records. Only a run whose contract
|
|
1102
|
+
// already failed can be said to have "run out of budget for the repair".
|
|
1103
|
+
if (contract !== 'none' && this.turnBudgetExhausted()) {
|
|
1104
|
+
this.stopReason = 'max_turns';
|
|
1105
|
+
return true;
|
|
1106
|
+
}
|
|
1107
|
+
if (contract === 'repair') {
|
|
1108
|
+
const sink = this.opts.resultSink;
|
|
1109
|
+
this.repairsUsed += 1;
|
|
1110
|
+
// THE CLI SPEAKS FOR THE CALLER HERE, exactly as `todo_continue` does, and says
|
|
1111
|
+
// so through `source` so a replayed transcript can tell them apart.
|
|
1112
|
+
source = 'result_repair';
|
|
1113
|
+
message = buildResultRepairMessage(sink?.lastViolations() ?? [], sink?.attempts() ?? 0);
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
if (contract === 'unsatisfied') {
|
|
1117
|
+
this.markResultUnsatisfied();
|
|
1118
|
+
// `error` ends the run (the single-call default); `continue` leaves the process
|
|
1119
|
+
// serving the next request with `result.isError` still true.
|
|
1120
|
+
return this.opts.onResultUnsatisfied !== 'continue';
|
|
1121
|
+
}
|
|
469
1122
|
const snapshot = controller.getTodoSnapshot?.() ?? null;
|
|
470
1123
|
budget = advanceBudget(budget, snapshot, wasAutoContinuation);
|
|
471
1124
|
wasAutoContinuation = false;
|
|
@@ -491,6 +1144,8 @@ export class ExecRunner {
|
|
|
491
1144
|
this.stopReason = 'max_turns';
|
|
492
1145
|
return true;
|
|
493
1146
|
}
|
|
1147
|
+
if (this.haltRequestOnBudget())
|
|
1148
|
+
return false;
|
|
494
1149
|
// CHARGED BEFORE THE RE-PROMPT. Headless has no grace timer, so without
|
|
495
1150
|
// this line `maxAutoContinuesPerList` would never bind in the one
|
|
496
1151
|
// environment with no human to press Esc.
|
|
@@ -501,6 +1156,115 @@ export class ExecRunner {
|
|
|
501
1156
|
source = 'todo_continue';
|
|
502
1157
|
}
|
|
503
1158
|
}
|
|
1159
|
+
/**
|
|
1160
|
+
* What the structured-result contract says about the request that just ended.
|
|
1161
|
+
*
|
|
1162
|
+
* FOUR ANSWERS, and `none` is the important one: with no `--result-schema` this
|
|
1163
|
+
* method reads nothing and the caller's behaviour is exactly what it was before the
|
|
1164
|
+
* feature existed (I-S2).
|
|
1165
|
+
*/
|
|
1166
|
+
resultContractOutcome() {
|
|
1167
|
+
const sink = this.opts.resultSink;
|
|
1168
|
+
if (!sink)
|
|
1169
|
+
return 'none';
|
|
1170
|
+
if (sink.accepted())
|
|
1171
|
+
return 'accepted';
|
|
1172
|
+
const repairTurns = this.opts.resultRepairTurns ?? RESULT_LIMITS.defaultRepairTurns;
|
|
1173
|
+
return this.repairsUsed < repairTurns ? 'repair' : 'unsatisfied';
|
|
1174
|
+
}
|
|
1175
|
+
/**
|
|
1176
|
+
* Record that this request ended without the answer the caller asked for.
|
|
1177
|
+
*
|
|
1178
|
+
* TWO FACTS, TWO FIELDS, NEVER ONE NAME FOR BOTH (P1-9): `error.code` says the
|
|
1179
|
+
* contract failed, and the process-level `stopReason` moves to `error` ONLY when
|
|
1180
|
+
* `--on-result-unsatisfied error` (the default) and nothing else already decided why
|
|
1181
|
+
* the request stopped.
|
|
1182
|
+
*/
|
|
1183
|
+
markResultUnsatisfied() {
|
|
1184
|
+
this.resultUnsatisfied = true;
|
|
1185
|
+
this.requestScope.cancel('error');
|
|
1186
|
+
if (this.errorCode === null) {
|
|
1187
|
+
this.errorCode = 'result_schema_unsatisfied';
|
|
1188
|
+
this.errorMessage = `No result matching ${this.opts.resultSchemaName ?? 'the schema'} was submitted.`;
|
|
1189
|
+
}
|
|
1190
|
+
const contractIsTheCause = this.requestScope.cancelledReason() === 'error';
|
|
1191
|
+
if (contractIsTheCause && this.stopReason === 'end_turn'
|
|
1192
|
+
&& this.opts.onResultUnsatisfied !== 'continue') {
|
|
1193
|
+
// THE PROCESS STOP REASON *AND* THE FLAG THE EXIT CODE READS. `exitCode()` is
|
|
1194
|
+
// `errored ? 1 : 0` for everything that is not a signal or a budget, so setting
|
|
1195
|
+
// only `stopReason` would report the failure in `result` while exiting 0 - the
|
|
1196
|
+
// one combination a shell script cannot defend against.
|
|
1197
|
+
this.stopReason = 'error';
|
|
1198
|
+
this.errored = true;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Stop the request for a budget reason, and record the contract failure it implies.
|
|
1203
|
+
*
|
|
1204
|
+
* THE CANCELLATION GUARD IS THE POINT. A caller that withdrew this request has not
|
|
1205
|
+
* failed to deliver a result, so `cancel_request` must not leave a
|
|
1206
|
+
* `result_schema_unsatisfied` behind - "the work was called off" and "the answer
|
|
1207
|
+
* never arrived" are different facts and only the second is a contract failure
|
|
1208
|
+
* (section 2.3.7). One helper rather than three copies, because the copy that
|
|
1209
|
+
* forgets this line is a defect nobody notices for a release.
|
|
1210
|
+
*/
|
|
1211
|
+
haltRequestOnBudget() {
|
|
1212
|
+
if (!this.stopRequestOnBudget())
|
|
1213
|
+
return false;
|
|
1214
|
+
if (this.opts.resultSink &&
|
|
1215
|
+
!this.opts.resultSink.accepted() &&
|
|
1216
|
+
this.requestScope.cancelledReason() !== 'cancelled') {
|
|
1217
|
+
this.markResultUnsatisfied();
|
|
1218
|
+
}
|
|
1219
|
+
return true;
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* The request-level budgets, at a boundary the engine can survive.
|
|
1223
|
+
*
|
|
1224
|
+
* RETURNS TRUE WHEN IT STOPPED THE REQUEST. The process is never touched: calling
|
|
1225
|
+
* `controller.abort()` ends the model's current loop, and the next `prompt()` starts
|
|
1226
|
+
* a fresh one - which is the whole of I-R1.
|
|
1227
|
+
*/
|
|
1228
|
+
stopRequestOnBudget() {
|
|
1229
|
+
if (this.requestScope.cancelledReason() !== null)
|
|
1230
|
+
return true;
|
|
1231
|
+
if (this.requestScope.turnsExhausted()) {
|
|
1232
|
+
this.requestScope.cancel('max_turns');
|
|
1233
|
+
}
|
|
1234
|
+
else if (this.requestScope.isOverDurationBudget(this.now())) {
|
|
1235
|
+
// THE LAZY HALF OF THE TIMER. A `setTimeout` can be late by an arbitrary amount
|
|
1236
|
+
// when the loop is busy, and a budget that only exists in a timer is a budget
|
|
1237
|
+
// that sometimes does not exist.
|
|
1238
|
+
this.requestScope.cancel('timeout');
|
|
1239
|
+
}
|
|
1240
|
+
else {
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
this.bridge?.abortInFlight();
|
|
1244
|
+
this.controller?.abort();
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* "ACCEPTED MEANS FINISHED", enforced at the one event that follows a tool result
|
|
1249
|
+
* (section 2.3.6).
|
|
1250
|
+
*
|
|
1251
|
+
* `turn_end` fires BEFORE `tool_execution_start`, so by the time this runs the turn
|
|
1252
|
+
* has already been counted and `agent_end` cannot mistake the abort for a run that
|
|
1253
|
+
* produced nothing.
|
|
1254
|
+
*/
|
|
1255
|
+
checkResultComplete() {
|
|
1256
|
+
if (!this.opts.resultSink?.accepted())
|
|
1257
|
+
return;
|
|
1258
|
+
// PER REQUEST, NOT PER PROCESS (P1-5): the latch lives in the scope, so the second
|
|
1259
|
+
// request of a long-lived process aborts itself exactly as the first one did - and
|
|
1260
|
+
// a request that never submitted anything is never aborted by a stale value.
|
|
1261
|
+
if (this.requestScope.acceptanceAborted())
|
|
1262
|
+
return;
|
|
1263
|
+
this.requestScope.markAcceptanceAborted();
|
|
1264
|
+
// THE SAME ABORT PATH AS THE BUDGET AND Esc, so subagents are torn down and the
|
|
1265
|
+
// engine settles normally instead of being abandoned mid-turn.
|
|
1266
|
+
this.controller?.abort();
|
|
1267
|
+
}
|
|
504
1268
|
// -----------------------------------------------------------------------
|
|
505
1269
|
// Budgets (section 3.5)
|
|
506
1270
|
// -----------------------------------------------------------------------
|
|
@@ -588,6 +1352,12 @@ export class ExecRunner {
|
|
|
588
1352
|
* this and add the getter.
|
|
589
1353
|
*/
|
|
590
1354
|
installTerminator() {
|
|
1355
|
+
// THE OPTION IS CHECKED HERE AS WELL AS AT THE CALL SITE (section 2.3.8a, R-13).
|
|
1356
|
+
// Two guards for one rule is not redundancy when the rule is "a second install
|
|
1357
|
+
// silently disables the first": a future `attach()` that forgets the call-site guard
|
|
1358
|
+
// still cannot take the terminator away from the thread owner.
|
|
1359
|
+
if (this.opts.installSignalTerminator === false)
|
|
1360
|
+
return;
|
|
591
1361
|
this.signals.setTerminator((signo) => this.onSignal(signo));
|
|
592
1362
|
}
|
|
593
1363
|
/**
|
|
@@ -620,13 +1390,50 @@ export class ExecRunner {
|
|
|
620
1390
|
this.lastSignalAt = at;
|
|
621
1391
|
this.signalNumber = signo;
|
|
622
1392
|
this.stopReason = 'interrupted';
|
|
1393
|
+
// BEFORE THE ABORT, AND SYNCHRONOUSLY. A decision wait is inside a tool
|
|
1394
|
+
// execution, and the executor abandons a tool 5 seconds after the abort; a
|
|
1395
|
+
// bridge that only learned about the interrupt from a later event would hand
|
|
1396
|
+
// the model "did not stop within 5000ms of abort" instead of "the turn was
|
|
1397
|
+
// interrupted" (AC-49). Settling first also means the tool's own promise has
|
|
1398
|
+
// already resolved when `abort()` reaches the executor.
|
|
1399
|
+
this.bridge?.abortInFlight();
|
|
623
1400
|
this.controller?.abort();
|
|
624
1401
|
}
|
|
625
1402
|
// -----------------------------------------------------------------------
|
|
626
1403
|
// Event translation
|
|
627
1404
|
// -----------------------------------------------------------------------
|
|
1405
|
+
/**
|
|
1406
|
+
* THE ONE STAMPING POINT (section 2.3.10, I-T10; review P1-1).
|
|
1407
|
+
*
|
|
1408
|
+
* NOT `correlation()`, which returns only `{requestId}` and is spread into a handful of
|
|
1409
|
+
* events: the thread id has to be on EVERY line this runner produces, including the ones
|
|
1410
|
+
* that carry no request (a `todo` snapshot, a `job` update). Stamping here and in
|
|
1411
|
+
* `writeToBridge` covers the whole file - and AC-54 turns the claim into a single
|
|
1412
|
+
* universal assertion ("every line with a sessionId has a threadId") rather than a
|
|
1413
|
+
* per-event checklist that a new event can quietly escape.
|
|
1414
|
+
*/
|
|
628
1415
|
emit(event) {
|
|
629
|
-
this.emitter.emit(
|
|
1416
|
+
this.emitter.emit(this.opts.threadId !== undefined
|
|
1417
|
+
? { ...event, threadId: this.opts.threadId }
|
|
1418
|
+
: event);
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* The same stamp for the frames that bypass `emit()`.
|
|
1422
|
+
*
|
|
1423
|
+
* SIX SITES USE THIS AND THEY ARE ALL ACKS AND CONTROL FRAMES: steer ack, interrupt ack,
|
|
1424
|
+
* the cancelled `request_end`, the request ack, the settled `request_end` and the
|
|
1425
|
+
* `steering` receipt. A seventh added later that writes to `bridge` directly would lose
|
|
1426
|
+
* the stamp - which is why the method exists rather than six copies of a conditional
|
|
1427
|
+
* spread.
|
|
1428
|
+
*/
|
|
1429
|
+
writeToBridge(event) {
|
|
1430
|
+
if (!this.bridge)
|
|
1431
|
+
return;
|
|
1432
|
+
this.bridge.write(this.opts.threadId !== undefined ? { ...event, threadId: this.opts.threadId } : event);
|
|
1433
|
+
}
|
|
1434
|
+
/** The correlation fields every attributable event carries in bridge mode. */
|
|
1435
|
+
correlation() {
|
|
1436
|
+
return this.bridge && this.requestId ? { requestId: this.requestId } : {};
|
|
630
1437
|
}
|
|
631
1438
|
/** Caller-facing prose. STDERR ALWAYS - stdout carries nothing but the schema. */
|
|
632
1439
|
note(text) {
|
|
@@ -654,6 +1461,7 @@ export class ExecRunner {
|
|
|
654
1461
|
id: event.toolCallId,
|
|
655
1462
|
name: event.toolName,
|
|
656
1463
|
input: (event.args ?? {}),
|
|
1464
|
+
...this.correlation(),
|
|
657
1465
|
});
|
|
658
1466
|
break;
|
|
659
1467
|
case 'tool_execution_end':
|
|
@@ -667,6 +1475,29 @@ export class ExecRunner {
|
|
|
667
1475
|
isError: event.isError,
|
|
668
1476
|
durationMs: event.duration,
|
|
669
1477
|
output: toolResultText(event.result),
|
|
1478
|
+
...this.correlation(),
|
|
1479
|
+
});
|
|
1480
|
+
// AFTER THE `tool_result`, SO THE CALLER SEES THE ACCEPTANCE BEFORE THE ABORT
|
|
1481
|
+
// THAT FOLLOWS IT. `turn_end` has already fired (it precedes the tool call), so
|
|
1482
|
+
// the abort cannot be mistaken for a turn that produced nothing.
|
|
1483
|
+
this.checkResultComplete();
|
|
1484
|
+
break;
|
|
1485
|
+
case 'steering_accepted':
|
|
1486
|
+
// ONE EVENT, THE WHOLE BATCH (review P1-7). Core's receipt carries every
|
|
1487
|
+
// id a single drain accepted; reporting only the first would leave the rest
|
|
1488
|
+
// permanently unconfirmed on the caller's side, which is the exact failure
|
|
1489
|
+
// the receipt exists to prevent.
|
|
1490
|
+
//
|
|
1491
|
+
// IT ALSO CONFIRMS THE `turn_start` INJECTION (section 2.4.6): this is the only
|
|
1492
|
+
// signal that the text actually entered history, and clearing the pending marker
|
|
1493
|
+
// here is what keeps that fact from being misreported as stranded.
|
|
1494
|
+
this.pendingInjection = null;
|
|
1495
|
+
this.steeringReceipts += 1;
|
|
1496
|
+
this.writeToBridge({
|
|
1497
|
+
type: 'steering',
|
|
1498
|
+
sessionId: this.sessionId,
|
|
1499
|
+
steeringIds: [...event.ids],
|
|
1500
|
+
...this.correlation(),
|
|
670
1501
|
});
|
|
671
1502
|
break;
|
|
672
1503
|
case 'turn_end':
|
|
@@ -737,13 +1568,23 @@ export class ExecRunner {
|
|
|
737
1568
|
onTurnEnd(message, usage) {
|
|
738
1569
|
this.sawTurnEnd = true;
|
|
739
1570
|
this.turns += 1;
|
|
1571
|
+
// THE SAME MOMENT AS THE PROCESS COUNTER, DELIBERATELY: two counters over one
|
|
1572
|
+
// event is one fact seen twice; two counters over two events is two facts that can
|
|
1573
|
+
// disagree, and the budget would be enforced against the wrong one.
|
|
1574
|
+
this.requestScope.countTurn();
|
|
740
1575
|
this.usage.inputTokens += usage.inputTokens;
|
|
741
1576
|
this.usage.outputTokens += usage.outputTokens;
|
|
742
1577
|
this.flushThinking();
|
|
743
1578
|
const text = assistantText(message);
|
|
744
1579
|
if (text.length > 0) {
|
|
745
1580
|
this.lastAssistantText = text;
|
|
746
|
-
this.emit({
|
|
1581
|
+
this.emit({
|
|
1582
|
+
type: 'assistant',
|
|
1583
|
+
sessionId: this.sessionId,
|
|
1584
|
+
turn: this.turns,
|
|
1585
|
+
text,
|
|
1586
|
+
...this.correlation(),
|
|
1587
|
+
});
|
|
747
1588
|
}
|
|
748
1589
|
}
|
|
749
1590
|
flushThinking() {
|
|
@@ -823,6 +1664,17 @@ function assistantText(message) {
|
|
|
823
1664
|
}
|
|
824
1665
|
return parts.join('').trim();
|
|
825
1666
|
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Clip one model-visible string to a bound the frame can carry.
|
|
1669
|
+
*
|
|
1670
|
+
* `request_end.result` is the ONLY place in this file where the full answer is
|
|
1671
|
+
* truncated, and the bound comes from `REQUEST_LIMITS` rather than from a literal: the
|
|
1672
|
+
* complete text is still on the `assistant` events, so this is a frame decision, not a
|
|
1673
|
+
* data-loss decision.
|
|
1674
|
+
*/
|
|
1675
|
+
function clipText(text, max) {
|
|
1676
|
+
return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 3))}...`;
|
|
1677
|
+
}
|
|
826
1678
|
/**
|
|
827
1679
|
* Flatten a `ToolResult` to the text the MODEL saw.
|
|
828
1680
|
*
|