@vincemakes/kiso-core 0.1.5 → 0.1.7

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.
@@ -69,6 +69,7 @@ export interface LoopConfig {
69
69
  */
70
70
  readonly microcompact?: {
71
71
  readonly thresholdTokens: number;
72
+ readonly keepResults?: number;
72
73
  };
73
74
  readonly signal?: AbortSignalLike;
74
75
  readonly temperature?: number;
@@ -151,7 +151,7 @@ export async function* loop(config) {
151
151
  }
152
152
  // ── C 区: one-shot microcompact boundary when over the threshold ──
153
153
  if (config.microcompact !== undefined && estimateTokens(messages) > config.microcompact.thresholdTokens) {
154
- const beforeSeq = microcompactBoundarySeq(log.all);
154
+ const beforeSeq = microcompactBoundarySeq(log.all, config.microcompact.keepResults ?? KEEP_COMPACTABLE_RESULTS);
155
155
  if (beforeSeq !== undefined) {
156
156
  const full = log.append({ type: "microcompacted", beforeSeq });
157
157
  if (hooks.onEvent)
@@ -588,6 +588,70 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
588
588
  // all. Checked again here, after any permission path.
589
589
  if (signal?.aborted)
590
590
  throw ABORTED;
591
+ /**
592
+ * The human approval pause (Phase D / 裁决 A): register the resolver
593
+ * BEFORE announcing the request (a consumer that answers the moment it
594
+ * sees the event must find the resolver already waiting — no deadlock
595
+ * between yield and await), persist the request, yield it, await the
596
+ * human's decision — abortable (an abort during the wait ends the run;
597
+ * a verdict given in the same instant is still recorded exactly once) —
598
+ * then persist and yield the decision. Returns the human's verdict.
599
+ */
600
+ async function* awaitHumanApproval(decisionId) {
601
+ const pendingDecision = resolveApproval !== undefined
602
+ ? resolveApproval(decisionId)
603
+ : Promise.resolve({ action: "deny", reason: "no approval channel configured" });
604
+ const requested = log.append({
605
+ type: "permission_requested",
606
+ decisionId,
607
+ callId: call.callId,
608
+ name: call.name,
609
+ input: payload.input,
610
+ });
611
+ if (hooks.onPause)
612
+ await hooks.onPause("awaiting approval", {}).catch(() => { });
613
+ yield requested;
614
+ // Area 4: the pause is abortable — a cancel during the human's wait
615
+ // ends the run now; the request stays durable and pending.
616
+ let finalDecision;
617
+ try {
618
+ finalDecision = await raceAbort(pendingDecision, signal);
619
+ }
620
+ catch (err) {
621
+ if (err === ABORTED) {
622
+ // 第四轮(对抗): the human may have answered in the same instant
623
+ // the abort landed — a CONSUMED verdict must be recorded
624
+ // (exactly once), never lost; the abort then ends the run with
625
+ // its honest aborted terminal.
626
+ const verdict = resolveApprovalVerdict?.(decisionId);
627
+ if (verdict !== undefined) {
628
+ yield log.append({
629
+ type: "permission_decided",
630
+ decisionId,
631
+ callId: call.callId,
632
+ decision: verdict ? "approved" : "denied",
633
+ ...(verdict ? {} : { reason: "denied by user" }),
634
+ });
635
+ }
636
+ }
637
+ throw err;
638
+ }
639
+ // The approval channel (session.approve) persists the decision
640
+ // write-ahead BEFORE waking the resolver (Area 2): if it already
641
+ // landed in the log, this is the same decision, not a duplicate.
642
+ const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === decisionId) ??
643
+ log.append({
644
+ type: "permission_decided",
645
+ decisionId,
646
+ callId: call.callId, // binds the decision to the invocation (B 组)
647
+ decision: finalDecision.action === "allow" ? "approved" : "denied",
648
+ ...(finalDecision.action === "deny" && finalDecision.reason !== undefined
649
+ ? { reason: finalDecision.reason }
650
+ : {}),
651
+ });
652
+ yield decided;
653
+ return finalDecision;
654
+ }
591
655
  // ── E1: the extension policy chain, decided BEFORE the human flow ─────
592
656
  // A durable POLICY decision for THIS call takes effect on resume — the
593
657
  // chain never re-runs when its verdict is already in the log (同构
@@ -651,77 +715,37 @@ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, res
651
715
  yield emitResult(denialResult(durable.reason ?? "denied"));
652
716
  return;
653
717
  }
654
- if (chainVerdict?.action === "ask" && hooks.onPreTool === undefined) {
655
- // No human flow exists the ask degrades to an honest denial, never
656
- // an unasked execution (mirrors the defer-without-channel path).
657
- yield emitResult(denialResult("a policy asked for a human decision, but no approval flow is configured"));
658
- return;
718
+ if (chainVerdict?.action === "ask") {
719
+ // 裁决 A (E1 ask 语义修正): an ask means "a HUMAN must decide" — it
720
+ // routes DIRECTLY to the human approval pause, never through
721
+ // onPreTool: a static automated policy (e.g. the CLI's default deny
722
+ // for unknown tools) must not answer for the human. No approval
723
+ // channel configured → an honest denial (judged by resolveApproval,
724
+ // not by the hook's presence).
725
+ if (resolveApproval === undefined) {
726
+ yield emitResult(denialResult("a policy asked for a human decision, but no approval flow is configured"));
727
+ return;
728
+ }
729
+ const decisionId = `d-${log.lastSeq + 1}`;
730
+ const finalDecision = yield* awaitHumanApproval(decisionId);
731
+ if (finalDecision.action !== "allow") {
732
+ yield emitResult(denialResult(finalDecision.reason ?? "denied"));
733
+ return;
734
+ }
659
735
  }
660
736
  // Permission negotiation — defer is a REAL pause (Phase D). C 组: the
661
737
  // hook itself is cancelable (a slow policy query must not outlive an
662
- // abort), and the signal is re-checked after it returns. Skipped when
663
- // the policy chain already decided (durable or all-allow).
664
- if (durable === undefined && chainVerdict?.action !== "allow" && hooks.onPreTool) {
738
+ // abort), and the signal is re-checked after it returns. Runs only when
739
+ // the policy chain did not run at all (裁决 A: an ask was already
740
+ // resolved by the human pause above the static hook never speaks for
741
+ // it, and a durable decision already spoke for the call).
742
+ if (durable === undefined && chainVerdict === undefined && hooks.onPreTool) {
665
743
  const decision = await raceAbort(hooks.onPreTool(payload, ctx), signal);
666
744
  if (signal?.aborted)
667
745
  throw ABORTED;
668
746
  if (decision.action === "defer") {
669
747
  const decisionId = `d-${log.lastSeq + 1}`;
670
- // Register the resolver BEFORE announcing the pause: a consumer
671
- // that answers the request the moment it sees it must find the
672
- // resolver already waiting (no deadlock between yield and await).
673
- const pendingDecision = resolveApproval !== undefined
674
- ? resolveApproval(decisionId)
675
- : Promise.resolve({ action: "deny", reason: "no approval channel configured" });
676
- const requested = log.append({
677
- type: "permission_requested",
678
- decisionId,
679
- callId: call.callId,
680
- name: call.name,
681
- input: payload.input,
682
- });
683
- if (hooks.onPause)
684
- await hooks.onPause("awaiting approval", {}).catch(() => { });
685
- yield requested;
686
- // Area 4: the pause is abortable — a cancel during the human's
687
- // wait ends the run now; the request stays durable and pending.
688
- let finalDecision;
689
- try {
690
- finalDecision = await raceAbort(pendingDecision, signal);
691
- }
692
- catch (err) {
693
- if (err === ABORTED) {
694
- // 第四轮(对抗): the human may have answered in the same
695
- // instant the abort landed — a CONSUMED verdict must be
696
- // recorded (exactly once), never lost; the abort then
697
- // ends the run with its honest aborted terminal.
698
- const verdict = resolveApprovalVerdict?.(decisionId);
699
- if (verdict !== undefined) {
700
- yield log.append({
701
- type: "permission_decided",
702
- decisionId,
703
- callId: call.callId,
704
- decision: verdict ? "approved" : "denied",
705
- ...(verdict ? {} : { reason: "denied by user" }),
706
- });
707
- }
708
- }
709
- throw err;
710
- }
711
- // The approval channel (session.approve) persists the decision
712
- // write-ahead BEFORE waking the resolver (Area 2): if it already
713
- // landed in the log, this is the same decision, not a duplicate.
714
- const decided = log.all.find((e) => e.type === "permission_decided" && e.decisionId === decisionId) ??
715
- log.append({
716
- type: "permission_decided",
717
- decisionId,
718
- callId: call.callId, // binds the decision to the invocation (B 组)
719
- decision: finalDecision.action === "allow" ? "approved" : "denied",
720
- ...(finalDecision.action === "deny" && finalDecision.reason !== undefined
721
- ? { reason: finalDecision.reason }
722
- : {}),
723
- });
724
- yield decided;
748
+ const finalDecision = yield* awaitHumanApproval(decisionId);
725
749
  if (finalDecision.action !== "allow") {
726
750
  yield emitResult(denialResult(finalDecision.reason ?? "denied"));
727
751
  return;
@@ -867,23 +891,24 @@ function sleep(ms, signal) {
867
891
  });
868
892
  }
869
893
  /**
870
- * C 区 (自举 #3): how many of the NEWEST compactable tool results survive a
871
- * microcompact boundary the model must keep reasoning over the recent
872
- * results, whatever turn they belong to.
894
+ * C 区 (自举 #3): the DEFAULT for how many of the NEWEST compactable tool
895
+ * results survive a microcompact boundary (overridable per config via
896
+ * microcompact.keepResults) the model must keep reasoning over the
897
+ * recent results, whatever turn they belong to.
873
898
  */
874
899
  const KEEP_COMPACTABLE_RESULTS = 4;
875
900
  /**
876
901
  * C 区: the boundary seq for a microcompact — drawn by COMPACTABLE-RESULT
877
902
  * recentness, never user turns: a SINGLE user turn that reads several big
878
903
  * files (the coding agent's main overflow shape) crosses the threshold and
879
- * must trigger. The newest KEEP_COMPACTABLE_RESULTS still-visible
880
- * compactable tool results stay intact; the boundary points AT the
881
- * (K+1)th-newest of them, so it and everything older is cleared. Results
882
- * already cleared by an earlier boundary do not count toward the kept
883
- * window — each new boundary makes progress. Undefined when fewer than
884
- * K+1 compactable results remain (the kept window is the whole context).
904
+ * must trigger. The newest `keepResults` still-visible compactable tool
905
+ * results stay intact; the boundary points AT the (K+1)th-newest of them,
906
+ * so it and everything older is cleared. Results already cleared by an
907
+ * earlier boundary do not count toward the kept window — each new boundary
908
+ * makes progress. Undefined when fewer than keepResults+1 compactable
909
+ * results remain (the kept window is the whole context).
885
910
  */
886
- function microcompactBoundarySeq(events) {
911
+ function microcompactBoundarySeq(events, keepResults) {
887
912
  const callName = new Map();
888
913
  let lastCleared = -1;
889
914
  for (const ev of events) {
@@ -900,9 +925,9 @@ function microcompactBoundarySeq(events) {
900
925
  if (name !== undefined && MICROCOMPACTABLE.has(name))
901
926
  visible.push(ev.seq);
902
927
  }
903
- if (visible.length <= KEEP_COMPACTABLE_RESULTS)
928
+ if (visible.length <= keepResults)
904
929
  return undefined;
905
- return visible[visible.length - KEEP_COMPACTABLE_RESULTS - 1];
930
+ return visible[visible.length - keepResults - 1];
906
931
  }
907
932
  /** A signal that never aborts — for executions outside any abort scope. */
908
933
  const NEVER_ABORT = {
@@ -39,4 +39,24 @@ export interface KisoExtension {
39
39
  readonly hooks?: HookHost;
40
40
  readonly tools?: readonly Tool[];
41
41
  readonly approvals?: readonly ApprovalPolicy[];
42
+ /**
43
+ * E2: the extension's compaction config — supplies the loop's microcompact
44
+ * parameters (threshold + optional keepResults) when the session config
45
+ * does not set its own microcompact.
46
+ */
47
+ readonly compaction?: {
48
+ readonly thresholdTokens?: number;
49
+ readonly keepResults?: number;
50
+ };
51
+ /**
52
+ * E2: EXTEND the system prompt — append-only, never replace (a replace
53
+ * is a footgun; appends guarantee "adding an extension never removes
54
+ * existing guidance" — the monotonicity family of the approval chain's
55
+ * deny>ask>allow and the veto short-circuit). The session's own
56
+ * systemPrompt comes first, then each extension's append in load order,
57
+ * \n\n-joined.
58
+ */
59
+ readonly systemPrompt?: {
60
+ readonly append: string;
61
+ };
42
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
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",
@@ -33,7 +33,7 @@
33
33
  "openai"
34
34
  ],
35
35
  "devDependencies": {
36
- "@vincemakes/kiso-evals": "0.1.5",
36
+ "@vincemakes/kiso-evals": "0.1.7",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"