@vincemakes/kiso-core 0.1.4 → 0.1.6

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.
@@ -28,6 +28,7 @@
28
28
  */
29
29
  import { type Adapter, type AbortSignalLike } from "../protocol/adapter.js";
30
30
  import type { Event, StructuredError } from "../protocol/events.js";
31
+ import type { ApprovalPolicy } from "../protocol/extension.js";
31
32
  import { EventLog } from "./event-log.js";
32
33
  import type { EventInput } from "./event-log.js";
33
34
  import type { AssistantBlock, AssistantMessage, Message, ToolResultMessage } from "../protocol/messages.js";
@@ -68,6 +69,7 @@ export interface LoopConfig {
68
69
  */
69
70
  readonly microcompact?: {
70
71
  readonly thresholdTokens: number;
72
+ readonly keepResults?: number;
71
73
  };
72
74
  readonly signal?: AbortSignalLike;
73
75
  readonly temperature?: number;
@@ -95,6 +97,19 @@ export interface LoopConfig {
95
97
  readonly resolveUncertainty?: (executionId: string) => Promise<"rerun" | "abandoned">;
96
98
  /** 第四轮(对抗): the uncertainty twin of `approvalVerdict`. */
97
99
  readonly uncertaintyVerdict?: (executionId: string) => "rerun" | "abandoned" | undefined;
100
+ /**
101
+ * E1: extension approval policies, tagged by their owning extension —
102
+ * decided BEFORE the human flow. Any deny wins (the FIRST denial's
103
+ * reason); else any ask falls into the existing flow; all allow
104
+ * auto-approves. A policy that throws counts as ask. Allow/deny are
105
+ * recorded durably with decidedBy = the extension's name, never pausing
106
+ * for a human; a durable decision already recorded (resume) takes
107
+ * effect and the chain never re-runs.
108
+ */
109
+ readonly approvalPolicies?: readonly {
110
+ readonly extension: string;
111
+ readonly policy: ApprovalPolicy;
112
+ }[];
98
113
  }
99
114
  export declare const DEFAULT_MAX_TURNS = 10;
100
115
  export declare const DEFAULT_MAX_RETRIES = 2;
@@ -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)
@@ -363,7 +363,7 @@ export async function* loop(config) {
363
363
  }
364
364
  let currentExecutionId;
