@msm-core/mini 0.8.0 → 0.14.0

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.
@@ -10,26 +10,70 @@
10
10
  * 4. Execute — call tool.execute()
11
11
  * 5. Cache — store result in dedup hash
12
12
  */
13
- import type { Tool, ToolResult, ToolMeta, AgentHooks } from "../core/types.js";
13
+ import type { Tool, ToolResult, ToolMeta, AgentHooks, DedupPort } from "../core/types.js";
14
14
  type RedisLike = {
15
15
  hget(key: string, field: string): Promise<string | null>;
16
16
  hset(key: string, field: string, value: string): Promise<unknown>;
17
17
  expire(key: string, seconds: number): Promise<unknown>;
18
18
  get(key: string): Promise<string | null>;
19
19
  };
20
- export interface ExecutorOptions {
20
+ /**
21
+ * Where step 3's cache lives — a port, or a Redis client to build one from.
22
+ *
23
+ * A union rather than "a port and an optional client", so the compiler still
24
+ * insists on exactly one answer. The second arm is the call this function has
25
+ * always taken (`{ redis, redisPrefix, … }`) and it keeps working character for
26
+ * character; the loop passes the first, because since س٦ it resolves the dedup
27
+ * port itself — injected or Redis-backed — and hands one thing down.
28
+ */
29
+ type DedupSource = {
30
+ dedup: DedupPort;
31
+ redis?: never;
32
+ redisPrefix?: never;
33
+ } | {
34
+ dedup?: never;
21
35
  redis: RedisLike;
22
36
  redisPrefix: string;
37
+ };
38
+ export type ExecutorOptions = DedupSource & {
23
39
  dedupTtlSeconds: number;
24
40
  /** Optional hooks — only onBeforeTool is used at this layer */
25
41
  hooks?: Pick<AgentHooks, "onBeforeTool">;
26
- }
42
+ };
27
43
  export interface ExecutionResult {
28
44
  result: ToolResult;
29
45
  cached: boolean;
30
46
  durationMs: number;
31
47
  }
32
48
  export declare function executeTool(tool: Tool, params: Record<string, unknown>, meta: ToolMeta, opts: ExecutorOptions): Promise<ExecutionResult>;
33
- /** Build a ToolDefinition list from app-provided Tool objects (for brain prompt) */
34
- export declare function toToolDefinitions(tools: Tool[]): import("../core/types.js").ToolDefinition[];
49
+ /**
50
+ * Build a ToolDefinition list from app-provided Tool objects (for brain prompt).
51
+ *
52
+ * **The stamp travels (ر١/١).** `ToolDefinition` has declared `destructive` and
53
+ * `category` since before MCP existed and nothing ever filled them: a tool an
54
+ * operator stamped `destructive` at the composition root reached the model
55
+ * looking exactly like a read-only one. This was never a safety hole — the
56
+ * executor enforces `requiresApproval` above regardless of what the model was
57
+ * told — but a model that is never shown which of its tools change the world
58
+ * cannot be asked to be careful with them, and the two fields existed on the
59
+ * type precisely so that it could be.
60
+ *
61
+ * **Why the parameter widens instead of the body casting.** Mini's `Tool` does
62
+ * not declare the two fields; `McpTool` (ر١) and `DelegateTool` (ر٢) each add
63
+ * them to a plain `Tool`, and both reach here through `AgentConfig.tools:
64
+ * Tool[]` — on the object, off the type. Saying so in the signature keeps it
65
+ * honest: a plain `Tool[]` still satisfies it (both fields are optional), a
66
+ * stamped array is read with no `as` anywhere, and a caller who writes
67
+ * `destructive: "yes"` is told by the compiler instead of dropped in silence.
68
+ *
69
+ * **Absent stays absent.** The conditional spreads are not style: under
70
+ * `exactOptionalPropertyTypes` an unstamped tool must produce the object it
71
+ * produced yesterday, key for key. That is what keeps an unstamped run's
72
+ * `@msm-core/replay` fingerprint unmoved — only a tool that actually carries a
73
+ * stamp changes what the model is asked, and only its fingerprint moves.
74
+ */
75
+ export declare function toToolDefinitions(tools: ReadonlyArray<Tool & {
76
+ destructive?: boolean;
77
+ category?: string;
78
+ }>): import("../core/types.js").ToolDefinition[];
35
79
  export {};
@@ -10,7 +10,7 @@
10
10
  * 4. Execute — call tool.execute()
11
11
  * 5. Cache — store result in dedup hash
12
12
  */
