@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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +29 -0
- package/dist/governance/delivery.d.ts +35 -0
- package/dist/governance/delivery.js +47 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/kernel/compaction.d.ts +60 -0
- package/dist/kernel/compaction.js +117 -0
- package/dist/kernel/event-log.d.ts +44 -0
- package/dist/kernel/event-log.js +51 -0
- package/dist/kernel/hooks.d.ts +52 -0
- package/dist/kernel/hooks.js +16 -0
- package/dist/kernel/ledger.d.ts +43 -0
- package/dist/kernel/ledger.js +88 -0
- package/dist/kernel/loop.d.ts +97 -0
- package/dist/kernel/loop.js +793 -0
- package/dist/kernel/mode.d.ts +26 -0
- package/dist/kernel/mode.js +21 -0
- package/dist/kernel/permission.d.ts +27 -0
- package/dist/kernel/permission.js +20 -0
- package/dist/kernel/project.d.ts +43 -0
- package/dist/kernel/project.js +287 -0
- package/dist/protocol/adapter.d.ts +77 -0
- package/dist/protocol/adapter.js +44 -0
- package/dist/protocol/events.d.ts +408 -0
- package/dist/protocol/events.js +230 -0
- package/dist/protocol/index.d.ts +3 -0
- package/dist/protocol/index.js +3 -0
- package/dist/protocol/messages.d.ts +113 -0
- package/dist/protocol/messages.js +17 -0
- package/dist/tools/registry.d.ts +28 -0
- package/dist/tools/registry.js +53 -0
- package/dist/tools/tool.d.ts +72 -0
- package/dist/tools/tool.js +21 -0
- package/dist/tools/validate.d.ts +11 -0
- package/dist/tools/validate.js +28 -0
- package/package.json +53 -0
|
@@ -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
|
+
};
|