@nebutra/agent-runtime 0.2.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/.turbo/turbo-build.log +115 -0
- package/.turbo/turbo-test.log +44 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +253 -0
- package/LICENSE +676 -0
- package/README.md +50 -0
- package/dist/adapters/dispatcher-sse.d.ts +68 -0
- package/dist/adapters/dispatcher-sse.js +11 -0
- package/dist/adapters/dispatcher-sse.js.map +1 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.js +21 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/mcp-catalog.d.ts +58 -0
- package/dist/adapters/mcp-catalog.js +9 -0
- package/dist/adapters/mcp-catalog.js.map +1 -0
- package/dist/adapters/prisma-rollout.d.ts +60 -0
- package/dist/adapters/prisma-rollout.js +7 -0
- package/dist/adapters/prisma-rollout.js.map +1 -0
- package/dist/chunk-24ZXP7FI.js +93 -0
- package/dist/chunk-24ZXP7FI.js.map +1 -0
- package/dist/chunk-2DA6Q6TN.js +126 -0
- package/dist/chunk-2DA6Q6TN.js.map +1 -0
- package/dist/chunk-37BBB2P2.js +73 -0
- package/dist/chunk-37BBB2P2.js.map +1 -0
- package/dist/chunk-57W3AR43.js +52 -0
- package/dist/chunk-57W3AR43.js.map +1 -0
- package/dist/chunk-5N4644PB.js +67 -0
- package/dist/chunk-5N4644PB.js.map +1 -0
- package/dist/chunk-5YS7WAPS.js +177 -0
- package/dist/chunk-5YS7WAPS.js.map +1 -0
- package/dist/chunk-6EGG2OZC.js +13 -0
- package/dist/chunk-6EGG2OZC.js.map +1 -0
- package/dist/chunk-7BUOF367.js +126 -0
- package/dist/chunk-7BUOF367.js.map +1 -0
- package/dist/chunk-BJBBR3QA.js +121 -0
- package/dist/chunk-BJBBR3QA.js.map +1 -0
- package/dist/chunk-CGRCUKGT.js +73 -0
- package/dist/chunk-CGRCUKGT.js.map +1 -0
- package/dist/chunk-FUG5DT2C.js +75 -0
- package/dist/chunk-FUG5DT2C.js.map +1 -0
- package/dist/chunk-LO24VOA3.js +199 -0
- package/dist/chunk-LO24VOA3.js.map +1 -0
- package/dist/chunk-MUF7ZZTO.js +57 -0
- package/dist/chunk-MUF7ZZTO.js.map +1 -0
- package/dist/chunk-NN7DATXA.js +46 -0
- package/dist/chunk-NN7DATXA.js.map +1 -0
- package/dist/chunk-PGGWSUTM.js +33 -0
- package/dist/chunk-PGGWSUTM.js.map +1 -0
- package/dist/chunk-RDKYDMXT.js +135 -0
- package/dist/chunk-RDKYDMXT.js.map +1 -0
- package/dist/chunk-YYFPDBJG.js +63 -0
- package/dist/chunk-YYFPDBJG.js.map +1 -0
- package/dist/chunk-ZMYX5VBU.js +135 -0
- package/dist/chunk-ZMYX5VBU.js.map +1 -0
- package/dist/chunk-ZTSKS42I.js +131 -0
- package/dist/chunk-ZTSKS42I.js.map +1 -0
- package/dist/commands.d.ts +74 -0
- package/dist/commands.js +10 -0
- package/dist/commands.js.map +1 -0
- package/dist/definitions.d.ts +94 -0
- package/dist/definitions.js +15 -0
- package/dist/definitions.js.map +1 -0
- package/dist/dispatcher.d.ts +50 -0
- package/dist/dispatcher.js +8 -0
- package/dist/dispatcher.js.map +1 -0
- package/dist/durable-turn.d.ts +58 -0
- package/dist/durable-turn.js +9 -0
- package/dist/durable-turn.js.map +1 -0
- package/dist/hook-pipeline.d.ts +114 -0
- package/dist/hook-pipeline.js +13 -0
- package/dist/hook-pipeline.js.map +1 -0
- package/dist/index.d.ts +1874 -0
- package/dist/index.js +3117 -0
- package/dist/index.js.map +1 -0
- package/dist/loop.d.ts +78 -0
- package/dist/loop.js +9 -0
- package/dist/loop.js.map +1 -0
- package/dist/mcp-bridge.d.ts +48 -0
- package/dist/mcp-bridge.js +8 -0
- package/dist/mcp-bridge.js.map +1 -0
- package/dist/model.d.ts +154 -0
- package/dist/model.js +9 -0
- package/dist/model.js.map +1 -0
- package/dist/policy.d.ts +130 -0
- package/dist/policy.js +23 -0
- package/dist/policy.js.map +1 -0
- package/dist/protocol.d.ts +170 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/rollout-store-persistent.d.ts +48 -0
- package/dist/rollout-store-persistent.js +9 -0
- package/dist/rollout-store-persistent.js.map +1 -0
- package/dist/rollout.d.ts +82 -0
- package/dist/rollout.js +15 -0
- package/dist/rollout.js.map +1 -0
- package/dist/sandbox.d.ts +65 -0
- package/dist/sandbox.js +15 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/skills.d.ts +93 -0
- package/dist/skills.js +10 -0
- package/dist/skills.js.map +1 -0
- package/dist/subagents.d.ts +129 -0
- package/dist/subagents.js +21 -0
- package/dist/subagents.js.map +1 -0
- package/dist/tools.d.ts +77 -0
- package/dist/tools.js +9 -0
- package/dist/tools.js.map +1 -0
- package/package.json +74 -0
- package/src/adapters/dispatcher-sse.test.ts +218 -0
- package/src/adapters/dispatcher-sse.ts +222 -0
- package/src/adapters/index.ts +18 -0
- package/src/adapters/mcp-catalog.test.ts +213 -0
- package/src/adapters/mcp-catalog.ts +188 -0
- package/src/adapters/prisma-rollout.test.ts +153 -0
- package/src/adapters/prisma-rollout.ts +104 -0
- package/src/agent-runtime.test.ts +176 -0
- package/src/artifact-stream.test.ts +330 -0
- package/src/artifact-stream.ts +453 -0
- package/src/channel-gateway.test.ts +432 -0
- package/src/channel-gateway.ts +357 -0
- package/src/code-review.test.ts +501 -0
- package/src/code-review.ts +495 -0
- package/src/command-suggestions.test.ts +251 -0
- package/src/command-suggestions.ts +338 -0
- package/src/commands.test.ts +184 -0
- package/src/commands.ts +140 -0
- package/src/commit-message.test.ts +249 -0
- package/src/commit-message.ts +180 -0
- package/src/context-compaction.test.ts +522 -0
- package/src/context-compaction.ts +434 -0
- package/src/definitions.test.ts +78 -0
- package/src/definitions.ts +190 -0
- package/src/deployment-status.test.ts +215 -0
- package/src/deployment-status.ts +227 -0
- package/src/design-context.test.ts +195 -0
- package/src/design-context.ts +198 -0
- package/src/dispatcher.test.ts +234 -0
- package/src/dispatcher.ts +189 -0
- package/src/durable-turn.test.ts +209 -0
- package/src/durable-turn.ts +135 -0
- package/src/edit-planner.test.ts +204 -0
- package/src/edit-planner.ts +325 -0
- package/src/fuzzy-match.test.ts +311 -0
- package/src/fuzzy-match.ts +444 -0
- package/src/hook-pipeline.test.ts +279 -0
- package/src/hook-pipeline.ts +373 -0
- package/src/inbound-admission.test.ts +394 -0
- package/src/inbound-admission.ts +246 -0
- package/src/index.ts +47 -0
- package/src/loop.test.ts +161 -0
- package/src/loop.ts +211 -0
- package/src/mcp-bridge.test.ts +165 -0
- package/src/mcp-bridge.ts +76 -0
- package/src/memory-provider.test.ts +232 -0
- package/src/memory-provider.ts +257 -0
- package/src/model.ts +168 -0
- package/src/permission-ruleset.test.ts +301 -0
- package/src/permission-ruleset.ts +200 -0
- package/src/policy.ts +151 -0
- package/src/project-repo.test.ts +232 -0
- package/src/project-repo.ts +311 -0
- package/src/protocol.ts +159 -0
- package/src/rollout-store-persistent.test.ts +217 -0
- package/src/rollout-store-persistent.ts +166 -0
- package/src/rollout.ts +150 -0
- package/src/sandbox.ts +113 -0
- package/src/session-share.test.ts +360 -0
- package/src/session-share.ts +310 -0
- package/src/skill-distillation.test.ts +177 -0
- package/src/skill-distillation.ts +369 -0
- package/src/skills.test.ts +277 -0
- package/src/skills.ts +255 -0
- package/src/subagents.test.ts +290 -0
- package/src/subagents.ts +332 -0
- package/src/tools.ts +126 -0
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +33 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { DefinitionResolver } from "./definitions";
|
|
3
|
+
import { buildSkillListing, expandSkill, type SkillRecord } from "./skills";
|
|
4
|
+
|
|
5
|
+
const skill = (
|
|
6
|
+
over: Partial<SkillRecord> &
|
|
7
|
+
Pick<SkillRecord, "slug" | "tenantId" | "sourceTier"> & {
|
|
8
|
+
name?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
whenToUse?: string;
|
|
11
|
+
allowedTools?: string[];
|
|
12
|
+
model?: string;
|
|
13
|
+
effort?: "low" | "medium" | "high";
|
|
14
|
+
executionMode?: "inline" | "fork";
|
|
15
|
+
paths?: string[];
|
|
16
|
+
},
|
|
17
|
+
): SkillRecord => ({
|
|
18
|
+
bodyRef: over.bodyRef ?? `${over.slug}.body`,
|
|
19
|
+
...over,
|
|
20
|
+
frontmatter: {
|
|
21
|
+
name: over.name ?? over.slug,
|
|
22
|
+
description: over.description ?? "",
|
|
23
|
+
allowedTools: over.allowedTools ?? [],
|
|
24
|
+
disallowedTools: [],
|
|
25
|
+
argNames: [],
|
|
26
|
+
modelInvocable: true,
|
|
27
|
+
userInvocable: true,
|
|
28
|
+
executionMode: over.executionMode ?? "inline",
|
|
29
|
+
paths: over.paths ?? [],
|
|
30
|
+
whenToUse: over.whenToUse,
|
|
31
|
+
model: over.model,
|
|
32
|
+
effort: over.effort,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("buildSkillListing", () => {
|
|
37
|
+
it("lists only name/description/whenToUse per skill", () => {
|
|
38
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
39
|
+
skill({
|
|
40
|
+
slug: "deploy",
|
|
41
|
+
tenantId: "t",
|
|
42
|
+
sourceTier: "workspace",
|
|
43
|
+
description: "ship it",
|
|
44
|
+
whenToUse: "when releasing",
|
|
45
|
+
}),
|
|
46
|
+
]);
|
|
47
|
+
const out = buildSkillListing(r, { tenantId: "t" });
|
|
48
|
+
expect(out.entries).toHaveLength(1);
|
|
49
|
+
expect(out.entries[0]).toMatchObject({
|
|
50
|
+
name: "deploy",
|
|
51
|
+
description: "ship it",
|
|
52
|
+
whenToUse: "when releasing",
|
|
53
|
+
});
|
|
54
|
+
expect(out.text).toContain("deploy");
|
|
55
|
+
expect(out.text).toContain("ship it");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("truncates per-entry description to maxListingDescChars", () => {
|
|
59
|
+
const long = "x".repeat(500);
|
|
60
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
61
|
+
skill({ slug: "a", tenantId: "t", sourceTier: "workspace", description: long }),
|
|
62
|
+
]);
|
|
63
|
+
const out = buildSkillListing(r, { tenantId: "t" }, { maxListingDescChars: 10 });
|
|
64
|
+
expect(out.entries[0]?.description.length).toBeLessThanOrEqual(10);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("first-party (bundled/builtin) is never truncated and always included even past budget", () => {
|
|
68
|
+
const long = "y".repeat(400);
|
|
69
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
70
|
+
skill({ slug: "fp", tenantId: "t", sourceTier: "bundled", description: long }),
|
|
71
|
+
skill({ slug: "lo", tenantId: "t", sourceTier: "workspace", description: long }),
|
|
72
|
+
]);
|
|
73
|
+
const out = buildSkillListing(
|
|
74
|
+
r,
|
|
75
|
+
{ tenantId: "t" },
|
|
76
|
+
{ contextWindowTokens: 1, budgetPercent: 0.01, maxListingDescChars: 1000 },
|
|
77
|
+
);
|
|
78
|
+
const fp = out.entries.find((e) => e.name === "fp");
|
|
79
|
+
expect(fp).toBeDefined();
|
|
80
|
+
expect(fp?.description).toBe(long);
|
|
81
|
+
expect(fp?.degraded).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("lower-tier skills degrade to names-only then drop when budget exceeded", () => {
|
|
85
|
+
const long = "z".repeat(400);
|
|
86
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
87
|
+
skill({ slug: "s1", tenantId: "t", sourceTier: "workspace", description: long }),
|
|
88
|
+
skill({ slug: "s2", tenantId: "t", sourceTier: "workspace", description: long }),
|
|
89
|
+
skill({ slug: "s3", tenantId: "t", sourceTier: "workspace", description: long }),
|
|
90
|
+
]);
|
|
91
|
+
const tight = buildSkillListing(
|
|
92
|
+
r,
|
|
93
|
+
{ tenantId: "t" },
|
|
94
|
+
{ contextWindowTokens: 100, budgetPercent: 0.01, maxListingDescChars: 1000 },
|
|
95
|
+
);
|
|
96
|
+
// some degrade to names-only (no description)
|
|
97
|
+
expect(tight.entries.some((e) => e.degraded && e.description === "")).toBe(true);
|
|
98
|
+
|
|
99
|
+
const veryTight = buildSkillListing(
|
|
100
|
+
r,
|
|
101
|
+
{ tenantId: "t" },
|
|
102
|
+
{ contextWindowTokens: 1, budgetPercent: 0.01, maxListingDescChars: 1000 },
|
|
103
|
+
);
|
|
104
|
+
// beyond names-only, lower-tier entries drop entirely
|
|
105
|
+
expect(veryTight.entries.length).toBeLessThan(3);
|
|
106
|
+
expect(tight.entries.length).toBeGreaterThanOrEqual(veryTight.entries.length);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("suppresses skills whose slug is in ctx.loadedSlugs", () => {
|
|
110
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
111
|
+
skill({ slug: "loaded", tenantId: "t", sourceTier: "workspace" }),
|
|
112
|
+
skill({ slug: "fresh", tenantId: "t", sourceTier: "workspace" }),
|
|
113
|
+
]);
|
|
114
|
+
const out = buildSkillListing(r, {
|
|
115
|
+
tenantId: "t",
|
|
116
|
+
loadedSlugs: new Set(["loaded"]),
|
|
117
|
+
});
|
|
118
|
+
expect(out.entries.map((e) => e.name)).toEqual(["fresh"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("path-activated skills hidden until a touched path matches its glob", () => {
|
|
122
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
123
|
+
skill({
|
|
124
|
+
slug: "gated",
|
|
125
|
+
tenantId: "t",
|
|
126
|
+
sourceTier: "workspace",
|
|
127
|
+
paths: ["src/**/*.ts"],
|
|
128
|
+
}),
|
|
129
|
+
skill({ slug: "always", tenantId: "t", sourceTier: "workspace" }),
|
|
130
|
+
]);
|
|
131
|
+
const hidden = buildSkillListing(r, { tenantId: "t" });
|
|
132
|
+
expect(hidden.entries.map((e) => e.name)).toEqual(["always"]);
|
|
133
|
+
|
|
134
|
+
const shown = buildSkillListing(r, { tenantId: "t" }, { touchedPaths: ["src/foo/bar.ts"] });
|
|
135
|
+
expect(shown.entries.map((e) => e.name).sort()).toEqual(["always", "gated"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("glob supports *, ** and exact match", () => {
|
|
139
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
140
|
+
skill({ slug: "star", tenantId: "t", sourceTier: "workspace", paths: ["*.md"] }),
|
|
141
|
+
skill({ slug: "exact", tenantId: "t", sourceTier: "workspace", paths: ["a/b.txt"] }),
|
|
142
|
+
]);
|
|
143
|
+
const a = buildSkillListing(r, { tenantId: "t" }, { touchedPaths: ["README.md"] });
|
|
144
|
+
expect(a.entries.map((e) => e.name)).toEqual(["star"]);
|
|
145
|
+
const b = buildSkillListing(r, { tenantId: "t" }, { touchedPaths: ["a/b.txt"] });
|
|
146
|
+
expect(b.entries.map((e) => e.name)).toEqual(["exact"]);
|
|
147
|
+
const none = buildSkillListing(r, { tenantId: "t" }, { touchedPaths: ["a/c.txt"] });
|
|
148
|
+
expect(none.entries).toHaveLength(0);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("empty tenant fails closed", () => {
|
|
152
|
+
const r = new DefinitionResolver<SkillRecord>([]);
|
|
153
|
+
expect(() => buildSkillListing(r, { tenantId: "" })).toThrow(/tenant/i);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("cross-tenant isolation (resolver enforced)", () => {
|
|
157
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
158
|
+
skill({ slug: "a", tenantId: "t_a", sourceTier: "workspace" }),
|
|
159
|
+
]);
|
|
160
|
+
expect(buildSkillListing(r, { tenantId: "t_b" }).entries).toHaveLength(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("is deterministic — same inputs produce same output", () => {
|
|
164
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
165
|
+
skill({ slug: "a", tenantId: "t", sourceTier: "workspace", description: "aa" }),
|
|
166
|
+
skill({ slug: "b", tenantId: "t", sourceTier: "workspace", description: "bb" }),
|
|
167
|
+
]);
|
|
168
|
+
const o1 = buildSkillListing(r, { tenantId: "t" });
|
|
169
|
+
const o2 = buildSkillListing(r, { tenantId: "t" });
|
|
170
|
+
expect(o1.text).toBe(o2.text);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("expandSkill", () => {
|
|
175
|
+
const base = (slug: string) => `/skills/${slug}`;
|
|
176
|
+
|
|
177
|
+
it("expands body, prepends resource base line, substitutes args + vars", async () => {
|
|
178
|
+
const s = skill({
|
|
179
|
+
slug: "deploy",
|
|
180
|
+
tenantId: "t",
|
|
181
|
+
sourceTier: "workspace",
|
|
182
|
+
allowedTools: ["Bash", "Edit"],
|
|
183
|
+
});
|
|
184
|
+
const bodyLoader = vi.fn(async () => "deploy ${env} via ${SESSION}");
|
|
185
|
+
const out = await expandSkill(s, {
|
|
186
|
+
tenantId: "t",
|
|
187
|
+
args: { env: "prod" },
|
|
188
|
+
vars: { SESSION: "s1" },
|
|
189
|
+
bodyLoader,
|
|
190
|
+
skillResourceBase: base,
|
|
191
|
+
});
|
|
192
|
+
expect(bodyLoader).toHaveBeenCalledTimes(1);
|
|
193
|
+
expect(bodyLoader).toHaveBeenCalledWith("deploy.body");
|
|
194
|
+
const content = out.messages.map((m) => m.content).join("\n");
|
|
195
|
+
expect(content).toContain("Base directory for this skill: /skills/deploy");
|
|
196
|
+
expect(content).toContain("deploy prod via s1");
|
|
197
|
+
expect(out.allowedTools).toEqual(["Bash", "Edit"]);
|
|
198
|
+
expect(out.executionMode).toBe("inline");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("returns model override + effort + fork mode", async () => {
|
|
202
|
+
const s = skill({
|
|
203
|
+
slug: "heavy",
|
|
204
|
+
tenantId: "t",
|
|
205
|
+
sourceTier: "workspace",
|
|
206
|
+
model: "big-model",
|
|
207
|
+
effort: "high",
|
|
208
|
+
executionMode: "fork",
|
|
209
|
+
});
|
|
210
|
+
const out = await expandSkill(s, {
|
|
211
|
+
tenantId: "t",
|
|
212
|
+
args: {},
|
|
213
|
+
vars: {},
|
|
214
|
+
bodyLoader: async () => "body",
|
|
215
|
+
skillResourceBase: base,
|
|
216
|
+
});
|
|
217
|
+
expect(out.modelOverride).toBe("big-model");
|
|
218
|
+
expect(out.effort).toBe("high");
|
|
219
|
+
expect(out.executionMode).toBe("fork");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("body loader is lazy — only invoked on expand", async () => {
|
|
223
|
+
const r = new DefinitionResolver<SkillRecord>([
|
|
224
|
+
skill({ slug: "lazy", tenantId: "t", sourceTier: "workspace" }),
|
|
225
|
+
]);
|
|
226
|
+
const bodyLoader = vi.fn(async () => "b");
|
|
227
|
+
buildSkillListing(r, { tenantId: "t" });
|
|
228
|
+
expect(bodyLoader).not.toHaveBeenCalled();
|
|
229
|
+
await expandSkill(r.resolveOne("lazy", { tenantId: "t" })!, {
|
|
230
|
+
tenantId: "t",
|
|
231
|
+
args: {},
|
|
232
|
+
vars: {},
|
|
233
|
+
bodyLoader,
|
|
234
|
+
skillResourceBase: base,
|
|
235
|
+
});
|
|
236
|
+
expect(bodyLoader).toHaveBeenCalledTimes(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("empty tenant fails closed on expand", async () => {
|
|
240
|
+
const s = skill({ slug: "x", tenantId: "t", sourceTier: "workspace" });
|
|
241
|
+
await expect(
|
|
242
|
+
expandSkill(s, {
|
|
243
|
+
tenantId: "",
|
|
244
|
+
args: {},
|
|
245
|
+
vars: {},
|
|
246
|
+
bodyLoader: async () => "b",
|
|
247
|
+
skillResourceBase: base,
|
|
248
|
+
}),
|
|
249
|
+
).rejects.toThrow(/tenant/i);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("cross-tenant expand fails closed", async () => {
|
|
253
|
+
const s = skill({ slug: "x", tenantId: "t_a", sourceTier: "workspace" });
|
|
254
|
+
await expect(
|
|
255
|
+
expandSkill(s, {
|
|
256
|
+
tenantId: "t_b",
|
|
257
|
+
args: {},
|
|
258
|
+
vars: {},
|
|
259
|
+
bodyLoader: async () => "b",
|
|
260
|
+
skillResourceBase: base,
|
|
261
|
+
}),
|
|
262
|
+
).rejects.toThrow(/tenant/i);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("does not execute any shell from the body (no command substitution)", async () => {
|
|
266
|
+
const s = skill({ slug: "x", tenantId: "t", sourceTier: "workspace" });
|
|
267
|
+
const out = await expandSkill(s, {
|
|
268
|
+
tenantId: "t",
|
|
269
|
+
args: {},
|
|
270
|
+
vars: {},
|
|
271
|
+
bodyLoader: async () => "literal !`rm -rf /` stays literal",
|
|
272
|
+
skillResourceBase: base,
|
|
273
|
+
});
|
|
274
|
+
const content = out.messages.map((m) => m.content).join("\n");
|
|
275
|
+
expect(content).toContain("!`rm -rf /`");
|
|
276
|
+
});
|
|
277
|
+
});
|
package/src/skills.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills system (WRAP — progressive-disclosure capability).
|
|
3
|
+
*
|
|
4
|
+
* Faithful re-expression of the source harness's two-phase skill model:
|
|
5
|
+
*
|
|
6
|
+
* Phase 1 — Listing (cheap, always in context): a budget-bounded catalog
|
|
7
|
+
* exposing only {name, description, whenToUse} per skill. First-party
|
|
8
|
+
* skills (bundled/builtin tier) are never truncated and never dropped;
|
|
9
|
+
* lower-tier skills degrade to names-only, then drop, as the context
|
|
10
|
+
* budget is exhausted. Path-activated skills stay hidden until a touched
|
|
11
|
+
* file matches one of their globs.
|
|
12
|
+
*
|
|
13
|
+
* Phase 2 — Invocation (lazy, on demand): expand the chosen skill's body
|
|
14
|
+
* into turn messages, with arg/var substitution and a merged tool
|
|
15
|
+
* allowlist contributed to the permission context.
|
|
16
|
+
*
|
|
17
|
+
* A skill IS a `Definition` — the same tenant-scoped layered resolver backs
|
|
18
|
+
* the command registry and the subagent registry (see ./definitions).
|
|
19
|
+
*
|
|
20
|
+
* SECURITY: this re-expression intentionally drops the source's body-side
|
|
21
|
+
* command-substitution (the upstream "!`…`" inline-shell facility). Skill
|
|
22
|
+
* bodies are inert text; they NEVER trigger host shell execution here. All
|
|
23
|
+
* entry points require a non-empty `tenantId` and fail closed; cross-tenant
|
|
24
|
+
* inputs are rejected by the underlying `DefinitionResolver`. Pure data/logic
|
|
25
|
+
* — no host filesystem access (resource base is an injected indirection).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import {
|
|
30
|
+
type Definition,
|
|
31
|
+
type DefinitionResolver,
|
|
32
|
+
type ResolveContext,
|
|
33
|
+
substituteArguments,
|
|
34
|
+
} from "./definitions";
|
|
35
|
+
|
|
36
|
+
/** A skill is just a resolvable `Definition`. */
|
|
37
|
+
export type SkillRecord = Definition;
|
|
38
|
+
|
|
39
|
+
/** Tiers treated as first-party (never truncated, never dropped). */
|
|
40
|
+
const FIRST_PARTY_TIERS: ReadonlySet<string> = new Set(["bundled", "builtin"]);
|
|
41
|
+
|
|
42
|
+
const isFirstParty = (s: SkillRecord): boolean => FIRST_PARTY_TIERS.has(s.sourceTier);
|
|
43
|
+
|
|
44
|
+
// ── Phase 1: listing ─────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** One catalog entry — only the cheap, always-in-context surface. */
|
|
47
|
+
export interface SkillListingEntry {
|
|
48
|
+
readonly name: string;
|
|
49
|
+
readonly description: string;
|
|
50
|
+
readonly whenToUse?: string | undefined;
|
|
51
|
+
/** True when this entry was degraded to names-only by the budget. */
|
|
52
|
+
readonly degraded: boolean;
|
|
53
|
+
readonly firstParty: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SkillListing {
|
|
57
|
+
readonly entries: readonly SkillListingEntry[];
|
|
58
|
+
readonly text: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const listingOptionsSchema = z.object({
|
|
62
|
+
maxListingDescChars: z.number().int().positive().default(250),
|
|
63
|
+
contextWindowTokens: z.number().int().positive().default(200_000),
|
|
64
|
+
budgetPercent: z.number().positive().max(1).default(0.01),
|
|
65
|
+
touchedPaths: z.array(z.string()).default([]),
|
|
66
|
+
});
|
|
67
|
+
export type SkillListingOptions = z.input<typeof listingOptionsSchema>;
|
|
68
|
+
|
|
69
|
+
/** Cheap, deterministic token estimate (≈4 chars/token). */
|
|
70
|
+
const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
|
|
71
|
+
|
|
72
|
+
/** Minimal glob: `**` = any chars, `*` = any chars except `/`, else exact. */
|
|
73
|
+
function globToRegExp(glob: string): RegExp {
|
|
74
|
+
let out = "^";
|
|
75
|
+
for (let i = 0; i < glob.length; i++) {
|
|
76
|
+
const c = glob[i]!;
|
|
77
|
+
if (c === "*") {
|
|
78
|
+
if (glob[i + 1] === "*") {
|
|
79
|
+
out += ".*";
|
|
80
|
+
i++;
|
|
81
|
+
} else {
|
|
82
|
+
out += "[^/]*";
|
|
83
|
+
}
|
|
84
|
+
} else if (".+?^${}()|[]\\".includes(c)) {
|
|
85
|
+
out += `\\${c}`;
|
|
86
|
+
} else {
|
|
87
|
+
out += c;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return new RegExp(`${out}$`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pathMatches = (globs: readonly string[], touched: readonly string[]): boolean =>
|
|
94
|
+
globs.some((g) => {
|
|
95
|
+
const re = globToRegExp(g);
|
|
96
|
+
return touched.some((p) => re.test(p));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/** Stable order: first-party first, then by slug — deterministic output. */
|
|
100
|
+
function orderSkills(skills: readonly SkillRecord[]): SkillRecord[] {
|
|
101
|
+
return [...skills].sort((a, b) => {
|
|
102
|
+
const fa = isFirstParty(a) ? 0 : 1;
|
|
103
|
+
const fb = isFirstParty(b) ? 0 : 1;
|
|
104
|
+
if (fa !== fb) return fa - fb;
|
|
105
|
+
return a.slug.localeCompare(b.slug);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const truncate = (s: string, max: number): string => (s.length <= max ? s : s.slice(0, max));
|
|
110
|
+
|
|
111
|
+
function renderEntry(e: SkillListingEntry): string {
|
|
112
|
+
const head = e.whenToUse ? `- ${e.name} — ${e.whenToUse}` : `- ${e.name}`;
|
|
113
|
+
return e.description ? `${head}\n ${e.description}` : head;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Phase 1 — build the always-in-context skill catalog. Pure & deterministic.
|
|
118
|
+
*
|
|
119
|
+
* Budget = `contextWindowTokens * budgetPercent`. First-party skills are
|
|
120
|
+
* emitted in full and do NOT consume budget against being dropped. Each
|
|
121
|
+
* remaining skill is tried at full description; if it would overflow it is
|
|
122
|
+
* degraded to names-only; if even that overflows it is dropped. Skills in
|
|
123
|
+
* `ctx.loadedSlugs` and unactivated path-gated skills are suppressed.
|
|
124
|
+
*/
|
|
125
|
+
export function buildSkillListing(
|
|
126
|
+
resolver: DefinitionResolver<SkillRecord>,
|
|
127
|
+
ctx: ResolveContext,
|
|
128
|
+
options: SkillListingOptions = {},
|
|
129
|
+
): SkillListing {
|
|
130
|
+
if (!ctx.tenantId) throw new Error("buildSkillListing: tenantId is required");
|
|
131
|
+
const opts = listingOptionsSchema.parse(options);
|
|
132
|
+
|
|
133
|
+
const loaded = ctx.loadedSlugs ?? new Set<string>();
|
|
134
|
+
const visible = resolver
|
|
135
|
+
.resolve(ctx)
|
|
136
|
+
.filter((s) => !loaded.has(s.slug))
|
|
137
|
+
.filter((s) => {
|
|
138
|
+
const paths = s.frontmatter.paths;
|
|
139
|
+
return paths.length === 0 || pathMatches(paths, opts.touchedPaths);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const budget = Math.max(1, Math.floor(opts.contextWindowTokens * opts.budgetPercent));
|
|
143
|
+
|
|
144
|
+
const entries: SkillListingEntry[] = [];
|
|
145
|
+
let used = 0;
|
|
146
|
+
|
|
147
|
+
for (const s of orderSkills(visible)) {
|
|
148
|
+
const fp = isFirstParty(s);
|
|
149
|
+
const fm = s.frontmatter;
|
|
150
|
+
const fullDesc = fp ? fm.description : truncate(fm.description, opts.maxListingDescChars);
|
|
151
|
+
|
|
152
|
+
if (fp) {
|
|
153
|
+
const entry: SkillListingEntry = {
|
|
154
|
+
name: fm.name,
|
|
155
|
+
description: fm.description,
|
|
156
|
+
whenToUse: fm.whenToUse,
|
|
157
|
+
degraded: false,
|
|
158
|
+
firstParty: true,
|
|
159
|
+
};
|
|
160
|
+
entries.push(entry);
|
|
161
|
+
used += estimateTokens(renderEntry(entry));
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const full: SkillListingEntry = {
|
|
166
|
+
name: fm.name,
|
|
167
|
+
description: fullDesc,
|
|
168
|
+
whenToUse: fm.whenToUse,
|
|
169
|
+
degraded: false,
|
|
170
|
+
firstParty: false,
|
|
171
|
+
};
|
|
172
|
+
const fullCost = estimateTokens(renderEntry(full));
|
|
173
|
+
if (used + fullCost <= budget) {
|
|
174
|
+
entries.push(full);
|
|
175
|
+
used += fullCost;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const nameOnly: SkillListingEntry = {
|
|
180
|
+
name: fm.name,
|
|
181
|
+
description: "",
|
|
182
|
+
whenToUse: undefined,
|
|
183
|
+
degraded: true,
|
|
184
|
+
firstParty: false,
|
|
185
|
+
};
|
|
186
|
+
const nameCost = estimateTokens(renderEntry(nameOnly));
|
|
187
|
+
if (used + nameCost <= budget) {
|
|
188
|
+
entries.push(nameOnly);
|
|
189
|
+
used += nameCost;
|
|
190
|
+
}
|
|
191
|
+
// else: drop entirely.
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const text = entries.map(renderEntry).join("\n");
|
|
195
|
+
return { entries, text };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── Phase 2: invocation ──────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
export interface SkillMessage {
|
|
201
|
+
readonly role: "system" | "user";
|
|
202
|
+
readonly content: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface ExpandedSkill {
|
|
206
|
+
readonly messages: readonly SkillMessage[];
|
|
207
|
+
readonly allowedTools: readonly string[];
|
|
208
|
+
readonly modelOverride?: string | undefined;
|
|
209
|
+
readonly effort?: "low" | "medium" | "high" | undefined;
|
|
210
|
+
readonly executionMode: "inline" | "fork";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface ExpandContext {
|
|
214
|
+
readonly tenantId: string;
|
|
215
|
+
readonly args: Record<string, string>;
|
|
216
|
+
readonly vars: Record<string, string>;
|
|
217
|
+
/** Lazy body fetch — host supplies; called only here, only once. */
|
|
218
|
+
readonly bodyLoader: (bodyRef: string) => Promise<string>;
|
|
219
|
+
/** Host-injected resource-base indirection (NO host FS access here). */
|
|
220
|
+
readonly skillResourceBase: (slug: string) => string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Phase 2 — expand a skill into turn messages. Body is loaded lazily via the
|
|
225
|
+
* injected `bodyLoader` (never during listing). A `Base directory…` line is
|
|
226
|
+
* prepended via the injected `skillResourceBase` indirection. Args + vars are
|
|
227
|
+
* substituted with `substituteArguments`. The skill's `allowedTools` are
|
|
228
|
+
* returned for the caller to merge into the permission context — this never
|
|
229
|
+
* mutates global state and never executes anything from the body.
|
|
230
|
+
*/
|
|
231
|
+
export async function expandSkill(skill: SkillRecord, ctx: ExpandContext): Promise<ExpandedSkill> {
|
|
232
|
+
if (!ctx.tenantId) throw new Error("expandSkill: tenantId is required");
|
|
233
|
+
if (skill.tenantId !== ctx.tenantId) {
|
|
234
|
+
throw new Error("expandSkill: cross-tenant skill access rejected");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const rawBody = await ctx.bodyLoader(skill.bodyRef);
|
|
238
|
+
const body = substituteArguments(rawBody, ctx.args, ctx.vars);
|
|
239
|
+
const baseLine = `Base directory for this skill: ${ctx.skillResourceBase(skill.slug)}`;
|
|
240
|
+
|
|
241
|
+
const messages: readonly SkillMessage[] = [
|
|
242
|
+
{ role: "system", content: baseLine },
|
|
243
|
+
{ role: "user", content: body },
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
const allowedTools = [...skill.frontmatter.allowedTools];
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
messages,
|
|
250
|
+
allowedTools,
|
|
251
|
+
modelOverride: skill.frontmatter.model,
|
|
252
|
+
effort: skill.frontmatter.effort,
|
|
253
|
+
executionMode: skill.frontmatter.executionMode,
|
|
254
|
+
};
|
|
255
|
+
}
|