@vincemakes/kiso-runtime 0.1.32 → 0.1.34

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/compose.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * from session.ts: the extension system-prompt appends, the extension
4
4
  * hook composition (the existing come first), and the loop's microcompact config lookup.
5
5
  */
6
- import type { HookHost, KisoExtension, ToolRegistry } from "@vincemakes/kiso-core";
6
+ import type { ApprovalChain, HookHost, KisoExtension, ToolRegistry } from "@vincemakes/kiso-core";
7
7
  import type { SessionConfig } from "./session.js";
8
8
  /** The table, or "" when the registry is empty (no vocabulary, no tools). */
9
9
  export declare function composeToolTable(registry: ToolRegistry): string;
@@ -32,3 +32,23 @@ export declare function microcompactFor(config: SessionConfig): {
32
32
  readonly thresholdTokens: number;
33
33
  readonly keepResults?: number;
34
34
  } | undefined;
35
+ /**
36
+ * E1 (W21/R3 — moved out of the kernel by the 2026-08-09 corrective
37
+ * action; the composition is extension wiring, the same category as
38
+ * composeHooks): compose the extensions' approval policies into ONE
39
+ * chain — the kernel's gate. deny > allow > ask: any deny wins (the
40
+ * FIRST denial's reason), then ANY allow (a LATER allow beats an
41
+ * EARLIER ask: the allow-only dont-ask-again extension must override a
42
+ * mode tier's ask — the old ask-wins chain left it structurally dead),
43
+ * and an ask falls into the kernel's human flow (its speaker = the
44
+ * first non-abstain — the panel's why-asked line). Only an ask-free
45
+ * chain auto-approves: decidedBy = the deciding extension (the FIRST
46
+ * allow — the symmetry of the first denial; an every-speaker-allows
47
+ * chain records the first speaker). An all-abstain chain (ADR-0042)
48
+ * ASKS — no opinion is never a silent allow; absent a channel the
49
+ * kernel's honest denial. A policy that throws counts as ask — it
50
+ * speaks, never silently. No policies → undefined (no chain: the
51
+ * kernel's plain flow — the all-abstain ask exists only where a chain
52
+ * does).
53
+ */
54
+ export declare function composeApprovalChain(extensions: readonly KisoExtension[]): ApprovalChain | undefined;
package/dist/compose.js CHANGED
@@ -5,7 +5,8 @@
5
5
  */
