@vincemakes/kiso-core 0.1.0

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.
@@ -0,0 +1,793 @@
1
+ /**
2
+ * L2 — the ReAct loop. The kernel's only loop; everything else is harness.
3
+ *
4
+ * An async generator that yields every event as it happens (never buffers a
5
+ * turn into a list — the agno failure), and converges on exactly one
6
+ * `terminal` event per run (ADR-0004).
7
+ *
8
+ * SINGLE TRUTH (Phase B): the loop holds ONE EventLog. Messages are never
9
+ * stored alongside it — every adapter call derives them via
10
+ * `projectMessages(log.all)` (kernel/project.ts). A fresh log encodes the
11
+ * seed `messages` into events first, so even a one-shot call replays
12
+ * exactly. Compaction is recorded as a `compacted` event and re-applied by
13
+ * the projection, keeping the replay identical to the live run.
14
+ *
15
+ * Per iteration:
16
+ * assemble (onUserMessage / onPreLlm)
17
+ * → adapter.stream(): events yielded straight through, tool calls collected
18
+ * → execute: validation → permission (onPreTool) → handler → rewrite
19
+ * (onPostTool), concurrency-safe calls batched parallel, the rest serial
20
+ * → tool_result events appended
21
+ * no tool calls / maxTurns / abort / max_tokens → terminal event, return
22
+ *
23
+ * Retry lives HERE and only here (ADR-0005): a retryable StructuredError
24
+ * from the adapter is retried with backoff inside the generator frame — and
25
+ * ONLY before anything streamed (Phase B): once a text delta or tool call
26
+ * left the adapter, a failure is an `error` terminal, never a silent
27
+ * re-stream that duplicates output or tool calls.
28
+ */
29
+ import { isAdapterEvent } from "../protocol/adapter.js";
30
+ import { estimateTokens, microcompact } from "./compaction.js";
31
+ import { EventLog } from "./event-log.js";
32
+ import { ToolRegistry } from "../tools/registry.js";
33
+ import { validateArgs } from "../tools/validate.js";
34
+ import { NoOpHooks } from "./hooks.js";
35
+ import { resolveModeProfile } from "./mode.js";
36
+ import { denialResult } from "./permission.js";
37
+ import { messagesToEvents, projectMessages } from "./project.js";
38
+ export const DEFAULT_MAX_TURNS = 10;
39
+ export const DEFAULT_MAX_RETRIES = 2;
40
+ export async function* loop(config) {
41
+ const log = config.log ?? new EventLog();
42
+ const hooks = config.hooks ?? NoOpHooks;
43
+ const maxTurns = config.maxTurns ?? DEFAULT_MAX_TURNS;
44
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
45
+ const signal = config.signal;
46
+ const mode = resolveModeProfile(config.modes, config.mode);
47
+ const registry = mode?.visibleToolNames !== undefined
48
+ ? config.registry.subset(mode.visibleToolNames)
49
+ : config.registry;
50
+ // Seed: a fresh log encodes the seed history as events so the projection
51
+ // (and any later replay) contains it. A non-empty log (session resume)
52
+ // is never re-seeded — the log already holds everything.
53
+ if (log.all.length === 0) {
54
+ for (const ev of messagesToEvents(config.messages ?? []))
55
+ log.append(ev);
56
+ }
57
+ const derive = () => projectMessages(log.all);
58
+ /** Yield a terminal: onStop (lifecycle) → event → onEvent (observer). */
59
+ const terminal = async (outcome) => {
60
+ if (hooks.onStop)
61
+ await hooks.onStop(outcome.kind, {}).catch(() => { });
62
+ const full = log.append({ type: "terminal", outcome });
63
+ if (hooks.onEvent)
64
+ await hooks.onEvent(full, {}).catch(() => { });
65
+ return full;
66
+ };
67
+ const aborted = () => signal?.aborted === true;
68
+ // Assemble: the incoming user message may be rewritten or vetoed.
69
+ // C 组: the outcome is PERSISTED as a user_input_replaced event — the
70
+ // projection renders the final replacement AT THE INPUT'S POSITION (or
71
+ // nothing, for a true veto), so the rewritten fact is the ONLY fact
72
+ // every later turn of the run sees.
73
+ let messages = derive();
74
+ let vetoed = false;
75
+ if (hooks.onUserMessage && messages.length > 0) {
76
+ const last = messages.at(-1);
77
+ if (last?.role === "user") {
78
+ const inputEvent = [...log.all].reverse().find((e) => e.type === "user_input");
79
+ // 六: the hook runs AT MOST ONCE per input. A replacement that
80
+ // ALREADY exists (persisted before a crash, or before a resume)
81
+ // means the hook already spoke for this input — it must never
82
+ // run again, and the run continues from the durable fact.
83
+ const replacement = log.all.find((e) => e.type === "user_input_replaced" && e.replaces === inputEvent?.seq);
84
+ if (replacement !== undefined) {
85
+ // 六/第五轮(P1-7): the hook ALREADY spoke for this input — a
86
+ // durable null content is a TRUE veto: restore the vetoed
87
+ // flag so the provider is NEVER called, even when earlier
88
+ // history exists (previously only an empty history happened
89
+ // to stop).
90
+ if (replacement.content === null)
91
+ vetoed = true;
92
+ }
93
+ if (replacement === undefined && inputEvent) {
94
+ const rewritten = await hooks.onUserMessage(last, {});
95
+ // 一: the rewrite/veto is a NORMAL stream event — persisted by
96
+ // the harness and visible to consumers, never a hidden append.
97
+ const replaced = log.append({
98
+ type: "user_input_replaced",
99
+ replaces: inputEvent.seq,
100
+ content: rewritten?.content ?? null,
101
+ ...(rewritten !== null && rewritten.source !== undefined ? { source: rewritten.source } : {}),
102
+ });
103
+ messages = derive();
104
+ yield replaced;
105
+ if (rewritten === null)
106
+ vetoed = true; // 三: a true veto ends the run
107
+ }
108
+ }
109
+ }
110
+ // 三: a true veto ends the run — the provider is NEVER called, even
111
+ // when earlier history exists.
112
+ if (vetoed || messages.length === 0) {
113
+ yield await terminal({ kind: "completed" });
114
+ return;
115
+ }
116
+ let turns = 0;
117
+ while (true) {
118
+ if (aborted()) {
119
+ yield await terminal({ kind: "aborted", by: "user" });
120
+ return;
121
+ }
122
+ if (turns >= maxTurns) {
123
+ yield await terminal({ kind: "max_turns", turns });
124
+ return;
125
+ }
126
+ turns += 1;
127
+ // ── Auto-compaction: ONLY this turn's NEWLY cleared results are
128
+ // persisted, keyed by the replaced tool-result event's seq; the
129
+ // projection applies them verbatim (A 组/D 组/五).
130
+ if (config.compaction && estimateTokens(messages) > config.compaction.thresholdTokens) {
131
+ if (hooks.onPreCompact)
132
+ await hooks.onPreCompact(messages, {}).catch(() => { });
133
+ const result = microcompact(messages);
134
+ // 五: the delta only — messages already carrying the clear marker
135
+ // are never re-cleared (microcompact's idempotence gate), so the
136
+ // same replacement is never recorded twice across turns.
137
+ const cleared = result.cleared.map((c) => ({
138
+ eventSeq: c.eventSeq,
139
+ callId: c.callId,
140
+ content: c.content,
141
+ }));
142
+ if (cleared.length > 0) {
143
+ const full = log.append({ type: "compacted", cleared });
144
+ if (hooks.onEvent)
145
+ await hooks.onEvent(full, {}).catch(() => { });
146
+ yield full;
147
+ messages = derive();
148
+ if (hooks.onPostCompact)
149
+ await hooks.onPostCompact(messages, {}).catch(() => { });
150
+ }
151
+ }
152
+ if (hooks.onPreLlm)
153
+ await hooks.onPreLlm({ model: config.model, turns }, {});
154
+ if (aborted()) {
155
+ yield await terminal({ kind: "aborted", by: "user" });
156
+ return;
157
+ }
158
+ // ── Model turn: stream events through, collect tool calls ──────────
159
+ const pending = [];
160
+ let lastStop;
161
+ let stopCount = 0;
162
+ let streamed = false;
163
+ let attempts = 0;
164
+ // 五: the turn is a strict protocol — once the provider stops, ANY
165
+ // further event (delta, tool call, usage, thinking) is a violation.
166
+ let sawStop = false;
167
+ let postStopViolation = false;
168
+ // 五: the adapter may only produce its OWN event kinds — a
169
+ // kernel-owned event (terminal, tool_execution_*, permission_*,
170
+ // user_input, …) from the stream is a FORGERY and must never reach
171
+ // the log.
172
+ let forgedEvent = false;
173
+ while (true) {
174
+ // Area 4: the backoff is abortable — a cancel landing during a
175
+ // retry wait ends the run now, not after the backoff.
176
+ if (aborted()) {
177
+ yield await terminal({ kind: "aborted", by: "user" });
178
+ return;
179
+ }
180
+ try {
181
+ const stream = config.adapter.stream({
182
+ model: config.model,
183
+ messages,
184
+ ...(config.systemPrompt !== undefined ? { systemPrompt: config.systemPrompt } : {}),
185
+ tools: registry.toSpecs(),
186
+ ...(config.maxTokens !== undefined ? { maxTokens: config.maxTokens } : {}),
187
+ ...(config.temperature !== undefined ? { temperature: config.temperature } : {}),
188
+ ...(signal !== undefined ? { signal } : {}),
189
+ });
190
+ for await (const ev of stream) {
191
+ streamed = true;
192
+ // 五: the trust gate — a kernel-owned event from the
193
+ // adapter is a forgery: it is never appended (never
194
+ // persisted), and the turn ends with a unique
195
+ // invalid_request terminal below.
196
+ if (!isAdapterEvent(ev)) {
197
+ forgedEvent = true;
198
+ break;
199
+ }
200
+ // 五: a delta/tool call/usage arriving AFTER the provider's
201
+ // stop is a protocol error — the violating event is never
202
+ // appended, and the turn ends with an error terminal (the
203
+ // pending tools must NOT execute).
204
+ if (sawStop && ev.type !== "stop") {
205
+ postStopViolation = true;
206
+ break;
207
+ }
208
+ if (ev.type === "stop") {
209
+ sawStop = true;
210
+ lastStop = ev.reason;
211
+ stopCount += 1;
212
+ }
213
+ if (ev.type === "tool_call_end")
214
+ pending.push(ev);
215
+ const full = log.append(ev);
216
+ if (hooks.onEvent)
217
+ await hooks.onEvent(full, {}).catch(() => { });
218
+ yield full;
219
+ }
220
+ break;
221
+ }
222
+ catch (err) {
223
+ // Area 4: a user cancel surfaced by the SDK (APIUserAbortError
224
+ // or any error while the signal is set) is an honest `aborted`
225
+ // terminal, never a generic error.
226
+ if (aborted()) {
227
+ yield await terminal({ kind: "aborted", by: "user" });
228
+ return;
229
+ }
230
+ const structured = toStructuredError(err);
231
+ // Phase B: never silently re-stream a turn that already
232
+ // emitted content — duplicates are worse than failures.
233
+ if (structured.retryable && !streamed && attempts < maxRetries) {
234
+ attempts += 1;
235
+ await sleep(attempts * 250, signal); // abortable backoff
236
+ continue;
237
+ }
238
+ yield await terminal({ kind: "error", error: structured });
239
+ return;
240
+ }
241
+ }
242
+ // ── 五: a forged kernel-owned event is a protocol error ──────────────
243
+ if (forgedEvent) {
244
+ yield await terminal({
245
+ kind: "error",
246
+ error: { code: "invalid_request", retryable: false, message: "provider emitted a kernel-owned event" },
247
+ });
248
+ return;
249
+ }
250
+ // ── 五: events after the stop are a protocol error ───────────────────
251
+ if (postStopViolation) {
252
+ yield await terminal({
253
+ kind: "error",
254
+ error: { code: "invalid_request", retryable: false, message: "provider emitted events after its stop event" },
255
+ });
256
+ return;
257
+ }
258
+ // ── Terminal check: no tool call this turn → done, honestly ────────
259
+ if (pending.length === 0) {
260
+ // Area 6: protocol anomalies are STRUCTURED ERRORS, never a
261
+ // default `completed` — a stream with no stop, a duplicate stop,
262
+ // or a tool_use that never produced a complete call.
263
+ if (stopCount === 0) {
264
+ yield await terminal({
265
+ kind: "error",
266
+ error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
267
+ });
268
+ return;
269
+ }
270
+ if (stopCount > 1) {
271
+ yield await terminal({
272
+ kind: "error",
273
+ error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount} stop events in one turn` },
274
+ });
275
+ return;
276
+ }
277
+ yield await terminal(terminalForStop(lastStop));
278
+ return;
279
+ }
280
+ // ── Abort check before side effects: a stop landing during the
281
+ // model turn must never let the pending tools run ────────────────
282
+ if (aborted()) {
283
+ yield await terminal({ kind: "aborted", by: "user" });
284
+ return;
285
+ }
286
+ // ── C 组: the turn is verified BEFORE any tool runs ────────────────
287
+ // A tool may only execute when the provider turn is well-formed:
288
+ // exactly one stop, whose reason is compatible with complete calls.
289
+ // Missing/duplicate stops, max_tokens, refusal, content_filter,
290
+ // pause_turn, context_window, abort, and the contradictory
291
+ // end_turn-with-pending-calls all terminate WITHOUT executing.
292
+ if (pending.length > 0) {
293
+ if (stopCount === 0) {
294
+ yield await terminal({
295
+ kind: "error",
296
+ error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
297
+ });
298
+ return;
299
+ }
300
+ if (stopCount > 1) {
301
+ yield await terminal({
302
+ kind: "error",
303
+ error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount} stop events in one turn` },
304
+ });
305
+ return;
306
+ }
307
+ switch (lastStop) {
308
+ case "tool_use":
309
+ case "function_call":
310
+ break; // compatible with complete calls — execute
311
+ case "max_tokens":
312
+ yield await terminal({ kind: "max_tokens" });
313
+ return;
314
+ case "abort":
315
+ yield await terminal({ kind: "aborted", by: "user" });
316
+ return;
317
+ case "error":
318
+ yield await terminal({
319
+ kind: "error",
320
+ error: { code: "unknown", retryable: false, message: "provider stopped with an error" },
321
+ });
322
+ return;
323
+ case "refusal":
324
+ case "pause_turn":
325
+ case "content_filter":
326
+ case "context_window":
327
+ case "end_turn":
328
+ case "stop_sequence":
329
+ default:
330
+ yield await terminal({
331
+ kind: "error",
332
+ error: {
333
+ code: "invalid_request",
334
+ retryable: false,
335
+ message: `provider stopped with '${String(lastStop)}' but left ${pending.length} tool call(s) unexecuted`,
336
+ },
337
+ });
338
+ return;
339
+ }
340
+ }
341
+ // ── Execute: sequential, ledgered, pause-capable (Phase D) ──────────
342
+ // Sequential on purpose: the ledger (started → succeeded/failed) and
343
+ // the approval pause need deterministic, write-ahead ordering; the
344
+ // windowed parallel batching (ADR-0015) returns as an optimization
345
+ // once the ledger contract is stable.
346
+ for (const call of pending) {
347
+ // Area 4: an abort after the first tool must never start a
348
+ // sibling tool — each pending call checks the signal first.
349
+ if (aborted()) {
350
+ yield await terminal({ kind: "aborted", by: "user" });
351
+ return;
352
+ }
353
+ let currentExecutionId;
354
+ try {
355
+ for await (const ev of executeOne(call, registry, hooks, { signal: signal ?? NEVER_ABORT }, log, config.resolveApproval, config.approvalVerdict, signal)) {
356
+ // 四: the identity of THIS execution comes from the stream —
357
+ // a historical same-callId execution must never be mistaken
358
+ // for this call's (the provider callId may repeat across runs).
359
+ if (ev.type === "tool_execution_started")
360
+ currentExecutionId = ev.executionId;
361
+ if (hooks.onEvent)
362
+ await hooks.onEvent(ev, {}).catch(() => { });
363
+ yield ev;
364
+ }
365
+ }
366
+ catch (err) {
367
+ // An abort during the approval pause propagates here as the
368
+ // sentinel — end the run honestly; the request stays durable.
369
+ if (err === ABORTED || aborted()) {
370
+ yield await terminal({ kind: "aborted", by: "user" });
371
+ return;
372
+ }
373
+ throw err;
374
+ }
375
+ // C 组: a failed NON-idempotent execution is a persistent
376
+ // uncertain PAUSE — no sibling tool, no auto-retry, and the next
377
+ // model turn waits for the human verdict. 四: the failed event is
378
+ // found by THIS execution's id — never by the repeatable callId,
379
+ // which would let a historical same-callId failure pollute a fresh
380
+ // successful execution with a stale uncertain pause.
381
+ const failed = currentExecutionId === undefined
382
+ ? undefined
383
+ : [...log.all]
384
+ .reverse()
385
+ .find((e) => e.type === "tool_execution_failed" && e.executionId === currentExecutionId);
386
+ if (failed !== undefined && !failed.safeToRetry) {
387
+ // Register the human channel BEFORE announcing the pause —
388
+ // a consumer that answers the moment it sees the event must
389
+ // find the resolver already waiting (no deadlock between
390
+ // yield and await, mirroring the approval pause).
391
+ const pendingResolution = config.resolveUncertainty !== undefined ? config.resolveUncertainty(failed.executionId) : undefined;
392
+ const pendingUncertain = log.append({
393
+ type: "uncertain_pending",
394
+ executionId: failed.executionId,
395
+ callId: call.callId,
396
+ name: call.name,
397
+ error: failed.error,
398
+ });
399
+ if (hooks.onEvent)
400
+ await hooks.onEvent(pendingUncertain, {}).catch(() => { });
401
+ yield pendingUncertain;
402
+ let resolution;
403
+ if (pendingResolution !== undefined) {
404
+ try {
405
+ resolution = await raceAbort(pendingResolution, signal);
406
+ }
407
+ catch (err) {
408
+ if (err === ABORTED) {
409
+ // 第四轮(对抗): the human may have answered in the
410
+ // same instant the abort landed — a CONSUMED verdict
411
+ // must be recorded (exactly once), never lost. It is
412
+ // appended here, then the run ends with its honest
413
+ // aborted terminal; the execution is resolved, not
414
+ // bricked.
415
+ const verdict = config.uncertaintyVerdict?.(failed.executionId);
416
+ if (verdict !== undefined) {
417
+ const verdictEvent = log.append({
418
+ type: "tool_execution_resolved",
419
+ executionId: failed.executionId,
420
+ callId: call.callId,
421
+ resolution: verdict,
422
+ });
423
+ if (hooks.onEvent)
424
+ await hooks.onEvent(verdictEvent, {}).catch(() => { });
425
+ yield verdictEvent;
426
+ }
427
+ yield await terminal({ kind: "aborted", by: "user" });
428
+ return;
429
+ }
430
+ throw err;
431
+ }
432
+ }
433
+ else {
434
+ // No channel: record the conservative verdict — the
435
+ // failure is NEVER auto-retried, and the ledger stays
436
+ // consistent for future resumes.
437
+ resolution = "abandoned";
438
+ }
439
+ // 七: the LOOP owns the resolution event — appended and
440
+ // yielded on EVERY verdict path (channel or not), so the Run
441
+ // persists it and the consumer's stream has no hidden gap.
442
+ // A live resolveUncertain() only passed the verdict; the
443
+ // event itself is created here, exactly once.
444
+ const resolvedEvent = log.append({
445
+ type: "tool_execution_resolved",
446
+ executionId: failed.executionId,
447
+ callId: call.callId,
448
+ resolution,
449
+ });
450
+ if (hooks.onEvent)
451
+ await hooks.onEvent(resolvedEvent, {}).catch(() => { });
452
+ yield resolvedEvent;
453
+ // Either verdict ends the pending list: siblings never run.
454
+ break;
455
+ }
456
+ }
457
+ // ── Advance history: the log grew; re-derive for the next turn ─────
458
+ messages = derive();
459
+ }
460
+ }
461
+ /**
462
+ * The terminal for a turn that stopped without tool calls — mapped from the
463
+ * provider's OWN stop reason, never blanket `completed` (Phase B, Area 6).
464
+ * `refusal`, `pause_turn`, `content_filter`, `context_window`, and a
465
+ * tool_use/function_call that produced no complete call are all explicit
466
+ * non-completions.
467
+ */
468
+ function terminalForStop(reason) {
469
+ switch (reason) {
470
+ case "max_tokens":
471
+ return { kind: "max_tokens" };
472
+ case "abort":
473
+ return { kind: "aborted", by: "user" };
474
+ case "error":
475
+ return { kind: "error", error: { code: "unknown", retryable: false, message: "provider stopped with an error" } };
476
+ case "refusal":
477
+ return { kind: "error", error: { code: "invalid_request", retryable: false, message: "the model refused the request" } };
478
+ case "pause_turn":
479
+ return { kind: "error", error: { code: "unknown", retryable: false, message: "the provider paused the turn (pause_turn)" } };
480
+ case "content_filter":
481
+ return { kind: "error", error: { code: "invalid_request", retryable: false, message: "the provider's content filter triggered" } };
482
+ case "context_window":
483
+ return {
484
+ kind: "error",
485
+ error: { code: "context_overflow", retryable: false, message: "the model's context window was exceeded" },
486
+ };
487
+ case "tool_use":
488
+ case "function_call":
489
+ return {
490
+ kind: "error",
491
+ error: {
492
+ code: "invalid_request",
493
+ retryable: false,
494
+ message: "provider stopped with a tool call that was never completed",
495
+ },
496
+ };
497
+ case "end_turn":
498
+ case "stop_sequence":
499
+ return { kind: "completed" };
500
+ default:
501
+ // D3: an unknown stop reason is an error, never completed.
502
+ return {
503
+ kind: "error",
504
+ error: { code: "unknown", retryable: false, message: `unrecognized stop reason: ${String(reason)}` },
505
+ };
506
+ }
507
+ }
508
+ // ── Execution ──────────────────────────────────────────────────────────
509
+ /**
510
+ * Execute one tool call as a ledgered sequence of events:
511
+ *
512
+ * [guards] → permission (allow / deny / DEFER→pause+resume)
513
+ * → tool_execution_started (durable BEFORE the side effect)
514
+ * → handler → tool_execution_succeeded|failed
515
+ * → tool_result (the model's view)
516
+ *
517
+ * Exactly-once (Phase D): before anything runs, the guard asks the ledger
518
+ * whether this tool+input reached a terminal state before. A confirmed
519
+ * success is replayed, an interrupted (uncertain) or abandoned attempt
520
+ * blocks with a precondition result — the handler never auto-runs a
521
+ * possibly-executed side effect.
522
+ */
523
+ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, resolveApprovalVerdict, signal) {
524
+ const payload = {
525
+ callId: call.callId,
526
+ name: call.name,
527
+ input: call.input ?? {},
528
+ };
529
+ const emitResult = (result, executionId) => log.append({
530
+ type: "tool_result",
531
+ callId: call.callId,
532
+ content: result.content,
533
+ isError: result.isError,
534
+ // P1-9: errorKind only exists on errors (the type now enforces it;
535
+ // the runtime guard keeps a JS tool's illegal combination out of
536
+ // the persisted event too).
537
+ ...(result.isError && result.errorKind ? { errorKind: result.errorKind } : {}),
538
+ // 五: a live tool's tags are preserved losslessly (do-not-compact,
539
+ // billing receipts, trace anchors) — never dropped at the loop.
540
+ ...(result.tags !== undefined ? { tags: result.tags } : {}),
541
+ ...(executionId !== undefined ? { executionId } : {}),
542
+ });
543
+ // Unknown tool or unparseable args — refuse before anything runs.
544
+ const tool = registry.get(call.name);
545
+ if (!tool) {
546
+ yield emitResult({
547
+ content: `Unknown tool: ${call.name}`,
548
+ isError: true,
549
+ errorKind: "invalid_input",
550
+ });
551
+ return;
552
+ }
553
+ if (call.input === null) {
554
+ yield emitResult({
555
+ content: "Arguments failed to parse as JSON",
556
+ isError: true,
557
+ errorKind: "invalid_input",
558
+ });
559
+ return;
560
+ }
561
+ // Phase B: real JSON Schema validation — the handler never sees garbage.
562
+ const schemaError = validateArgs(tool.parameters, call.input);
563
+ if (schemaError !== null) {
564
+ yield emitResult({
565
+ content: `Arguments failed schema validation:${schemaError}`,
566
+ isError: true,
567
+ errorKind: "invalid_input",
568
+ });
569
+ return;
570
+ }
571
+ // Area 3: NO (name, input) dedup — a new logical call with identical
572
+ // parameters is a new execution and runs normally. Exactly-once is
573
+ // enforced by the receipt repair (Area 2) and the human decisions on
574
+ // uncertain executions, not by swallowing repeats.
575
+ // Area 4 hardening (review finding 5): an abort that landed while a
576
+ // slow permission hook was answering must not let the tool run after
577
+ // all. Checked again here, after any permission path.
578
+ if (signal?.aborted)
579
+ throw ABORTED;
580
+ // Permission negotiation — defer is a REAL pause (Phase D). C 组: the
581
+ // hook itself is cancelable (a slow policy query must not outlive an
582
+ // abort), and the signal is re-checked after it returns.
583
+ if (hooks.onPreTool) {
584
+ const decision = await raceAbort(hooks.onPreTool(payload, ctx), signal);
585
+ if (signal?.aborted)
586
+ throw ABORTED;
587
+ if (decision.action === "defer") {
588
+ const decisionId = `d-${log.lastSeq + 1}`;
589
+ // Register the resolver BEFORE announcing the pause: a consumer
590
+ // that answers the request the moment it sees it must find the
591
+ // resolver already waiting (no deadlock between yield and await).
592
+ const pendingDecision = resolveApproval !== undefined
593
+ ? resolveApproval(decisionId)
594
+ : Promise.resolve({ action: "deny", reason: "no approval channel configured" });
595
+ const requested = log.append({
596
+ type: "permission_requested",
597
+ decisionId,
598
+ callId: call.callId,
599
+ name: call.name,
600
+ input: payload.input,
601
+ });
602
+ if (hooks.onPause)
603
+ await hooks.onPause("awaiting approval", {}).catch(() => { });
604
+ yield requested;
605
+ // Area 4: the pause is abortable — a cancel during the human's
606
+ // wait ends the run now; the request stays durable and pending.
607
+ let finalDecision;
608
+ try {
609
+ finalDecision = await raceAbort(pendingDecision, signal);
610
+ }
611
+ catch (err) {
612
+ if (err === ABORTED) {
613
+ // 第四轮(对抗): the human may have answered in the same
614
+ // instant the abort landed — a CONSUMED verdict must be
615
+ // recorded (exactly once), never lost; the abort then
616
+ // ends the run with its honest aborted terminal.
617
+ const verdict = resolveApprovalVerdict?.(decisionId);
618
+ if (verdict !== undefined) {
619
+ yield log.append({
620
+ type: "permission_decided",
621
+ decisionId,
622
+ callId: call.callId,
623
+ decision: verdict ? "approved" : "denied",
624
+ ...(verdict ? {} : { reason: "denied by user" }),
625
+ });
626
+ }
627
+ }
628
+ throw err;
629
+ }
630
+ // The approval channel (session.approve) persists the decision
631
+ // write-ahead BEFORE waking the resolver (Area 2): if it already
632
+ // landed in the log, this is the same decision, not a duplicate.
633
+ const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === decisionId) ??
634
+ log.append({
635
+ type: "permission_decided",
636
+ decisionId,
637
+ callId: call.callId, // binds the decision to the invocation (B 组)
638
+ decision: finalDecision.action === "allow" ? "approved" : "denied",
639
+ ...(finalDecision.action === "deny" && finalDecision.reason !== undefined
640
+ ? { reason: finalDecision.reason }
641
+ : {}),
642
+ });
643
+ yield decided;
644
+ if (finalDecision.action !== "allow") {
645
+ yield emitResult(denialResult(finalDecision.reason ?? "denied"));
646
+ return;
647
+ }
648
+ }
649
+ else if (decision.action !== "allow") {
650
+ yield emitResult(denialResult(decision.reason ?? "denied"));
651
+ return;
652
+ }
653
+ }
654
+ // The ledgered execution. The started event is durable BEFORE the side
655
+ // effect; a crash between it and the result leaves "uncertain". The
656
+ // executionId is the persistent identity of THIS logical execution
657
+ // (Area 3): generated from the log's next seq, so it is unique per log
658
+ // and survives restarts.
659
+ const executionId = `ex-${log.lastSeq + 1}`;
660
+ // C 组: the signal is re-checked immediately before the started event —
661
+ // an abort that landed in any permission path must not let the side
662
+ // effect begin.
663
+ if (signal?.aborted)
664
+ throw ABORTED;
665
+ const started = log.append({
666
+ type: "tool_execution_started",
667
+ executionId,
668
+ callId: call.callId,
669
+ name: call.name,
670
+ input: call.input,
671
+ });
672
+ yield started;
673
+ let result;
674
+ try {
675
+ // C 组: re-checked again right before the handler — the handler also
676
+ // observes ctx.signal, but the gate itself must not invoke it after
677
+ // a cancel.
678
+ result = signal?.aborted
679
+ ? { content: "aborted before execution", isError: true, errorKind: "fatal" }
680
+ : await tool.execute(call.input, ctx);
681
+ }
682
+ catch (err) {
683
+ result = {
684
+ content: err instanceof Error ? err.message : String(err),
685
+ isError: true,
686
+ errorKind: "fatal",
687
+ };
688
+ }
689
+ if (hooks.onPostTool) {
690
+ result = await hooks.onPostTool(payload, result, ctx);
691
+ }
692
+ if (result.isError) {
693
+ // Area 3: only a tool that PROVED safe-to-retry (idempotent) gets a
694
+ // clean failure; a non-idempotent failure may have produced a side
695
+ // effect and is uncertain until a human decides.
696
+ // 八: the tags ride on the RECEIPT too — a crash-window repair of the
697
+ // tool_result reproduces the normal path losslessly.
698
+ yield log.append({
699
+ type: "tool_execution_failed",
700
+ executionId,
701
+ callId: call.callId,
702
+ error: result.content,
703
+ // P1-9: errorKind only exists on errors (the type now enforces it;
704
+ // the runtime guard keeps a JS tool's illegal combination out of
705
+ // the persisted event too).
706
+ ...(result.isError && result.errorKind ? { errorKind: result.errorKind } : {}),
707
+ safeToRetry: tool.idempotent === true,
708
+ ...(result.tags !== undefined ? { tags: result.tags } : {}),
709
+ });
710
+ }
711
+ else {
712
+ yield log.append({
713
+ type: "tool_execution_succeeded",
714
+ executionId,
715
+ callId: call.callId,
716
+ result: { content: result.content, isError: false },
717
+ ...(result.tags !== undefined ? { tags: result.tags } : {}),
718
+ });
719
+ }
720
+ yield emitResult(result, executionId);
721
+ }
722
+ /** Thrown when an abort lands while the loop awaits a human decision. */
723
+ const ABORTED = Symbol("kiso-aborted-during-approval");
724
+ /**
725
+ * Wait for a human decision (approval or uncertain verdict), but WAKE on
726
+ * abort (Area 4 / C 组): a cancel during the wait must end the run, not
727
+ * leave the iterator hung. Throws ABORTED; the loop converts it to an
728
+ * `aborted` terminal.
729
+ */
730
+ async function raceAbort(pendingDecision, signal) {
731
+ if (signal === undefined)
732
+ return pendingDecision;
733
+ if (signal.aborted)
734
+ throw ABORTED;
735
+ return new Promise((resolve, reject) => {
736
+ const onAbort = () => {
737
+ signal.removeEventListener("abort", onAbort);
738
+ reject(ABORTED);
739
+ };
740
+ signal.addEventListener("abort", onAbort, { once: true });
741
+ pendingDecision.then((value) => {
742
+ signal.removeEventListener("abort", onAbort);
743
+ resolve(value);
744
+ }, (err) => {
745
+ signal.removeEventListener("abort", onAbort);
746
+ reject(err);
747
+ });
748
+ });
749
+ }
750
+ // ── Error structuring ───────────────────────────────────────────────────
751
+ /**
752
+ * Adapter exceptions → StructuredError. Anything already shaped like one
753
+ * passes through; everything else is `unknown` — never a regex over error
754
+ * text (ADR-0005).
755
+ */
756
+ export function toStructuredError(err) {
757
+ if (typeof err === "object" && err !== null) {
758
+ const e = err;
759
+ if (typeof e.code === "string" && typeof e.retryable === "boolean") {
760
+ return {
761
+ code: e.code,
762
+ ...(e.status !== undefined ? { status: e.status } : {}),
763
+ retryable: e.retryable,
764
+ message: typeof e.message === "string" ? e.message : String(err),
765
+ };
766
+ }
767
+ }
768
+ return {
769
+ code: "unknown",
770
+ retryable: false,
771
+ message: err instanceof Error ? err.message : String(err),
772
+ };
773
+ }
774
+ /** Abortable sleep: a cancel during backoff wakes the run immediately. */
775
+ function sleep(ms, signal) {
776
+ return new Promise((resolve) => {
777
+ if (signal?.aborted) {
778
+ resolve();
779
+ return;
780
+ }
781
+ const timer = setTimeout(resolve, ms);
782
+ signal?.addEventListener("abort", () => {
783
+ clearTimeout(timer);
784
+ resolve();
785
+ }, { once: true });
786
+ });
787
+ }
788
+ /** A signal that never aborts — for executions outside any abort scope. */
789
+ const NEVER_ABORT = {
790
+ aborted: false,
791
+ addEventListener: () => { },
792
+ removeEventListener: () => { },
793
+ };