@vincemakes/kiso-core 0.1.3 → 0.1.5

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";
@@ -95,6 +96,19 @@ export interface LoopConfig {
95
96
  readonly resolveUncertainty?: (executionId: string) => Promise<"rerun" | "abandoned">;
96
97
  /** 第四轮(对抗): the uncertainty twin of `approvalVerdict`. */
97
98
  readonly uncertaintyVerdict?: (executionId: string) => "rerun" | "abandoned" | undefined;
99
+ /**
100
+ * E1: extension approval policies, tagged by their owning extension —
101
+ * decided BEFORE the human flow. Any deny wins (the FIRST denial's
102
+ * reason); else any ask falls into the existing flow; all allow
103
+ * auto-approves. A policy that throws counts as ask. Allow/deny are
104
+ * recorded durably with decidedBy = the extension's name, never pausing
105
+ * for a human; a durable decision already recorded (resume) takes
106
+ * effect and the chain never re-runs.
107
+ */
108
+ readonly approvalPolicies?: readonly {
109
+ readonly extension: string;
110
+ readonly policy: ApprovalPolicy;
111
+ }[];
98
112
  }
99
113
  export declare const DEFAULT_MAX_TURNS = 10;
100
114
  export declare const DEFAULT_MAX_RETRIES = 2;
@@ -27,14 +27,14 @@
27
27
  * re-stream that duplicates output or tool calls.
28
28
  */
29
29
  import { isAdapterEvent } from "../protocol/adapter.js";
30
- import { estimateTokens, KEEP_RECENT_TURNS, microcompact } from "./compaction.js";
30
+ import { estimateTokens, microcompact } from "./compaction.js";
31
31
  import { EventLog } from "./event-log.js";
32
32
  import { ToolRegistry } from "../tools/registry.js";
33
33
  import { validateArgs } from "../tools/validate.js";
34
34
  import { NoOpHooks } from "./hooks.js";
35
35
  import { resolveModeProfile } from "./mode.js";
36
36
  import { denialResult } from "./permission.js";
37
- import { messagesToEvents, projectMessages } from "./project.js";
37
+ import { messagesToEvents, MICROCOMPACTABLE, projectMessages } from "./project.js";
38
38
  export const DEFAULT_MAX_TURNS = 10;
39
39
  export const DEFAULT_MAX_RETRIES = 2;
40
40
  export async function* loop(config) {
@@ -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,19 +867,42 @@ function sleep(ms, signal) {
797
867
  });
798
868
  }
799
869
  /**
800
- * C 区: the boundary seq for a microcompact the seq of the user input
801
- * KEEP_RECENT_TURNS+1 places from the end (everything BEFORE that user
802
- * input is old enough to clear; the recent turns stay intact). Undefined
803
- * when the history is too short to clear anything.
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.
873
+ */
874
+ const KEEP_COMPACTABLE_RESULTS = 4;
875
+ /**
876
+ * C 区: the boundary seq for a microcompact — drawn by COMPACTABLE-RESULT
877
+ * recentness, never user turns: a SINGLE user turn that reads several big
878
+ * 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).
804
885
  */
805
886
  function microcompactBoundarySeq(events) {
806
- const userSeqs = [];
887
+ const callName = new Map();
888
+ let lastCleared = -1;
889
+ for (const ev of events) {
890
+ if (ev.type === "tool_call_end")
891
+ callName.set(ev.callId, ev.name);
892
+ if (ev.type === "microcompacted" && ev.beforeSeq > lastCleared)
893
+ lastCleared = ev.beforeSeq;
894
+ }
895
+ const visible = [];
807
896
  for (const ev of events) {
808
- if (ev.type === "user_input")
809
- userSeqs.push(ev.seq);
897
+ if (ev.type !== "tool_result" || ev.seq <= lastCleared)
898
+ continue;
899
+ const name = callName.get(ev.callId);
900
+ if (name !== undefined && MICROCOMPACTABLE.has(name))
901
+ visible.push(ev.seq);
810
902
  }
811
- const boundary = userSeqs[userSeqs.length - KEEP_RECENT_TURNS - 1];
812
- return boundary !== undefined ? boundary - 1 : undefined;
903
+ if (visible.length <= KEEP_COMPACTABLE_RESULTS)
904
+ return undefined;
905
+ return visible[visible.length - KEEP_COMPACTABLE_RESULTS - 1];
813
906
  }
814
907
  /** A signal that never aborts — for executions outside any abort scope. */
815
908
  const NEVER_ABORT = {
@@ -22,6 +22,13 @@
22
22
  import type { Event } from "../protocol/events.js";
23
23
  import type { EventInput } from "./event-log.js";
24
24
  import type { AssistantBlock, Message, MessageSource } from "../protocol/messages.js";
25
+ /**
26
+ * C 区: tools whose output is eligible for microcompact clearing — reads,
27
+ * listings, searches, and shell output. write/edit outputs are short and
28
+ * never cleared. Exported so the loop's boundary computation (自举 #3)
29
+ * counts exactly the results the projection can clear.
30
+ */
31
+ export declare const MICROCOMPACTABLE: Set<string>;
25
32
  /** The tag that makes a tool result un-clearable (C 区). */
26
33
  export declare const DO_NOT_COMPACT = "do-not-compact";
27
34
  /**
@@ -22,9 +22,10 @@
22
22
  /**
23
23
  * C 区: tools whose output is eligible for microcompact clearing — reads,
24
24
  * listings, searches, and shell output. write/edit outputs are short and
25
- * never cleared.
25
+ * never cleared. Exported so the loop's boundary computation (自举 #3)
26
+ * counts exactly the results the projection can clear.
26
27
  */
27
- const MICROCOMPACTABLE = new Set(["read_file", "list_dir", "search_text", "shell"]);
28
+ export const MICROCOMPACTABLE = new Set(["read_file", "list_dir", "search_text", "shell"]);
28
29
  /** The tag that makes a tool result un-clearable (C 区). */
29
30
  export const DO_NOT_COMPACT = "do-not-compact";
30
31
  /**
@@ -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,42 @@
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
+ }
@@ -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.3",
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.5",
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.3",
36
+ "@vincemakes/kiso-evals": "0.1.5",
37
37
  "@types/node": "^26.1.2",
38
38
  "typescript": "^5.7.2",
39
39
  "vitest": "^3.0.0"