@frockbot/plugin-skills 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/frockbot.json +15 -0
- package/package.json +36 -6
- package/src/agent.test.ts +360 -0
- package/src/agent.ts +678 -0
- package/src/catalog.test.ts +364 -0
- package/src/catalog.ts +704 -0
- package/src/index.ts +8 -0
- package/src/managed.ts +233 -0
- package/src/manifest.ts +3 -0
- package/src/plugin-index.ts +194 -0
- package/src/quota.ts +159 -0
- package/src/skill-md.test.ts +98 -0
- package/src/skill-md.ts +163 -0
- package/src/sources.test.ts +760 -0
- package/src/testing.ts +175 -0
- package/src/write.ts +181 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/agent.ts
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// The Skills runtime Contribution.
|
|
2
|
+
//
|
|
3
|
+
// Three responsibilities, and no authority of its own:
|
|
4
|
+
//
|
|
5
|
+
// 1. Load the Bot's Skills once per admitted Turn, through the
|
|
6
|
+
// kernel-declared `WorkspaceReadsV1`. "Skills are files under the Bot's
|
|
7
|
+
// instruction roots. An edit is visible to the Bot on its next admitted
|
|
8
|
+
// Turn" — so the catalog is loaded at the Turn's first step and reused for
|
|
9
|
+
// every later step of that Turn, and an edit made mid-Turn is not visible
|
|
10
|
+
// until the next one.
|
|
11
|
+
// 2. Record what it injected. "What Memory enters a model request, and when,
|
|
12
|
+
// is Package policy, and the session event log records exactly what was
|
|
13
|
+
// injected, so an injection gap is visible in durable state rather than
|
|
14
|
+
// silently changing the Bot's behavior." A Skill is an instruction, so the
|
|
15
|
+
// same rule binds: `skill/injected` names every loaded Skill with its
|
|
16
|
+
// generation, and every refused candidate with its reason.
|
|
17
|
+
// 3. Offer the two tools: `skill_load` reads one body on demand (progressive
|
|
18
|
+
// disclosure, GrokBot parity), `skill_write` authors a Skill into one of
|
|
19
|
+
// the Bot's instruction roots — its own (self-modification) or its User's
|
|
20
|
+
// shared root, which every Bot of that User reads (ADR 0016).
|
|
21
|
+
//
|
|
22
|
+
// It never calls the Computer interface and never wakes a Computer; see the
|
|
23
|
+
// hibernation seam documented in `./catalog.ts`.
|
|
24
|
+
import type {
|
|
25
|
+
Session,
|
|
26
|
+
SkillRefV1,
|
|
27
|
+
ToolDefinition,
|
|
28
|
+
ToolExecutionContext,
|
|
29
|
+
WorkspaceFilesV1,
|
|
30
|
+
WorkspaceReadsV1,
|
|
31
|
+
WorkspaceWriteRequestV1,
|
|
32
|
+
} from "@frockbot/kernel-contracts";
|
|
33
|
+
import { formatSkillRefV1, parseSkillRefV1 } from "@frockbot/kernel-contracts";
|
|
34
|
+
// Merges the Agent loop's event declarations into the cordis Context type.
|
|
35
|
+
import type {} from "@frockbot/kernel-agent-loop/agent";
|
|
36
|
+
import type { Plugin } from "cordis";
|
|
37
|
+
import {
|
|
38
|
+
botInstructionRootV1,
|
|
39
|
+
countSkillDocumentsV1,
|
|
40
|
+
emptySkillCatalogV1,
|
|
41
|
+
type InvokedSkillV1,
|
|
42
|
+
loadFullSkillCatalogV1,
|
|
43
|
+
renderInvokedSkillsPromptV1,
|
|
44
|
+
renderSkillCatalogPromptV1,
|
|
45
|
+
resolveSkillRefV1,
|
|
46
|
+
type SkillCatalogV1,
|
|
47
|
+
type SkillOwnerV1,
|
|
48
|
+
} from "./catalog.js";
|
|
49
|
+
import type { PluginSkillsSourceV1 } from "./plugin-index.js";
|
|
50
|
+
import { writeSkillDocumentV1 } from "./write.js";
|
|
51
|
+
import {
|
|
52
|
+
checkSkillQuotaV1,
|
|
53
|
+
SKILL_QUOTA_DEFAULTS_V1,
|
|
54
|
+
type SkillQuotaConfigV1,
|
|
55
|
+
type SkillQuotaScopeV1,
|
|
56
|
+
} from "./quota.js";
|
|
57
|
+
import {
|
|
58
|
+
isSkillSlugV1,
|
|
59
|
+
skillSlugFromNameV1,
|
|
60
|
+
SKILL_MAX_DESCRIPTION_LENGTH,
|
|
61
|
+
SKILL_MAX_NAME_LENGTH,
|
|
62
|
+
} from "./skill-md.js";
|
|
63
|
+
|
|
64
|
+
/** Bot write provenance: the Session and Turn that authored a Skill. */
|
|
65
|
+
export interface SkillWriterIdentityV1 {
|
|
66
|
+
sessionId: string;
|
|
67
|
+
turnId: string;
|
|
68
|
+
runId: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The host seam this Package receives. The Durable Object supplies it for one
|
|
73
|
+
* admitted Turn: `reads` is always present, `files` and `writer` only when the
|
|
74
|
+
* Turn may author, so a Bot cannot write a Skill outside a Turn whose Session
|
|
75
|
+
* and Turn its provenance can name.
|
|
76
|
+
*/
|
|
77
|
+
export interface SkillsRuntimeHostV1 {
|
|
78
|
+
owner: SkillOwnerV1;
|
|
79
|
+
reads: WorkspaceReadsV1;
|
|
80
|
+
files?: WorkspaceFilesV1;
|
|
81
|
+
writer?: SkillWriterIdentityV1;
|
|
82
|
+
quota?: SkillQuotaConfigV1;
|
|
83
|
+
/**
|
|
84
|
+
* The index over the User's installed Catalog entries. Absent when the
|
|
85
|
+
* deployment has no Catalog, and the Turn then carries no plugin-borne
|
|
86
|
+
* Skills — which is the true answer, not a failure.
|
|
87
|
+
*/
|
|
88
|
+
pluginSkills?: PluginSkillsSourceV1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function sha256HexV1(text: string): Promise<string> {
|
|
92
|
+
const digest = await crypto.subtle.digest(
|
|
93
|
+
"SHA-256",
|
|
94
|
+
new TextEncoder().encode(text),
|
|
95
|
+
);
|
|
96
|
+
return [...new Uint8Array(digest)]
|
|
97
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
98
|
+
.join("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The turn and step a Skill write is recorded under. */
|
|
102
|
+
export interface SkillTurnPositionV1 {
|
|
103
|
+
turn: number;
|
|
104
|
+
step: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The open step a Skill event belongs to. The session log is the
|
|
109
|
+
* reconstruction surface, so an event without its turn and step would not
|
|
110
|
+
* replay in place.
|
|
111
|
+
*/
|
|
112
|
+
export function openSkillTurnPositionV1(session: Session): SkillTurnPositionV1 {
|
|
113
|
+
const started = session.events.findLast(
|
|
114
|
+
(event) => event.type === "step/start",
|
|
115
|
+
);
|
|
116
|
+
const ended = session.events.findLast((event) => event.type === "step/end");
|
|
117
|
+
if (started?.type !== "step/start") {
|
|
118
|
+
throw new Error("a Skill write has no open step to record against");
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
ended?.type === "step/end" &&
|
|
122
|
+
ended.turn === started.turn &&
|
|
123
|
+
ended.step === started.step
|
|
124
|
+
) {
|
|
125
|
+
throw new Error("a Skill write has no open step to record against");
|
|
126
|
+
}
|
|
127
|
+
return { turn: started.turn, step: started.step };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What resolving a Turn's invoked refs produced. Declared, never thrown. */
|
|
131
|
+
export type SkillInvocationOutcomeV1 =
|
|
132
|
+
| { status: "ok"; invoked: InvokedSkillV1[] }
|
|
133
|
+
| { status: "unresolved"; reason: string };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The Turn-scoped catalog. Deep module, small surface: `refresh` is the only
|
|
137
|
+
* way a catalog changes, and `current` is what the prompt and `skill_load`
|
|
138
|
+
* both read, so those two can never disagree about what this Turn loaded.
|
|
139
|
+
*/
|
|
140
|
+
export class SkillCatalog {
|
|
141
|
+
#owner: SkillOwnerV1;
|
|
142
|
+
#reads: WorkspaceReadsV1;
|
|
143
|
+
#pluginSkills: PluginSkillsSourceV1 | undefined;
|
|
144
|
+
#catalog: SkillCatalogV1;
|
|
145
|
+
#turn: number | undefined;
|
|
146
|
+
#invoked: InvokedSkillV1[] = [];
|
|
147
|
+
#invokedTurn: number | undefined;
|
|
148
|
+
#step: { turn: number; step: number } | undefined;
|
|
149
|
+
|
|
150
|
+
constructor(
|
|
151
|
+
owner: SkillOwnerV1,
|
|
152
|
+
reads: WorkspaceReadsV1,
|
|
153
|
+
pluginSkills?: PluginSkillsSourceV1,
|
|
154
|
+
) {
|
|
155
|
+
this.#owner = owner;
|
|
156
|
+
this.#reads = reads;
|
|
157
|
+
this.#pluginSkills = pluginSkills;
|
|
158
|
+
this.#catalog = emptySkillCatalogV1(owner);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
current(): SkillCatalogV1 {
|
|
162
|
+
return this.#catalog;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
loadedTurn(): number | undefined {
|
|
166
|
+
return this.#turn;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Loads the Turn's Skills and records the injection in the session log. */
|
|
170
|
+
async refresh(turn: number, session: Session): Promise<SkillCatalogV1> {
|
|
171
|
+
this.#catalog = await loadFullSkillCatalogV1(this.#reads, this.#owner, {
|
|
172
|
+
...(this.#pluginSkills ? { pluginSkills: this.#pluginSkills } : {}),
|
|
173
|
+
});
|
|
174
|
+
this.#turn = turn;
|
|
175
|
+
session.append({
|
|
176
|
+
type: "skill/injected",
|
|
177
|
+
turn,
|
|
178
|
+
skills: this.#catalog.skills.map((skill) => ({
|
|
179
|
+
path: skill.path,
|
|
180
|
+
name: skill.name,
|
|
181
|
+
generationId: skill.generationId,
|
|
182
|
+
contentHash: skill.contentHash,
|
|
183
|
+
// Whose Skill it is, when it is not this Bot's own. A shared tier
|
|
184
|
+
// whose durable record did not say who wrote the instruction would
|
|
185
|
+
// make "the Bot ran under an instruction it did not author" invisible.
|
|
186
|
+
...(skill.by ? { by: skill.by } : {}),
|
|
187
|
+
})),
|
|
188
|
+
refusals: this.#catalog.refusals.map((refusal) => ({
|
|
189
|
+
path: refusal.path,
|
|
190
|
+
reason: `${refusal.kind}: ${refusal.reason}`,
|
|
191
|
+
})),
|
|
192
|
+
});
|
|
193
|
+
await session.flush();
|
|
194
|
+
return this.#catalog;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resolves the Skills one Turn's input invoked, and records each one.
|
|
199
|
+
*
|
|
200
|
+
* An unresolvable ref is a declared failure, never a silent drop: a User who
|
|
201
|
+
* typed `/daily-standup` and got an answer that ignored it would have no way
|
|
202
|
+
* to tell. The caller turns this into a blocked Turn with the reason.
|
|
203
|
+
*/
|
|
204
|
+
async invoke(
|
|
205
|
+
turn: number,
|
|
206
|
+
session: Session,
|
|
207
|
+
refs: readonly SkillRefV1[],
|
|
208
|
+
): Promise<SkillInvocationOutcomeV1> {
|
|
209
|
+
const invoked: InvokedSkillV1[] = [];
|
|
210
|
+
for (const ref of refs) {
|
|
211
|
+
const skill = resolveSkillRefV1(this.#catalog, ref);
|
|
212
|
+
if (!skill) {
|
|
213
|
+
return {
|
|
214
|
+
status: "unresolved",
|
|
215
|
+
reason: `no Skill "${formatSkillRefV1(ref)}" is available to this Bot on this Turn`,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
invoked.push({ ref, skill });
|
|
219
|
+
}
|
|
220
|
+
if (invoked.length > 0) {
|
|
221
|
+
session.appendBatch(
|
|
222
|
+
invoked.map((entry) => ({
|
|
223
|
+
type: "skill/invoked" as const,
|
|
224
|
+
turn,
|
|
225
|
+
ref: entry.ref,
|
|
226
|
+
generationId: entry.skill.generationId,
|
|
227
|
+
contentHash: entry.skill.contentHash,
|
|
228
|
+
})),
|
|
229
|
+
);
|
|
230
|
+
await session.flush();
|
|
231
|
+
}
|
|
232
|
+
this.#invoked = invoked;
|
|
233
|
+
this.#invokedTurn = turn;
|
|
234
|
+
return { status: "ok", invoked };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The Skills whose bodies belong in the request being assembled right now.
|
|
239
|
+
*
|
|
240
|
+
* Empty unless this is the first step of the Turn that invoked them: an
|
|
241
|
+
* invocation expands once, into the step the User's message enters, and the
|
|
242
|
+
* later steps of the same Turn run on the conversation the expansion already
|
|
243
|
+
* produced.
|
|
244
|
+
*/
|
|
245
|
+
invokedFor(turn: number, step: number): readonly InvokedSkillV1[] {
|
|
246
|
+
return turn === this.#invokedTurn && step === 1 ? this.#invoked : [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The step whose request the prompt is being assembled for. */
|
|
250
|
+
enterStep(turn: number, step: number): void {
|
|
251
|
+
this.#step = { turn, step };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The invoked bodies for the open step, as the prompt section renders them. */
|
|
255
|
+
currentInvoked(): readonly InvokedSkillV1[] {
|
|
256
|
+
const open = this.#step;
|
|
257
|
+
return open ? this.invokedFor(open.turn, open.step) : [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Drops the catalog, so the next Turn reloads it rather than reusing it. */
|
|
261
|
+
invalidate(): void {
|
|
262
|
+
this.#catalog = emptySkillCatalogV1(this.#owner);
|
|
263
|
+
this.#turn = undefined;
|
|
264
|
+
this.#invoked = [];
|
|
265
|
+
this.#invokedTurn = undefined;
|
|
266
|
+
this.#step = undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const SKILL_LOAD_INPUT_SCHEMA = {
|
|
271
|
+
type: "object",
|
|
272
|
+
properties: {
|
|
273
|
+
path: {
|
|
274
|
+
type: "string",
|
|
275
|
+
description:
|
|
276
|
+
"The Skill's ref exactly as listed in <agent_skills> — bot/daily-standup, managed/add-connector, or plugin/<packageId>/<slug>. The path listed beside it is also accepted.",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
required: ["path"],
|
|
280
|
+
additionalProperties: false,
|
|
281
|
+
} as const;
|
|
282
|
+
|
|
283
|
+
const SKILL_WRITE_INPUT_SCHEMA = {
|
|
284
|
+
type: "object",
|
|
285
|
+
properties: {
|
|
286
|
+
name: { type: "string", description: "The Skill's display name." },
|
|
287
|
+
description: {
|
|
288
|
+
type: "string",
|
|
289
|
+
description:
|
|
290
|
+
'When to use this Skill, phrased as "Use this when ...". This is the only part always in your prompt.',
|
|
291
|
+
},
|
|
292
|
+
body: {
|
|
293
|
+
type: "string",
|
|
294
|
+
description: "The Markdown recipe the Skill runs through.",
|
|
295
|
+
},
|
|
296
|
+
slug: {
|
|
297
|
+
type: "string",
|
|
298
|
+
description:
|
|
299
|
+
"Optional directory slug, lowercase letters, digits and hyphens. Derived from the name when omitted. Reuse a slug to supersede that Skill.",
|
|
300
|
+
},
|
|
301
|
+
scope: {
|
|
302
|
+
type: "string",
|
|
303
|
+
enum: ["bot", "user"],
|
|
304
|
+
description:
|
|
305
|
+
"Where the Skill is written: your own instruction root (bot, the default), or your User's shared root (user), where every one of their Bots can read it. Managed and plugin Skills are not editable this way.",
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
required: ["name", "description", "body"],
|
|
309
|
+
additionalProperties: false,
|
|
310
|
+
} as const;
|
|
311
|
+
|
|
312
|
+
/** C0 controls, DEL, and the C1 range: never valid in a frontmatter scalar. */
|
|
313
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Where a `skill_write` lands.
|
|
317
|
+
*
|
|
318
|
+
* All four sources are named so a refusal can be specific about *why* two of
|
|
319
|
+
* them are not writable, rather than reading as an unknown-field error. `bot`
|
|
320
|
+
* and `user` are the two instruction roots (ADR 0016) and both are written the
|
|
321
|
+
* same way, with the Bot's own provenance recorded. `managed` and `plugin` are
|
|
322
|
+
* not durable-root files at all — one is bytes of a first-party artifact, the
|
|
323
|
+
* other an index over a pinned Catalog generation — so neither has a write
|
|
324
|
+
* path to route to.
|
|
325
|
+
*/
|
|
326
|
+
export type SkillWriteScopeV1 = "bot" | "user" | "managed" | "plugin";
|
|
327
|
+
|
|
328
|
+
const SKILL_WRITE_SCOPES: readonly SkillWriteScopeV1[] = [
|
|
329
|
+
"bot",
|
|
330
|
+
"user",
|
|
331
|
+
"managed",
|
|
332
|
+
"plugin",
|
|
333
|
+
];
|
|
334
|
+
|
|
335
|
+
/** Why a scope is refused, or `undefined` when it is writable. GrokBot's own wording for managed. */
|
|
336
|
+
export function skillWriteScopeRefusalV1(
|
|
337
|
+
scope: SkillWriteScopeV1,
|
|
338
|
+
): string | undefined {
|
|
339
|
+
const target = skillWriteTargetV1(scope);
|
|
340
|
+
return target.status === "refused" ? target.reason : undefined;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The instruction root a scope writes, or the reason there is none.
|
|
345
|
+
*
|
|
346
|
+
* Declared rather than narrowed at the call site: the two writable scopes are
|
|
347
|
+
* exactly the two instruction roots, and this is the one place that says so,
|
|
348
|
+
* so a caller cannot reach the write path holding `managed`.
|
|
349
|
+
*/
|
|
350
|
+
export type SkillWriteTargetV1 =
|
|
351
|
+
| { status: "writable"; scope: SkillQuotaScopeV1 }
|
|
352
|
+
| { status: "refused"; reason: string };
|
|
353
|
+
|
|
354
|
+
export function skillWriteTargetV1(
|
|
355
|
+
scope: SkillWriteScopeV1,
|
|
356
|
+
): SkillWriteTargetV1 {
|
|
357
|
+
switch (scope) {
|
|
358
|
+
case "bot":
|
|
359
|
+
case "user":
|
|
360
|
+
return { status: "writable", scope };
|
|
361
|
+
case "managed":
|
|
362
|
+
case "plugin":
|
|
363
|
+
return {
|
|
364
|
+
status: "refused",
|
|
365
|
+
reason: "managed skills are not editable this way",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
interface SkillWriteInputV1 {
|
|
371
|
+
name: string;
|
|
372
|
+
description: string;
|
|
373
|
+
body: string;
|
|
374
|
+
slug?: string;
|
|
375
|
+
scope?: SkillWriteScopeV1;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function decodeSkillWriteInputV1(input: unknown): SkillWriteInputV1 {
|
|
379
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
380
|
+
throw new Error("skill_write input must be an object");
|
|
381
|
+
}
|
|
382
|
+
const value = input as Record<string, unknown>;
|
|
383
|
+
const allowed = ["name", "description", "body", "slug", "scope"];
|
|
384
|
+
if (!Object.keys(value).every((key) => allowed.includes(key))) {
|
|
385
|
+
throw new Error("skill_write input has unknown fields");
|
|
386
|
+
}
|
|
387
|
+
const text = (key: string, maximum: number, singleLine: boolean): string => {
|
|
388
|
+
const candidate = value[key];
|
|
389
|
+
if (
|
|
390
|
+
typeof candidate !== "string" ||
|
|
391
|
+
candidate.trim().length === 0 ||
|
|
392
|
+
candidate.length > maximum
|
|
393
|
+
) {
|
|
394
|
+
throw new Error(`skill_write ${key} must be a bounded string`);
|
|
395
|
+
}
|
|
396
|
+
// `name` and `description` become one frontmatter line each. A newline or
|
|
397
|
+
// a control character there renders a `SKILL.md` this Package's own parser
|
|
398
|
+
// refuses, so the Bot would have written a Skill it can never load.
|
|
399
|
+
if (singleLine && CONTROL_CHARACTERS.test(candidate)) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`skill_write ${key} must not contain newlines or control characters`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
return candidate.trim();
|
|
405
|
+
};
|
|
406
|
+
const decoded: SkillWriteInputV1 = {
|
|
407
|
+
name: text("name", SKILL_MAX_NAME_LENGTH, true),
|
|
408
|
+
description: text("description", SKILL_MAX_DESCRIPTION_LENGTH, true),
|
|
409
|
+
body: text("body", 65_536, false),
|
|
410
|
+
};
|
|
411
|
+
if (value.slug !== undefined) {
|
|
412
|
+
if (!isSkillSlugV1(value.slug)) {
|
|
413
|
+
throw new Error("skill_write slug is invalid");
|
|
414
|
+
}
|
|
415
|
+
decoded.slug = value.slug;
|
|
416
|
+
}
|
|
417
|
+
if (value.scope !== undefined) {
|
|
418
|
+
const scope = SKILL_WRITE_SCOPES.find(
|
|
419
|
+
(candidate) => candidate === value.scope,
|
|
420
|
+
);
|
|
421
|
+
if (!scope) throw new Error("skill_write scope is invalid");
|
|
422
|
+
decoded.scope = scope;
|
|
423
|
+
}
|
|
424
|
+
return decoded;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function createSkillLoadTool(catalog: SkillCatalog): ToolDefinition {
|
|
428
|
+
return {
|
|
429
|
+
name: "skill_load",
|
|
430
|
+
// A general work tool: the full toolset an `executor` subagent gets, and
|
|
431
|
+
// not part of the narrow reach of `browserUse`, `computerUse`, or the two
|
|
432
|
+
// video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
|
|
433
|
+
admission: { subagentRoles: ["executor"] },
|
|
434
|
+
description:
|
|
435
|
+
"Read one of your Skills in full. Pass the path listed in <agent_skills>. Only Skills listed there can be loaded.",
|
|
436
|
+
inputSchema: SKILL_LOAD_INPUT_SCHEMA as unknown as Record<string, unknown>,
|
|
437
|
+
idempotent: true,
|
|
438
|
+
validate: (input: unknown) =>
|
|
439
|
+
!!input &&
|
|
440
|
+
typeof input === "object" &&
|
|
441
|
+
typeof (input as { path?: unknown }).path === "string",
|
|
442
|
+
execute: (input: unknown) => {
|
|
443
|
+
const named = String((input as { path: string }).path).trim();
|
|
444
|
+
const loaded = catalog.current().skills;
|
|
445
|
+
// A ref first, then the path. Both are printed in `<agent_skills>`, and
|
|
446
|
+
// a ref is the only form that names a managed or plugin Skill, since
|
|
447
|
+
// neither is a file under any root the Bot could path into.
|
|
448
|
+
const ref = parseSkillRefV1(named);
|
|
449
|
+
const skill =
|
|
450
|
+
(ref
|
|
451
|
+
? loaded.find(
|
|
452
|
+
(candidate) =>
|
|
453
|
+
candidate.ref !== undefined &&
|
|
454
|
+
formatSkillRefV1(candidate.ref) === formatSkillRefV1(ref),
|
|
455
|
+
)
|
|
456
|
+
: undefined) ?? loaded.find((candidate) => candidate.path === named);
|
|
457
|
+
if (!skill) {
|
|
458
|
+
// A candidate refused as an instruction is not readable here either:
|
|
459
|
+
// `skill_load` discloses only what this Turn actually loaded.
|
|
460
|
+
return Promise.resolve({
|
|
461
|
+
content: `No Skill "${named}" is loaded for this Turn. Use only the refs listed in <agent_skills>.`,
|
|
462
|
+
isError: true,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
return Promise.resolve({
|
|
466
|
+
content: [
|
|
467
|
+
`# ${skill.name}`,
|
|
468
|
+
`${skill.ref ? `Ref: ${formatSkillRefV1(skill.ref)}\n` : ""}Path: ${skill.path} (generation ${skill.generationId})`,
|
|
469
|
+
"",
|
|
470
|
+
skill.body,
|
|
471
|
+
].join("\n"),
|
|
472
|
+
isError: false,
|
|
473
|
+
});
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function writeRefusal(reason: string): { content: string; isError: boolean } {
|
|
479
|
+
return { content: `skill_write was refused: ${reason}`, isError: true };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The effect id one Skill write is recorded under.
|
|
484
|
+
*
|
|
485
|
+
* The root is part of it. The two instruction roots address Skills by the same
|
|
486
|
+
* relative path, so `bot` and `user` writes of one slug with one body would
|
|
487
|
+
* otherwise share an id, and a replay could match the wrong recorded effect.
|
|
488
|
+
* `bot` keeps its historical form, so ids already in durable logs still match.
|
|
489
|
+
*/
|
|
490
|
+
function effectIdOf(
|
|
491
|
+
scope: SkillQuotaScopeV1,
|
|
492
|
+
path: string,
|
|
493
|
+
contentHash: string,
|
|
494
|
+
): string {
|
|
495
|
+
return scope === "bot"
|
|
496
|
+
? `skill:${path}:${contentHash}`
|
|
497
|
+
: `skill:${scope}:${path}:${contentHash}`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function createSkillWriteTool(
|
|
501
|
+
host: SkillsRuntimeHostV1 & { files: WorkspaceFilesV1 },
|
|
502
|
+
writer: SkillWriterIdentityV1,
|
|
503
|
+
sessions: { get(sessionId: string): Session | undefined },
|
|
504
|
+
): ToolDefinition {
|
|
505
|
+
const quota = host.quota ?? SKILL_QUOTA_DEFAULTS_V1;
|
|
506
|
+
return {
|
|
507
|
+
name: "skill_write",
|
|
508
|
+
// A general work tool: the full toolset an `executor` subagent gets, and
|
|
509
|
+
// not part of the narrow reach of `browserUse`, `computerUse`, or the two
|
|
510
|
+
// video roles. See `@frockbot/plugin-subagents` `SUBAGENT_TOOL_REACH_V1`.
|
|
511
|
+
admission: { subagentRoles: ["executor"] },
|
|
512
|
+
description:
|
|
513
|
+
"Write a Skill: a Markdown recipe stored under your own instruction root, or under your User's shared root where all of their Bots can read it. It becomes visible to you on your next Turn, not this one.",
|
|
514
|
+
inputSchema: SKILL_WRITE_INPUT_SCHEMA as unknown as Record<string, unknown>,
|
|
515
|
+
idempotent: false,
|
|
516
|
+
validate: (input: unknown) => {
|
|
517
|
+
try {
|
|
518
|
+
decodeSkillWriteInputV1(input);
|
|
519
|
+
return true;
|
|
520
|
+
} catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
execute: async (input: unknown, context: ToolExecutionContext) => {
|
|
525
|
+
let decoded: SkillWriteInputV1;
|
|
526
|
+
try {
|
|
527
|
+
decoded = decodeSkillWriteInputV1(input);
|
|
528
|
+
} catch (error) {
|
|
529
|
+
return writeRefusal(
|
|
530
|
+
error instanceof Error ? error.message : String(error),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
const target = skillWriteTargetV1(decoded.scope ?? "bot");
|
|
534
|
+
if (target.status === "refused") return writeRefusal(target.reason);
|
|
535
|
+
const scope = target.scope;
|
|
536
|
+
const slug = decoded.slug ?? skillSlugFromNameV1(decoded.name);
|
|
537
|
+
if (!slug) {
|
|
538
|
+
return writeRefusal(
|
|
539
|
+
"the Skill name yields no usable slug; pass an explicit slug",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
const session = sessions.get(context.sessionId);
|
|
543
|
+
if (!session) {
|
|
544
|
+
return writeRefusal(
|
|
545
|
+
`session "${context.sessionId}" is unavailable, so the intent cannot be recorded`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
let position: { turn: number; step: number };
|
|
549
|
+
try {
|
|
550
|
+
position = openSkillTurnPositionV1(session);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
return writeRefusal(
|
|
553
|
+
error instanceof Error ? error.message : String(error),
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
// One write path, shared with the template import (`./write.ts`); the
|
|
557
|
+
// only thing that differs between them is the writer, and here it is
|
|
558
|
+
// this Bot inside the Turn whose Session and Turn it names.
|
|
559
|
+
const outcome = await writeSkillDocumentV1(
|
|
560
|
+
host.files,
|
|
561
|
+
host.owner,
|
|
562
|
+
{
|
|
563
|
+
kind: "bot",
|
|
564
|
+
botId: host.owner.botId,
|
|
565
|
+
sessionId: writer.sessionId,
|
|
566
|
+
turnId: writer.turnId,
|
|
567
|
+
runId: writer.runId,
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
slug,
|
|
571
|
+
name: decoded.name,
|
|
572
|
+
description: decoded.description,
|
|
573
|
+
body: decoded.body,
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
scope,
|
|
577
|
+
quota,
|
|
578
|
+
// Intent before effect, and durable before the write is attempted.
|
|
579
|
+
onIntent: async ({ path: relativePath, contentHash }) => {
|
|
580
|
+
session.append({
|
|
581
|
+
type: "skill/write-intent",
|
|
582
|
+
...position,
|
|
583
|
+
effectId: effectIdOf(scope, relativePath, contentHash),
|
|
584
|
+
path: relativePath,
|
|
585
|
+
contentHash,
|
|
586
|
+
});
|
|
587
|
+
await session.flush();
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
);
|
|
591
|
+
if (outcome.status === "refused") return writeRefusal(outcome.reason);
|
|
592
|
+
session.append({
|
|
593
|
+
type: "skill/written",
|
|
594
|
+
...position,
|
|
595
|
+
effectId: effectIdOf(scope, outcome.path, outcome.contentHash),
|
|
596
|
+
path: outcome.path,
|
|
597
|
+
generationId: outcome.generationId,
|
|
598
|
+
contentHash: outcome.contentHash,
|
|
599
|
+
});
|
|
600
|
+
// The model must not be told it succeeded before the record is durable.
|
|
601
|
+
await session.flush();
|
|
602
|
+
return {
|
|
603
|
+
content: [
|
|
604
|
+
`Wrote Skill "${decoded.name}" to ${outcome.path} as generation ${outcome.generationId}.`,
|
|
605
|
+
scope === "user"
|
|
606
|
+
? "It is under your User's shared instruction root, with your provenance recorded, so every one of their Bots can read it and will be told you wrote it."
|
|
607
|
+
: "It is under your own instruction root with your provenance recorded.",
|
|
608
|
+
"Your Skill catalog is fixed for this Turn, so it appears in <agent_skills> on your next Turn.",
|
|
609
|
+
].join(" "),
|
|
610
|
+
isError: false,
|
|
611
|
+
};
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The runtime Contribution. Registers the prompt section, `skill_load`, and —
|
|
618
|
+
* only when the host supplies a writable Workspace and Bot provenance —
|
|
619
|
+
* `skill_write`.
|
|
620
|
+
*/
|
|
621
|
+
export function createSkillsRuntimePlugin(
|
|
622
|
+
host: SkillsRuntimeHostV1,
|
|
623
|
+
): Plugin.Function {
|
|
624
|
+
const plugin: Plugin.Function = (ctx) => {
|
|
625
|
+
const catalog = new SkillCatalog(host.owner, host.reads, host.pluginSkills);
|
|
626
|
+
const disposers: Array<() => void> = [];
|
|
627
|
+
disposers.push(
|
|
628
|
+
ctx.systemPrompt.register({
|
|
629
|
+
id: "skills",
|
|
630
|
+
order: 90,
|
|
631
|
+
render: () =>
|
|
632
|
+
[
|
|
633
|
+
renderSkillCatalogPromptV1(catalog.current()),
|
|
634
|
+
renderInvokedSkillsPromptV1(catalog.currentInvoked()),
|
|
635
|
+
]
|
|
636
|
+
.filter((block) => block.length > 0)
|
|
637
|
+
.join("\n\n"),
|
|
638
|
+
}),
|
|
639
|
+
);
|
|
640
|
+
disposers.push(ctx.tools.register(createSkillLoadTool(catalog)));
|
|
641
|
+
if (host.files && host.writer) {
|
|
642
|
+
disposers.push(
|
|
643
|
+
ctx.tools.register(
|
|
644
|
+
createSkillWriteTool(
|
|
645
|
+
{ ...host, files: host.files },
|
|
646
|
+
host.writer,
|
|
647
|
+
ctx.sessions,
|
|
648
|
+
),
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
disposers.push(
|
|
653
|
+
ctx.on("agent/pre-step", async (agent, inputs, turn, step, next) => {
|
|
654
|
+
// Once per Turn, at its first step: "an edit is visible to the Bot on
|
|
655
|
+
// its next admitted Turn", so a Skill written mid-Turn does not change
|
|
656
|
+
// the instructions the Turn is already running under.
|
|
657
|
+
if (step === 1 || catalog.loadedTurn() !== turn) {
|
|
658
|
+
await catalog.refresh(turn, agent.session);
|
|
659
|
+
}
|
|
660
|
+
catalog.enterStep(turn, step);
|
|
661
|
+
if (step === 1) {
|
|
662
|
+
const refs = inputs.flatMap((input) => input.skills ?? []);
|
|
663
|
+
const outcome = await catalog.invoke(turn, agent.session, refs);
|
|
664
|
+
if (outcome.status === "unresolved") {
|
|
665
|
+
return { kind: "reject", reason: outcome.reason };
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return next();
|
|
669
|
+
}),
|
|
670
|
+
);
|
|
671
|
+
return () => {
|
|
672
|
+
for (const dispose of disposers.toReversed()) dispose();
|
|
673
|
+
catalog.invalidate();
|
|
674
|
+
};
|
|
675
|
+
};
|
|
676
|
+
plugin.inject = ["tools", "systemPrompt", "sessions"];
|
|
677
|
+
return plugin;
|
|
678
|
+
}
|