@crewhaus/ir-passes 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/index.d.ts +99 -0
- package/dist/index.js +375 -0
- package/package.json +10 -7
- package/src/index.test.ts +0 -380
- package/src/index.ts +0 -441
- package/src/wellformedness.test.ts +0 -118
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 28 — `ir-passes`. Idempotent IR optimization passes; each pass
|
|
3
|
+
* is a pure `(IrNode) → IrNode` function. The pipeline is composable and
|
|
4
|
+
* deterministic: same input → same output regardless of pass order
|
|
5
|
+
* (within the published "safe order" — see `applyPasses`).
|
|
6
|
+
*
|
|
7
|
+
* Built-in passes:
|
|
8
|
+
* - `deadToolElimination` — drop entries from `tools` that no sub-agent
|
|
9
|
+
* or permission rule references. Catches the common case where a spec
|
|
10
|
+
* declares `tools: [Read, Write, Bash]` but only ever references some of
|
|
11
|
+
* them — e.g. an `alwaysDeny` rule naming `Write` actually counts as a
|
|
12
|
+
* reference and KEEPS `Write` in the list; only a tool that no rule and
|
|
13
|
+
* no sub-agent mentions at all (say `Bash` here) gets dropped.
|
|
14
|
+
* - `redundantMcpServerCollapse` — dedup `mcp_servers` map entries by
|
|
15
|
+
* `(transport, command, args)` signature so two specs that import the
|
|
16
|
+
* same server under different keys collapse into one boot per process.
|
|
17
|
+
* - `permissionRuleCanonicalize` — sort + dedup `permissions.rules` by
|
|
18
|
+
* canonical (type, pattern) tuples; preserves source priority order
|
|
19
|
+
* (alwaysDeny > alwaysAsk > alwaysAllow) but de-dupes identical entries.
|
|
20
|
+
* - `transactionPolicyEnforcement` — §47 validating pass: checks the
|
|
21
|
+
* blockchain blocks (wallets/contracts/chains/transaction_policy) for
|
|
22
|
+
* referential integrity and throws `IrPassError` on a mismatch.
|
|
23
|
+
* - `wellFormednessCheck` — Track F validating pass: checks typed
|
|
24
|
+
* multi-agent graphs/crews (edges connect declared nodes, reachability
|
|
25
|
+
* from entry, schema/role references resolve) and throws on a violation.
|
|
26
|
+
* - `promptCachePrefixSort` — TODO: re-orders system-block segments so
|
|
27
|
+
* the cache prefix is maximised. v0 stub returns IR unchanged so the
|
|
28
|
+
* pipeline contract holds; v1 follow-up wires this once we land
|
|
29
|
+
* multi-block system prompts in IR.
|
|
30
|
+
*
|
|
31
|
+
* Pipeline order in `applyPasses` (the safe default):
|
|
32
|
+
* deadToolElimination → redundantMcpServerCollapse →
|
|
33
|
+
* permissionRuleCanonicalize → transactionPolicyEnforcement →
|
|
34
|
+
* wellFormednessCheck → promptCachePrefixSort
|
|
35
|
+
*/
|
|
36
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
37
|
+
import type { IrNode } from "@crewhaus/ir";
|
|
38
|
+
export declare class IrPassError extends CrewhausError {
|
|
39
|
+
readonly name = "IrPassError";
|
|
40
|
+
constructor(message: string, cause?: unknown);
|
|
41
|
+
}
|
|
42
|
+
export type IrPass = (ir: IrNode) => IrNode;
|
|
43
|
+
export type ApplyPassesOptions = {
|
|
44
|
+
/** Override the pass order. Default: safe order. */
|
|
45
|
+
readonly passes?: ReadonlyArray<IrPass>;
|
|
46
|
+
};
|
|
47
|
+
/** Apply every pass in order; returns the final IR. */
|
|
48
|
+
export declare function applyPasses(ir: IrNode, opts?: ApplyPassesOptions): IrNode;
|
|
49
|
+
/**
|
|
50
|
+
* Pass 1 — drop tools that no permission rule and no sub-agent references.
|
|
51
|
+
* v0 only inspects `IrV0` (the cli target) since that's where tools[] +
|
|
52
|
+
* sub_agents + permissions live in the same shape. Other targets either
|
|
53
|
+
* don't carry `tools[]` (workflow uses per-step tools) or aren't v0-IR
|
|
54
|
+
* subjects of dead-tool elimination yet (graph nodes carry their own
|
|
55
|
+
* tool sets — handled by a follow-up pass).
|
|
56
|
+
*/
|
|
57
|
+
export declare function deadToolElimination(ir: IrNode): IrNode;
|
|
58
|
+
/**
|
|
59
|
+
* Pass 2 — collapse `mcp_servers` entries that share `(transport, command,
|
|
60
|
+
* args)` (stdio) or `(transport, url)` (sse). The first key wins on
|
|
61
|
+
* collision; later duplicates are dropped. Order is preserved for entries
|
|
62
|
+
* that survive.
|
|
63
|
+
*/
|
|
64
|
+
export declare function redundantMcpServerCollapse(ir: IrNode): IrNode;
|
|
65
|
+
/**
|
|
66
|
+
* Pass 3 — sort + dedup permission rules. Within each precedence tier
|
|
67
|
+
* (alwaysDeny > alwaysAsk > alwaysAllow), rules are sorted alphabetically
|
|
68
|
+
* by pattern and exact duplicates dropped.
|
|
69
|
+
*/
|
|
70
|
+
export declare function permissionRuleCanonicalize(ir: IrNode): IrNode;
|
|
71
|
+
/**
|
|
72
|
+
* Pass 4 — re-order system-block segments to maximise the prompt-cache
|
|
73
|
+
* prefix. v0 stub: IR carries the system prompt as a single string, so
|
|
74
|
+
* there's no segmentation to reorder yet. Returns input unchanged.
|
|
75
|
+
* The placeholder keeps the pipeline contract stable; once IR carries
|
|
76
|
+
* multi-block system prompts (Section 31's Studio v1 paths), the real
|
|
77
|
+
* impl lands here.
|
|
78
|
+
*/
|
|
79
|
+
export declare function promptCachePrefixSort(ir: IrNode): IrNode;
|
|
80
|
+
export declare function transactionPolicyEnforcement(ir: IrNode): IrNode;
|
|
81
|
+
/**
|
|
82
|
+
* Track F (Section 57) — well-formedness check for typed multi-agent
|
|
83
|
+
* graphs. Source: AgentFlow (arxiv 2604.20801). Before any candidate
|
|
84
|
+
* harness is sent for expensive LLM evaluation, this pass:
|
|
85
|
+
*
|
|
86
|
+
* 1. Verifies every graph edge connects declared nodes.
|
|
87
|
+
* 2. Verifies the graph is connected (every node reachable from entry).
|
|
88
|
+
* 3. Verifies every edge's `schema` either is `untyped` or resolves
|
|
89
|
+
* to a declared `messageSchemas` entry.
|
|
90
|
+
* 4. For crews: verifies routing.match targets all reference declared
|
|
91
|
+
* roles (a subset of what `parseSpec` does; we re-check here so
|
|
92
|
+
* `applyPasses(ir)` is safe to call standalone).
|
|
93
|
+
*
|
|
94
|
+
* Failing this check is fast and cheap, which means the search budget
|
|
95
|
+
* for upstream optimizers (Tracks D, E) goes to well-formed harnesses
|
|
96
|
+
* only. Cited paper: AgentFlow (arxiv 2604.20801).
|
|
97
|
+
*/
|
|
98
|
+
export declare function wellFormednessCheck(ir: IrNode): IrNode;
|
|
99
|
+
export declare const DEFAULT_PIPELINE: ReadonlyArray<IrPass>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section 28 — `ir-passes`. Idempotent IR optimization passes; each pass
|
|
3
|
+
* is a pure `(IrNode) → IrNode` function. The pipeline is composable and
|
|
4
|
+
* deterministic: same input → same output regardless of pass order
|
|
5
|
+
* (within the published "safe order" — see `applyPasses`).
|
|
6
|
+
*
|
|
7
|
+
* Built-in passes:
|
|
8
|
+
* - `deadToolElimination` — drop entries from `tools` that no sub-agent
|
|
9
|
+
* or permission rule references. Catches the common case where a spec
|
|
10
|
+
* declares `tools: [Read, Write, Bash]` but only ever references some of
|
|
11
|
+
* them — e.g. an `alwaysDeny` rule naming `Write` actually counts as a
|
|
12
|
+
* reference and KEEPS `Write` in the list; only a tool that no rule and
|
|
13
|
+
* no sub-agent mentions at all (say `Bash` here) gets dropped.
|
|
14
|
+
* - `redundantMcpServerCollapse` — dedup `mcp_servers` map entries by
|
|
15
|
+
* `(transport, command, args)` signature so two specs that import the
|
|
16
|
+
* same server under different keys collapse into one boot per process.
|
|
17
|
+
* - `permissionRuleCanonicalize` — sort + dedup `permissions.rules` by
|
|
18
|
+
* canonical (type, pattern) tuples; preserves source priority order
|
|
19
|
+
* (alwaysDeny > alwaysAsk > alwaysAllow) but de-dupes identical entries.
|
|
20
|
+
* - `transactionPolicyEnforcement` — §47 validating pass: checks the
|
|
21
|
+
* blockchain blocks (wallets/contracts/chains/transaction_policy) for
|
|
22
|
+
* referential integrity and throws `IrPassError` on a mismatch.
|
|
23
|
+
* - `wellFormednessCheck` — Track F validating pass: checks typed
|
|
24
|
+
* multi-agent graphs/crews (edges connect declared nodes, reachability
|
|
25
|
+
* from entry, schema/role references resolve) and throws on a violation.
|
|
26
|
+
* - `promptCachePrefixSort` — TODO: re-orders system-block segments so
|
|
27
|
+
* the cache prefix is maximised. v0 stub returns IR unchanged so the
|
|
28
|
+
* pipeline contract holds; v1 follow-up wires this once we land
|
|
29
|
+
* multi-block system prompts in IR.
|
|
30
|
+
*
|
|
31
|
+
* Pipeline order in `applyPasses` (the safe default):
|
|
32
|
+
* deadToolElimination → redundantMcpServerCollapse →
|
|
33
|
+
* permissionRuleCanonicalize → transactionPolicyEnforcement →
|
|
34
|
+
* wellFormednessCheck → promptCachePrefixSort
|
|
35
|
+
*/
|
|
36
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
37
|
+
export class IrPassError extends CrewhausError {
|
|
38
|
+
name = "IrPassError";
|
|
39
|
+
constructor(message, cause) {
|
|
40
|
+
super("compiler", message, cause);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Apply every pass in order; returns the final IR. */
|
|
44
|
+
export function applyPasses(ir, opts = {}) {
|
|
45
|
+
const pipeline = opts.passes ?? DEFAULT_PIPELINE;
|
|
46
|
+
let current = ir;
|
|
47
|
+
for (const pass of pipeline) {
|
|
48
|
+
current = pass(current);
|
|
49
|
+
}
|
|
50
|
+
return current;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pass 1 — drop tools that no permission rule and no sub-agent references.
|
|
54
|
+
* v0 only inspects `IrV0` (the cli target) since that's where tools[] +
|
|
55
|
+
* sub_agents + permissions live in the same shape. Other targets either
|
|
56
|
+
* don't carry `tools[]` (workflow uses per-step tools) or aren't v0-IR
|
|
57
|
+
* subjects of dead-tool elimination yet (graph nodes carry their own
|
|
58
|
+
* tool sets — handled by a follow-up pass).
|
|
59
|
+
*/
|
|
60
|
+
export function deadToolElimination(ir) {
|
|
61
|
+
if (ir.target !== "cli")
|
|
62
|
+
return ir;
|
|
63
|
+
const cli = ir;
|
|
64
|
+
const tools = cli.tools ?? [];
|
|
65
|
+
if (tools.length === 0)
|
|
66
|
+
return ir;
|
|
67
|
+
// Track which tool names have any reachable use site.
|
|
68
|
+
const used = new Set();
|
|
69
|
+
// Permission rules can reference a specific tool name (e.g. "Bash" or
|
|
70
|
+
// "Bash(rm *)"). The matcher's compilePattern parses this as the tool
|
|
71
|
+
// followed by an optional invocation pattern in parens. We look at the
|
|
72
|
+
// characters before the first `(` or `:` to extract the tool name.
|
|
73
|
+
const ruleNames = (cli.permissions?.rules ?? []).map((r) => extractToolFromPattern(r.pattern));
|
|
74
|
+
for (const n of ruleNames)
|
|
75
|
+
if (n)
|
|
76
|
+
used.add(n);
|
|
77
|
+
// Sub-agents inherit a subset of the parent's tools — surface them.
|
|
78
|
+
const subAgentRefs = cli.subAgents ?? [];
|
|
79
|
+
for (const sa of subAgentRefs) {
|
|
80
|
+
for (const t of sa.tools)
|
|
81
|
+
used.add(t);
|
|
82
|
+
}
|
|
83
|
+
// Always-allow defaults: if any rule references a tool by exact name we
|
|
84
|
+
// count it; otherwise the original tool list serves as the
|
|
85
|
+
// "implicitly used" baseline. We use case-insensitive comparison since
|
|
86
|
+
// tool registration uses the lowercase variant.
|
|
87
|
+
const filtered = tools.filter((t) => used.has(t) ||
|
|
88
|
+
used.has(t.toLowerCase()) ||
|
|
89
|
+
[...used].some((u) => u.toLowerCase() === t.toLowerCase()));
|
|
90
|
+
// If no reference exists at all (rules + sub-agents both empty), return
|
|
91
|
+
// input unchanged — eliminating every tool would be wrong.
|
|
92
|
+
if (used.size === 0)
|
|
93
|
+
return ir;
|
|
94
|
+
if (filtered.length === tools.length)
|
|
95
|
+
return ir;
|
|
96
|
+
return { ...cli, tools: Object.freeze(filtered) };
|
|
97
|
+
}
|
|
98
|
+
function extractToolFromPattern(pattern) {
|
|
99
|
+
if (!pattern)
|
|
100
|
+
return undefined;
|
|
101
|
+
// Strip a `(...)` invocation suffix: "Bash(rm *)" → "Bash".
|
|
102
|
+
const parenIdx = pattern.indexOf("(");
|
|
103
|
+
const head = (parenIdx === -1 ? pattern : pattern.slice(0, parenIdx)).trim();
|
|
104
|
+
if (!head)
|
|
105
|
+
return undefined;
|
|
106
|
+
return head;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Pass 2 — collapse `mcp_servers` entries that share `(transport, command,
|
|
110
|
+
* args)` (stdio) or `(transport, url)` (sse). The first key wins on
|
|
111
|
+
* collision; later duplicates are dropped. Order is preserved for entries
|
|
112
|
+
* that survive.
|
|
113
|
+
*/
|
|
114
|
+
export function redundantMcpServerCollapse(ir) {
|
|
115
|
+
// mcp_servers lives on cli, channel, managed
|
|
116
|
+
const carriesMcp = (n) => n.target === "cli" || n.target === "channel" || n.target === "managed";
|
|
117
|
+
if (!carriesMcp(ir))
|
|
118
|
+
return ir;
|
|
119
|
+
const ms = ir.mcp_servers;
|
|
120
|
+
if (!ms || Object.keys(ms).length < 2)
|
|
121
|
+
return ir;
|
|
122
|
+
const sigToFirstKey = new Map();
|
|
123
|
+
const keptOrdered = [];
|
|
124
|
+
for (const [k, v] of Object.entries(ms)) {
|
|
125
|
+
const sig = mcpSignature(v);
|
|
126
|
+
if (sigToFirstKey.has(sig))
|
|
127
|
+
continue;
|
|
128
|
+
sigToFirstKey.set(sig, k);
|
|
129
|
+
keptOrdered.push([k, v]);
|
|
130
|
+
}
|
|
131
|
+
if (keptOrdered.length === Object.keys(ms).length)
|
|
132
|
+
return ir;
|
|
133
|
+
const next = {};
|
|
134
|
+
for (const [k, v] of keptOrdered)
|
|
135
|
+
next[k] = v;
|
|
136
|
+
return { ...ir, mcp_servers: Object.freeze(next) };
|
|
137
|
+
}
|
|
138
|
+
function mcpSignature(c) {
|
|
139
|
+
if (c.transport === "stdio") {
|
|
140
|
+
return `stdio|${c.command}|${(c.args ?? []).join(" ")}`;
|
|
141
|
+
}
|
|
142
|
+
return `sse|${c.url}`;
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Pass 3 — sort + dedup permission rules. Within each precedence tier
|
|
146
|
+
* (alwaysDeny > alwaysAsk > alwaysAllow), rules are sorted alphabetically
|
|
147
|
+
* by pattern and exact duplicates dropped.
|
|
148
|
+
*/
|
|
149
|
+
export function permissionRuleCanonicalize(ir) {
|
|
150
|
+
const carriesPerms = (n) => n.target === "cli" || n.target === "channel" || n.target === "managed";
|
|
151
|
+
if (!carriesPerms(ir))
|
|
152
|
+
return ir;
|
|
153
|
+
const perms = ir.permissions;
|
|
154
|
+
if (!perms)
|
|
155
|
+
return ir;
|
|
156
|
+
const tier = (t) => t === "alwaysDeny" ? 0 : t === "alwaysAsk" ? 1 : 2;
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const sorted = [...perms.rules]
|
|
159
|
+
.map((r) => ({ r, key: `${r.type}:${r.pattern}` }))
|
|
160
|
+
.filter(({ key }) => {
|
|
161
|
+
if (seen.has(key))
|
|
162
|
+
return false;
|
|
163
|
+
seen.add(key);
|
|
164
|
+
return true;
|
|
165
|
+
})
|
|
166
|
+
.sort((a, b) => {
|
|
167
|
+
const ta = tier(a.r.type);
|
|
168
|
+
const tb = tier(b.r.type);
|
|
169
|
+
if (ta !== tb)
|
|
170
|
+
return ta - tb;
|
|
171
|
+
return a.r.pattern.localeCompare(b.r.pattern);
|
|
172
|
+
})
|
|
173
|
+
.map(({ r }) => r);
|
|
174
|
+
if (sorted.length === perms.rules.length) {
|
|
175
|
+
let identical = true;
|
|
176
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
177
|
+
if (sorted[i] !== perms.rules[i]) {
|
|
178
|
+
identical = false;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (identical)
|
|
183
|
+
return ir;
|
|
184
|
+
}
|
|
185
|
+
const newPerms = {
|
|
186
|
+
...perms,
|
|
187
|
+
rules: Object.freeze(sorted),
|
|
188
|
+
};
|
|
189
|
+
return { ...ir, permissions: newPerms };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Pass 4 — re-order system-block segments to maximise the prompt-cache
|
|
193
|
+
* prefix. v0 stub: IR carries the system prompt as a single string, so
|
|
194
|
+
* there's no segmentation to reorder yet. Returns input unchanged.
|
|
195
|
+
* The placeholder keeps the pipeline contract stable; once IR carries
|
|
196
|
+
* multi-block system prompts (Section 31's Studio v1 paths), the real
|
|
197
|
+
* impl lands here.
|
|
198
|
+
*/
|
|
199
|
+
export function promptCachePrefixSort(ir) {
|
|
200
|
+
return ir;
|
|
201
|
+
}
|
|
202
|
+
function carriesChainSubsystem(ir) {
|
|
203
|
+
const t = ir.target;
|
|
204
|
+
return (t === "cli" ||
|
|
205
|
+
t === "workflow" ||
|
|
206
|
+
t === "channel" ||
|
|
207
|
+
t === "graph" ||
|
|
208
|
+
t === "crew" ||
|
|
209
|
+
t === "research" ||
|
|
210
|
+
t === "batch");
|
|
211
|
+
}
|
|
212
|
+
export function transactionPolicyEnforcement(ir) {
|
|
213
|
+
if (!carriesChainSubsystem(ir))
|
|
214
|
+
return ir;
|
|
215
|
+
const chains = ir.chains;
|
|
216
|
+
const wallets = ir.wallets;
|
|
217
|
+
const contracts = ir.contracts;
|
|
218
|
+
const policy = ir.transactionPolicy;
|
|
219
|
+
// Empty subsystem is a no-op (existing specs untouched).
|
|
220
|
+
if ((chains === undefined || chains.length === 0) &&
|
|
221
|
+
(wallets === undefined || wallets.length === 0) &&
|
|
222
|
+
(contracts === undefined || contracts.length === 0) &&
|
|
223
|
+
policy === undefined) {
|
|
224
|
+
return ir;
|
|
225
|
+
}
|
|
226
|
+
const chainIds = new Set((chains ?? []).map((c) => c.id));
|
|
227
|
+
const contractIds = new Set((contracts ?? []).map((c) => c.id));
|
|
228
|
+
// chains[] uniqueness
|
|
229
|
+
if (chains !== undefined) {
|
|
230
|
+
const seen = new Set();
|
|
231
|
+
for (const c of chains) {
|
|
232
|
+
if (seen.has(c.id)) {
|
|
233
|
+
throw new IrPassError(`duplicate chains[].id "${c.id}"`);
|
|
234
|
+
}
|
|
235
|
+
seen.add(c.id);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// wallets[*].chainId ⊆ chains[].id; keyRef required for non-user-controlled
|
|
239
|
+
if (wallets !== undefined) {
|
|
240
|
+
const seen = new Set();
|
|
241
|
+
for (const w of wallets) {
|
|
242
|
+
if (seen.has(w.id)) {
|
|
243
|
+
throw new IrPassError(`duplicate wallets[].id "${w.id}"`);
|
|
244
|
+
}
|
|
245
|
+
seen.add(w.id);
|
|
246
|
+
if (!chainIds.has(w.chainId)) {
|
|
247
|
+
throw new IrPassError(`wallets[].id "${w.id}" references chainId "${w.chainId}" which is not declared in chains[]`);
|
|
248
|
+
}
|
|
249
|
+
if (w.custody !== "user-controlled" && w.keyRef === undefined) {
|
|
250
|
+
throw new IrPassError(`wallets[].id "${w.id}" has custody="${w.custody}" but no keyRef; kms/hsm/local custody requires keyRef`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// contracts[*].chainId ⊆ chains[].id
|
|
255
|
+
if (contracts !== undefined) {
|
|
256
|
+
const seen = new Set();
|
|
257
|
+
for (const c of contracts) {
|
|
258
|
+
if (seen.has(c.id)) {
|
|
259
|
+
throw new IrPassError(`duplicate contracts[].id "${c.id}"`);
|
|
260
|
+
}
|
|
261
|
+
seen.add(c.id);
|
|
262
|
+
if (!chainIds.has(c.chainId)) {
|
|
263
|
+
throw new IrPassError(`contracts[].id "${c.id}" references chainId "${c.chainId}" which is not declared in chains[]`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// transaction_policy.allowed_contracts ⊆ contracts[].id
|
|
268
|
+
if (policy !== undefined) {
|
|
269
|
+
for (const cid of policy.allowedContracts) {
|
|
270
|
+
if (!contractIds.has(cid)) {
|
|
271
|
+
throw new IrPassError(`transaction_policy.allowedContracts entry "${cid}" is not a declared contracts[].id`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (policy.defaultWriteApproval === "none") {
|
|
275
|
+
const allAutomated = (wallets ?? []).every((w) => w.signingPolicy === "automated");
|
|
276
|
+
if (!allAutomated) {
|
|
277
|
+
throw new IrPassError('transaction_policy.defaultWriteApproval="none" requires every wallet to have signingPolicy="automated"');
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// No structural rewrite — the pass validates and passes through.
|
|
282
|
+
return ir;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Track F (Section 57) — well-formedness check for typed multi-agent
|
|
286
|
+
* graphs. Source: AgentFlow (arxiv 2604.20801). Before any candidate
|
|
287
|
+
* harness is sent for expensive LLM evaluation, this pass:
|
|
288
|
+
*
|
|
289
|
+
* 1. Verifies every graph edge connects declared nodes.
|
|
290
|
+
* 2. Verifies the graph is connected (every node reachable from entry).
|
|
291
|
+
* 3. Verifies every edge's `schema` either is `untyped` or resolves
|
|
292
|
+
* to a declared `messageSchemas` entry.
|
|
293
|
+
* 4. For crews: verifies routing.match targets all reference declared
|
|
294
|
+
* roles (a subset of what `parseSpec` does; we re-check here so
|
|
295
|
+
* `applyPasses(ir)` is safe to call standalone).
|
|
296
|
+
*
|
|
297
|
+
* Failing this check is fast and cheap, which means the search budget
|
|
298
|
+
* for upstream optimizers (Tracks D, E) goes to well-formed harnesses
|
|
299
|
+
* only. Cited paper: AgentFlow (arxiv 2604.20801).
|
|
300
|
+
*/
|
|
301
|
+
export function wellFormednessCheck(ir) {
|
|
302
|
+
if (ir.target === "graph") {
|
|
303
|
+
const g = ir;
|
|
304
|
+
const nodeNames = new Set(g.nodes.map((n) => n.name));
|
|
305
|
+
if (!nodeNames.has(g.entry)) {
|
|
306
|
+
throw new IrPassError(`graph entry "${g.entry}" is not a declared node (nodes: ${[...nodeNames].join(", ")})`);
|
|
307
|
+
}
|
|
308
|
+
const schemaNames = new Set((g.messageSchemas ?? []).map((s) => s.name));
|
|
309
|
+
for (const e of g.edges) {
|
|
310
|
+
if (!nodeNames.has(e.from)) {
|
|
311
|
+
throw new IrPassError(`graph edge from "${e.from}" references an undeclared node`);
|
|
312
|
+
}
|
|
313
|
+
if (!nodeNames.has(e.to)) {
|
|
314
|
+
throw new IrPassError(`graph edge to "${e.to}" references an undeclared node`);
|
|
315
|
+
}
|
|
316
|
+
if (e.schema !== undefined && e.schema.kind === "named") {
|
|
317
|
+
if (!schemaNames.has(e.schema.name)) {
|
|
318
|
+
throw new IrPassError(`graph edge ${e.from}→${e.to} references undeclared message schema "${e.schema.name}"`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Reachability from entry.
|
|
323
|
+
const reachable = new Set([g.entry]);
|
|
324
|
+
let added = true;
|
|
325
|
+
while (added) {
|
|
326
|
+
added = false;
|
|
327
|
+
for (const e of g.edges) {
|
|
328
|
+
if (reachable.has(e.from) && !reachable.has(e.to)) {
|
|
329
|
+
reachable.add(e.to);
|
|
330
|
+
added = true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const n of g.nodes) {
|
|
335
|
+
if (!reachable.has(n.name)) {
|
|
336
|
+
throw new IrPassError(`graph node "${n.name}" is unreachable from entry "${g.entry}"`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else if (ir.target === "crew") {
|
|
341
|
+
const c = ir;
|
|
342
|
+
const roleNames = new Set(c.roles.map((r) => r.name));
|
|
343
|
+
if (!roleNames.has(c.entry)) {
|
|
344
|
+
throw new IrPassError(`crew entry "${c.entry}" is not a declared role (roles: ${[...roleNames].join(", ")})`);
|
|
345
|
+
}
|
|
346
|
+
const schemaNames = new Set((c.messageSchemas ?? []).map((s) => s.name));
|
|
347
|
+
if (c.routing?.kind === "match" && c.routing.match !== undefined) {
|
|
348
|
+
for (const [from, rules] of Object.entries(c.routing.match)) {
|
|
349
|
+
if (!roleNames.has(from)) {
|
|
350
|
+
throw new IrPassError(`crew routing.match["${from}"]: source role not declared`);
|
|
351
|
+
}
|
|
352
|
+
for (const rule of rules) {
|
|
353
|
+
if (!roleNames.has(rule.to)) {
|
|
354
|
+
throw new IrPassError(`crew routing.match["${from}"].to "${rule.to}": target role not declared`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// Schema declarations exist (they're optional but if present they must
|
|
360
|
+
// be uniquely named). This catches the easy authoring bug of declaring
|
|
361
|
+
// two schemas with the same name.
|
|
362
|
+
if (schemaNames.size !== (c.messageSchemas ?? []).length) {
|
|
363
|
+
throw new IrPassError("crew messageSchemas contains duplicate names");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return ir;
|
|
367
|
+
}
|
|
368
|
+
export const DEFAULT_PIPELINE = Object.freeze([
|
|
369
|
+
deadToolElimination,
|
|
370
|
+
redundantMcpServerCollapse,
|
|
371
|
+
permissionRuleCanonicalize,
|
|
372
|
+
transactionPolicyEnforcement,
|
|
373
|
+
wellFormednessCheck,
|
|
374
|
+
promptCachePrefixSort,
|
|
375
|
+
]);
|
package/package.json
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/ir-passes",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Idempotent IR optimization passes (dead-tool-elimination, prompt-cache-prefix-sort, redundant-mcp-server-collapse, permission-rule-canonicalize)",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
16
|
-
"@crewhaus/ir": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.6",
|
|
19
|
+
"@crewhaus/ir": "0.1.6"
|
|
17
20
|
},
|
|
18
21
|
"license": "Apache-2.0",
|
|
19
22
|
"author": {
|
|
@@ -33,5 +36,5 @@
|
|
|
33
36
|
"publishConfig": {
|
|
34
37
|
"access": "public"
|
|
35
38
|
},
|
|
36
|
-
"files": ["
|
|
39
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
37
40
|
}
|