@suluk/agents 0.1.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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@suluk/agents",
3
+ "version": "0.1.0",
4
+ "description": "Suluk Agent composition (C027): lint + project an `x-suluk-agents` map (skills + deterministic routes + by-name sub-agents) to a Claude plugin AND an OpenRouter/OpenAI-compatible manifest — one contract, two artifacts, zero network at generate time. Determinism is DECLARED not enforced; the matcher never reads an agent field. CANDIDATE tooling — NOT official OAS.",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "license": "Apache-2.0",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/MahmoodKhalil57/suluk.git",
12
+ "directory": "tooling/ts/packages/agents"
13
+ },
14
+ "homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
15
+ "bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
16
+ "type": "module",
17
+ "main": "src/index.ts",
18
+ "exports": {
19
+ ".": "./src/index.ts"
20
+ },
21
+ "dependencies": {
22
+ "@suluk/core": "^0.1.7",
23
+ "@suluk/models": "^0.1.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "latest",
27
+ "@suluk/builder": "^0.1.11"
28
+ },
29
+ "scripts": {
30
+ "test": "bun test",
31
+ "typecheck": "tsc --noEmit -p ."
32
+ }
33
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Conformance checks that are NOT lint (they need a runtime/served fact to compare against the contract). The
3
+ * headline one is the OVER-SERVE auditor: the council red-line says the full reachable tool/route surface must be
4
+ * STATICALLY enumerable from the document, and a serving layer's `discover_tools` may REORDER/lazy-load but NEVER
5
+ * WIDEN the declared set. Conin's public MCP `tools/list` (app.ts:2585) ships the FULL catalog — a NAMED
6
+ * conformance failure this function catches.
7
+ */
8
+ import type { OpenAPIv4Document } from "@suluk/core";
9
+ import { agentMap, childKeys } from "./resolve";
10
+ import { contentHash } from "./skill";
11
+ import { effectiveUnderPolicies, policiesFor } from "./policy";
12
+
13
+ export interface ConformanceFinding {
14
+ code: string;
15
+ detail: string;
16
+ }
17
+
18
+ /**
19
+ * The statically-enumerable reachable surface of an agent: its own route keys (the wire ids) + every route key of
20
+ * every transitively-reachable sub-agent. Worst-case authz reach, computed with ZERO requests. (Cycle-safe.)
21
+ */
22
+ export function reachableSurface(doc: OpenAPIv4Document, agentName: string): { tools: string[]; agents: string[] } {
23
+ const map = agentMap(doc);
24
+ const tools = new Set<string>();
25
+ const agents = new Set<string>();
26
+ const walk = (key: string) => {
27
+ const a = map[key];
28
+ if (!a || agents.has(key)) return;
29
+ agents.add(key);
30
+ for (const rk of Object.keys(a.routes ?? {})) tools.add(rk);
31
+ for (const c of childKeys(a)) if (c.key) walk(c.key);
32
+ };
33
+ walk(agentName);
34
+ agents.delete(agentName);
35
+ return { tools: [...tools].sort(), agents: [...agents].sort() };
36
+ }
37
+
38
+ /**
39
+ * OVER-SERVE auditor: assert the tools a server actually exposes are a SUBSET of the declared reachable surface.
40
+ * Any served tool NOT in the surface is a WIDENING — the contract is no longer the source of truth for authz reach.
41
+ */
42
+ export function assertServedSubset(doc: OpenAPIv4Document, agentName: string, servedToolNames: string[]): ConformanceFinding[] {
43
+ const surface = new Set(reachableSurface(doc, agentName).tools);
44
+ return servedToolNames
45
+ .filter((t) => !surface.has(t))
46
+ .map((t) => ({ code: "over-serve", detail: `served tool "${t}" is NOT in the declared reachable surface — discover_tools may reorder/lazy-load, never widen` }));
47
+ }
48
+
49
+ /**
50
+ * The RESIDENT surface of an agent (C027) — its own routes whose `tier` is not `cold-tail` (the default-visible
51
+ * tool set). Cold-tail routes are revealed via `discover_tools`, never in the default list. This is the set a
52
+ * conforming serving adapter must trim to for the context-reduction claim to bind.
53
+ */
54
+ export function residentSurface(doc: OpenAPIv4Document, agentName: string): string[] {
55
+ const a = agentMap(doc)[agentName];
56
+ return Object.entries(a?.routes ?? {}).filter(([, r]) => r.tier !== "cold-tail").map(([k]) => k).sort();
57
+ }
58
+
59
+ /**
60
+ * TIER-TRIM CONFORMANCE: the DEFAULT served tool set must contain NO cold-tail tool (those belong behind
61
+ * `discover_tools`). A cold-tail tool in the default list is a silent no-op of the tier label — the reduction the
62
+ * tiering thesis promises is not actually being delivered on the served path.
63
+ */
64
+ export function assertDefaultServedResident(doc: OpenAPIv4Document, agentName: string, defaultServedToolNames: string[]): ConformanceFinding[] {
65
+ const resident = new Set(residentSurface(doc, agentName));
66
+ const a = agentMap(doc)[agentName];
67
+ const coldTail = new Set(Object.entries(a?.routes ?? {}).filter(([, r]) => r.tier === "cold-tail").map(([k]) => k));
68
+ return defaultServedToolNames
69
+ .filter((t) => coldTail.has(t) && !resident.has(t))
70
+ .map((t) => ({ code: "cold-tail-in-default", detail: `cold-tail tool "${t}" is in the DEFAULT served set — it must sit behind discover_tools, or the tier-trim (and its context reduction) is a no-op` }));
71
+ }
72
+
73
+ /**
74
+ * POLICY-AWARE OVER-SERVE (C028): when an operator policy governs the agent, the served tools must be a subset of
75
+ * the POST-POLICY effective surface — a served tool the operator DENIED is a conformance failure (the operator cap
76
+ * must hold on the wire). With no governing policy this is identical to {@link assertServedSubset}.
77
+ */
78
+ export function assertServedSubsetGoverned(doc: OpenAPIv4Document, agentName: string, servedToolNames: string[]): ConformanceFinding[] {
79
+ if (policiesFor(doc, agentName).length === 0) return assertServedSubset(doc, agentName, servedToolNames);
80
+ const allowed = new Set(effectiveUnderPolicies(doc, agentName).effective.allowedTools);
81
+ const surface = new Set(reachableSurface(doc, agentName).tools);
82
+ return servedToolNames.flatMap((t) =>
83
+ !surface.has(t) ? [{ code: "over-serve", detail: `served tool "${t}" is NOT in the declared reachable surface` }]
84
+ : !allowed.has(t) ? [{ code: "policy-denied-served", detail: `served tool "${t}" was DENIED by operator policy but is being served — the operator cap is not holding on the wire` }]
85
+ : []);
86
+ }
87
+
88
+ /**
89
+ * SKILL-FRESHNESS: a skill's declared `provenance.contentHash` must match the hash of the CURRENT served snapshot.
90
+ * A mismatch means the served preprompt drifted after the contentHash was minted — an unsigned change in production
91
+ * (the C021 supply-chain concern). No declared hash ⇒ a warning (drift is undetectable).
92
+ */
93
+ export function verifySkillFreshness(declaredHash: string | undefined, currentSnapshot: string): ConformanceFinding[] {
94
+ if (!declaredHash) return [{ code: "unpinned-skill", detail: "no declared contentHash — served-instruction drift cannot be detected" }];
95
+ const now = contentHash(currentSnapshot);
96
+ return declaredHash === now ? [] : [{ code: "stale-skill", detail: `declared contentHash ${declaredHash} ≠ current ${now} — the served instructions drifted` }];
97
+ }
package/src/context.ts ADDED
@@ -0,0 +1,256 @@
1
+ /**
2
+ * CONTEXT INTELLIGENCE (C027) — the agent layer's self-knowledge about its own context economics. No one engineers
3
+ * the perfect agent first try: you declare tools + agents, then MEASURE and let the analyzer right-size the layering.
4
+ * It answers three questions per agent, statically and honestly:
5
+ *
6
+ * 1. LOAD — how much context a single inference AT this agent must hold by default: resident skill instructions
7
+ * (when a snapshot is supplied) + the resident tool surface (name+description+schema per tool, +1 dispatch tool
8
+ * per sub-agent, +discover_tools when cold-tail exists) + framing overhead. Cold-tail tools and sub-agent
9
+ * INTERNALS are NOT counted — that is the whole point of the tiering.
10
+ * 2. RIGHT-SIZING (the flatten/unflatten DUALITY) — too big (over budget/window, heavy resident surface) ⇒
11
+ * UNFLATTEN (move tools to cold-tail / extract a sub-agent). Too small or redundant (a thin leaf, or a
12
+ * passthrough that just delegates) ⇒ FLATTEN (collapse the layer up), because every layer COSTS a dispatch tool
13
+ * + a fresh framing overhead per front-door hop — an unearned layer can cost more than one flat call.
14
+ * 3. MODEL FIT — which of an agent's DECLARED candidate models can actually hold its load, and the minimum context
15
+ * window it needs. "Which models are expected to work with this agent" is then a computed, checkable fact.
16
+ *
17
+ * HONESTY: token counts are ESTIMATES (~4 chars/token, no real tokenizer) and instruction sizes are only known when
18
+ * a snapshot is supplied — every report says so. This sizes the SHAPE + the window need, not the bill, and it does
19
+ * NOT judge a model's REASONING capability (only whether the context fits) — the declared model[] is the author's
20
+ * capability intent; the analyzer validates window-fit against it.
21
+ */
22
+ import type { OpenAPIv4Document } from "@suluk/core";
23
+ import type { ModelCatalog } from "@suluk/models";
24
+ import { agentMap, resolveOperationRef, subAgentKey } from "./resolve";
25
+ import type { LintFinding, Severity } from "./lint";
26
+
27
+ const CHARS_PER_TOKEN = 4;
28
+ const estTokens = (s: string) => Math.ceil(s.length / CHARS_PER_TOKEN);
29
+ const BASE_OVERHEAD = 400; // function-calling framing / system scaffolding (paid once per inference hop)
30
+ const SUBAGENT_DISPATCH = 60; // one front-door dispatch tool per sub-agent (name + short desc)
31
+ const DISCOVER_TOOLS = 60; // the discover_tools meta-tool, present when cold-tail routes exist
32
+ const OVERLOAD_TOOL_COUNT = 12; // a flat agent with this many resident tools is an unflatten candidate
33
+ const OVERLOAD_FRACTION = 0.5; // ...or whose resident tool surface eats this share of its target
34
+ const FLATTEN_THIN_TOOLS = 3; // a leaf sub-agent with <= this many resident tools is a flatten candidate
35
+
36
+ /**
37
+ * A model's context window — read from the @suluk/models CATALOG (the council deleted the old hard-coded
38
+ * DEFAULT_WINDOWS table: we do not guess windows). Precedence: an explicit `opts.modelWindows` override, then the
39
+ * catalog row's `context.maxWindow`, else null (UNKNOWN — fail-closed in the hard min-context filter).
40
+ */
41
+ function windowFor(id: string, opts: ContextOptions): number | null {
42
+ if (opts.modelWindows?.[id] !== undefined) return opts.modelWindows[id];
43
+ return opts.catalog?.rows.find((r) => r.id === id)?.context.maxWindow.value ?? null;
44
+ }
45
+
46
+ export interface ToolContextCost { name: string; tokens: number; tier: "resident" | "cold-tail" }
47
+ /** Per declared candidate model: does its context window hold this agent's load? (window null ⇒ unknown model.) */
48
+ export interface ModelFit { model: string; window: number | null; fits: boolean | null; headroom: number | null }
49
+ export interface AgentContextLoad {
50
+ agent: string;
51
+ instructionsTokens: number;
52
+ instructionsMeasured: boolean;
53
+ residentToolTokens: number;
54
+ overheadTokens: number;
55
+ totalTokens: number;
56
+ coldTailTokens: number;
57
+ tools: ToolContextCost[];
58
+ subAgentCount: number;
59
+ /** the minimum context window a model needs to run this agent (= the multi-round PEAK load). */
60
+ minWindowRequired: number;
61
+ /** within-agent thinking cap (C029), if declared. */
62
+ maxRounds?: number;
63
+ thinkingBudget?: number;
64
+ /** worst-case load accounting for thinking round-accretion (= totalTokens when no thinking). Fit checks use THIS. */
65
+ peakTokens: number;
66
+ /** which DECLARED models are expected to work (window ≥ load) and which can't hold it. */
67
+ modelFit: ModelFit[];
68
+ budget?: number;
69
+ /** the smallest declared model window (the binding window constraint), if any model is known. */
70
+ modelWindow?: number;
71
+ target?: number;
72
+ utilization?: number;
73
+ }
74
+
75
+ export interface UnflattenSuggestion {
76
+ agent: string;
77
+ reason: string;
78
+ moveToColdTail: string[];
79
+ wouldSaveTokens: number;
80
+ alsoConsider: string;
81
+ }
82
+
83
+ /** The dual of unflatten: a thin/redundant layer worth collapsing UP into its parent. */
84
+ export interface FlattenSuggestion {
85
+ parent: string;
86
+ child: string;
87
+ reason: string;
88
+ /** the parent's load if the child's resident tools+instructions were inlined. */
89
+ mergedParentTokens: number;
90
+ fitsTarget: boolean;
91
+ /** per-hop overhead removed by collapsing (the child's framing + its dispatch tool). */
92
+ savedHopOverhead: number;
93
+ }
94
+
95
+ export interface ContextReport {
96
+ loads: AgentContextLoad[];
97
+ findings: LintFinding[];
98
+ /** unflatten suggestions for over-target agents (split DOWN). */
99
+ suggestions: UnflattenSuggestion[];
100
+ /** flatten suggestions for thin/redundant layers (collapse UP). */
101
+ flatten: FlattenSuggestion[];
102
+ }
103
+
104
+ export interface ContextOptions {
105
+ instructions?: Record<string, string>;
106
+ /** the model catalog (@suluk/models) — context windows are read from it; replaces the old hard-coded table. */
107
+ catalog?: ModelCatalog;
108
+ /** per-id window overrides (takes precedence over the catalog); handy for tests/pins. */
109
+ modelWindows?: Record<string, number>;
110
+ }
111
+
112
+ function routeCost(doc: OpenAPIv4Document, name: string, operationRef: string, tier: "resident" | "cold-tail"): ToolContextCost {
113
+ const req = resolveOperationRef(doc, operationRef)?.request;
114
+ const desc = req?.summary ?? req?.description ?? "";
115
+ const schema = req?.contentSchema ?? req?.parameterSchema?.body ?? {};
116
+ const tokens = estTokens(name) + estTokens(desc) + estTokens(JSON.stringify(schema)) + 12 /* wrapper */;
117
+ return { name, tokens, tier };
118
+ }
119
+
120
+ /** Compute the context-intelligence report (load + right-sizing + model fit) for every agent in the document. */
121
+ export function contextReport(doc: OpenAPIv4Document, opts: ContextOptions = {}): ContextReport {
122
+ const map = agentMap(doc);
123
+ const loads: AgentContextLoad[] = [];
124
+ const findings: LintFinding[] = [];
125
+ const suggestions: UnflattenSuggestion[] = [];
126
+ const add = (severity: Severity, code: string, agent: string, detail: string) => findings.push({ severity, code, agent, detail });
127
+
128
+ for (const [name, agent] of Object.entries(map)) {
129
+ const skillEntries = Object.entries(agent.skills ?? {});
130
+ const routeEntries = Object.entries(agent.routes ?? {});
131
+
132
+ let instructionsTokens = 0; let anyResidentSkill = false; let anyUnmeasured = false;
133
+ for (const [sk, s] of skillEntries) {
134
+ if (s.tier === "cold-tail") continue;
135
+ anyResidentSkill = true;
136
+ const snap = opts.instructions?.[`${name}/${sk}`];
137
+ if (snap !== undefined) instructionsTokens += estTokens(snap);
138
+ else anyUnmeasured = true;
139
+ }
140
+ const instructionsMeasured = anyResidentSkill ? !anyUnmeasured : true;
141
+
142
+ const tools = routeEntries.map(([rk, r]) => routeCost(doc, rk, r.operationRef, r.tier === "cold-tail" ? "cold-tail" : "resident"));
143
+ const residentTools = tools.filter((t) => t.tier === "resident");
144
+ const coldTailTools = tools.filter((t) => t.tier === "cold-tail");
145
+ const subAgentCount = Object.keys(agent.agents ?? {}).length;
146
+
147
+ const residentToolTokens = residentTools.reduce((n, t) => n + t.tokens, 0) + subAgentCount * SUBAGENT_DISPATCH + (coldTailTools.length ? DISCOVER_TOOLS : 0);
148
+ const coldTailTokens = coldTailTools.reduce((n, t) => n + t.tokens, 0);
149
+ const overheadTokens = BASE_OVERHEAD;
150
+ const totalTokens = instructionsTokens + residentToolTokens + overheadTokens;
151
+
152
+ // thinking round-accretion (C029): a multi-round agent's PEAK load exceeds the single-shot base. Estimate the
153
+ // accretion as the declared thinking budget, else each extra round re-accumulates ~the resident tool surface.
154
+ const maxRounds = agent.thinking?.maxRounds;
155
+ const thinkingBudget = agent.thinking?.budget?.tokens;
156
+ const accretion = agent.thinking ? (thinkingBudget ?? Math.max(0, (maxRounds ?? 1) - 1) * residentToolTokens) : 0;
157
+ const peakTokens = totalTokens + accretion;
158
+
159
+ // model fit: which declared (resident-skill) models can hold this load (the multi-round PEAK)
160
+ const candidateModels = [...new Set(skillEntries.filter(([, s]) => s.tier !== "cold-tail").flatMap(([, s]) => s.model ?? []))];
161
+ const modelFit: ModelFit[] = candidateModels.map((m) => {
162
+ const window = windowFor(m, opts);
163
+ return { model: m, window, fits: window === null ? null : peakTokens <= window, headroom: window === null ? null : window - peakTokens };
164
+ });
165
+ const withWindow = modelFit.filter((f) => f.window !== null);
166
+ const modelWindow = withWindow.length ? Math.min(...withWindow.map((f) => f.window!)) : undefined;
167
+
168
+ const budget = agent.contextBudget?.tokens;
169
+ const target = budget !== undefined && modelWindow !== undefined ? Math.min(budget, modelWindow) : budget ?? modelWindow;
170
+ const utilization = target ? peakTokens / target : undefined;
171
+
172
+ const load: AgentContextLoad = {
173
+ agent: name, instructionsTokens, instructionsMeasured, residentToolTokens, overheadTokens, totalTokens,
174
+ coldTailTokens, tools, subAgentCount, minWindowRequired: peakTokens, modelFit, peakTokens,
175
+ ...(maxRounds !== undefined ? { maxRounds } : {}), ...(thinkingBudget !== undefined ? { thinkingBudget } : {}),
176
+ ...(budget !== undefined ? { budget } : {}), ...(modelWindow !== undefined ? { modelWindow } : {}),
177
+ ...(target !== undefined ? { target } : {}), ...(utilization !== undefined ? { utilization } : {}),
178
+ };
179
+ loads.push(load);
180
+
181
+ // findings
182
+ if (skillEntries.length === 0 && routeEntries.length === 0)
183
+ add("info", "empty-layer", name, "agent declares no skills and no routes — a reserved layer to fill");
184
+ if (anyResidentSkill && anyUnmeasured)
185
+ add("info", "unmeasured-instructions", name, "no instruction snapshot supplied — totalTokens is a LOWER BOUND (tools+overhead only)");
186
+
187
+ // thinking round-accretion (C029): the multi-round peak, fixing the single-shot blindspot
188
+ if (agent.thinking && accretion > 0)
189
+ add("info", "thinking-context-growth", name, `thinking (maxRounds ${maxRounds ?? 1}) accretes ~${accretion} tok over the ${totalTokens}-tok single-shot base → peak ~${peakTokens}; fit checks use the peak`);
190
+
191
+ // model-fit findings (replaces a bare over-window check — names WHICH models work; basis = the PEAK)
192
+ const tooSmall = withWindow.filter((f) => !f.fits);
193
+ if (withWindow.length > 0 && tooSmall.length === withWindow.length)
194
+ add("error", "no-fitting-model", name, `estimated peak load ${peakTokens} tok exceeds EVERY declared model window (smallest ${modelWindow}) — unflatten${agent.thinking ? ", lower maxRounds," : ""} or declare a larger-context model`);
195
+ else if (tooSmall.length > 0)
196
+ add("warning", "model-too-small", name, `declared model(s) ${tooSmall.map((f) => f.model).join(", ")} cannot hold this agent (needs ≥${peakTokens} tok window) — they are listed but will not work`);
197
+
198
+ if (budget !== undefined && peakTokens > budget)
199
+ add("warning", "context-over-budget", name, `estimated peak load ${peakTokens} tok exceeds declared contextBudget ${budget}`);
200
+ const overloadByFraction = target !== undefined && residentToolTokens > OVERLOAD_FRACTION * target;
201
+ if (residentTools.length >= OVERLOAD_TOOL_COUNT || overloadByFraction)
202
+ add("warning", "flat-agent-overloaded", name, `resident surface is heavy (${residentTools.length} tools, ${residentToolTokens} tok) — candidate to unflatten (move tools to cold-tail or extract a sub-agent)`);
203
+
204
+ const s = suggestUnflatten(load);
205
+ if (s) suggestions.push(s);
206
+ }
207
+
208
+ // ── FLATTEN pass (the dual): a thin leaf reached by exactly one parent, whose merge still fits, is gratuitous ──
209
+ const loadBy = new Map(loads.map((l) => [l.agent, l]));
210
+ const refCount = new Map<string, number>();
211
+ for (const a of Object.values(map)) for (const r of Object.values(a.agents ?? {})) { const k = subAgentKey(r.ref); if (k) refCount.set(k, (refCount.get(k) ?? 0) + 1); }
212
+ const flatten: FlattenSuggestion[] = [];
213
+ for (const [pname, agent] of Object.entries(map)) {
214
+ const childKeys = Object.values(agent.agents ?? {}).map((r) => subAgentKey(r.ref)).filter((k): k is string => !!k && !!map[k]);
215
+ const ownRoutes = Object.keys(agent.routes ?? {}).length;
216
+ const ownResidentSkills = Object.values(agent.skills ?? {}).filter((s) => s.tier !== "cold-tail").length;
217
+ if (ownRoutes === 0 && ownResidentSkills === 0 && childKeys.length === 1)
218
+ add("warning", "passthrough-agent", pname, `delegates to "${childKeys[0]}" with no own tools or resident instructions — pure indirection; collapse the two`);
219
+ for (const ck of childKeys) {
220
+ const child = map[ck];
221
+ const childIsLeaf = Object.keys(child.agents ?? {}).length === 0;
222
+ if (!childIsLeaf || (refCount.get(ck) ?? 0) !== 1) continue; // only a leaf reached by exactly one parent
223
+ const pl = loadBy.get(pname)!; const cl = loadBy.get(ck)!;
224
+ const inlinedTools = cl.tools.filter((t) => t.tier === "resident").reduce((n, t) => n + t.tokens, 0);
225
+ const childResidentToolCount = cl.tools.filter((t) => t.tier === "resident").length;
226
+ const merged = pl.totalTokens - SUBAGENT_DISPATCH + inlinedTools + cl.instructionsTokens;
227
+ const fitsTarget = pl.target === undefined || merged <= pl.target;
228
+ if (childResidentToolCount <= FLATTEN_THIN_TOOLS && fitsTarget) {
229
+ flatten.push({ parent: pname, child: ck, reason: `thin leaf (${childResidentToolCount} resident tool(s)); merged load ${merged}${pl.target !== undefined ? ` ≤ target ${pl.target}` : " (no target)"}`, mergedParentTokens: merged, fitsTarget, savedHopOverhead: BASE_OVERHEAD + SUBAGENT_DISPATCH });
230
+ add("info", "flattenable-layer", pname, `sub-agent "${ck}" is a thin leaf that merges within budget — the layer costs ~${BASE_OVERHEAD + SUBAGENT_DISPATCH} tok/hop it does not earn; consider flattening`);
231
+ }
232
+ }
233
+ }
234
+
235
+ return { loads, findings, suggestions, flatten };
236
+ }
237
+
238
+ /** When an agent is over its target, the cheapest decomposition: which resident tools to push to cold-tail. */
239
+ export function suggestUnflatten(load: AgentContextLoad, target = load.target): UnflattenSuggestion | null {
240
+ if (target === undefined || load.peakTokens <= target) return null;
241
+ const resident = load.tools.filter((t) => t.tier === "resident").sort((a, b) => b.tokens - a.tokens);
242
+ const move: string[] = []; let saved = 0;
243
+ for (const t of resident) {
244
+ if (load.peakTokens - saved <= target) break;
245
+ move.push(t.name); saved += t.tokens;
246
+ }
247
+ return {
248
+ agent: load.agent,
249
+ reason: `peak load ${load.peakTokens} tok over target ${target} (${Math.round((load.utilization ?? 0) * 100)}%)`,
250
+ moveToColdTail: move,
251
+ wouldSaveTokens: saved,
252
+ alsoConsider: move.length >= 3
253
+ ? `${move.length} tools cluster here — consider extracting them into a sub-agent (front-door re-entry keeps them out of this agent's context entirely)`
254
+ : "move these tools to cold-tail (tier: cold-tail) so they sit behind discover_tools",
255
+ };
256
+ }
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @suluk/agents — the Suluk Agent composition layer (C027). Lint + project an `x-suluk-agents` map (skills +
3
+ * deterministic routes + by-name sub-agents) into a Claude plugin AND an OpenRouter/OpenAI-compatible manifest:
4
+ * one contract, two artifacts, zero network at generate time. This package is the OTHER side of the D1 wall —
5
+ * it reads `x-suluk-agents`, which @suluk/core's matcher (buildAda/matchRequest) provably never does. Selection
6
+ * and tiering are runtime-advisory; determinism is DECLARED, never enforced. CANDIDATE tooling — NOT official OAS.
7
+ *
8
+ * NB (the C027 module-boundary invariant): @suluk/core MUST NEVER import @suluk/agents. The dependency is one-way.
9
+ * test/core-boundary.test.ts enforces it as a maintained tripwire.
10
+ */
11
+ export { lintAgents, lintOk, assertAgentInstallable, type LintFinding, type Severity } from "./lint";
12
+ export {
13
+ parsePointer, resolveOperationRef, agentMap, subAgentKey, childKeys, findCycle, subtreeDepth, deepStrings,
14
+ type OperationLocus, type ResolvedOperation,
15
+ } from "./resolve";
16
+ export { contentHash, renderSkillMd, type SkillRenderInput } from "./skill";
17
+ export {
18
+ projectClaudePlugin, projectOpenRouter,
19
+ type ClaudePluginOptions, type ClaudePluginArtifacts,
20
+ type OpenRouterOptions, type OpenRouterAgentManifest, type OpenRouterFunctionTool,
21
+ } from "./project";
22
+ export { reachableSurface, residentSurface, assertServedSubset, assertServedSubsetGoverned, assertDefaultServedResident, verifySkillFreshness, type ConformanceFinding } from "./conformance";
23
+ export { intersectScope, analyzeScopes, localEscalations, type Scope, type ScopeEscalation } from "./scope";
24
+ export {
25
+ agentManifest, verifyAgentFreshness,
26
+ type AgentManifest, type AgentManifestNode, type AgentManifestSkill, type AgentManifestRoute, type AgentManifestGoverned,
27
+ } from "./manifest";
28
+ // policy (C028): the operator governance overlay — monotone-narrowing MEET + the static lints. costCeiling is
29
+ // DECLARED, never schema-enforced (enforcedBy names a runtime adapter); enforcement is reserved (build-by-nobody).
30
+ export {
31
+ policyConstrain, effectiveUnderPolicies, policiesFor, policyAppliesTo, lintPolicy, policyOk,
32
+ type EffectiveAgent, type EffectiveSkill, type PolicyNarrowing, type PolicyConstrainResult,
33
+ } from "./policy";
34
+ // context budget (C027): estimate each agent's default context load (resident instructions + tool surface) vs its
35
+ // budget + smallest model window, and say what to unflatten when overloaded. Estimates, not a tokenizer.
36
+ export {
37
+ contextReport, suggestUnflatten,
38
+ type ContextReport, type AgentContextLoad, type UnflattenSuggestion, type FlattenSuggestion,
39
+ type ModelFit, type ToolContextCost, type ContextOptions,
40
+ } from "./context";
41
+ // model-selection seam (C027 × @suluk/models): a skill declares NEEDS (profile + the analyzer's minWindowRequired +
42
+ // the C028 allowlist MEET) and the catalog picks the best CURRENT model — never a hard-coded id.
43
+ export { resolveSkillModels, skillModels, type SkillModelResolution } from "./model-select";
44
+ export { selectModel, deriveRequirements, SEED_CATALOG, PROFILES, type ModelCatalog, type SelectResult, type Preferences, type HardFilters } from "@suluk/models";
package/src/lint.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The C027 agent linter — the BLOCKING author/install gate the council made a red-line. It enforces what
3
+ * JSON-Schema cannot, and (crucially) the D1 selector-rejection: NO agent field may carry a request-value
4
+ * runtime-expression, so the matcher can never be pressured into reading an agent field (the #20 tripwire,
5
+ * declined by removal-by-design). Lint is the only place determinism/acyclicity/depth are checked — never the
6
+ * runtime matcher.
7
+ */
8
+ import type { OpenAPIv4Document, SulukAgent } from "@suluk/core";
9
+ import { agentMap, childKeys, deepStrings, findCycle, resolveOperationRef, subtreeDepth } from "./resolve";
10
+ import { localEscalations } from "./scope";
11
+
12
+ export type Severity = "error" | "warning" | "info";
13
+ export interface LintFinding {
14
+ severity: Severity;
15
+ /** machine code, e.g. "agent-cycle", "missing-max-depth", "dangling-operation-ref", "request-value-selector". */
16
+ code: string;
17
+ agent: string;
18
+ detail: string;
19
+ /** dotted locus within the agent, e.g. "routes.run_core_primitive.operationRef". */
20
+ at?: string;
21
+ }
22
+
23
+ /** A C018-style runtime expression — a request-value selector. Forbidden ANYWHERE in an agent (D1). */
24
+ const RUNTIME_EXPR = /\{\s*\$(request|response|method|url|statusCode|inputs)\b/i;
25
+
26
+ /** Fields whose presence-as-a-router would smuggle dynamic dispatch into the static contract (defense in depth). */
27
+ const FORBIDDEN_SELECTOR_KEYS = new Set(["selector", "strategy", "discriminator", "routeWhen", "dispatchOn"]);
28
+
29
+ export function lintAgents(doc: OpenAPIv4Document): LintFinding[] {
30
+ const map = agentMap(doc);
31
+ const out: LintFinding[] = [];
32
+ const add = (severity: Severity, code: string, agent: string, detail: string, at?: string) =>
33
+ out.push({ severity, code, agent, detail, at });
34
+
35
+ for (const [name, agent] of Object.entries(map)) {
36
+ // --- description: required, routing-oriented (the field the serving LLM selects on) ---
37
+ const desc = (agent.description ?? "").trim();
38
+ if (!desc) add("error", "empty-description", name, "an agent description is required and routing-oriented");
39
+ else if (!desc.includes(" ")) add("warning", "thin-description", name, `one-word description "${desc}" — the serving LLM will mis-route`);
40
+
41
+ // --- D1 GATE: no request-value selector anywhere in the agent object ---
42
+ for (const { path, value } of deepStrings(agent)) {
43
+ if (RUNTIME_EXPR.test(value))
44
+ add("error", "request-value-selector", name, `D1: a request-value runtime-expression is forbidden in an agent ("${value}")`, path);
45
+ }
46
+ for (const { path } of deepStrings(agent)) {
47
+ const leaf = path.split(".").pop() ?? "";
48
+ if (FORBIDDEN_SELECTOR_KEYS.has(leaf))
49
+ add("error", "request-value-selector", name, `D1: field "${leaf}" would route on request data — inadmissible`, path);
50
+ }
51
+
52
+ // --- routes: by-name $refs into EXISTING operations; NO model; resolve-lint ---
53
+ for (const [rk, route] of Object.entries(agent.routes ?? {})) {
54
+ if ((route as unknown as Record<string, unknown>).model !== undefined)
55
+ add("error", "route-has-model", name, `route "${rk}" carries a model — a route is deterministic (no model); make it a skill`, `routes.${rk}`);
56
+ if (!route.operationRef)
57
+ add("error", "missing-operation-ref", name, `route "${rk}" has no operationRef`, `routes.${rk}`);
58
+ else if (!resolveOperationRef(doc, route.operationRef))
59
+ add("error", "dangling-operation-ref", name, `route "${rk}" operationRef does not resolve to an existing operation: ${route.operationRef}`, `routes.${rk}.operationRef`);
60
+ }
61
+
62
+ // --- skills: a skill is the LLM tier (model present); provenance should pin the source ---
63
+ for (const [sk, skill] of Object.entries(agent.skills ?? {})) {
64
+ if (!skill.model || skill.model.length === 0)
65
+ add("warning", "skill-without-model", name, `skill "${sk}" has no model — a no-model unit is a route, not a skill`, `skills.${sk}`);
66
+ if (skill.provenance && !skill.provenance.contentHash)
67
+ add("warning", "skill-provenance-unpinned", name, `skill "${sk}" provenance lacks a contentHash — drift cannot be detected`, `skills.${sk}.provenance`);
68
+ }
69
+
70
+ // --- sub-agents: by-name refs must resolve; recursion needs a bound; no cycles ---
71
+ const children = childKeys(agent);
72
+ for (const c of children) {
73
+ if (!c.key) add("error", "malformed-subagent-ref", name, `sub-agent "${c.local}" ref is not a #/x-suluk-agents/<key> pointer: ${c.ref}`, `agents.${c.local}`);
74
+ else if (!map[c.key]) add("error", "dangling-subagent-ref", name, `sub-agent "${c.local}" refs a missing agent: ${c.key}`, `agents.${c.local}`);
75
+ }
76
+
77
+ // --- scope: a child may not DECLARE a permission its caller does not grant (no escalation across a hop; D1-of-authz) ---
78
+ for (const esc of localEscalations(doc, name)) {
79
+ add("error", "scope-escalation", name, `sub-agent "${esc.childLocal}" (${esc.child}) declares scope its caller does not grant: ${esc.perms.join(", ")} — a child's effective scope is INTERSECTION(child, caller), never union`, `agents.${esc.childLocal}`);
80
+ }
81
+
82
+ // --- thinking bound (C029): maxRounds REQUIRED + positive when `thinking` present; no loop-process / stopCondition ---
83
+ if (agent.thinking) {
84
+ const mr = agent.thinking.maxRounds;
85
+ if (mr === undefined) add("error", "missing-max-rounds", name, "thinking present but no maxRounds — a thinking envelope MUST declare its round cap (mirrors maxDepth-required-when-agents)", "thinking");
86
+ else if (!(typeof mr === "number" && mr >= 1 && Number.isInteger(mr))) add("error", "invalid-max-rounds", name, `maxRounds must be an integer >= 1 (got ${mr})`, "thinking.maxRounds");
87
+ // any stopCondition-shaped member is forbidden — the loop trajectory stays runtime-opaque (declare the bound, not the process)
88
+ for (const k of Object.keys(agent.thinking as Record<string, unknown>))
89
+ if (/^(stopCondition|stopConditionKind|steps?|loop|process|until|while)$/i.test(k))
90
+ add("error", "thinking-process-declared", name, `thinking.${k} models the loop PROCESS — forbidden (declare the bound maxRounds/budget, never the trajectory; it stays runtime-opaque)`, `thinking.${k}`);
91
+ }
92
+ if (children.length > 0) {
93
+ const cycle = findCycle(map, name);
94
+ if (cycle) add("error", "agent-cycle", name, `recursion cycle: ${cycle.join(" → ")}`);
95
+ if (agent.maxDepth === undefined)
96
+ add("error", "missing-max-depth", name, "an agent with sub-agents MUST declare maxDepth (no bound ⇒ does not install)");
97
+ else if (!cycle) {
98
+ const depth = subtreeDepth(map, name);
99
+ if (depth > agent.maxDepth)
100
+ add("error", "depth-exceeds-max", name, `reachable sub-agent depth ${depth} exceeds declared maxDepth ${agent.maxDepth}`);
101
+ }
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /** True ⇒ no error-severity findings (warnings/info are advisory). */
108
+ export const lintOk = (findings: LintFinding[]): boolean => !findings.some((f) => f.severity === "error");
109
+
110
+ /** Convenience: lint a single agent's existence + errors, throwing the first error (for fail-loud projection). */
111
+ export function assertAgentInstallable(doc: OpenAPIv4Document, agentName: string): void {
112
+ if (!agentMap(doc)[agentName]) throw new Error(`@suluk/agents: no agent "${agentName}" in x-suluk-agents`);
113
+ const errs = lintAgents(doc).filter((f) => f.severity === "error" && f.agent === agentName);
114
+ if (errs.length) throw new Error(`@suluk/agents: agent "${agentName}" does not install:\n - ${errs.map((e) => `${e.code}: ${e.detail}`).join("\n - ")}`);
115
+ }
116
+
117
+ // re-export the agent type for consumers that only import the linter
118
+ export type { SulukAgent };