6
6
  /**
7
7
  * 0.1.40 (R-C item 1) — the tool substitution table: the fixed vocabulary
8
- * (the CC content in kiso's voice, each line bound to the tool that makes
8
+ * (the reference implementation's content in kiso's voice, each line bound
9
+ * to the tool that makes
9
10
  * it true) filtered to the ACTIVE tool set + each active tool's ONE-line
10
11
  * snippet + its guideline bullets. The full descriptions NEVER enter the
11
12
  * system prompt — the provider transmits them in the JSON schema anyway
@@ -28,6 +29,9 @@ export function composeToolTable(registry) {
28
29
  ...TOOL_RULES.filter((r) => active.has(r.tool)).map((r) => `- ${r.line}`),
29
30
  // the parallel directive: the window applies to every active turn.
30
31
  "- batch independent tool calls into one reply — they run in parallel",
32
+ // D1: a tool result is evidence, not an answer — the turn ends with
33
+ // the findings that make the evidence useful to the human.
34
+ "- end your turn with your findings — never end on a bare tool result",
31
35
  ...tools.flatMap((t) => (t.promptSnippet === undefined ? [] : [`- ${t.promptSnippet}`])),
32
36
  ];
33
37
  const guidelines = tools.flatMap((t) => (t.promptGuidelines ?? []).map((g) => `- ${t.name}: ${g}`));
@@ -147,3 +151,81 @@ export function microcompactFor(config) {
147
151
  }
148
152
  return undefined;
149
153
  }
154
+ /**
155
+ * E1 (W21/R3 — moved out of the kernel by the 2026-08-09 corrective
156
+ * action; the composition is extension wiring, the same category as
157
+ * composeHooks): compose the extensions' approval policies into ONE
158
+ * chain — the kernel's gate. deny > allow > ask: any deny wins (the
159
+ * FIRST denial's reason), then ANY allow (a LATER allow beats an
160
+ * EARLIER ask: the allow-only dont-ask-again extension must override a
161
+ * mode tier's ask — the old ask-wins chain left it structurally dead),
162
+ * and an ask falls into the kernel's human flow (its speaker = the
163
+ * first non-abstain — the panel's why-asked line). Only an ask-free
164
+ * chain auto-approves: decidedBy = the deciding extension (the FIRST
165
+ * allow — the symmetry of the first denial; an every-speaker-allows
166
+ * chain records the first speaker). An all-abstain chain (ADR-0042)
167
+ * ASKS — no opinion is never a silent allow; absent a channel the
168
+ * kernel's honest denial. A policy that throws counts as ask — it
169
+ * speaks, never silently. No policies → undefined (no chain: the
170
+ * kernel's plain flow — the all-abstain ask exists only where a chain
171
+ * does).
172
+ */
173
+ export function composeApprovalChain(extensions) {
174
+ const policies = extensions.flatMap((e) => (e.approvals ?? []).map((policy) => ({ extension: e.name, policy })));
175
+ if (policies.length === 0)
176
+ return undefined;
177
+ return {
178
+ async decide(payload, ctx) {
179
+ let chainVerdict;
180
+ let deniedReason;
181
+ let deniedBy;
182
+ let allowedBy; // the FIRST allowing extension (the composition's decider for an allow)
183
+ let firstSpeaker; // the first non-abstain verdict's extension
184
+ let anySpoke = false;
185
+ for (const { extension, policy } of policies) {
186
+ let v;
187
+ try {
188
+ v = await Promise.resolve(policy.decide(payload, ctx));
189
+ }
190
+ catch {
191
+ v = { action: "ask" }; // a throwing policy counts as ask — it speaks, never silently
192
+ }
193
+ if (v.action === "abstain")
194
+ continue; // no opinion — not a verdict
195
+ anySpoke = true;
196
+ firstSpeaker ??= extension;
197
+ if (v.action === "deny") {
198
+ deniedBy ??= extension;
199
+ deniedReason ??= v.reason; // the FIRST denial's reason
200
+ }
201
+ else if (v.action === "allow") {
202
+ // deny > allow > ask — the allow overrides any EARLIER
203
+ // ask in the chain (the allow-only dont-ask-again
204
+ // extension after a mode tier that asked). The first
205
+ // allow is the deciding one — the symmetry of deniedBy.
206
+ allowedBy ??= extension;
207
+ chainVerdict = { action: "allow" };
208
+ }
209
+ else if (chainVerdict === undefined) {
210
+ chainVerdict = { action: "ask" }; // recorded — a later allow overrides it
211
+ }
212
+ }
213
+ if (deniedBy !== undefined) {
214
+ return { action: "deny", reason: deniedReason ?? "denied", decidedBy: deniedBy };
215
+ }
216
+ if (allowedBy !== undefined) {
217
+ return { action: "allow", decidedBy: allowedBy }; // an allow beat any earlier ask
218
+ }
219
+ if (chainVerdict === undefined && anySpoke) {
220
+ return { action: "allow", decidedBy: firstSpeaker }; // every speaker allows
221
+ }
222
+ if (chainVerdict === undefined) {
223
+ // an all-abstain (ADR-0042): NO policy speaks — the call falls
224
+ // to the ask flow, never to a silent auto-approve. The human
225
+ // decides; absent a channel, the kernel's honest denial.
226
+ return { action: "ask" };
227
+ }
228
+ return { action: "ask", ...(firstSpeaker !== undefined ? { speaker: firstSpeaker } : {}) };
229
+ },
230
+ };
231
+ }
package/dist/run.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { denialResult, loop } from "@vincemakes/kiso-core";
7
7
  import { ABORTED, MergedSignal, abortable, openRunId } from "./recovery.js";
8
- import { composeSystemPrompt, composeToolTable, microcompactFor } from "./compose.js";
8
+ import { composeApprovalChain, composeSystemPrompt, composeToolTable, microcompactFor } from "./compose.js";
9
9
  import { truncationGuard } from "./truncation-guard.js";
