@vincemakes/kiso-core 0.1.25 → 0.1.27

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/index.d.ts CHANGED
@@ -8,7 +8,6 @@ export * from "./kernel/mode.js";
8
8
  export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
10
  export * from "./kernel/compaction.js";
11
- export * from "./kernel/summarize.js";
12
11
  export * from "./kernel/project.js";
13
12
  export * from "./kernel/ledger.js";
14
13
  export * from "./governance/delivery.js";
package/dist/index.js CHANGED
@@ -8,7 +8,6 @@ export * from "./kernel/mode.js";
8
8
  export * from "./kernel/permission.js";
9
9
  export * from "./kernel/loop.js";
10
10
  export * from "./kernel/compaction.js";
11
- export * from "./kernel/summarize.js";
12
11
  export * from "./kernel/project.js";
13
12
  export * from "./kernel/ledger.js";
14
13
  export * from "./governance/delivery.js";
@@ -15,10 +15,15 @@
15
15
  *
16
16
  * Per iteration:
17
17
  * assemble (onUserMessage / onPreLlm)
18
- * → adapter.stream(): events yielded straight through, tool calls collected
19
- * execute: validation permission (onPreTool) handler → rewrite
20
- * (onPostTool), concurrency-safe calls batched parallel, the rest serial
21
- * tool_result events appended
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
@@ -15,10 +15,15 @@
15
15
  *
16
16
  * Per iteration:
17
17
  * assemble (onUserMessage / onPreLlm)
18
- * → adapter.stream(): events yielded straight through, tool calls collected
19
- * execute: validation permission (onPreTool) handler → rewrite
20
- * (onPostTool), concurrency-safe calls batched parallel, the rest serial
21
- * tool_result events appended
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
- // ── 五: a forged kernel-owned event is a protocol error ──────────────
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
- yield await terminal({
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
- // ── 五: events after the stop are a protocol error ───────────────────
238
- if (postStopViolation) {
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
- // ── Terminal check: no tool call this turn → done, honestly ────────
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
- yield await terminal({
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
- yield await terminal({
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 before side effects: a stop landing during the
268
- // model turn must never let the pending tools run ────────────────
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 BEFORE any tool runs ────────────────
274
- // A tool may only execute when the provider turn is well-formed:
275
- // exactly one stop, whose reason is compatible with complete calls.
276
- // Missing/duplicate stops, max_tokens, refusal, content_filter,
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
- yield await terminal({
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
- yield await terminal({
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
- catch (err) {
348
- // An abort during the approval pause propagates here as the
349
- // sentinel — end the run honestly; the request stays durable.
350
- if (err === ABORTED || aborted()) {
351
- yield await terminal({ kind: "aborted", by: "user" });
352
- return;
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
- // 裁决 #12 (ADR-0038): the failed-receipt uncertain PAUSE is
357
- // REMOVED with a complete receipt (succeeded or failed) the
358
- // outcome is KNOWN, and uncertainty belongs to the crash window
359
- // alone (started, no receipt; surfaced through the ledger's
360
- // uncertainExecutions and resolved offline). A retry is a NEW
361
- // call it passes the approval chain again, which is the correct
362
- // guard for partial side effects; the honest note on
363
- // non-idempotent failures rides the result (修正一).
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
- // ── Execution ──────────────────────────────────────────────────────────
417
- /**
418
- * Execute one tool call as a ledgered sequence of events:
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
- yield emitResult({
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
- yield emitResult({
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
- yield emitResult({
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, awaitHumanApproval's honest denial.
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
- yield log.append({
693
+ push({
611
694
  type: "permission_decided",
612
- decisionId: `d-${log.lastSeq + 1}`,
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
- yield emitResult(denialResult(chainVerdict.reason));
622
- return;
704
+ return { action: "deny", result: resultEvent(call, denialResult(chainVerdict.reason)) };
623
705
  }
624
706
  if (durable !== undefined && durable.decision === "denied") {
625
- yield emitResult(denialResult(durable.reason ?? "denied"));
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
- yield emitResult(denialResult("a policy asked for a human decision, but no approval flow is configured"));
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
- const decisionId = `d-${log.lastSeq + 1}`;
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
- else if (decision.action !== "allow") {
665
- yield emitResult(denialResult(decision.reason ?? "denied"));
666
- return;
734
+ if (decision.action !== "allow") {
735
+ return { action: "deny", result: resultEvent(call, denialResult(decision.reason ?? "denied")) };
667
736
  }
668
737
  }
669
- // The ledgered execution. The started event is durable BEFORE the side
670
- // effect; a crash between it and the result leaves "uncertain". The
671
- // executionId is the persistent identity of THIS logical execution
672
- // (Area 3): generated from the log's next seq, so it is unique per log
673
- // and survives restarts.
674
- const executionId = `ex-${log.lastSeq + 1}`;
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
- const started = log.append({
681
- type: "tool_execution_started",
682
- executionId,
683
- callId: call.callId,
684
- name: call.name,
685
- input: call.input,
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(payload, result, ctx);
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
- yield log.append({
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
- yield log.append({
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
- yield emitResult(result, executionId);
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");
@@ -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
- out.push(message);
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
- flushAssistant();
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
- flushAssistant();
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
@@ -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;
@@ -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
- return this.#tools.get(name);
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
- return this.#tools.has(name);
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.#tools.get(name);
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 [...this.#tools.values()].map((t) => ({
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,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.1.25",
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.",
3
+ "version": "0.1.27",
4
+ "description": "kiso(基礎) core 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",
7
7
  "exports": {
@@ -33,7 +33,7 @@
33
33
  "openai"
34
34
  ],
35
35
  "devDependencies": {
36
- "@vincemakes/kiso-evals": "0.1.25",
36
+ "@vincemakes/kiso-evals": "0.1.27",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"