@vincemakes/kiso-core 0.1.24 → 0.1.26
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/kernel/loop.d.ts +9 -4
- package/dist/kernel/loop.js +398 -255
- package/dist/kernel/project.js +88 -6
- package/dist/protocol/extension.d.ts +6 -0
- package/dist/tools/registry.d.ts +12 -0
- package/dist/tools/registry.js +30 -5
- package/package.json +2 -2
package/dist/kernel/loop.d.ts
CHANGED
|
@@ -15,10 +15,15 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Per iteration:
|
|
17
17
|
* assemble (onUserMessage / onPreLlm)
|
|
18
|
-
* → adapter.stream(): events yielded straight through
|
|
19
|
-
*
|
|
20
|
-
* (
|
|
21
|
-
*
|
|
18
|
+
* → adapter.stream(): events yielded straight through; every validated
|
|
19
|
+
* and policy-allowed tool call LAUNCHES its execution immediately
|
|
20
|
+
* (流中执行) — the executions run concurrently under a window of 4
|
|
21
|
+
* (0.1.26, ADR-0024 Amd), their events queued and drained between
|
|
22
|
+
* stream events (completion order; the projection re-orders the
|
|
23
|
+
* results by call order — 字节纪律)
|
|
24
|
+
* → the turn settles: the launched executions finish (receipts land
|
|
25
|
+
* before any terminal), the ask-gated successors follow the human's
|
|
26
|
+
* verdict (保守序)
|
|
22
27
|
* no tool calls / maxTurns / abort / max_tokens → terminal event, return
|
|
23
28
|
*
|
|
24
29
|
* Retry lives HERE and only here (ADR-0005): a retryable StructuredError
|
package/dist/kernel/loop.js
CHANGED
|
@@ -15,10 +15,15 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Per iteration:
|
|
17
17
|
* assemble (onUserMessage / onPreLlm)
|
|
18
|
-
* → adapter.stream(): events yielded straight through
|
|
19
|
-
*
|
|
20
|
-
* (
|
|
21
|
-
*
|
|
18
|
+
* → adapter.stream(): events yielded straight through; every validated
|
|
19
|
+
* and policy-allowed tool call LAUNCHES its execution immediately
|
|
20
|
+
* (流中执行) — the executions run concurrently under a window of 4
|
|
21
|
+
* (0.1.26, ADR-0024 Amd), their events queued and drained between
|
|
22
|
+
* stream events (completion order; the projection re-orders the
|
|
23
|
+
* results by call order — 字节纪律)
|
|
24
|
+
* → the turn settles: the launched executions finish (receipts land
|
|
25
|
+
* before any terminal), the ask-gated successors follow the human's
|
|
26
|
+
* verdict (保守序)
|
|
22
27
|
* no tool calls / maxTurns / abort / max_tokens → terminal event, return
|
|
23
28
|
*
|
|
24
29
|
* Retry lives HERE and only here (ADR-0005): a retryable StructuredError
|
|
@@ -114,6 +119,169 @@ export async function* loop(config) {
|
|
|
114
119
|
yield await terminal({ kind: "completed" });
|
|
115
120
|
return;
|
|
116
121
|
}
|
|
122
|
+
// 0.1.26 (ADR-0024 Amd — the trigger condition is met: a real workload
|
|
123
|
+
// showed the sequential ledger is the bottleneck): the windowed parallel
|
|
124
|
+
// batching returns, this time with the ledger events emitted per call in
|
|
125
|
+
// deterministic order. The model stream and the tool executions run
|
|
126
|
+
// CONCURRENTLY (流中执行): a tool_call_end validated and allowed by the
|
|
127
|
+
// policy chain launches its execution immediately; the events land
|
|
128
|
+
// through a queue the stream loop drains on every stream event — their
|
|
129
|
+
// seq order is the COMPLETION order (started/receipt/result land when
|
|
130
|
+
// each execution finishes; seq stays monotonic by construction). The
|
|
131
|
+
// byte discipline is preserved by the projection, which re-orders the
|
|
132
|
+
// turn's results by CALL order (project.ts flushResults) — the
|
|
133
|
+
// completion order only affects the landing moment, never the derived
|
|
134
|
+
// messages. The window caps concurrent executions; the ask gate holds
|
|
135
|
+
// an ask AND the calls after it until the human decides (保守序 — the
|
|
136
|
+
// context may have changed when the human approves); the STARTED event
|
|
137
|
+
// is acked by the drain so the handler never runs before its receipt is
|
|
138
|
+
// persisted (write-ahead preserved). A voided turn (forged event,
|
|
139
|
+
// post-stop violation, a non-compatible stop reason) fires the violated
|
|
140
|
+
// signal: started executions finish and their receipts land (已开跑照落
|
|
141
|
+
// receipt), not-started ones bail without a started event (abort 语义 —
|
|
142
|
+
// clean, never uncertain).
|
|
143
|
+
const WINDOW_SIZE = 4;
|
|
144
|
+
// The execution event queue. The drain (below) appends + yields each
|
|
145
|
+
// queued event; the ack of the STARTED event gates the handler (the
|
|
146
|
+
// write-ahead: the receipt is persisted before the side effect).
|
|
147
|
+
const execQueue = [];
|
|
148
|
+
const pushExec = (ev, ack) => {
|
|
149
|
+
execQueue.push({ ev, ack: ack ?? (() => { }) });
|
|
150
|
+
};
|
|
151
|
+
const drainExec = async function* () {
|
|
152
|
+
while (execQueue.length > 0) {
|
|
153
|
+
const { ev, ack } = execQueue.shift();
|
|
154
|
+
// The STARTED event's executionId is allocated HERE, atomically
|
|
155
|
+
// with the append: `ex-<seq>` — the id equals the event's seq, so
|
|
156
|
+
// the SAME logical execution derives the SAME id on a replay or a
|
|
157
|
+
// resume (the execution-identity contract). Under the parallel
|
|
158
|
+
// execution a pre-append prediction raced; the drain is the only
|
|
159
|
+
// place the next seq is known without a gap. The ack carries the
|
|
160
|
+
// id back to the launch (the receipts reference it).
|
|
161
|
+
const full = ev.type === "tool_execution_started"
|
|
162
|
+
? log.append({ ...ev, executionId: `ex-${log.lastSeq + 1}` })
|
|
163
|
+
: log.append(ev);
|
|
164
|
+
if (hooks.onEvent)
|
|
165
|
+
await hooks.onEvent(full, {}).catch(() => { });
|
|
166
|
+
yield full;
|
|
167
|
+
ack(full.type === "tool_execution_started" ? full.executionId : undefined);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
// The window: at most WINDOW_SIZE executions run concurrently.
|
|
171
|
+
let freeSlots = WINDOW_SIZE;
|
|
172
|
+
const windowWaiters = [];
|
|
173
|
+
const acquireWindow = () => {
|
|
174
|
+
if (freeSlots > 0) {
|
|
175
|
+
freeSlots -= 1;
|
|
176
|
+
return Promise.resolve();
|
|
177
|
+
}
|
|
178
|
+
return new Promise((res) => {
|
|
179
|
+
windowWaiters.push(res);
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
const releaseWindow = () => {
|
|
183
|
+
const w = windowWaiters.shift();
|
|
184
|
+
if (w !== undefined)
|
|
185
|
+
w();
|
|
186
|
+
else
|
|
187
|
+
freeSlots += 1;
|
|
188
|
+
};
|
|
189
|
+
// The turn's launches: one async task per tool_call_end, in call order.
|
|
190
|
+
const launches = [];
|
|
191
|
+
let execActive = 0;
|
|
192
|
+
// A launch failure (a throwing onPostTool, a real error) fails the RUN —
|
|
193
|
+
// the launch records it and the loop re-throws it after the settle
|
|
194
|
+
// (same propagation the sequential execute had).
|
|
195
|
+
let launchError = null;
|
|
196
|
+
let violated = false;
|
|
197
|
+
// The violated signal: rejects when the turn is voided — the paused
|
|
198
|
+
// ask-branches bail (abort 语义 for not-started executions). Typed
|
|
199
|
+
// `never` so the ask race resolves to the human decision alone.
|
|
200
|
+
let violatedReject = () => { };
|
|
201
|
+
const violatedP = new Promise((_, reject) => {
|
|
202
|
+
violatedReject = () => reject();
|
|
203
|
+
});
|
|
204
|
+
// The turn's ask-branches race it; a turn with NO ask leaves the
|
|
205
|
+
// rejection un-consumed — the no-op keeps it from surfacing as an
|
|
206
|
+
// unhandled rejection while the race consumers still receive it.
|
|
207
|
+
void violatedP.catch(() => { });
|
|
208
|
+
// The ask gate: an ask's human resolution blocks the calls after it.
|
|
209
|
+
// The DECIDE chain serializes the decisions in CALL order, so the gate
|
|
210
|
+
// an ask installs is structurally in place before the successors decide.
|
|
211
|
+
let askGate = Promise.resolve();
|
|
212
|
+
let askRelease = null;
|
|
213
|
+
// The decision chain: each launch's decide is chained onto the previous
|
|
214
|
+
// one's — the decides run in CALL order and the chain resolves to the
|
|
215
|
+
// call's verdict.
|
|
216
|
+
let decideChain = Promise.resolve({ action: "allow" });
|
|
217
|
+
// The decisionId allocator: monotonic per log (seeded past the existing
|
|
218
|
+
// log), unique under the parallel decides — the ids are correlation
|
|
219
|
+
// keys (the executionId comes from the drain, seq-stable).
|
|
220
|
+
let idSeq = log.all.length + 1;
|
|
221
|
+
const nextDecisionId = () => `d-${idSeq++}`;
|
|
222
|
+
const launch = (call) => {
|
|
223
|
+
execActive += 1;
|
|
224
|
+
launches.push((async () => {
|
|
225
|
+
try {
|
|
226
|
+
await acquireWindow();
|
|
227
|
+
decideChain = decideChain.then(async () => {
|
|
228
|
+
const v = await decideCall(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, log, config.resolveApproval, config.approvalVerdict, signal, config.approvalPolicies, nextDecisionId, pushExec);
|
|
229
|
+
if (v.action === "ask") {
|
|
230
|
+
askGate = new Promise((res) => {
|
|
231
|
+
askRelease = res;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return v;
|
|
235
|
+
});
|
|
236
|
+
const verdict = await decideChain;
|
|
237
|
+
if (violated)
|
|
238
|
+
return; // the turn was voided before this call started
|
|
239
|
+
if (verdict.action === "deny") {
|
|
240
|
+
pushExec(verdict.result);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (verdict.action === "ask") {
|
|
244
|
+
// The human pause — abortable by a user abort OR a turn
|
|
245
|
+
// void (the violated promise). The gate opens when the
|
|
246
|
+
// human decides, whatever the outcome (conservative
|
|
247
|
+
// ordering: the successors then proceed with their own
|
|
248
|
+
// verdicts).
|
|
249
|
+
const decision = await Promise.race([
|
|
250
|
+
humanPause(call, verdict.decisionId, hooks, log, config.resolveApproval, config.approvalVerdict, signal, pushExec),
|
|
251
|
+
violatedP,
|
|
252
|
+
]);
|
|
253
|
+
askRelease?.();
|
|
254
|
+
askRelease = null;
|
|
255
|
+
if (violated)
|
|
256
|
+
return;
|
|
257
|
+
if (decision.action !== "allow") {
|
|
258
|
+
// the reason rides the denial — the human's words,
|
|
259
|
+
// or the honest "no approval flow configured".
|
|
260
|
+
pushExec(resultEvent(call, denialResult(decision.reason ?? "denied")));
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// 保守序: the calls AFTER an ask wait for its human
|
|
265
|
+
// resolution (the askGate is the ask's pause promise —
|
|
266
|
+
// resolved by default, released by the ask branch above).
|
|
267
|
+
// The context may have changed when the human approves.
|
|
268
|
+
await askGate;
|
|
269
|
+
await runLedgered(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, signal, pushExec);
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
// The abort sentinel (a user cancel during the decide or
|
|
273
|
+
// the pause) is swallowed — the loop's aborted() check
|
|
274
|
+
// ends the run honestly; anything else is recorded and
|
|
275
|
+
// re-thrown after the settle (the consumer sees it).
|
|
276
|
+
if (err !== ABORTED)
|
|
277
|
+
launchError ??= err;
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
releaseWindow();
|
|
281
|
+
execActive -= 1;
|
|
282
|
+
}
|
|
283
|
+
})());
|
|
284
|
+
};
|
|
117
285
|
let turns = 0;
|
|
118
286
|
while (true) {
|
|
119
287
|
if (aborted()) {
|
|
@@ -176,12 +344,19 @@ export async function* loop(config) {
|
|
|
176
344
|
});
|
|
177
345
|
for await (const ev of stream) {
|
|
178
346
|
streamed = true;
|
|
347
|
+
// 0.1.26: the launched executions' events land first —
|
|
348
|
+
// the completion order; the projection re-orders the
|
|
349
|
+
// results by call order (字节纪律).
|
|
350
|
+
for await (const q of drainExec())
|
|
351
|
+
yield q;
|
|
179
352
|
// 五: the trust gate — a kernel-owned event from the
|
|
180
353
|
// adapter is a forgery: it is never appended (never
|
|
181
354
|
// persisted), and the turn ends with a unique
|
|
182
355
|
// invalid_request terminal below.
|
|
183
356
|
if (!isAdapterEvent(ev)) {
|
|
184
357
|
forgedEvent = true;
|
|
358
|
+
violated = true;
|
|
359
|
+
violatedReject();
|
|
185
360
|
break;
|
|
186
361
|
}
|
|
187
362
|
// 五: a delta/tool call/usage arriving AFTER the provider's
|
|
@@ -190,6 +365,8 @@ export async function* loop(config) {
|
|
|
190
365
|
// pending tools must NOT execute).
|
|
191
366
|
if (sawStop && ev.type !== "stop") {
|
|
192
367
|
postStopViolation = true;
|
|
368
|
+
violated = true;
|
|
369
|
+
violatedReject();
|
|
193
370
|
break;
|
|
194
371
|
}
|
|
195
372
|
if (ev.type === "stop") {
|
|
@@ -197,8 +374,13 @@ export async function* loop(config) {
|
|
|
197
374
|
lastStop = ev.reason;
|
|
198
375
|
stopCount += 1;
|
|
199
376
|
}
|
|
200
|
-
if (ev.type === "tool_call_end")
|
|
377
|
+
if (ev.type === "tool_call_end") {
|
|
201
378
|
pending.push(ev);
|
|
379
|
+
// 流中执行: the call launches immediately — the decide
|
|
380
|
+
// and the ledgered run proceed in parallel with the
|
|
381
|
+
// model stream.
|
|
382
|
+
launch(ev);
|
|
383
|
+
}
|
|
202
384
|
const full = log.append(ev);
|
|
203
385
|
if (hooks.onEvent)
|
|
204
386
|
await hooks.onEvent(full, {}).catch(() => { });
|
|
@@ -226,141 +408,123 @@ export async function* loop(config) {
|
|
|
226
408
|
return;
|
|
227
409
|
}
|
|
228
410
|
}
|
|
229
|
-
// ──
|
|
411
|
+
// ── The voided-terminal computation (五 / C 组) ────────────────────
|
|
412
|
+
// A forged kernel-owned event, a post-stop event, or a
|
|
413
|
+
// non-compatible stop reason voids the turn: the launched
|
|
414
|
+
// executions still finish and their receipts land (已开跑照落
|
|
415
|
+
// receipt — the side effects happened), the not-started bail
|
|
416
|
+
// (abort 语义), then the terminal.
|
|
417
|
+
let voided = null;
|
|
230
418
|
if (forgedEvent) {
|
|
231
|
-
|
|
419
|
+
voided = {
|
|
232
420
|
kind: "error",
|
|
233
421
|
error: { code: "invalid_request", retryable: false, message: "provider emitted a kernel-owned event" },
|
|
234
|
-
}
|
|
235
|
-
return;
|
|
422
|
+
};
|
|
236
423
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
yield await terminal({
|
|
424
|
+
else if (postStopViolation) {
|
|
425
|
+
voided = {
|
|
240
426
|
kind: "error",
|
|
241
427
|
error: { code: "invalid_request", retryable: false, message: "provider emitted events after its stop event" },
|
|
242
|
-
}
|
|
243
|
-
return;
|
|
428
|
+
};
|
|
244
429
|
}
|
|
245
|
-
|
|
246
|
-
if (pending.length === 0) {
|
|
430
|
+
else if (pending.length === 0) {
|
|
247
431
|
// Area 6: protocol anomalies are STRUCTURED ERRORS, never a
|
|
248
432
|
// default `completed` — a stream with no stop, a duplicate stop,
|
|
249
433
|
// or a tool_use that never produced a complete call.
|
|
250
434
|
if (stopCount === 0) {
|
|
251
|
-
|
|
435
|
+
voided = {
|
|
252
436
|
kind: "error",
|
|
253
437
|
error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
|
|
254
|
-
}
|
|
255
|
-
return;
|
|
438
|
+
};
|
|
256
439
|
}
|
|
257
|
-
if (stopCount > 1) {
|
|
258
|
-
|
|
440
|
+
else if (stopCount > 1) {
|
|
441
|
+
voided = {
|
|
259
442
|
kind: "error",
|
|
260
443
|
error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount} stop events in one turn` },
|
|
261
|
-
}
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
yield await terminal(terminalForStop(lastStop));
|
|
262
448
|
return;
|
|
263
449
|
}
|
|
264
|
-
yield await terminal(terminalForStop(lastStop));
|
|
265
|
-
return;
|
|
266
450
|
}
|
|
267
|
-
// ── Abort check
|
|
268
|
-
//
|
|
269
|
-
if (aborted()) {
|
|
451
|
+
// ── Abort check: an abort now abandons the launched executions
|
|
452
|
+
// (started without receipt → uncertain), exactly as before ──────
|
|
453
|
+
if (voided === null && aborted()) {
|
|
270
454
|
yield await terminal({ kind: "aborted", by: "user" });
|
|
271
455
|
return;
|
|
272
456
|
}
|
|
273
|
-
// ── C 组: the turn is verified
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
// pause_turn, context_window, abort, and the contradictory
|
|
278
|
-
// end_turn-with-pending-calls all terminate WITHOUT executing.
|
|
279
|
-
if (pending.length > 0) {
|
|
457
|
+
// ── C 组: the turn is verified. 0.1.26 (流中执行): the calls were
|
|
458
|
+
// ALREADY launched at tool_call_end, so a non-compatible stop reason
|
|
459
|
+
// VOIDS the turn instead of preventing the execution.
|
|
460
|
+
if (voided === null && pending.length > 0) {
|
|
280
461
|
if (stopCount === 0) {
|
|
281
|
-
|
|
462
|
+
voided = {
|
|
282
463
|
kind: "error",
|
|
283
464
|
error: { code: "invalid_request", retryable: false, message: "provider stream ended without a stop event" },
|
|
284
|
-
}
|
|
285
|
-
return;
|
|
465
|
+
};
|
|
286
466
|
}
|
|
287
|
-
if (stopCount > 1) {
|
|
288
|
-
|
|
467
|
+
else if (stopCount > 1) {
|
|
468
|
+
voided = {
|
|
289
469
|
kind: "error",
|
|
290
470
|
error: { code: "invalid_request", retryable: false, message: `provider emitted ${stopCount} stop events in one turn` },
|
|
291
|
-
}
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
switch (lastStop) {
|
|
295
|
-
case "tool_use":
|
|
296
|
-
case "function_call":
|
|
297
|
-
break; // compatible with complete calls — execute
|
|
298
|
-
case "max_tokens":
|
|
299
|
-
yield await terminal({ kind: "max_tokens" });
|
|
300
|
-
return;
|
|
301
|
-
case "abort":
|
|
302
|
-
yield await terminal({ kind: "aborted", by: "user" });
|
|
303
|
-
return;
|
|
304
|
-
case "error":
|
|
305
|
-
yield await terminal({
|
|
306
|
-
kind: "error",
|
|
307
|
-
error: { code: "unknown", retryable: false, message: "provider stopped with an error" },
|
|
308
|
-
});
|
|
309
|
-
return;
|
|
310
|
-
case "refusal":
|
|
311
|
-
case "pause_turn":
|
|
312
|
-
case "content_filter":
|
|
313
|
-
case "context_window":
|
|
314
|
-
case "end_turn":
|
|
315
|
-
case "stop_sequence":
|
|
316
|
-
default:
|
|
317
|
-
yield await terminal({
|
|
318
|
-
kind: "error",
|
|
319
|
-
error: {
|
|
320
|
-
code: "invalid_request",
|
|
321
|
-
retryable: false,
|
|
322
|
-
message: `provider stopped with '${String(lastStop)}' but left ${pending.length} tool call(s) unexecuted`,
|
|
323
|
-
},
|
|
324
|
-
});
|
|
325
|
-
return;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
// ── Execute: sequential, ledgered, pause-capable (Phase D) ──────────
|
|
329
|
-
// Sequential on purpose: the ledger (started → succeeded/failed) and
|
|
330
|
-
// the approval pause need deterministic, write-ahead ordering; the
|
|
331
|
-
// windowed parallel batching (ADR-0015) returns as an optimization
|
|
332
|
-
// once the ledger contract is stable.
|
|
333
|
-
for (const call of pending) {
|
|
334
|
-
// Area 4: an abort after the first tool must never start a
|
|
335
|
-
// sibling tool — each pending call checks the signal first.
|
|
336
|
-
if (aborted()) {
|
|
337
|
-
yield await terminal({ kind: "aborted", by: "user" });
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
try {
|
|
341
|
-
for await (const ev of executeOne(call, registry, hooks, { signal: signal ?? NEVER_ABORT, ...(config.sessionId !== undefined ? { sessionId: config.sessionId } : {}) }, log, config.resolveApproval, config.approvalVerdict, signal, config.approvalPolicies)) {
|
|
342
|
-
if (hooks.onEvent)
|
|
343
|
-
await hooks.onEvent(ev, {}).catch(() => { });
|
|
344
|
-
yield ev;
|
|
345
|
-
}
|
|
471
|
+
};
|
|
346
472
|
}
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
473
|
+
else {
|
|
474
|
+
switch (lastStop) {
|
|
475
|
+
case "tool_use":
|
|
476
|
+
case "function_call":
|
|
477
|
+
break; // compatible with complete calls — the executions proceed
|
|
478
|
+
case "max_tokens":
|
|
479
|
+
voided = { kind: "max_tokens" };
|
|
480
|
+
break;
|
|
481
|
+
case "abort":
|
|
482
|
+
voided = { kind: "aborted", by: "user" };
|
|
483
|
+
break;
|
|
484
|
+
case "error":
|
|
485
|
+
voided = {
|
|
486
|
+
kind: "error",
|
|
487
|
+
error: { code: "unknown", retryable: false, message: "provider stopped with an error" },
|
|
488
|
+
};
|
|
489
|
+
break;
|
|
490
|
+
default:
|
|
491
|
+
voided = {
|
|
492
|
+
kind: "error",
|
|
493
|
+
error: {
|
|
494
|
+
code: "invalid_request",
|
|
495
|
+
retryable: false,
|
|
496
|
+
message: `provider stopped with '${String(lastStop)}' with ${pending.length} tool call(s) launched`,
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
break;
|
|
353
500
|
}
|
|
354
|
-
throw err;
|
|
355
501
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
502
|
+
}
|
|
503
|
+
// ── The turn settles: a void fires the violated signal — the
|
|
504
|
+
// not-started executions bail (abort 语义 — no started, no
|
|
505
|
+
// receipt, never uncertain); the started ones finish and their
|
|
506
|
+
// receipts land BEFORE the terminal or the next turn (已开跑照落
|
|
507
|
+
// receipt). The drain yields every queued event; the STARTED
|
|
508
|
+
// ack resolves as the consumer persists (write-ahead), so the
|
|
509
|
+
// launches advance DURING the drain — a 10ms settle poll covers
|
|
510
|
+
// the in-between gaps (mid-handler launches, the ask pause:
|
|
511
|
+
// never a busy spin, never a deadlock on a pending ack).
|
|
512
|
+
if (voided !== null) {
|
|
513
|
+
violated = true;
|
|
514
|
+
violatedReject();
|
|
515
|
+
}
|
|
516
|
+
while (execActive > 0) {
|
|
517
|
+
for await (const q of drainExec())
|
|
518
|
+
yield q;
|
|
519
|
+
await sleep(10);
|
|
520
|
+
}
|
|
521
|
+
for await (const q of drainExec())
|
|
522
|
+
yield q;
|
|
523
|
+
if (launchError !== null)
|
|
524
|
+
throw launchError;
|
|
525
|
+
if (voided !== null) {
|
|
526
|
+
yield await terminal(voided);
|
|
527
|
+
return;
|
|
364
528
|
}
|
|
365
529
|
// ── Advance history: the log grew; re-derive for the next turn ─────
|
|
366
530
|
messages = derive();
|
|
@@ -413,28 +577,10 @@ function terminalForStop(reason) {
|
|
|
413
577
|
};
|
|
414
578
|
}
|
|
415
579
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
* [guards] → permission (allow / deny / DEFER→pause+resume)
|
|
421
|
-
* → tool_execution_started (durable BEFORE the side effect)
|
|
422
|
-
* → handler → tool_execution_succeeded|failed
|
|
423
|
-
* → tool_result (the model's view)
|
|
424
|
-
*
|
|
425
|
-
* Exactly-once (Phase D): before anything runs, the guard asks the ledger
|
|
426
|
-
* whether this tool+input reached a terminal state before. A confirmed
|
|
427
|
-
* success is replayed, an interrupted (uncertain) or abandoned attempt
|
|
428
|
-
* blocks with a precondition result — the handler never auto-runs a
|
|
429
|
-
* possibly-executed side effect.
|
|
430
|
-
*/
|
|
431
|
-
async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, resolveApprovalVerdict, signal, approvalPolicies) {
|
|
432
|
-
const payload = {
|
|
433
|
-
callId: call.callId,
|
|
434
|
-
name: call.name,
|
|
435
|
-
input: call.input ?? {},
|
|
436
|
-
};
|
|
437
|
-
const emitResult = (result, executionId) => log.append({
|
|
580
|
+
/** The tool_result event for a call — the shared shape (executionId rides
|
|
581
|
+
* it as the durable correlation, 五). */
|
|
582
|
+
function resultEvent(call, result, executionId) {
|
|
583
|
+
return {
|
|
438
584
|
type: "tool_result",
|
|
439
585
|
callId: call.callId,
|
|
440
586
|
content: result.content,
|
|
@@ -447,34 +593,35 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
447
593
|
// billing receipts, trace anchors) — never dropped at the loop.
|
|
448
594
|
...(result.tags !== undefined ? { tags: result.tags } : {}),
|
|
449
595
|
...(executionId !== undefined ? { executionId } : {}),
|
|
450
|
-
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* 0.1.26: the front of ONE call's execution — unknown tool / unparseable
|
|
600
|
+
* args / schema validation, the durable-policy check, the E1 extension
|
|
601
|
+
* policy chain, and onPreTool → a verdict. The policy allow/deny facts are
|
|
602
|
+
* pushed (durable, decidedBy = the speaking extension — never a human
|
|
603
|
+
* pause). An ask verdict carries the decisionId for the human pause; the
|
|
604
|
+
* caller runs it (conservative ordering: the calls after an ask wait for
|
|
605
|
+
* its resolution).
|
|
606
|
+
*/
|
|
607
|
+
async function decideCall(call, registry, hooks, ctx, log, resolveApproval, resolveApprovalVerdict, signal, approvalPolicies, nextDecisionId, push) {
|
|
608
|
+
const payload = {
|
|
609
|
+
callId: call.callId,
|
|
610
|
+
name: call.name,
|
|
611
|
+
input: call.input ?? {},
|
|
612
|
+
};
|
|
451
613
|
// Unknown tool or unparseable args — refuse before anything runs.
|
|
452
614
|
const tool = registry.get(call.name);
|
|
453
615
|
if (!tool) {
|
|
454
|
-
|
|
455
|
-
content: `Unknown tool: ${call.name}`,
|
|
456
|
-
isError: true,
|
|
457
|
-
errorKind: "invalid_input",
|
|
458
|
-
});
|
|
459
|
-
return;
|
|
616
|
+
return { action: "deny", result: resultEvent(call, { content: `Unknown tool: ${call.name}`, isError: true, errorKind: "invalid_input" }) };
|
|
460
617
|
}
|
|
461
618
|
if (call.input === null) {
|
|
462
|
-
|
|
463
|
-
content: "Arguments failed to parse as JSON",
|
|
464
|
-
isError: true,
|
|
465
|
-
errorKind: "invalid_input",
|
|
466
|
-
});
|
|
467
|
-
return;
|
|
619
|
+
return { action: "deny", result: resultEvent(call, { content: "Arguments failed to parse as JSON", isError: true, errorKind: "invalid_input" }) };
|
|
468
620
|
}
|
|
469
621
|
// Phase B: real JSON Schema validation — the handler never sees garbage.
|
|
470
622
|
const schemaError = validateArgs(tool.parameters, call.input);
|
|
471
623
|
if (schemaError !== null) {
|
|
472
|
-
|
|
473
|
-
content: `Arguments failed schema validation:${schemaError}`,
|
|
474
|
-
isError: true,
|
|
475
|
-
errorKind: "invalid_input",
|
|
476
|
-
});
|
|
477
|
-
return;
|
|
624
|
+
return { action: "deny", result: resultEvent(call, { content: `Arguments failed schema validation:${schemaError}`, isError: true, errorKind: "invalid_input" }) };
|
|
478
625
|
}
|
|
479
626
|
// Area 3: NO (name, input) dedup — a new logical call with identical
|
|
480
627
|
// parameters is a new execution and runs normally. Exactly-once is
|
|
@@ -485,70 +632,6 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
485
632
|
// all. Checked again here, after any permission path.
|
|
486
633
|
if (signal?.aborted)
|
|
487
634
|
throw ABORTED;
|
|
488
|
-
/**
|
|
489
|
-
* The human approval pause (Phase D / 裁决 A): register the resolver
|
|
490
|
-
* BEFORE announcing the request (a consumer that answers the moment it
|
|
491
|
-
* sees the event must find the resolver already waiting — no deadlock
|
|
492
|
-
* between yield and await), persist the request, yield it, await the
|
|
493
|
-
* human's decision — abortable (an abort during the wait ends the run;
|
|
494
|
-
* a verdict given in the same instant is still recorded exactly once) —
|
|
495
|
-
* then persist and yield the decision. Returns the human's verdict.
|
|
496
|
-
*/
|
|
497
|
-
async function* awaitHumanApproval(decisionId) {
|
|
498
|
-
const pendingDecision = resolveApproval !== undefined
|
|
499
|
-
? resolveApproval(decisionId)
|
|
500
|
-
: Promise.resolve({ action: "deny", reason: "no approval channel configured" });
|
|
501
|
-
const requested = log.append({
|
|
502
|
-
type: "permission_requested",
|
|
503
|
-
decisionId,
|
|
504
|
-
callId: call.callId,
|
|
505
|
-
name: call.name,
|
|
506
|
-
input: payload.input,
|
|
507
|
-
});
|
|
508
|
-
if (hooks.onPause)
|
|
509
|
-
await hooks.onPause("awaiting approval", {}).catch(() => { });
|
|
510
|
-
yield requested;
|
|
511
|
-
// Area 4: the pause is abortable — a cancel during the human's wait
|
|
512
|
-
// ends the run now; the request stays durable and pending.
|
|
513
|
-
let finalDecision;
|
|
514
|
-
try {
|
|
515
|
-
finalDecision = await raceAbort(pendingDecision, signal);
|
|
516
|
-
}
|
|
517
|
-
catch (err) {
|
|
518
|
-
if (err === ABORTED) {
|
|
519
|
-
// 第四轮(对抗): the human may have answered in the same instant
|
|
520
|
-
// the abort landed — a CONSUMED verdict must be recorded
|
|
521
|
-
// (exactly once), never lost; the abort then ends the run with
|
|
522
|
-
// its honest aborted terminal.
|
|
523
|
-
const verdict = resolveApprovalVerdict?.(decisionId);
|
|
524
|
-
if (verdict !== undefined) {
|
|
525
|
-
yield log.append({
|
|
526
|
-
type: "permission_decided",
|
|
527
|
-
decisionId,
|
|
528
|
-
callId: call.callId,
|
|
529
|
-
decision: verdict ? "approved" : "denied",
|
|
530
|
-
...(verdict ? {} : { reason: "denied by user" }),
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
throw err;
|
|
535
|
-
}
|
|
536
|
-
// The approval channel (session.approve) persists the decision
|
|
537
|
-
// write-ahead BEFORE waking the resolver (Area 2): if it already
|
|
538
|
-
// landed in the log, this is the same decision, not a duplicate.
|
|
539
|
-
const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === decisionId) ??
|
|
540
|
-
log.append({
|
|
541
|
-
type: "permission_decided",
|
|
542
|
-
decisionId,
|
|
543
|
-
callId: call.callId, // binds the decision to the invocation (B 组)
|
|
544
|
-
decision: finalDecision.action === "allow" ? "approved" : "denied",
|
|
545
|
-
...(finalDecision.action === "deny" && finalDecision.reason !== undefined
|
|
546
|
-
? { reason: finalDecision.reason }
|
|
547
|
-
: {}),
|
|
548
|
-
});
|
|
549
|
-
yield decided;
|
|
550
|
-
return finalDecision;
|
|
551
|
-
}
|
|
552
635
|
// ── E1: the extension policy chain, decided BEFORE the human flow ─────
|
|
553
636
|
// A durable POLICY decision for THIS call takes effect on resume — the
|
|
554
637
|
// chain never re-runs when its verdict is already in the log (同构
|
|
@@ -600,16 +683,16 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
600
683
|
else if (chainVerdict === undefined) {
|
|
601
684
|
// 全员 abstain (ADR-0042): NO policy speaks — the call falls to
|
|
602
685
|
// the ask flow below, never to a silent auto-approve. The human
|
|
603
|
-
// decides; absent a channel,
|
|
686
|
+
// decides; absent a channel, humanPause's honest denial.
|
|
604
687
|
chainVerdict = { action: "ask" };
|
|
605
688
|
}
|
|
606
689
|
if (chainVerdict.action !== "ask") {
|
|
607
690
|
// allow/deny are PERSISTED FACTS (decidedBy = a SPEAKING
|
|
608
691
|
// extension — never the chain head on behalf of a non-speaker)
|
|
609
692
|
// — never a human pause.
|
|
610
|
-
|
|
693
|
+
push({
|
|
611
694
|
type: "permission_decided",
|
|
612
|
-
decisionId:
|
|
695
|
+
decisionId: nextDecisionId(),
|
|
613
696
|
callId: call.callId,
|
|
614
697
|
decision: chainVerdict.action === "allow" ? "approved" : "denied",
|
|
615
698
|
...(chainVerdict.action === "deny" ? { reason: chainVerdict.reason } : {}),
|
|
@@ -618,12 +701,10 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
618
701
|
}
|
|
619
702
|
}
|
|
620
703
|
if (chainVerdict?.action === "deny") {
|
|
621
|
-
|
|
622
|
-
return;
|
|
704
|
+
return { action: "deny", result: resultEvent(call, denialResult(chainVerdict.reason)) };
|
|
623
705
|
}
|
|
624
706
|
if (durable !== undefined && durable.decision === "denied") {
|
|
625
|
-
|
|
626
|
-
return;
|
|
707
|
+
return { action: "deny", result: resultEvent(call, denialResult(durable.reason ?? "denied")) };
|
|
627
708
|
}
|
|
628
709
|
if (chainVerdict?.action === "ask") {
|
|
629
710
|
// 裁决 A (E1 ask 语义修正): an ask means "a HUMAN must decide" — it
|
|
@@ -633,15 +714,9 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
633
714
|
// channel configured → an honest denial (judged by resolveApproval,
|
|
634
715
|
// not by the hook's presence).
|
|
635
716
|
if (resolveApproval === undefined) {
|
|
636
|
-
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
const decisionId = `d-${log.lastSeq + 1}`;
|
|
640
|
-
const finalDecision = yield* awaitHumanApproval(decisionId);
|
|
641
|
-
if (finalDecision.action !== "allow") {
|
|
642
|
-
yield emitResult(denialResult(finalDecision.reason ?? "denied"));
|
|
643
|
-
return;
|
|
717
|
+
return { action: "deny", result: resultEvent(call, denialResult("a policy asked for a human decision, but no approval flow is configured")) };
|
|
644
718
|
}
|
|
719
|
+
return { action: "ask", decisionId: nextDecisionId() };
|
|
645
720
|
}
|
|
646
721
|
// Permission negotiation — defer is a REAL pause (Phase D). C 组: the
|
|
647
722
|
// hook itself is cancelable (a slow policy query must not outlive an
|
|
@@ -654,37 +729,105 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
654
729
|
if (signal?.aborted)
|
|
655
730
|
throw ABORTED;
|
|
656
731
|
if (decision.action === "defer") {
|
|
657
|
-
|
|
658
|
-
const finalDecision = yield* awaitHumanApproval(decisionId);
|
|
659
|
-
if (finalDecision.action !== "allow") {
|
|
660
|
-
yield emitResult(denialResult(finalDecision.reason ?? "denied"));
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
732
|
+
return { action: "ask", decisionId: nextDecisionId() };
|
|
663
733
|
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
return;
|
|
734
|
+
if (decision.action !== "allow") {
|
|
735
|
+
return { action: "deny", result: resultEvent(call, denialResult(decision.reason ?? "denied")) };
|
|
667
736
|
}
|
|
668
737
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
738
|
+
return { action: "allow" };
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* The human approval pause (Phase D / 裁决 A): register the resolver
|
|
742
|
+
* BEFORE announcing the request (a consumer that answers the moment it
|
|
743
|
+
* sees the event must find the resolver already waiting — no deadlock
|
|
744
|
+
* between push and await), persist the request (via push), await the
|
|
745
|
+
* human's decision — abortable (an abort during the wait ends the run; a
|
|
746
|
+
* verdict given in the same instant is still recorded exactly once) —
|
|
747
|
+
* then persist the decision. Returns "approved" | "denied".
|
|
748
|
+
*/
|
|
749
|
+
async function humanPause(call, decisionId, hooks, log, resolveApproval, resolveApprovalVerdict, signal, push) {
|
|
750
|
+
const pendingDecision = resolveApproval !== undefined
|
|
751
|
+
? resolveApproval(decisionId)
|
|
752
|
+
: Promise.resolve({ action: "deny", reason: "no approval channel configured" });
|
|
753
|
+
push({
|
|
754
|
+
type: "permission_requested",
|
|
755
|
+
decisionId,
|
|
756
|
+
callId: call.callId,
|
|
757
|
+
name: call.name,
|
|
758
|
+
input: call.input ?? {},
|
|
759
|
+
});
|
|
760
|
+
if (hooks.onPause)
|
|
761
|
+
await hooks.onPause("awaiting approval", {}).catch(() => { });
|
|
762
|
+
// Area 4: the pause is abortable — a cancel during the human's wait
|
|
763
|
+
// ends the run now; the request stays durable and pending.
|
|
764
|
+
let finalDecision;
|
|
765
|
+
try {
|
|
766
|
+
finalDecision = await raceAbort(pendingDecision, signal);
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
if (err === ABORTED) {
|
|
770
|
+
// 第四轮(对抗): the human may have answered in the same instant
|
|
771
|
+
// the abort landed — a CONSUMED verdict must be recorded
|
|
772
|
+
// (exactly once), never lost; the abort then ends the run with
|
|
773
|
+
// its honest aborted terminal.
|
|
774
|
+
const verdict = resolveApprovalVerdict?.(decisionId);
|
|
775
|
+
if (verdict !== undefined) {
|
|
776
|
+
push({
|
|
777
|
+
type: "permission_decided",
|
|
778
|
+
decisionId,
|
|
779
|
+
callId: call.callId,
|
|
780
|
+
decision: verdict ? "approved" : "denied",
|
|
781
|
+
...(verdict ? {} : { reason: "denied by user" }),
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
// The approval channel (session.approve) persists the decision
|
|
788
|
+
// write-ahead BEFORE waking the resolver (Area 2): if it already
|
|
789
|
+
// landed in the log, this is the same decision, not a duplicate.
|
|
790
|
+
if (log.all.find((e) => e.type === "permission_decided" && e.decisionId === decisionId) === undefined) {
|
|
791
|
+
push({
|
|
792
|
+
type: "permission_decided",
|
|
793
|
+
decisionId,
|
|
794
|
+
callId: call.callId, // binds the decision to the invocation (B 组)
|
|
795
|
+
decision: finalDecision.action === "allow" ? "approved" : "denied",
|
|
796
|
+
...(finalDecision.action === "deny" && finalDecision.reason !== undefined
|
|
797
|
+
? { reason: finalDecision.reason }
|
|
798
|
+
: {}),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
return finalDecision;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* 0.1.26: the ledgered execution of an ALLOWED call — started (durable
|
|
805
|
+
* BEFORE the side effect, write-ahead acked by the drain), handler,
|
|
806
|
+
* receipt, result. The executionId comes from the loop's monotonic
|
|
807
|
+
* allocator (under concurrency the old `lastSeq + 1` prediction raced).
|
|
808
|
+
*/
|
|
809
|
+
async function runLedgered(call, registry, hooks, ctx, signal, push) {
|
|
810
|
+
const tool = registry.get(call.name); // validated + decided by decideCall
|
|
675
811
|
// C 组: the signal is re-checked immediately before the started event —
|
|
676
812
|
// an abort that landed in any permission path must not let the side
|
|
677
813
|
// effect begin.
|
|
678
814
|
if (signal?.aborted)
|
|
679
815
|
throw ABORTED;
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
816
|
+
// The started event is durable BEFORE the side effect; a crash between
|
|
817
|
+
// it and the result leaves "uncertain". The ack resolves when the
|
|
818
|
+
// drain yields the event and the consumer persisted it — the handler
|
|
819
|
+
// never runs before its receipt is on disk (write-ahead preserved
|
|
820
|
+
// under the parallel execution). The executionId is allocated BY THE
|
|
821
|
+
// DRAIN (ex-<seq> — atomic with the append, stable across replays) and
|
|
822
|
+
// carried back through the ack.
|
|
823
|
+
const executionId = await new Promise((res) => {
|
|
824
|
+
push({
|
|
825
|
+
type: "tool_execution_started",
|
|
826
|
+
callId: call.callId,
|
|
827
|
+
name: call.name,
|
|
828
|
+
input: call.input, // non-null: decideCall denied a null input before this ran
|
|
829
|
+
}, (id) => res(id ?? ""));
|
|
686
830
|
});
|
|
687
|
-
yield started;
|
|
688
831
|
let result;
|
|
689
832
|
try {
|
|
690
833
|
// C 组: re-checked again right before the handler — the handler also
|
|
@@ -702,7 +845,7 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
702
845
|
};
|
|
703
846
|
}
|
|
704
847
|
if (hooks.onPostTool) {
|
|
705
|
-
result = await hooks.onPostTool(
|
|
848
|
+
result = await hooks.onPostTool({ callId: call.callId, name: call.name, input: call.input ?? {} }, result, ctx);
|
|
706
849
|
}
|
|
707
850
|
// 裁决 #12 修正一: a non-idempotent failure's side effects may have
|
|
708
851
|
// partially applied — an honest note rides the RESULT (and the failed
|
|
@@ -722,7 +865,7 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
722
865
|
// effect and is uncertain until a human decides.
|
|
723
866
|
// 八: the tags ride on the RECEIPT too — a crash-window repair of the
|
|
724
867
|
// tool_result reproduces the normal path losslessly.
|
|
725
|
-
|
|
868
|
+
push({
|
|
726
869
|
type: "tool_execution_failed",
|
|
727
870
|
executionId,
|
|
728
871
|
callId: call.callId,
|
|
@@ -736,7 +879,7 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
736
879
|
});
|
|
737
880
|
}
|
|
738
881
|
else {
|
|
739
|
-
|
|
882
|
+
push({
|
|
740
883
|
type: "tool_execution_succeeded",
|
|
741
884
|
executionId,
|
|
742
885
|
callId: call.callId,
|
|
@@ -744,7 +887,7 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
|
|
|
744
887
|
...(result.tags !== undefined ? { tags: result.tags } : {}),
|
|
745
888
|
});
|
|
746
889
|
}
|
|
747
|
-
|
|
890
|
+
push(resultEvent(call, result, executionId));
|
|
748
891
|
}
|
|
749
892
|
/** Thrown when an abort lands while the loop awaits a human decision. */
|
|
750
893
|
const ABORTED = Symbol("kiso-aborted-during-approval");
|
package/dist/kernel/project.js
CHANGED
|
@@ -139,6 +139,27 @@ export function projectMessages(events) {
|
|
|
139
139
|
const isCovered = (seq) => summaryRanges.some((r) => seq > r.from && seq <= r.to);
|
|
140
140
|
// Summaries render in range order as the pass crosses their boundaries.
|
|
141
141
|
let renderedSummaries = 0;
|
|
142
|
+
// 0.1.26 (ADR-0024 Amd, parallel execution): the tool results of ONE turn
|
|
143
|
+
// are buffered and emitted in CALL order at the turn boundary. The
|
|
144
|
+
// physical seq order is the COMPLETION order (started/receipt/result land
|
|
145
|
+
// when each execution finishes — parallel), which must never enter the
|
|
146
|
+
// projection: the same logical turn projects byte-identically whatever
|
|
147
|
+
// the completion interleaving (字节纪律 — 以 call 序为准,完成序只影响
|
|
148
|
+
// 落盘时刻). `callOrder` is rebuilt per turn from the tool_call_end
|
|
149
|
+
// events (their seq order IS the call order — the stream order).
|
|
150
|
+
let resultBuf = [];
|
|
151
|
+
const callOrder = new Map();
|
|
152
|
+
let callOrderNext = 0;
|
|
153
|
+
const flushResults = () => {
|
|
154
|
+
if (resultBuf.length === 0)
|
|
155
|
+
return;
|
|
156
|
+
resultBuf.sort((a, b) => (callOrder.get(a.callId) ?? 0) - (callOrder.get(b.callId) ?? 0));
|
|
157
|
+
for (const r of resultBuf)
|
|
158
|
+
out.push(r.message);
|
|
159
|
+
resultBuf = [];
|
|
160
|
+
callOrder.clear();
|
|
161
|
+
callOrderNext = 0;
|
|
162
|
+
};
|
|
142
163
|
let explicitAssistant = false;
|
|
143
164
|
for (const ev of events) {
|
|
144
165
|
// ADR-0044: covered events are replaced by their summary — seed
|
|
@@ -154,6 +175,7 @@ export function projectMessages(events) {
|
|
|
154
175
|
ev.seq !== undefined &&
|
|
155
176
|
ev.seq > summaryRanges[renderedSummaries].to) {
|
|
156
177
|
flushAssistant();
|
|
178
|
+
flushResults(); // the results follow the assistant in reading order
|
|
157
179
|
out.push({
|
|
158
180
|
role: "assistant",
|
|
159
181
|
blocks: [{ type: "text", text: summaryRanges[renderedSummaries].summary }],
|
|
@@ -162,6 +184,10 @@ export function projectMessages(events) {
|
|
|
162
184
|
}
|
|
163
185
|
switch (ev.type) {
|
|
164
186
|
case "user_input": {
|
|
187
|
+
// 0.1.26: the previous turn closes here — the assistant
|
|
188
|
+
// first, then its results in call order (the turn boundary).
|
|
189
|
+
flushAssistant();
|
|
190
|
+
flushResults();
|
|
165
191
|
// 六: the final replacement renders HERE, at the input's own
|
|
166
192
|
// position — the original is skipped, the replacement event
|
|
167
193
|
// itself produces nothing (a later replacement for the same
|
|
@@ -194,6 +220,7 @@ export function projectMessages(events) {
|
|
|
194
220
|
// D 组: an explicit message boundary — close any open message
|
|
195
221
|
// and begin a new one (adjacent assistants stay separate).
|
|
196
222
|
flushAssistant();
|
|
223
|
+
flushResults();
|
|
197
224
|
explicitAssistant = true;
|
|
198
225
|
if (ev.source !== undefined)
|
|
199
226
|
assistantSource = ev.source;
|
|
@@ -214,6 +241,8 @@ export function projectMessages(events) {
|
|
|
214
241
|
explicitAssistant = false;
|
|
215
242
|
break;
|
|
216
243
|
case "text_start":
|
|
244
|
+
// An INTERNAL block boundary (a multi-block assistant
|
|
245
|
+
// message) — never a turn boundary; no flush.
|
|
217
246
|
pushText(); // an explicit boundary: a new block begins
|
|
218
247
|
if (ev.source !== undefined)
|
|
219
248
|
assistantSource = ev.source;
|
|
@@ -222,6 +251,20 @@ export function projectMessages(events) {
|
|
|
222
251
|
pushText(); // an explicit boundary: the block closes
|
|
223
252
|
break;
|
|
224
253
|
case "text_delta":
|
|
254
|
+
// 0.1.26: a text delta with BUFFERED RESULTS opens the NEXT
|
|
255
|
+
// turn's stream — the previous turn closes HERE: its
|
|
256
|
+
// assistant first (already closed at the stop, usually),
|
|
257
|
+
// then its buffered results in call order (the turn
|
|
258
|
+
// boundary; the API requires each tool_calls message to be
|
|
259
|
+
// followed by its tool messages — a real DeepSeek 400
|
|
260
|
+
// without the boundary). The guard keys on the RESULT
|
|
261
|
+
// buffer: a mid-message delta (the current turn's text, the
|
|
262
|
+
// multi-block assistant) has nothing buffered and must not
|
|
263
|
+
// flush — the empty flush would clear the source and split
|
|
264
|
+
// the message.
|
|
265
|
+
if (resultBuf.length > 0)
|
|
266
|
+
flushAssistant();
|
|
267
|
+
flushResults();
|
|
225
268
|
text = (text ?? "") + ev.text;
|
|
226
269
|
break;
|
|
227
270
|
case "tool_call_start":
|
|
@@ -231,7 +274,15 @@ export function projectMessages(events) {
|
|
|
231
274
|
case "tool_call_input_delta":
|
|
232
275
|
break; // the parsed input arrives at tool_call_end
|
|
233
276
|
case "tool_call_end":
|
|
277
|
+
// 0.1.26: a tool_call_end with BUFFERED RESULTS opens the
|
|
278
|
+
// NEXT turn's stream (the model called again) — the
|
|
279
|
+
// previous turn closes first; a same-turn call has nothing
|
|
280
|
+
// buffered.
|
|
281
|
+
if (resultBuf.length > 0)
|
|
282
|
+
flushAssistant();
|
|
283
|
+
flushResults();
|
|
234
284
|
pushText();
|
|
285
|
+
callOrder.set(ev.callId, callOrderNext++);
|
|
235
286
|
blocks.push({
|
|
236
287
|
type: "tool_use",
|
|
237
288
|
callId: ev.callId,
|
|
@@ -240,7 +291,11 @@ export function projectMessages(events) {
|
|
|
240
291
|
});
|
|
241
292
|
break;
|
|
242
293
|
case "tool_result": {
|
|
243
|
-
flushAssistant
|
|
294
|
+
// 0.1.26: NO flushAssistant — the streaming execution lands
|
|
295
|
+
// results BETWEEN the turn's tool_call_ends; closing the
|
|
296
|
+
// assistant here splits the turn's tool_calls message (the
|
|
297
|
+
// API 400). The assistant stays open and closes at the turn
|
|
298
|
+
// boundary with ALL its tool_use blocks.
|
|
244
299
|
const message = {
|
|
245
300
|
role: "tool",
|
|
246
301
|
callId: ev.callId,
|
|
@@ -258,10 +313,14 @@ export function projectMessages(events) {
|
|
|
258
313
|
if ("seq" in ev && typeof ev.seq === "number") {
|
|
259
314
|
Object.defineProperty(message, "eventSeq", { value: ev.seq, enumerable: false, configurable: true });
|
|
260
315
|
}
|
|
261
|
-
|
|
316
|
+
// 0.1.26: BUFFERED — the results flush in call order at the
|
|
317
|
+
// turn boundary (flushResults), not in completion order.
|
|
318
|
+
resultBuf.push({ callId: ev.callId, message });
|
|
262
319
|
break;
|
|
263
320
|
}
|
|
264
321
|
case "microcompacted": {
|
|
322
|
+
flushAssistant();
|
|
323
|
+
flushResults(); // the results must be in `out` before the replacement pass
|
|
265
324
|
flushAssistant();
|
|
266
325
|
// C 区: replace every eligible OLD tool result with the fixed
|
|
267
326
|
// placeholder. Eligibility: the result's own event seq <= the
|
|
@@ -283,6 +342,8 @@ export function projectMessages(events) {
|
|
|
283
342
|
break;
|
|
284
343
|
}
|
|
285
344
|
case "compacted": {
|
|
345
|
+
flushAssistant();
|
|
346
|
+
flushResults(); // the results must be in `out` before the replacement pass
|
|
286
347
|
flushAssistant();
|
|
287
348
|
// Apply the EXACT persisted replacements — never re-run the
|
|
288
349
|
// compaction algorithm (a future version could differ). 五:
|
|
@@ -310,8 +371,12 @@ export function projectMessages(events) {
|
|
|
310
371
|
case "thinking":
|
|
311
372
|
// 自举 P1: accumulate the turn's reasoning — the flush (an
|
|
312
373
|
// empty one at the turn's start) keeps the pending text, and
|
|
313
|
-
// the assistant message that follows carries it.
|
|
314
|
-
|
|
374
|
+
// the assistant message that follows carries it. 0.1.26: the
|
|
375
|
+
// assistant flush is guarded on the buffered results (a
|
|
376
|
+
// mid-turn reasoning must not split the current message).
|
|
377
|
+
if (resultBuf.length > 0)
|
|
378
|
+
flushAssistant();
|
|
379
|
+
flushResults();
|
|
315
380
|
pendingReasoning = (pendingReasoning ?? "") + ev.text;
|
|
316
381
|
break;
|
|
317
382
|
case "summarized":
|
|
@@ -320,9 +385,23 @@ export function projectMessages(events) {
|
|
|
320
385
|
// covered range's position.
|
|
321
386
|
break;
|
|
322
387
|
case "usage":
|
|
388
|
+
// 0.1.26: NO flush — the non-rendered events interleave with
|
|
389
|
+
// the streaming execution (a tool's started/receipt, an
|
|
390
|
+
// ask's request land BETWEEN the turn's tool_call_ends).
|
|
391
|
+
// Flushing here SPLIT the turn's assistant message — the
|
|
392
|
+
// API requires each tool_calls message to be followed by
|
|
393
|
+
// ITS tool messages, and a second assistant message (with
|
|
394
|
+
// the later calls) between the first's calls and results
|
|
395
|
+
// is a real 400. The assistant closes at the stop and the
|
|
396
|
+
// turn boundaries.
|
|
397
|
+
break;
|
|
323
398
|
case "stop":
|
|
399
|
+
// The turn boundary: the assistant closes at the provider's
|
|
400
|
+
// stop; the results are buffered and flush at the next
|
|
401
|
+
// turn's first event.
|
|
402
|
+
flushAssistant();
|
|
403
|
+
break;
|
|
324
404
|
case "terminal":
|
|
325
|
-
case "microcompacted":
|
|
326
405
|
case "tool_execution_started":
|
|
327
406
|
case "tool_execution_succeeded":
|
|
328
407
|
case "tool_execution_failed":
|
|
@@ -331,11 +410,14 @@ export function projectMessages(events) {
|
|
|
331
410
|
case "permission_decided":
|
|
332
411
|
case "permission_expired":
|
|
333
412
|
case "uncertain_pending":
|
|
334
|
-
|
|
413
|
+
break;
|
|
414
|
+
case "microcompacted":
|
|
415
|
+
// handled above (the replacement pass) — no open message.
|
|
335
416
|
break;
|
|
336
417
|
}
|
|
337
418
|
}
|
|
338
419
|
flushAssistant();
|
|
420
|
+
flushResults();
|
|
339
421
|
return out;
|
|
340
422
|
}
|
|
341
423
|
/**
|
|
@@ -67,6 +67,12 @@ export interface KisoExtension {
|
|
|
67
67
|
readonly systemPrompt?: {
|
|
68
68
|
readonly append: string;
|
|
69
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* 0.1.26 (MCP 懒连接): an optional LIVE flag — the CLI's banner renders
|
|
72
|
+
* "name (connecting…)" while it is true. Soft surface: absent = no
|
|
73
|
+
* marker (the default).
|
|
74
|
+
*/
|
|
75
|
+
readonly connecting?: boolean;
|
|
70
76
|
/**
|
|
71
77
|
* 发现#8 (P1): the extension's shutdown action — the closing of external
|
|
72
78
|
* resources it holds (child processes, connections). The LOADER is
|
package/dist/tools/registry.d.ts
CHANGED
|
@@ -11,12 +11,24 @@
|
|
|
11
11
|
* registry whose tool table PHYSICALLY lacks the tools it must not see. The
|
|
12
12
|
* model cannot call a tool that is not in its registry — no prompt can
|
|
13
13
|
* achieve that guarantee.
|
|
14
|
+
*
|
|
15
|
+
* 0.1.26 (MCP 懒连接): `registerLive()` adds a LIVE tool source — a
|
|
16
|
+
* function returning the extension's current tools array. The array grows
|
|
17
|
+
* when the extension's background connections settle (the MCP bridge
|
|
18
|
+
* registers its servers' tools post-connect); the registry consults the
|
|
19
|
+
* live sources on every lookup. The registered map wins a name collision
|
|
20
|
+
* (the agent's built-ins are authoritative); the collision check that
|
|
21
|
+
* would otherwise fire at registration time cannot run against a live,
|
|
22
|
+
* still-growing source.
|
|
14
23
|
*/
|
|
15
24
|
import type { ToolSpec } from "../protocol/messages.js";
|
|
16
25
|
import type { Tool } from "./tool.js";
|
|
17
26
|
export declare class ToolRegistry {
|
|
18
27
|
#private;
|
|
19
28
|
register(tool: Tool<any>): void;
|
|
29
|
+
/** 0.1.26: a live tool source — consulted on every lookup, never
|
|
30
|
+
* snapshotted. The source returns the CURRENT array (it may grow). */
|
|
31
|
+
registerLive(source: () => readonly Tool[]): void;
|
|
20
32
|
get(name: string): Tool<any> | undefined;
|
|
21
33
|
list(): readonly Tool[];
|
|
22
34
|
has(name: string): boolean;
|
package/dist/tools/registry.js
CHANGED
|
@@ -11,30 +11,55 @@
|
|
|
11
11
|
* registry whose tool table PHYSICALLY lacks the tools it must not see. The
|
|
12
12
|
* model cannot call a tool that is not in its registry — no prompt can
|
|
13
13
|
* achieve that guarantee.
|
|
14
|
+
*
|
|
15
|
+
* 0.1.26 (MCP 懒连接): `registerLive()` adds a LIVE tool source — a
|
|
16
|
+
* function returning the extension's current tools array. The array grows
|
|
17
|
+
* when the extension's background connections settle (the MCP bridge
|
|
18
|
+
* registers its servers' tools post-connect); the registry consults the
|
|
19
|
+
* live sources on every lookup. The registered map wins a name collision
|
|
20
|
+
* (the agent's built-ins are authoritative); the collision check that
|
|
21
|
+
* would otherwise fire at registration time cannot run against a live,
|
|
22
|
+
* still-growing source.
|
|
14
23
|
*/
|
|
15
24
|
export class ToolRegistry {
|
|
16
25
|
#tools = new Map();
|
|
26
|
+
#live = [];
|
|
17
27
|
register(tool) {
|
|
18
28
|
if (this.#tools.has(tool.name)) {
|
|
19
29
|
throw new Error(`Tool already registered: ${tool.name}`);
|
|
20
30
|
}
|
|
21
31
|
this.#tools.set(tool.name, tool);
|
|
22
32
|
}
|
|
33
|
+
/** 0.1.26: a live tool source — consulted on every lookup, never
|
|
34
|
+
* snapshotted. The source returns the CURRENT array (it may grow). */
|
|
35
|
+
registerLive(source) {
|
|
36
|
+
this.#live.push(source);
|
|
37
|
+
}
|
|
23
38
|
get(name) {
|
|
24
|
-
|
|
39
|
+
const t = this.#tools.get(name);
|
|
40
|
+
if (t !== undefined)
|
|
41
|
+
return t;
|
|
42
|
+
for (const src of this.#live) {
|
|
43
|
+
const found = src().find((x) => x.name === name);
|
|
44
|
+
if (found !== undefined)
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
25
48
|
}
|
|
26
49
|
list() {
|
|
27
|
-
return [...this.#tools.values()];
|
|
50
|
+
return [...this.#tools.values(), ...this.#live.flatMap((src) => src())];
|
|
28
51
|
}
|
|
29
52
|
has(name) {
|
|
30
|
-
|
|
53
|
+
if (this.#tools.has(name))
|
|
54
|
+
return true;
|
|
55
|
+
return this.#live.some((src) => src().some((x) => x.name === name));
|
|
31
56
|
}
|
|
32
57
|
/** A registry restricted to the named tools. Unknown names are dropped
|
|
33
58
|
* loudly (the kernel never silently shrinks a tool set). */
|
|
34
59
|
subset(names) {
|
|
35
60
|
const out = new ToolRegistry();
|
|
36
61
|
for (const name of names) {
|
|
37
|
-
const tool = this
|
|
62
|
+
const tool = this.get(name);
|
|
38
63
|
if (tool === undefined) {
|
|
39
64
|
throw new Error(`subset(): unknown tool '${name}'`);
|
|
40
65
|
}
|
|
@@ -44,7 +69,7 @@ export class ToolRegistry {
|
|
|
44
69
|
}
|
|
45
70
|
/** The minimal projection an adapter may see (never the handlers). */
|
|
46
71
|
toSpecs() {
|
|
47
|
-
return
|
|
72
|
+
return this.list().map((t) => ({
|
|
48
73
|
name: t.name,
|
|
49
74
|
description: t.description,
|
|
50
75
|
inputSchema: t.parameters,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "kiso(\u57fa\u790e) core \u2014 protocol, event log, loop, hooks, modes, permissions, compaction, delivery truth. The 2,000-line kernel at the bottom of the kiso framework.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"openai"
|
|
34
34
|
],
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@vincemakes/kiso-evals": "0.1.
|
|
36
|
+
"@vincemakes/kiso-evals": "0.1.26",
|
|
37
37
|
"@types/node": "^26.1.2",
|
|
38
38
|
"typescript": "^5.7.2",
|
|
39
39
|
"vitest": "^3.0.0"
|