10
10
  import { ResumeBlockedError } from "./session.js";
11
11
  /**
@@ -70,6 +70,7 @@ export class Run {
70
70
  // append in LOAD order — deterministic (same extensions → same
71
71
  // prompt); no appends → byte-identical to the extension-less run.
72
72
  const systemPrompt = composeSystemPrompt(basePrompt, this.#config.extensions ?? []);
73
+ const approvalChain = composeApprovalChain(this.#config.extensions ?? []);
73
74
  const loopConfig = () => ({
74
75
  // 0.1.40 (R-C item 3): the truncation guard gates the model
75
76
  // stream — a truncated turn's tool batch never executes.
@@ -85,7 +86,9 @@ export class Run {
85
86
  ...(this.#config.compaction !== undefined ? { compaction: this.#config.compaction } : {}),
86
87
  ...(microcompact !== undefined ? { microcompact } : {}),
87
88
  ...(this.#config.maxRetries !== undefined ? { maxRetries: this.#config.maxRetries } : {}),
88
- approvalPolicies: (this.#config.extensions ?? []).flatMap((e) => (e.approvals ?? []).map((policy) => ({ extension: e.name, policy }))),
89
+ // E1: the composed approval chain the extensions'
90
+ // policies composed into ONE gate (deny > allow > ask).
91
+ ...(approvalChain !== undefined ? { approvalPolicy: approvalChain } : {}),
89
92
  log,
90
93
  signal,
91
94
  resolveApproval: (decisionId) => new Promise((resolve) => {
package/dist/session.d.ts CHANGED
@@ -139,8 +139,11 @@ export declare class AgentSession {
139
139
  * next resume applies it without re-asking. The crash window between a
140
140
  * resolve and the run's write is benign: nothing has executed yet, so a
141
141
  * lost decision only re-presents the request.
142
+ * W21: an optional reason rides a DENIAL (the panel's feedback — the
143
+ * tool_result carries `[Permission denied] <the words>`); allow reasons
144
+ * are never persisted (the words ride the next user turn instead).
142
145
  */
143
- approve(decisionId: string, allow: boolean): Promise<void>;
146
+ approve(decisionId: string, allow: boolean, reason?: string): Promise<void>;
144
147
  /** Executions that started but never reported a result (crash window). */
145
148
  uncertainExecutions(): import("@vincemakes/kiso-core").ExecutionRecord[];
146
149
  /**
package/dist/session.js CHANGED
@@ -270,8 +270,11 @@ export class AgentSession {
270
270
  * next resume applies it without re-asking. The crash window between a
271
271
  * resolve and the run's write is benign: nothing has executed yet, so a
272
272
  * lost decision only re-presents the request.
273
+ * W21: an optional reason rides a DENIAL (the panel's feedback — the
274
+ * tool_result carries `[Permission denied] <the words>`); allow reasons
275
+ * are never persisted (the words ride the next user turn instead).
273
276
  */
274
- async approve(decisionId, allow) {
277
+ async approve(decisionId, allow, reason) {
275
278
  // round 4: a poisoned session may not mutate the log — checked before
276
279
  // anything is recorded.
277
280
  this.ensureHealthy();
@@ -306,7 +309,10 @@ export class AgentSession {
306
309
  // cannot issue while awaiting approve().)
307
310
  this.#pendingDurableApprovals.set(decisionId, allow);
308
311
  this.#pendingResolvers.delete(decisionId);
309
- resolver(allow ? { action: "allow" } : { action: "deny", reason: "denied by user" });
312
+ // W21: the panel's feedback rides the denial the tool_result
313
+ // carries `[Permission denied] <the words>` (the rejection
314
+ // asymmetry: words keep the run alive).
315
+ resolver(allow ? { action: "allow" } : { action: "deny", reason: reason ?? "denied by user" });
310
316
  return;
311
317
  }
312
318
  const runId = request?.runId ?? "approval";
@@ -315,7 +321,7 @@ export class AgentSession {
315
321
  decisionId,
316
322
  ...(request !== undefined ? { callId: request.event.callId } : {}),
317
323
  decision: allow ? "approved" : "denied",
318
- ...(allow ? {} : { reason: "denied by user" }),
324
+ ...(allow ? {} : { reason: reason ?? "denied by user" }),
319
325
  });
320
326
  await this.persist(runId, decided);
321
327
  }
