@vincemakes/kiso-runtime 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.
package/dist/agent.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * package (optional peers — an unused provider costs nothing). The kernel
12
12
  * itself stays dependency-free; the SDKs live in the provider packages.
13
13
  */
14
- import { type Adapter, type HookHost, type Tool } from "@vincemakes/kiso-core";
14
+ import { type Adapter, type HookHost, type KisoExtension, type Tool } from "@vincemakes/kiso-core";
15
15
  import { AgentSession } from "./session.js";
16
16
  import type { SessionStore } from "./store.js";
17
17
  export interface PermissionRule {
@@ -50,6 +50,11 @@ export interface AgentDefinition {
50
50
  readonly thresholdTokens: number;
51
51
  };
52
52
  readonly maxRetries?: number;
53
+ /** E1: loaded extensions — their tools merge into the registry (a name
54
+ * collision with a built-in is a loud startup error), their hooks
55
+ * compose after the agent's own (既有先行), their approvals join the
56
+ * loop's policy chain. */
57
+ readonly extensions?: readonly KisoExtension[];
53
58
  }
54
59
  export declare class AgentRuntime {
55
60
  #private;
package/dist/agent.js CHANGED
@@ -22,6 +22,12 @@ export class AgentRuntime {
22
22
  this.#registry = new ToolRegistry();
23
23
  for (const tool of definition.tools)
24
24
  this.#registry.register(tool);
25
+ // E1: extension tools join the registry — a collision with a built-in
26
+ // name throws here, at agent creation: a loud startup failure.
27
+ for (const ext of definition.extensions ?? []) {
28
+ for (const tool of ext.tools ?? [])
29
+ this.#registry.register(tool);
30
+ }
25
31
  this.#adapterPromise = resolveAdapter(definition);
26
32
  }
27
33
  sessionIds() {
@@ -59,6 +65,7 @@ export class AgentRuntime {
59
65
  ...(this.#definition.compaction !== undefined ? { compaction: this.#definition.compaction } : {}),
60
66
  ...(this.#definition.microcompact !== undefined ? { microcompact: this.#definition.microcompact } : {}),
61
67
  ...(this.#definition.maxRetries !== undefined ? { maxRetries: this.#definition.maxRetries } : {}),
68
+ ...(this.#definition.extensions !== undefined ? { extensions: this.#definition.extensions } : {}),
62
69
  };
63
70
  return new AgentSession(options.id, log, store, adapter, config);
64
71
  }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * E1 — loadExtensions: extension modules from a directory.
3
+ *
4
+ * Each *.mjs file's default export is a KisoExtension — or a factory
5
+ * returning one. Loading is LOUD: a bad file, a malformed export, or a
6
+ * duplicate extension name throws with the file name(s), so a broken
7
+ * extension installation fails the process at startup instead of silently
8
+ * changing behavior. An absent directory is the normal "no extensions"
9
+ * case and yields [].
10
+ */
11
+ import type { KisoExtension } from "@vincemakes/kiso-core";
12
+ export type { KisoExtension };
13
+ export declare function loadExtensions(dir: string): Promise<KisoExtension[]>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * E1 — loadExtensions: extension modules from a directory.
3
+ *
4
+ * Each *.mjs file's default export is a KisoExtension — or a factory
5
+ * returning one. Loading is LOUD: a bad file, a malformed export, or a
6
+ * duplicate extension name throws with the file name(s), so a broken
7
+ * extension installation fails the process at startup instead of silently
8
+ * changing behavior. An absent directory is the normal "no extensions"
9
+ * case and yields [].
10
+ */
11
+ import { readdir } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ export async function loadExtensions(dir) {
15
+ let files;
16
+ try {
17
+ files = (await readdir(dir)).filter((f) => f.endsWith(".mjs")).sort();
18
+ }
19
+ catch (err) {
20
+ if (err.code === "ENOENT")
21
+ return []; // no extensions dir = none installed
22
+ throw err;
23
+ }
24
+ const out = [];
25
+ for (const file of files) {
26
+ let ext;
27
+ try {
28
+ const mod = (await import(pathToFileURL(join(dir, file)).href));
29
+ ext = mod.default;
30
+ if (typeof ext === "function")
31
+ ext = await ext(); // a factory
32
+ }
33
+ catch (err) {
34
+ throw new Error(`[extensions] failed to load ${file}: ${err.message}`);
35
+ }
36
+ if (!isExtension(ext)) {
37
+ throw new Error(`[extensions] ${file} must default-export a KisoExtension {name, hooks?, tools?, approvals?} or a factory returning one`);
38
+ }
39
+ if (out.some((e) => e.name === ext.name)) {
40
+ throw new Error(`[extensions] duplicate extension name "${ext.name}" in ${file}`);
41
+ }
42
+ out.push(ext);
43
+ }
44
+ return out;
45
+ }
46
+ function isExtension(v) {
47
+ if (typeof v !== "object" || v === null)
48
+ return false;
49
+ const e = v;
50
+ return (typeof e.name === "string" &&
51
+ (e.hooks === undefined || (typeof e.hooks === "object" && e.hooks !== null)) &&
52
+ (e.tools === undefined || Array.isArray(e.tools)) &&
53
+ (e.approvals === undefined || Array.isArray(e.approvals)));
54
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./agent.js";
2
2
  export * from "./session.js";
3
3
  export * from "./store.js";
4
+ export * from "./extensions.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from "./agent.js";
2
2
  export * from "./session.js";
3
3
  export * from "./store.js";
4
+ export * from "./extensions.js";
package/dist/session.d.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * Restart recovery is the same code path as a second run: rebuild the log
24
24
  * from the JSONL, continue numbering where the file ended.
25
25
  */
26
- import { EventLog, type AbortSignalLike, type Adapter, type Event, type Message, type PermissionDecision, type Tool } from "@vincemakes/kiso-core";
26
+ import { EventLog, type AbortSignalLike, type Adapter, type Event, type KisoExtension, type Message, type PermissionDecision, type Tool } from "@vincemakes/kiso-core";
27
27
  import { type SessionStore } from "./store.js";
28
28
  /** A session whose disk write was rejected (stale handle) is PERMANENTLY
29
29
  * poisoned: its in-memory log no longer matches the disk, so no further
@@ -144,6 +144,13 @@ export interface SessionConfig {
144
144
  readonly thresholdTokens: number;
145
145
  };
146
146
  readonly maxRetries?: number;
147
+ /**
148
+ * E1: loaded extensions — their tools join the registry (idempotently;
149
+ * a collision with a built-in name was already rejected at agent
150
+ * creation), their hooks compose AFTER the existing ones (既有先行),
151
+ * their approval policies enter the loop's policy chain.
152
+ */
153
+ readonly extensions?: readonly KisoExtension[];
147
154
  }
148
155
  /**
149
156
  * A single turn. Async-iterable, so `for await (const ev of session.run(x))`
package/dist/session.js CHANGED
@@ -88,7 +88,17 @@ export class AgentSession {
88
88
  this.log = log;
89
89
  this.#store = store;
90
90
  this.#adapter = adapter;
91
- this.#config = config;
91
+ // E1: extension tools join the registry. The collision check already
92
+ // happened at agent creation (loud startup error); the idempotent
93
+ // skip keeps a second session on the same registry from re-registering.
94
+ for (const ext of config.extensions ?? []) {
95
+ for (const tool of ext.tools ?? []) {
96
+ if (!config.registry.has(tool.name))
97
+ config.registry.register(tool);
98
+ }
99
+ }
100
+ const composedHooks = composeHooks(config.hooks, config.extensions ?? []);
101
+ this.#config = composedHooks === undefined ? config : { ...config, hooks: composedHooks };
92
102
  }
93
103
  /** Write-ahead through the store; a rejected write POISONS the session
94
104
  * (一/第四轮): the in-memory log no longer matches the disk — whatever
@@ -427,18 +437,26 @@ export class Run {
427
437
  this.#session.beginRun(this);
428
438
  const log = this.#session.log;
429
439
  const signal = this.#externalSignal ? new MergedSignal(this.#abort.signal, this.#externalSignal) : this.#abort.signal;
440
+ // E2: the session's own microcompact wins; otherwise the FIRST
441
+ // extension providing a compaction config supplies it.
442
+ const microcompact = microcompactFor(this.#config);
443
+ // E2: the session's own systemPrompt first, then every extension
444
+ // append in LOAD order — deterministic (same extensions → same
445
+ // prompt); no appends → byte-identical to the extension-less run.
446
+ const systemPrompt = composeSystemPrompt(this.#config.systemPrompt, this.#config.extensions ?? []);
430
447
  const loopConfig = () => ({
431
448
  adapter: this.#adapter,
432
449
  model: this.#config.model,
433
- ...(this.#config.systemPrompt !== undefined ? { systemPrompt: this.#config.systemPrompt } : {}),
450
+ ...(systemPrompt !== undefined ? { systemPrompt } : {}),
434
451
  registry: this.#config.registry,
435
452
  ...(this.#config.hooks !== undefined ? { hooks: this.#config.hooks } : {}),
436
453
  ...(this.#config.maxTurns !== undefined ? { maxTurns: this.#config.maxTurns } : {}),
437
454
  ...(this.#config.maxTokens !== undefined ? { maxTokens: this.#config.maxTokens } : {}),
438
455
  ...(this.#config.temperature !== undefined ? { temperature: this.#config.temperature } : {}),
439
456
  ...(this.#config.compaction !== undefined ? { compaction: this.#config.compaction } : {}),
440
- ...(this.#config.microcompact !== undefined ? { microcompact: this.#config.microcompact } : {}),
457
+ ...(microcompact !== undefined ? { microcompact } : {}),
441
458
  ...(this.#config.maxRetries !== undefined ? { maxRetries: this.#config.maxRetries } : {}),
459
+ approvalPolicies: (this.#config.extensions ?? []).flatMap((e) => (e.approvals ?? []).map((policy) => ({ extension: e.name, policy }))),
442
460
  log,
443
461
  signal,
444
462
  resolveApproval: (decisionId) => new Promise((resolve) => {
@@ -856,6 +874,118 @@ export class Run {
856
874
  });
857
875
  }
858
876
  }
877
+ /**
878
+ * E2: the session's systemPrompt plus every extension's append, in LOAD
879
+ * order, \n\n-joined — deterministic (same extension list → same prompt).
880
+ * No appends → the base passes through byte-identical.
881
+ */
882
+ function composeSystemPrompt(base, extensions) {
883
+ const appends = extensions.flatMap((e) => (e.systemPrompt?.append === undefined ? [] : [e.systemPrompt.append]));
884
+ if (appends.length === 0)
885
+ return base;
886
+ return base === undefined ? appends.join("\n\n") : `${base}\n\n${appends.join("\n\n")}`;
887
+ }
888
+ /**
889
+ * E1: extension hooks compose AFTER the agent's own (既有先行 — the existing
890
+ * hook sees every event first). Observers all run, in order; onUserMessage
891
+ * and onPreTool — the FIRST decisive answer wins (the existing hook
892
+ * outranks extensions; defers fall through); onPostTool folds — each
893
+ * transforms the previous result. Returns the existing host unchanged when
894
+ * no extension provides hooks.
895
+ */
896
+ function composeHooks(existing, extensions) {
897
+ const extHooks = extensions.flatMap((e) => (e.hooks === undefined ? [] : [e.hooks]));
898
+ if (extHooks.length === 0)
899
+ return existing;
900
+ const out = { ...existing };
901
+ const sources = existing === undefined ? extHooks : [existing, ...extHooks];
902
+ const observers = (key) => {
903
+ const handlers = sources.map(key).filter((h) => h !== undefined);
904
+ if (handlers.length <= 1)
905
+ return handlers[0];
906
+ return async (payload, ctx) => {
907
+ for (const h of handlers)
908
+ await h(payload, ctx);
909
+ };
910
+ };
911
+ for (const key of ["onPreLlm", "onEvent", "onPreCompact", "onPostCompact", "onPause", "onStop"]) {
912
+ const handler = observers((h) => h[key]);
913
+ if (handler !== undefined)
914
+ out[key] = handler;
915
+ }
916
+ const messageHandlers = sources
917
+ .map((h) => h.onUserMessage)
918
+ .filter((h) => h !== undefined);
919
+ if (messageHandlers.length === 1) {
920
+ out.onUserMessage = messageHandlers[0]; // length 1 guarantees the element
921
+ }
922
+ else if (messageHandlers.length > 1) {
923
+ // 复审 E1-P2: the pipe + veto short-circuit — each handler sees the
924
+ // message as the PREVIOUS one left it (既有先行), and a null (veto)
925
+ // anywhere ends the chain immediately: never "no opinion" for the
926
+ // next handler to outvote. Adding an extension can therefore never
927
+ // make the chain MORE permissive (the approval chain's deny>ask>allow
928
+ // monotonicity, on the message side).
929
+ out.onUserMessage = async (msg, ctx) => {
930
+ let current = msg;
931
+ for (const h of messageHandlers) {
932
+ const r = await h(current, ctx);
933
+ if (r === null)
934
+ return null;
935
+ current = r;
936
+ }
937
+ return current;
938
+ };
939
+ }
940
+ const preToolHandlers = sources
941
+ .map((h) => h.onPreTool)
942
+ .filter((h) => h !== undefined);
943
+ if (preToolHandlers.length === 1) {
944
+ out.onPreTool = preToolHandlers[0];
945
+ }
946
+ else if (preToolHandlers.length > 1) {
947
+ out.onPreTool = async (call, ctx) => {
948
+ for (const h of preToolHandlers) {
949
+ const d = await h(call, ctx);
950
+ if (d.action !== "defer")
951
+ return d;
952
+ }
953
+ return { action: "defer" };
954
+ };
955
+ }
956
+ const postToolHandlers = sources
957
+ .map((h) => h.onPostTool)
958
+ .filter((h) => h !== undefined);
959
+ if (postToolHandlers.length === 1) {
960
+ out.onPostTool = postToolHandlers[0];
961
+ }
962
+ else if (postToolHandlers.length > 1) {
963
+ out.onPostTool = async (call, result, ctx) => {
964
+ let r = result;
965
+ for (const h of postToolHandlers)
966
+ r = await h(call, r, ctx);
967
+ return r;
968
+ };
969
+ }
970
+ return out;
971
+ }
972
+ /**
973
+ * E2: the loop's microcompact config — the session's own microcompact wins;
974
+ * otherwise the FIRST extension providing a compaction config supplies it.
975
+ * An extension config without a threshold contributes nothing (a boundary
976
+ * needs a threshold to ever fire).
977
+ */
978
+ function microcompactFor(config) {
979
+ if (config.microcompact !== undefined)
980
+ return config.microcompact;
981
+ for (const ext of config.extensions ?? []) {
982
+ const c = ext.compaction;
983
+ if (c !== undefined && c.thresholdTokens !== undefined) {
984
+ return { thresholdTokens: c.thresholdTokens, ...(c.keepResults !== undefined ? { keepResults: c.keepResults } : {}) };
985
+ }
986
+ }
987
+ return undefined;
988
+ }
859
989
  /**
860
990
  * The most recent run WITHOUT a terminal, or undefined when every recorded
861
991
  * run terminated. Recovery can only drive ONE run to its terminal, so an
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.1.4",
4
- "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
3
+ "version": "0.1.6",
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",
7
7
  "exports": {
@@ -21,11 +21,11 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@vincemakes/kiso-core": "0.1.4"
24
+ "@vincemakes/kiso-core": "0.1.6"
25
25
  },
26
26
  "peerDependencies": {
27
- "@vincemakes/kiso-provider-anthropic": "0.1.4",
28
- "@vincemakes/kiso-provider-openai": "0.1.4"
27
+ "@vincemakes/kiso-provider-anthropic": "0.1.6",
28
+ "@vincemakes/kiso-provider-openai": "0.1.6"
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.4",
39
+ "@vincemakes/kiso-evals": "0.1.6",
40
40
  "@types/node": "^26.1.2",
41
41
  "typescript": "^5.7.2",
42
42
  "vitest": "^3.0.0"