auggy 0.3.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/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/README.md +161 -0
- package/package.json +76 -0
- package/src/agent-card.ts +39 -0
- package/src/agent.ts +283 -0
- package/src/agentmail-client.ts +138 -0
- package/src/augments/bash/index.ts +463 -0
- package/src/augments/bash/skill/SKILL.md +156 -0
- package/src/augments/budgets/budget-store.ts +513 -0
- package/src/augments/budgets/index.ts +134 -0
- package/src/augments/budgets/preamble.ts +93 -0
- package/src/augments/budgets/types.ts +89 -0
- package/src/augments/file-memory/index.ts +71 -0
- package/src/augments/filesystem/index.ts +533 -0
- package/src/augments/filesystem/skill/SKILL.md +142 -0
- package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
- package/src/augments/layered-memory/extractor/buffer.ts +56 -0
- package/src/augments/layered-memory/extractor/frequency.ts +79 -0
- package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
- package/src/augments/layered-memory/extractor/parse.ts +75 -0
- package/src/augments/layered-memory/extractor/prompt.md +26 -0
- package/src/augments/layered-memory/index.ts +757 -0
- package/src/augments/layered-memory/skill/SKILL.md +153 -0
- package/src/augments/layered-memory/storage/migrations/README.md +16 -0
- package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
- package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
- package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
- package/src/augments/layered-memory/storage/types.ts +98 -0
- package/src/augments/link/index.ts +489 -0
- package/src/augments/link/translate.ts +261 -0
- package/src/augments/notify/adapters/agentmail.ts +70 -0
- package/src/augments/notify/adapters/telegram.ts +60 -0
- package/src/augments/notify/adapters/webhook.ts +55 -0
- package/src/augments/notify/index.ts +284 -0
- package/src/augments/notify/skill/SKILL.md +150 -0
- package/src/augments/org-context/index.ts +721 -0
- package/src/augments/org-context/skill/SKILL.md +96 -0
- package/src/augments/skills/index.ts +103 -0
- package/src/augments/supabase-memory/index.ts +151 -0
- package/src/augments/telegram-transport/index.ts +312 -0
- package/src/augments/telegram-transport/polling.ts +55 -0
- package/src/augments/telegram-transport/webhook.ts +56 -0
- package/src/augments/turn-control/index.ts +61 -0
- package/src/augments/turn-control/skill/SKILL.md +155 -0
- package/src/augments/visitor-auth/email-validation.ts +66 -0
- package/src/augments/visitor-auth/index.ts +779 -0
- package/src/augments/visitor-auth/rate-limiter.ts +90 -0
- package/src/augments/visitor-auth/skill/SKILL.md +55 -0
- package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
- package/src/augments/visitor-auth/storage/types.ts +164 -0
- package/src/augments/visitor-auth/types.ts +123 -0
- package/src/augments/visitor-auth/verify-page.ts +179 -0
- package/src/augments/web-fetch/index.ts +331 -0
- package/src/augments/web-fetch/skill/SKILL.md +100 -0
- package/src/cli/agent-index.ts +289 -0
- package/src/cli/augment-catalog.ts +320 -0
- package/src/cli/augment-resolver.ts +597 -0
- package/src/cli/commands/add-skill.ts +194 -0
- package/src/cli/commands/add.ts +87 -0
- package/src/cli/commands/chat.ts +207 -0
- package/src/cli/commands/create.ts +462 -0
- package/src/cli/commands/dev.ts +139 -0
- package/src/cli/commands/eval.ts +180 -0
- package/src/cli/commands/ls.ts +66 -0
- package/src/cli/commands/remove.ts +95 -0
- package/src/cli/commands/restart.ts +40 -0
- package/src/cli/commands/start.ts +123 -0
- package/src/cli/commands/status.ts +104 -0
- package/src/cli/commands/stop.ts +84 -0
- package/src/cli/commands/visitors-revoke.ts +155 -0
- package/src/cli/commands/visitors.ts +101 -0
- package/src/cli/config-parser.ts +1034 -0
- package/src/cli/engine-resolver.ts +68 -0
- package/src/cli/index.ts +178 -0
- package/src/cli/model-picker.ts +89 -0
- package/src/cli/pid-registry.ts +146 -0
- package/src/cli/plist-generator.ts +117 -0
- package/src/cli/resolve-config.ts +56 -0
- package/src/cli/scaffold-skills.ts +158 -0
- package/src/cli/scaffold.ts +291 -0
- package/src/cli/skill-frontmatter.ts +51 -0
- package/src/cli/skill-validator.ts +151 -0
- package/src/cli/types.ts +228 -0
- package/src/cli/yaml-helpers.ts +66 -0
- package/src/engines/_shared/cost.ts +55 -0
- package/src/engines/_shared/schema-normalize.ts +75 -0
- package/src/engines/anthropic/pricing.ts +117 -0
- package/src/engines/anthropic.ts +483 -0
- package/src/engines/openai/pricing.ts +67 -0
- package/src/engines/openai.ts +446 -0
- package/src/engines/openrouter/pricing.ts +83 -0
- package/src/engines/openrouter.ts +185 -0
- package/src/helpers.ts +24 -0
- package/src/http.ts +387 -0
- package/src/index.ts +165 -0
- package/src/kernel/capability-table.ts +172 -0
- package/src/kernel/context-allocator.ts +161 -0
- package/src/kernel/history-manager.ts +198 -0
- package/src/kernel/lifecycle-manager.ts +106 -0
- package/src/kernel/output-validator.ts +35 -0
- package/src/kernel/preamble.ts +23 -0
- package/src/kernel/route-collector.ts +97 -0
- package/src/kernel/timeout.ts +21 -0
- package/src/kernel/tool-selector.ts +47 -0
- package/src/kernel/trace-emitter.ts +66 -0
- package/src/kernel/transport-queue.ts +147 -0
- package/src/kernel/turn-loop.ts +1148 -0
- package/src/memory/context-synthesis.ts +83 -0
- package/src/memory/memory-bus.ts +61 -0
- package/src/memory/registry.ts +80 -0
- package/src/memory/tools.ts +320 -0
- package/src/memory/types.ts +8 -0
- package/src/parts.ts +30 -0
- package/src/scaffold-templates/identity.md +31 -0
- package/src/telegram-client.ts +145 -0
- package/src/tokenizer.ts +14 -0
- package/src/transports/ag-ui-events.ts +253 -0
- package/src/transports/visitor-token.ts +82 -0
- package/src/transports/web-transport.ts +948 -0
- package/src/types.ts +1009 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config parser — YAML agent.yaml → ParsedConfig.
|
|
3
|
+
*
|
|
4
|
+
* Three passes:
|
|
5
|
+
* 1. YAML parse (raw object)
|
|
6
|
+
* 2. Env var interpolation (${VAR_NAME} → process.env.VAR_NAME)
|
|
7
|
+
* 3. Structural validation (required fields, types, constraints)
|
|
8
|
+
*
|
|
9
|
+
* The parser loads a .env file from the agent directory before parsing
|
|
10
|
+
* so secrets are available for interpolation (same pattern as the
|
|
11
|
+
* telemetry-exporter daemon).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
15
|
+
import { resolve, dirname, join } from "node:path";
|
|
16
|
+
import { parse as parseYaml } from "yaml";
|
|
17
|
+
import type {
|
|
18
|
+
ParsedConfig,
|
|
19
|
+
AugmentConfig,
|
|
20
|
+
EngineConfig,
|
|
21
|
+
AgentSettings,
|
|
22
|
+
SecurityEvalOverride,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// .env loading
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load a .env file into process.env. Simple KEY=VALUE format, no
|
|
31
|
+
* interpolation, no quoting beyond trimming quotes from values.
|
|
32
|
+
* Silently skips if the file doesn't exist.
|
|
33
|
+
*/
|
|
34
|
+
export function loadEnvFile(dir: string): void {
|
|
35
|
+
const envPath = resolve(dir, ".env");
|
|
36
|
+
if (!existsSync(envPath)) return;
|
|
37
|
+
|
|
38
|
+
const content = readFileSync(envPath, "utf-8");
|
|
39
|
+
for (const line of content.split("\n")) {
|
|
40
|
+
const trimmed = line.trim();
|
|
41
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
42
|
+
const eqIdx = trimmed.indexOf("=");
|
|
43
|
+
if (eqIdx < 0) continue;
|
|
44
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
45
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
46
|
+
// Strip surrounding quotes.
|
|
47
|
+
if (
|
|
48
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
49
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
50
|
+
) {
|
|
51
|
+
value = value.slice(1, -1);
|
|
52
|
+
}
|
|
53
|
+
// Skip empty values (placeholder lines like KEY= in the template).
|
|
54
|
+
// Don't override existing env vars (shell exports take precedence).
|
|
55
|
+
if (key && value && !(key in process.env)) {
|
|
56
|
+
process.env[key] = value;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Env var interpolation
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
const ENV_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Recursively walk all string values in an object tree and replace
|
|
69
|
+
* ${VAR_NAME} references with process.env[VAR_NAME].
|
|
70
|
+
*
|
|
71
|
+
* Missing vars collect into an error array. If any are missing, throw
|
|
72
|
+
* with a clear message listing all of them.
|
|
73
|
+
*/
|
|
74
|
+
export function interpolateEnvVars(obj: unknown, path = ""): unknown {
|
|
75
|
+
const missing: string[] = [];
|
|
76
|
+
const result = walkAndInterpolate(obj, path, missing);
|
|
77
|
+
if (missing.length > 0) {
|
|
78
|
+
throw new Error(`Missing environment variables:\n${missing.map((m) => ` - ${m}`).join("\n")}`);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walkAndInterpolate(obj: unknown, path: string, missing: string[]): unknown {
|
|
84
|
+
if (typeof obj === "string") {
|
|
85
|
+
return obj.replace(ENV_VAR_RE, (_match, varName: string) => {
|
|
86
|
+
const value = process.env[varName];
|
|
87
|
+
if (value === undefined) {
|
|
88
|
+
missing.push(`${varName} (referenced in ${path || "root"})`);
|
|
89
|
+
return `\${${varName}}`;
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (Array.isArray(obj)) {
|
|
95
|
+
return obj.map((item, i) => walkAndInterpolate(item, `${path}[${i}]`, missing));
|
|
96
|
+
}
|
|
97
|
+
if (obj !== null && typeof obj === "object") {
|
|
98
|
+
const out: Record<string, unknown> = {};
|
|
99
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
100
|
+
out[key] = walkAndInterpolate(value, path ? `${path}.${key}` : key, missing);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
return obj;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Validation
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
const AUG1_ID_RE = /^aug1_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
112
|
+
/** Agent and augment names: lowercase alphanumeric, hyphens, underscores. No dots, slashes, spaces. */
|
|
113
|
+
export const VALID_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
|
|
114
|
+
const VALID_COMPACTION = new Set(["truncate", "summarize", "sliding-window"]);
|
|
115
|
+
const BUILTIN_TYPES = new Set([
|
|
116
|
+
"fileMemory",
|
|
117
|
+
"supabaseMemory",
|
|
118
|
+
"layeredMemory",
|
|
119
|
+
"filesystem",
|
|
120
|
+
"webTransport",
|
|
121
|
+
"webFetch",
|
|
122
|
+
"orgContext",
|
|
123
|
+
"skills",
|
|
124
|
+
"bash",
|
|
125
|
+
"budgets",
|
|
126
|
+
"notify",
|
|
127
|
+
"telegramTransport",
|
|
128
|
+
"turnControl",
|
|
129
|
+
"visitorAuth",
|
|
130
|
+
"link",
|
|
131
|
+
]);
|
|
132
|
+
const KNOWN_PROVIDERS = new Set(["anthropic", "openai", "openrouter"]);
|
|
133
|
+
const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]);
|
|
134
|
+
const VALID_ROUTING_SORTS = new Set(["price", "throughput", "latency"]);
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// Per-augment option validators
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Validate a BudgetCaps object (used for agent, public.anonymous, public.recognized).
|
|
142
|
+
* Each field must be a positive number when present.
|
|
143
|
+
*/
|
|
144
|
+
function validateBudgetCaps(caps: Record<string, unknown>, path: string, errors: string[]): void {
|
|
145
|
+
const numericFields = [
|
|
146
|
+
"maxTurnsPerThread",
|
|
147
|
+
"maxTurnsPerDay",
|
|
148
|
+
"maxUsdPerDay",
|
|
149
|
+
"maxUsdPerThread",
|
|
150
|
+
] as const;
|
|
151
|
+
for (const field of numericFields) {
|
|
152
|
+
if (caps[field] !== undefined) {
|
|
153
|
+
if (typeof caps[field] !== "number" || (caps[field] as number) <= 0) {
|
|
154
|
+
errors.push(`${path}.${field}: must be a positive number`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Validate the options block for a budgets augment.
|
|
162
|
+
*/
|
|
163
|
+
function validateBudgetsOptions(
|
|
164
|
+
opts: Record<string, unknown>,
|
|
165
|
+
prefix: string,
|
|
166
|
+
errors: string[],
|
|
167
|
+
): void {
|
|
168
|
+
if (typeof opts.dbPath !== "string" || opts.dbPath.length === 0) {
|
|
169
|
+
errors.push(`${prefix}.options.dbPath: required string`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const numericPositive: Array<keyof typeof opts> = [
|
|
173
|
+
"anonymousGlobalLimit",
|
|
174
|
+
"dailyBudgetUsd",
|
|
175
|
+
"cleanupWindowMs",
|
|
176
|
+
];
|
|
177
|
+
for (const field of numericPositive) {
|
|
178
|
+
if (opts[field] !== undefined) {
|
|
179
|
+
if (typeof opts[field] !== "number" || (opts[field] as number) <= 0) {
|
|
180
|
+
errors.push(`${prefix}.options.${field}: must be a positive number`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (opts.caps !== undefined) {
|
|
186
|
+
if (typeof opts.caps !== "object" || opts.caps === null || Array.isArray(opts.caps)) {
|
|
187
|
+
errors.push(`${prefix}.options.caps: must be an object`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const caps = opts.caps as Record<string, unknown>;
|
|
191
|
+
|
|
192
|
+
if (caps.agent !== undefined) {
|
|
193
|
+
if (typeof caps.agent !== "object" || caps.agent === null || Array.isArray(caps.agent)) {
|
|
194
|
+
errors.push(`${prefix}.options.caps.agent: must be an object`);
|
|
195
|
+
} else {
|
|
196
|
+
validateBudgetCaps(
|
|
197
|
+
caps.agent as Record<string, unknown>,
|
|
198
|
+
`${prefix}.options.caps.agent`,
|
|
199
|
+
errors,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (caps.public !== undefined) {
|
|
205
|
+
if (typeof caps.public !== "object" || caps.public === null || Array.isArray(caps.public)) {
|
|
206
|
+
errors.push(`${prefix}.options.caps.public: must be an object`);
|
|
207
|
+
} else {
|
|
208
|
+
const pub = caps.public as Record<string, unknown>;
|
|
209
|
+
for (const substate of ["anonymous", "recognized"] as const) {
|
|
210
|
+
if (pub[substate] !== undefined) {
|
|
211
|
+
if (
|
|
212
|
+
typeof pub[substate] !== "object" ||
|
|
213
|
+
pub[substate] === null ||
|
|
214
|
+
Array.isArray(pub[substate])
|
|
215
|
+
) {
|
|
216
|
+
errors.push(`${prefix}.options.caps.public.${substate}: must be an object`);
|
|
217
|
+
} else {
|
|
218
|
+
validateBudgetCaps(
|
|
219
|
+
pub[substate] as Record<string, unknown>,
|
|
220
|
+
`${prefix}.options.caps.public.${substate}`,
|
|
221
|
+
errors,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Valid extraction-frequency values for layered-memory's autoSave block.
|
|
233
|
+
* Aligned with `ExtractionFrequency` in
|
|
234
|
+
* `src/augments/layered-memory/extractor/frequency.ts` — kept duplicated
|
|
235
|
+
* here to avoid pulling augment runtime imports into the CLI parser.
|
|
236
|
+
*/
|
|
237
|
+
const VALID_EXTRACTION_FREQUENCIES = new Set([
|
|
238
|
+
"every-turn",
|
|
239
|
+
"every-N-turns",
|
|
240
|
+
"session-end-only",
|
|
241
|
+
"never",
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Validate the per-trust-level extractionFrequency map. Rejects flat
|
|
246
|
+
* `"public.recognized"`-style keys (per Codex 2nd-pass High-2 — the
|
|
247
|
+
* runtime taxonomy is two fields, never a colon/dot-joined string) and
|
|
248
|
+
* unknown frequency values. The nested shape mirrors Decision 3 of the
|
|
249
|
+
* memorist design.
|
|
250
|
+
*/
|
|
251
|
+
function validateExtractionFrequency(ef: unknown, prefix: string, errors: string[]): void {
|
|
252
|
+
if (ef === null || typeof ef !== "object" || Array.isArray(ef)) {
|
|
253
|
+
errors.push(`${prefix}: must be an object`);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const e = ef as Record<string, unknown>;
|
|
257
|
+
|
|
258
|
+
// Reject flat keys like "public.recognized" — they look like nested
|
|
259
|
+
// accessors but the runtime trust enum has two distinct fields. A flat
|
|
260
|
+
// key would silently fall through validation since the code below only
|
|
261
|
+
// checks the recognized top-level keys.
|
|
262
|
+
for (const key of Object.keys(e)) {
|
|
263
|
+
if (key.includes(".")) {
|
|
264
|
+
errors.push(
|
|
265
|
+
`${prefix}: flat key "${key}" not supported; use nested shape (public: { recognized: ..., anonymous: ... })`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const k of ["creator", "agent"] as const) {
|
|
271
|
+
if (e[k] !== undefined && !VALID_EXTRACTION_FREQUENCIES.has(e[k] as string)) {
|
|
272
|
+
errors.push(
|
|
273
|
+
`${prefix}.${k}: invalid frequency "${String(e[k])}" (expected one of: ${[...VALID_EXTRACTION_FREQUENCIES].join(", ")})`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (e.public !== undefined) {
|
|
279
|
+
if (e.public === null || typeof e.public !== "object" || Array.isArray(e.public)) {
|
|
280
|
+
errors.push(`${prefix}.public: must be an object with recognized + anonymous keys`);
|
|
281
|
+
} else {
|
|
282
|
+
const p = e.public as Record<string, unknown>;
|
|
283
|
+
for (const sub of ["recognized", "anonymous"] as const) {
|
|
284
|
+
if (p[sub] !== undefined && !VALID_EXTRACTION_FREQUENCIES.has(p[sub] as string)) {
|
|
285
|
+
errors.push(
|
|
286
|
+
`${prefix}.public.${sub}: invalid frequency "${String(p[sub])}" (expected one of: ${[...VALID_EXTRACTION_FREQUENCIES].join(", ")})`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Validate the options block for a layeredMemory augment.
|
|
296
|
+
*
|
|
297
|
+
* Currently scoped to the optional `autoSave` block (PR β / ADR-018
|
|
298
|
+
* Phase 2). Other layered-memory options (backend, namespace, dbPath,
|
|
299
|
+
* retentionDays) are validated only by the augment factory at boot —
|
|
300
|
+
* adding parser-level checks for them is out of PR β's scope.
|
|
301
|
+
*/
|
|
302
|
+
function validateLayeredMemoryOptions(
|
|
303
|
+
opts: Record<string, unknown>,
|
|
304
|
+
prefix: string,
|
|
305
|
+
errors: string[],
|
|
306
|
+
): void {
|
|
307
|
+
if (opts.autoSave === undefined) return;
|
|
308
|
+
if (opts.autoSave === null || typeof opts.autoSave !== "object" || Array.isArray(opts.autoSave)) {
|
|
309
|
+
errors.push(`${prefix}.options.autoSave: must be an object`);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const a = opts.autoSave as Record<string, unknown>;
|
|
313
|
+
|
|
314
|
+
if (a.enabled !== undefined && typeof a.enabled !== "boolean") {
|
|
315
|
+
errors.push(`${prefix}.options.autoSave.enabled: must be a boolean`);
|
|
316
|
+
}
|
|
317
|
+
if (a.everyNTurns !== undefined) {
|
|
318
|
+
if (typeof a.everyNTurns !== "number" || a.everyNTurns <= 0) {
|
|
319
|
+
errors.push(`${prefix}.options.autoSave.everyNTurns: must be a positive number`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (a.confidenceThreshold !== undefined) {
|
|
323
|
+
if (
|
|
324
|
+
typeof a.confidenceThreshold !== "number" ||
|
|
325
|
+
a.confidenceThreshold < 0 ||
|
|
326
|
+
a.confidenceThreshold > 1
|
|
327
|
+
) {
|
|
328
|
+
errors.push(
|
|
329
|
+
`${prefix}.options.autoSave.confidenceThreshold: must be a number between 0 and 1`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (a.promptTemplate !== undefined && typeof a.promptTemplate !== "string") {
|
|
334
|
+
errors.push(`${prefix}.options.autoSave.promptTemplate: must be a string (path to file)`);
|
|
335
|
+
}
|
|
336
|
+
if (a.extractionFrequency !== undefined) {
|
|
337
|
+
validateExtractionFrequency(
|
|
338
|
+
a.extractionFrequency,
|
|
339
|
+
`${prefix}.options.autoSave.extractionFrequency`,
|
|
340
|
+
errors,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Validate the options block for a link augment (peer-to-peer A2A v0.2).
|
|
347
|
+
* Shape:
|
|
348
|
+
* { port?, dbPath, agentCard: {...}, peers: { name: {...} } }
|
|
349
|
+
*/
|
|
350
|
+
function validateLinkOptions(
|
|
351
|
+
opts: Record<string, unknown>,
|
|
352
|
+
prefix: string,
|
|
353
|
+
errors: string[],
|
|
354
|
+
): void {
|
|
355
|
+
if (opts.port !== undefined && (typeof opts.port !== "number" || opts.port < 0)) {
|
|
356
|
+
errors.push(`${prefix}.options.port: must be a non-negative number`);
|
|
357
|
+
}
|
|
358
|
+
if (typeof opts.dbPath !== "string" || opts.dbPath.length === 0) {
|
|
359
|
+
errors.push(`${prefix}.options.dbPath: required non-empty string`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const card = opts.agentCard;
|
|
363
|
+
if (!card || typeof card !== "object" || Array.isArray(card)) {
|
|
364
|
+
errors.push(`${prefix}.options.agentCard: required object`);
|
|
365
|
+
} else {
|
|
366
|
+
const c = card as Record<string, unknown>;
|
|
367
|
+
for (const field of ["id", "name", "description", "endpointUrl"] as const) {
|
|
368
|
+
if (typeof c[field] !== "string" || (c[field] as string).length === 0) {
|
|
369
|
+
errors.push(`${prefix}.options.agentCard.${field}: required non-empty string`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (c.capabilities !== undefined) {
|
|
373
|
+
if (
|
|
374
|
+
!Array.isArray(c.capabilities) ||
|
|
375
|
+
(c.capabilities as unknown[]).some((v) => typeof v !== "string")
|
|
376
|
+
) {
|
|
377
|
+
errors.push(`${prefix}.options.agentCard.capabilities: must be an array of strings`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const peers = opts.peers;
|
|
383
|
+
if (peers !== undefined) {
|
|
384
|
+
if (!peers || typeof peers !== "object" || Array.isArray(peers)) {
|
|
385
|
+
errors.push(`${prefix}.options.peers: must be an object keyed by peer name`);
|
|
386
|
+
} else {
|
|
387
|
+
for (const [name, value] of Object.entries(peers as Record<string, unknown>)) {
|
|
388
|
+
const peerPrefix = `${prefix}.options.peers.${name}`;
|
|
389
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
390
|
+
errors.push(`${peerPrefix}: must be an object`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const p = value as Record<string, unknown>;
|
|
394
|
+
for (const field of [
|
|
395
|
+
"url",
|
|
396
|
+
"bearer",
|
|
397
|
+
"participantId",
|
|
398
|
+
"inboundBearer",
|
|
399
|
+
"inboundBearerId",
|
|
400
|
+
] as const) {
|
|
401
|
+
if (typeof p[field] !== "string" || (p[field] as string).length === 0) {
|
|
402
|
+
errors.push(`${peerPrefix}.${field}: required non-empty string`);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Validate the options block for a notify augment.
|
|
412
|
+
*/
|
|
413
|
+
function validateNotifyOptions(
|
|
414
|
+
opts: Record<string, unknown>,
|
|
415
|
+
prefix: string,
|
|
416
|
+
errors: string[],
|
|
417
|
+
): void {
|
|
418
|
+
if (!Array.isArray(opts.destinations)) {
|
|
419
|
+
errors.push(`${prefix}.destinations: required array`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (opts.destinations.length === 0) {
|
|
423
|
+
errors.push(`${prefix}.destinations: must have at least one destination`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const seenNames = new Set<string>();
|
|
427
|
+
for (let i = 0; i < opts.destinations.length; i++) {
|
|
428
|
+
const dest = opts.destinations[i] as Record<string, unknown>;
|
|
429
|
+
const dPrefix = `${prefix}.destinations[${i}]`;
|
|
430
|
+
if (typeof dest.name !== "string" || !dest.name) {
|
|
431
|
+
errors.push(`${dPrefix}.name: required string`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (seenNames.has(dest.name)) {
|
|
435
|
+
errors.push(`${dPrefix}.name: duplicate name "${dest.name}"`);
|
|
436
|
+
}
|
|
437
|
+
seenNames.add(dest.name);
|
|
438
|
+
|
|
439
|
+
if (dest.transport === "webhook") {
|
|
440
|
+
if (typeof dest.url !== "string" || !dest.url) {
|
|
441
|
+
errors.push(`${dPrefix}.url: required string for webhook transport`);
|
|
442
|
+
}
|
|
443
|
+
} else if (dest.transport === "telegram") {
|
|
444
|
+
if (typeof dest.botToken !== "string" || !dest.botToken) {
|
|
445
|
+
errors.push(`${dPrefix}.botToken: required string for telegram transport`);
|
|
446
|
+
}
|
|
447
|
+
if (
|
|
448
|
+
dest.chatId == null ||
|
|
449
|
+
(typeof dest.chatId !== "string" && typeof dest.chatId !== "number")
|
|
450
|
+
) {
|
|
451
|
+
errors.push(`${dPrefix}.chatId: required string or number for telegram transport`);
|
|
452
|
+
}
|
|
453
|
+
} else if (dest.transport === "agentmail") {
|
|
454
|
+
if (typeof dest.apiKey !== "string" || !dest.apiKey) {
|
|
455
|
+
errors.push(`${dPrefix}.apiKey: required string for agentmail transport`);
|
|
456
|
+
}
|
|
457
|
+
if (typeof dest.inboxId !== "string" || !dest.inboxId) {
|
|
458
|
+
errors.push(`${dPrefix}.inboxId: required string for agentmail transport`);
|
|
459
|
+
}
|
|
460
|
+
if (dest.to == null || (typeof dest.to !== "string" && !Array.isArray(dest.to))) {
|
|
461
|
+
errors.push(`${dPrefix}.to: required string or array for agentmail transport`);
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
errors.push(`${dPrefix}.transport: must be "webhook", "telegram", or "agentmail"`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (opts.rateLimit !== undefined) {
|
|
469
|
+
const rl = opts.rateLimit as Record<string, unknown>;
|
|
470
|
+
const numericFields = [
|
|
471
|
+
"cooldownMs",
|
|
472
|
+
"globalMaxPerHour",
|
|
473
|
+
"dedupWindowMs",
|
|
474
|
+
"dedupThreshold",
|
|
475
|
+
"perPeerCooldownMs",
|
|
476
|
+
] as const;
|
|
477
|
+
for (const field of numericFields) {
|
|
478
|
+
if (rl[field] !== undefined && (typeof rl[field] !== "number" || (rl[field] as number) < 0)) {
|
|
479
|
+
errors.push(`${prefix}.rateLimit.${field}: must be a non-negative number`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (rl.enabled !== undefined && typeof rl.enabled !== "boolean") {
|
|
483
|
+
errors.push(`${prefix}.rateLimit.enabled: must be a boolean`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Validate the options block for a telegramTransport augment.
|
|
490
|
+
* Enforces mode mutual exclusion: polling block is forbidden when mode=webhook
|
|
491
|
+
* and vice versa.
|
|
492
|
+
*/
|
|
493
|
+
function validateTelegramTransportOptions(
|
|
494
|
+
opts: Record<string, unknown>,
|
|
495
|
+
prefix: string,
|
|
496
|
+
errors: string[],
|
|
497
|
+
): void {
|
|
498
|
+
if (typeof opts.botToken !== "string" || !opts.botToken) {
|
|
499
|
+
errors.push(`${prefix}.botToken: required string`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const inbound = opts.inbound as Record<string, unknown> | undefined;
|
|
503
|
+
if (!inbound || typeof inbound !== "object") {
|
|
504
|
+
errors.push(`${prefix}.inbound: required object`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const mode = inbound.mode;
|
|
508
|
+
if (mode !== "polling" && mode !== "webhook") {
|
|
509
|
+
errors.push(`${prefix}.inbound.mode: must be "polling" or "webhook"`);
|
|
510
|
+
} else if (mode === "polling") {
|
|
511
|
+
if (inbound.polling !== undefined) {
|
|
512
|
+
const polling = inbound.polling as Record<string, unknown>;
|
|
513
|
+
if (
|
|
514
|
+
polling.timeoutSec !== undefined &&
|
|
515
|
+
(typeof polling.timeoutSec !== "number" || polling.timeoutSec <= 0)
|
|
516
|
+
) {
|
|
517
|
+
errors.push(`${prefix}.inbound.polling.timeoutSec: must be a positive number`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (inbound.webhook !== undefined) {
|
|
521
|
+
errors.push(`${prefix}.inbound: cannot set webhook block when mode is "polling"`);
|
|
522
|
+
}
|
|
523
|
+
} else if (mode === "webhook") {
|
|
524
|
+
if (inbound.polling !== undefined) {
|
|
525
|
+
errors.push(`${prefix}.inbound: cannot set polling block when mode is "webhook"`);
|
|
526
|
+
}
|
|
527
|
+
const webhook = inbound.webhook as Record<string, unknown> | undefined;
|
|
528
|
+
if (!webhook || typeof webhook !== "object") {
|
|
529
|
+
errors.push(`${prefix}.inbound.webhook: required object when mode is "webhook"`);
|
|
530
|
+
} else {
|
|
531
|
+
if (typeof webhook.publicUrl !== "string" || !webhook.publicUrl) {
|
|
532
|
+
errors.push(`${prefix}.inbound.webhook.publicUrl: required string`);
|
|
533
|
+
}
|
|
534
|
+
if (typeof webhook.secretToken !== "string" || !webhook.secretToken) {
|
|
535
|
+
errors.push(`${prefix}.inbound.webhook.secretToken: required string`);
|
|
536
|
+
}
|
|
537
|
+
if (
|
|
538
|
+
webhook.port !== undefined &&
|
|
539
|
+
(typeof webhook.port !== "number" || webhook.port <= 0 || webhook.port > 65535)
|
|
540
|
+
) {
|
|
541
|
+
errors.push(`${prefix}.inbound.webhook.port: must be a positive number ≤ 65535`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const auth = opts.auth as Record<string, unknown> | undefined;
|
|
547
|
+
if (auth !== undefined && typeof auth === "object") {
|
|
548
|
+
if (auth.creatorUserIds !== undefined && !Array.isArray(auth.creatorUserIds)) {
|
|
549
|
+
errors.push(`${prefix}.auth.creatorUserIds: must be an array of numbers`);
|
|
550
|
+
}
|
|
551
|
+
if (auth.recognizedUserIds !== undefined && !Array.isArray(auth.recognizedUserIds)) {
|
|
552
|
+
errors.push(`${prefix}.auth.recognizedUserIds: must be an array of numbers`);
|
|
553
|
+
}
|
|
554
|
+
if (auth.admittedAgents !== undefined) {
|
|
555
|
+
if (!Array.isArray(auth.admittedAgents)) {
|
|
556
|
+
errors.push(`${prefix}.auth.admittedAgents: must be an array`);
|
|
557
|
+
} else {
|
|
558
|
+
for (let i = 0; i < auth.admittedAgents.length; i++) {
|
|
559
|
+
const a = auth.admittedAgents[i] as Record<string, unknown>;
|
|
560
|
+
if (typeof a.id !== "string" || !a.id) {
|
|
561
|
+
errors.push(`${prefix}.auth.admittedAgents[${i}].id: required string`);
|
|
562
|
+
}
|
|
563
|
+
if (typeof a.telegramUserId !== "number") {
|
|
564
|
+
errors.push(`${prefix}.auth.admittedAgents[${i}].telegramUserId: required number`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (
|
|
570
|
+
auth.anonymousIdentityMode !== undefined &&
|
|
571
|
+
auth.anonymousIdentityMode !== "ephemeral" &&
|
|
572
|
+
auth.anonymousIdentityMode !== "durable"
|
|
573
|
+
) {
|
|
574
|
+
errors.push(`${prefix}.auth.anonymousIdentityMode: must be "ephemeral" or "durable"`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Validate the optional top-level `identity` shorthand field. Returns the
|
|
581
|
+
* trimmed string when present and well-formed, undefined when absent. Any
|
|
582
|
+
* malformed value (non-string, empty string) pushes an error and returns
|
|
583
|
+
* undefined.
|
|
584
|
+
*/
|
|
585
|
+
function validateIdentityShorthand(raw: unknown, errors: string[]): string | undefined {
|
|
586
|
+
if (raw === undefined) return undefined;
|
|
587
|
+
if (typeof raw !== "string") {
|
|
588
|
+
errors.push(
|
|
589
|
+
`identity: must be a non-empty string path to a markdown file (got ${Array.isArray(raw) ? "array" : raw === null ? "null" : typeof raw})`,
|
|
590
|
+
);
|
|
591
|
+
return undefined;
|
|
592
|
+
}
|
|
593
|
+
if (raw.length === 0) {
|
|
594
|
+
errors.push("identity: must be a non-empty string path to a markdown file (got empty string)");
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
// Trim before length check: a whitespace-only value would pass the
|
|
598
|
+
// length-zero gate but produce a useless source path. Catch it at parse
|
|
599
|
+
// time with a clear error rather than letting it fail later at boot
|
|
600
|
+
// with an opaque file-memory load error.
|
|
601
|
+
const trimmed = raw.trim();
|
|
602
|
+
if (trimmed.length === 0) {
|
|
603
|
+
errors.push(
|
|
604
|
+
"identity: must be a non-empty string path to a markdown file (got whitespace-only string)",
|
|
605
|
+
);
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
return trimmed;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Build the synthetic fileMemory entry equivalent to the `identity:`
|
|
613
|
+
* shorthand. Per spec §Decision 4 of the PR α foundation spec.
|
|
614
|
+
*/
|
|
615
|
+
function synthesizeIdentityAugment(source: string): AugmentConfig {
|
|
616
|
+
return {
|
|
617
|
+
name: "identity",
|
|
618
|
+
type: "fileMemory",
|
|
619
|
+
options: {
|
|
620
|
+
label: "self",
|
|
621
|
+
source,
|
|
622
|
+
mutable: false,
|
|
623
|
+
origin: "operator",
|
|
624
|
+
priority: "required",
|
|
625
|
+
placement: "system",
|
|
626
|
+
eviction: "never",
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function validateConfig(raw: Record<string, unknown>): ParsedConfig {
|
|
632
|
+
const errors: string[] = [];
|
|
633
|
+
|
|
634
|
+
// Required top-level fields.
|
|
635
|
+
if (typeof raw.id !== "string" || !AUG1_ID_RE.test(raw.id)) {
|
|
636
|
+
errors.push(`id: must be a valid aug1_ UUID (got "${raw.id}")`);
|
|
637
|
+
}
|
|
638
|
+
if (typeof raw.name !== "string" || raw.name.length === 0) {
|
|
639
|
+
errors.push("name: required, non-empty string");
|
|
640
|
+
} else if (!VALID_NAME_RE.test(raw.name)) {
|
|
641
|
+
errors.push(
|
|
642
|
+
`name: must be lowercase alphanumeric with hyphens/underscores (got "${raw.name}")`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// identity shorthand (optional) — synthesizes an equivalent fileMemory
|
|
647
|
+
// entry prepended to augments[]. Conflict detection happens after the
|
|
648
|
+
// augments array is validated below.
|
|
649
|
+
const identityShorthand = validateIdentityShorthand(raw.identity, errors);
|
|
650
|
+
|
|
651
|
+
// Engine.
|
|
652
|
+
const engine = raw.engine as Record<string, unknown> | undefined;
|
|
653
|
+
if (!engine || typeof engine !== "object") {
|
|
654
|
+
errors.push("engine: required object with provider and model");
|
|
655
|
+
} else {
|
|
656
|
+
if (typeof engine.provider !== "string") {
|
|
657
|
+
errors.push("engine.provider: required string");
|
|
658
|
+
} else if (!KNOWN_PROVIDERS.has(engine.provider)) {
|
|
659
|
+
errors.push(
|
|
660
|
+
`engine.provider: unknown provider "${engine.provider}" (supported: ${[...KNOWN_PROVIDERS].join(", ")})`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
if (typeof engine.model !== "string") {
|
|
664
|
+
errors.push("engine.model: required string");
|
|
665
|
+
}
|
|
666
|
+
if (engine.reasoningEffort !== undefined) {
|
|
667
|
+
if (
|
|
668
|
+
typeof engine.reasoningEffort !== "string" ||
|
|
669
|
+
!VALID_REASONING_EFFORTS.has(engine.reasoningEffort)
|
|
670
|
+
) {
|
|
671
|
+
errors.push(
|
|
672
|
+
`engine.reasoningEffort: must be one of ${[...VALID_REASONING_EFFORTS].join(", ")}`,
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (engine.providerRouting !== undefined) {
|
|
677
|
+
if (
|
|
678
|
+
typeof engine.providerRouting !== "object" ||
|
|
679
|
+
engine.providerRouting === null ||
|
|
680
|
+
Array.isArray(engine.providerRouting)
|
|
681
|
+
) {
|
|
682
|
+
errors.push("engine.providerRouting: must be an object");
|
|
683
|
+
} else if (engine.provider !== "openrouter") {
|
|
684
|
+
errors.push("engine.providerRouting: only valid for provider 'openrouter'");
|
|
685
|
+
} else {
|
|
686
|
+
const r = engine.providerRouting as Record<string, unknown>;
|
|
687
|
+
if (r.only !== undefined) {
|
|
688
|
+
if (
|
|
689
|
+
!Array.isArray(r.only) ||
|
|
690
|
+
r.only.length === 0 ||
|
|
691
|
+
!r.only.every((v) => typeof v === "string")
|
|
692
|
+
) {
|
|
693
|
+
errors.push("engine.providerRouting.only: must be a non-empty array of strings");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (r.ignore !== undefined) {
|
|
697
|
+
if (
|
|
698
|
+
!Array.isArray(r.ignore) ||
|
|
699
|
+
r.ignore.length === 0 ||
|
|
700
|
+
!r.ignore.every((v) => typeof v === "string")
|
|
701
|
+
) {
|
|
702
|
+
errors.push("engine.providerRouting.ignore: must be a non-empty array of strings");
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (
|
|
706
|
+
r.sort !== undefined &&
|
|
707
|
+
(typeof r.sort !== "string" || !VALID_ROUTING_SORTS.has(r.sort))
|
|
708
|
+
) {
|
|
709
|
+
errors.push(
|
|
710
|
+
`engine.providerRouting.sort: must be one of ${[...VALID_ROUTING_SORTS].join(", ")}`,
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
if (r.max_price !== undefined) {
|
|
714
|
+
if (
|
|
715
|
+
typeof r.max_price !== "object" ||
|
|
716
|
+
r.max_price === null ||
|
|
717
|
+
Array.isArray(r.max_price)
|
|
718
|
+
) {
|
|
719
|
+
errors.push("engine.providerRouting.max_price: must be an object");
|
|
720
|
+
} else {
|
|
721
|
+
const mp = r.max_price as Record<string, unknown>;
|
|
722
|
+
if (mp.prompt !== undefined && (typeof mp.prompt !== "number" || mp.prompt <= 0)) {
|
|
723
|
+
errors.push("engine.providerRouting.max_price.prompt: must be a positive number");
|
|
724
|
+
}
|
|
725
|
+
if (
|
|
726
|
+
mp.completion !== undefined &&
|
|
727
|
+
(typeof mp.completion !== "number" || mp.completion <= 0)
|
|
728
|
+
) {
|
|
729
|
+
errors.push("engine.providerRouting.max_price.completion: must be a positive number");
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
if (engine.costOverride !== undefined) {
|
|
736
|
+
if (
|
|
737
|
+
typeof engine.costOverride !== "object" ||
|
|
738
|
+
engine.costOverride === null ||
|
|
739
|
+
Array.isArray(engine.costOverride)
|
|
740
|
+
) {
|
|
741
|
+
errors.push("engine.costOverride: must be an object");
|
|
742
|
+
} else {
|
|
743
|
+
const co = engine.costOverride as Record<string, unknown>;
|
|
744
|
+
if (
|
|
745
|
+
typeof co.inputUsdPerMtok !== "number" ||
|
|
746
|
+
!Number.isFinite(co.inputUsdPerMtok) ||
|
|
747
|
+
co.inputUsdPerMtok < 0
|
|
748
|
+
) {
|
|
749
|
+
errors.push("engine.costOverride.inputUsdPerMtok: must be a finite non-negative number");
|
|
750
|
+
}
|
|
751
|
+
if (
|
|
752
|
+
typeof co.outputUsdPerMtok !== "number" ||
|
|
753
|
+
!Number.isFinite(co.outputUsdPerMtok) ||
|
|
754
|
+
co.outputUsdPerMtok < 0
|
|
755
|
+
) {
|
|
756
|
+
errors.push("engine.costOverride.outputUsdPerMtok: must be a finite non-negative number");
|
|
757
|
+
}
|
|
758
|
+
// Optional cache rates — accepted for the Anthropic adapter (where they
|
|
759
|
+
// contribute to costUsd) and for OpenAI/OpenRouter (where they're
|
|
760
|
+
// accepted for type symmetry; those adapters warn at boot when set).
|
|
761
|
+
if (co.cacheWriteUsdPerMtok !== undefined) {
|
|
762
|
+
if (
|
|
763
|
+
typeof co.cacheWriteUsdPerMtok !== "number" ||
|
|
764
|
+
!Number.isFinite(co.cacheWriteUsdPerMtok) ||
|
|
765
|
+
co.cacheWriteUsdPerMtok < 0
|
|
766
|
+
) {
|
|
767
|
+
errors.push(
|
|
768
|
+
"engine.costOverride.cacheWriteUsdPerMtok: must be a finite non-negative number",
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (co.cacheReadUsdPerMtok !== undefined) {
|
|
773
|
+
if (
|
|
774
|
+
typeof co.cacheReadUsdPerMtok !== "number" ||
|
|
775
|
+
!Number.isFinite(co.cacheReadUsdPerMtok) ||
|
|
776
|
+
co.cacheReadUsdPerMtok < 0
|
|
777
|
+
) {
|
|
778
|
+
errors.push(
|
|
779
|
+
"engine.costOverride.cacheReadUsdPerMtok: must be a finite non-negative number",
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Augments.
|
|
788
|
+
const augments = raw.augments;
|
|
789
|
+
if (!Array.isArray(augments) || augments.length === 0) {
|
|
790
|
+
errors.push("augments: required non-empty array");
|
|
791
|
+
} else {
|
|
792
|
+
const names = new Set<string>();
|
|
793
|
+
for (let i = 0; i < augments.length; i++) {
|
|
794
|
+
const aug = augments[i] as Record<string, unknown>;
|
|
795
|
+
const prefix = `augments[${i}]`;
|
|
796
|
+
|
|
797
|
+
if (typeof aug.name !== "string" || aug.name.length === 0) {
|
|
798
|
+
errors.push(`${prefix}.name: required, non-empty string`);
|
|
799
|
+
} else if (!VALID_NAME_RE.test(aug.name)) {
|
|
800
|
+
errors.push(
|
|
801
|
+
`${prefix}.name: must be lowercase alphanumeric with hyphens/underscores (got "${aug.name}")`,
|
|
802
|
+
);
|
|
803
|
+
} else if (names.has(aug.name)) {
|
|
804
|
+
errors.push(`${prefix}.name: duplicate name "${aug.name}"`);
|
|
805
|
+
} else {
|
|
806
|
+
names.add(aug.name);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (typeof aug.type !== "string") {
|
|
810
|
+
errors.push(`${prefix}.type: required string`);
|
|
811
|
+
} else if (!BUILTIN_TYPES.has(aug.type) && aug.type !== "custom") {
|
|
812
|
+
errors.push(
|
|
813
|
+
`${prefix}.type: unknown type "${aug.type}" (expected one of: ${[...BUILTIN_TYPES, "custom"].join(", ")})`,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (aug.type === "custom" && typeof aug.source !== "string") {
|
|
818
|
+
errors.push(`${prefix}.source: required for type "custom"`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (aug.type === "budgets") {
|
|
822
|
+
const opts = (aug.options ?? {}) as Record<string, unknown>;
|
|
823
|
+
validateBudgetsOptions(opts, prefix, errors);
|
|
824
|
+
} else if (aug.type === "notify") {
|
|
825
|
+
const notifyOpts = (aug.options ?? {}) as Record<string, unknown>;
|
|
826
|
+
validateNotifyOptions(notifyOpts, `${prefix}.options`, errors);
|
|
827
|
+
} else if (aug.type === "telegramTransport") {
|
|
828
|
+
const tgOpts = (aug.options ?? {}) as Record<string, unknown>;
|
|
829
|
+
validateTelegramTransportOptions(tgOpts, `${prefix}.options`, errors);
|
|
830
|
+
} else if (aug.type === "layeredMemory") {
|
|
831
|
+
const lmOpts = (aug.options ?? {}) as Record<string, unknown>;
|
|
832
|
+
validateLayeredMemoryOptions(lmOpts, prefix, errors);
|
|
833
|
+
} else if (aug.type === "link") {
|
|
834
|
+
const linkOpts = (aug.options ?? {}) as Record<string, unknown>;
|
|
835
|
+
validateLinkOptions(linkOpts, prefix, errors);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// Settings.
|
|
841
|
+
const settings = (raw.settings ?? {}) as Record<string, unknown>;
|
|
842
|
+
if (settings.compactionStrategy && !VALID_COMPACTION.has(settings.compactionStrategy as string)) {
|
|
843
|
+
errors.push(`settings.compactionStrategy: must be one of ${[...VALID_COMPACTION].join(", ")}`);
|
|
844
|
+
}
|
|
845
|
+
if (
|
|
846
|
+
settings.maxInferenceLoops !== undefined &&
|
|
847
|
+
(typeof settings.maxInferenceLoops !== "number" || settings.maxInferenceLoops < 1)
|
|
848
|
+
) {
|
|
849
|
+
errors.push("settings.maxInferenceLoops: must be a positive integer");
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Security eval overrides (optional). Per-agent context for the portable
|
|
853
|
+
// security eval suite — consumed by evals/security/eval-context.ts.
|
|
854
|
+
const securityEval = validateSecurityEval(raw.securityEval, errors);
|
|
855
|
+
|
|
856
|
+
// Identity shorthand conflict detection. If both `identity:` and an
|
|
857
|
+
// explicit fileMemory augment with placement:system are present, the
|
|
858
|
+
// config is ambiguous — operator must pick one form. The conflict only
|
|
859
|
+
// fires for placement:system; fileMemory entries with other placements
|
|
860
|
+
// (e.g. "context") coexist with the shorthand without issue.
|
|
861
|
+
//
|
|
862
|
+
// Separately, the synthesized augment is always named "identity", so the
|
|
863
|
+
// shorthand also reserves that name — an explicit augment also named
|
|
864
|
+
// "identity" would produce a duplicate after synthesis.
|
|
865
|
+
if (identityShorthand !== undefined && Array.isArray(augments)) {
|
|
866
|
+
const hasExplicitSystemFileMemory = (augments as unknown[]).some((a) => {
|
|
867
|
+
if (typeof a !== "object" || a === null) return false;
|
|
868
|
+
const aug = a as Record<string, unknown>;
|
|
869
|
+
if (aug.type !== "fileMemory") return false;
|
|
870
|
+
const opts = aug.options as Record<string, unknown> | undefined;
|
|
871
|
+
return opts?.placement === "system";
|
|
872
|
+
});
|
|
873
|
+
if (hasExplicitSystemFileMemory) {
|
|
874
|
+
errors.push(
|
|
875
|
+
"agent.yaml has both 'identity' shorthand and an explicit fileMemory augment with placement:system — pick one.",
|
|
876
|
+
);
|
|
877
|
+
} else {
|
|
878
|
+
// Only check the name collision when there's no placement:system
|
|
879
|
+
// conflict, to avoid stacking errors for the same operator mistake.
|
|
880
|
+
const hasExplicitIdentityName = (augments as unknown[]).some((a) => {
|
|
881
|
+
if (typeof a !== "object" || a === null) return false;
|
|
882
|
+
const aug = a as Record<string, unknown>;
|
|
883
|
+
return aug.name === "identity";
|
|
884
|
+
});
|
|
885
|
+
if (hasExplicitIdentityName) {
|
|
886
|
+
errors.push(
|
|
887
|
+
"agent.yaml has 'identity' shorthand and an explicit augment named 'identity' — rename the explicit augment or remove the shorthand.",
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (errors.length > 0) {
|
|
894
|
+
throw new Error(`Invalid agent.yaml:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Build the final augments list. When the identity shorthand is set and
|
|
898
|
+
// no conflict was detected, prepend the synthesized fileMemory entry so
|
|
899
|
+
// identity loads first (matches the convention of operators putting it
|
|
900
|
+
// at the top of agents.yaml manually today).
|
|
901
|
+
const parsedAugments = (augments as unknown[]).map((a) => a as AugmentConfig);
|
|
902
|
+
const finalAugments =
|
|
903
|
+
identityShorthand !== undefined
|
|
904
|
+
? [synthesizeIdentityAugment(identityShorthand), ...parsedAugments]
|
|
905
|
+
: parsedAugments;
|
|
906
|
+
|
|
907
|
+
return {
|
|
908
|
+
id: raw.id as string,
|
|
909
|
+
name: raw.name as string,
|
|
910
|
+
purpose: raw.purpose as string | undefined,
|
|
911
|
+
identity: identityShorthand,
|
|
912
|
+
engine: engine as unknown as EngineConfig,
|
|
913
|
+
settings: settings as AgentSettings,
|
|
914
|
+
operators: raw.operators as string[] | undefined,
|
|
915
|
+
augments: finalAugments,
|
|
916
|
+
securityEval,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/** Scalar fields on `securityEval` (each must be a string when present). */
|
|
921
|
+
const SECURITY_EVAL_SCALAR_FIELDS = [
|
|
922
|
+
"operatorName",
|
|
923
|
+
"agentName",
|
|
924
|
+
"fixtureEnvPath",
|
|
925
|
+
"fixtureInternalUrl",
|
|
926
|
+
"fixtureShellInitPath",
|
|
927
|
+
"fixtureWorkspaceRoot",
|
|
928
|
+
"fixtureAwsCredentialsPath",
|
|
929
|
+
] as const;
|
|
930
|
+
|
|
931
|
+
/** List fields on `securityEval` (each must be a string array when present). */
|
|
932
|
+
const SECURITY_EVAL_LIST_FIELDS = [
|
|
933
|
+
"refusalPhrasings",
|
|
934
|
+
"systemPromptLeakMarkers",
|
|
935
|
+
"identitySelfClaimKeywords",
|
|
936
|
+
"secretLeakMarkers",
|
|
937
|
+
] as const;
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Validate the optional `securityEval` block. Returns the parsed value when
|
|
941
|
+
* present and well-formed, or `undefined` when absent. Pushes informative
|
|
942
|
+
* errors onto `errors` for any malformed fields; does not throw.
|
|
943
|
+
*/
|
|
944
|
+
function validateSecurityEval(raw: unknown, errors: string[]): SecurityEvalOverride | undefined {
|
|
945
|
+
if (raw === undefined) return undefined;
|
|
946
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
947
|
+
errors.push("securityEval: must be an object");
|
|
948
|
+
return undefined;
|
|
949
|
+
}
|
|
950
|
+
const block = raw as Record<string, unknown>;
|
|
951
|
+
const out: SecurityEvalOverride = {};
|
|
952
|
+
|
|
953
|
+
for (const field of SECURITY_EVAL_SCALAR_FIELDS) {
|
|
954
|
+
const value = block[field];
|
|
955
|
+
if (value === undefined) continue;
|
|
956
|
+
if (typeof value !== "string") {
|
|
957
|
+
errors.push(`securityEval.${field}: must be a string`);
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
out[field] = value;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
for (const field of SECURITY_EVAL_LIST_FIELDS) {
|
|
964
|
+
const value = block[field];
|
|
965
|
+
if (value === undefined) continue;
|
|
966
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
967
|
+
errors.push(`securityEval.${field}: must be an array of strings`);
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
out[field] = value as string[];
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
return out;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// ---------------------------------------------------------------------------
|
|
977
|
+
// Public API
|
|
978
|
+
// ---------------------------------------------------------------------------
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* Parse an agent.yaml file into a validated ParsedConfig.
|
|
982
|
+
*
|
|
983
|
+
* Loads .env from the config file's directory, interpolates env vars,
|
|
984
|
+
* and validates the structure.
|
|
985
|
+
*/
|
|
986
|
+
export function parseConfig(yamlPath: string): ParsedConfig {
|
|
987
|
+
const absPath = resolve(yamlPath);
|
|
988
|
+
const agentDir = dirname(absPath);
|
|
989
|
+
|
|
990
|
+
// Load .env before parsing so secrets are available for interpolation.
|
|
991
|
+
loadEnvFile(agentDir);
|
|
992
|
+
|
|
993
|
+
const raw = readFileSync(absPath, "utf-8");
|
|
994
|
+
const parsed = parseYaml(raw);
|
|
995
|
+
if (!parsed || typeof parsed !== "object") {
|
|
996
|
+
throw new Error(`${yamlPath}: not a valid YAML document`);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
let interpolated: Record<string, unknown>;
|
|
1000
|
+
try {
|
|
1001
|
+
interpolated = interpolateEnvVars(parsed) as Record<string, unknown>;
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
const msg = (err as Error).message;
|
|
1004
|
+
if (msg.startsWith("Missing environment variables:")) {
|
|
1005
|
+
throw new Error(augmentMissingEnvError(msg, agentDir), { cause: err });
|
|
1006
|
+
}
|
|
1007
|
+
throw err;
|
|
1008
|
+
}
|
|
1009
|
+
return validateConfig(interpolated);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function augmentMissingEnvError(originalMsg: string, agentDir: string): string {
|
|
1013
|
+
const envPath = join(agentDir, ".env");
|
|
1014
|
+
const envExamplePath = join(agentDir, ".env.example");
|
|
1015
|
+
|
|
1016
|
+
const lines: string[] = [
|
|
1017
|
+
originalMsg.replace(
|
|
1018
|
+
/^Missing environment variables:/,
|
|
1019
|
+
"Missing environment variables in agent.yaml:",
|
|
1020
|
+
),
|
|
1021
|
+
"",
|
|
1022
|
+
"Set them in the agent's .env file:",
|
|
1023
|
+
` ${envPath}`,
|
|
1024
|
+
];
|
|
1025
|
+
|
|
1026
|
+
// Suggest cp ONLY when .env.example exists and .env doesn't.
|
|
1027
|
+
if (existsSync(envExamplePath) && !existsSync(envPath)) {
|
|
1028
|
+
lines.push("");
|
|
1029
|
+
lines.push("Or copy from the template:");
|
|
1030
|
+
lines.push(` cp ${envExamplePath} ${envPath}`);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return lines.join("\n");
|
|
1034
|
+
}
|