@agentswf/engine 0.0.0-stage → 0.1.0-beta.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/package.json +39 -4
- package/src/accounting/format.ts +252 -0
- package/src/accounting/plans.ts +38 -0
- package/src/accounting/prices.ts +98 -0
- package/src/accounting/summary.ts +385 -0
- package/src/accounting/tokens.ts +34 -0
- package/src/agent-launcher.ts +127 -0
- package/src/allowance-command.ts +105 -0
- package/src/attempt-ending.ts +198 -0
- package/src/attempt-output.ts +132 -0
- package/src/attempt-view.ts +362 -0
- package/src/canonical-json.ts +12 -0
- package/src/control-plane.ts +424 -0
- package/src/deadlines.ts +106 -0
- package/src/decisions/directory.ts +432 -0
- package/src/decisions/fake.ts +60 -0
- package/src/decisions/openrouter.ts +161 -0
- package/src/decisions/seam.ts +70 -0
- package/src/display-path.ts +16 -0
- package/src/duration.ts +12 -0
- package/src/errors.ts +4 -0
- package/src/files.ts +78 -0
- package/src/herdr-placement.ts +182 -0
- package/src/herdr-run-session.ts +530 -0
- package/src/herdr-workspace-marks.ts +194 -0
- package/src/here.ts +289 -0
- package/src/index.ts +5 -0
- package/src/init-command.ts +240 -0
- package/src/init-template/AGENTS.md.template +16 -0
- package/src/init-template/hello/workflow.test.ts.template +17 -0
- package/src/init-template/hello/workflow.ts.template +26 -0
- package/src/init-template/tsconfig.json.template +11 -0
- package/src/jsonl.ts +74 -0
- package/src/logs-command.ts +605 -0
- package/src/machine.ts +27 -0
- package/src/message-operation.ts +245 -0
- package/src/messaging/limits.ts +35 -0
- package/src/messaging/messaging.ts +1303 -0
- package/src/messaging/routes.ts +79 -0
- package/src/messaging/text.ts +251 -0
- package/src/operation-events.ts +182 -0
- package/src/operation-liveness.ts +674 -0
- package/src/operator-aliases.ts +10 -0
- package/src/operator-cli.ts +600 -0
- package/src/operator-runtime.ts +503 -0
- package/src/pane-layout.ts +125 -0
- package/src/progress-view.ts +568 -0
- package/src/result-slots.ts +325 -0
- package/src/result-validation.ts +47 -0
- package/src/run-command.ts +242 -0
- package/src/run-commands.ts +215 -0
- package/src/run-event-log.ts +72 -0
- package/src/run-prepare.ts +178 -0
- package/src/run-progress.ts +137 -0
- package/src/run-read.ts +425 -0
- package/src/run-state.ts +120 -0
- package/src/run-usage.ts +393 -0
- package/src/run-view.ts +225 -0
- package/src/runs-command.ts +356 -0
- package/src/runs.ts +645 -0
- package/src/sandbox-homes.ts +211 -0
- package/src/sandboxes.ts +521 -0
- package/src/skills/fetch.ts +305 -0
- package/src/skills/run-skills.ts +181 -0
- package/src/skills/sources.ts +87 -0
- package/src/skills/tree.ts +60 -0
- package/src/stage-ledger.ts +535 -0
- package/src/stage-plan.ts +127 -0
- package/src/stage-schema.ts +88 -0
- package/src/stopped.ts +36 -0
- package/src/test-command.ts +110 -0
- package/src/testing/operation-events.ts +89 -0
- package/src/testing.ts +164 -0
- package/src/workflow-loader.ts +113 -0
- package/src/workflow-runner.ts +3666 -0
- package/src/workflow-test-preload.ts +13 -0
- package/src/workflow-testing/decisions.ts +129 -0
- package/src/workflow-testing/host.ts +811 -0
- package/src/workflow-testing/index.ts +420 -0
- package/src/workflow-testing/script.ts +447 -0
- package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
- package/README.md +0 -3
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { exists } from "../files";
|
|
5
|
+
import { parseSkillFile } from "./sources";
|
|
6
|
+
import { MAX_BYTES, MAX_FILES } from "./tree";
|
|
7
|
+
|
|
8
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
9
|
+
type GitResult = { ok: boolean; out: string; err: string };
|
|
10
|
+
|
|
11
|
+
/** Past this, a fetch is stuck, not slow: a skill is a few files. */
|
|
12
|
+
const GIT_TIMEOUT_MS = 5 * 60_000;
|
|
13
|
+
const HEX = /^[0-9a-f]{7,40}$/i;
|
|
14
|
+
|
|
15
|
+
/** A bare repository per URL in `cache`, shared by every run: git locks its own refs and objects. */
|
|
16
|
+
export async function cachedRepository(
|
|
17
|
+
cache: string,
|
|
18
|
+
url: string,
|
|
19
|
+
environment: Environment,
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
const directory = join(
|
|
22
|
+
cache,
|
|
23
|
+
"repos",
|
|
24
|
+
createHash("sha256").update(url).digest("hex").slice(0, 24),
|
|
25
|
+
);
|
|
26
|
+
if (!(await exists(join(directory, "HEAD")))) {
|
|
27
|
+
await mkdir(join(cache, "repos"), { recursive: true });
|
|
28
|
+
const staged = `${directory}.${randomUUID()}`;
|
|
29
|
+
await git(["init", "--quiet", "--bare", staged], environment);
|
|
30
|
+
// Two runs may create it at once; the one that lands second keeps the first's.
|
|
31
|
+
await rename(staged, directory).catch(() => rm(staged, { recursive: true, force: true }));
|
|
32
|
+
}
|
|
33
|
+
return directory;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The commit `ref` names, or the default branch's, in `repository`: fetched unless it is a commit
|
|
38
|
+
* an earlier fetch kept. Every commit resolved is kept under `refs/awf/commits/`, so its objects
|
|
39
|
+
* stay and a run that pins it again needs no network.
|
|
40
|
+
*/
|
|
41
|
+
export async function fetchCommit(
|
|
42
|
+
repository: string,
|
|
43
|
+
url: string,
|
|
44
|
+
ref: string | undefined,
|
|
45
|
+
environment: Environment,
|
|
46
|
+
): Promise<string> {
|
|
47
|
+
const kept = ref && HEX.test(ref) ? await commitOf(repository, ref, environment) : undefined;
|
|
48
|
+
if (kept) return kept;
|
|
49
|
+
// A ref of our own per fetch: two agents or runs fetching at once never read each other's.
|
|
50
|
+
const into = `refs/awf/fetch/${randomUUID()}`;
|
|
51
|
+
try {
|
|
52
|
+
const shallow = await unlocked(() =>
|
|
53
|
+
git(
|
|
54
|
+
["-C", repository, "fetch", "--quiet", "--depth", "1", url, `+${ref ?? "HEAD"}:${into}`],
|
|
55
|
+
environment,
|
|
56
|
+
{ allowFailure: true },
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
if (shallow.ok) return await keep(repository, into, environment);
|
|
60
|
+
if (ref === undefined || !HEX.test(ref)) {
|
|
61
|
+
throw new Error(`cannot fetch ${ref ?? "the default branch"} of ${url}: ${shallow.err}`);
|
|
62
|
+
}
|
|
63
|
+
// A commit, which a server need not serve alone, and a short one it cannot: every branch and
|
|
64
|
+
// tag with their history, past any shallow boundary an earlier fetch left.
|
|
65
|
+
const shallowed = await git(
|
|
66
|
+
["-C", repository, "rev-parse", "--is-shallow-repository"],
|
|
67
|
+
environment,
|
|
68
|
+
);
|
|
69
|
+
const full = await unlocked(() =>
|
|
70
|
+
git(
|
|
71
|
+
[
|
|
72
|
+
"-C",
|
|
73
|
+
repository,
|
|
74
|
+
"fetch",
|
|
75
|
+
"--quiet",
|
|
76
|
+
...(shallowed.out === "true" ? ["--unshallow"] : []),
|
|
77
|
+
url,
|
|
78
|
+
`+refs/heads/*:${into}/heads/*`,
|
|
79
|
+
`+refs/tags/*:${into}/tags/*`,
|
|
80
|
+
],
|
|
81
|
+
environment,
|
|
82
|
+
{ allowFailure: true },
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
if (!full.ok) throw new Error(`cannot fetch ${url}: ${full.err}`);
|
|
86
|
+
if (!(await commitOf(repository, ref, environment))) {
|
|
87
|
+
throw new Error(`${url} has no commit ${ref}`);
|
|
88
|
+
}
|
|
89
|
+
return await keep(repository, ref, environment);
|
|
90
|
+
} finally {
|
|
91
|
+
const refs = await git(
|
|
92
|
+
["-C", repository, "for-each-ref", "--format=%(refname)", into, `${into}/`],
|
|
93
|
+
environment,
|
|
94
|
+
{ allowFailure: true },
|
|
95
|
+
);
|
|
96
|
+
const names = refs.out.split("\n").filter(Boolean);
|
|
97
|
+
if (names.length > 0) {
|
|
98
|
+
await git(["-C", repository, "update-ref", "--stdin"], environment, {
|
|
99
|
+
allowFailure: true,
|
|
100
|
+
stdin: names.map((name) => `delete ${name}\n`).join(""),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The commit `name` resolves to, an annotated tag peeled, or `undefined`. */
|
|
107
|
+
async function commitOf(
|
|
108
|
+
repository: string,
|
|
109
|
+
name: string,
|
|
110
|
+
environment: Environment,
|
|
111
|
+
): Promise<string | undefined> {
|
|
112
|
+
const found = await git(
|
|
113
|
+
["-C", repository, "rev-parse", "--verify", "--quiet", `${name}^{commit}`],
|
|
114
|
+
environment,
|
|
115
|
+
{ allowFailure: true },
|
|
116
|
+
);
|
|
117
|
+
return found.ok ? found.out : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function keep(repository: string, name: string, environment: Environment): Promise<string> {
|
|
121
|
+
const commit = await commitOf(repository, name, environment);
|
|
122
|
+
if (!commit) throw new Error(`${name} is not a commit`);
|
|
123
|
+
await git(["-C", repository, "update-ref", `refs/awf/commits/${commit}`, commit], environment);
|
|
124
|
+
return commit;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* `fetch`, again while another run's fetch into the shared cache holds one of git's locks: git
|
|
129
|
+
* refuses rather than waits. Two minutes covers a large fetch by the other run.
|
|
130
|
+
*/
|
|
131
|
+
async function unlocked(fetch: () => Promise<GitResult>): Promise<GitResult> {
|
|
132
|
+
for (let attempt = 0; ; attempt++) {
|
|
133
|
+
const result = await fetch();
|
|
134
|
+
if (result.ok || !/\.lock'?: File exists/.test(result.err) || attempt >= 480) return result;
|
|
135
|
+
await Bun.sleep(250);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The directory, relative to the repository's root, whose `SKILL.md` is named `skill`. */
|
|
140
|
+
export async function findSkill(
|
|
141
|
+
repository: string,
|
|
142
|
+
commit: string,
|
|
143
|
+
skill: string,
|
|
144
|
+
environment: Environment,
|
|
145
|
+
): Promise<string> {
|
|
146
|
+
// `-z`, or a path outside ASCII comes back quoted and never matches.
|
|
147
|
+
const listed = await git(
|
|
148
|
+
["-C", repository, "ls-tree", "-r", "-z", "--name-only", commit],
|
|
149
|
+
environment,
|
|
150
|
+
);
|
|
151
|
+
const files = listed.out
|
|
152
|
+
.split("\0")
|
|
153
|
+
.filter(
|
|
154
|
+
(path) =>
|
|
155
|
+
(path === "SKILL.md" || path.endsWith("/SKILL.md")) && !path.includes("node_modules/"),
|
|
156
|
+
);
|
|
157
|
+
const matches: string[] = [];
|
|
158
|
+
const names: string[] = [];
|
|
159
|
+
for (const file of files) {
|
|
160
|
+
const text = await git(["-C", repository, "show", `${commit}:${file}`], environment);
|
|
161
|
+
let name: string | undefined;
|
|
162
|
+
try {
|
|
163
|
+
({ name } = parseSkillFile(text.out, file));
|
|
164
|
+
} catch {
|
|
165
|
+
// Not a skill this can load; the one asked for may still be elsewhere.
|
|
166
|
+
}
|
|
167
|
+
if (name) names.push(name);
|
|
168
|
+
if (name === skill) matches.push(file === "SKILL.md" ? "" : file.slice(0, -"/SKILL.md".length));
|
|
169
|
+
}
|
|
170
|
+
if (matches.length === 0) {
|
|
171
|
+
const known = names.length > 0 ? `; it has ${names.slice(0, 10).join(", ")}` : "";
|
|
172
|
+
throw new Error(`no skill named ${skill} at ${commit.slice(0, 12)}${known}`);
|
|
173
|
+
}
|
|
174
|
+
if (matches.length > 1) {
|
|
175
|
+
throw new Error(`more than one skill is named ${skill}: ${matches.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
return matches[0]!;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `within` at `commit`, written under `into` from git's objects: every file, where an archive
|
|
182
|
+
* would drop what `.gitattributes` marks `export-ignore`, and a link or a submodule refused, and
|
|
183
|
+
* the caps checked, before anything is written.
|
|
184
|
+
*/
|
|
185
|
+
export async function extract(
|
|
186
|
+
repository: string,
|
|
187
|
+
commit: string,
|
|
188
|
+
within: string,
|
|
189
|
+
into: string,
|
|
190
|
+
environment: Environment,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const tree = within === "" ? `${commit}^{tree}` : `${commit}:${within}`;
|
|
193
|
+
const listed = await git(["-C", repository, "ls-tree", "-r", "-z", "-l", tree], environment);
|
|
194
|
+
const entries: { mode: string; object: string; path: string }[] = [];
|
|
195
|
+
let bytes = 0;
|
|
196
|
+
for (const line of listed.out.split("\0").filter(Boolean)) {
|
|
197
|
+
const match = /^(\d+) (\w+) ([0-9a-f]+) +(-|\d+)\t(.+)$/s.exec(line);
|
|
198
|
+
if (!match) throw new Error(`cannot read git's listing of ${tree}: ${line}`);
|
|
199
|
+
const [, mode, , object, size, path] = match as unknown as [
|
|
200
|
+
string,
|
|
201
|
+
string,
|
|
202
|
+
string,
|
|
203
|
+
string,
|
|
204
|
+
string,
|
|
205
|
+
string,
|
|
206
|
+
];
|
|
207
|
+
if (mode === "120000") throw new Error(`skill at ${within || "the root"}: ${path} is a link`);
|
|
208
|
+
if (mode !== "100644" && mode !== "100755") {
|
|
209
|
+
throw new Error(`skill at ${within || "the root"}: ${path} is not a file`);
|
|
210
|
+
}
|
|
211
|
+
if (path.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
212
|
+
throw new Error(`skill at ${within || "the root"}: ${path} is not a path inside it`);
|
|
213
|
+
}
|
|
214
|
+
bytes += Number(size);
|
|
215
|
+
entries.push({ mode, object, path });
|
|
216
|
+
}
|
|
217
|
+
if (bytes > MAX_BYTES || entries.length > MAX_FILES) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`skill at ${within || "the root"} is over ${MAX_FILES} files or ${MAX_BYTES} bytes`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const blobs = await readBlobs(
|
|
223
|
+
repository,
|
|
224
|
+
entries.map((entry) => entry.object),
|
|
225
|
+
environment,
|
|
226
|
+
);
|
|
227
|
+
for (const [index, entry] of entries.entries()) {
|
|
228
|
+
const path = join(into, entry.path);
|
|
229
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o755 });
|
|
230
|
+
await writeFile(path, blobs[index]!, { mode: entry.mode === "100755" ? 0o755 : 0o644 });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Each object's bytes, in order, from one `cat-file --batch`. */
|
|
235
|
+
async function readBlobs(
|
|
236
|
+
repository: string,
|
|
237
|
+
objects: readonly string[],
|
|
238
|
+
environment: Environment,
|
|
239
|
+
): Promise<Buffer[]> {
|
|
240
|
+
if (objects.length === 0) return [];
|
|
241
|
+
const child = Bun.spawn({
|
|
242
|
+
cmd: ["git", "-C", repository, "cat-file", "--batch"],
|
|
243
|
+
env: gitEnvironment(environment),
|
|
244
|
+
stdin: new TextEncoder().encode(`${objects.join("\n")}\n`),
|
|
245
|
+
stdout: "pipe",
|
|
246
|
+
stderr: "pipe",
|
|
247
|
+
timeout: GIT_TIMEOUT_MS,
|
|
248
|
+
});
|
|
249
|
+
const [code, out, err] = await Promise.all([
|
|
250
|
+
child.exited,
|
|
251
|
+
new Response(child.stdout).arrayBuffer(),
|
|
252
|
+
new Response(child.stderr).text(),
|
|
253
|
+
]);
|
|
254
|
+
if (code !== 0) throw new Error(`git cat-file failed: ${err.trim()}`);
|
|
255
|
+
const buffer = Buffer.from(out);
|
|
256
|
+
const blobs: Buffer[] = [];
|
|
257
|
+
let at = 0;
|
|
258
|
+
for (const object of objects) {
|
|
259
|
+
const end = buffer.indexOf(10, at);
|
|
260
|
+
const header = buffer.subarray(at, end).toString("utf8");
|
|
261
|
+
const match = /^([0-9a-f]+) blob (\d+)$/.exec(header);
|
|
262
|
+
if (!match || match[1] !== object) throw new Error(`git cat-file answered ${header}`);
|
|
263
|
+
const size = Number(match[2]);
|
|
264
|
+
blobs.push(buffer.subarray(end + 1, end + 1 + size));
|
|
265
|
+
at = end + 1 + size + 1;
|
|
266
|
+
}
|
|
267
|
+
return blobs;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Never a prompt: a private repository fails instead of waiting for a password nobody types, and
|
|
272
|
+
* ssh, which ignores git's variable, asks for no passphrase or host key.
|
|
273
|
+
*/
|
|
274
|
+
function gitEnvironment(environment: Environment): Record<string, string | undefined> {
|
|
275
|
+
return {
|
|
276
|
+
...environment,
|
|
277
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
278
|
+
GIT_SSH_COMMAND: environment.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function git(
|
|
283
|
+
args: readonly string[],
|
|
284
|
+
environment: Environment,
|
|
285
|
+
options: { allowFailure?: boolean; stdin?: string } = {},
|
|
286
|
+
): Promise<GitResult> {
|
|
287
|
+
const child = Bun.spawn({
|
|
288
|
+
cmd: ["git", ...args],
|
|
289
|
+
env: gitEnvironment(environment),
|
|
290
|
+
stdin: options.stdin === undefined ? "ignore" : new TextEncoder().encode(options.stdin),
|
|
291
|
+
stdout: "pipe",
|
|
292
|
+
stderr: "pipe",
|
|
293
|
+
timeout: GIT_TIMEOUT_MS,
|
|
294
|
+
});
|
|
295
|
+
const [code, out, err] = await Promise.all([
|
|
296
|
+
child.exited,
|
|
297
|
+
new Response(child.stdout).text(),
|
|
298
|
+
new Response(child.stderr).text(),
|
|
299
|
+
]);
|
|
300
|
+
const result = { ok: code === 0, out: out.trim(), err: err.trim() || `exit ${code}` };
|
|
301
|
+
if (!result.ok && !options.allowFailure) {
|
|
302
|
+
throw new Error(`git ${args[0] === "-C" ? args[2] : args[0]} failed: ${result.err}`);
|
|
303
|
+
}
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import type { AgentSkillsRecord, SkillRecord } from "@agentswf/contract/records";
|
|
6
|
+
import type { SkillSource } from "@agentswf/contract/workflow";
|
|
7
|
+
import { cachedRepository, extract, fetchCommit, findSkill } from "./fetch";
|
|
8
|
+
import { parseSkillFile, repositoryUrl } from "./sources";
|
|
9
|
+
import { copySkillTree, digestTree } from "./tree";
|
|
10
|
+
|
|
11
|
+
export { parseSkillFile, readSkillSources } from "./sources";
|
|
12
|
+
|
|
13
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
14
|
+
|
|
15
|
+
/** A skill resolved for this run: a checked snapshot every agent naming it is copied from. */
|
|
16
|
+
export type ResolvedSkill = {
|
|
17
|
+
name: string;
|
|
18
|
+
/** The run's snapshot, which no agent reaches. */
|
|
19
|
+
snapshot: string;
|
|
20
|
+
record: SkillRecord;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A run's skills: each source resolved once, so every agent naming it gets the same files, into a
|
|
25
|
+
* snapshot under the run directory; then a copy per agent, and the record of what each agent had.
|
|
26
|
+
*/
|
|
27
|
+
export class RunSkills {
|
|
28
|
+
readonly #resolved = new Map<string, Promise<ResolvedSkill>>();
|
|
29
|
+
/** Each repository's last fetch in line: git's shallow lock refuses a second one at once. */
|
|
30
|
+
readonly #fetching = new Map<string, Promise<unknown>>();
|
|
31
|
+
/** Each (URL, ref)'s commit, so two skills from one repository's `main` get one commit. */
|
|
32
|
+
readonly #commits = new Map<string, Promise<string>>();
|
|
33
|
+
readonly #records: AgentSkillsRecord[] = [];
|
|
34
|
+
readonly #root: string;
|
|
35
|
+
readonly #cache: string;
|
|
36
|
+
readonly #environment: Environment;
|
|
37
|
+
|
|
38
|
+
constructor(options: { runDir: string; cacheRoot?: string; environment?: Environment }) {
|
|
39
|
+
this.#environment = options.environment ?? process.env;
|
|
40
|
+
// Absolute: git runs in the cache's repository, and a harness is pointed at these paths.
|
|
41
|
+
this.#root = resolve(options.runDir, "skills");
|
|
42
|
+
this.#cache = resolve(options.cacheRoot ?? defaultCacheRoot(this.#environment));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Every source resolved and checked, or the first reason one could not be. */
|
|
46
|
+
async resolve(sources: readonly SkillSource[]): Promise<ResolvedSkill[]> {
|
|
47
|
+
const resolved = await Promise.all(sources.map((source) => this.#one(source)));
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
for (const { name } of resolved) {
|
|
50
|
+
if (seen.has(name)) throw new Error(`two skills are named ${name}`);
|
|
51
|
+
seen.add(name);
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What an agent was given, and the home of its own it ran with on the host, if any. */
|
|
57
|
+
record(agent: string, skills: "operator" | readonly ResolvedSkill[], home?: string): void {
|
|
58
|
+
this.#records.push({
|
|
59
|
+
callPath: [],
|
|
60
|
+
agent,
|
|
61
|
+
skills: skills === "operator" ? "operator" : skills.map((skill) => ({ ...skill.record })),
|
|
62
|
+
...(home === undefined ? {} : { home }),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
records(): AgentSkillsRecord[] {
|
|
67
|
+
return this.#records.map((record) => structuredClone(record));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#one(source: SkillSource): Promise<ResolvedSkill> {
|
|
71
|
+
const key = JSON.stringify(source);
|
|
72
|
+
let resolving = this.#resolved.get(key);
|
|
73
|
+
if (!resolving) {
|
|
74
|
+
resolving = "path" in source ? this.#fromPath(source) : this.#fromRepo(source);
|
|
75
|
+
this.#resolved.set(key, resolving);
|
|
76
|
+
// A source that failed is tried again by the next agent that names it, not remembered.
|
|
77
|
+
resolving.catch(() => {
|
|
78
|
+
if (this.#resolved.get(key) === resolving) this.#resolved.delete(key);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return resolving;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async #fromPath(source: { path: string }): Promise<ResolvedSkill> {
|
|
85
|
+
let directory: string;
|
|
86
|
+
try {
|
|
87
|
+
directory = await realpath(source.path);
|
|
88
|
+
} catch {
|
|
89
|
+
throw new Error(`skill path ${source.path} does not exist`);
|
|
90
|
+
}
|
|
91
|
+
const snapshot = await this.#snapshot((into) => copySkillTree(directory, into));
|
|
92
|
+
const { name, digest } = await checkSkill(snapshot, source.path);
|
|
93
|
+
return { name, snapshot, record: { name, source: { path: source.path }, digest } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async #fromRepo(source: { repo: string; skill: string; ref?: string }): Promise<ResolvedSkill> {
|
|
97
|
+
const url = repositoryUrl(source.repo);
|
|
98
|
+
const repository = await cachedRepository(this.#cache, url, this.#environment);
|
|
99
|
+
const commit = await this.#commit(repository, url, source.ref);
|
|
100
|
+
const within = await findSkill(repository, commit, source.skill, this.#environment);
|
|
101
|
+
const snapshot = await this.#snapshot((into) =>
|
|
102
|
+
extract(repository, commit, within, into, this.#environment),
|
|
103
|
+
);
|
|
104
|
+
const label = `${source.repo}@${source.skill}`;
|
|
105
|
+
const { name, digest } = await checkSkill(snapshot, label);
|
|
106
|
+
if (name !== source.skill) throw new Error(`${label}: its SKILL.md is named ${name}`);
|
|
107
|
+
return {
|
|
108
|
+
name,
|
|
109
|
+
snapshot,
|
|
110
|
+
record: {
|
|
111
|
+
name,
|
|
112
|
+
source: { ...source },
|
|
113
|
+
commit,
|
|
114
|
+
...(within === "" ? {} : { within }),
|
|
115
|
+
digest,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#commit(repository: string, url: string, ref: string | undefined): Promise<string> {
|
|
121
|
+
const key = `${url}\0${ref ?? ""}`;
|
|
122
|
+
let commit = this.#commits.get(key);
|
|
123
|
+
if (!commit) {
|
|
124
|
+
const fetch = () => fetchCommit(repository, url, ref, this.#environment);
|
|
125
|
+
commit = (this.#fetching.get(repository) ?? Promise.resolve()).then(fetch, fetch);
|
|
126
|
+
this.#fetching.set(
|
|
127
|
+
repository,
|
|
128
|
+
commit.catch(() => undefined),
|
|
129
|
+
);
|
|
130
|
+
this.#commits.set(key, commit);
|
|
131
|
+
const settled = commit;
|
|
132
|
+
settled.catch(() => {
|
|
133
|
+
if (this.#commits.get(key) === settled) this.#commits.delete(key);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return commit;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A fresh directory under the run's, filled by `fill` before it is used. */
|
|
140
|
+
async #snapshot(fill: (into: string) => Promise<void>): Promise<string> {
|
|
141
|
+
const snapshot = join(this.#root, "sources", randomUUID());
|
|
142
|
+
await mkdir(snapshot, { recursive: true, mode: 0o700 });
|
|
143
|
+
await fill(snapshot);
|
|
144
|
+
return snapshot;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Copies each skill into `directory/{name}`. The directory is made even for none: a harness pointed
|
|
150
|
+
* at it must find it.
|
|
151
|
+
*/
|
|
152
|
+
export async function placeSkills(
|
|
153
|
+
skills: readonly ResolvedSkill[],
|
|
154
|
+
directory: string,
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
await mkdir(directory, { recursive: true });
|
|
157
|
+
for (const skill of skills) await copySkillTree(skill.snapshot, join(directory, skill.name));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function defaultCacheRoot(environment: Environment): string {
|
|
161
|
+
const cache = environment.XDG_CACHE_HOME || join(environment.HOME ?? homedir(), ".cache");
|
|
162
|
+
return join(cache, "awf", "skills");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The name and digest of a checked snapshot: a `SKILL.md` with a `name` and a `description`, and
|
|
167
|
+
* nothing a copy could carry out of it.
|
|
168
|
+
*/
|
|
169
|
+
async function checkSkill(
|
|
170
|
+
snapshot: string,
|
|
171
|
+
label: string,
|
|
172
|
+
): Promise<{ name: string; digest: string }> {
|
|
173
|
+
let text: string;
|
|
174
|
+
try {
|
|
175
|
+
text = await readFile(join(snapshot, "SKILL.md"), "utf8");
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error(`skill ${label} has no SKILL.md`);
|
|
178
|
+
}
|
|
179
|
+
const { name } = parseSkillFile(text, label);
|
|
180
|
+
return { name, digest: await digestTree(snapshot) };
|
|
181
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type { SkillSource } from "@agentswf/contract/workflow";
|
|
4
|
+
|
|
5
|
+
/** The name becomes a directory; claude's rule, which every harness accepts. */
|
|
6
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const GITHUB_SHORTHAND = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
8
|
+
|
|
9
|
+
/** A workflow is untyped JavaScript at run time: each source is checked, and a relative path refused. */
|
|
10
|
+
export function readSkillSources(skills: unknown): SkillSource[] {
|
|
11
|
+
if (!Array.isArray(skills)) throw new Error("skills must be an array of skill sources");
|
|
12
|
+
return skills.map((skill): SkillSource => {
|
|
13
|
+
if (typeof skill === "string") {
|
|
14
|
+
throw new Error(
|
|
15
|
+
`skill ${JSON.stringify(skill)}: name a source, { path } or { repo, skill, ref? }`,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
const source = skill as Record<string, unknown> | null;
|
|
19
|
+
if (source && "path" in source && !("repo" in source) && !("skill" in source)) {
|
|
20
|
+
return { path: absoluteSkillPath(source.path) };
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
source &&
|
|
24
|
+
typeof source.repo === "string" &&
|
|
25
|
+
typeof source.skill === "string" &&
|
|
26
|
+
!("path" in source)
|
|
27
|
+
) {
|
|
28
|
+
if (source.ref !== undefined && (typeof source.ref !== "string" || source.ref === "")) {
|
|
29
|
+
throw new Error(`skill ${source.skill}: ref must be a non-empty string`);
|
|
30
|
+
}
|
|
31
|
+
repositoryUrl(source.repo);
|
|
32
|
+
return {
|
|
33
|
+
repo: source.repo,
|
|
34
|
+
skill: source.skill,
|
|
35
|
+
...(source.ref === undefined ? {} : { ref: source.ref }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
throw new Error(
|
|
39
|
+
`not a skill source: ${JSON.stringify(skill)}; use { path } or { repo, skill }`,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function absoluteSkillPath(path: unknown): string {
|
|
45
|
+
if (path instanceof URL) {
|
|
46
|
+
if (path.protocol !== "file:") throw new Error(`skill path ${path.href} is not a file: URL`);
|
|
47
|
+
return fileURLToPath(path);
|
|
48
|
+
}
|
|
49
|
+
if (typeof path !== "string" || path === "") throw new Error("a skill path must be a string");
|
|
50
|
+
if (path.startsWith("file:")) return fileURLToPath(path);
|
|
51
|
+
if (!isAbsolute(path)) {
|
|
52
|
+
// Relative to what would differ between a workflow and the one that calls it.
|
|
53
|
+
throw new Error(
|
|
54
|
+
`skill path ${path} is relative; use an absolute path, or new URL("${path}", import.meta.url)`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `owner/repo` is GitHub's; anything else must already be a URL git fetches. */
|
|
61
|
+
export function repositoryUrl(repo: string): string {
|
|
62
|
+
if (GITHUB_SHORTHAND.test(repo)) return `https://github.com/${repo}.git`;
|
|
63
|
+
if (/^(?:https?|ssh|git|file):\/\//.test(repo) || /^[\w.-]+@[\w.-]+:/.test(repo)) return repo;
|
|
64
|
+
throw new Error(`skill repo ${repo} is neither owner/repo nor a git URL`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The frontmatter every harness reads: a `name` it can be a directory by, and a `description`. */
|
|
68
|
+
export function parseSkillFile(text: string, label: string): { name: string; description: string } {
|
|
69
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text);
|
|
70
|
+
if (!match) throw new Error(`skill ${label}: SKILL.md has no frontmatter`);
|
|
71
|
+
let front: unknown;
|
|
72
|
+
try {
|
|
73
|
+
front = Bun.YAML.parse(match[1]!);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`skill ${label}: SKILL.md frontmatter is not YAML: ${String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
const { name, description } = (front ?? {}) as Record<string, unknown>;
|
|
78
|
+
if (typeof name !== "string" || !SKILL_NAME.test(name) || name.length > 64) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`skill ${label}: its name must be lowercase letters, digits and hyphens, at most 64`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (typeof description !== "string" || description.trim() === "") {
|
|
84
|
+
throw new Error(`skill ${label}: SKILL.md has no description`);
|
|
85
|
+
}
|
|
86
|
+
return { name, description };
|
|
87
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Refused: past these, a skill is not instructions and a few scripts. */
|
|
6
|
+
export const MAX_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
export const MAX_FILES = 2_000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A skill's files, copied: never a link, which could carry what it points at, `~/.ssh` say, into
|
|
11
|
+
* a sandbox, nor anything but files and directories, and within the size caps.
|
|
12
|
+
*/
|
|
13
|
+
export async function copySkillTree(from: string, to: string): Promise<void> {
|
|
14
|
+
const budget = { bytes: 0, files: 0 };
|
|
15
|
+
await mkdir(to, { recursive: true, mode: 0o755 });
|
|
16
|
+
const walk = async (at: string) => {
|
|
17
|
+
for (const entry of await readdir(join(from, at))) {
|
|
18
|
+
const path = join(at, entry);
|
|
19
|
+
const found = await lstat(join(from, path));
|
|
20
|
+
if (found.isSymbolicLink()) throw new Error(`skill ${from}: ${path} is a link`);
|
|
21
|
+
if (found.isDirectory()) {
|
|
22
|
+
await mkdir(join(to, path), { mode: 0o755 });
|
|
23
|
+
await walk(path);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!found.isFile()) throw new Error(`skill ${from}: ${path} is not a file`);
|
|
27
|
+
budget.bytes += found.size;
|
|
28
|
+
budget.files += 1;
|
|
29
|
+
if (budget.bytes > MAX_BYTES || budget.files > MAX_FILES) {
|
|
30
|
+
throw new Error(`skill ${from} is over ${MAX_FILES} files or ${MAX_BYTES} bytes`);
|
|
31
|
+
}
|
|
32
|
+
await writeFile(join(to, path), await readFile(join(from, path)), {
|
|
33
|
+
mode: found.mode & 0o111 ? 0o755 : 0o644,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
await walk("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Over each file's path, executable bit and bytes, sorted: equal digests, equal skills. */
|
|
41
|
+
export async function digestTree(root: string): Promise<string> {
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
const walk = async (at: string) => {
|
|
44
|
+
for (const entry of await readdir(join(root, at))) {
|
|
45
|
+
const path = join(at, entry);
|
|
46
|
+
const found = await lstat(join(root, path));
|
|
47
|
+
if (found.isDirectory()) {
|
|
48
|
+
await walk(path);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const bytes = await readFile(join(root, path));
|
|
52
|
+
const hash = createHash("sha256").update(bytes).digest("hex");
|
|
53
|
+
lines.push(`${path}\0${found.mode & 0o111 ? "x" : "-"}\0${hash}\n`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
await walk("");
|
|
57
|
+
const digest = createHash("sha256");
|
|
58
|
+
for (const line of lines.sort()) digest.update(line);
|
|
59
|
+
return `sha256:${digest.digest("hex")}`;
|
|
60
|
+
}
|