@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.
- package/README.md +375 -15
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +77 -2
- package/contracts/turn.js +35 -2
- package/index.d.ts +6 -2
- package/index.js +6 -2
- package/loop/index.d.ts +2 -1
- package/loop/index.js +1 -1
- package/loop/tool-loop.d.ts +117 -12
- package/loop/tool-loop.js +242 -67
- package/package.json +10 -2
- package/plugins/dispatch.d.ts +130 -0
- package/plugins/dispatch.js +241 -0
- package/plugins/index.d.ts +2 -0
- package/plugins/index.js +2 -0
- package/plugins/tool-message.d.ts +23 -0
- package/plugins/tool-message.js +31 -0
- package/skills/activation.d.ts +64 -0
- package/skills/activation.js +39 -0
- package/skills/admission.d.ts +61 -0
- package/skills/admission.js +41 -0
- package/skills/catalog.d.ts +54 -0
- package/skills/catalog.js +77 -0
- package/skills/discovery.d.ts +82 -0
- package/skills/discovery.js +91 -0
- package/skills/index.d.ts +19 -0
- package/skills/index.js +19 -0
- package/skills/refs.d.ts +21 -0
- package/skills/refs.js +27 -0
- package/skills/registry.d.ts +57 -0
- package/skills/registry.js +94 -0
- package/skills/resolve.d.ts +89 -0
- package/skills/resolve.js +124 -0
- package/skills/sha.d.ts +53 -0
- package/skills/sha.js +60 -0
- package/skills/sha256.d.ts +38 -0
- package/skills/sha256.js +122 -0
- package/skills/skill-md.d.ts +73 -0
- package/skills/skill-md.js +149 -0
- package/skills/types.d.ts +174 -0
- package/skills/types.js +55 -0
- package/testing/index.d.ts +153 -0
- package/testing/index.js +188 -0
- package/tools/control-chars.d.ts +23 -0
- 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
|
+
}
|
package/plugins/index.d.ts
CHANGED
|
@@ -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
|
+
}
|