@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.
- package/dist/kernel/loop.d.ts +1 -0
- package/dist/kernel/loop.js +101 -76
- package/dist/protocol/extension.d.ts +20 -0
- package/package.json +2 -2
package/dist/kernel/loop.d.ts
CHANGED
package/dist/kernel/loop.js
CHANGED
|
@@ -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"
|
|
655
|
-
//
|
|
656
|
-
//
|
|
657
|
-
|
|
658
|
-
|
|
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.
|
|
663
|
-
// the policy chain
|
|
664
|
-
|
|
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
|
-
|
|
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
|
|
871
|
-
*
|
|
872
|
-
*
|
|
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
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
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 <=
|
|
928
|
+
if (visible.length <= keepResults)
|
|
904
929
|
return undefined;
|
|
905
|
-
return visible[visible.length -
|
|
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.
|
|
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.
|
|
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"
|