@sagentlab/navarch-runtime 0.1.131 → 0.1.132
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/dist/adapters/claude.cjs +148 -10
- package/dist/prompt.cjs +15 -0
- package/dist/steering.cjs +82 -25
- package/package.json +1 -1
package/dist/adapters/claude.cjs
CHANGED
|
@@ -247,12 +247,25 @@ function attachSession(result, options) {
|
|
|
247
247
|
...(rejected ? { resumeRejected: true } : {}),
|
|
248
248
|
};
|
|
249
249
|
}
|
|
250
|
-
/**
|
|
250
|
+
/**
|
|
251
|
+
* Parses the `claude -p --output-format stream-json` result records for usage
|
|
252
|
+
* and folds them onto the raw result (best-effort; leaves
|
|
253
|
+
* tokensIn/tokensOut/costUsd unset when parsing fails). A streaming launch runs
|
|
254
|
+
* one turn per interrupt or late message, each ending in its own `result`:
|
|
255
|
+
* `usage` covers that turn alone, so the tokens are summed, while
|
|
256
|
+
* `total_cost_usd` is already the process's running total, so the last one counts.
|
|
257
|
+
*/
|
|
251
258
|
function attachUsage(result) {
|
|
252
259
|
const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout);
|
|
253
260
|
if (!parsed)
|
|
254
261
|
return result;
|
|
255
262
|
const usage = (0, exit_conditions_cjs_1.extractUsageFromClaudeJson)(parsed);
|
|
263
|
+
const turns = streamResultRecords(result.stdout).map(exit_conditions_cjs_1.extractUsageFromClaudeJson);
|
|
264
|
+
if (turns.length > 1) {
|
|
265
|
+
usage.tokensIn = turns.reduce((sum, turn) => sum + turn.tokensIn, 0);
|
|
266
|
+
usage.tokensOut = turns.reduce((sum, turn) => sum + turn.tokensOut, 0);
|
|
267
|
+
usage.cacheHitTokensIn = turns.reduce((sum, turn) => sum + turn.cacheHitTokensIn, 0);
|
|
268
|
+
}
|
|
256
269
|
return {
|
|
257
270
|
...result,
|
|
258
271
|
tokensIn: usage.tokensIn,
|
|
@@ -261,6 +274,26 @@ function attachUsage(result) {
|
|
|
261
274
|
costUsd: usage.costUsd,
|
|
262
275
|
};
|
|
263
276
|
}
|
|
277
|
+
/** Every stream-json `type: "result"` record, one per turn the process ran. */
|
|
278
|
+
function streamResultRecords(stdout) {
|
|
279
|
+
const records = [];
|
|
280
|
+
for (const line of stdout.split("\n")) {
|
|
281
|
+
const trimmed = line.trim();
|
|
282
|
+
if (!trimmed.startsWith("{"))
|
|
283
|
+
continue;
|
|
284
|
+
try {
|
|
285
|
+
const record = JSON.parse(trimmed);
|
|
286
|
+
if (typeof record === "object" && record !== null && !Array.isArray(record) &&
|
|
287
|
+
record.type === "result") {
|
|
288
|
+
records.push(record);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return records;
|
|
296
|
+
}
|
|
264
297
|
async function runOnHost(options, args) {
|
|
265
298
|
return new Promise((resolve) => {
|
|
266
299
|
let stdout = "";
|
|
@@ -316,6 +349,12 @@ async function runOnHost(options, args) {
|
|
|
316
349
|
}
|
|
317
350
|
/** How long the process may stay open for a written message the agent has not echoed after its turn ended. */
|
|
318
351
|
const UNECHOED_INPUT_GRACE_MS = 30_000;
|
|
352
|
+
/**
|
|
353
|
+
* How long an interrupt waits for the stopped turn's `result` before its input
|
|
354
|
+
* is written anyway. Spike S1 saw the result at once; a CLI that ignores the
|
|
355
|
+
* request then reads the message at its next step, which the ack reports as a steer.
|
|
356
|
+
*/
|
|
357
|
+
const INTERRUPT_RESULT_TIMEOUT_MS = 15_000;
|
|
319
358
|
/**
|
|
320
359
|
* `claude -p --input-format stream-json --replay-user-messages`: the prompt is
|
|
321
360
|
* the first user message, and stdin stays open so a steering message written
|
|
@@ -329,6 +368,14 @@ const UNECHOED_INPUT_GRACE_MS = 30_000;
|
|
|
329
368
|
* in the pipe even after stdin ends and runs it as a further turn, but a
|
|
330
369
|
* message written after the process has finished is silently dropped, which is
|
|
331
370
|
* why `send` refuses once input is closed.
|
|
371
|
+
*
|
|
372
|
+
* Native interrupt (spike S1, claude 2.1.284 and 2.1.304): the control request
|
|
373
|
+
* `{"type":"control_request","request":{"subtype":"interrupt"}}` stops the
|
|
374
|
+
* running tool and ends the turn with a `result` of subtype
|
|
375
|
+
* `error_during_execution`, and the process and conversation stay. The
|
|
376
|
+
* interrupt's message is written only after that `result`, so it starts the
|
|
377
|
+
* next turn instead of joining the stopped one, and every message sent while
|
|
378
|
+
* the interrupt is pending waits behind it, in order (as the hosted relay does).
|
|
332
379
|
*/
|
|
333
380
|
async function runOnHostStreaming(options, args, live) {
|
|
334
381
|
return new Promise((resolve) => {
|
|
@@ -339,8 +386,14 @@ async function runOnHostStreaming(options, args, live) {
|
|
|
339
386
|
let inputOpen = true;
|
|
340
387
|
let lineBuffer = "";
|
|
341
388
|
let graceTimer;
|
|
389
|
+
let interruptTimer;
|
|
390
|
+
let interruptRequests = 0;
|
|
391
|
+
// A turn is running: the prompt starts one, and a `result` ends it.
|
|
392
|
+
let turnRunning = true;
|
|
342
393
|
// Written steering messages the agent has not echoed yet.
|
|
343
394
|
const pending = new Map();
|
|
395
|
+
// Inputs waiting for the turn an interrupt is stopping, in the order they were sent.
|
|
396
|
+
const held = [];
|
|
344
397
|
const child = (0, node_child_process_1.spawn)(options.bin, args, {
|
|
345
398
|
detached: process.platform !== "win32",
|
|
346
399
|
cwd: options.cwd,
|
|
@@ -374,20 +427,78 @@ async function runOnHostStreaming(options, args, live) {
|
|
|
374
427
|
detach();
|
|
375
428
|
child.stdin?.end();
|
|
376
429
|
};
|
|
430
|
+
const canWrite = () => inputOpen && child.exitCode === null && !child.killed;
|
|
431
|
+
/** Writes one steering input. A message written while no turn runs starts the next one. */
|
|
432
|
+
const writeInput = (input, state) => {
|
|
433
|
+
try {
|
|
434
|
+
writeUser(input.id, input.text);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
pending.set(input.id, state);
|
|
440
|
+
return true;
|
|
441
|
+
};
|
|
442
|
+
/**
|
|
443
|
+
* Writes what waited for the interrupted turn. `stopped` is null when the
|
|
444
|
+
* turn has not ended (no `result` came in time), so the inputs join it.
|
|
445
|
+
*/
|
|
446
|
+
const releaseHeld = (stopped) => {
|
|
447
|
+
clearTimeout(interruptTimer);
|
|
448
|
+
interruptTimer = undefined;
|
|
449
|
+
// Input closed meanwhile: nothing is written, and the session takes them back.
|
|
450
|
+
if (!canWrite()) {
|
|
451
|
+
held.length = 0;
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
// The first input (the interrupt that sent the request) starts the next
|
|
455
|
+
// turn and owns the stop; the rest join that turn.
|
|
456
|
+
let first = true;
|
|
457
|
+
for (const { input } of held.splice(0)) {
|
|
458
|
+
writeInput(input, {
|
|
459
|
+
afterTurnEnd: first && stopped !== null,
|
|
460
|
+
stoppedTurn: first && stopped === true,
|
|
461
|
+
});
|
|
462
|
+
first = false;
|
|
463
|
+
}
|
|
464
|
+
turnRunning = true;
|
|
465
|
+
};
|
|
377
466
|
const promptId = (0, node_crypto_1.randomUUID)();
|
|
378
467
|
writeUser(promptId, options.prompt);
|
|
379
468
|
attached = true;
|
|
380
469
|
live.attach({
|
|
381
470
|
send: (input) => {
|
|
382
|
-
if (!
|
|
471
|
+
if (!canWrite())
|
|
383
472
|
return false;
|
|
473
|
+
// Behind a pending interrupt, so the interrupt stays the next input.
|
|
474
|
+
if (held.length > 0) {
|
|
475
|
+
held.push({ input, interrupt: false });
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
return writeInput(input, { afterTurnEnd: !turnRunning, stoppedTurn: false });
|
|
479
|
+
},
|
|
480
|
+
interrupt: (input) => {
|
|
481
|
+
if (!canWrite())
|
|
482
|
+
return false;
|
|
483
|
+
// Nothing to stop: the message starts the next turn, a boundary delivery.
|
|
484
|
+
if (!turnRunning)
|
|
485
|
+
return writeInput(input, { afterTurnEnd: true, stoppedTurn: false });
|
|
486
|
+
held.push({ input, interrupt: true });
|
|
487
|
+
if (held.length > 1)
|
|
488
|
+
return true;
|
|
384
489
|
try {
|
|
385
|
-
|
|
490
|
+
interruptRequests += 1;
|
|
491
|
+
child.stdin?.write(`${JSON.stringify({
|
|
492
|
+
type: "control_request",
|
|
493
|
+
request_id: `navarch-interrupt-${interruptRequests}`,
|
|
494
|
+
request: { subtype: "interrupt" },
|
|
495
|
+
})}\n`);
|
|
386
496
|
}
|
|
387
497
|
catch {
|
|
498
|
+
held.length = 0;
|
|
388
499
|
return false;
|
|
389
500
|
}
|
|
390
|
-
|
|
501
|
+
interruptTimer = setTimeout(() => releaseHeld(null), INTERRUPT_RESULT_TIMEOUT_MS);
|
|
391
502
|
return true;
|
|
392
503
|
},
|
|
393
504
|
});
|
|
@@ -405,7 +516,7 @@ async function runOnHostStreaming(options, args, live) {
|
|
|
405
516
|
}
|
|
406
517
|
if (typeof record !== "object" || record === null || Array.isArray(record))
|
|
407
518
|
return true;
|
|
408
|
-
const { type, uuid, isReplay } = record;
|
|
519
|
+
const { type, subtype, uuid, isReplay } = record;
|
|
409
520
|
if (type === "user" && isReplay === true && typeof uuid === "string") {
|
|
410
521
|
// The echo of the task brief would copy the whole prompt into every
|
|
411
522
|
// transcript; a one-shot launch never prints it. Steering messages are
|
|
@@ -416,13 +527,33 @@ async function runOnHostStreaming(options, args, live) {
|
|
|
416
527
|
if (!entry)
|
|
417
528
|
return true;
|
|
418
529
|
pending.delete(uuid);
|
|
419
|
-
|
|
530
|
+
// The agent took it in, so a turn is running again; its `result`
|
|
531
|
+
// decides whether input can close.
|
|
532
|
+
turnRunning = true;
|
|
533
|
+
clearTimeout(graceTimer);
|
|
534
|
+
live.consumed(uuid, entry.afterTurnEnd, entry.stoppedTurn);
|
|
535
|
+
}
|
|
536
|
+
else if (type === "control_response" && held.length > 0) {
|
|
537
|
+
// A CLI that refuses the interrupt stops nothing: the waiting inputs
|
|
538
|
+
// join the running turn now instead of after the 15 s fallback.
|
|
539
|
+
const response = record.response;
|
|
540
|
+
if (response?.subtype === "error" && response.request_id === `navarch-interrupt-${interruptRequests}`) {
|
|
541
|
+
releaseHeld(null);
|
|
542
|
+
}
|
|
420
543
|
}
|
|
421
544
|
else if (type === "result") {
|
|
422
545
|
// A turn ended. A message written during it that was not echoed will
|
|
423
|
-
// start the next turn, so it now counts as consumed after the boundary
|
|
546
|
+
// start the next turn, so it now counts as consumed after the boundary,
|
|
547
|
+
// and that next turn counts as running before its echo arrives: an
|
|
548
|
+
// interrupt in that gap must still stop it.
|
|
549
|
+
turnRunning = pending.size > 0;
|
|
424
550
|
for (const entry of pending.values())
|
|
425
551
|
entry.afterTurnEnd = true;
|
|
552
|
+
// An interrupt was waiting for this turn to end. Only a turn the request
|
|
553
|
+
// cut short ends in `error_during_execution`; one that finished first
|
|
554
|
+
// was not stopped, and its message is a boundary delivery.
|
|
555
|
+
if (held.length > 0)
|
|
556
|
+
releaseHeld(subtype === "error_during_execution");
|
|
426
557
|
if (pending.size === 0)
|
|
427
558
|
closeInput();
|
|
428
559
|
else {
|
|
@@ -468,7 +599,9 @@ async function runOnHostStreaming(options, args, live) {
|
|
|
468
599
|
});
|
|
469
600
|
const finish = (exitCode) => {
|
|
470
601
|
clearTimeout(timer);
|
|
471
|
-
|
|
602
|
+
// An input still waiting for an interrupted turn was never written: the
|
|
603
|
+
// session takes it back with every message the agent did not echo.
|
|
604
|
+
held.length = 0;
|
|
472
605
|
inputOpen = false;
|
|
473
606
|
detach();
|
|
474
607
|
if (lineBuffer) {
|
|
@@ -480,6 +613,9 @@ async function runOnHostStreaming(options, args, live) {
|
|
|
480
613
|
options.onOutput?.("stdout", rest);
|
|
481
614
|
}
|
|
482
615
|
}
|
|
616
|
+
// After the last line: a trailing `result` there can arm the grace timer.
|
|
617
|
+
clearTimeout(graceTimer);
|
|
618
|
+
clearTimeout(interruptTimer);
|
|
483
619
|
options.signal?.removeEventListener("abort", onAbort);
|
|
484
620
|
resolve({ exitCode, timedOut, killedByLeaseLoss, stdout, stderr });
|
|
485
621
|
};
|
|
@@ -524,7 +660,9 @@ function readClaudeHelp(bin, env) {
|
|
|
524
660
|
/**
|
|
525
661
|
* Native steering needs `--input-format stream-json` and
|
|
526
662
|
* `--replay-user-messages`, the host path (Docker `exec -i` has no streaming
|
|
527
|
-
* runner here), and the stream formats left to Navarch.
|
|
663
|
+
* runner here), and the stream formats left to Navarch. The same streaming
|
|
664
|
+
* process takes the interrupt control request (spike S1), so native steering
|
|
665
|
+
* also brings native interrupt; without it, interrupt stays `restart`.
|
|
528
666
|
*/
|
|
529
667
|
async function probeClaudeSteering(context) {
|
|
530
668
|
if (context.sandboxMode !== "host")
|
|
@@ -534,7 +672,7 @@ async function probeClaudeSteering(context) {
|
|
|
534
672
|
const help = await readClaudeHelp(context.bin, context.env);
|
|
535
673
|
if (!/--input-format\b/.test(help) || !/--replay-user-messages\b/.test(help))
|
|
536
674
|
return null;
|
|
537
|
-
return { steer: "native", queue: "boundary", interrupt: "
|
|
675
|
+
return { steer: "native", queue: "boundary", interrupt: "native" };
|
|
538
676
|
}
|
|
539
677
|
async function runViaDocker(options, args) {
|
|
540
678
|
const { containerName, runner } = options.dockerExec;
|
package/dist/prompt.cjs
CHANGED
|
@@ -4,6 +4,7 @@ exports.renderPrompt = renderPrompt;
|
|
|
4
4
|
exports.renderGuidanceCorrectionPrompt = renderGuidanceCorrectionPrompt;
|
|
5
5
|
exports.renderMidFlightGuidanceSection = renderMidFlightGuidanceSection;
|
|
6
6
|
exports.renderSteerInjection = renderSteerInjection;
|
|
7
|
+
exports.renderInterruptInjection = renderInterruptInjection;
|
|
7
8
|
exports.steeringPromptNote = steeringPromptNote;
|
|
8
9
|
exports.renderResumedTurnPreamble = renderResumedTurnPreamble;
|
|
9
10
|
exports.renderResumedGuidancePrompt = renderResumedGuidancePrompt;
|
|
@@ -133,6 +134,20 @@ function renderSteerInjection(entry) {
|
|
|
133
134
|
block,
|
|
134
135
|
].join("\n");
|
|
135
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* The input a native interrupt hands the agent once its turn has stopped: the
|
|
139
|
+
* interrupt and every earlier message it carries, in `seq` order (section 6.4).
|
|
140
|
+
* The text is written before the outcome is known (the turn may have finished
|
|
141
|
+
* first), so it does not claim that a step was cut short.
|
|
142
|
+
*/
|
|
143
|
+
function renderInterruptInjection(entries) {
|
|
144
|
+
const blocks = entries.map((entry) => entry.steering ? steeringBlock(entry, entry.steering).trimEnd() : entry.content);
|
|
145
|
+
return [
|
|
146
|
+
"A teammate sent an interrupt: stop what you were doing and follow the messages below. They take precedence over earlier context. Continue in the same worktree. If your last step was cut short, it may have left half-finished edits, so run `git status` and review the recent changes first. If you already received a message with the same id, ignore this repeat.",
|
|
147
|
+
"",
|
|
148
|
+
blocks.join("\n\n"),
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
136
151
|
/**
|
|
137
152
|
* The brief section that makes live messages credible and spoof-resistant. The
|
|
138
153
|
* token is generated per lease and appears nowhere an attacker's content could
|
package/dist/steering.cjs
CHANGED
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
// Most adapters take no input during a turn, so their messages are delivered
|
|
13
13
|
// at the turn boundary or by restarting the turn. An adapter that reports
|
|
14
14
|
// `steer: native` (Claude Code over streaming input) also takes a message into
|
|
15
|
-
// the running turn, and confirms it when the agent has read it.
|
|
15
|
+
// the running turn, and confirms it when the agent has read it. One that also
|
|
16
|
+
// reports `interrupt: native` stops the running turn in the same process and
|
|
17
|
+
// hands the agent an `interrupt_native` message as the next turn, with every
|
|
18
|
+
// earlier message that was still waiting (section 6.4).
|
|
16
19
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
20
|
exports.SteeringSession = void 0;
|
|
18
21
|
exports.liveSteeringCapabilities = liveSteeringCapabilities;
|
|
@@ -95,7 +98,7 @@ class SteeringSession {
|
|
|
95
98
|
/** What the adapter calls while it runs a turn that takes input (native steering). */
|
|
96
99
|
liveInput = {
|
|
97
100
|
attach: (channel) => this.attachChannel(channel),
|
|
98
|
-
consumed: (id, afterTurnEnd) => this.steerConsumed(id, afterTurnEnd),
|
|
101
|
+
consumed: (id, afterTurnEnd, stoppedTurn) => this.steerConsumed(id, afterTurnEnd, stoppedTurn ?? false),
|
|
99
102
|
};
|
|
100
103
|
constructor(options) {
|
|
101
104
|
this.options = options;
|
|
@@ -203,9 +206,12 @@ class SteeringSession {
|
|
|
203
206
|
*/
|
|
204
207
|
turnEnded() {
|
|
205
208
|
this.channel = null;
|
|
206
|
-
for (const entry of this.entries.values())
|
|
207
|
-
if (entry.phase
|
|
208
|
-
|
|
209
|
+
for (const entry of this.entries.values()) {
|
|
210
|
+
if (entry.phase !== "sent")
|
|
211
|
+
continue;
|
|
212
|
+
entry.phase = "held";
|
|
213
|
+
entry.carriedBy = null;
|
|
214
|
+
}
|
|
209
215
|
}
|
|
210
216
|
/** The agent process for the turn started, so it holds the messages: report them delivered. */
|
|
211
217
|
markInjected(taken) {
|
|
@@ -268,10 +274,43 @@ class SteeringSession {
|
|
|
268
274
|
this.channel = channel;
|
|
269
275
|
if (!channel)
|
|
270
276
|
return;
|
|
271
|
-
// A
|
|
272
|
-
|
|
277
|
+
// A message that arrived after this turn's prompt was built has no other way
|
|
278
|
+
// in. The latest waiting interrupt carries everything before it, so a steer
|
|
279
|
+
// written first cannot overtake it; later steers follow it.
|
|
280
|
+
const interrupt = this.bySeq().filter((entry) => entry.phase === "held" &&
|
|
281
|
+
entry.message.effective_mode === "interrupt_native").at(-1);
|
|
282
|
+
if (interrupt)
|
|
283
|
+
this.tryInterrupt(interrupt);
|
|
284
|
+
for (const entry of this.bySeq())
|
|
273
285
|
this.trySteer(entry);
|
|
274
286
|
}
|
|
287
|
+
bySeq() {
|
|
288
|
+
return [...this.entries.values()].sort((a, b) => a.message.seq - b.message.seq);
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Stops the running turn natively and hands the agent the interrupt, with
|
|
292
|
+
* every earlier message still waiting, as one input in `seq` order (section
|
|
293
|
+
* 6.4). Left held when there is no channel that can interrupt: the next turn
|
|
294
|
+
* then carries it, and the ack reports a boundary delivery.
|
|
295
|
+
*/
|
|
296
|
+
tryInterrupt(entry) {
|
|
297
|
+
if (entry.phase !== "held" || entry.message.effective_mode !== "interrupt_native" || !this.channel?.interrupt)
|
|
298
|
+
return;
|
|
299
|
+
const carried = this.bySeq().filter((other) => other.phase === "held" && other.message.seq <= entry.message.seq);
|
|
300
|
+
const sent = this.channel.interrupt({
|
|
301
|
+
id: entry.message.id,
|
|
302
|
+
text: (0, prompt_cjs_1.renderInterruptInjection)(carried.map((other) => this.guidanceFor(other.message, other === entry))),
|
|
303
|
+
});
|
|
304
|
+
if (!sent)
|
|
305
|
+
return;
|
|
306
|
+
for (const other of carried) {
|
|
307
|
+
other.phase = "sent";
|
|
308
|
+
other.carriedBy = entry.message.id;
|
|
309
|
+
}
|
|
310
|
+
const earlier = carried.slice(0, -1).map((other) => other.message.seq);
|
|
311
|
+
log.info(`interrupt message ${entry.message.id} (seq ${entry.message.seq}) is stopping the running turn` +
|
|
312
|
+
`${earlier.length > 0 ? `, carrying seq ${earlier.join(", ")}` : ""}.`);
|
|
313
|
+
}
|
|
275
314
|
/** Writes a held `steer` message to the running agent. Left held when there is no open input. */
|
|
276
315
|
trySteer(entry) {
|
|
277
316
|
if (entry.phase !== "held" || entry.message.effective_mode !== "steer" || !this.channel)
|
|
@@ -285,30 +324,42 @@ class SteeringSession {
|
|
|
285
324
|
entry.phase = "sent";
|
|
286
325
|
log.info(`steer message ${entry.message.id} (seq ${entry.message.seq}) written to the running agent.`);
|
|
287
326
|
}
|
|
288
|
-
/**
|
|
289
|
-
|
|
327
|
+
/**
|
|
328
|
+
* The agent read a message written to its input: only now is it delivered.
|
|
329
|
+
* For a native interrupt, that is every message its input carried.
|
|
330
|
+
*/
|
|
331
|
+
steerConsumed(id, afterTurnEnd, stoppedTurn) {
|
|
290
332
|
const entry = this.entries.get(id);
|
|
291
333
|
if (!entry || entry.phase !== "sent")
|
|
292
334
|
return;
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
335
|
+
const batch = entry.carriedBy
|
|
336
|
+
? this.bySeq().filter((other) => other.phase === "sent" && other.carriedBy === entry.carriedBy)
|
|
337
|
+
: [entry];
|
|
338
|
+
for (const item of batch) {
|
|
339
|
+
item.phase = "done";
|
|
340
|
+
item.carriedBy = null;
|
|
341
|
+
// `interrupt_native` only for the interrupt whose input really stopped the
|
|
342
|
+
// running turn; what it carried is acked `queue`. Anything else the agent
|
|
343
|
+
// read only after its turn ended began the next turn: that is a boundary
|
|
344
|
+
// delivery, whatever it was sent as.
|
|
345
|
+
const interrupted = stoppedTurn && item.message.id === id;
|
|
346
|
+
this.options.onSteerConsumed?.(this.guidanceFor(item.message, interrupted));
|
|
347
|
+
const mode = interrupted ? "interrupt_native" : afterTurnEnd || stoppedTurn ? "queue" : "steer";
|
|
348
|
+
void this.sendAck(item.message.id, item.cancelRequested
|
|
349
|
+
? {
|
|
350
|
+
state: "too_late",
|
|
351
|
+
effective_mode: mode,
|
|
352
|
+
detail: "The cancel arrived after the message was handed to the agent.",
|
|
353
|
+
}
|
|
354
|
+
: { state: "delivered", effective_mode: mode });
|
|
355
|
+
}
|
|
305
356
|
}
|
|
306
357
|
ingestOne(message) {
|
|
307
358
|
const known = this.entries.get(message.id);
|
|
308
359
|
if (message.cancel_requested) {
|
|
309
360
|
if (!known) {
|
|
310
361
|
// Never seen, so never delivered: honor the cancel.
|
|
311
|
-
this.entries.set(message.id, { message, phase: "done", stoppedTurn: false, cancelRequested: true });
|
|
362
|
+
this.entries.set(message.id, { message, phase: "done", stoppedTurn: false, carriedBy: null, cancelRequested: true });
|
|
312
363
|
void this.sendAck(message.id, { state: "canceled", detail: "Canceled before the runtime read it." });
|
|
313
364
|
}
|
|
314
365
|
else if (known.phase === "held") {
|
|
@@ -329,10 +380,12 @@ class SteeringSession {
|
|
|
329
380
|
if (known.phase === "held" && known.message.effective_mode !== message.effective_mode) {
|
|
330
381
|
known.message = message;
|
|
331
382
|
this.stopTurnForInterrupt(known);
|
|
383
|
+
this.tryInterrupt(known);
|
|
384
|
+
this.trySteer(known);
|
|
332
385
|
}
|
|
333
386
|
return;
|
|
334
387
|
}
|
|
335
|
-
const entry = { message, phase: "held", stoppedTurn: false, cancelRequested: false };
|
|
388
|
+
const entry = { message, phase: "held", stoppedTurn: false, carriedBy: null, cancelRequested: false };
|
|
336
389
|
this.entries.set(message.id, entry);
|
|
337
390
|
const refusal = this.refusal(message);
|
|
338
391
|
if (refusal) {
|
|
@@ -342,6 +395,7 @@ class SteeringSession {
|
|
|
342
395
|
return;
|
|
343
396
|
}
|
|
344
397
|
this.stopTurnForInterrupt(entry);
|
|
398
|
+
this.tryInterrupt(entry);
|
|
345
399
|
this.trySteer(entry);
|
|
346
400
|
}
|
|
347
401
|
/** Why this runtime cannot deliver `message` as the control plane resolved it, or null. */
|
|
@@ -351,11 +405,14 @@ class SteeringSession {
|
|
|
351
405
|
case "queue":
|
|
352
406
|
return capabilities.queue === "none" ? "This runtime does not queue messages." : null;
|
|
353
407
|
case "interrupt_restart":
|
|
354
|
-
|
|
408
|
+
// A runtime that interrupts natively can still restart a turn: a message
|
|
409
|
+
// resolved against the claim's baseline record, before the probe's
|
|
410
|
+
// upgrade reached the control plane, is delivered as it was resolved.
|
|
411
|
+
return capabilities.interrupt === "none" ? "This runtime cannot restart a turn for an interrupt." : null;
|
|
355
412
|
case "steer":
|
|
356
413
|
return capabilities.steer === "native" ? null : "This runtime cannot inject a message into a running turn.";
|
|
357
414
|
case "interrupt_native":
|
|
358
|
-
return "This runtime cannot interrupt a running turn without restarting it.";
|
|
415
|
+
return capabilities.interrupt === "native" ? null : "This runtime cannot interrupt a running turn without restarting it.";
|
|
359
416
|
default:
|
|
360
417
|
return `This runtime cannot deliver a message resolved as ${String(message.effective_mode)}.`;
|
|
361
418
|
}
|
package/package.json
CHANGED