@vincemakes/kiso-runtime 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.
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
@@ -439,6 +449,7 @@ export class Run {
439
449
  ...(this.#config.compaction !== undefined ? { compaction: this.#config.compaction } : {}),
440
450
  ...(this.#config.microcompact !== undefined ? { microcompact: this.#config.microcompact } : {}),
441
451
  ...(this.#config.maxRetries !== undefined ? { maxRetries: this.#config.maxRetries } : {}),
452
+ approvalPolicies: (this.#config.extensions ?? []).flatMap((e) => (e.approvals ?? []).map((policy) => ({ extension: e.name, policy }))),
442
453
  log,
443
454
  signal,
444
455
  resolveApproval: (decisionId) => new Promise((resolve) => {
@@ -856,6 +867,90 @@ export class Run {
856
867
  });
857
868
  }
858
869
  }
870
+ /**
871
+ * E1: extension hooks compose AFTER the agent's own (既有先行 — the existing
872
+ * hook sees every event first). Observers all run, in order; onUserMessage
873
+ * and onPreTool — the FIRST decisive answer wins (the existing hook
874
+ * outranks extensions; defers fall through); onPostTool folds — each
875
+ * transforms the previous result. Returns the existing host unchanged when
876
+ * no extension provides hooks.
877
+ */
878
+ function composeHooks(existing, extensions) {
879
+ const extHooks = extensions.flatMap((e) => (e.hooks === undefined ? [] : [e.hooks]));
880
+ if (extHooks.length === 0)
881
+ return existing;
882
+ const out = { ...existing };
883
+ const sources = existing === undefined ? extHooks : [existing, ...extHooks];
884
+ const observers = (key) => {
885
+ const handlers = sources.map(key).filter((h) => h !== undefined);
886
+ if (handlers.length <= 1)
887
+ return handlers[0];
888
+ return async (payload, ctx) => {
889
+ for (const h of handlers)
890
+ await h(payload, ctx);
891
+ };
892
+ };
893
+ for (const key of ["onPreLlm", "onEvent", "onPreCompact", "onPostCompact", "onPause", "onStop"]) {
894
+ const handler = observers((h) => h[key]);
895
+ if (handler !== undefined)
896
+ out[key] = handler;
897
+ }
898
+ const messageHandlers = sources
899
+ .map((h) => h.onUserMessage)
900
+ .filter((h) => h !== undefined);
901
+ if (messageHandlers.length === 1) {
902
+ out.onUserMessage = messageHandlers[0]; // length 1 guarantees the element
903
+ }
904
+ else if (messageHandlers.length > 1) {
905
+ // 复审 E1-P2: the pipe + veto short-circuit — each handler sees the
906
+ // message as the PREVIOUS one left it (既有先行), and a null (veto)
907
+ // anywhere ends the chain immediately: never "no opinion" for the
908
+ // next handler to outvote. Adding an extension can therefore never
909
+ // make the chain MORE permissive (the approval chain's deny>ask>allow
910
+ // monotonicity, on the message side).
911
+ out.onUserMessage = async (msg, ctx) => {
912
+ let current = msg;
913
+ for (const h of messageHandlers) {
914
+ const r = await h(current, ctx);
915
+ if (r === null)
916
+ return null;
917
+ current = r;
918
+ }
919
+ return current;
920
+ };
921
+ }
922
+ const preToolHandlers = sources
923
+ .map((h) => h.onPreTool)
924
+ .filter((h) => h !== undefined);
925
+ if (preToolHandlers.length === 1) {
926
+ out.onPreTool = preToolHandlers[0];
927
+ }
928
+ else if (preToolHandlers.length > 1) {
929
+ out.onPreTool = async (call, ctx) => {
930
+ for (const h of preToolHandlers) {
931
+ const d = await h(call, ctx);
932
+ if (d.action !== "defer")
933
+ return d;
934
+ }
935
+ return { action: "defer" };
936
+ };
937
+ }
938
+ const postToolHandlers = sources
939
+ .map((h) => h.onPostTool)
940
+ .filter((h) => h !== undefined);
941
+ if (postToolHandlers.length === 1) {
942
+ out.onPostTool = postToolHandlers[0];
943
+ }
944
+ else if (postToolHandlers.length > 1) {
945
+ out.onPostTool = async (call, result, ctx) => {
946
+ let r = result;
947
+ for (const h of postToolHandlers)
948
+ r = await h(call, r, ctx);
949
+ return r;
950
+ };
951
+ }
952
+ return out;
953
+ }
859
954
  /**
860
955
  * The most recent run WITHOUT a terminal, or undefined when every recorded
861
956
  * 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.3",
4
- "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
3
+ "version": "0.1.5",
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.3"
24
+ "@vincemakes/kiso-core": "0.1.5"
25
25
  },
26
26
  "peerDependencies": {
27
- "@vincemakes/kiso-provider-anthropic": "0.1.3",
28
- "@vincemakes/kiso-provider-openai": "0.1.3"
27
+ "@vincemakes/kiso-provider-anthropic": "0.1.5",
28
+ "@vincemakes/kiso-provider-openai": "0.1.5"
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.3",
39
+ "@vincemakes/kiso-evals": "0.1.5",
40
40
  "@types/node": "^26.1.2",
41
41
  "typescript": "^5.7.2",
42
42
  "vitest": "^3.0.0"