13
- import { hashToolCall, checkDedup, storeDedup, toolDedupKey } from "./dedup.js";
13
+ import { hashToolCall, RedisToolDedup } from "./dedup.js";
14
14
  export async function executeTool(tool, params, meta, opts) {
15
15
  const start = Date.now();
16
16
  // Step 1: Validate required parameters
@@ -95,9 +95,15 @@ export async function executeTool(tool, params, meta, opts) {
95
95
  }
96
96
  // Step 3: Dedup check (keyed on the EFFECTIVE params, so edited-approval runs
97
97
  // are not served a cached result for the original params).
98
+ //
99
+ // The hash stays here and stays pure — it is not I/O and there is nothing to
100
+ // swap about it, so every implementation of the port dedups on exactly the
101
+ // same key the Redis one always did.
102
+ const dedup = opts.dedup
103
+ ? opts.dedup
104
+ : new RedisToolDedup(opts.redis, opts.redisPrefix);
98
105
  const hash = hashToolCall(tool.name, effectiveParams);
99
- const dedupKey = toolDedupKey(opts.redisPrefix, meta.sessionId);
100
- const cached = await checkDedup(opts.redis, dedupKey, hash);
106
+ const cached = await dedup.check(meta.sessionId, hash);
101
107
  if (cached) {
102
108
  return { result: cached, cached: true, durationMs: Date.now() - start };
103
109
  }
@@ -115,7 +121,7 @@ export async function executeTool(tool, params, meta, opts) {
115
121
  }
116
122
  // Step 5: Cache successful results only
117
123
  if (result.status === "ok") {
118
- await storeDedup(opts.redis, dedupKey, hash, result, opts.dedupTtlSeconds);
124
+ await dedup.store(meta.sessionId, hash, result, opts.dedupTtlSeconds);
119
125
  }
120
126
  return { result, cached: false, durationMs: Date.now() - start };
121
127
  }
@@ -135,7 +141,32 @@ function validateParams(tool, params) {
135
141
  }
136
142
  return null;
137
143
  }
138
- /** Build a ToolDefinition list from app-provided Tool objects (for brain prompt) */
144
+ /**
145
+ * Build a ToolDefinition list from app-provided Tool objects (for brain prompt).
146
+ *
147
+ * **The stamp travels (ر١/١).** `ToolDefinition` has declared `destructive` and
148
+ * `category` since before MCP existed and nothing ever filled them: a tool an
149
+ * operator stamped `destructive` at the composition root reached the model
150
+ * looking exactly like a read-only one. This was never a safety hole — the
151
+ * executor enforces `requiresApproval` above regardless of what the model was
152
+ * told — but a model that is never shown which of its tools change the world
153
+ * cannot be asked to be careful with them, and the two fields existed on the
154
+ * type precisely so that it could be.
155
+ *
156
+ * **Why the parameter widens instead of the body casting.** Mini's `Tool` does
157
+ * not declare the two fields; `McpTool` (ر١) and `DelegateTool` (ر٢) each add
158
+ * them to a plain `Tool`, and both reach here through `AgentConfig.tools:
159
+ * Tool[]` — on the object, off the type. Saying so in the signature keeps it
160
+ * honest: a plain `Tool[]` still satisfies it (both fields are optional), a
161
+ * stamped array is read with no `as` anywhere, and a caller who writes
162
+ * `destructive: "yes"` is told by the compiler instead of dropped in silence.
163
+ *
164
+ * **Absent stays absent.** The conditional spreads are not style: under
165
+ * `exactOptionalPropertyTypes` an unstamped tool must produce the object it
166
+ * produced yesterday, key for key. That is what keeps an unstamped run's
167
+ * `@msm-core/replay` fingerprint unmoved — only a tool that actually carries a
168
+ * stamp changes what the model is asked, and only its fingerprint moves.
169
+ */
139
170
  export function toToolDefinitions(tools) {
140
171
  return tools.map((t) => ({
141
172
  name: t.name,
@@ -144,5 +175,7 @@ export function toToolDefinitions(tools) {
144
175
  ...(t.requiresApproval !== undefined
145
176
  ? { requiresApproval: t.requiresApproval }
146
177
  : {}),
178
+ ...(t.destructive !== undefined ? { destructive: t.destructive } : {}),
179
+ ...(t.category !== undefined ? { category: t.category } : {}),
147
180
  }));
148
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msm-core/mini",
3
- "version": "0.8.0",
3
+ "version": "0.14.0",
4
4
  "description": "Portable AI agent execution loop — brain-agnostic, zero embedded databases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "ioredis": "^5.3.2",
52
- "@msm-core/session": "^0.2.0"
52
+ "@msm-core/session": "^0.3.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@types/node": "^20.0.0",
@@ -68,7 +68,8 @@
68
68
  "license": "UNLICENSED",
69
69
  "scripts": {
70
70
  "build": "tsc",
71
- "test": "vitest run",
71
+ "typecheck": "tsc -p tsconfig.test.json",
72
+ "test": "tsc -p tsconfig.test.json && vitest run",
72
73
  "test:watch": "vitest",
73
74
  "clean": "rm -rf dist"
74
75
  }