@juno-ai/bind 9.0.0 → 11.0.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.
Files changed (46) hide show
  1. package/README.md +375 -15
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +77 -2
  5. package/contracts/turn.js +35 -2
  6. package/index.d.ts +6 -2
  7. package/index.js +6 -2
  8. package/loop/index.d.ts +2 -1
  9. package/loop/index.js +1 -1
  10. package/loop/tool-loop.d.ts +117 -12
  11. package/loop/tool-loop.js +242 -67
  12. package/package.json +10 -2
  13. package/plugins/dispatch.d.ts +130 -0
  14. package/plugins/dispatch.js +241 -0
  15. package/plugins/index.d.ts +2 -0
  16. package/plugins/index.js +2 -0
  17. package/plugins/tool-message.d.ts +23 -0
  18. package/plugins/tool-message.js +31 -0
  19. package/skills/activation.d.ts +64 -0
  20. package/skills/activation.js +39 -0
  21. package/skills/admission.d.ts +61 -0
  22. package/skills/admission.js +41 -0
  23. package/skills/catalog.d.ts +54 -0
  24. package/skills/catalog.js +77 -0
  25. package/skills/discovery.d.ts +82 -0
  26. package/skills/discovery.js +91 -0
  27. package/skills/index.d.ts +19 -0
  28. package/skills/index.js +19 -0
  29. package/skills/refs.d.ts +21 -0
  30. package/skills/refs.js +27 -0
  31. package/skills/registry.d.ts +57 -0
  32. package/skills/registry.js +94 -0
  33. package/skills/resolve.d.ts +89 -0
  34. package/skills/resolve.js +124 -0
  35. package/skills/sha.d.ts +53 -0
  36. package/skills/sha.js +60 -0
  37. package/skills/sha256.d.ts +38 -0
  38. package/skills/sha256.js +122 -0
  39. package/skills/skill-md.d.ts +73 -0
  40. package/skills/skill-md.js +149 -0
  41. package/skills/types.d.ts +174 -0
  42. package/skills/types.js +55 -0
  43. package/testing/index.d.ts +153 -0
  44. package/testing/index.js +188 -0
  45. package/tools/control-chars.d.ts +23 -0
  46. package/tools/control-chars.js +35 -0
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Authoring and dispatching a tool: the mechanical half of writing one, which
3
+ * every host had been reimplementing.
4
+ *
5
+ * A `ToolPlugin` is a bundle whose `execute` dispatches by tool name, because
6
+ * that is the shape a plugin with shared setup wants. It is not the shape a
7
+ * *single* tool wants, and a host with a flat list of tools ends up writing the
8
+ * same four steps for each: switch on the name, parse the arguments, map a
9
+ * parse failure onto a result the model can read, and encode the result as a
10
+ * `role:"tool"` message. All four are mechanical, all four are easy to get
11
+ * subtly wrong (the usual bug is a parse failure thrown rather than returned,
12
+ * which turns a recoverable "you passed the wrong argument" into a dead run),
13
+ * and none of them are where a host's judgement belongs.
14
+ *
15
+ * {@link defineTool} and {@link pluginFromTools} do those steps. They are a
16
+ * convenience over the vocabulary in `./tool`, not a replacement for it: a
17
+ * plugin that needs shared setup across its tools, or whose dispatch is genuinely
18
+ * one decision, still writes `ToolPlugin` by hand and loses nothing.
19
+ */
20
+ import { z } from "zod";
21
+ import { sanitizeToolSchema } from "../tools/sanitize-schema.js";
22
+ import { stripControlChars } from "../tools/control-chars.js";
23
+ /**
24
+ * Define one tool from its schema and implementation.
25
+ *
26
+ * The returned value is an ordinary {@link ToolDef} with an `execute` attached,
27
+ * so it drops into anything that already consumes `ToolDef` — a catalog
28
+ * renderer, a schema regression test — without an adapter.
29
+ */
30
+ export function defineTool(spec) {
31
+ // Hoisted so the closures below narrow it once; reading `spec.normalizeArgs`
32
+ // inside each would re-widen it to possibly-undefined on every call.
33
+ const normalize = spec.normalizeArgs;
34
+ return {
35
+ name: spec.name,
36
+ description: spec.description,
37
+ parameters: spec.schema,
38
+ ...(spec.annotations === undefined ? {} : { annotations: spec.annotations }),
39
+ ...(spec.hidden === undefined ? {} : { hidden: spec.hidden }),
40
+ ...(spec.supportsProgress === undefined
41
+ ? {}
42
+ : { supportsProgress: spec.supportsProgress }),
43
+ ...(spec.rawJsonSchema === undefined
44
+ ? {}
45
+ : { rawJsonSchema: spec.rawJsonSchema }),
46
+ ...(normalize === undefined
47
+ ? {}
48
+ : {
49
+ // Handed straight through, NOT re-parsed. `ToolDef.normalizeArgs` is
50
+ // documented as post-parse canonicalization and every dispatcher
51
+ // calls it that way, so re-parsing here was pure harm: it ran the
52
+ // schema a second time, and on any input the schema could not
53
+ // re-accept it silently returned the *un-normalized* value — which
54
+ // is the idempotency hash, so two equivalent calls stopped agreeing
55
+ // exactly when canonicalization mattered most.
56
+ //
57
+ // The cast is confined to this line. It is safe by the same contract:
58
+ // the value is post-parse, so it is a `z.output<TSchema>`.
59
+ normalizeArgs: normalize,
60
+ }),
61
+ ...(spec.summarizeActivity === undefined
62
+ ? {}
63
+ : { summarizeActivity: spec.summarizeActivity }),
64
+ // Parses, and deliberately does NOT normalize.
65
+ //
66
+ // Normalization is the dispatcher's step — it has to happen before the
67
+ // idempotency hash, which the harness never sees — and a dispatcher that
68
+ // applies `normalizeArgs` and then calls this would otherwise apply it
69
+ // twice. Once is a no-op for an idempotent canonicalizer and wrong for
70
+ // anything else (`n => n + 1` reached `execute` as `n + 2`).
71
+ //
72
+ // The parse stays because this is also the entry point for a host with no
73
+ // dispatcher of its own, and re-parsing an already-parsed value is a
74
+ // no-op for any schema that can legally be a tool schema — `.transform()`
75
+ // cannot (`z.toJSONSchema` rejects it, so the tool could never be
76
+ // advertised), and `.default()` / `z.coerce` are both parse-idempotent.
77
+ // A tool that supplies `rawJsonSchema` to bypass that conversion owns the
78
+ // requirement itself; see `ToolSpec.rawJsonSchema`.
79
+ execute: (args, ctx) => {
80
+ const parsed = spec.schema.safeParse(args);
81
+ if (!parsed.success) {
82
+ return validationFailure(spec.name, args, parsed.error);
83
+ }
84
+ return spec.execute(parsed.data, ctx);
85
+ },
86
+ };
87
+ }
88
+ /**
89
+ * Cap on the issues quoted back. A tool taking an array validates every
90
+ * element, so one malformed argument can produce thousands — and the result
91
+ * message is not transient: it is appended to the transcript and re-sent to
92
+ * the provider on every remaining turn of the run. Ten is enough for a model
93
+ * to act on; the rest are counted, not quoted.
94
+ */
95
+ const MAX_QUOTED_ISSUES = 10;
96
+ /**
97
+ * Quote model-supplied text back at it safely. A zod issue path can contain
98
+ * input *keys* (via `z.record`), and this string reaches a host's logs and, in
99
+ * Monad, a member-visible activity row — so control characters are replaced
100
+ * with a space rather than deleted, keeping adjacent words apart, and the
101
+ * result is bounded.
102
+ */
103
+ function safeQuote(text) {
104
+ // `Array.from` iterates code points, so the bound cannot slice an astral
105
+ // character in half and put a lone surrogate in the transcript.
106
+ return Array.from(stripControlChars(text, " ")).slice(0, 200).join("");
107
+ }
108
+ /** How a tool's arguments failed to parse, phrased for the model. */
109
+ function validationFailure(toolName, args, error) {
110
+ const quoted = error.issues.slice(0, MAX_QUOTED_ISSUES).map((issue) => {
111
+ const path = issue.path.map((segment) => String(segment)).join(".");
112
+ const where = path ? safeQuote(path) : "(top level)";
113
+ // zod renders an omitted key as "expected string, received undefined",
114
+ // which reads as a *type* error — and a model that reads it that way
115
+ // retries with the literal string "undefined" instead of supplying the
116
+ // field. Resolving the path against the input is what tells the two apart.
117
+ const omitted = issue.code === "invalid_type" && pathIsAbsent(args, issue.path);
118
+ return `- ${where}: ${omitted ? "required, but missing" : safeQuote(issue.message)}`;
119
+ });
120
+ const hidden = error.issues.length - quoted.length;
121
+ return {
122
+ success: false,
123
+ kind: "validation",
124
+ // Leads with the outcome, itemizes what to change, and ends with the
125
+ // action — the shape `ABORTED_TOOL_CALL_MESSAGE` established. A message
126
+ // that only diagnoses leaves the model to guess whether to retry.
127
+ error: `Invalid arguments for ${toolName} — the call did not run and nothing changed.\n` +
128
+ quoted.join("\n") +
129
+ (hidden > 0 ? `\n- (and ${hidden} more problems)` : "") +
130
+ `\nFix these fields and call ${toolName} again with the same intent. ` +
131
+ `Do not resend the same arguments.`,
132
+ };
133
+ }
134
+ /**
135
+ * Is the key a zod issue points at genuinely absent?
136
+ *
137
+ * Checked with `in` rather than by comparing the value to `undefined`, so a
138
+ * field explicitly present as `undefined` is reported as the wrong type rather
139
+ * than as missing. `JSON.parse` never produces `undefined`, so this only
140
+ * matters for a host dispatching pre-parsed arguments — which is exactly the
141
+ * caller `defineTool` supports.
142
+ */
143
+ function pathIsAbsent(value, path) {
144
+ let current = value;
145
+ for (const segment of path) {
146
+ if (current === null || typeof current !== "object")
147
+ return true;
148
+ if (!(segment in current))
149
+ return true;
150
+ current = current[segment];
151
+ }
152
+ return false;
153
+ }
154
+ /**
155
+ * Bundle self-contained tools into a {@link ToolPlugin}.
156
+ *
157
+ * The generated `execute` is only a name resolver — each tool already validates
158
+ * its own arguments (see {@link DefinedTool}). An unknown name is a *returned*
159
+ * `not_found` failure rather than a throw: it happens whenever a resumed
160
+ * session's history references a tool that has since been retired, and a run
161
+ * should survive that.
162
+ */
163
+ export function pluginFromTools(spec) {
164
+ const byName = new Map();
165
+ for (const tool of spec.tools) {
166
+ if (byName.has(tool.name)) {
167
+ // Thrown, not returned: two tools sharing a name is an authoring mistake
168
+ // that makes one of them permanently unreachable, and it should fail at
169
+ // construction rather than at whichever call happens to resolve first.
170
+ throw new Error(`pluginFromTools: plugin "${spec.name}" declares two tools named "${tool.name}".`);
171
+ }
172
+ byName.set(tool.name, tool);
173
+ }
174
+ return {
175
+ name: spec.name,
176
+ description: spec.description,
177
+ ...(spec.systemMessage === undefined
178
+ ? {}
179
+ : { systemMessage: spec.systemMessage }),
180
+ ...(spec.icon === undefined ? {} : { icon: spec.icon }),
181
+ ...(spec.isAvailable === undefined
182
+ ? {}
183
+ : { isAvailable: spec.isAvailable }),
184
+ tools: [...spec.tools],
185
+ async execute(toolName, args, ctx) {
186
+ const tool = byName.get(toolName);
187
+ if (!tool) {
188
+ return {
189
+ success: false,
190
+ kind: "not_found",
191
+ // Leads with the outcome and ends with an action. A bare "unknown
192
+ // tool" is indistinguishable from a transient miss, and a model
193
+ // reading it that way re-issues the same call every iteration until
194
+ // the budget runs out — which is exactly the state this arm exists
195
+ // to survive (a resumed session referencing a retired tool).
196
+ error: `No tool named "${safeQuote(toolName)}" exists on plugin "${spec.name}" — ` +
197
+ `nothing ran and nothing changed, and calling it again will fail the ` +
198
+ `same way. Use one of the tools currently listed for "${spec.name}", ` +
199
+ `or finish the task without it.`,
200
+ };
201
+ }
202
+ return await tool.execute(args, ctx);
203
+ },
204
+ };
205
+ }
206
+ /**
207
+ * Convert a tool to the wire definition a provider is shown.
208
+ *
209
+ * `rawJsonSchema` wins when present (an MCP tool forwards its server's schema
210
+ * verbatim); otherwise the zod schema is converted. Either way the result goes
211
+ * through {@link sanitizeToolSchema}, because a strict validator rejects the
212
+ * *entire* request on the first unsupported construct — one bad tool takes
213
+ * every other tool down with it.
214
+ *
215
+ * `wireName` exists because tool naming is host policy: Monad encodes
216
+ * `plugin__tool` so it can route a call back to its plugin, and a host with a
217
+ * flat namespace does not need to. Defaults to the tool's own name.
218
+ *
219
+ * Returns the narrow `ChatCompletionFunctionTool` rather than the
220
+ * `ChatCompletionTool` union — a tool built from a parameter schema is always
221
+ * the function variant, and returning the union would make every caller narrow
222
+ * past a `custom` case that cannot occur. It still assigns to the union.
223
+ *
224
+ * Converts whatever it is handed. **Skip `hidden` tools in the caller's catalog
225
+ * loop** — a hidden tool stays runnable so a resumed session's history still
226
+ * resolves, but advertising it puts a retired tool back in front of the model.
227
+ */
228
+ export function toolWireDefinition(tool, wireName = tool.name) {
229
+ const jsonSchema = tool.rawJsonSchema ??
230
+ // zod's converter is typed as its own JSON Schema shape; the wire wants a
231
+ // plain object, which is what it structurally is.
232
+ z.toJSONSchema(tool.parameters);
233
+ return {
234
+ type: "function",
235
+ function: {
236
+ name: wireName,
237
+ description: tool.description,
238
+ parameters: sanitizeToolSchema(jsonSchema),
239
+ },
240
+ };
241
+ }
@@ -1,3 +1,5 @@
1
1
  export { hasContentParts, type ToolAnnotations, type ToolDef, type ToolFailureKind, type ToolPlugin, type ToolResult, type RegistrablePlugin, type SuspendDirective, } from "./tool.js";
