@juno-ai/bind 10.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 +146 -0
- package/index.d.ts +6 -2
- package/index.js +6 -2
- package/package.json +6 -2
- 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/README.md
CHANGED
|
@@ -33,6 +33,27 @@ constraints that will fail CI if you break them.
|
|
|
33
33
|
|
|
34
34
|
### Unreleased
|
|
35
35
|
|
|
36
|
+
**Added**
|
|
37
|
+
|
|
38
|
+
- **`@juno-ai/bind/skills`** — progressive disclosure for *instructions*, the
|
|
39
|
+
mirror of `@juno-ai/bind/plugins`. `createSkillRegistry` for what your source
|
|
40
|
+
ships, `partitionSkillCatalog` for the Tier-1 catalog and its token budget,
|
|
41
|
+
`resolveActiveSkillInstructions` for the bodies (live-head or pinned to a
|
|
42
|
+
completed run's hashes, over a batched `ExternalSkillSource` for skills your
|
|
43
|
+
users author), `createSkillActivation` to drive the loop's `activateSkills`
|
|
44
|
+
port, `admitSkillLoad` for the active-set bounds, `parseSkillMarkdown` /
|
|
45
|
+
`serializeSkillMarkdown` for the `SKILL.md` interchange format over your own
|
|
46
|
+
YAML, and `buildAgentSkillsDiscoveryIndex` for the Agent Skills Discovery RFC
|
|
47
|
+
v0.2.0 document. See "How to give an agent loadable skills".
|
|
48
|
+
|
|
49
|
+
Two notes for a host that already has something like this. The catalog
|
|
50
|
+
returns **data, not prose** — the wording is yours, for the same prompt-cache
|
|
51
|
+
reason `partitionPluginCatalog` gives. And the content digest is **the
|
|
52
|
+
package's**, not a port like the one `toolCallArgsHash` takes: a skill's hash
|
|
53
|
+
is computed at registration, which is synchronous, and it identifies build
|
|
54
|
+
content rather than being persisted across versions. `Sha256Hex` is still a
|
|
55
|
+
parameter if you would rather inject a native one.
|
|
56
|
+
|
|
36
57
|
**Breaking**
|
|
37
58
|
|
|
38
59
|
- `runToolLoop` now returns `ToolLoopResult` (`{ stopReason, stats }`) instead
|
|
@@ -1073,6 +1094,130 @@ data migration.
|
|
|
1073
1094
|
system prompt's business, and re-rendering it byte-identically for unchanged
|
|
1074
1095
|
inputs is what preserves a provider's prompt-cache prefix.
|
|
1075
1096
|
|
|
1097
|
+
### How to give an agent loadable skills
|
|
1098
|
+
|
|
1099
|
+
A *skill* is a markdown procedure or reference module the model can pull into
|
|
1100
|
+
its own instructions: a catalog line it always sees, a body injected into the
|
|
1101
|
+
system prompt once loaded, and resources it may read after that. It is the same
|
|
1102
|
+
progressive disclosure as tool activation, applied to knowledge — and it exists
|
|
1103
|
+
because the accumulated know-how of a real workspace does not fit in a context
|
|
1104
|
+
window, while a catalog line costs about fifty tokens.
|
|
1105
|
+
|
|
1106
|
+
Register what your own source ships:
|
|
1107
|
+
|
|
1108
|
+
```ts
|
|
1109
|
+
import { createSkillRegistry, partitionSkillCatalog } from "@juno-ai/bind/skills";
|
|
1110
|
+
|
|
1111
|
+
const skills = createSkillRegistry({ onWarn: (message, fields) => log.warn(message, fields) });
|
|
1112
|
+
|
|
1113
|
+
skills.registerPlatform({
|
|
1114
|
+
name: "triaging-inbound-work",
|
|
1115
|
+
description: "How this team triages inbound requests.",
|
|
1116
|
+
whenToUse: "When asked to sort, rank, or route a queue of incoming work.",
|
|
1117
|
+
render: () => TRIAGE_BODY,
|
|
1118
|
+
});
|
|
1119
|
+
// A skill contributed by a plugin is offered only to an agent that can load
|
|
1120
|
+
// that plugin — a recipe for tools it cannot call is noise.
|
|
1121
|
+
skills.registerPlugin("documents", DRAFTING_SKILL);
|
|
1122
|
+
```
|
|
1123
|
+
|
|
1124
|
+
Render the catalog from a partition, in your own words:
|
|
1125
|
+
|
|
1126
|
+
```ts
|
|
1127
|
+
const active = new Set<string>(session.activeSkills);
|
|
1128
|
+
const available = skills.summaries({ availablePlugins: agent.plugins });
|
|
1129
|
+
const { active: loaded, loadable, truncated } = partitionSkillCatalog(active, available);
|
|
1130
|
+
```
|
|
1131
|
+
|
|
1132
|
+
`partitionSkillCatalog` returns **data, not prose**, exactly as
|
|
1133
|
+
`partitionPluginCatalog` does. It also enforces a token budget by marking the
|
|
1134
|
+
overflow `name_only` rather than dropping it — a name is still enough for the
|
|
1135
|
+
model to call `load_skill` and read the real description, whereas a skill it
|
|
1136
|
+
cannot see is one it can never ask for. Pass your own `cost` if your line format
|
|
1137
|
+
differs from `- name: description — whenToUse`; a budget is only as honest as
|
|
1138
|
+
its measurement.
|
|
1139
|
+
|
|
1140
|
+
Then wire activation into the loop, and bound how much can be loaded:
|
|
1141
|
+
|
|
1142
|
+
```ts
|
|
1143
|
+
import {
|
|
1144
|
+
admitSkillLoad,
|
|
1145
|
+
createSkillActivation,
|
|
1146
|
+
estimateSkillBodyTokens,
|
|
1147
|
+
resolveActiveSkillInstructions,
|
|
1148
|
+
} from "@juno-ai/bind/skills";
|
|
1149
|
+
|
|
1150
|
+
const loadedSkillShas: Record<string, string> = {};
|
|
1151
|
+
const resolveActiveInstructions = (activeRefs: string[]) =>
|
|
1152
|
+
resolveActiveSkillInstructions({ activeRefs, available, registry: skills });
|
|
1153
|
+
|
|
1154
|
+
const activation = createSkillActivation({
|
|
1155
|
+
availableSkills: available,
|
|
1156
|
+
activeSkills: active, // yours: seeded before the first turn, persisted after the last
|
|
1157
|
+
loadedSkillShas, // yours: hoist it so a failed run still records what it read
|
|
1158
|
+
store: { resolveActiveInstructions },
|
|
1159
|
+
applyInstructions: (instructions) => renderSystemPrompt({ instructions }),
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
await runToolLoop({ ...params, activateSkills: (refs) => activation.activateSkills(refs) });
|
|
1163
|
+
```
|
|
1164
|
+
|
|
1165
|
+
Two details are worth knowing before you wire your own `load_skill` tool. The
|
|
1166
|
+
resolver renders in a **total order** (origin, then name) rather than the order
|
|
1167
|
+
skills were loaded, because these bodies sit high in the system prompt and a
|
|
1168
|
+
resumed session's persisted order would otherwise byte-shift the cacheable
|
|
1169
|
+
prefix. And `admitSkillLoad` is what keeps a looping model from loading its way
|
|
1170
|
+
into a context-limit error — you measure, it judges:
|
|
1171
|
+
|
|
1172
|
+
```ts
|
|
1173
|
+
// Cheapest first. The count is a `Set.size`; the token bound needs the
|
|
1174
|
+
// resolver, which for a host-stored skill is a query plus the wrapping of every
|
|
1175
|
+
// active body. A model that has hit the cap keeps calling `load_skill`, so
|
|
1176
|
+
// folding these into one pass pays that cost on every call purely to refuse.
|
|
1177
|
+
const byCount = admitSkillLoad({ activeCount: active.size });
|
|
1178
|
+
if (!byCount.admitted) return { success: false, kind: "validation", error: byCount.reason };
|
|
1179
|
+
|
|
1180
|
+
const { instructions } = await resolveActiveInstructions([...active, ref]);
|
|
1181
|
+
const byTokens = admitSkillLoad({ projectedBodyTokens: estimateSkillBodyTokens(instructions) });
|
|
1182
|
+
if (!byTokens.admitted) return { success: false, kind: "validation", error: byTokens.reason };
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
Omitting a measurement omits its bound, which is what makes the two-pass shape
|
|
1186
|
+
expressible — and a measurement that arrives broken (`NaN`, negative) refuses
|
|
1187
|
+
rather than admits, because a nonsense count is not evidence of room.
|
|
1188
|
+
|
|
1189
|
+
Skills your *users* author live in your database, not the registry. Hand the
|
|
1190
|
+
resolver an `externalSource` and it routes any ref that is not `platform:<name>`
|
|
1191
|
+
to you — batched, so one activation stays one query:
|
|
1192
|
+
|
|
1193
|
+
```ts
|
|
1194
|
+
resolveActiveSkillInstructions({
|
|
1195
|
+
activeRefs,
|
|
1196
|
+
available,
|
|
1197
|
+
registry: skills,
|
|
1198
|
+
externalSource: async (refs, pinnedShas) => loadWorkspaceSkills(workspaceId, refs, pinnedShas),
|
|
1199
|
+
});
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
`pinnedShas` is how a replay stays honest. Each resolution records the
|
|
1203
|
+
`contentSha` it actually rendered; feed a completed run's map back in and every
|
|
1204
|
+
skill resolves to the body that run saw, so an eval is not silently grading
|
|
1205
|
+
against instructions that were edited afterwards.
|
|
1206
|
+
|
|
1207
|
+
To read or write the interchange format, pass your own YAML implementation —
|
|
1208
|
+
the package takes peer dependencies only:
|
|
1209
|
+
|
|
1210
|
+
```ts
|
|
1211
|
+
import { parseSkillMarkdown } from "@juno-ai/bind/skills";
|
|
1212
|
+
import yaml from "js-yaml";
|
|
1213
|
+
|
|
1214
|
+
const parsed = parseSkillMarkdown(raw, { parse: yaml.load, stringify: (v) => yaml.dump(v, { lineWidth: -1 }) });
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
Import is deliberately lenient — it repairs the unquoted-colon frontmatter
|
|
1218
|
+
mistake and warns — and fails only on frontmatter that is not YAML and on a
|
|
1219
|
+
missing `description`, the one field with no sensible default.
|
|
1220
|
+
|
|
1076
1221
|
### How to restore a persisted activation set
|
|
1077
1222
|
|
|
1078
1223
|
```ts
|
|
@@ -1135,6 +1280,7 @@ keeps a consumer who only wants routing from pulling in the rest.
|
|
|
1135
1280
|
| `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
|
|
1136
1281
|
| `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
|
|
1137
1282
|
| `@juno-ai/bind/plugins` | Tool/plugin vocabulary, `defineTool` / `pluginFromTools`, the wire-definition and tool-result encoders, the registry factory, progressive-disclosure activation | You are authoring tools, or you have more of them than fit comfortably in one prompt |
|
|
1283
|
+
| `@juno-ai/bind/skills` | Skill vocabulary — the code-skill registry, the `SKILL.md` codec, the content hash, the catalog's total order and token budget, the active-instruction resolver, the activation controller, active-set bounds, and the Agent Skills Discovery document | Your agent needs loadable instructions, not just tools — a workspace's procedures, a house style, a runbook |
|
|
1138
1284
|
| `@juno-ai/bind/testing` | Scripted-model fixtures — `loopHarness`, `scriptedModel`, `toolCallTurn`, `finalAnswer`, `freshState`, `recordingSink`, `steppingClock` | You want multi-turn, multi-tool tests without a credential or a mocked chat client |
|
|
1139
1285
|
|
|
1140
1286
|
### Contracts the types do not carry
|
package/index.d.ts
CHANGED
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
* coalesced heartbeat, failure classification, tool-batch pooling, child-run
|
|
12
12
|
* lineage and admission), the streaming-completion watchdog and completion
|
|
13
13
|
* defect detection (`src/completion/`), transcript validation/healing, provider
|
|
14
|
-
* tool-schema sanitization,
|
|
14
|
+
* tool-schema sanitization, the plugin/tool vocabulary with its registry and
|
|
15
15
|
* progressive-disclosure activation — generic over the host's invocation
|
|
16
|
-
* context
|
|
16
|
+
* context — and the skill vocabulary that applies the same disclosure to
|
|
17
|
+
* instructions (`src/skills/` — the code-skill registry, the `SKILL.md` codec,
|
|
18
|
+
* the content hash that makes a replay honest, the catalog budget, the
|
|
19
|
+
* active-instruction resolver and its activation controller). What is NOT here is
|
|
17
20
|
* the run driver: starting a run, recording what it did, and delivering its
|
|
18
21
|
* output. See the README for the rest of what is deliberately absent.
|
|
19
22
|
*/
|
|
@@ -24,4 +27,5 @@ export * from "./run/index.js";
|
|
|
24
27
|
export * from "./transcript/index.js";
|
|
25
28
|
export * from "./tools/index.js";
|
|
26
29
|
export * from "./plugins/index.js";
|
|
30
|
+
export * from "./skills/index.js";
|
|
27
31
|
export * from "./loop/index.js";
|
package/index.js
CHANGED
|
@@ -11,9 +11,12 @@
|
|
|
11
11
|
* coalesced heartbeat, failure classification, tool-batch pooling, child-run
|
|
12
12
|
* lineage and admission), the streaming-completion watchdog and completion
|
|
13
13
|
* defect detection (`src/completion/`), transcript validation/healing, provider
|
|
14
|
-
* tool-schema sanitization,
|
|
14
|
+
* tool-schema sanitization, the plugin/tool vocabulary with its registry and
|
|
15
15
|
* progressive-disclosure activation — generic over the host's invocation
|
|
16
|
-
* context
|
|
16
|
+
* context — and the skill vocabulary that applies the same disclosure to
|
|
17
|
+
* instructions (`src/skills/` — the code-skill registry, the `SKILL.md` codec,
|
|
18
|
+
* the content hash that makes a replay honest, the catalog budget, the
|
|
19
|
+
* active-instruction resolver and its activation controller). What is NOT here is
|
|
17
20
|
* the run driver: starting a run, recording what it did, and delivering its
|
|
18
21
|
* output. See the README for the rest of what is deliberately absent.
|
|
19
22
|
*/
|
|
@@ -24,4 +27,5 @@ export * from "./run/index.js";
|
|
|
24
27
|
export * from "./transcript/index.js";
|
|
25
28
|
export * from "./tools/index.js";
|
|
26
29
|
export * from "./plugins/index.js";
|
|
30
|
+
export * from "./skills/index.js";
|
|
27
31
|
export * from "./loop/index.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juno-ai/bind",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization,
|
|
3
|
+
"version": "11.0.0",
|
|
4
|
+
"description": "Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, the plugin/tool vocabulary, and the skill vocabulary (`./skills`) for progressive knowledge disclosure. MIT-licensed; published to npm from the canonical repo via scripts/publish-bind.ts (docs/bind.md).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./index.js",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"types": "./plugins/index.d.ts",
|
|
44
44
|
"import": "./plugins/index.js"
|
|
45
45
|
},
|
|
46
|
+
"./skills": {
|
|
47
|
+
"types": "./skills/index.d.ts",
|
|
48
|
+
"import": "./skills/index.js"
|
|
49
|
+
},
|
|
46
50
|
"./testing": {
|
|
47
51
|
"types": "./testing/index.d.ts",
|
|
48
52
|
"import": "./testing/index.js"
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type SkillYamlCodec } from "./skill-md.js";
|
|
2
|
+
import { type Sha256Hex } from "./sha256.js";
|
|
3
|
+
import type { RegisteredSkill } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The Agent Skills **discovery index** — how a deployment advertises skills to
|
|
6
|
+
* agents that are not its own.
|
|
7
|
+
*
|
|
8
|
+
* Per the Agent Skills Discovery RFC v0.2.0
|
|
9
|
+
* (https://github.com/cloudflare/agent-skills-discovery-rfc,
|
|
10
|
+
* https://agentskills.io/), an external agent fetches
|
|
11
|
+
* `/.well-known/agent-skills/index.json`, reads the `skills[]` catalogue, pulls
|
|
12
|
+
* each `SKILL.md` from its advertised `url`, and verifies the bytes against the
|
|
13
|
+
* published `digest`. Both documents are generated from the same registry here,
|
|
14
|
+
* so the digest a reader sees always matches the bytes served.
|
|
15
|
+
*
|
|
16
|
+
* **What to publish is a product decision and stays with the host** — hence the
|
|
17
|
+
* explicit `include` allowlist rather than "every platform skill". The
|
|
18
|
+
* criterion that matters is whether a skill helps an outside agent drive
|
|
19
|
+
* *something the deployment actually exposes externally*. A skill about the
|
|
20
|
+
* host's internal agent runtime cannot help an external client and does not
|
|
21
|
+
* belong in a public document; a plugin-contributed skill is tied to tools that
|
|
22
|
+
* are not on the external surface; a workspace's own skills are private data.
|
|
23
|
+
* Making the list an argument also means dropping a new file into a skills
|
|
24
|
+
* folder never publishes it by accident.
|
|
25
|
+
*/
|
|
26
|
+
/** The RFC v0.2.0 schema URL advertised in the index's `$schema` field. */
|
|
27
|
+
export declare const AGENT_SKILLS_DISCOVERY_SCHEMA_URL = "https://schemas.agentskills.io/discovery/0.2.0/schema.json";
|
|
28
|
+
/** Root of the well-known discovery namespace (host-relative per RFC 3986). */
|
|
29
|
+
export declare const AGENT_SKILLS_WELL_KNOWN_BASE = "/.well-known/agent-skills";
|
|
30
|
+
/** Host-relative URL of the `SKILL.md` artifact for a skill name. */
|
|
31
|
+
export declare function skillMarkdownUrl(name: string, base?: string): string;
|
|
32
|
+
/** One entry in the discovery index's `skills[]` array. */
|
|
33
|
+
export interface DiscoverySkillEntry {
|
|
34
|
+
/** Lower-kebab skill identifier. */
|
|
35
|
+
name: string;
|
|
36
|
+
/** Distribution format. Single-file `SKILL.md` skills only. */
|
|
37
|
+
type: "skill-md";
|
|
38
|
+
/** Tier-1 catalogue line (≤ 1024 chars per the RFC). */
|
|
39
|
+
description: string;
|
|
40
|
+
/** Location of the `SKILL.md`, resolved per RFC 3986. */
|
|
41
|
+
url: string;
|
|
42
|
+
/** SHA-256 of the served `SKILL.md` bytes, `sha256:{64-hex}`. */
|
|
43
|
+
digest: string;
|
|
44
|
+
}
|
|
45
|
+
/** The full `/.well-known/agent-skills/index.json` document. */
|
|
46
|
+
export interface AgentSkillsDiscoveryIndex {
|
|
47
|
+
$schema: string;
|
|
48
|
+
skills: DiscoverySkillEntry[];
|
|
49
|
+
}
|
|
50
|
+
export interface SkillDiscoveryOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Names to publish. Anything not registered as a `platform` skill is skipped.
|
|
53
|
+
*
|
|
54
|
+
* Deliberately **not** `Iterable<string>`: this options object is built once
|
|
55
|
+
* and read by both functions below, and a one-shot iterable (a generator, a
|
|
56
|
+
* `Map.keys()`) would be drained by the first call — so the index would
|
|
57
|
+
* publish the allowlist and every subsequent `SKILL.md` fetch would 404, with
|
|
58
|
+
* nothing in either signature to suggest why. Both accepted forms are
|
|
59
|
+
* re-iterable, which makes that failure unrepresentable rather than merely
|
|
60
|
+
* documented.
|
|
61
|
+
*/
|
|
62
|
+
include: readonly string[] | ReadonlySet<string>;
|
|
63
|
+
/** The registry to read from; the host bootstraps it before calling. */
|
|
64
|
+
registry: {
|
|
65
|
+
get(name: string): RegisteredSkill | undefined;
|
|
66
|
+
};
|
|
67
|
+
yaml: SkillYamlCodec;
|
|
68
|
+
/** Base for the advertised `url`. Host-relative by default. */
|
|
69
|
+
baseUrl?: string;
|
|
70
|
+
sha256Hex?: Sha256Hex;
|
|
71
|
+
}
|
|
72
|
+
/** Build the discovery index from the allowlisted, registered platform skills. */
|
|
73
|
+
export declare function buildAgentSkillsDiscoveryIndex(options: SkillDiscoveryOptions): AgentSkillsDiscoveryIndex;
|
|
74
|
+
/**
|
|
75
|
+
* The published `SKILL.md` text for a name, or `null` when it is not published.
|
|
76
|
+
*
|
|
77
|
+
* One return value for "not on the allowlist", "not registered", and
|
|
78
|
+
* "registered but not a platform skill": the caller serves a 404 for all three,
|
|
79
|
+
* and distinguishing them would tell an anonymous reader which internal skills
|
|
80
|
+
* exist.
|
|
81
|
+
*/
|
|
82
|
+
export declare function getPublishedSkillMarkdown(name: string, options: SkillDiscoveryOptions): string | null;
|