@@ -58,6 +58,16 @@ export declare function lastSummaryPoint(events: readonly Event[]): number;
58
58
  * containing the LATEST one (still a turn boundary). A protected round
59
59
  * as the FIRST uncovered round leaves nothing before it to cover →
60
60
  * undefined (an honest "nothing to compact").
61
+ *
62
+ * P1 (0.1.42): the SAME pullback family now enforces the pairing
63
+ * invariant — a boundary NEVER splits a tool_call/tool_result pair. A
64
+ * mid-execution input leaves a covered call with a kept result, and the
65
+ * projection renders an orphaned tool message (a real provider 400 — the
66
+ * fresh2 family). The straddle pullback ITERATES to stability — every
67
+ * straddled pair in the shrinking range pulls the boundary before its
68
+ * round — while the protected pullback applies ONCE on the base range
69
+ * (the operative list is the LATEST echo — the old ⑥ semantics:
70
+ * superseded echoes stay coverable).
61
71
  */
62
72
  export declare function summaryBoundarySeq(events: readonly Event[], keepRounds?: number): number | undefined;
63
73
  /**
package/dist/summarize.js CHANGED
@@ -92,6 +92,16 @@ export function lastSummaryPoint(events) {
92
92
  * containing the LATEST one (still a turn boundary). A protected round
93
93
  * as the FIRST uncovered round leaves nothing before it to cover →
94
94
  * undefined (an honest "nothing to compact").
95
+ *
96
+ * P1 (0.1.42): the SAME pullback family now enforces the pairing
97
+ * invariant — a boundary NEVER splits a tool_call/tool_result pair. A
98
+ * mid-execution input leaves a covered call with a kept result, and the
99
+ * projection renders an orphaned tool message (a real provider 400 — the
100
+ * fresh2 family). The straddle pullback ITERATES to stability — every
101
+ * straddled pair in the shrinking range pulls the boundary before its
102
+ * round — while the protected pullback applies ONCE on the base range
103
+ * (the operative list is the LATEST echo — the old ⑥ semantics:
104
+ * superseded echoes stay coverable).
95
105
  */
