@msm-core/mini 0.8.0 → 0.9.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.
@@ -14,6 +14,7 @@
14
14
  *
15
15
  * The pipeline is duck-typed — no direct import of @msm-core/pipeline.
16
16
  */
17
+ import { BRAIN_ACTIONS } from "../core/types.js";
17
18
  // ─── Adapter ──────────────────────────────────────────────────
18
19
  /**
19
20
  * Wrap an @msm-core/pipeline as a Brain for @msm-core/mini.
@@ -57,14 +58,16 @@ export function wrapPipeline(pipeline, name = "pipeline") {
57
58
  : {}),
58
59
  });
59
60
  const brain = result.payload.brain;
60
- // Normalise action — pipeline may return arbitrary strings; mini only knows these
61
- const VALID_ACTIONS = new Set([
62
- "use_tool",
63
- "respond",
64
- "clarify",
65
- "escalate",
66
- "delegate",
67
- ]);
61
+ // Normalise action — a duck-typed pipeline may return any string, and
62
+ // mini dispatches on four. The set is built FROM `BRAIN_ACTIONS` and is
63
+ // no longer a second hand-written copy of it (ر٢): a copy is how the
64
+ // union and this list came to disagree about a fifth action that the loop
65
+ // never dispatched on, and how that disagreement went unnoticed.
66
+ //
67
+ // The fallback is unchanged: an unrecognised string becomes `respond`,
68
+ // which is also what the loop itself does with one that arrives past this
69
+ // point. Two layers, one rule.
70
+ const VALID_ACTIONS = new Set(BRAIN_ACTIONS);
68
71
  const rawAction = brain?.action ?? "respond";
69
72
  const action = VALID_ACTIONS.has(rawAction)
70
73
  ? rawAction
@@ -154,8 +154,45 @@ export interface BrainToolCall {
154
154
  name: string;
155
155
  params: Record<string, unknown>;
156
156
  }
157
+ /**
158
+ * Every action the loop DISPATCHES ON — declared as data, and the only list.
159
+ *
160
+ * ── Why this is a `const` array and not an inline union (ر٢) ────────────────
161
+ *
162
+ * There were two lists. This union, and a second copy in `bridge/pipeline.ts`
163
+ * that normalises whatever a duck-typed pipeline hands back. Both carried a
164
+ * fifth member the loop never dispatched on — a routing action that was
165
+ * declared at the beginning, implemented nowhere, and used by nobody (measured:
166
+ * zero occurrences in the live consumer). It survived precisely because
167
+ * deleting it meant finding and agreeing two places, and س١'s lesson is that
168
+ * two lists of the same thing drift apart silently and are only noticed by the
169
+ * damage.
170
+ *
171
+ * So there is now one list. The union below is derived from it and the bridge
172
+ * validates against it, which means a member cannot be added to one and
173
+ * forgotten in the other, and a member cannot be REMOVED from one and left
174
+ * standing in the other. `BrainActionsAreTheFourLiveOnes` then makes growing
175
+ * this array a decision someone has to write down rather than one that happens
176
+ * by omission.
177
+ *
178
+ * **What the loop actually does with each** (`core/loop.ts`):
179
+ * - `use_tool` → the step's tool calls run, then back to the model.
180
+ * - `clarify` / `escalate` → terminal, and the outcome carries that type.
181
+ * - `respond` → terminal, the text is delivered.
182
+ *
183
+ * And an action that is NONE of these — a rogue model emitting a string at
184
+ * runtime, where no type can stop it — takes the `respond` path: terminal,
185
+ * one iteration, whatever text the payload carried. That is the existing
186
+ * behaviour, it is fail-safe (a nonsense action never loops and never runs a
187
+ * tool), and it is pinned by a guard in `tests/delegate.test.ts` rather than
188
+ * left as an accident. The bridge's `respond` fallback for an unrecognised
189
+ * string is the same rule stated one layer earlier.
190
+ */
191
+ export declare const BRAIN_ACTIONS: readonly ["use_tool", "respond", "clarify", "escalate"];
192
+ /** What a model may ask the loop to do. Derived from `BRAIN_ACTIONS`. */
193
+ export type BrainAction = (typeof BRAIN_ACTIONS)[number];
157
194
  export interface BrainOrchestration {
158
- action: "use_tool" | "respond" | "clarify" | "escalate" | "delegate";
195
+ action: BrainAction;
159
196
  confidence: number;
160
197
  /**
161
198
  * The FIRST call of the step. Always filled whenever `tool_calls` is —
@@ -4,4 +4,43 @@
4
4
  * All contracts for the lite agent runtime. Brain-agnostic, zero embedded
5
5
  * databases. Application layer owns persistence; agent owns the loop.
6
6
  */
7
- export {};
7
+ /**
8
+ * Every action the loop DISPATCHES ON — declared as data, and the only list.
9
+ *
10
+ * ── Why this is a `const` array and not an inline union (ر٢) ────────────────
11
+ *
12
+ * There were two lists. This union, and a second copy in `bridge/pipeline.ts`
13
+ * that normalises whatever a duck-typed pipeline hands back. Both carried a
14
+ * fifth member the loop never dispatched on — a routing action that was
15
+ * declared at the beginning, implemented nowhere, and used by nobody (measured:
16
+ * zero occurrences in the live consumer). It survived precisely because
17
+ * deleting it meant finding and agreeing two places, and س١'s lesson is that
18
+ * two lists of the same thing drift apart silently and are only noticed by the
19
+ * damage.
20
+ *
21
+ * So there is now one list. The union below is derived from it and the bridge
22
+ * validates against it, which means a member cannot be added to one and
23
+ * forgotten in the other, and a member cannot be REMOVED from one and left
24
+ * standing in the other. `BrainActionsAreTheFourLiveOnes` then makes growing
25
+ * this array a decision someone has to write down rather than one that happens
26
+ * by omission.
27
+ *
28
+ * **What the loop actually does with each** (`core/loop.ts`):
29
+ * - `use_tool` → the step's tool calls run, then back to the model.
30
+ * - `clarify` / `escalate` → terminal, and the outcome carries that type.
31
+ * - `respond` → terminal, the text is delivered.
32
+ *
33
+ * And an action that is NONE of these — a rogue model emitting a string at
34
+ * runtime, where no type can stop it — takes the `respond` path: terminal,
35
+ * one iteration, whatever text the payload carried. That is the existing
36
+ * behaviour, it is fail-safe (a nonsense action never loops and never runs a
37
+ * tool), and it is pinned by a guard in `tests/delegate.test.ts` rather than
38
+ * left as an accident. The bridge's `respond` fallback for an unrecognised
39
+ * string is the same rule stated one layer earlier.
40
+ */
41
+ export const BRAIN_ACTIONS = [
42
+ "use_tool",
43
+ "respond",
44
+ "clarify",
45
+ "escalate",
46
+ ];
package/dist/index.d.ts CHANGED
@@ -15,6 +15,8 @@ export { createAnthropicBrain } from "./brain/anthropic.js";
15
15
  export { createOllamaBrain } from "./brain/ollama.js";
16
16
  export { buildBrain } from "./brain/factory.js";
17
17
  export { parseDefinition } from "./definition/parser.js";
18
+ export { createDelegateTool, DELEGATE_TOOL_NAME } from "./tools/delegate.js";
19
+ export type { DelegateTool, DelegateToolOptions } from "./tools/delegate.js";
18
20
  export type { Agent, AgentConfig, AgentEvent, AgentContext, AgentDefinition, AgentHooks, BeforeToolHookResult, Brain, BrainChunk, BrainOrchestration, BrainPayload, BrainRunInput, BrainToolCall, ChunkInfo, CompactionDecision, CompactionPort, DocumentState, GateConfig, GuardConfig, GuardSignal, GuardSignalType, IterationInfo, LoopOutcome, MemoryEntry, Message, OutcomeType, OutputValidation, OutputValidator, RedisConfig, RunState, SectionInfo, SessionMetadata, SessionStore, TenantContext, Tool, ToolCallInfo, ToolDefinition, ToolMeta, ToolParameter, ToolResult, } from "./core/types.js";
19
21
  export type { ContextBudget } from "./core/context-builder.js";
20
22
  export { resolveGuards, DEFAULT_GUARDS } from "./core/guards.js";
package/dist/index.js CHANGED
@@ -18,6 +18,8 @@ export { createOllamaBrain } from "./brain/ollama.js";
18
18
  export { buildBrain } from "./brain/factory.js";
19
19
  // ── Definition parser ───────────────────────────────────────────────────────
20
20
  export { parseDefinition } from "./definition/parser.js";
21
+ // ── Delegation (ر٢) — one agent asks another, as an ordinary tool ───────────
22
+ export { createDelegateTool, DELEGATE_TOOL_NAME } from "./tools/delegate.js";
21
23
  // ── Guard utilities ─────────────────────────────────────────────────────────
22
24
  export { resolveGuards, DEFAULT_GUARDS } from "./core/guards.js";
23
25
  // ── Quality scorer ──────────────────────────────────────────────────────────
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Delegation — one agent asks another, as an ORDINARY TOOL (ر٢).
3
+ *
4
+ * ── Why a tool and not a loop action ────────────────────────────────────────
5
+ *
6
+ * `BrainOrchestration.action` used to carry a fifth member for routing work to
7
+ * another agent. It was declared at the beginning, dispatched on by nothing,
8
+ * and used by nobody — measured, not assumed: zero occurrences in the live
9
+ * consumer. Meanwhile that same consumer HAS been delegating in production for
10
+ * months, and it does it the other way round: a `call_agent` tool over an
11
+ * injected handle. The measurement settled the design argument, so the action
12
+ * was buried (`core/types.ts`) and this is the thing that replaces it.
13
+ *
14
+ * The consequence is the point of the whole exercise: **there is not one line
15
+ * of loop code here.** A delegation is a tool call, so it already goes through
16
+ * the parameter validation, the `onBeforeTool` approval gate, the dedup cache,
17
+ * the control-bus disable, the per-call `tool_call`/`tool_result` pair in the
18
+ * session log, the tool-call budget and the consecutive-failure counter —
19
+ * every one of them, for free, because it is not special. An action in the loop
20
+ * would have had to re-earn each of those, one `if` at a time, in the file
21
+ * every agent rides on.
22
+ *
23
+ * ── What this improves on the lifted original ───────────────────────────────
24
+ *
25
+ * Three things, each of them a rule this repo already pays for elsewhere:
26
+ *
27
+ * 1. **The child's session id is DERIVED, not random.** `parent.d.child`,
28
+ * built from the parent's own id. The original minted a fresh UUID per
29
+ * delegation, which is exactly the thing س٥ named the enemy of replay: an
30
+ * arbitrary identifier kills every comparison that crosses a run — a tape
31
+ * fingerprint, a golden log, a diff of two sessions that should be
32
+ * identical. Derived, the same delegation twice is the same id twice, the
33
+ * log reads `s-42.d.researcher` and says what it is, and the depth is
34
+ * legible in the id instead of held in a side channel.
35
+ *
36
+ * 2. **The agent name is an `enum`, so the model cannot invent one.** The
37
+ * brains forward `enum` into the provider schema (`toolParamsToJsonSchema`),
38
+ * so this is a real constraint at the provider and not a hint. It is still
39
+ * checked at execution — `validateParams` enforces presence and type, never
40
+ * membership — and an unknown name is a NAMED failure the model can read
41
+ * and correct, not a crash.
42
+ *
43
+ * 3. **The cost is visible.** The original returned the child's text and threw
44
+ * the rest away, so a delegating agent's real spend was invisible to
45
+ * everything that watched it. `totalCostUsd` rides back in the result.
46
+ *
47
+ * ── What is deliberately NOT here ───────────────────────────────────────────
48
+ *
49
+ * • **The child's cost is not added to the parent's `totalCostUsd`.** It is
50
+ * reported and left there. Both agents run their own budgets, and a number
51
+ * counted in two places is worse than a number counted in one: it would
52
+ * make the parent's cost cap fire on spend the child already paid for, and
53
+ * the pair would then disagree about what the run cost. Raised for
54
+ * management, not decided here.
55
+ *
56
+ * • **No parent context is forwarded.** The child gets the message and its
57
+ * tenant, and assembles its own persona, memories and tools. Handing it the
58
+ * parent's `AgentContext` would make it a continuation of the parent rather
59
+ * than a second agent — and `ToolMeta` does not carry one anyway.
60
+ */
61
+ import type { Agent, Tool } from "../core/types.js";
62
+ /**
63
+ * The tool's name, exported because consumers dispatch on it.
64
+ *
65
+ * An approval hook, an audit trail or a UI badge all need to recognise a
66
+ * delegation by name, and a hand-typed string in each of them is the drift س١
67
+ * was paid for. It is `call_agent` and not something new so that the consumer
68
+ * already running this pattern in production can swap its hand-rolled tool for
69
+ * this factory without changing a prompt, a manifest, or an approval rule.
70
+ */
71
+ export declare const DELEGATE_TOOL_NAME = "call_agent";
72
+ /** Tuning for `createDelegateTool`. Every field has a working default. */
73
+ export interface DelegateToolOptions {
74
+ /**
75
+ * How many hops deep delegation may go. Default **1**: the agent you build
76
+ * may ask a delegate, and that delegate may not ask anyone.
77
+ *
78
+ * Measured off the CALLER's session id (`.d.` counted), not off a counter
79
+ * threaded through the call — so it cannot be lost, reset or lied about by a
80
+ * path that forgot to pass it on.
81
+ *
82
+ * `0` is a legitimate value and means "wired but switched off". Anything that
83
+ * is not a finite number ≥ 0 falls back to the default, in the spirit of
84
+ * `resolveGuards`: a typo must not silently remove a cap.
85
+ */
86
+ maxDepth?: number;
87
+ /**
88
+ * Stamped onto the tool, exactly as the MCP adapter stamps an MCP tool (ر١).
89
+ *
90
+ * That is the entire mechanism — there is no delegation-specific approval
91
+ * code, because `requiresApproval` is already a contract the executor fails
92
+ * closed on: set it with no `onBeforeTool` hook configured and the call is
93
+ * blocked before `execute` runs, which means blocked before the child agent
94
+ * is reached at all.
95
+ */
96
+ requiresApproval?: boolean;
97
+ /** Advisory metadata, mirroring `ToolDefinition`. See the note on `DelegateTool`. */
98
+ destructive?: boolean;
99
+ /** Advisory metadata, mirroring `ToolDefinition`. See the note on `DelegateTool`. */
100
+ category?: string;
101
+ }
102
+ /**
103
+ * A `Tool`, plus the two advisory fields mini's `Tool` does not carry.
104
+ *
105
+ * Shaped exactly like `McpTool` (ر١) and for the same reason: `ToolDefinition`
106
+ * declares `destructive` and `category`, `Tool` does not, and
107
+ * `toToolDefinitions` copies neither — so today they are carried and not yet
108
+ * read. That gap is already raised for management as the `toToolDefinitions`
109
+ * debt; this type is written to mirror the MCP stamp so that the day the debt
110
+ * is paid, both stamps start being read by the same change.
111
+ */
112
+ export interface DelegateTool extends Tool {
113
+ destructive?: boolean;
114
+ category?: string;
115
+ }
116
+ /**
117
+ * Build the delegation tool for a set of named agents.
118
+ *
119
+ * ```ts
120
+ * const agent = createAgent({
121
+ * definition, brain, redis,
122
+ * tools: [searchTool, createDelegateTool({ researcher, drafter })],
123
+ * });
124
+ * ```
125
+ *
126
+ * The delegates are `mini` agents themselves — whatever `createAgent` returned,
127
+ * or anything else satisfying `Agent`. Nothing is constructed here and no
128
+ * connection is opened: this is a port like every other in the package, handed
129
+ * in at composition time.
130
+ *
131
+ * @param delegates agents this tool may call, by the name the model will use.
132
+ * @param opts depth cap and the approval/metadata stamp.
133
+ */
134
+ export declare function createDelegateTool(delegates: Record<string, Agent>, opts?: DelegateToolOptions): DelegateTool;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Delegation — one agent asks another, as an ORDINARY TOOL (ر٢).
3
+ *
4
+ * ── Why a tool and not a loop action ────────────────────────────────────────
5
+ *
6
+ * `BrainOrchestration.action` used to carry a fifth member for routing work to
7
+ * another agent. It was declared at the beginning, dispatched on by nothing,
8
+ * and used by nobody — measured, not assumed: zero occurrences in the live
9
+ * consumer. Meanwhile that same consumer HAS been delegating in production for
10
+ * months, and it does it the other way round: a `call_agent` tool over an
11
+ * injected handle. The measurement settled the design argument, so the action
12
+ * was buried (`core/types.ts`) and this is the thing that replaces it.
13
+ *
14
+ * The consequence is the point of the whole exercise: **there is not one line
15
+ * of loop code here.** A delegation is a tool call, so it already goes through
16
+ * the parameter validation, the `onBeforeTool` approval gate, the dedup cache,
17
+ * the control-bus disable, the per-call `tool_call`/`tool_result` pair in the
18
+ * session log, the tool-call budget and the consecutive-failure counter —
19
+ * every one of them, for free, because it is not special. An action in the loop
20
+ * would have had to re-earn each of those, one `if` at a time, in the file
21
+ * every agent rides on.
22
+ *
23
+ * ── What this improves on the lifted original ───────────────────────────────
24
+ *
25
+ * Three things, each of them a rule this repo already pays for elsewhere:
26
+ *
27
+ * 1. **The child's session id is DERIVED, not random.** `parent.d.child`,
28
+ * built from the parent's own id. The original minted a fresh UUID per
29
+ * delegation, which is exactly the thing س٥ named the enemy of replay: an
30
+ * arbitrary identifier kills every comparison that crosses a run — a tape
31
+ * fingerprint, a golden log, a diff of two sessions that should be
32
+ * identical. Derived, the same delegation twice is the same id twice, the
33
+ * log reads `s-42.d.researcher` and says what it is, and the depth is
34
+ * legible in the id instead of held in a side channel.
35
+ *
36
+ * 2. **The agent name is an `enum`, so the model cannot invent one.** The
37
+ * brains forward `enum` into the provider schema (`toolParamsToJsonSchema`),
38
+ * so this is a real constraint at the provider and not a hint. It is still
39
+ * checked at execution — `validateParams` enforces presence and type, never
40
+ * membership — and an unknown name is a NAMED failure the model can read
41
+ * and correct, not a crash.
42
+ *
43
+ * 3. **The cost is visible.** The original returned the child's text and threw
44
+ * the rest away, so a delegating agent's real spend was invisible to
45
+ * everything that watched it. `totalCostUsd` rides back in the result.
46
+ *
47
+ * ── What is deliberately NOT here ───────────────────────────────────────────
48
+ *
49
+ * • **The child's cost is not added to the parent's `totalCostUsd`.** It is
50
+ * reported and left there. Both agents run their own budgets, and a number
51
+ * counted in two places is worse than a number counted in one: it would
52
+ * make the parent's cost cap fire on spend the child already paid for, and
53
+ * the pair would then disagree about what the run cost. Raised for
54
+ * management, not decided here.
55
+ *
56
+ * • **No parent context is forwarded.** The child gets the message and its
57
+ * tenant, and assembles its own persona, memories and tools. Handing it the
58
+ * parent's `AgentContext` would make it a continuation of the parent rather
59
+ * than a second agent — and `ToolMeta` does not carry one anyway.
60
+ */
61
+ /**
62
+ * The tool's name, exported because consumers dispatch on it.
63
+ *
64
+ * An approval hook, an audit trail or a UI badge all need to recognise a
65
+ * delegation by name, and a hand-typed string in each of them is the drift س١
66
+ * was paid for. It is `call_agent` and not something new so that the consumer
67
+ * already running this pattern in production can swap its hand-rolled tool for
68
+ * this factory without changing a prompt, a manifest, or an approval rule.
69
+ */
70
+ export const DELEGATE_TOOL_NAME = "call_agent";
71
+ /**
72
+ * The marker that makes a delegated session id readable and countable.
73
+ *
74
+ * `parent.d.child` — one segment per hop, so the depth is `.d.` counted. It is
75
+ * `.d.` and not a bare `.` because a session id may legitimately contain dots,
76
+ * and a separator that ordinary ids collide with would count hops that never
77
+ * happened.
78
+ */
79
+ const DEPTH_MARKER = ".d.";
80
+ /** Delegation hops already taken to reach this session. A plain session is 0. */
81
+ function delegationDepth(sessionId) {
82
+ return sessionId.split(DEPTH_MARKER).length - 1;
83
+ }
84
+ /** The child's session id: derived from the parent's, one hop deeper. */
85
+ function childSessionId(parentSessionId, agentName) {
86
+ return `${parentSessionId}${DEPTH_MARKER}${agentName}`;
87
+ }
88
+ /** `maxDepth`, coerced. Non-finite or negative garbage falls back to the default. */
89
+ function resolveMaxDepth(value) {
90
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
91
+ return 1;
92
+ return Math.floor(value);
93
+ }
94
+ /**
95
+ * Build the delegation tool for a set of named agents.
96
+ *
97
+ * ```ts
98
+ * const agent = createAgent({
99
+ * definition, brain, redis,
100
+ * tools: [searchTool, createDelegateTool({ researcher, drafter })],
101
+ * });
102
+ * ```
103
+ *
104
+ * The delegates are `mini` agents themselves — whatever `createAgent` returned,
105
+ * or anything else satisfying `Agent`. Nothing is constructed here and no
106
+ * connection is opened: this is a port like every other in the package, handed
107
+ * in at composition time.
108
+ *
109
+ * @param delegates agents this tool may call, by the name the model will use.
110
+ * @param opts depth cap and the approval/metadata stamp.
111
+ */
112
+ export function createDelegateTool(delegates, opts = {}) {
113
+ const names = Object.keys(delegates);
114
+ const maxDepth = resolveMaxDepth(opts.maxDepth);
115
+ const fail = (error) => ({
116
+ tool: DELEGATE_TOOL_NAME,
117
+ status: "failed",
118
+ error,
119
+ });
120
+ return {
121
+ name: DELEGATE_TOOL_NAME,
122
+ description: `Hand a subtask to another agent and get its answer back. ` +
123
+ `Available agents: ${names.join(", ") || "(none)"}.`,
124
+ parameters: {
125
+ agent: {
126
+ type: "string",
127
+ description: `Which agent to ask. One of: ${names.join(", ") || "(none)"}.`,
128
+ required: true,
129
+ // The list the provider itself enforces. It is also re-checked below:
130
+ // `validateParams` checks presence and type, never membership.
131
+ enum: names,
132
+ },
133
+ message: {
134
+ type: "string",
135
+ description: "The task to hand over, stated in full — the other agent sees none of this conversation.",
136
+ required: true,
137
+ },
138
+ },
139
+ ...(opts.requiresApproval !== undefined
140
+ ? { requiresApproval: opts.requiresApproval }
141
+ : {}),
142
+ ...(opts.destructive !== undefined ? { destructive: opts.destructive } : {}),
143
+ ...(opts.category !== undefined ? { category: opts.category } : {}),
144
+ async execute(args, meta) {
145
+ const agentName = String(args["agent"] ?? "");
146
+ const message = String(args["message"] ?? "");
147
+ // ── The depth cap, and it fails CLOSED ───────────────────────────────
148
+ //
149
+ // Checked before the name is even resolved, because the cheapest place to
150
+ // stop a runaway recursion is before it can name its next victim. The
151
+ // answer is a named `failed` result and not a thrown exception: a throw
152
+ // ends the step, while a result is something the model reads, understands
153
+ // and can act on — the same reasoning that makes an MCP `isError` a
154
+ // failed result rather than a throw.
155
+ //
156
+ // Two ways the count can read HIGH — an app whose own session ids contain
157
+ // `.d.`, or a delegate registered under a name containing it — and both
158
+ // err toward refusing to delegate. There is no input that makes it read
159
+ // low, which is the only direction that would matter.
160
+ const depth = delegationDepth(meta.sessionId);
161
+ if (depth >= maxDepth) {
162
+ return fail(`delegation depth exceeded: "${meta.sessionId}" is already ${depth} ` +
163
+ `hop(s) deep and maxDepth is ${maxDepth}`);
164
+ }
165
+ // ── The name, re-checked here and not only in the schema ─────────────
166
+ const child = Object.prototype.hasOwnProperty.call(delegates, agentName)
167
+ ? delegates[agentName]
168
+ : undefined;
169
+ if (!child) {
170
+ return fail(`unknown agent "${agentName}" — available: ${names.join(", ") || "(none)"}`);
171
+ }
172
+ const sessionId = childSessionId(meta.sessionId, agentName);
173
+ // ── The child's event ────────────────────────────────────────────────
174
+ //
175
+ // The tenant rides across unchanged. "Whoever injects a store injects its
176
+ // isolation with it" (س١'s ruling) has a corollary here: a delegation
177
+ // that dropped the tenant would run the child UNSCOPED — writing to the
178
+ // bare prefix — and the isolation covenant would be broken by a tool
179
+ // rather than by a store. It is passed through exactly as received; this
180
+ // tool neither invents a tenant nor widens one.
181
+ const childEvent = {
182
+ sessionId,
183
+ message,
184
+ ...(meta.tenantContext ? { tenantContext: meta.tenantContext } : {}),
185
+ };
186
+ try {
187
+ const outcome = await child.handle(childEvent);
188
+ // `error` is the only outcome type that is a FAILURE of the delegation.
189
+ // `clarify`, `escalate` and a suppressed answer are all things the child
190
+ // successfully decided, and the model needs to see which one it got —
191
+ // hence `outcome` in the payload rather than a flattened string.
192
+ const failed = outcome.type === "error";
193
+ const result = {
194
+ agent: agentName,
195
+ sessionId,
196
+ outcome: outcome.type,
197
+ text: outcome.text ?? "",
198
+ // Reported, never folded into the parent's total. See the header.
199
+ totalCostUsd: outcome.metrics.totalCostUsd,
200
+ };
201
+ return {
202
+ tool: DELEGATE_TOOL_NAME,
203
+ status: failed ? "failed" : "ok",
204
+ result,
205
+ ...(failed
206
+ ? {
207
+ error: outcome.error ??
208
+ outcome.text ??
209
+ `agent "${agentName}" failed`,
210
+ }
211
+ : {}),
212
+ };
213
+ }
214
+ catch (err) {
215
+ // `handle` does its own catching and normally returns an error outcome
216
+ // rather than throwing — but it can still throw before its try block
217
+ // (resolving Redis, acquiring the session lock). One agent failing to
218
+ // start is this tool's failure, not the parent step's.
219
+ return fail(`agent "${agentName}" threw: ${err instanceof Error ? err.message : String(err)}`);
220
+ }
221
+ },
222
+ };
223
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@msm-core/mini",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Portable AI agent execution loop — brain-agnostic, zero embedded databases",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",