@vincemakes/kiso-runtime 0.1.19 → 0.1.21
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/agent.d.ts +6 -0
- package/dist/compose.d.ts +32 -0
- package/dist/compose.js +117 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/recovery.d.ts +28 -0
- package/dist/recovery.js +68 -0
- package/dist/run.d.ts +20 -0
- package/dist/run.js +463 -0
- package/dist/session.d.ts +32 -13
- package/dist/session.js +45 -631
- package/package.json +5 -5
package/dist/session.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* AgentSession
|
|
2
|
+
* AgentSession — the durable multi-turn conversation (Phase C/D).
|
|
3
3
|
*
|
|
4
4
|
* A session owns ONE EventLog, seeded from disk on load and continued in
|
|
5
5
|
* memory. Each `run(input)`:
|
|
@@ -22,10 +22,17 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Restart recovery is the same code path as a second run: rebuild the log
|
|
24
24
|
* from the JSONL, continue numbering where the file ended.
|
|
25
|
+
*
|
|
26
|
+
* 手感批 B4 (pure move): the Run class lives in run.ts, the recovery
|
|
27
|
+
* support in recovery.ts, the E1/E2 composition helpers in compose.ts —
|
|
28
|
+
* same package, same exports (index.ts re-exports all four).
|
|
25
29
|
*/
|
|
26
|
-
import { EventLog, executionLedger,
|
|
30
|
+
import { EventLog, executionLedger, projectMessages, } from "@vincemakes/kiso-core";
|
|
27
31
|
import { denialResult } from "@vincemakes/kiso-core";
|
|
32
|
+
import { estimateSummarySavings, KEEP_RECENT_ROUNDS, lastSummaryPoint, summarizeConversation, summaryBoundarySeq, } from "@vincemakes/kiso-core";
|
|
28
33
|
import { StaleWriterError } from "./store.js";
|
|
34
|
+
import { composeHooks } from "./compose.js";
|
|
35
|
+
import { Run } from "./run.js";
|
|
29
36
|
/** A session whose disk write was rejected (stale handle) is PERMANENTLY
|
|
30
37
|
* poisoned: its in-memory log no longer matches the disk, so no further
|
|
31
38
|
* run may proceed — reload the session (一). */
|
|
@@ -148,6 +155,42 @@ export class AgentSession {
|
|
|
148
155
|
this.ensureHealthy();
|
|
149
156
|
return new Run(this.#store, this.#adapter, this.#config, this, undefined, undefined, true);
|
|
150
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* /compact (ADR-0044): compress the older conversation with a model
|
|
160
|
+
* summary. Covers the range (previous summary point, boundary] —
|
|
161
|
+
* boundary = the event before the keepRounds-th most recent round —
|
|
162
|
+
* and persists ONE `summarized` event. The summary call is OFF-LOOP
|
|
163
|
+
* through the session's OWN adapter: it writes nothing; a failure
|
|
164
|
+
* throws and the session is unchanged ("nothing happened"). Returns
|
|
165
|
+
* null when fewer than keepRounds+1 uncovered rounds exist (nothing
|
|
166
|
+
* worth covering yet). Crash semantics: a crash BEFORE the persist is
|
|
167
|
+
* "nothing happened"; after it, a resume projects the compressed view.
|
|
168
|
+
*/
|
|
169
|
+
async summarize(options = {}) {
|
|
170
|
+
this.ensureHealthy();
|
|
171
|
+
const keepRounds = options.keepRounds ?? KEEP_RECENT_ROUNDS;
|
|
172
|
+
const events = this.log.all;
|
|
173
|
+
const boundary = summaryBoundarySeq(events, keepRounds);
|
|
174
|
+
if (boundary === undefined)
|
|
175
|
+
return null;
|
|
176
|
+
const prevPoint = lastSummaryPoint(events);
|
|
177
|
+
const covered = projectMessages(events.filter((e) => e.seq > prevPoint && e.seq <= boundary && e.type !== "summarized"));
|
|
178
|
+
const summary = await summarizeConversation({
|
|
179
|
+
adapter: this.#adapter,
|
|
180
|
+
model: this.#config.model,
|
|
181
|
+
messages: covered,
|
|
182
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
183
|
+
});
|
|
184
|
+
const full = this.log.append({ type: "summarized", coversToSeq: boundary, summary });
|
|
185
|
+
// The record rides the LAST recorded run's id — a summarized fact
|
|
186
|
+
// must never open a run of its own: the open-run gate keys on
|
|
187
|
+
// terminal-less runIds, and a "compact" runId would block the next
|
|
188
|
+
// run() ("still has an open run").
|
|
189
|
+
const records = this.#store.load(this.id);
|
|
190
|
+
const runId = records.length > 0 ? records[records.length - 1].runId : "compact";
|
|
191
|
+
await this.persist(runId, full);
|
|
192
|
+
return { coversToSeq: boundary, summary, savedTokens: estimateSummarySavings(covered, summary) };
|
|
193
|
+
}
|
|
151
194
|
// ── Phase D: approvals ───────────────────────────────────────────────
|
|
152
195
|
/**
|
|
153
196
|
* Pauses that still await a human decision (durable, survives restart).
|
|
@@ -391,632 +434,3 @@ export class AgentSession {
|
|
|
391
434
|
this.#pendingResolvers.delete(decisionId);
|
|
392
435
|
}
|
|
393
436
|
}
|
|
394
|
-
/**
|
|
395
|
-
* A single turn. Async-iterable, so `for await (const ev of session.run(x))`
|
|
396
|
-
* is the natural shape; the handle also carries the runId and the abort.
|
|
397
|
-
*/
|
|
398
|
-
export class Run {
|
|
399
|
-
runId;
|
|
400
|
-
#store;
|
|
401
|
-
#adapter;
|
|
402
|
-
#config;
|
|
403
|
-
#session;
|
|
404
|
-
#input;
|
|
405
|
-
#resume;
|
|
406
|
-
#abort = new AbortController();
|
|
407
|
-
#externalSignal;
|
|
408
|
-
#decisionIds = [];
|
|
409
|
-
#uncertaintyIds = [];
|
|
410
|
-
#started = false;
|
|
411
|
-
constructor(store, adapter, config, session, input, externalSignal, resume) {
|
|
412
|
-
this.#store = store;
|
|
413
|
-
this.#adapter = adapter;
|
|
414
|
-
this.#config = config;
|
|
415
|
-
this.#session = session;
|
|
416
|
-
this.#input = input;
|
|
417
|
-
this.#externalSignal = externalSignal;
|
|
418
|
-
this.#resume = resume;
|
|
419
|
-
this.runId = crypto.randomUUID();
|
|
420
|
-
}
|
|
421
|
-
/** Cancel the run: propagates to the adapter (SDK) and future executions. */
|
|
422
|
-
abort() {
|
|
423
|
-
this.#abort.abort();
|
|
424
|
-
}
|
|
425
|
-
async *[Symbol.asyncIterator]() {
|
|
426
|
-
if (this.#started)
|
|
427
|
-
throw new Error("a run may only be consumed once");
|
|
428
|
-
this.#started = true;
|
|
429
|
-
// The WHOLE body is one try/finally: a consumer that abandons the
|
|
430
|
-
// run at ANY yield (even the user_input one) must release the
|
|
431
|
-
// session's single-run slot and its approval resolvers.
|
|
432
|
-
try {
|
|
433
|
-
// 第四轮: health is re-checked when the iterator ACTUALLY starts —
|
|
434
|
-
// a run constructed before the session was poisoned must fail
|
|
435
|
-
// here, before any log or disk mutation.
|
|
436
|
-
this.#session.ensureHealthy();
|
|
437
|
-
this.#session.beginRun(this);
|
|
438
|
-
const log = this.#session.log;
|
|
439
|
-
const signal = this.#externalSignal ? new MergedSignal(this.#abort.signal, this.#externalSignal) : this.#abort.signal;
|
|
440
|
-
// E2: the session's own microcompact wins; otherwise the FIRST
|
|
441
|
-
// extension providing a compaction config supplies it.
|
|
442
|
-
const microcompact = microcompactFor(this.#config);
|
|
443
|
-
// E2: the session's own systemPrompt first, then every extension
|
|
444
|
-
// append in LOAD order — deterministic (same extensions → same
|
|
445
|
-
// prompt); no appends → byte-identical to the extension-less run.
|
|
446
|
-
const systemPrompt = composeSystemPrompt(this.#config.systemPrompt, this.#config.extensions ?? []);
|
|
447
|
-
const loopConfig = () => ({
|
|
448
|
-
adapter: this.#adapter,
|
|
449
|
-
model: this.#config.model,
|
|
450
|
-
sessionId: this.#session.id, // P3: tools see their session (ToolContext.sessionId)
|
|
451
|
-
...(systemPrompt !== undefined ? { systemPrompt } : {}),
|
|
452
|
-
registry: this.#config.registry,
|
|
453
|
-
...(this.#config.hooks !== undefined ? { hooks: this.#config.hooks } : {}),
|
|
454
|
-
...(this.#config.maxTurns !== undefined ? { maxTurns: this.#config.maxTurns } : {}),
|
|
455
|
-
...(this.#config.maxTokens !== undefined ? { maxTokens: this.#config.maxTokens } : {}),
|
|
456
|
-
...(this.#config.temperature !== undefined ? { temperature: this.#config.temperature } : {}),
|
|
457
|
-
...(this.#config.compaction !== undefined ? { compaction: this.#config.compaction } : {}),
|
|
458
|
-
...(microcompact !== undefined ? { microcompact } : {}),
|
|
459
|
-
...(this.#config.maxRetries !== undefined ? { maxRetries: this.#config.maxRetries } : {}),
|
|
460
|
-
approvalPolicies: (this.#config.extensions ?? []).flatMap((e) => (e.approvals ?? []).map((policy) => ({ extension: e.name, policy }))),
|
|
461
|
-
log,
|
|
462
|
-
signal,
|
|
463
|
-
resolveApproval: (decisionId) => new Promise((resolve) => {
|
|
464
|
-
this.#decisionIds.push(decisionId);
|
|
465
|
-
this.#session.registerResolver(decisionId, resolve);
|
|
466
|
-
}),
|
|
467
|
-
// 第四轮(对抗): the abort paths consult these so a verdict
|
|
468
|
-
// the human gave in the same instant as the abort is
|
|
469
|
-
// recorded, exactly once.
|
|
470
|
-
approvalVerdict: (decisionId) => this.#session.approvalVerdict(decisionId),
|
|
471
|
-
uncertaintyVerdict: (executionId) => this.#session.uncertaintyVerdict(executionId),
|
|
472
|
-
resolveUncertainty: (executionId) => new Promise((resolve) => {
|
|
473
|
-
this.#uncertaintyIds.push(executionId);
|
|
474
|
-
this.#session.registerUncertaintyResolver(executionId, resolve);
|
|
475
|
-
}),
|
|
476
|
-
});
|
|
477
|
-
const self = this;
|
|
478
|
-
const runLoop = async function* () {
|
|
479
|
-
for await (const ev of loop(loopConfig())) {
|
|
480
|
-
await self.#session.persist(self.runId, ev);
|
|
481
|
-
yield ev;
|
|
482
|
-
}
|
|
483
|
-
};
|
|
484
|
-
if (this.#resume) {
|
|
485
|
-
// ── B 组: recovery is PER-RUN, keyed by StoreRecord.runId ──
|
|
486
|
-
// Rebuild run boundaries; only the LAST unterminated run is
|
|
487
|
-
// recovered. Earlier runs that DID terminate have their
|
|
488
|
-
// dangling approvals closed (permission_expired) — a dead
|
|
489
|
-
// run's approval is never re-presented or resurrected.
|
|
490
|
-
const records = this.#store.load(this.#session.id);
|
|
491
|
-
const runs = new Map();
|
|
492
|
-
const order = [];
|
|
493
|
-
for (const r of records) {
|
|
494
|
-
if (!runs.has(r.runId)) {
|
|
495
|
-
runs.set(r.runId, []);
|
|
496
|
-
order.push(r.runId);
|
|
497
|
-
}
|
|
498
|
-
runs.get(r.runId).push(r.event);
|
|
499
|
-
}
|
|
500
|
-
let lastOpen;
|
|
501
|
-
for (const runId of order) {
|
|
502
|
-
const events = runs.get(runId);
|
|
503
|
-
if (!events.some((e) => e.type === "terminal"))
|
|
504
|
-
lastOpen = { runId, events };
|
|
505
|
-
}
|
|
506
|
-
if (!lastOpen)
|
|
507
|
-
return; // everything terminated — nothing to resume
|
|
508
|
-
// Adopt the ORIGINAL runId so the whole trajectory stays one
|
|
509
|
-
// run in the audit.
|
|
510
|
-
this.runId = lastOpen.runId;
|
|
511
|
-
// Close dangling approvals of TERMINATED runs.
|
|
512
|
-
for (const [runId, events] of runs) {
|
|
513
|
-
if (runId === lastOpen.runId)
|
|
514
|
-
continue;
|
|
515
|
-
if (!events.some((e) => e.type === "terminal"))
|
|
516
|
-
continue; // an open earlier run? impossible — lastOpen is the LAST
|
|
517
|
-
for (const ev of events) {
|
|
518
|
-
if (ev.type !== "permission_requested")
|
|
519
|
-
continue;
|
|
520
|
-
const dead = this.#session.log.all.some((e) => (e.type === "permission_decided" || e.type === "permission_expired") &&
|
|
521
|
-
e.decisionId === ev.decisionId);
|
|
522
|
-
if (dead)
|
|
523
|
-
continue;
|
|
524
|
-
const expired = this.#session.log.append({
|
|
525
|
-
type: "permission_expired",
|
|
526
|
-
decisionId: ev.decisionId,
|
|
527
|
-
reason: `run ${runId} terminated before the request was answered`,
|
|
528
|
-
});
|
|
529
|
-
await this.#session.persist(runId, expired);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
// Uncertain executions block until a human decides.
|
|
533
|
-
const uncertain = this.#session.uncertainExecutions();
|
|
534
|
-
if (uncertain.length > 0) {
|
|
535
|
-
throw new ResumeBlockedError(uncertain.map((u) => ({ executionId: u.executionId, callId: u.callId, name: u.name })));
|
|
536
|
-
}
|
|
537
|
-
// 1. Recovery scoped to the LAST OPEN RUN's events. The recover
|
|
538
|
-
// phase re-announces ALREADY-PERSISTED events (the stored
|
|
539
|
-
// permission_requested) for the consumer to re-prompt on —
|
|
540
|
-
// those must never be written to the store again, or seq
|
|
541
|
-
// would duplicate. Only events newer than the base log
|
|
542
|
-
// entry are durable.
|
|
543
|
-
const baseSeq = log.lastSeq;
|
|
544
|
-
const persist = async (ev) => {
|
|
545
|
-
if (ev.seq > baseSeq)
|
|
546
|
-
await this.#session.persist(this.runId, ev);
|
|
547
|
-
};
|
|
548
|
-
for await (const ev of this.#recover(log, signal, lastOpen.events)) {
|
|
549
|
-
await persist(ev);
|
|
550
|
-
yield ev;
|
|
551
|
-
}
|
|
552
|
-
// 2. Continuation: drive the LAST OPEN run to its terminal.
|
|
553
|
-
// The guard is scoped to that run — an earlier run's
|
|
554
|
-
// terminal must not suppress it (B 组).
|
|
555
|
-
if (!lastOpen.events.some((e) => e.type === "terminal")) {
|
|
556
|
-
for await (const ev of runLoop())
|
|
557
|
-
yield ev;
|
|
558
|
-
}
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
// 四: a session with an open run REFUSES new runs at the
|
|
562
|
-
// persistence layer — a second open run would be permanently
|
|
563
|
-
// orphaned (recovery only ever recovers the last one). The
|
|
564
|
-
// open run is continued via resume(), never by starting another.
|
|
565
|
-
const openRun = openRunId(this.#store.load(this.#session.id));
|
|
566
|
-
if (openRun !== undefined) {
|
|
567
|
-
throw new Error(`session ${this.#session.id} still has an open run (${openRun}) — resume() it instead of starting a new run`);
|
|
568
|
-
}
|
|
569
|
-
// 1. Durable first: the prompt enters the log and the store
|
|
570
|
-
// before any model call — a crash here leaves a restorable
|
|
571
|
-
// session. The prompt is also the first event the consumer
|
|
572
|
-
// sees, so what was asked and what happened live in the same
|
|
573
|
-
// stream.
|
|
574
|
-
const inputEvent = log.append({ type: "user_input", content: this.#input });
|
|
575
|
-
await this.#session.persist(this.runId, inputEvent);
|
|
576
|
-
yield inputEvent;
|
|
577
|
-
// 2. The loop projects from the session log — multi-turn context
|
|
578
|
-
// is the projection, not a second copy.
|
|
579
|
-
for await (const ev of runLoop())
|
|
580
|
-
yield ev;
|
|
581
|
-
}
|
|
582
|
-
finally {
|
|
583
|
-
// 第五轮(P1-5): flush verdicts the consumer submitted before the
|
|
584
|
-
// generator was abandoned — an approve()/resolveUncertain() whose
|
|
585
|
-
// durable event the loop never got to persist must STILL land on
|
|
586
|
-
// disk, exactly once.
|
|
587
|
-
try {
|
|
588
|
-
await this.#session.flushPendingVerdicts(this.runId, this.#session.log);
|
|
589
|
-
}
|
|
590
|
-
catch {
|
|
591
|
-
// the flush itself failed (poisoned session) — the error
|
|
592
|
-
// already poisoned everything; nothing more can be done.
|
|
593
|
-
}
|
|
594
|
-
// The run is over (or abandoned): its unanswered approvals must
|
|
595
|
-
// fall back to the direct-persist path, so a late approve() is
|
|
596
|
-
// still durable.
|
|
597
|
-
for (const decisionId of this.#decisionIds) {
|
|
598
|
-
this.#session.dropResolver(decisionId);
|
|
599
|
-
}
|
|
600
|
-
for (const executionId of this.#uncertaintyIds) {
|
|
601
|
-
this.#session.dropUncertaintyResolver(executionId);
|
|
602
|
-
}
|
|
603
|
-
this.#session.endRun(this);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
// ── Area 2: the durable recovery state machine ───────────────────────
|
|
607
|
-
/**
|
|
608
|
-
* Apply every durable decision and fill every missing receipt, in log
|
|
609
|
-
* order. A decision with no execution yet EXECUTES the persisted call
|
|
610
|
-
* (its original name/input/callId — never re-asked of the model, never
|
|
611
|
-
* re-approved); a denial writes its tool result; a succeeded/failed
|
|
612
|
-
* execution whose tool_result never landed is completed from the
|
|
613
|
-
* receipt. Undecided requests pause and await approve().
|
|
614
|
-
*/
|
|
615
|
-
async *#recover(log, signal, scope) {
|
|
616
|
-
const requests = scope.filter((e) => e.type === "permission_requested");
|
|
617
|
-
for (const pending of requests) {
|
|
618
|
-
const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === pending.decisionId);
|
|
619
|
-
// 四: paired by events NEWER than the request — a historical
|
|
620
|
-
// same-callId execution from an earlier run must not count as THIS
|
|
621
|
-
// request's execution (the provider callId may repeat across runs).
|
|
622
|
-
const hasExecution = log.all.some((e) => e.type === "tool_execution_started" && e.callId === pending.callId && e.seq > pending.seq);
|
|
623
|
-
const hasResult = log.all.some((e) => e.type === "tool_result" && e.callId === pending.callId && e.seq > pending.seq);
|
|
624
|
-
if (decided === undefined) {
|
|
625
|
-
// Pause: announce the stored request, await the human.
|
|
626
|
-
const pendingDecision = new Promise((resolve) => {
|
|
627
|
-
this.#decisionIds.push(pending.decisionId);
|
|
628
|
-
this.#session.registerResolver(pending.decisionId, resolve);
|
|
629
|
-
});
|
|
630
|
-
yield pending;
|
|
631
|
-
// Area 4: an abort during the resumed approval wait ends the
|
|
632
|
-
// run; the request stays durable and pending.
|
|
633
|
-
if (signal.aborted) {
|
|
634
|
-
// 第五轮(P1-6): a verdict given in the same instant as the
|
|
635
|
-
// abort is still recorded — the abort must not bypass the
|
|
636
|
-
// durable fallback (aligned with the loop's abort path).
|
|
637
|
-
const verdict = this.#session.approvalVerdict(pending.decisionId);
|
|
638
|
-
if (verdict !== undefined) {
|
|
639
|
-
yield log.append({
|
|
640
|
-
type: "permission_decided",
|
|
641
|
-
decisionId: pending.decisionId,
|
|
642
|
-
callId: pending.callId,
|
|
643
|
-
decision: verdict ? "approved" : "denied",
|
|
644
|
-
...(verdict ? {} : { reason: "denied by user" }),
|
|
645
|
-
});
|
|
646
|
-
}
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
const final = await abortable(pendingDecision, signal);
|
|
650
|
-
if (final === ABORTED) {
|
|
651
|
-
// 第四轮(对抗): a verdict given in the same instant as the
|
|
652
|
-
// abort is recorded (exactly once), never lost.
|
|
653
|
-
const verdict = this.#session.approvalVerdict(pending.decisionId);
|
|
654
|
-
if (verdict !== undefined) {
|
|
655
|
-
yield log.append({
|
|
656
|
-
type: "permission_decided",
|
|
657
|
-
decisionId: pending.decisionId,
|
|
658
|
-
callId: pending.callId,
|
|
659
|
-
decision: verdict ? "approved" : "denied",
|
|
660
|
-
...(verdict ? {} : { reason: "denied by user" }),
|
|
661
|
-
});
|
|
662
|
-
}
|
|
663
|
-
return;
|
|
664
|
-
}
|
|
665
|
-
// The decision is written here — exactly one writer per event.
|
|
666
|
-
yield log.append({
|
|
667
|
-
type: "permission_decided",
|
|
668
|
-
decisionId: pending.decisionId,
|
|
669
|
-
callId: pending.callId, // binds the decision to the invocation (B 组)
|
|
670
|
-
decision: final.action === "allow" ? "approved" : "denied",
|
|
671
|
-
...(final.action === "deny" && final.reason !== undefined ? { reason: final.reason } : {}),
|
|
672
|
-
});
|
|
673
|
-
if (final.action === "allow") {
|
|
674
|
-
if (!hasExecution)
|
|
675
|
-
yield* this.#executePersisted(pending.callId, pending.name, pending.input, signal);
|
|
676
|
-
}
|
|
677
|
-
else if (!hasResult) {
|
|
678
|
-
yield* this.#denialResult(pending.callId, final.reason ?? "denied by user");
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
else if (decided.decision === "approved") {
|
|
682
|
-
// Decided while no process was running: apply without pausing.
|
|
683
|
-
// An abort during recovery must stop the pending executions,
|
|
684
|
-
// exactly like the live loop's sibling guard (finding 3).
|
|
685
|
-
if (signal.aborted)
|
|
686
|
-
return;
|
|
687
|
-
if (!hasExecution)
|
|
688
|
-
yield* this.#executePersisted(pending.callId, pending.name, pending.input, signal);
|
|
689
|
-
}
|
|
690
|
-
else if (!hasResult) {
|
|
691
|
-
yield* this.#denialResult(pending.callId, decided.reason ?? "denied by user");
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
// Receipt repair: an execution that reached a terminal state but
|
|
695
|
-
// whose model-facing result never landed is completed FROM THE
|
|
696
|
-
// RECEIPT — never re-executed. Snapshot the scope first: this phase
|
|
697
|
-
// appends the repaired results, and iterating a growing array would
|
|
698
|
-
// re-visit them. 四: pairing is by executionId — a same-callId result
|
|
699
|
-
// from a different execution never suppresses the repair.
|
|
700
|
-
for (const ev of [...scope]) {
|
|
701
|
-
if (ev.type !== "tool_execution_succeeded" && ev.type !== "tool_execution_failed")
|
|
702
|
-
continue;
|
|
703
|
-
const hasResult = log.all.some((e) => e.type === "tool_result" && e.executionId === ev.executionId);
|
|
704
|
-
if (hasResult)
|
|
705
|
-
continue;
|
|
706
|
-
yield log.append(ev.type === "tool_execution_succeeded"
|
|
707
|
-
? {
|
|
708
|
-
type: "tool_result",
|
|
709
|
-
callId: ev.callId,
|
|
710
|
-
content: ev.result.content,
|
|
711
|
-
isError: false,
|
|
712
|
-
// 八: the repaired result reproduces the normal path
|
|
713
|
-
// losslessly — the tags ride on the durable receipt.
|
|
714
|
-
...(ev.tags !== undefined ? { tags: ev.tags } : {}),
|
|
715
|
-
executionId: ev.executionId,
|
|
716
|
-
}
|
|
717
|
-
: {
|
|
718
|
-
type: "tool_result",
|
|
719
|
-
callId: ev.callId,
|
|
720
|
-
content: ev.error,
|
|
721
|
-
isError: true,
|
|
722
|
-
...(ev.errorKind !== undefined ? { errorKind: ev.errorKind } : {}),
|
|
723
|
-
...(ev.tags !== undefined ? { tags: ev.tags } : {}),
|
|
724
|
-
executionId: ev.executionId,
|
|
725
|
-
});
|
|
726
|
-
}
|
|
727
|
-
// B 组 crash window: a resolution was persisted but its tool_result
|
|
728
|
-
// fill never landed — complete it so the model is never left staring
|
|
729
|
-
// at a dangling tool_use. 四: keyed by executionId, and the fill
|
|
730
|
-
// carries it, so a same-callId result from another execution is never
|
|
731
|
-
// confused with this one.
|
|
732
|
-
for (const ev of [...scope]) {
|
|
733
|
-
if (ev.type !== "tool_execution_resolved")
|
|
734
|
-
continue;
|
|
735
|
-
const hasResult = log.all.some((e) => e.type === "tool_result" && e.executionId === ev.executionId);
|
|
736
|
-
if (hasResult)
|
|
737
|
-
continue;
|
|
738
|
-
const denial = denialResult(ev.resolution === "rerun"
|
|
739
|
-
? "interrupted execution — rerun approved: the attempt is treated as NOT applied; the model may retry"
|
|
740
|
-
: "abandoned by human decision — the interrupted attempt must not be treated as applied");
|
|
741
|
-
yield log.append({
|
|
742
|
-
type: "tool_result",
|
|
743
|
-
callId: ev.callId,
|
|
744
|
-
content: denial.content,
|
|
745
|
-
isError: true,
|
|
746
|
-
errorKind: denial.errorKind,
|
|
747
|
-
executionId: ev.executionId,
|
|
748
|
-
});
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
/**
|
|
752
|
-
* Execute a call whose approval is already durable: the original
|
|
753
|
-
* name/input/callId from the persisted permission_requested, bypassing
|
|
754
|
-
* the permission hook (it was decided) and the model (it was never
|
|
755
|
-
* asked to re-issue). Full ledgered lifecycle.
|
|
756
|
-
*/
|
|
757
|
-
async *#executePersisted(callId, name, input, signal) {
|
|
758
|
-
const log = this.#session.log;
|
|
759
|
-
const tool = this.#config.registry.get(name);
|
|
760
|
-
const executionId = `ex-${log.lastSeq + 1}`;
|
|
761
|
-
// An abort that landed while the decision was being applied must
|
|
762
|
-
// not start the side effect (finding 3).
|
|
763
|
-
if (signal.aborted)
|
|
764
|
-
return;
|
|
765
|
-
yield log.append({ type: "tool_execution_started", executionId, callId, name, input });
|
|
766
|
-
let result;
|
|
767
|
-
if (tool === undefined) {
|
|
768
|
-
result = { content: `Unknown tool: ${name}`, isError: true, errorKind: "invalid_input" };
|
|
769
|
-
}
|
|
770
|
-
else {
|
|
771
|
-
try {
|
|
772
|
-
if (signal.aborted) {
|
|
773
|
-
result = { content: "aborted before execution", isError: true, errorKind: "fatal" };
|
|
774
|
-
}
|
|
775
|
-
else {
|
|
776
|
-
result = await tool.execute(input, { signal });
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
catch (err) {
|
|
780
|
-
result = {
|
|
781
|
-
content: err instanceof Error ? err.message : String(err),
|
|
782
|
-
isError: true,
|
|
783
|
-
errorKind: "fatal",
|
|
784
|
-
};
|
|
785
|
-
}
|
|
786
|
-
if (this.#config.hooks?.onPostTool) {
|
|
787
|
-
result = await this.#config.hooks.onPostTool({ callId, name, input }, result, { sessionId: this.#session.id });
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
// 裁决 #12 修正一: the honest note rides the recovered failure too —
|
|
791
|
-
// the receipt and the repaired tool_result reproduce the live path
|
|
792
|
-
// losslessly.
|
|
793
|
-
if (result.isError && tool?.idempotent !== true) {
|
|
794
|
-
result = {
|
|
795
|
-
...result,
|
|
796
|
-
content: `${result.content}\n[non-idempotent tool failed — its side effects may have partially applied; verify before retrying]`,
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
if (result.isError) {
|
|
800
|
-
yield log.append({
|
|
801
|
-
type: "tool_execution_failed",
|
|
802
|
-
executionId,
|
|
803
|
-
callId,
|
|
804
|
-
error: result.content,
|
|
805
|
-
// P1-9: errorKind only exists on errors — runtime-guarded too.
|
|
806
|
-
...(result.isError && result.errorKind !== undefined ? { errorKind: result.errorKind } : {}),
|
|
807
|
-
safeToRetry: tool?.idempotent === true,
|
|
808
|
-
...(result.tags !== undefined ? { tags: result.tags } : {}),
|
|
809
|
-
});
|
|
810
|
-
}
|
|
811
|
-
else {
|
|
812
|
-
yield log.append({
|
|
813
|
-
type: "tool_execution_succeeded",
|
|
814
|
-
executionId,
|
|
815
|
-
callId,
|
|
816
|
-
result: { content: result.content, isError: false },
|
|
817
|
-
...(result.tags !== undefined ? { tags: result.tags } : {}),
|
|
818
|
-
});
|
|
819
|
-
}
|
|
820
|
-
yield log.append({
|
|
821
|
-
type: "tool_result",
|
|
822
|
-
callId,
|
|
823
|
-
content: result.content,
|
|
824
|
-
isError: result.isError,
|
|
825
|
-
// P1-9: errorKind only exists on errors — runtime-guarded too.
|
|
826
|
-
...(result.isError && result.errorKind !== undefined ? { errorKind: result.errorKind } : {}),
|
|
827
|
-
// 五: live tags survive the resumed path too.
|
|
828
|
-
...(result.tags !== undefined ? { tags: result.tags } : {}),
|
|
829
|
-
executionId,
|
|
830
|
-
});
|
|
831
|
-
// 裁决 #12 (ADR-0038): the failed-receipt uncertain PAUSE is REMOVED
|
|
832
|
-
// here too (it mirrored the live loop's C 组 pause) — a complete
|
|
833
|
-
// receipt IS the outcome; uncertainty belongs to the crash window
|
|
834
|
-
// alone. A retry passes the approval chain again.
|
|
835
|
-
}
|
|
836
|
-
/** The model-facing result of a durable denial — no execution happened. */
|
|
837
|
-
async *#denialResult(callId, reason) {
|
|
838
|
-
const denial = denialResult(reason);
|
|
839
|
-
yield this.#session.log.append({
|
|
840
|
-
type: "tool_result",
|
|
841
|
-
callId,
|
|
842
|
-
content: denial.content,
|
|
843
|
-
isError: true,
|
|
844
|
-
errorKind: denial.errorKind,
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
/**
|
|
849
|
-
* E2: the session's systemPrompt plus every extension's append, in LOAD
|
|
850
|
-
* order, \n\n-joined — deterministic (same extension list → same prompt).
|
|
851
|
-
* No appends → the base passes through byte-identical.
|
|
852
|
-
*/
|
|
853
|
-
function composeSystemPrompt(base, extensions) {
|
|
854
|
-
const appends = extensions.flatMap((e) => (e.systemPrompt?.append === undefined ? [] : [e.systemPrompt.append]));
|
|
855
|
-
if (appends.length === 0)
|
|
856
|
-
return base;
|
|
857
|
-
return base === undefined ? appends.join("\n\n") : `${base}\n\n${appends.join("\n\n")}`;
|
|
858
|
-
}
|
|
859
|
-
/**
|
|
860
|
-
* E1: extension hooks compose AFTER the agent's own (既有先行 — the existing
|
|
861
|
-
* hook sees every event first). Observers all run, in order; onUserMessage
|
|
862
|
-
* and onPreTool — the FIRST decisive answer wins (the existing hook
|
|
863
|
-
* outranks extensions; defers fall through); onPostTool folds — each
|
|
864
|
-
* transforms the previous result. Returns the existing host unchanged when
|
|
865
|
-
* no extension provides hooks.
|
|
866
|
-
*/
|
|
867
|
-
function composeHooks(existing, extensions) {
|
|
868
|
-
const extHooks = extensions.flatMap((e) => (e.hooks === undefined ? [] : [e.hooks]));
|
|
869
|
-
if (extHooks.length === 0)
|
|
870
|
-
return existing;
|
|
871
|
-
const out = { ...existing };
|
|
872
|
-
const sources = existing === undefined ? extHooks : [existing, ...extHooks];
|
|
873
|
-
const observers = (key) => {
|
|
874
|
-
const handlers = sources.map(key).filter((h) => h !== undefined);
|
|
875
|
-
if (handlers.length <= 1)
|
|
876
|
-
return handlers[0];
|
|
877
|
-
return async (payload, ctx) => {
|
|
878
|
-
for (const h of handlers)
|
|
879
|
-
await h(payload, ctx);
|
|
880
|
-
};
|
|
881
|
-
};
|
|
882
|
-
for (const key of ["onPreLlm", "onEvent", "onPreCompact", "onPostCompact", "onPause", "onStop"]) {
|
|
883
|
-
const handler = observers((h) => h[key]);
|
|
884
|
-
if (handler !== undefined)
|
|
885
|
-
out[key] = handler;
|
|
886
|
-
}
|
|
887
|
-
const messageHandlers = sources
|
|
888
|
-
.map((h) => h.onUserMessage)
|
|
889
|
-
.filter((h) => h !== undefined);
|
|
890
|
-
if (messageHandlers.length === 1) {
|
|
891
|
-
out.onUserMessage = messageHandlers[0]; // length 1 guarantees the element
|
|
892
|
-
}
|
|
893
|
-
else if (messageHandlers.length > 1) {
|
|
894
|
-
// 复审 E1-P2: the pipe + veto short-circuit — each handler sees the
|
|
895
|
-
// message as the PREVIOUS one left it (既有先行), and a null (veto)
|
|
896
|
-
// anywhere ends the chain immediately: never "no opinion" for the
|
|
897
|
-
// next handler to outvote. Adding an extension can therefore never
|
|
898
|
-
// make the chain MORE permissive (the approval chain's deny>ask>allow
|
|
899
|
-
// monotonicity, on the message side).
|
|
900
|
-
out.onUserMessage = async (msg, ctx) => {
|
|
901
|
-
let current = msg;
|
|
902
|
-
for (const h of messageHandlers) {
|
|
903
|
-
const r = await h(current, ctx);
|
|
904
|
-
if (r === null)
|
|
905
|
-
return null;
|
|
906
|
-
current = r;
|
|
907
|
-
}
|
|
908
|
-
return current;
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
const preToolHandlers = sources
|
|
912
|
-
.map((h) => h.onPreTool)
|
|
913
|
-
.filter((h) => h !== undefined);
|
|
914
|
-
if (preToolHandlers.length === 1) {
|
|
915
|
-
out.onPreTool = preToolHandlers[0];
|
|
916
|
-
}
|
|
917
|
-
else if (preToolHandlers.length > 1) {
|
|
918
|
-
out.onPreTool = async (call, ctx) => {
|
|
919
|
-
for (const h of preToolHandlers) {
|
|
920
|
-
const d = await h(call, ctx);
|
|
921
|
-
if (d.action !== "defer")
|
|
922
|
-
return d;
|
|
923
|
-
}
|
|
924
|
-
return { action: "defer" };
|
|
925
|
-
};
|
|
926
|
-
}
|
|
927
|
-
const postToolHandlers = sources
|
|
928
|
-
.map((h) => h.onPostTool)
|
|
929
|
-
.filter((h) => h !== undefined);
|
|
930
|
-
if (postToolHandlers.length === 1) {
|
|
931
|
-
out.onPostTool = postToolHandlers[0];
|
|
932
|
-
}
|
|
933
|
-
else if (postToolHandlers.length > 1) {
|
|
934
|
-
out.onPostTool = async (call, result, ctx) => {
|
|
935
|
-
let r = result;
|
|
936
|
-
for (const h of postToolHandlers)
|
|
937
|
-
r = await h(call, r, ctx);
|
|
938
|
-
return r;
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
return out;
|
|
942
|
-
}
|
|
943
|
-
/**
|
|
944
|
-
* E2: the loop's microcompact config — the session's own microcompact wins;
|
|
945
|
-
* otherwise the FIRST extension providing a compaction config supplies it.
|
|
946
|
-
* An extension config without a threshold contributes nothing (a boundary
|
|
947
|
-
* needs a threshold to ever fire).
|
|
948
|
-
*/
|
|
949
|
-
function microcompactFor(config) {
|
|
950
|
-
if (config.microcompact !== undefined)
|
|
951
|
-
return config.microcompact;
|
|
952
|
-
for (const ext of config.extensions ?? []) {
|
|
953
|
-
const c = ext.compaction;
|
|
954
|
-
if (c !== undefined && c.thresholdTokens !== undefined) {
|
|
955
|
-
return { thresholdTokens: c.thresholdTokens, ...(c.keepResults !== undefined ? { keepResults: c.keepResults } : {}) };
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
return undefined;
|
|
959
|
-
}
|
|
960
|
-
/**
|
|
961
|
-
* The most recent run WITHOUT a terminal, or undefined when every recorded
|
|
962
|
-
* run terminated. Recovery can only drive ONE run to its terminal, so an
|
|
963
|
-
* open run must be the exclusive reason a session refuses new runs (四).
|
|
964
|
-
*/
|
|
965
|
-
function openRunId(records) {
|
|
966
|
-
const terminated = new Set(records.filter((r) => r.event.type === "terminal").map((r) => r.runId));
|
|
967
|
-
for (let i = records.length - 1; i >= 0; i--) {
|
|
968
|
-
const runId = records[i].runId;
|
|
969
|
-
if (!terminated.has(runId))
|
|
970
|
-
return runId;
|
|
971
|
-
}
|
|
972
|
-
return undefined;
|
|
973
|
-
}
|
|
974
|
-
/** Sentinel: the signal aborted while the recovery awaited a decision. */
|
|
975
|
-
const ABORTED = Symbol("kiso-resume-aborted");
|
|
976
|
-
/** Resolve with the decision, or ABORTED when the signal fires first. */
|
|
977
|
-
async function abortable(promise, signal) {
|
|
978
|
-
if (signal.aborted)
|
|
979
|
-
return ABORTED;
|
|
980
|
-
return new Promise((resolve) => {
|
|
981
|
-
const onAbort = () => {
|
|
982
|
-
signal.removeEventListener("abort", onAbort);
|
|
983
|
-
resolve(ABORTED);
|
|
984
|
-
};
|
|
985
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
986
|
-
promise.then((value) => {
|
|
987
|
-
signal.removeEventListener("abort", onAbort);
|
|
988
|
-
resolve(value);
|
|
989
|
-
}, (err) => {
|
|
990
|
-
signal.removeEventListener("abort", onAbort);
|
|
991
|
-
throw err;
|
|
992
|
-
});
|
|
993
|
-
});
|
|
994
|
-
}
|
|
995
|
-
/**
|
|
996
|
-
* A signal that fires when ANY source fires — the run's own controller and
|
|
997
|
-
* an optional external signal (the CLI's Ctrl+C, a fixture's flip).
|
|
998
|
-
*/
|
|
999
|
-
class MergedSignal {
|
|
1000
|
-
#sources;
|
|
1001
|
-
#listeners = new Set();
|
|
1002
|
-
constructor(...sources) {
|
|
1003
|
-
this.#sources = sources;
|
|
1004
|
-
for (const source of sources) {
|
|
1005
|
-
if (source.aborted)
|
|
1006
|
-
continue;
|
|
1007
|
-
source.addEventListener("abort", () => {
|
|
1008
|
-
for (const listener of this.#listeners)
|
|
1009
|
-
listener();
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
get aborted() {
|
|
1014
|
-
return this.#sources.some((s) => s.aborted);
|
|
1015
|
-
}
|
|
1016
|
-
addEventListener(_type, listener) {
|
|
1017
|
-
this.#listeners.add(() => listener.call(this, undefined));
|
|
1018
|
-
}
|
|
1019
|
-
removeEventListener(_type, listener) {
|
|
1020
|
-
this.#listeners.delete(listener);
|
|
1021
|
-
}
|
|
1022
|
-
}
|