96
106
  export function summaryBoundarySeq(events, keepRounds = KEEP_RECENT_ROUNDS) {
97
107
  const prevPoint = lastSummaryPoint(events);
@@ -102,16 +112,27 @@ export function summaryBoundarySeq(events, keepRounds = KEEP_RECENT_ROUNDS) {
102
112
  }
103
113
  if (uncoveredInputs.length <= keepRounds)
104
114
  return undefined;
105
- const base = uncoveredInputs[uncoveredInputs.length - keepRounds] - 1;
106
- const protectedRound = latestProtectedBoundary(events, prevPoint, base);
107
- if (protectedRound !== undefined) {
108
- if (protectedRound <= prevPoint)
115
+ const firstUncovered = uncoveredInputs[0];
116
+ let boundary = uncoveredInputs[uncoveredInputs.length - keepRounds] - 1;
117
+ // The protected pullback applies ONCE on the base range (⑥); the
118
+ // straddle pullback recomputes against the SHRINKING range below it.
119
+ const protectedBoundary = latestProtectedBoundary(events, prevPoint, boundary);
120
+ for (;;) {
121
+ const straddleBoundary = latestStraddleBoundary(events, prevPoint, boundary);
122
+ let pull = straddleBoundary;
123
+ if (protectedBoundary !== undefined) {
124
+ pull = pull === undefined ? protectedBoundary : Math.min(pull, protectedBoundary);
125
+ }
126
+ if (pull === undefined)
127
+ return boundary;
128
+ // Nothing before the first uncovered round (or before the previous
129
+ // summary point) is coverable — the honest "nothing to compact".
130
+ if (pull < firstUncovered || pull <= prevPoint)
109
131
  return undefined;
110
- return protectedRound;
132
+ if (pull >= boundary)
133
+ return boundary; // stable — the pull never advances
134
+ boundary = pull;
111
135
  }
112
- // The input at m - keepRounds opens the FIRST KEPT round; everything
113
- // before it (m - keepRounds ≥ 1 covered rounds) is summarizable.
114
- return base;
115
136
  }
116
137
  /**
117
138
  * ⑥: the boundary just before the round holding the LATEST do-not-compact
@@ -145,6 +166,37 @@ function latestProtectedBoundary(events, prevPoint, base) {
145
166
  return undefined;
146
167
  return inputSeq - 1;
147
168
  }
169
+ /**
170
+ * P1 (0.1.42): the boundary just before the round holding the LATEST
171
+ * tool_call_end in (prevPoint, cut] whose tool_result landed on the KEPT
172
+ * side of the cut — covering the call alone would project an orphaned
173
+ * tool message (the pairing invariant; the fresh2 400 family). Returns
174
+ * the pair's round-opening input minus one — still a turn boundary —
175
+ * or prevPoint when the round opened at or before the previous summary
176
+ * point (the caller's `pull <= prevPoint` guard turns that into the
177
+ * honest nothing-to-compact: the range holds no whole pair to keep, so
178
+ * the compact is refused) — or undefined when the range holds no
179
+ * straddled pair.
180
+ */
181
+ function latestStraddleBoundary(events, prevPoint, cut) {
182
+ let straddledCall = -1;
183
+ for (const ev of events) {
184
+ if (ev.type !== "tool_call_end" || ev.seq <= prevPoint || ev.seq > cut)
185
+ continue;
186
+ const keptResult = events.some((e) => e.type === "tool_result" && e.callId === ev.callId && e.seq > cut);
187
+ if (keptResult)
188
+ straddledCall = ev.seq;
189
+ }
190
+ if (straddledCall < 0)
191
+ return undefined;
192
+ // The pair's round opening: the last user_input before the call.
193
+ let inputSeq = -1;
194
+ for (const ev of events) {
195
+ if (ev.type === "user_input" && ev.seq > prevPoint && ev.seq < straddledCall)
196
+ inputSeq = ev.seq;
197
+ }
198
+ return inputSeq < 0 ? prevPoint : inputSeq - 1;
199
+ }
148
200
  /**
149
201
  * The NoticeCell's number: estimated tokens of the covered content minus
150
202
  * the summary's own — the same chars/4 proxy as estimateTokens (a stable
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 0.1.40 (R-C item 3) — the truncation guard: a runtime adapter wrapper.
3
3
  *
4
- * The pi protection: a truncated stream (stopReason max_tokens/length) can
4
+ * The reference implementation's protection: a truncated stream (stopReason
5
+ * max_tokens/length) can
5
6
  * yield tool args that parse and validate but are silently incomplete —
6
7
  * executing them is the destructive-bug class. The provider adapters already
7
8
  * see the stop reason; the RUNTIME vetoes execution of the whole batch:
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 0.1.40 (R-C item 3) — the truncation guard: a runtime adapter wrapper.
3
3
  *
4
- * The pi protection: a truncated stream (stopReason max_tokens/length) can
4
+ * The reference implementation's protection: a truncated stream (stopReason
5
+ * max_tokens/length) can
5
6
  * yield tool args that parse and validate but are silently incomplete —
6
7
  * executing them is the destructive-bug class. The provider adapters already
7
8
  * see the stop reason; the RUNTIME vetoes execution of the whole batch:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,11 +21,11 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.1.31"
24
+ "@vincemakes/kiso-core": "0.1.33"
25
25
  },
26
26
  "peerDependencies": {
27
- "@vincemakes/kiso-provider-anthropic": "0.1.32",
28
- "@vincemakes/kiso-provider-openai": "0.1.32"
27
+ "@vincemakes/kiso-provider-anthropic": "0.1.34",
28
+ "@vincemakes/kiso-provider-openai": "0.1.34"
29
29
  },
30
30
  "peerDependenciesMeta": {
31
31
  "@vincemakes/kiso-provider-anthropic": {
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "devDependencies": {
39
- "@vincemakes/kiso-evals": "0.1.32",
39
+ "@vincemakes/kiso-evals": "0.1.33",
40
40
  "@types/node": "^26.1.2",
41
41
  "typescript": "^5.7.2",
42
42
  "vitest": "^3.0.0"