@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.
- package/CHANGELOG.md +279 -0
- package/dist/adapters/index.d.ts +12 -2
- package/dist/adapters/index.js +10 -0
- package/dist/adapters/memory-control.d.ts +43 -0
- package/dist/adapters/memory-control.js +56 -0
- package/dist/adapters/memory-dedup.d.ts +36 -0
- package/dist/adapters/memory-dedup.js +59 -0
- package/dist/adapters/memory-lock.d.ts +45 -0
- package/dist/adapters/memory-lock.js +89 -0
- package/dist/adapters/redis-control.d.ts +2 -1
- package/dist/adapters/redis-lock.d.ts +9 -5
- package/dist/brain/anthropic.js +8 -1
- package/dist/brain/gemini.js +15 -2
- package/dist/brain/ollama.js +8 -2
- package/dist/brain/openai.js +9 -2
- package/dist/brain/retry.d.ts +98 -2
- package/dist/brain/retry.js +132 -2
- package/dist/brain/streaming.d.ts +42 -0
- package/dist/brain/streaming.js +44 -1
- package/dist/bridge/pipeline.js +11 -8
- package/dist/core/hooks.d.ts +14 -2
- package/dist/core/hooks.js +20 -3
- package/dist/core/loop.js +148 -23
- package/dist/core/types.d.ts +387 -5
- package/dist/core/types.js +130 -1
- package/dist/definition/skills.d.ts +74 -0
- package/dist/definition/skills.js +119 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +30 -0
- package/dist/tools/dedup.d.ts +22 -1
- package/dist/tools/dedup.js +28 -0
- package/dist/tools/delegate.d.ts +134 -0
- package/dist/tools/delegate.js +223 -0
- package/dist/tools/executor.d.ts +49 -5
- package/dist/tools/executor.js +38 -5
- package/package.json +4 -3
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills — reusable instruction packs an agent loads, "files all the way down."
|
|
3
|
+
*
|
|
4
|
+
* Drop markdown into a folder and each file becomes part of the agent's
|
|
5
|
+
* definition, so the model gains that know-how with no rebuild and no code
|
|
6
|
+
* change. Two shapes are read, and they are the shapes Claude-style skill packs
|
|
7
|
+
* already use:
|
|
8
|
+
*
|
|
9
|
+
* skills/
|
|
10
|
+
* summarizing/SKILL.md ← a folder pack
|
|
11
|
+
* tone-of-voice.md ← a single-file skill
|
|
12
|
+
*
|
|
13
|
+
* `loadSkills(dir)` returns ONE markdown block (`## Skills`), or `""` when
|
|
14
|
+
* there is nothing to load. **Composition stays with the host**: this module
|
|
15
|
+
* reads files and concatenates them; appending the block to a definition is the
|
|
16
|
+
* caller's line of code. The loop is not involved and does not know skills
|
|
17
|
+
* exist — `parseDefinition` has always *stripped* a `## Skills` section as
|
|
18
|
+
* "owned by the app layer", and that stays true.
|
|
19
|
+
*
|
|
20
|
+
* ── Lifted from `nisus/runtime/skills/loader.ts`, hardened where it mattered ──
|
|
21
|
+
*
|
|
22
|
+
* The pattern was right, so it was carried over rather than reinvented. Three
|
|
23
|
+
* things changed, each for a failure mode, and each pinned by a guard in
|
|
24
|
+
* `tests/skills.test.ts` — including a differential test against a verbatim
|
|
25
|
+
* transcription of the original, so any divergence beyond these is a bug:
|
|
26
|
+
*
|
|
27
|
+
* 1. **The order is deterministic.** The original iterated `readdirSync`
|
|
28
|
+
* directly, and that order is filesystem- and platform-dependent (APFS
|
|
29
|
+
* hands back roughly hash order; ext4 differs again). Two machines with
|
|
30
|
+
* the same two skills therefore built two different definitions — the same
|
|
31
|
+
* agent with two fingerprints, which is the enemy of replay
|
|
32
|
+
* (`@msm-core/replay` fingerprints `system_context`) and of any diff a
|
|
33
|
+
* human tries to read. Entries are now sorted by code unit before they are
|
|
34
|
+
* read. **Not** `localeCompare`: that depends on ICU data and the ambient
|
|
35
|
+
* locale, which would reintroduce exactly the nondeterminism being
|
|
36
|
+
* removed.
|
|
37
|
+
*
|
|
38
|
+
* 2. **Diagnostics go to an injected port, never to `console`.** Same reason
|
|
39
|
+
* as `@msm-core/mcp`'s `McpLogPort`: a library that prints is a library you
|
|
40
|
+
* cannot embed. Absent a port, the loader is silent.
|
|
41
|
+
*
|
|
42
|
+
* 3. **An empty skill file contributes nothing instead of a bare separator.**
|
|
43
|
+
* The original pushed `readFileSync(...).trim()` unconditionally, so an
|
|
44
|
+
* empty (or whitespace-only) `.md` injected `"\n\n---\n\n\n\n---\n\n"`
|
|
45
|
+
* into the text the model reads — a horizontal rule with no section under
|
|
46
|
+
* it, mid-definition. It is now skipped, with a warning naming the file,
|
|
47
|
+
* because a file someone left empty is a mistake worth hearing about.
|
|
48
|
+
*
|
|
49
|
+
* What deliberately did NOT change: a folder without a `SKILL.md` is skipped in
|
|
50
|
+
* silence (it is a plain folder, not a failure); a non-`.md` file is ignored;
|
|
51
|
+
* an entry that throws is reported and its siblings still load; and a folder
|
|
52
|
+
* pack and a single-file skill sitting side by side are BOTH read — the folder
|
|
53
|
+
* from its `SKILL.md`, the file from itself. That last one is the precedence
|
|
54
|
+
* rule, and it is a rule rather than an accident because a guard says so.
|
|
55
|
+
*/
|
|
56
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
57
|
+
import { extname, join } from "node:path";
|
|
58
|
+
/** The pack marker inside a skill folder. */
|
|
59
|
+
const PACK_FILE = "SKILL.md";
|
|
60
|
+
/** The heading the block is published under. */
|
|
61
|
+
const BLOCK_HEADING = "## Skills";
|
|
62
|
+
/** What sits between two skills inside the block. */
|
|
63
|
+
const SEPARATOR = "\n\n---\n\n";
|
|
64
|
+
/**
|
|
65
|
+
* Code-unit order: total, locale-independent, and identical on every platform.
|
|
66
|
+
* `Array.prototype.sort()`'s default is also code-unit order, but it gets there
|
|
67
|
+
* by stringifying — spelling it out states the intent that the guard tests.
|
|
68
|
+
*/
|
|
69
|
+
function byCodeUnit(a, b) {
|
|
70
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read every skill under `dir` and return them as one markdown block, or `""`
|
|
74
|
+
* when the directory is missing, is not a directory, or holds no skills.
|
|
75
|
+
*
|
|
76
|
+
* Never throws for a skill it cannot read: the failure is reported on `log` and
|
|
77
|
+
* the remaining skills still load. A definition missing one section is worth
|
|
78
|
+
* more than an agent that will not start.
|
|
79
|
+
*/
|
|
80
|
+
export function loadSkills(dir, log = {}) {
|
|
81
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
82
|
+
return "";
|
|
83
|
+
// Sorted BEFORE anything is read: the order of this array is the order of the
|
|
84
|
+
// block, and the block is part of what the model is asked.
|
|
85
|
+
const entries = [...readdirSync(dir)].sort(byCodeUnit);
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const path = join(dir, entry);
|
|
89
|
+
let text;
|
|
90
|
+
try {
|
|
91
|
+
const stat = statSync(path);
|
|
92
|
+
if (stat.isDirectory()) {
|
|
93
|
+
const pack = join(path, PACK_FILE);
|
|
94
|
+
if (!existsSync(pack))
|
|
95
|
+
continue; // a plain folder, not a skill pack
|
|
96
|
+
text = readFileSync(pack, "utf-8").trim();
|
|
97
|
+
}
|
|
98
|
+
else if (extname(entry).toLowerCase() === ".md") {
|
|
99
|
+
text = readFileSync(path, "utf-8").trim();
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
continue; // not a skill in either shape
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
log.warn?.(`[skills] failed to load ${entry}: ${err.message}`);
|
|
107
|
+
continue; // one unreadable skill must not cost the others
|
|
108
|
+
}
|
|
109
|
+
if (text === "") {
|
|
110
|
+
log.warn?.(`[skills] skipped empty "${entry}"`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
parts.push(text);
|
|
114
|
+
log.info?.(`[skills] loaded "${entry}"`);
|
|
115
|
+
}
|
|
116
|
+
if (parts.length === 0)
|
|
117
|
+
return "";
|
|
118
|
+
return `${BLOCK_HEADING}\n\n${parts.join(SEPARATOR)}`;
|
|
119
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -15,8 +15,14 @@ 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
|
|
18
|
+
export { loadSkills } from "./definition/skills.js";
|
|
19
|
+
export type { SkillsLogPort } from "./definition/skills.js";
|
|
20
|
+
export { createDelegateTool, DELEGATE_TOOL_NAME } from "./tools/delegate.js";
|
|
21
|
+
export type { DelegateTool, DelegateToolOptions } from "./tools/delegate.js";
|
|
22
|
+
export type { Agent, AgentConfig, AgentEvent, AgentContext, AgentDefinition, AgentHooks, BeforeToolHookResult, Brain, BrainChunk, BrainOrchestration, BrainPayload, BrainRunInput, BrainToolCall, ChunkInfo, CompactionDecision, CompactionPort, ControlBusPort, DedupPort, DocumentState, GateConfig, GuardConfig, GuardSignal, GuardSignalType, IterationInfo, LockHandle, LoopOutcome, MemoryEntry, Message, OutcomeType, OutputValidation, OutputValidator, RedisConfig, ResetAwareChunkHook, ResetAwareChunkSink, RunLockPort, RunState, SectionInfo, SessionMetadata, SessionStore, TenantContext, Tool, ToolCallInfo, ToolDefinition, ToolMeta, ToolParameter, ToolResult, } from "./core/types.js";
|
|
19
23
|
export type { ContextBudget } from "./core/context-builder.js";
|
|
24
|
+
export { acceptResets, isResetAware, acceptChunkResets, isResetAwareHook, } from "./core/types.js";
|
|
25
|
+
export { respondingModel } from "./core/types.js";
|
|
20
26
|
export { resolveGuards, DEFAULT_GUARDS } from "./core/guards.js";
|
|
21
27
|
export { scoreOutcome } from "./quality/scorer.js";
|
|
22
28
|
export type { QualityScore, QualityFlag } from "./quality/scorer.js";
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,36 @@ 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
|
+
// ── Skills (م١) — instruction packs read from disk, composed by the host ─────
|
|
22
|
+
//
|
|
23
|
+
// `loadSkills(dir)` returns one `## Skills` markdown block; appending it to a
|
|
24
|
+
// definition is the composition root's line of code, not the loop's.
|
|
25
|
+
export { loadSkills } from "./definition/skills.js";
|
|
26
|
+
// ── Delegation (ر٢) — one agent asks another, as an ordinary tool ───────────
|
|
27
|
+
export { createDelegateTool, DELEGATE_TOOL_NAME } from "./tools/delegate.js";
|
|
28
|
+
// ── Streaming: opting in to a reset (ص٢/٢ · ص٣/٢) ───────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// Two doors onto the same opt-in, one per altitude.
|
|
31
|
+
//
|
|
32
|
+
// • A consumer that drives a brain DIRECTLY wraps its chunk sink in
|
|
33
|
+
// `acceptResets` (ص٢/٢) and stops losing the tail of a retried answer.
|
|
34
|
+
// • A consumer that runs an AGENT wraps its `AgentHooks.onChunk` in
|
|
35
|
+
// `acceptChunkResets` (ص٣/٢), and the loop declares to the brain on its
|
|
36
|
+
// behalf; `ChunkInfo.reset` then arrives on the first chunk of a retried
|
|
37
|
+
// attempt. This is the door most people want — `onChunk` is where an SSE
|
|
38
|
+
// channel is actually wired.
|
|
39
|
+
//
|
|
40
|
+
// Without a wrapper nothing changes anywhere: no reset is produced, and the
|
|
41
|
+
// object delivered to `onChunk` keeps the exact three keys it has always had.
|
|
42
|
+
export { acceptResets, isResetAware, acceptChunkResets, isResetAwareHook, } from "./core/types.js";
|
|
43
|
+
// ── Who answered (س٦/٤) ─────────────────────────────────────────────────────
|
|
44
|
+
//
|
|
45
|
+
// The reading the four bundled brains use to fill `BrainPayload.model` from
|
|
46
|
+
// their provider's reply — exported so a custom brain fills it the same way
|
|
47
|
+
// instead of inventing a second reading that drifts. Hand it whatever the
|
|
48
|
+
// provider returned; it yields `{ model }` when the reply named one and `{}`
|
|
49
|
+
// when it did not, ready to spread into the payload.
|
|
50
|
+
export { respondingModel } from "./core/types.js";
|
|
21
51
|
// ── Guard utilities ─────────────────────────────────────────────────────────
|
|
22
52
|
export { resolveGuards, DEFAULT_GUARDS } from "./core/guards.js";
|
|
23
53
|
// ── Quality scorer ──────────────────────────────────────────────────────────
|
package/dist/tools/dedup.d.ts
CHANGED
|
@@ -5,12 +5,33 @@
|
|
|
5
5
|
* Stored in Redis as: {prefix}:session:{id}:tools:dedup hash field
|
|
6
6
|
* TTL: 5 minutes (configurable)
|
|
7
7
|
*/
|
|
8
|
-
import type { ToolResult } from "../core/types.js";
|
|
8
|
+
import type { DedupPort, ToolResult } from "../core/types.js";
|
|
9
9
|
type RedisLike = {
|
|
10
10
|
hget(key: string, field: string): Promise<string | null>;
|
|
11
11
|
hset(key: string, field: string, value: string): Promise<unknown>;
|
|
12
12
|
expire(key: string, seconds: number): Promise<unknown>;
|
|
13
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* The bundled Redis-backed `DedupPort` — the three functions below, behind the
|
|
16
|
+
* port the executor now takes.
|
|
17
|
+
*
|
|
18
|
+
* It adds nothing: `check`/`store` are `checkDedup`/`storeDedup` with the key
|
|
19
|
+
* built by `toolDedupKey` from the prefix this was constructed with, which is
|
|
20
|
+
* precisely what the executor used to do inline with a raw client. The class
|
|
21
|
+
* exists so that the executor talks to ONE thing whether the dedup came from a
|
|
22
|
+
* config or from Redis, and so the Redis path keeps its exact key shape and TTL
|
|
23
|
+
* behaviour while doing so.
|
|
24
|
+
*
|
|
25
|
+
* The prefix is the tenant-scoped one when the loop builds this
|
|
26
|
+
* (`{prefix}:{companyId}:{agentType}`); an injected port scopes itself.
|
|
27
|
+
*/
|
|
28
|
+
export declare class RedisToolDedup implements DedupPort {
|
|
29
|
+
private readonly redis;
|
|
30
|
+
private readonly prefix;
|
|
31
|
+
constructor(redis: RedisLike, prefix: string);
|
|
32
|
+
check(sessionId: string, hash: string): Promise<ToolResult | null>;
|
|
33
|
+
store(sessionId: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
|
|
34
|
+
}
|
|
14
35
|
export declare function toolDedupKey(prefix: string, sessionId: string): string;
|
|
15
36
|
export declare function checkDedup(redis: RedisLike, dedupKey: string, hash: string): Promise<ToolResult | null>;
|
|
16
37
|
export declare function storeDedup(redis: RedisLike, dedupKey: string, hash: string, result: ToolResult, ttlSeconds: number): Promise<void>;
|
package/dist/tools/dedup.js
CHANGED
|
@@ -6,6 +6,34 @@
|
|
|
6
6
|
* TTL: 5 minutes (configurable)
|
|
7
7
|
*/
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
|
+
/**
|
|
10
|
+
* The bundled Redis-backed `DedupPort` — the three functions below, behind the
|
|
11
|
+
* port the executor now takes.
|
|
12
|
+
*
|
|
13
|
+
* It adds nothing: `check`/`store` are `checkDedup`/`storeDedup` with the key
|
|
14
|
+
* built by `toolDedupKey` from the prefix this was constructed with, which is
|
|
15
|
+
* precisely what the executor used to do inline with a raw client. The class
|
|
16
|
+
* exists so that the executor talks to ONE thing whether the dedup came from a
|
|
17
|
+
* config or from Redis, and so the Redis path keeps its exact key shape and TTL
|
|
18
|
+
* behaviour while doing so.
|
|
19
|
+
*
|
|
20
|
+
* The prefix is the tenant-scoped one when the loop builds this
|
|
21
|
+
* (`{prefix}:{companyId}:{agentType}`); an injected port scopes itself.
|
|
22
|
+
*/
|
|
23
|
+
export class RedisToolDedup {
|
|
24
|
+
redis;
|
|
25
|
+
prefix;
|
|
26
|
+
constructor(redis, prefix) {
|
|
27
|
+
this.redis = redis;
|
|
28
|
+
this.prefix = prefix;
|
|
29
|
+
}
|
|
30
|
+
async check(sessionId, hash) {
|
|
31
|
+
return checkDedup(this.redis, toolDedupKey(this.prefix, sessionId), hash);
|
|
32
|
+
}
|
|
33
|
+
async store(sessionId, hash, result, ttlSeconds) {
|
|
34
|
+
await storeDedup(this.redis, toolDedupKey(this.prefix, sessionId), hash, result, ttlSeconds);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
9
37
|
export function toolDedupKey(prefix, sessionId) {
|
|
10
38
|
return `${prefix}:session:${sessionId}:tools:dedup`;
|
|
11
39
|
}
|
|
@@ -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
|
+
}
|