365
365
  try {
366
- for await (const ev of executeOne(call, registry, hooks, { signal: signal ?? NEVER_ABORT }, log, config.resolveApproval, config.approvalVerdict, signal)) {
366
+ for await (const ev of executeOne(call, registry, hooks, { signal: signal ?? NEVER_ABORT }, log, config.resolveApproval, config.approvalVerdict, signal, config.approvalPolicies)) {
367
367
  // 四: the identity of THIS execution comes from the stream —
368
368
  // a historical same-callId execution must never be mistaken
369
369
  // for this call's (the provider callId may repeat across runs).
@@ -531,7 +531,7 @@ function terminalForStop(reason) {
531
531
  * blocks with a precondition result — the handler never auto-runs a
532
532
  * possibly-executed side effect.
533
533
  */
534
- async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, resolveApprovalVerdict, signal) {
534
+ async function* executeOne(call, registry, hooks, ctx, log, resolveApproval, resolveApprovalVerdict, signal, approvalPolicies) {
535
535
  const payload = {
536
536
  callId: call.callId,
537
537
  name: call.name,
@@ -588,10 +588,80 @@ 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
+ // ── E1: the extension policy chain, decided BEFORE the human flow ─────
592
+ // A durable POLICY decision for THIS call takes effect on resume — the
593
+ // chain never re-runs when its verdict is already in the log (同构
594
+ // alreadyReplaced: the persisted fact speaks for the call). The match is
595
+ // the same logical call: same callId, decidedBy set (a policy verdict,
596
+ // never a human's), and input identical to the original tool_call_end —
597
+ // a re-issued call with different arguments is a NEW call and re-decided.
598
+ // Composition: deny > ask > allow — any deny wins (the FIRST denial's
599
+ // reason), else any ask falls into the existing human flow below, and
600
+ // only an ALL-allow chain auto-approves: recorded durably with decidedBy
601
+ // = the extension's name, never a human-visible pause. A policy that
602
+ // throws counts as ask.
603
+ const originalCall = [...log.all].reverse().find((e) => e.type === "tool_call_end" && e.callId === call.callId);
604
+ const durable = originalCall !== undefined && JSON.stringify(originalCall.input) === JSON.stringify(call.input)
605
+ ? log.all.find((e) => e.type === "permission_decided" && e.decidedBy !== undefined && e.callId === call.callId)
606
+ : undefined;
607
+ let chainVerdict;
608
+ let deniedReason;
609
+ let deniedBy;
610
+ if (durable === undefined && approvalPolicies !== undefined && approvalPolicies.length > 0) {
611
+ for (const { extension, policy } of approvalPolicies) {
612
+ let v;
613
+ try {
614
+ v = await raceAbort(Promise.resolve(policy.decide(payload, ctx)), signal);
615
+ }
616
+ catch {
617
+ v = { action: "ask" }; // 抛错 = 该扩展计为 ask
618
+ }
619
+ if (v.action === "deny") {
620
+ deniedBy ??= extension;
621
+ deniedReason ??= v.reason; // the FIRST denial's reason
622
+ }
623
+ else if (v.action === "ask") {
624
+ chainVerdict = { action: "ask" };
625
+ }
626
+ }
627
+ if (deniedBy !== undefined) {
628
+ chainVerdict = { action: "deny", reason: deniedReason ?? "denied" };
629
+ }
630
+ else if (chainVerdict === undefined) {
631
+ chainVerdict = { action: "allow" };
632
+ }
633
+ if (chainVerdict.action !== "ask") {
634
+ // allow/deny are PERSISTED FACTS (decidedBy = the extension) —
635
+ // never a human pause.
636
+ yield log.append({
637
+ type: "permission_decided",
638
+ decisionId: `d-${log.lastSeq + 1}`,
639
+ callId: call.callId,
640
+ decision: chainVerdict.action === "allow" ? "approved" : "denied",
641
+ ...(chainVerdict.action === "deny" ? { reason: chainVerdict.reason } : {}),
642
+ decidedBy: deniedBy ?? approvalPolicies[0].extension,
643
+ });
644
+ }
645
+ }
646
+ if (chainVerdict?.action === "deny") {
647
+ yield emitResult(denialResult(chainVerdict.reason));
648
+ return;
649
+ }
650
+ if (durable !== undefined && durable.decision === "denied") {
651
+ yield emitResult(denialResult(durable.reason ?? "denied"));
652
+ return;
653
+ }
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;
659
+ }
591
660
  // Permission negotiation — defer is a REAL pause (Phase D). C 组: the
592
661
  // hook itself is cancelable (a slow policy query must not outlive an
593
- // abort), and the signal is re-checked after it returns.
594
- if (hooks.onPreTool) {
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) {
595
665
  const decision = await raceAbort(hooks.onPreTool(payload, ctx), signal);
596
666
  if (signal?.aborted)
597
667
  throw ABORTED;
@@ -797,23 +867,24 @@ function sleep(ms, signal) {
797
867
  });
798
868
  }
799
869
  /**
800
- * C 区 (自举 #3): how many of the NEWEST compactable tool results survive a
801
- * microcompact boundary the model must keep reasoning over the recent
802
- * results, whatever turn they belong to.
870
+ * C 区 (自举 #3): the DEFAULT for how many of the NEWEST compactable tool
871
+ * results survive a microcompact boundary (overridable per config via
872
+ * microcompact.keepResults) the model must keep reasoning over the
873
+ * recent results, whatever turn they belong to.
803
874
  */
804
875
  const KEEP_COMPACTABLE_RESULTS = 4;
805
876
  /**
806
877
  * C 区: the boundary seq for a microcompact — drawn by COMPACTABLE-RESULT
807
878
  * recentness, never user turns: a SINGLE user turn that reads several big
808
879
  * files (the coding agent's main overflow shape) crosses the threshold and
809
- * must trigger. The newest KEEP_COMPACTABLE_RESULTS still-visible
810
- * compactable tool results stay intact; the boundary points AT the
811
- * (K+1)th-newest of them, so it and everything older is cleared. Results
812
- * already cleared by an earlier boundary do not count toward the kept
813
- * window — each new boundary makes progress. Undefined when fewer than
814
- * K+1 compactable results remain (the kept window is the whole context).
880
+ * must trigger. The newest `keepResults` still-visible compactable tool
881
+ * results stay intact; the boundary points AT the (K+1)th-newest of them,
882
+ * so it and everything older is cleared. Results already cleared by an
883
+ * earlier boundary do not count toward the kept window — each new boundary
884
+ * makes progress. Undefined when fewer than keepResults+1 compactable
885
+ * results remain (the kept window is the whole context).
815
886
  */
816
- function microcompactBoundarySeq(events) {
887
+ function microcompactBoundarySeq(events, keepResults) {
817
888
  const callName = new Map();
818
889
  let lastCleared = -1;
819
890
  for (const ev of events) {
@@ -830,9 +901,9 @@ function microcompactBoundarySeq(events) {
830
901
  if (name !== undefined && MICROCOMPACTABLE.has(name))
831
902
  visible.push(ev.seq);
832
903
  }
833
- if (visible.length <= KEEP_COMPACTABLE_RESULTS)
904
+ if (visible.length <= keepResults)
834
905
  return undefined;
835
- return visible[visible.length - KEEP_COMPACTABLE_RESULTS - 1];
906
+ return visible[visible.length - keepResults - 1];
836
907
  }
837
908
  /** A signal that never aborts — for executions outside any abort scope. */
838
909
  const NEVER_ABORT = {
@@ -268,6 +268,9 @@ export interface PermissionDecided {
268
268
  readonly callId?: string;
269
269
  readonly decision: "approved" | "denied";
270
270
  readonly reason?: string;
271
+ /** E1: the deciding extension's name — present ONLY on policy decisions;
272
+ * absent = the human decided (old logs stay compatible). */
273
+ readonly decidedBy?: string;
271
274
  }
272
275
  /**
273
276
  * A permission request was CLOSED because its run terminated first (B 组):
@@ -209,7 +209,8 @@ const EVENT_VALIDATORS = {
209
209
  permission_decided: (v) => typeof v.decisionId === "string" &&
210
210
  (v.decision === "approved" || v.decision === "denied") &&
211
211
  (v.callId === undefined || typeof v.callId === "string") &&
212
- (v.reason === undefined || typeof v.reason === "string"),
212
+ (v.reason === undefined || typeof v.reason === "string") &&
213
+ (v.decidedBy === undefined || typeof v.decidedBy === "string"),
213
214
  permission_expired: (v) => typeof v.decisionId === "string" && typeof v.reason === "string",
214
215
  uncertain_pending: (v) => typeof v.executionId === "string" && typeof v.callId === "string" && typeof v.name === "string" && typeof v.error === "string",
215
216
  microcompacted: (v) => isNonNegativeInt(v.beforeSeq),
@@ -0,0 +1,62 @@
1
+ /**
2
+ * E1 — extension approval policies: pure types, no runtime.
3
+ *
4
+ * An extension is a named bundle of optional capabilities: hooks (composed
5
+ * AFTER the harness's own — 既有先行), tools (merged into the registry), and
6
+ * approval policies (the loop's policy chain, decided BEFORE the human
7
+ * flow). This file is types-only: loading and composition live in the
8
+ * runtime package (loadExtensions) and the kernel loop.
9
+ */
10
+ import type { HookHost } from "../kernel/hooks.js";
11
+ import type { Tool, ToolContext } from "../tools/tool.js";
12
+ /** The call a policy decides on — the tool's name and parsed input. */
13
+ export interface PolicyCall {
14
+ readonly name: string;
15
+ readonly input: Readonly<Record<string, unknown>>;
16
+ }
17
+ /**
18
+ * A policy's verdict. `ask` defers to the existing human approval flow;
19
+ * `deny` carries the reason the model sees; `allow` auto-approves.
20
+ */
21
+ export type PolicyVerdict = {
22
+ readonly action: "allow";
23
+ } | {
24
+ readonly action: "deny";
25
+ readonly reason: string;
26
+ } | {
27
+ readonly action: "ask";
28
+ };
29
+ /** One approval policy — a pure decide function over a tool call. */
30
+ export interface ApprovalPolicy {
31
+ readonly decide: (call: PolicyCall, ctx: ToolContext) => PolicyVerdict | Promise<PolicyVerdict>;
32
+ }
33
+ /**
34
+ * A loaded extension. `name` is unique per installation (the loader rejects
35
+ * duplicates loudly); hooks/tools/approvals are all optional.
36
+ */
37
+ export interface KisoExtension {
38
+ readonly name: string;
39
+ readonly hooks?: HookHost;
40
+ readonly tools?: readonly Tool[];
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
+ };
62
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * E1 — extension approval policies: pure types, no runtime.
3
+ *
4
+ * An extension is a named bundle of optional capabilities: hooks (composed
5
+ * AFTER the harness's own — 既有先行), tools (merged into the registry), and
6
+ * approval policies (the loop's policy chain, decided BEFORE the human
7
+ * flow). This file is types-only: loading and composition live in the
8
+ * runtime package (loadExtensions) and the kernel loop.
9
+ */
10
+ export {};
@@ -1,3 +1,4 @@
1
1
  export * from "./events.js";
2
2
  export * from "./messages.js";
3
3
  export * from "./adapter.js";
4
+ export * from "./extension.js";
@@ -1,3 +1,4 @@
1
1
  export * from "./events.js";
2
2
  export * from "./messages.js";
3
3
  export * from "./adapter.js";
4
+ export * from "./extension.js";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-core",
3
- "version": "0.1.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.",
3
+ "version": "0.1.6",
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.4",
36
+ "@vincemakes/kiso-evals": "0.1.6",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"