2
+ export { defineTool, pluginFromTools, toolWireDefinition, type DefinedTool, type ToolSpec, type PluginSpec, } from "./dispatch.js";
3
+ export { toolResultMessage } from "./tool-message.js";
2
4
  export { createToolRegistry, type PluginSummary, type ToolRegistry, type ToolRegistryOptions, } from "./registry.js";
3
5
  export { rehydrateActivation, initialActivePlugins, partitionPluginCatalog, type ActivationDropReason, type CatalogPartition, type DroppedActivation, type RehydrateOptions, type RehydrateResult, } from "./activation.js";
package/plugins/index.js CHANGED
@@ -1,3 +1,5 @@
1
1
  export { hasContentParts, } from "./tool.js";
2
+ export { defineTool, pluginFromTools, toolWireDefinition, } from "./dispatch.js";
3
+ export { toolResultMessage } from "./tool-message.js";
2
4
  export { createToolRegistry, } from "./registry.js";
3
5
  export { rehydrateActivation, initialActivePlugins, partitionPluginCatalog, } from "./activation.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Encoding a tool result as the message that answers its call.
3
+ *
4
+ * Deliberately its own module with **type-only** imports: the tool loop uses
5
+ * this encoder, and `./dispatch` imports zod at runtime for schema conversion.
6
+ * Folding the two together would pull zod into the module graph of every host
7
+ * that imports only `@juno-ai/bind/loop`, which today needs none of it.
8
+ */
9
+ import type OpenAI from "openai";
10
+ import type { ToolResult } from "./tool.js";
11
+ /**
12
+ * Encode a {@link ToolResult} as the `role:"tool"` message that answers a call.
13
+ *
14
+ * This is the encoding the loop itself synthesizes for a failed or refused
15
+ * call, exported so a host's own results are shaped identically — a model that
16
+ * sees `{"success":false,"kind":…,"error":…}` from the harness and something
17
+ * else from the host has to learn two error formats in one transcript.
18
+ *
19
+ * `contentParts` and `suspend` are deliberately omitted: both are control
20
+ * signals for the host, not text for the model. A host relaying multimodal
21
+ * parts attaches them alongside this message.
22
+ */
23
+ export declare function toolResultMessage(toolCallId: string, result: ToolResult<unknown>): OpenAI.ChatCompletionToolMessageParam;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Encode a {@link ToolResult} as the `role:"tool"` message that answers a call.
3
+ *
4
+ * This is the encoding the loop itself synthesizes for a failed or refused
5
+ * call, exported so a host's own results are shaped identically — a model that
6
+ * sees `{"success":false,"kind":…,"error":…}` from the harness and something
7
+ * else from the host has to learn two error formats in one transcript.
8
+ *
9
+ * `contentParts` and `suspend` are deliberately omitted: both are control
10
+ * signals for the host, not text for the model. A host relaying multimodal
11
+ * parts attaches them alongside this message.
12
+ */
13
+ export function toolResultMessage(toolCallId, result) {
14
+ const body = result.success
15
+ ? { success: true, data: result.data }
16
+ : {
17
+ success: false,
18
+ // `success` and `kind` lead so a model skimming a batch of results
19
+ // reads the verdict before the prose, and the order is fixed —
20
+ // `success`, `kind`, `error`, then `data` when present — so the same
21
+ // result always serializes to the same bytes.
22
+ ...(result.kind === undefined ? {} : { kind: result.kind }),
23
+ error: result.error,
24
+ ...(result.data === undefined ? {} : { data: result.data }),
25
+ };
26
+ return {
27
+ role: "tool",
28
+ tool_call_id: toolCallId,
29
+ content: JSON.stringify(body),
30
+ };
31
+ }
@@ -0,0 +1,64 @@
1
+ import type { ResolvedSkillInstructions } from "./resolve.js";
2
+ import type { SkillSummary, SkillWarn } from "./types.js";
3
+ /**
4
+ * Progressive knowledge disclosure — the skill half of `plugins/activation.ts`.
5
+ *
6
+ * The controller does one thing and refuses to do a second: it adds refs to the
7
+ * run's active set, resolves the bodies, records the hash pin, and hands the
8
+ * full resolved set back. It **never touches the transcript**. The system
9
+ * message is a pure render of run state, so a host re-renders it from the
10
+ * instructions it is given rather than splicing a marker or mutating a string
11
+ * in place — the difference shows up the first time a run is resumed and the
12
+ * spliced text is already there twice.
13
+ */
14
+ /**
15
+ * The skill read path, injectable. The other half of the source — the Tier-1
16
+ * catalog — is the `availableSkills` array, so a host customizes both
17
+ * independently: an in-memory store for a test, an isolated one-skill store for
18
+ * an always-on persona, the real registry plus its database for a live run.
19
+ */
20
+ export interface SkillStore {
21
+ resolveActiveInstructions(activeRefs: string[]): Promise<ResolvedSkillInstructions>;
22
+ }
23
+ export interface SkillActivation {
24
+ /**
25
+ * Activate skills for this run: admit the refs that are in the catalog,
26
+ * resolve the bodies for the **whole** active set, pin their hashes, and hand
27
+ * the instructions to `applyInstructions`.
28
+ *
29
+ * Resolution covers the whole set rather than the delta because the caller
30
+ * re-renders one section from the result; a delta would make it the caller's
31
+ * job to concatenate in the right order, which is the ordering guarantee the
32
+ * resolver exists to own. A call that admits nothing new is a no-op.
33
+ */
34
+ activateSkills(refs: Iterable<string>): Promise<void>;
35
+ }
36
+ export interface SkillActivationParams {
37
+ /** This run's Tier-1 catalog — the set a ref must be in to be activatable. */
38
+ availableSkills: readonly SkillSummary[];
39
+ /**
40
+ * Caller-owned active-ref set, mutated in place.
41
+ *
42
+ * Owned by the caller because two other things read it: conversation assembly
43
+ * seeds it before the first turn (it is what the initial catalog renders as
44
+ * active), and the host persists it when the run ends.
45
+ */
46
+ activeSkills: Set<string>;
47
+ /**
48
+ * Caller-owned ref → `contentSha` pin, grown on each activation.
49
+ *
50
+ * Also caller-owned, and for a sharper reason: a host hoists it above the try
51
+ * block so the terminal-path catch can still persist what the run had loaded.
52
+ * A pin the controller owned would be lost on exactly the failed runs an eval
53
+ * most wants to reproduce.
54
+ */
55
+ loadedSkillShas: Record<string, string>;
56
+ store: SkillStore;
57
+ /**
58
+ * Called after each activation that changed the set, with the freshly
59
+ * resolved bodies for the full active set.
60
+ */
61
+ applyInstructions: (instructions: string[]) => void;
62
+ onWarn?: SkillWarn;
63
+ }
64
+ export declare function createSkillActivation(params: SkillActivationParams): SkillActivation;
@@ -0,0 +1,39 @@
1
+ export function createSkillActivation(params) {
2
+ const { activeSkills, loadedSkillShas, store, applyInstructions } = params;
3
+ const availableRefs = new Set(params.availableSkills.map((summary) => summary.ref));
4
+ return {
5
+ async activateSkills(refs) {
6
+ const admitted = [];
7
+ for (const ref of refs) {
8
+ // The catalog is the authorization boundary for reading a skill: it is
9
+ // already gated on the agent's plugins and the host's scope, so a ref
10
+ // outside it is refused here rather than resolved and then filtered.
11
+ if (!availableRefs.has(ref)) {
12
+ params.onWarn?.("refusing to activate unavailable skill", { ref });
13
+ continue;
14
+ }
15
+ if (!activeSkills.has(ref) && !admitted.includes(ref))
16
+ admitted.push(ref);
17
+ }
18
+ if (admitted.length === 0)
19
+ return;
20
+ // Resolve against the *would-be* set, and commit only if that succeeds.
21
+ //
22
+ // Adding first is the obvious order and it is wrong. A store that throws
23
+ // — a database blip, an external source that times out — would leave the
24
+ // caller's set claiming the skill is active while `applyInstructions`
25
+ // never ran, so the body is not in the prompt. The retry then finds
26
+ // nothing new to admit and returns early, and the run finishes with a
27
+ // catalog that lists a skill as loaded whose instructions the model never
28
+ // saw. Failing cleanly is what makes a retry able to fix it.
29
+ const next = new Set(activeSkills);
30
+ for (const ref of admitted)
31
+ next.add(ref);
32
+ const { instructions, shas } = await store.resolveActiveInstructions([...next]);
33
+ for (const ref of admitted)
34
+ activeSkills.add(ref);
35
+ Object.assign(loadedSkillShas, shas);
36
+ applyInstructions(instructions);
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Whether a run may load one more skill.
3
+ *
4
+ * Two bounds, and they are not redundant. The **count** is cheap and is checked
5
+ * before anything is resolved. The **token total** is the one that actually
6
+ * protects the context window: twelve short skills fit and three long ones do
7
+ * not, so a count cap alone bounds the wrong quantity. Without the second, a
8
+ * model in a loop loads its way to a context-limit error mid-run, which reads
9
+ * to a user as the agent breaking rather than as it over-reaching.
10
+ *
11
+ * Shaped like `admitChildRun` in `run/children.ts`, and for the same reason:
12
+ * **the host measures, the harness judges.** Estimating the token cost of the
13
+ * would-be active set means resolving bodies, which is I/O; comparing a number
14
+ * to a limit is not. So each bound arrives with the measurement it judges, and
15
+ * a bound whose number the host could not produce is simply omitted — a rule
16
+ * that silently never fires is worse than one that is visibly absent.
17
+ */
18
+ export type SkillLoadRefusal = {
19
+ kind: "active_count";
20
+ active: number;
21
+ max: number;
22
+ } | {
23
+ kind: "active_body_tokens";
24
+ tokens: number;
25
+ max: number;
26
+ };
27
+ export type SkillLoadDecision = {
28
+ admitted: true;
29
+ } | {
30
+ admitted: false;
31
+ refusal: SkillLoadRefusal;
32
+ /**
33
+ * A noun phrase, carrying no identifiers. Hosts surface a refusal to the
34
+ * model as a tool error and sometimes to a person, so it must be safe to
35
+ * render in both places and must read after a host's own prefix.
36
+ */
37
+ reason: string;
38
+ };
39
+ export interface SkillLoadBounds {
40
+ /**
41
+ * Refs already active. Omit the pair to skip the count bound; a non-finite or
42
+ * negative count **refuses**, because a broken measurement is not evidence
43
+ * that there is room.
44
+ */
45
+ activeCount?: number;
46
+ maxActive?: number;
47
+ /**
48
+ * Estimated tokens of the instruction set the load would produce — the whole
49
+ * would-be active set, not the increment. Omit the pair to skip the bound.
50
+ */
51
+ projectedBodyTokens?: number;
52
+ maxBodyTokens?: number;
53
+ }
54
+ /**
55
+ * Judge a `load_skill` against the active-set bounds. A skill that is already
56
+ * active is never subject to either bound — reloading it adds nothing — so
57
+ * callers should short-circuit before calling.
58
+ */
59
+ export declare function admitSkillLoad(bounds: SkillLoadBounds): SkillLoadDecision;
60
+ /** Estimated token cost of a resolved instruction set — the input to the body bound. */
61
+ export declare function estimateSkillBodyTokens(instructions: readonly string[]): number;
@@ -0,0 +1,41 @@
1
+ import { SKILL_MAX_ACTIVE_BODY_TOKENS, SKILL_MAX_ACTIVE_PER_SESSION, estimateSkillTokens, } from "./types.js";
2
+ function bounded(measurement) {
3
+ return measurement !== undefined;
4
+ }
5
+ /**
6
+ * Judge a `load_skill` against the active-set bounds. A skill that is already
7
+ * active is never subject to either bound — reloading it adds nothing — so
8
+ * callers should short-circuit before calling.
9
+ */
10
+ export function admitSkillLoad(bounds) {
11
+ const maxActive = bounds.maxActive ?? SKILL_MAX_ACTIVE_PER_SESSION;
12
+ if (bounded(bounds.activeCount)) {
13
+ const active = bounds.activeCount;
14
+ // `!(active < max)` rather than `active >= max`: every comparison is false
15
+ // against NaN, so the naive form admits on a broken count instead of
16
+ // refusing on one.
17
+ if (!(Number.isFinite(active) && active >= 0 && active < maxActive)) {
18
+ return {
19
+ admitted: false,
20
+ refusal: { kind: "active_count", active, max: maxActive },
21
+ reason: `active-skill limit reached (${maxActive} loaded). Work from the skills already loaded rather than loading more.`,
22
+ };
23
+ }
24
+ }
25
+ const maxBodyTokens = bounds.maxBodyTokens ?? SKILL_MAX_ACTIVE_BODY_TOKENS;
26
+ if (bounded(bounds.projectedBodyTokens)) {
27
+ const tokens = bounds.projectedBodyTokens;
28
+ if (!(Number.isFinite(tokens) && tokens >= 0 && tokens <= maxBodyTokens)) {
29
+ return {
30
+ admitted: false,
31
+ refusal: { kind: "active_body_tokens", tokens, max: maxBodyTokens },
32
+ reason: `loading this skill would take the loaded instructions to about ${tokens} tokens, over the ${maxBodyTokens}-token budget. Work from the skills already loaded instead of loading more.`,
33
+ };
34
+ }
35
+ }
36
+ return { admitted: true };
37
+ }
38
+ /** Estimated token cost of a resolved instruction set — the input to the body bound. */
39
+ export function estimateSkillBodyTokens(instructions) {
40
+ return instructions.reduce((total, body) => total + estimateSkillTokens(body), 0);
41
+ }
@@ -0,0 +1,54 @@
1
+ import { type SkillSummary } from "./types.js";
2
+ /**
3
+ * Total order over summaries: origin, then name, then ref.
4
+ *
5
+ * A **total** order, not just a stable one: `ref` is the unique tiebreak so two
6
+ * skills that collide on name still sort deterministically. Both the catalog
7
+ * and the injected instruction bodies use this comparator, which is what keeps
8
+ * the system prompt byte-stable across a fresh run and a resumed one — the
9
+ * active set is persisted in load order, and rendering in *that* order would
10
+ * shift the prefix on every resume.
11
+ */
12
+ export declare function compareSkillSummaries(a: SkillSummary, b: SkillSummary): number;
13
+ /** How much of a skill's catalog entry survived the budget. */
14
+ export type SkillCatalogDetail = "full" | "name_only";
15
+ export interface SkillCatalogEntry {
16
+ summary: SkillSummary;
17
+ detail: SkillCatalogDetail;
18
+ }
19
+ export interface SkillCatalogPartition {
20
+ /** Already loaded. Always rendered in full — the body's cost is already paid. */
21
+ active: SkillSummary[];
22
+ /** Loadable, each marked with the detail the budget allows. */
23
+ loadable: SkillCatalogEntry[];
24
+ /** How many loadable entries were demoted to `name_only`. */
25
+ truncated: number;
26
+ }
27
+ export interface SkillCatalogOptions {
28
+ /** Defaults to {@link SKILL_CATALOG_TOKEN_BUDGET}. */
29
+ tokenBudget?: number;
30
+ /**
31
+ * Cost of one entry at a given detail, in tokens. Defaults to an estimate of
32
+ * `- name: description — whenToUse`.
33
+ *
34
+ * A host that renders a different line should pass its own: the budget is
35
+ * only as honest as its measurement, and a default that under-counts a
36
+ * verbose format silently overruns the prefix it was meant to protect.
37
+ */
38
+ cost?: (summary: SkillSummary, detail: SkillCatalogDetail) => number;
39
+ }
40
+ /**
41
+ * Split the catalog into active and loadable, demoting the loadable tail to
42
+ * names only once the budget is spent.
43
+ *
44
+ * **Demote, never drop.** A skill the model cannot see is a skill it cannot
45
+ * ask for, and a workspace's library grows past any budget worth setting. A
46
+ * bare name still routes: it is enough for the model to call `load_skill` and
47
+ * read the real description. The host is expected to say so in the line it
48
+ * renders for the truncated tail — `truncated` is there to let it.
49
+ *
50
+ * The active set is charged against the budget but never demoted: those bodies
51
+ * are already in the prompt, so shortening their catalog lines would save
52
+ * nothing that matters while hiding what the agent is currently working from.
53
+ */
54
+ export declare function partitionSkillCatalog(activeSkills: ReadonlySet<string>, available: readonly SkillSummary[], options?: SkillCatalogOptions): SkillCatalogPartition;
@@ -0,0 +1,77 @@
1
+ import { SKILL_CATALOG_TOKEN_BUDGET, estimateSkillTokens, } from "./types.js";
2
+ /**
3
+ * The Tier-1 catalog — what the model sees for every skill on every turn,
4
+ * whether or not it ever loads one.
5
+ *
6
+ * This module returns **data, not prose**, exactly as `partitionPluginCatalog`
7
+ * does and for the same two reasons: catalog wording is a product surface with
8
+ * the host's voice, and it sits in the cacheable system prefix, where the host
9
+ * needs byte-identical output for unchanged inputs or it loses the provider's
10
+ * prompt cache for everything below it. What is genuinely shared is the
11
+ * *ordering* and the *budget*, and both are here.
12
+ */
13
+ /**
14
+ * Origin precedence. Deployment-owned knowledge is described before
15
+ * plugin-specific recipes, and both before what the workspace wrote — so the
16
+ * general instruction is read before the specialization, and so the tail that
17
+ * a budget demotes is the tail that grows without bound.
18
+ */
19
+ const ORIGIN_RANK = {
20
+ platform: 0,
21
+ plugin: 1,
22
+ tenant: 2,
23
+ };
24
+ /**
25
+ * Total order over summaries: origin, then name, then ref.
26
+ *
27
+ * A **total** order, not just a stable one: `ref` is the unique tiebreak so two
28
+ * skills that collide on name still sort deterministically. Both the catalog
29
+ * and the injected instruction bodies use this comparator, which is what keeps
30
+ * the system prompt byte-stable across a fresh run and a resumed one — the
31
+ * active set is persisted in load order, and rendering in *that* order would
32
+ * shift the prefix on every resume.
33
+ */
34
+ export function compareSkillSummaries(a, b) {
35
+ return (ORIGIN_RANK[a.origin] - ORIGIN_RANK[b.origin] ||
36
+ a.name.localeCompare(b.name) ||
37
+ a.ref.localeCompare(b.ref));
38
+ }
39
+ function defaultCost(summary, detail) {
40
+ if (detail === "name_only")
41
+ return estimateSkillTokens(`- ${summary.name}`);
42
+ const hint = summary.whenToUse ? ` — ${summary.whenToUse}` : "";
43
+ return estimateSkillTokens(`- ${summary.name}: ${summary.description}${hint}`);
44
+ }
45
+ /**
46
+ * Split the catalog into active and loadable, demoting the loadable tail to
47
+ * names only once the budget is spent.
48
+ *
49
+ * **Demote, never drop.** A skill the model cannot see is a skill it cannot
50
+ * ask for, and a workspace's library grows past any budget worth setting. A
51
+ * bare name still routes: it is enough for the model to call `load_skill` and
52
+ * read the real description. The host is expected to say so in the line it
53
+ * renders for the truncated tail — `truncated` is there to let it.
54
+ *
55
+ * The active set is charged against the budget but never demoted: those bodies
56
+ * are already in the prompt, so shortening their catalog lines would save
57
+ * nothing that matters while hiding what the agent is currently working from.
58
+ */
59
+ export function partitionSkillCatalog(activeSkills, available, options = {}) {
60
+ const budget = options.tokenBudget ?? SKILL_CATALOG_TOKEN_BUDGET;
61
+ const cost = options.cost ?? defaultCost;
62
+ const ordered = [...available].sort(compareSkillSummaries);
63
+ const active = ordered.filter((summary) => activeSkills.has(summary.ref));
64
+ const loadableSummaries = ordered.filter((summary) => !activeSkills.has(summary.ref));
65
+ let spent = active.reduce((total, summary) => total + cost(summary, "full"), 0);
66
+ let truncated = 0;
67
+ const loadable = loadableSummaries.map((summary) => {
68
+ const full = cost(summary, "full");
69
+ if (spent + full <= budget) {
70
+ spent += full;
71
+ return { summary, detail: "full" };
72
+ }
73
+ truncated += 1;
74
+ return { summary, detail: "name_only" };
75
+ });
76
+ return { active, loadable, truncated };
77
+ }