@anteros/core 0.0.42 → 0.0.44
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/database/replication.ts +614 -26
- package/lib/agent.ts +279 -39
- package/lib/agentMemory.ts +103 -0
- package/lib/agents.ts +25 -2
- package/lib/define.ts +1 -1
- package/lib/mcpClient.ts +128 -0
- package/lib/providers.ts +169 -66
- package/lib/skills.ts +481 -0
- package/package.json +1 -1
- package/server/agents.ts +27 -6
- package/server/api.ts +74 -1
- package/tests/agent-mcp.test.ts +116 -0
- package/tests/agent-memory.test.ts +6 -6
- package/tests/agent-provider.test.ts +288 -0
- package/tests/agent-runtime.test.ts +369 -0
- package/tests/agent-skills.test.ts +177 -0
- package/tests/agent-summarize.test.ts +124 -0
- package/tests/agent-working-memory.test.ts +5 -3
- package/tests/agent.test.ts +18 -16
- package/tests/agents-api.test.ts +1 -1
- package/tests/api-private-fields.test.ts +196 -0
- package/tests/file-collection-replication.test.ts +2 -0
- package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
- package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
- package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
- package/tests/fixtures/repl-only/app.ts +3 -0
- package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
- package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
- package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
- package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
- package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
- package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
- package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
- package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
- package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
- package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
- package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
- package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
- package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
- package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
- package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
- package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
- package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
- package/tests/replication-change-stream.test.ts +240 -0
- package/tests/replication-control.test.ts +2 -0
- package/tests/replication-initial-sync.test.ts +2 -0
- package/tests/replication-meta.test.ts +3 -0
- package/tests/replication.test.ts +2 -0
- package/tests/vars-replication.test.ts +2 -0
- package/types/agent.d.ts +237 -7
- package/types/replication.d.ts +41 -4
package/lib/skills.ts
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
import { Glob } from "bun";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { AppError } from "./error";
|
|
5
|
+
import { logger } from "../utils/logger";
|
|
6
|
+
import type { AgentSkill, AgentSkillSource } from "../types/agent";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Agent **skills** — markdown documents declared on an agent
|
|
10
|
+
* (`skills: ['./docs/*.md', './guide/ml.md', /^runbooks\/.*\.md$/]`), resolved
|
|
11
|
+
* once at load and injected into the **system prompt** of every run.
|
|
12
|
+
*
|
|
13
|
+
* A skill file is markdown with a frontmatter header:
|
|
14
|
+
*
|
|
15
|
+
* ```md
|
|
16
|
+
* ---
|
|
17
|
+
* name: pdf-forms
|
|
18
|
+
* description: Fill, flatten and merge PDF forms. Use for any AcroForm work.
|
|
19
|
+
* license: MIT
|
|
20
|
+
* compatibility: Needs the `pdftk` binary.
|
|
21
|
+
* metadata: { owner: platform }
|
|
22
|
+
* allowed-tools: read_pdf write_pdf
|
|
23
|
+
* ---
|
|
24
|
+
*
|
|
25
|
+
* # Filling a form
|
|
26
|
+
* …
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* `name` and `description` are required; the body is what the model reads.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Kebab-case, and short — the name identifies a skill in the prompt. */
|
|
33
|
+
const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
34
|
+
const MAX_NAME_CHARS = 64;
|
|
35
|
+
const MAX_DESCRIPTION_CHARS = 1024;
|
|
36
|
+
const MAX_COMPATIBILITY_CHARS = 500;
|
|
37
|
+
/**
|
|
38
|
+
* Per-file ceiling. A skill is a prompt document, read on **every** call: past
|
|
39
|
+
* this, the agent is better served by a tool than by a document (same order of
|
|
40
|
+
* magnitude as the attachment text limit).
|
|
41
|
+
*/
|
|
42
|
+
const MAX_SKILL_CHARS = 200_000;
|
|
43
|
+
|
|
44
|
+
/** The keys the format defines — anything else is kept out of the prompt. */
|
|
45
|
+
const KNOWN_KEYS = new Set(["name", "description", "license", "compatibility", "metadata", "allowed-tools", "allowedTools"]);
|
|
46
|
+
|
|
47
|
+
/* ------------------------------------------------------------------ */
|
|
48
|
+
/* Frontmatter */
|
|
49
|
+
/* ------------------------------------------------------------------ */
|
|
50
|
+
|
|
51
|
+
type Line = { indent: number; text: string };
|
|
52
|
+
|
|
53
|
+
/** Drop a trailing ` # comment`, unless the `#` sits inside a quoted value. */
|
|
54
|
+
function stripComment(line: string): string {
|
|
55
|
+
let quote: string | null = null;
|
|
56
|
+
for (let i = 0; i < line.length; i++) {
|
|
57
|
+
const char = line[i]!;
|
|
58
|
+
if (quote) {
|
|
59
|
+
if (char === quote) quote = null;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (char === '"' || char === "'") {
|
|
63
|
+
quote = char;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (char === "#" && (i === 0 || /\s/.test(line[i - 1]!))) {
|
|
67
|
+
return line.slice(0, i).replace(/\s+$/, "");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return line;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Index of the `:` that separates a key from its value (outside quotes). */
|
|
74
|
+
function keySeparator(text: string): number {
|
|
75
|
+
let quote: string | null = null;
|
|
76
|
+
for (let i = 0; i < text.length; i++) {
|
|
77
|
+
const char = text[i]!;
|
|
78
|
+
if (quote) {
|
|
79
|
+
if (char === quote) quote = null;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (char === '"' || char === "'") {
|
|
83
|
+
quote = char;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === ":" && (i === text.length - 1 || /\s/.test(text[i + 1]!))) return i;
|
|
87
|
+
}
|
|
88
|
+
return -1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function unquote(text: string): string {
|
|
92
|
+
if (text.length >= 2 && (text[0] === '"' || text[0] === "'") && text[text.length - 1] === text[0]) {
|
|
93
|
+
const body = text.slice(1, -1);
|
|
94
|
+
return text[0] === '"' ? body.replace(/\\"/g, '"') : body.replace(/''/g, "'");
|
|
95
|
+
}
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function scalar(text: string): any {
|
|
100
|
+
const raw = text.trim();
|
|
101
|
+
if (raw.startsWith("[") && raw.endsWith("]")) {
|
|
102
|
+
const inner = raw.slice(1, -1).trim();
|
|
103
|
+
return inner ? splitTopLevel(inner, ",").map((item) => scalar(item)).filter((item) => item !== undefined) : [];
|
|
104
|
+
}
|
|
105
|
+
if (raw.startsWith("{") && raw.endsWith("}")) {
|
|
106
|
+
const inner = raw.slice(1, -1).trim();
|
|
107
|
+
if (!inner) return {};
|
|
108
|
+
const out: Record<string, any> = {};
|
|
109
|
+
for (const pair of splitTopLevel(inner, ",")) {
|
|
110
|
+
const separator = keySeparator(pair.trim());
|
|
111
|
+
if (separator === -1) continue;
|
|
112
|
+
out[unquote(pair.slice(0, separator).trim())] = scalar(pair.slice(separator + 1).trim());
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
if (raw.length >= 2 && (raw[0] === '"' || raw[0] === "'") && raw[raw.length - 1] === raw[0]) return unquote(raw);
|
|
117
|
+
if (raw === "" || raw === "null" || raw === "~") return null;
|
|
118
|
+
if (raw === "true") return true;
|
|
119
|
+
if (raw === "false") return false;
|
|
120
|
+
if (/^-?\d+(?:\.\d+)?$/.test(raw)) return Number(raw);
|
|
121
|
+
return raw;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Split on a separator that is outside quotes and outside `[…]` / `{…}`. */
|
|
125
|
+
function splitTopLevel(text: string, separator: string): string[] {
|
|
126
|
+
const parts: string[] = [];
|
|
127
|
+
let depth = 0;
|
|
128
|
+
let quote: string | null = null;
|
|
129
|
+
let current = "";
|
|
130
|
+
|
|
131
|
+
for (const char of text) {
|
|
132
|
+
if (quote) {
|
|
133
|
+
current += char;
|
|
134
|
+
if (char === quote) quote = null;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (char === '"' || char === "'") {
|
|
138
|
+
quote = char;
|
|
139
|
+
current += char;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (char === "[" || char === "{") depth += 1;
|
|
143
|
+
if (char === "]" || char === "}") depth -= 1;
|
|
144
|
+
if (char === separator && depth === 0) {
|
|
145
|
+
parts.push(current.trim());
|
|
146
|
+
current = "";
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
current += char;
|
|
150
|
+
}
|
|
151
|
+
parts.push(current.trim());
|
|
152
|
+
return parts;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The block reader behind `parseYaml()` — mappings and sequences, nested by
|
|
157
|
+
* indentation, `[a, b]` inline.
|
|
158
|
+
*/
|
|
159
|
+
function parseBlock(lines: Line[], start: number, indent: number): [any, number] {
|
|
160
|
+
const first = lines[start];
|
|
161
|
+
if (!first) return [{}, start];
|
|
162
|
+
|
|
163
|
+
// Sequence: `- value`, or `-` followed by a deeper block
|
|
164
|
+
if (first.text === "-" || first.text.startsWith("- ")) {
|
|
165
|
+
const out: any[] = [];
|
|
166
|
+
let index = start;
|
|
167
|
+
while (index < lines.length) {
|
|
168
|
+
const line = lines[index]!;
|
|
169
|
+
if (line.indent !== first.indent || !(line.text === "-" || line.text.startsWith("- "))) break;
|
|
170
|
+
const inline = line.text.slice(1).trim();
|
|
171
|
+
index += 1;
|
|
172
|
+
if (inline) {
|
|
173
|
+
out.push(scalar(inline));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const next = lines[index];
|
|
177
|
+
if (next && next.indent > first.indent) {
|
|
178
|
+
const [nested, after] = parseBlock(lines, index, next.indent);
|
|
179
|
+
out.push(nested);
|
|
180
|
+
index = after;
|
|
181
|
+
} else {
|
|
182
|
+
out.push(null);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return [out, index];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Mapping
|
|
189
|
+
const out: Record<string, any> = {};
|
|
190
|
+
let index = start;
|
|
191
|
+
while (index < lines.length) {
|
|
192
|
+
const line = lines[index]!;
|
|
193
|
+
if (line.indent < indent) break;
|
|
194
|
+
if (line.indent > indent) {
|
|
195
|
+
index += 1; // a stray deeper line: ignore rather than guess
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const separator = keySeparator(line.text);
|
|
199
|
+
if (separator === -1) {
|
|
200
|
+
index += 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const key = unquote(line.text.slice(0, separator).trim());
|
|
204
|
+
const rest = line.text.slice(separator + 1).trim();
|
|
205
|
+
index += 1;
|
|
206
|
+
if (rest) {
|
|
207
|
+
out[key] = scalar(rest);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const next = lines[index];
|
|
211
|
+
if (next && next.indent > indent) {
|
|
212
|
+
const [nested, after] = parseBlock(lines, index, next.indent);
|
|
213
|
+
out[key] = nested;
|
|
214
|
+
index = after;
|
|
215
|
+
} else {
|
|
216
|
+
out[key] = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return [out, index];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* A dependency-free reader for the frontmatter of a skill file — a **documented
|
|
224
|
+
* subset of YAML**, the same way `lib/zip.ts` reads an archive without a library.
|
|
225
|
+
*
|
|
226
|
+
* Handled: `key: value` (quoted or not, `true`/`false`/`null`/numbers), blocks
|
|
227
|
+
* nested by indentation, sequences (`- item`), and the inline forms — `[a, b]`,
|
|
228
|
+
* `{ key: value }`.
|
|
229
|
+
* Not handled, on purpose: multi-line scalars (`|`, `>`), anchors, tags, multiple
|
|
230
|
+
* documents. A skill file does not need them.
|
|
231
|
+
*/
|
|
232
|
+
function parseYaml(block: string): Record<string, any> {
|
|
233
|
+
const lines: Line[] = [];
|
|
234
|
+
for (const raw of block.split("\n")) {
|
|
235
|
+
if (!raw.trim()) continue;
|
|
236
|
+
const stripped = stripComment(raw);
|
|
237
|
+
if (!stripped.trim()) continue;
|
|
238
|
+
lines.push({ indent: stripped.length - stripped.trimStart().length, text: stripped.trim() });
|
|
239
|
+
}
|
|
240
|
+
const [value] = parseBlock(lines, 0, lines[0]?.indent ?? 0);
|
|
241
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Split a skill file into its frontmatter and its body. No frontmatter at all is
|
|
246
|
+
* not an error here — validation says so, with a message that names the file.
|
|
247
|
+
*/
|
|
248
|
+
function parseFrontmatter(text: string): { data: Record<string, any>; content: string } {
|
|
249
|
+
const normalized = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
250
|
+
if (!/^---\s*\n/.test(normalized)) return { data: {}, content: normalized.trim() };
|
|
251
|
+
|
|
252
|
+
const end = normalized.indexOf("\n---", 3);
|
|
253
|
+
if (end === -1) return { data: {}, content: normalized.trim() };
|
|
254
|
+
|
|
255
|
+
const data = parseYaml(normalized.slice(normalized.indexOf("\n") + 1, end + 1));
|
|
256
|
+
const content = normalized.slice(end + 4).replace(/^\s*\n/, "").trim();
|
|
257
|
+
return { data, content };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ------------------------------------------------------------------ */
|
|
261
|
+
/* Resolution */
|
|
262
|
+
/* ------------------------------------------------------------------ */
|
|
263
|
+
|
|
264
|
+
function invalid(skill: string, message: string): AppError {
|
|
265
|
+
return new AppError(`Skill '${skill}': ${message}`, { status: 500, code: "AGENT_SKILL_INVALID" });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The body a skill must carry — it *is* the skill. */
|
|
269
|
+
function requireContent(content: any, label: string): string {
|
|
270
|
+
const body = typeof content === "string" ? content.trim() : "";
|
|
271
|
+
if (!body) throw invalid(label, "`content` is required — a skill is the text the model reads");
|
|
272
|
+
return body;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function asString(value: any): string | undefined {
|
|
276
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
277
|
+
return typeof value === "string" ? value : String(value);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The files a source selects, as absolute paths. A **string** is a path or a glob
|
|
282
|
+
* (``.` = the tenant directory); a **RegExp** is matched against every file of the
|
|
283
|
+
* tenant directory, on the relative POSIX path (`runbooks/incident.md`).
|
|
284
|
+
*
|
|
285
|
+
* The glob is split in two — the directory chain that holds no wildcard, then the
|
|
286
|
+
* pattern — so `Bun.Glob` always scans from a real root instead of depending on the
|
|
287
|
+
* process cwd (a relative tenant dir would otherwise be prefixed twice).
|
|
288
|
+
*/
|
|
289
|
+
async function matchFiles(source: AgentSkillSource, baseDir: string): Promise<string[]> {
|
|
290
|
+
if (source instanceof RegExp) {
|
|
291
|
+
// `g`/`y` carry a `lastIndex` between calls — one regex must not skip files
|
|
292
|
+
const pattern = new RegExp(source.source, source.flags.replace(/[gy]/g, ""));
|
|
293
|
+
const found: string[] = [];
|
|
294
|
+
for await (const file of new Glob("**/*").scan(baseDir)) {
|
|
295
|
+
const relative = file.split(path.sep).join("/");
|
|
296
|
+
if (pattern.test(relative)) found.push(path.join(baseDir, file));
|
|
297
|
+
}
|
|
298
|
+
return found;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const pattern = String(source ?? "").trim();
|
|
302
|
+
if (!pattern) return [];
|
|
303
|
+
|
|
304
|
+
const target = path.resolve(baseDir, pattern);
|
|
305
|
+
const parts = target.split(path.sep);
|
|
306
|
+
let wildcard = parts.findIndex((part) => /[*?[\]{}]/.test(part));
|
|
307
|
+
if (wildcard === -1) wildcard = parts.length - 1; // a plain file: scan its directory
|
|
308
|
+
|
|
309
|
+
const root = parts.slice(0, wildcard).join(path.sep) || path.sep;
|
|
310
|
+
const rest = parts.slice(wildcard).join("/");
|
|
311
|
+
|
|
312
|
+
const found: string[] = [];
|
|
313
|
+
for await (const file of new Glob(rest).scan(root)) {
|
|
314
|
+
found.push(path.resolve(root, file));
|
|
315
|
+
}
|
|
316
|
+
return found;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Validate the header of a skill file. `label` names the source in the messages
|
|
321
|
+
* (the path relative to the tenant directory).
|
|
322
|
+
*/
|
|
323
|
+
function validateSkill(data: Record<string, any>, label: string): Omit<AgentSkill, "content" | "source"> {
|
|
324
|
+
const name = asString(data.name);
|
|
325
|
+
if (!name) throw invalid(label, "`name` is required");
|
|
326
|
+
if (name.length > MAX_NAME_CHARS || !SKILL_NAME.test(name)) {
|
|
327
|
+
throw invalid(label, `\`name\` must be lowercase alphanumeric with hyphens, 1-${MAX_NAME_CHARS} characters (got '${name}')`);
|
|
328
|
+
}
|
|
329
|
+
// The directory-skill layout (`pdf/SKILL.md`): the name is the directory's, so a
|
|
330
|
+
// folder copied or renamed cannot serve a skill under a stale identity
|
|
331
|
+
if (label && path.basename(label).toLowerCase() === "skill.md") {
|
|
332
|
+
const parent = path.basename(path.dirname(label));
|
|
333
|
+
if (parent !== name) throw invalid(label, `\`name\` must match the parent directory ('${parent}') for a SKILL.md`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const description = asString(data.description);
|
|
337
|
+
if (!description) throw invalid(label, "`description` is required");
|
|
338
|
+
if (description.length > MAX_DESCRIPTION_CHARS) {
|
|
339
|
+
throw invalid(label, `\`description\` is ${description.length} characters — over the ${MAX_DESCRIPTION_CHARS} limit`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const compatibility = asString(data.compatibility);
|
|
343
|
+
if (compatibility && compatibility.length > MAX_COMPATIBILITY_CHARS) {
|
|
344
|
+
throw invalid(label, `\`compatibility\` is ${compatibility.length} characters — over the ${MAX_COMPATIBILITY_CHARS} limit`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const metadata = data.metadata ?? undefined;
|
|
348
|
+
if (metadata !== undefined && (typeof metadata !== "object" || Array.isArray(metadata))) {
|
|
349
|
+
throw invalid(label, "`metadata` must be a mapping of key-value pairs");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const allowed = data["allowed-tools"] ?? data.allowedTools;
|
|
353
|
+
let allowedTools: string[] | undefined;
|
|
354
|
+
if (Array.isArray(allowed)) allowedTools = allowed.map((item) => String(item));
|
|
355
|
+
else if (typeof allowed === "string") allowedTools = allowed.split(/\s+/).filter(Boolean);
|
|
356
|
+
else if (allowed !== undefined && allowed !== null) throw invalid(label, "`allowed-tools` must be a space-separated list");
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
name,
|
|
360
|
+
description,
|
|
361
|
+
license: asString(data.license),
|
|
362
|
+
compatibility,
|
|
363
|
+
metadata: metadata as Record<string, any> | undefined,
|
|
364
|
+
allowedTools,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Read one file and validate its frontmatter against the skill format. */
|
|
369
|
+
async function readSkill(file: string, relative: string): Promise<AgentSkill> {
|
|
370
|
+
const raw = await fs.readFile(file, "utf8");
|
|
371
|
+
if (raw.length > MAX_SKILL_CHARS) {
|
|
372
|
+
throw new AppError(
|
|
373
|
+
`Skill '${relative}' is ${raw.length} characters — over the ${MAX_SKILL_CHARS} limit (a skill is injected in every prompt)`,
|
|
374
|
+
{ status: 500, code: "AGENT_SKILL_TOO_LARGE" },
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const { data, content } = parseFrontmatter(raw);
|
|
379
|
+
|
|
380
|
+
for (const key of Object.keys(data)) {
|
|
381
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
382
|
+
logger.file("warn", "agent skill: unknown frontmatter key ignored", { skill: relative, key });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return { ...validateSkill(data, relative), content: requireContent(content, relative), source: relative };
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Resolve the `skills` of an agent into the documents injected into its system
|
|
391
|
+
* prompt, in a stable order (by name).
|
|
392
|
+
*
|
|
393
|
+
* A source is a **string** — a path or a glob — or a **RegExp**, a walk of the
|
|
394
|
+
* tenant: a skill is a **link to markdown files**, never a declaration. A file that
|
|
395
|
+
* is **not markdown** is skipped with a line in the log rather than refused: a
|
|
396
|
+
* pattern meant to catch a folder must not take an agent down because a `README.txt`
|
|
397
|
+
* lives beside the skills. Everything else — a missing `name`, a name that is not
|
|
398
|
+
* kebab-case, a description over the limit, an empty body, two skills sharing a name
|
|
399
|
+
* — refuses the agent at load, so a typo never changes the prompt silently (the
|
|
400
|
+
* loader logs it and keeps the other agents).
|
|
401
|
+
*/
|
|
402
|
+
async function resolveSkills(
|
|
403
|
+
sources: AgentSkillSource[] | undefined,
|
|
404
|
+
options: { baseDir: string; agentId?: string },
|
|
405
|
+
): Promise<AgentSkill[]> {
|
|
406
|
+
if (!sources?.length) return [];
|
|
407
|
+
|
|
408
|
+
const byFile = new Map<string, AgentSkill>();
|
|
409
|
+
|
|
410
|
+
for (const source of sources) {
|
|
411
|
+
const files = await matchFiles(source, options.baseDir);
|
|
412
|
+
// A pattern that selects nothing is a typo, not a choice — say so
|
|
413
|
+
if (!files.length) {
|
|
414
|
+
logger.file("warn", "agent skill: pattern matched no file", {
|
|
415
|
+
agent: options.agentId,
|
|
416
|
+
pattern: source instanceof RegExp ? source.source : source,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
for (const file of files) {
|
|
420
|
+
const relative = path.relative(options.baseDir, file).split(path.sep).join("/");
|
|
421
|
+
if (byFile.has(relative)) continue;
|
|
422
|
+
|
|
423
|
+
if (!relative.toLowerCase().endsWith(".md")) {
|
|
424
|
+
logger.file("warn", "agent skill skipped: not markdown", {
|
|
425
|
+
agent: options.agentId,
|
|
426
|
+
skill: relative,
|
|
427
|
+
});
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
byFile.set(relative, await readSkill(file, relative));
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const skills = [...byFile.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
436
|
+
const seen = new Map<string, string>();
|
|
437
|
+
for (const skill of skills) {
|
|
438
|
+
const previous = seen.get(skill.name);
|
|
439
|
+
if (previous) {
|
|
440
|
+
throw new AppError(
|
|
441
|
+
`Skill '${skill.source}': the name '${skill.name}' is already used by '${previous}' — two skills of an agent must not share one`,
|
|
442
|
+
{ status: 500, code: "AGENT_SKILL_DUPLICATE" },
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
seen.set(skill.name, skill.source);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return skills;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The system-prompt block of the skills. Static by nature — no store, nothing
|
|
453
|
+
* resolved per run — which is why it is injected **before** the working memory:
|
|
454
|
+
* the prefix of the prompt stays cacheable.
|
|
455
|
+
*/
|
|
456
|
+
function skillsBlock(skills: AgentSkill[]): string {
|
|
457
|
+
if (!skills.length) return "";
|
|
458
|
+
|
|
459
|
+
const parts = [
|
|
460
|
+
"## Skills",
|
|
461
|
+
"",
|
|
462
|
+
"Reference documents you can rely on. Read the ones that match the task at hand instead of guessing, and apply them as written.",
|
|
463
|
+
];
|
|
464
|
+
|
|
465
|
+
for (const skill of skills) {
|
|
466
|
+
parts.push("", `### ${skill.name}`, "", skill.description);
|
|
467
|
+
if (skill.content) parts.push("", skill.content);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return parts.join("\n");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export {
|
|
474
|
+
MAX_SKILL_CHARS,
|
|
475
|
+
MAX_DESCRIPTION_CHARS,
|
|
476
|
+
MAX_COMPATIBILITY_CHARS,
|
|
477
|
+
MAX_NAME_CHARS,
|
|
478
|
+
parseFrontmatter,
|
|
479
|
+
resolveSkills,
|
|
480
|
+
skillsBlock,
|
|
481
|
+
};
|
package/package.json
CHANGED
package/server/agents.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
AgentGenerateResult,
|
|
15
15
|
AgentInput,
|
|
16
16
|
AgentMessageInput,
|
|
17
|
+
AgentResume,
|
|
17
18
|
} from "../types/agent";
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -74,6 +75,11 @@ type AgentRequestBody = {
|
|
|
74
75
|
}>;
|
|
75
76
|
/** Read the thread without writing the run back. */
|
|
76
77
|
readOnly?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Continue a run that paused for an approval — the `resume` payload `generate`
|
|
80
|
+
* handed back (`{ messages, decisions }`). `input` is then not needed.
|
|
81
|
+
*/
|
|
82
|
+
resume?: AgentResume;
|
|
77
83
|
/** Mastra-style shorthand — `memory: { thread, resource, readOnly }`. */
|
|
78
84
|
memory?: {
|
|
79
85
|
thread?: string;
|
|
@@ -164,6 +170,15 @@ function presentResult(result: AgentGenerateResult) {
|
|
|
164
170
|
steps: result.steps,
|
|
165
171
|
usage: result.usage,
|
|
166
172
|
finishReason: result.finishReason,
|
|
173
|
+
// A paused run hands back the conversation to resume with: nothing is stored
|
|
174
|
+
// until the run completes, and an assistant turn with unanswered calls would
|
|
175
|
+
// poison the thread
|
|
176
|
+
...(result.pendingApprovals?.length
|
|
177
|
+
? {
|
|
178
|
+
pendingApprovals: result.pendingApprovals,
|
|
179
|
+
resume: { messages: result.messages },
|
|
180
|
+
}
|
|
181
|
+
: {}),
|
|
167
182
|
};
|
|
168
183
|
}
|
|
169
184
|
|
|
@@ -180,6 +195,8 @@ function auditInput(input: AgentInput | undefined) {
|
|
|
180
195
|
type AuditOptions = {
|
|
181
196
|
rest: InstanceType<typeof useRest>;
|
|
182
197
|
agentId: string;
|
|
198
|
+
/** The prompt's version, when the agent declares one — what produced the answer. */
|
|
199
|
+
version?: string;
|
|
183
200
|
action: string;
|
|
184
201
|
input?: AgentInput;
|
|
185
202
|
thread?: string;
|
|
@@ -190,12 +207,12 @@ type AuditOptions = {
|
|
|
190
207
|
|
|
191
208
|
/** One audit entry per call, at the same place as `_vars_:<ns>` — `_agents_:<id>`. */
|
|
192
209
|
async function auditAgent({
|
|
193
|
-
rest, agentId, action, input, thread, result, error, started,
|
|
210
|
+
rest, agentId, version, action, input, thread, result, error, started,
|
|
194
211
|
}: AuditOptions): Promise<void> {
|
|
195
212
|
await rest.audit.log({
|
|
196
213
|
action: `agent.${action}`,
|
|
197
214
|
collection: `_agents_:${agentId}`,
|
|
198
|
-
input: { agent: agentId, thread, ...auditInput(input) },
|
|
215
|
+
input: { agent: agentId, ...(version ? { version } : {}), thread, ...auditInput(input) },
|
|
199
216
|
// Never the answer, never the tool arguments: the envelope only.
|
|
200
217
|
result: result
|
|
201
218
|
? {
|
|
@@ -288,6 +305,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
288
305
|
let rest: InstanceType<typeof useRest> | undefined;
|
|
289
306
|
let tenant_id = '';
|
|
290
307
|
let agentId = '';
|
|
308
|
+
let agentVersion: string | undefined;
|
|
291
309
|
let action = '';
|
|
292
310
|
|
|
293
311
|
try {
|
|
@@ -323,9 +341,11 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
323
341
|
rest = new useRest({ internal: false, tenant_id });
|
|
324
342
|
const agent = createAgents(tenant_id, rest).get(agentId);
|
|
325
343
|
if (!agent) throw new AppError(`Agent '${agentId}' not found`, { status: 400, code: 'AGENT_NOT_FOUND' });
|
|
344
|
+
agentVersion = agent.getConfig().version;
|
|
326
345
|
|
|
327
346
|
const api = agent.getConfig().api;
|
|
328
|
-
|
|
347
|
+
// A resume carries its own conversation: no input to ask for
|
|
348
|
+
const needsInput = (action === 'generate' || action === 'stream' || action === 'object') && !body?.resume;
|
|
329
349
|
const input = needsInput ? normalizeInput(body) : undefined;
|
|
330
350
|
// `memory: { thread, resource }` or the flat `thread` / `resource`
|
|
331
351
|
const thread = resolveThread(body);
|
|
@@ -353,12 +373,13 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
353
373
|
// Stop the run when the client disconnects (no tokens past the wire)
|
|
354
374
|
signal: c.req.raw.signal,
|
|
355
375
|
...(body?.files?.length ? { files: body.files } : {}),
|
|
376
|
+
...(body?.resume ? { resume: body.resume } : {}),
|
|
356
377
|
...(readOnly ? { memory: { readOnly: true } } : {}),
|
|
357
378
|
};
|
|
358
379
|
|
|
359
380
|
if (action === 'stream') {
|
|
360
381
|
return streamResponse(c, agent, input as AgentInput, callOptions, {
|
|
361
|
-
rest, agentId, action, started,
|
|
382
|
+
rest, agentId, action, started, version: agent.getConfig().version,
|
|
362
383
|
});
|
|
363
384
|
}
|
|
364
385
|
|
|
@@ -430,7 +451,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
430
451
|
throw new AppError(`Action '${action}' not found`, { status: 400, code: 'ACTION_NOT_FOUND' });
|
|
431
452
|
}
|
|
432
453
|
|
|
433
|
-
await auditAgent({ rest, agentId, action, input, thread, result, started });
|
|
454
|
+
await auditAgent({ rest, agentId, action, input, thread, result, started, version: agent.getConfig().version });
|
|
434
455
|
return c.json(response);
|
|
435
456
|
} catch (err: any) {
|
|
436
457
|
if (cfg?.debug) console.error(err);
|
|
@@ -438,7 +459,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
438
459
|
await rest.audit.log({
|
|
439
460
|
action: `agent.${action || 'unknown'}`,
|
|
440
461
|
collection: `_agents_:${agentId}`,
|
|
441
|
-
input: { agent: agentId },
|
|
462
|
+
input: { agent: agentId, ...(agentVersion ? { version: agentVersion } : {}) },
|
|
442
463
|
error: { message: err?.message, code: err?.code || 'INTERNAL_AGENT_ERROR' },
|
|
443
464
|
duration: Date.now() - started,
|
|
444
465
|
}).catch(() => {});
|
package/server/api.ts
CHANGED
|
@@ -61,6 +61,75 @@ function stripReadOnlyUpdate(col: any, body: any) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The collections a request pulls documents **into** its response — an `$include`
|
|
66
|
+
* on `find`/`findOne`, a `$lookup` / `$graphLookup` anywhere in an `aggregate`
|
|
67
|
+
* pipeline (`$facet` branches and nested lookups included).
|
|
68
|
+
*
|
|
69
|
+
* Returns the path each joined document lands at, paired with the target
|
|
70
|
+
* collection's `api.privateFields`. `func.omit` only knows the *queried*
|
|
71
|
+
* collection's matchers, so a joined document — which carries the *included*
|
|
72
|
+
* collection's fields — has to be masked separately, at its own path.
|
|
73
|
+
*/
|
|
74
|
+
function embeddedPrivateFields(col: any, body: any): Array<{ path: string[]; keys: (string | RegExp)[] }> {
|
|
75
|
+
const found: Array<{ path: string[]; keys: (string | RegExp)[] }> = [];
|
|
76
|
+
|
|
77
|
+
const keysOf = (slug: string): (string | RegExp)[] =>
|
|
78
|
+
(cfg.collections?.find((c) => c.slug === slug && c._tenant_ === col._tenant_)?.api?.privateFields) ?? [];
|
|
79
|
+
|
|
80
|
+
const push = (slug: string, path?: string) => {
|
|
81
|
+
if (!path) return;
|
|
82
|
+
const keys = keysOf(slug);
|
|
83
|
+
if (keys.length) found.push({ path: path.split('.'), keys });
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// find / findOne — `$include`
|
|
87
|
+
for (const inc of body?.params?.$include ?? []) {
|
|
88
|
+
if (typeof inc === 'string') {
|
|
89
|
+
const field = col.fields?.find((f: any) => f.name === inc);
|
|
90
|
+
if (field?.relation?.to) push(field.relation.to, inc);
|
|
91
|
+
} else if (inc?.from) {
|
|
92
|
+
push(inc.from, inc.as || inc.localField);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// aggregate — every `$lookup` / `$graphLookup`, nested ones included
|
|
97
|
+
const walk = (stages: any, prefix: string) => {
|
|
98
|
+
if (!Array.isArray(stages)) return;
|
|
99
|
+
for (const stage of stages) {
|
|
100
|
+
if (!stage || typeof stage !== 'object') continue;
|
|
101
|
+
const lookup = stage.$lookup ?? stage.$graphLookup;
|
|
102
|
+
if (lookup?.from && typeof lookup.as === 'string') {
|
|
103
|
+
const path = prefix ? `${prefix}.${lookup.as}` : lookup.as;
|
|
104
|
+
push(lookup.from, path);
|
|
105
|
+
walk(lookup.pipeline, path);
|
|
106
|
+
}
|
|
107
|
+
if (stage.$facet && typeof stage.$facet === 'object') {
|
|
108
|
+
for (const branch of Object.values(stage.$facet)) walk(branch, prefix);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(body?.pipeline, '');
|
|
113
|
+
|
|
114
|
+
return found;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Apply `func.omit` **at `path` only**, walking through arrays (a `$include` of a
|
|
119
|
+
* `hasMany`). Each node on the way is shallow-copied, so a cached result is never
|
|
120
|
+
* mutated in place.
|
|
121
|
+
*/
|
|
122
|
+
function omitAtPath(node: any, path: string[], keys: (string | RegExp)[]): any {
|
|
123
|
+
if (node === null || node === undefined) return node;
|
|
124
|
+
if (Array.isArray(node)) return node.map((item) => omitAtPath(item, path, keys));
|
|
125
|
+
if (typeof node !== 'object') return node;
|
|
126
|
+
|
|
127
|
+
const [head, ...rest] = path;
|
|
128
|
+
if (!head || !(head in node)) return node;
|
|
129
|
+
|
|
130
|
+
return { ...node, [head]: rest.length ? omitAtPath(node[head], rest, keys) : func.omit(node[head], keys) };
|
|
131
|
+
}
|
|
132
|
+
|
|
64
133
|
// ─── Access control ─────────────────────────────────────────────────────────
|
|
65
134
|
// `evaluateAccess` / `errorResponse` / `getAccessToken` live in `./access`
|
|
66
135
|
// (shared with the agents API).
|
|
@@ -313,10 +382,14 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
313
382
|
response = await handler(rest, collection, body);
|
|
314
383
|
}
|
|
315
384
|
|
|
316
|
-
// Strip private fields from response
|
|
385
|
+
// Strip private fields from response — the queried collection's own,
|
|
386
|
+
// then each collection it embeds via `$include` / `$lookup`
|
|
317
387
|
if (privateFields.length > 0) {
|
|
318
388
|
response = func.omit(response!, privateFields, fieldOrder);
|
|
319
389
|
}
|
|
390
|
+
for (const { path, keys } of embeddedPrivateFields(col, body)) {
|
|
391
|
+
response = omitAtPath(response, path, keys);
|
|
392
|
+
}
|
|
320
393
|
|
|
321
394
|
return c.json(response);
|
|
322
395
|
|