@frockbot/plugin-skills 0.0.0 → 0.1.1
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
|
@@ -0,0 +1,760 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SessionStore, type Session } from "@frockbot/kernel-contracts";
|
|
3
|
+
import { Context } from "cordis";
|
|
4
|
+
import {
|
|
5
|
+
createSkillLoadTool,
|
|
6
|
+
createSkillWriteTool,
|
|
7
|
+
SkillCatalog,
|
|
8
|
+
skillWriteScopeRefusalV1,
|
|
9
|
+
} from "./agent.ts";
|
|
10
|
+
import {
|
|
11
|
+
assembleSkillCatalogV1,
|
|
12
|
+
botInstructionRootV1,
|
|
13
|
+
type LoadedSkillV1,
|
|
14
|
+
loadFullSkillCatalogV1,
|
|
15
|
+
renderSkillCatalogPromptV1,
|
|
16
|
+
resolveSkillRefV1,
|
|
17
|
+
SKILL_CATALOG_CAPS_V1,
|
|
18
|
+
type SkillCatalogCapsV1,
|
|
19
|
+
userInstructionRootV1,
|
|
20
|
+
} from "./catalog.ts";
|
|
21
|
+
import { loadManagedSkillsV1, MANAGED_SKILL_DOCUMENTS_V1 } from "./managed.ts";
|
|
22
|
+
import {
|
|
23
|
+
loadPluginSkillsV1,
|
|
24
|
+
type PluginSkillsSourceV1,
|
|
25
|
+
} from "./plugin-index.ts";
|
|
26
|
+
import { FakeWorkspace, skillMarkdown } from "./testing.ts";
|
|
27
|
+
|
|
28
|
+
const OWNER = { userId: "user-1", botId: "bot-1" };
|
|
29
|
+
const OWN_ROOT = botInstructionRootV1(OWNER);
|
|
30
|
+
const USER_ROOT = userInstructionRootV1(OWNER);
|
|
31
|
+
const BOT_WRITER = {
|
|
32
|
+
kind: "bot" as const,
|
|
33
|
+
botId: "bot-1",
|
|
34
|
+
sessionId: "user-1:bot-1",
|
|
35
|
+
turnId: "turn-1",
|
|
36
|
+
runId: "run-1",
|
|
37
|
+
};
|
|
38
|
+
const CONTEXT = {
|
|
39
|
+
botId: "bot-1",
|
|
40
|
+
agentId: "bot-1",
|
|
41
|
+
sessionId: "user-1:bot-1",
|
|
42
|
+
compositionGenerationId: "2026-08-31T00:00:00.000Z:0123456789abcdef",
|
|
43
|
+
turnType: "chat" as const,
|
|
44
|
+
effectId: "tool:1:1:0",
|
|
45
|
+
signal: new AbortController().signal,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function pluginSource(
|
|
49
|
+
packages: {
|
|
50
|
+
packageId: string;
|
|
51
|
+
catalogId: string;
|
|
52
|
+
generation: string;
|
|
53
|
+
skills: { name: string; description?: string; body?: string }[];
|
|
54
|
+
}[],
|
|
55
|
+
): PluginSkillsSourceV1 {
|
|
56
|
+
return { read: () => Promise.resolve({ status: "ok", packages }) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function openSession(): Promise<{
|
|
60
|
+
session: Session;
|
|
61
|
+
sessions: { get(id: string): Session | undefined };
|
|
62
|
+
dispose: () => Promise<void>;
|
|
63
|
+
}> {
|
|
64
|
+
const root = new Context();
|
|
65
|
+
await root.plugin(SessionStore);
|
|
66
|
+
const session = root.sessions.create("user-1:bot-1");
|
|
67
|
+
session.appendBatch([
|
|
68
|
+
{ type: "turn/start", turn: 1 },
|
|
69
|
+
{ type: "step/start", turn: 1, step: 1 },
|
|
70
|
+
]);
|
|
71
|
+
return {
|
|
72
|
+
session,
|
|
73
|
+
sessions: root.sessions,
|
|
74
|
+
dispose: () => root.fiber.dispose(),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("the managed Skill source", () => {
|
|
79
|
+
test("ships the four first-party Skills, content-addressed", async () => {
|
|
80
|
+
const loaded = await loadManagedSkillsV1();
|
|
81
|
+
|
|
82
|
+
expect(loaded.refusals).toEqual([]);
|
|
83
|
+
expect(loaded.skills.map((skill) => skill.ref?.slug)).toEqual([
|
|
84
|
+
"add-connector",
|
|
85
|
+
"export-bot-template",
|
|
86
|
+
"import-bot-template",
|
|
87
|
+
"learn-from-demonstration",
|
|
88
|
+
]);
|
|
89
|
+
for (const skill of loaded.skills) {
|
|
90
|
+
expect(skill.ref?.source).toBe("managed");
|
|
91
|
+
expect(skill.path).toBe(`managed/${skill.ref?.slug}/SKILL.md`);
|
|
92
|
+
expect(skill.description.length).toBeGreaterThan(0);
|
|
93
|
+
// The generation is the content hash: the bytes are the artifact's, and
|
|
94
|
+
// the artifact set hash the Composition pins makes them reproducible.
|
|
95
|
+
expect(skill.generationId).toBe(skill.contentHash);
|
|
96
|
+
expect(skill.generationId).toMatch(/^[0-9a-f]{64}$/u);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("is stable across loads, so a pinned Composition reproduces it", async () => {
|
|
101
|
+
const first = await loadManagedSkillsV1();
|
|
102
|
+
const second = await loadManagedSkillsV1();
|
|
103
|
+
|
|
104
|
+
expect(second.skills.map((skill) => skill.contentHash)).toEqual(
|
|
105
|
+
first.skills.map((skill) => skill.contentHash),
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("records a malformed bundled document as a refusal, never a throw", async () => {
|
|
110
|
+
const loaded = await loadManagedSkillsV1([
|
|
111
|
+
{ slug: "broken", text: "no frontmatter here" },
|
|
112
|
+
{ slug: "Not A Slug", text: MANAGED_SKILL_DOCUMENTS_V1[0]?.text ?? "" },
|
|
113
|
+
{
|
|
114
|
+
slug: "add-connector",
|
|
115
|
+
text: MANAGED_SKILL_DOCUMENTS_V1[0]?.text ?? "",
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(loaded.skills.map((skill) => skill.ref?.slug)).toEqual([
|
|
120
|
+
"add-connector",
|
|
121
|
+
]);
|
|
122
|
+
expect(loaded.refusals.map((refusal) => refusal.kind)).toEqual([
|
|
123
|
+
"malformed",
|
|
124
|
+
"malformed",
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("is not editable this way", () => {
|
|
129
|
+
expect(skillWriteScopeRefusalV1("bot")).toBeUndefined();
|
|
130
|
+
expect(skillWriteScopeRefusalV1("managed")).toBe(
|
|
131
|
+
"managed skills are not editable this way",
|
|
132
|
+
);
|
|
133
|
+
expect(skillWriteScopeRefusalV1("plugin")).toBe(
|
|
134
|
+
"managed skills are not editable this way",
|
|
135
|
+
);
|
|
136
|
+
// The User-global root landed with ADR 0016, so `user` is writable and no
|
|
137
|
+
// longer a refusal.
|
|
138
|
+
expect(skillWriteScopeRefusalV1("user")).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("refuses a managed-scope skill_write with GrokBot's own wording", async () => {
|
|
142
|
+
const workspace = new FakeWorkspace();
|
|
143
|
+
const { sessions, dispose } = await openSession();
|
|
144
|
+
const tool = createSkillWriteTool(
|
|
145
|
+
{ owner: OWNER, reads: workspace, files: workspace },
|
|
146
|
+
{ sessionId: "user-1:bot-1", turnId: "turn-1", runId: "run-1" },
|
|
147
|
+
sessions,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const refused = await tool.execute(
|
|
151
|
+
{
|
|
152
|
+
name: "Add connector",
|
|
153
|
+
description: "Use this when connecting.",
|
|
154
|
+
body: "Overwritten.",
|
|
155
|
+
scope: "managed",
|
|
156
|
+
},
|
|
157
|
+
CONTEXT,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(refused.isError).toBe(true);
|
|
161
|
+
expect(refused.content).toContain("managed skills are not editable");
|
|
162
|
+
// Nothing was written: a refusal is not a partial write.
|
|
163
|
+
expect(workspace.calls).toEqual([]);
|
|
164
|
+
await dispose();
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("the plugin-borne Skill index", () => {
|
|
169
|
+
test("indexes an installed entry's Skills at its pinned generation", async () => {
|
|
170
|
+
const loaded = await loadPluginSkillsV1(
|
|
171
|
+
pluginSource([
|
|
172
|
+
{
|
|
173
|
+
packageId: "composio",
|
|
174
|
+
catalogId: "composio",
|
|
175
|
+
generation: "gen-7",
|
|
176
|
+
skills: [
|
|
177
|
+
{
|
|
178
|
+
name: "Compose email",
|
|
179
|
+
description: "Use this when drafting mail.",
|
|
180
|
+
body: "PLUGIN-BODY",
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
]),
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(loaded.refusals).toEqual([]);
|
|
188
|
+
expect(loaded.skills).toHaveLength(1);
|
|
189
|
+
expect(loaded.skills[0]?.ref).toEqual({
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
source: "plugin",
|
|
192
|
+
slug: "compose-email",
|
|
193
|
+
packageId: "composio",
|
|
194
|
+
});
|
|
195
|
+
expect(loaded.skills[0]?.path).toBe(
|
|
196
|
+
"plugin/composio/compose-email/SKILL.md",
|
|
197
|
+
);
|
|
198
|
+
expect(loaded.skills[0]?.by).toBe('Package "composio"');
|
|
199
|
+
expect(loaded.skills[0]?.generationId).toBe("catalog:gen-7");
|
|
200
|
+
expect(loaded.skills[0]?.body).toBe("PLUGIN-BODY");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("refuses a declaration with nothing to say, and keeps the rest", async () => {
|
|
204
|
+
const loaded = await loadPluginSkillsV1(
|
|
205
|
+
pluginSource([
|
|
206
|
+
{
|
|
207
|
+
packageId: "composio",
|
|
208
|
+
catalogId: "composio",
|
|
209
|
+
generation: "gen-7",
|
|
210
|
+
skills: [
|
|
211
|
+
{ name: "No body", description: "Use this when nothing." },
|
|
212
|
+
{ name: "No description", body: "Body." },
|
|
213
|
+
{ name: "!!!", description: "Use this when unnamed.", body: "B." },
|
|
214
|
+
{ name: "Kept", description: "Use this when keeping.", body: "B." },
|
|
215
|
+
{
|
|
216
|
+
name: "kept",
|
|
217
|
+
description: "Use this when colliding.",
|
|
218
|
+
body: "B.",
|
|
219
|
+
},
|
|
220
|
+
],
|
|
221
|
+
},
|
|
222
|
+
]),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(loaded.skills.map((skill) => skill.ref?.slug)).toEqual(["kept"]);
|
|
226
|
+
expect(loaded.refusals.map((refusal) => refusal.reason)).toEqual([
|
|
227
|
+
"the Catalog entry lists this Skill without a body",
|
|
228
|
+
"the Catalog entry lists this Skill without a description",
|
|
229
|
+
'the Skill name "!!!" yields no usable slug',
|
|
230
|
+
'Package "composio" declares "kept" more than once',
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("records an unreadable Catalog as a refusal rather than failing the Turn", async () => {
|
|
235
|
+
const loaded = await loadPluginSkillsV1({
|
|
236
|
+
read: () =>
|
|
237
|
+
Promise.resolve({ status: "unavailable", reason: "R2 is down" }),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(loaded.skills).toEqual([]);
|
|
241
|
+
expect(loaded.refusals[0]).toMatchObject({
|
|
242
|
+
path: "plugin",
|
|
243
|
+
kind: "unreadable",
|
|
244
|
+
});
|
|
245
|
+
expect(loaded.refusals[0]?.reason).toContain("R2 is down");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("an uninstalled Package contributes nothing on the next Turn", async () => {
|
|
249
|
+
const installed = await loadPluginSkillsV1(
|
|
250
|
+
pluginSource([
|
|
251
|
+
{
|
|
252
|
+
packageId: "composio",
|
|
253
|
+
catalogId: "composio",
|
|
254
|
+
generation: "gen-7",
|
|
255
|
+
skills: [
|
|
256
|
+
{ name: "Kept", description: "Use this when keeping.", body: "B." },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
]),
|
|
260
|
+
);
|
|
261
|
+
expect(installed.skills).toHaveLength(1);
|
|
262
|
+
|
|
263
|
+
// Uninstalling removes the installation row, so the next Turn's index has
|
|
264
|
+
// nothing to read. Nothing was ever copied into a root, so nothing lingers.
|
|
265
|
+
const uninstalled = await loadPluginSkillsV1(pluginSource([]));
|
|
266
|
+
expect(uninstalled.skills).toEqual([]);
|
|
267
|
+
expect(uninstalled.refusals).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
function fakeSkill(
|
|
272
|
+
source: "bot" | "user" | "managed" | "plugin",
|
|
273
|
+
slug: string,
|
|
274
|
+
packageId?: string,
|
|
275
|
+
): LoadedSkillV1 {
|
|
276
|
+
return {
|
|
277
|
+
path: `${source}/${slug}/SKILL.md`,
|
|
278
|
+
ref: {
|
|
279
|
+
schemaVersion: 1,
|
|
280
|
+
source,
|
|
281
|
+
slug,
|
|
282
|
+
...(packageId ? { packageId } : {}),
|
|
283
|
+
},
|
|
284
|
+
name: slug,
|
|
285
|
+
description: "Use this when testing.",
|
|
286
|
+
body: "Body.",
|
|
287
|
+
generationId: "g",
|
|
288
|
+
contentHash: "c",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
describe("assembling the catalog", () => {
|
|
293
|
+
test("orders bot, then user, then managed, then plugin", () => {
|
|
294
|
+
const catalog = assembleSkillCatalogV1(OWNER, {
|
|
295
|
+
plugin: {
|
|
296
|
+
skills: [
|
|
297
|
+
fakeSkill("plugin", "p", "zed"),
|
|
298
|
+
fakeSkill("plugin", "a", "abc"),
|
|
299
|
+
],
|
|
300
|
+
refusals: [],
|
|
301
|
+
},
|
|
302
|
+
managed: { skills: [fakeSkill("managed", "m")], refusals: [] },
|
|
303
|
+
user: { skills: [fakeSkill("user", "u")], refusals: [] },
|
|
304
|
+
bot: { skills: [fakeSkill("bot", "b")], refusals: [] },
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
expect(catalog.skills.map((skill) => skill.path)).toEqual([
|
|
308
|
+
"bot/b/SKILL.md",
|
|
309
|
+
"user/u/SKILL.md",
|
|
310
|
+
"managed/m/SKILL.md",
|
|
311
|
+
"plugin/a/SKILL.md",
|
|
312
|
+
"plugin/p/SKILL.md",
|
|
313
|
+
]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("records every drop over a source cap as a refusal", () => {
|
|
317
|
+
const caps: SkillCatalogCapsV1 = {
|
|
318
|
+
...SKILL_CATALOG_CAPS_V1,
|
|
319
|
+
managed: 1,
|
|
320
|
+
plugin: 0,
|
|
321
|
+
};
|
|
322
|
+
const catalog = assembleSkillCatalogV1(
|
|
323
|
+
OWNER,
|
|
324
|
+
{
|
|
325
|
+
managed: {
|
|
326
|
+
skills: [fakeSkill("managed", "a"), fakeSkill("managed", "b")],
|
|
327
|
+
refusals: [],
|
|
328
|
+
},
|
|
329
|
+
plugin: {
|
|
330
|
+
skills: [fakeSkill("plugin", "c", "pkg")],
|
|
331
|
+
refusals: [],
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
caps,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
expect(catalog.skills.map((skill) => skill.ref?.slug)).toEqual(["a"]);
|
|
338
|
+
expect(catalog.refusals).toEqual([
|
|
339
|
+
{
|
|
340
|
+
path: "managed/b/SKILL.md",
|
|
341
|
+
kind: "over-source-cap",
|
|
342
|
+
reason:
|
|
343
|
+
"the managed Skill source is bounded at 1 entries in one catalog",
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
path: "plugin/c/SKILL.md",
|
|
347
|
+
kind: "over-source-cap",
|
|
348
|
+
reason:
|
|
349
|
+
"the plugin Skill source is bounded at 0 entries in one catalog",
|
|
350
|
+
},
|
|
351
|
+
]);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("bounds the rendered block by bytes, visibly", () => {
|
|
355
|
+
const catalog = assembleSkillCatalogV1(
|
|
356
|
+
OWNER,
|
|
357
|
+
{
|
|
358
|
+
bot: {
|
|
359
|
+
skills: [fakeSkill("bot", "a"), fakeSkill("bot", "b")],
|
|
360
|
+
refusals: [],
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{ ...SKILL_CATALOG_CAPS_V1, totalBytes: 40 },
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
expect(catalog.skills).toHaveLength(1);
|
|
367
|
+
expect(catalog.refusals[0]).toMatchObject({ kind: "over-source-cap" });
|
|
368
|
+
expect(catalog.refusals[0]?.reason).toContain("40 rendered bytes");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test("carries a source's own refusals through unchanged", () => {
|
|
372
|
+
const catalog = assembleSkillCatalogV1(OWNER, {
|
|
373
|
+
bot: {
|
|
374
|
+
skills: [],
|
|
375
|
+
refusals: [{ path: "x", kind: "authority", reason: "no" }],
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
expect(catalog.refusals).toEqual([
|
|
380
|
+
{ path: "x", kind: "authority", reason: "no" },
|
|
381
|
+
]);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe("the rendered catalog block", () => {
|
|
386
|
+
test("names each Skill's source and attribution", () => {
|
|
387
|
+
const rendered = renderSkillCatalogPromptV1(
|
|
388
|
+
assembleSkillCatalogV1(OWNER, {
|
|
389
|
+
managed: { skills: [fakeSkill("managed", "teach")], refusals: [] },
|
|
390
|
+
plugin: {
|
|
391
|
+
skills: [
|
|
392
|
+
{
|
|
393
|
+
...fakeSkill("plugin", "compose", "composio"),
|
|
394
|
+
by: 'Package "composio"',
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
refusals: [],
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
expect(rendered).toContain('source="managed" ref="managed/teach"');
|
|
403
|
+
expect(rendered).toContain('source="plugin" ref="plugin/composio/compose"');
|
|
404
|
+
expect(rendered).toContain('by="Package "composio""');
|
|
405
|
+
// Progressive disclosure survives the new sources.
|
|
406
|
+
expect(rendered).not.toContain("Body.");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("disambiguates a duplicated name by its ref", () => {
|
|
410
|
+
const rendered = renderSkillCatalogPromptV1(
|
|
411
|
+
assembleSkillCatalogV1(OWNER, {
|
|
412
|
+
bot: { skills: [fakeSkill("bot", "standup")], refusals: [] },
|
|
413
|
+
plugin: {
|
|
414
|
+
skills: [fakeSkill("plugin", "standup", "composio")],
|
|
415
|
+
refusals: [],
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
expect(rendered).toContain('name="standup (bot/standup)"');
|
|
421
|
+
expect(rendered).toContain('name="standup (plugin/composio/standup)"');
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("resolving a ref against a Turn's catalog", () => {
|
|
426
|
+
test("resolves managed and plugin refs, and refuses an unknown one", () => {
|
|
427
|
+
const catalog = assembleSkillCatalogV1(OWNER, {
|
|
428
|
+
managed: { skills: [fakeSkill("managed", "teach")], refusals: [] },
|
|
429
|
+
plugin: {
|
|
430
|
+
skills: [fakeSkill("plugin", "compose", "composio")],
|
|
431
|
+
refusals: [],
|
|
432
|
+
},
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(
|
|
436
|
+
resolveSkillRefV1(catalog, {
|
|
437
|
+
schemaVersion: 1,
|
|
438
|
+
source: "managed",
|
|
439
|
+
slug: "teach",
|
|
440
|
+
})?.path,
|
|
441
|
+
).toBe("managed/teach/SKILL.md");
|
|
442
|
+
expect(
|
|
443
|
+
resolveSkillRefV1(catalog, {
|
|
444
|
+
schemaVersion: 1,
|
|
445
|
+
source: "plugin",
|
|
446
|
+
slug: "compose",
|
|
447
|
+
packageId: "composio",
|
|
448
|
+
})?.path,
|
|
449
|
+
).toBe("plugin/compose/SKILL.md");
|
|
450
|
+
// The same slug under another Package is another Skill, not this one.
|
|
451
|
+
expect(
|
|
452
|
+
resolveSkillRefV1(catalog, {
|
|
453
|
+
schemaVersion: 1,
|
|
454
|
+
source: "plugin",
|
|
455
|
+
slug: "compose",
|
|
456
|
+
packageId: "other",
|
|
457
|
+
}),
|
|
458
|
+
).toBeUndefined();
|
|
459
|
+
expect(
|
|
460
|
+
resolveSkillRefV1(catalog, {
|
|
461
|
+
schemaVersion: 1,
|
|
462
|
+
source: "bot",
|
|
463
|
+
slug: "teach",
|
|
464
|
+
}),
|
|
465
|
+
).toBeUndefined();
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
describe("a Turn's whole catalog", () => {
|
|
470
|
+
test("assembles all three live sources and loads a body by ref", async () => {
|
|
471
|
+
const workspace = await FakeWorkspace.seeded([
|
|
472
|
+
{
|
|
473
|
+
root: OWN_ROOT,
|
|
474
|
+
path: "skills/standup/SKILL.md",
|
|
475
|
+
text: skillMarkdown("Daily standup", "Use this when standing.", "OWN."),
|
|
476
|
+
writer: BOT_WRITER,
|
|
477
|
+
},
|
|
478
|
+
]);
|
|
479
|
+
const { session, dispose } = await openSession();
|
|
480
|
+
const catalog = new SkillCatalog(
|
|
481
|
+
OWNER,
|
|
482
|
+
workspace,
|
|
483
|
+
pluginSource([
|
|
484
|
+
{
|
|
485
|
+
packageId: "composio",
|
|
486
|
+
catalogId: "composio",
|
|
487
|
+
generation: "gen-7",
|
|
488
|
+
skills: [
|
|
489
|
+
{
|
|
490
|
+
name: "Compose email",
|
|
491
|
+
description: "Use this when drafting mail.",
|
|
492
|
+
body: "PLUGIN-BODY",
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
},
|
|
496
|
+
]),
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
await catalog.refresh(1, session);
|
|
500
|
+
|
|
501
|
+
expect(catalog.current().skills.map((skill) => skill.ref?.source)).toEqual([
|
|
502
|
+
"bot",
|
|
503
|
+
"managed",
|
|
504
|
+
"managed",
|
|
505
|
+
"managed",
|
|
506
|
+
"managed",
|
|
507
|
+
"plugin",
|
|
508
|
+
]);
|
|
509
|
+
|
|
510
|
+
const tool = createSkillLoadTool(catalog);
|
|
511
|
+
const managed = await tool.execute(
|
|
512
|
+
{ path: "managed/add-connector" },
|
|
513
|
+
CONTEXT,
|
|
514
|
+
);
|
|
515
|
+
expect(managed.isError).toBe(false);
|
|
516
|
+
expect(managed.content).toContain("Ref: managed/add-connector");
|
|
517
|
+
|
|
518
|
+
const plugin = await tool.execute(
|
|
519
|
+
{ path: "plugin/composio/compose-email" },
|
|
520
|
+
CONTEXT,
|
|
521
|
+
);
|
|
522
|
+
expect(plugin.isError).toBe(false);
|
|
523
|
+
expect(plugin.content).toContain("PLUGIN-BODY");
|
|
524
|
+
|
|
525
|
+
// The path form still works, for one release.
|
|
526
|
+
const byPath = await tool.execute(
|
|
527
|
+
{ path: "skills/standup/SKILL.md" },
|
|
528
|
+
CONTEXT,
|
|
529
|
+
);
|
|
530
|
+
expect(byPath.isError).toBe(false);
|
|
531
|
+
expect(byPath.content).toContain("OWN.");
|
|
532
|
+
|
|
533
|
+
const unknown = await tool.execute({ path: "managed/nope" }, CONTEXT);
|
|
534
|
+
expect(unknown.isError).toBe(true);
|
|
535
|
+
await dispose();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
test("omits the managed set when the host disables it", async () => {
|
|
539
|
+
const workspace = new FakeWorkspace();
|
|
540
|
+
const catalog = await loadFullSkillCatalogV1(workspace, OWNER, {
|
|
541
|
+
managed: false,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
expect(catalog.skills).toEqual([]);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
describe("the User-global instruction root", () => {
|
|
549
|
+
const OTHER_BOT_WRITER = { ...BOT_WRITER, botId: "bot-2" };
|
|
550
|
+
const USER_WRITER = { kind: "user" as const, userId: "user-1" };
|
|
551
|
+
|
|
552
|
+
test("loads a Skill another of the User's Bots wrote, attributed to that Bot, and refuses what carries no authority", async () => {
|
|
553
|
+
const workspace = await FakeWorkspace.seeded([
|
|
554
|
+
{
|
|
555
|
+
root: OWN_ROOT,
|
|
556
|
+
path: "skills/own/SKILL.md",
|
|
557
|
+
text: skillMarkdown("Own", "Use this when local.", "OWN."),
|
|
558
|
+
writer: BOT_WRITER,
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
root: USER_ROOT,
|
|
562
|
+
path: "skills/standup/SKILL.md",
|
|
563
|
+
text: skillMarkdown("Daily standup", "Use this when standing.", "A."),
|
|
564
|
+
writer: OTHER_BOT_WRITER,
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
root: USER_ROOT,
|
|
568
|
+
path: "skills/house-style/SKILL.md",
|
|
569
|
+
text: skillMarkdown("House style", "Use this when writing.", "U."),
|
|
570
|
+
writer: USER_WRITER,
|
|
571
|
+
},
|
|
572
|
+
// A shell on the Computer dropped this one: nothing recorded a writer,
|
|
573
|
+
// so it is data, never an instruction.
|
|
574
|
+
{
|
|
575
|
+
root: USER_ROOT,
|
|
576
|
+
path: "skills/dropped/SKILL.md",
|
|
577
|
+
text: skillMarkdown("Dropped", "Use this never.", "X."),
|
|
578
|
+
writer: { kind: "unattributed" },
|
|
579
|
+
},
|
|
580
|
+
// Another User's root is not reachable from this owner at all.
|
|
581
|
+
{
|
|
582
|
+
root: { kind: "user-instructions", userId: "user-2" },
|
|
583
|
+
path: "skills/foreign/SKILL.md",
|
|
584
|
+
text: skillMarkdown("Foreign", "Use this never.", "X."),
|
|
585
|
+
writer: OTHER_BOT_WRITER,
|
|
586
|
+
},
|
|
587
|
+
]);
|
|
588
|
+
|
|
589
|
+
const catalog = await loadFullSkillCatalogV1(workspace, OWNER, {
|
|
590
|
+
managed: false,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
expect(
|
|
594
|
+
catalog.skills.map((skill) => [skill.ref?.source, skill.ref?.slug]),
|
|
595
|
+
).toEqual([
|
|
596
|
+
["bot", "own"],
|
|
597
|
+
["user", "house-style"],
|
|
598
|
+
["user", "standup"],
|
|
599
|
+
]);
|
|
600
|
+
// The shared-tier attribution: the reading Bot is told whose instruction
|
|
601
|
+
// it is about to follow, and its own Skill discloses nothing.
|
|
602
|
+
expect(catalog.skills.map((skill) => skill.by)).toEqual([
|
|
603
|
+
undefined,
|
|
604
|
+
"your User",
|
|
605
|
+
'Bot "bot-2"',
|
|
606
|
+
]);
|
|
607
|
+
expect(
|
|
608
|
+
catalog.refusals.map((refusal) => [refusal.path, refusal.kind]),
|
|
609
|
+
).toEqual([["skills/dropped/SKILL.md", "authority"]]);
|
|
610
|
+
// Another User's root was never even listed.
|
|
611
|
+
expect(catalog.skills.map((skill) => skill.name)).not.toContain("Foreign");
|
|
612
|
+
|
|
613
|
+
const rendered = renderSkillCatalogPromptV1(catalog);
|
|
614
|
+
expect(rendered).toContain('ref="user/standup"');
|
|
615
|
+
expect(rendered).toContain('source="user"');
|
|
616
|
+
expect(rendered).toContain('by="Bot "bot-2""');
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("skill_write with scope user lands in the shared root with the writing Bot's full provenance", async () => {
|
|
620
|
+
const workspace = new FakeWorkspace();
|
|
621
|
+
const { sessions, dispose } = await openSession();
|
|
622
|
+
const tool = createSkillWriteTool(
|
|
623
|
+
{ owner: OWNER, reads: workspace, files: workspace },
|
|
624
|
+
{ sessionId: "user-1:bot-1", turnId: "turn-1", runId: "run-1" },
|
|
625
|
+
sessions,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
const written = await tool.execute(
|
|
629
|
+
{
|
|
630
|
+
name: "Daily standup",
|
|
631
|
+
description: "Use this when standing.",
|
|
632
|
+
body: "Ask each Bot for its blockers.",
|
|
633
|
+
scope: "user",
|
|
634
|
+
},
|
|
635
|
+
CONTEXT,
|
|
636
|
+
);
|
|
637
|
+
expect(written.isError).toBe(false);
|
|
638
|
+
expect(written.content).toContain("shared instruction root");
|
|
639
|
+
|
|
640
|
+
const stat = await workspace.stat({
|
|
641
|
+
root: USER_ROOT,
|
|
642
|
+
path: "skills/daily-standup/SKILL.md",
|
|
643
|
+
});
|
|
644
|
+
if (stat.status !== "ok") throw new Error(stat.reason);
|
|
645
|
+
// The full Bot writer, not a bare id: the Session, Turn and run that
|
|
646
|
+
// authored it are what make the write reconstructable.
|
|
647
|
+
expect(stat.entry.generation.writer).toEqual({
|
|
648
|
+
kind: "bot",
|
|
649
|
+
botId: "bot-1",
|
|
650
|
+
sessionId: "user-1:bot-1",
|
|
651
|
+
turnId: "turn-1",
|
|
652
|
+
runId: "run-1",
|
|
653
|
+
});
|
|
654
|
+
// Nothing was written to the Bot's own root.
|
|
655
|
+
expect(
|
|
656
|
+
(
|
|
657
|
+
await workspace.stat({
|
|
658
|
+
root: OWN_ROOT,
|
|
659
|
+
path: "skills/daily-standup/SKILL.md",
|
|
660
|
+
})
|
|
661
|
+
).status,
|
|
662
|
+
).toBe("not-found");
|
|
663
|
+
|
|
664
|
+
// Another Bot of the same User loads it, and is told who wrote it.
|
|
665
|
+
const catalog = await loadFullSkillCatalogV1(
|
|
666
|
+
workspace,
|
|
667
|
+
{ userId: "user-1", botId: "bot-2" },
|
|
668
|
+
{ managed: false },
|
|
669
|
+
);
|
|
670
|
+
expect(catalog.skills.map((skill) => [skill.name, skill.by])).toEqual([
|
|
671
|
+
["Daily standup", 'Bot "bot-1"'],
|
|
672
|
+
]);
|
|
673
|
+
await dispose();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test("the two roots have separate Skill-count quotas", async () => {
|
|
677
|
+
const workspace = new FakeWorkspace();
|
|
678
|
+
const { sessions, dispose } = await openSession();
|
|
679
|
+
const tool = createSkillWriteTool(
|
|
680
|
+
{
|
|
681
|
+
owner: OWNER,
|
|
682
|
+
reads: workspace,
|
|
683
|
+
files: workspace,
|
|
684
|
+
quota: {
|
|
685
|
+
schemaVersion: 1,
|
|
686
|
+
maxSkillsPerBot: 2,
|
|
687
|
+
maxSkillsPerUser: 1,
|
|
688
|
+
maxSkillBytes: 65_536,
|
|
689
|
+
},
|
|
690
|
+
},
|
|
691
|
+
{ sessionId: "user-1:bot-1", turnId: "turn-1", runId: "run-1" },
|
|
692
|
+
sessions,
|
|
693
|
+
);
|
|
694
|
+
const write = (name: string, scope: "bot" | "user") =>
|
|
695
|
+
tool.execute(
|
|
696
|
+
{
|
|
697
|
+
name,
|
|
698
|
+
description: `Use this when ${name}.`,
|
|
699
|
+
body: "Body.",
|
|
700
|
+
scope,
|
|
701
|
+
},
|
|
702
|
+
CONTEXT,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
expect((await write("one", "user")).isError).toBe(false);
|
|
706
|
+
const overUser = await write("two", "user");
|
|
707
|
+
expect(overUser.isError).toBe(true);
|
|
708
|
+
expect(overUser.content).toContain("shared instruction root holds 1");
|
|
709
|
+
|
|
710
|
+
// The Bot's own root is bounded separately, so the shared root filling up
|
|
711
|
+
// never refuses a Bot its own self-modification.
|
|
712
|
+
expect((await write("three", "bot")).isError).toBe(false);
|
|
713
|
+
expect((await write("four", "bot")).isError).toBe(false);
|
|
714
|
+
const overBot = await write("five", "bot");
|
|
715
|
+
expect(overBot.isError).toBe(true);
|
|
716
|
+
expect(overBot.content).toContain("this Bot holds 2");
|
|
717
|
+
await dispose();
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("a User-global write that supersedes a Skill is admitted at the limit", async () => {
|
|
721
|
+
const workspace = new FakeWorkspace();
|
|
722
|
+
const { sessions, dispose } = await openSession();
|
|
723
|
+
const tool = createSkillWriteTool(
|
|
724
|
+
{
|
|
725
|
+
owner: OWNER,
|
|
726
|
+
reads: workspace,
|
|
727
|
+
files: workspace,
|
|
728
|
+
quota: {
|
|
729
|
+
schemaVersion: 1,
|
|
730
|
+
maxSkillsPerBot: 1,
|
|
731
|
+
maxSkillsPerUser: 1,
|
|
732
|
+
maxSkillBytes: 65_536,
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{ sessionId: "user-1:bot-1", turnId: "turn-1", runId: "run-1" },
|
|
736
|
+
sessions,
|
|
737
|
+
);
|
|
738
|
+
const draft = {
|
|
739
|
+
name: "Daily standup",
|
|
740
|
+
description: "Use this when standing.",
|
|
741
|
+
scope: "user" as const,
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
expect(
|
|
745
|
+
(await tool.execute({ ...draft, body: "First." }, CONTEXT)).isError,
|
|
746
|
+
).toBe(false);
|
|
747
|
+
// Superseding does not grow the root, so it is admitted at the cap.
|
|
748
|
+
expect(
|
|
749
|
+
(await tool.execute({ ...draft, body: "Second." }, CONTEXT)).isError,
|
|
750
|
+
).toBe(false);
|
|
751
|
+
|
|
752
|
+
const read = await workspace.read({
|
|
753
|
+
root: USER_ROOT,
|
|
754
|
+
path: "skills/daily-standup/SKILL.md",
|
|
755
|
+
});
|
|
756
|
+
if (read.status !== "ok") throw new Error(read.reason);
|
|
757
|
+
expect(new TextDecoder().decode(read.file.bytes)).toContain("Second.");
|
|
758
|
+
await dispose();
|
|
759
|
+
});
|
|
760
|
+
});
|