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.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. 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
+ }