@zooid/core 0.7.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/LICENSE +21 -0
- package/dist/index.d.ts +491 -0
- package/dist/index.js +868 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
- package/src/__fixtures__/ec2-workspace.yaml +16 -0
- package/src/__fixtures__/opencode-vertex-gemini.yaml +34 -0
- package/src/__fixtures__/triage-agent.yaml +40 -0
- package/src/__fixtures__/zooid-dev.yaml +53 -0
- package/src/acp-config.test.ts +162 -0
- package/src/acp-registry.test.ts +217 -0
- package/src/acp-registry.ts +235 -0
- package/src/acp-types.test.ts +55 -0
- package/src/acp-types.ts +48 -0
- package/src/approval-correlator.test.ts +129 -0
- package/src/approval-correlator.ts +143 -0
- package/src/config-file.test.ts +35 -0
- package/src/config.test.ts +1317 -0
- package/src/config.ts +712 -0
- package/src/env-interpolation.ts +90 -0
- package/src/example-yaml.test.ts +35 -0
- package/src/index.ts +56 -0
- package/src/transport-context.test.ts +34 -0
- package/src/transport-context.ts +91 -0
- package/src/types.ts +213 -0
- package/src/zooid-config.test.ts +164 -0
- package/src/zooid-helpers.test.ts +54 -0
- package/src/zooid-yaml-sweep.test.ts +389 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { parse } from 'yaml'
|
|
4
|
+
import type { AcpAgentSpec } from './acp-types.js'
|
|
5
|
+
import { isPreset } from '@zooid/acp-client'
|
|
6
|
+
import { interpolateEnv, interpolateString } from './env-interpolation.js'
|
|
7
|
+
import type {
|
|
8
|
+
AgentConfig,
|
|
9
|
+
CliFlags,
|
|
10
|
+
ContainerConfig,
|
|
11
|
+
HttpBinding,
|
|
12
|
+
HttpTransportConfig,
|
|
13
|
+
MatrixBinding,
|
|
14
|
+
MatrixTransportConfig,
|
|
15
|
+
TransportConfig,
|
|
16
|
+
ZooidConfig,
|
|
17
|
+
ZooidContainerConfig,
|
|
18
|
+
} from './types.js'
|
|
19
|
+
|
|
20
|
+
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
|
|
21
|
+
const MATRIX_USER_ID_RE = /^@[A-Za-z0-9._\-=/+]+:[A-Za-z0-9.\-]+$/
|
|
22
|
+
const MATRIX_USER_LOCALPART_RE = /^@[a-z0-9._=/+\-]+$/
|
|
23
|
+
const MATRIX_ROOM_IDENT_RE = /^[#!]/
|
|
24
|
+
|
|
25
|
+
function deriveServerName(userNamespace: string): string {
|
|
26
|
+
// user_namespace is a regex like `@.*:localhost`. The part after the first
|
|
27
|
+
// `:` is the server_name (strip a trailing `)` left over from a wrapped
|
|
28
|
+
// group like `@(.*):localhost)`).
|
|
29
|
+
const tail = userNamespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '')
|
|
30
|
+
if (!tail) throw new Error(`user_namespace missing server_name: ${userNamespace}`)
|
|
31
|
+
return tail
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TRANSPORT_KINDS = ['matrix', 'http'] as const
|
|
35
|
+
type TransportKind = (typeof TRANSPORT_KINDS)[number]
|
|
36
|
+
|
|
37
|
+
function parseAcpBlock(name: string, raw: unknown): AcpAgentSpec {
|
|
38
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
39
|
+
throw new Error(`agents.${name}.acp: must be a mapping with either preset or command`)
|
|
40
|
+
}
|
|
41
|
+
const a = raw as Record<string, unknown>
|
|
42
|
+
const hasPreset = a.preset !== undefined
|
|
43
|
+
const hasCommand = a.command !== undefined
|
|
44
|
+
if (hasPreset && hasCommand) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`agents.${name}.acp: specify either preset or command, not both`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
if (!hasPreset && !hasCommand) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`agents.${name}.acp: must specify either preset or command`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
if (hasPreset) {
|
|
55
|
+
if (typeof a.preset !== 'string' || a.preset.length === 0) {
|
|
56
|
+
throw new Error(`agents.${name}.acp.preset: must be a non-empty string`)
|
|
57
|
+
}
|
|
58
|
+
if (!isPreset(a.preset)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`agents.${name}.acp.preset: unknown preset "${a.preset}"`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
const out: { preset: string; model?: string } = { preset: a.preset }
|
|
64
|
+
if (a.model !== undefined) {
|
|
65
|
+
if (typeof a.model !== 'string' || a.model.trim().length === 0) {
|
|
66
|
+
throw new Error(`agents.${name}.acp.model: must be a non-empty string`)
|
|
67
|
+
}
|
|
68
|
+
out.model = a.model.trim()
|
|
69
|
+
}
|
|
70
|
+
return out as AcpAgentSpec
|
|
71
|
+
}
|
|
72
|
+
if (typeof a.command !== 'string' || a.command.length === 0) {
|
|
73
|
+
throw new Error(`agents.${name}.acp.command: must be a non-empty string`)
|
|
74
|
+
}
|
|
75
|
+
const args: string[] = []
|
|
76
|
+
if (a.args !== undefined) {
|
|
77
|
+
if (!Array.isArray(a.args)) {
|
|
78
|
+
throw new Error(`agents.${name}.acp.args: must be an array of strings`)
|
|
79
|
+
}
|
|
80
|
+
for (const v of a.args) {
|
|
81
|
+
if (typeof v !== 'string') {
|
|
82
|
+
throw new Error(`agents.${name}.acp.args[]: must be a string`)
|
|
83
|
+
}
|
|
84
|
+
args.push(v)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { command: a.command, args } as AcpAgentSpec
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseApprovalTimeout(name: string, raw: unknown): number {
|
|
91
|
+
if (raw === undefined) return 0
|
|
92
|
+
if (raw === 0 || raw === '0') return 0
|
|
93
|
+
if (typeof raw !== 'string') {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`agents.${name}.approval_timeout: must be a duration like "1h", "15m", "30s", or 0 to disable (got ${JSON.stringify(raw)})`,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
const m = /^(\d+)(s|m|h)$/.exec(raw)
|
|
99
|
+
if (!m) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`agents.${name}.approval_timeout: "${raw}" is not a valid duration (use "<n>s", "<n>m", or "<n>h")`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
const n = Number(m[1])
|
|
105
|
+
switch (m[2]) {
|
|
106
|
+
case 's':
|
|
107
|
+
return n * 1000
|
|
108
|
+
case 'm':
|
|
109
|
+
return n * 60_000
|
|
110
|
+
case 'h':
|
|
111
|
+
return n * 60 * 60_000
|
|
112
|
+
}
|
|
113
|
+
throw new Error('unreachable')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseAgentContainer(
|
|
117
|
+
name: string,
|
|
118
|
+
raw: unknown,
|
|
119
|
+
processEnv: NodeJS.ProcessEnv,
|
|
120
|
+
): ContainerConfig {
|
|
121
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
122
|
+
throw new Error(`agents.${name}.container must be a mapping`)
|
|
123
|
+
}
|
|
124
|
+
const r = raw as Record<string, unknown>
|
|
125
|
+
const out: ContainerConfig = {}
|
|
126
|
+
if (r.image !== undefined) {
|
|
127
|
+
if (typeof r.image !== 'string' || r.image.length === 0) {
|
|
128
|
+
throw new Error(`agents.${name}.container.image must be a non-empty string`)
|
|
129
|
+
}
|
|
130
|
+
out.image = r.image
|
|
131
|
+
}
|
|
132
|
+
if (r.env !== undefined && r.env !== null) {
|
|
133
|
+
if (typeof r.env !== 'object' || Array.isArray(r.env)) {
|
|
134
|
+
throw new Error(`agents.${name}.container.env must be a mapping`)
|
|
135
|
+
}
|
|
136
|
+
const rawEnv = r.env as Record<string, unknown>
|
|
137
|
+
const stringEnv: Record<string, string> = {}
|
|
138
|
+
for (const [k, v] of Object.entries(rawEnv)) {
|
|
139
|
+
if (typeof v !== 'string') {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`agents.${name}.container.env.${k}: must be a string (got ${typeof v})`,
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
stringEnv[k] = v
|
|
145
|
+
}
|
|
146
|
+
out.env = interpolateEnv(stringEnv, processEnv, `agents.${name}.container.env`)
|
|
147
|
+
}
|
|
148
|
+
return out
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseZooidContainer(raw: unknown): ZooidContainerConfig {
|
|
152
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
153
|
+
throw new Error('container must be a mapping')
|
|
154
|
+
}
|
|
155
|
+
const r = raw as Record<string, unknown>
|
|
156
|
+
const out: ZooidContainerConfig = {}
|
|
157
|
+
if (r.env !== undefined) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
"Top-level 'container.env' is not supported (workforce-level env defaults are out of scope; see [ZOD043]). " +
|
|
160
|
+
'Move env entries to per-agent container.env.',
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
if (r.image !== undefined) {
|
|
164
|
+
if (typeof r.image !== 'string' || r.image.length === 0) {
|
|
165
|
+
throw new Error('container.image must be a non-empty string')
|
|
166
|
+
}
|
|
167
|
+
out.image = r.image
|
|
168
|
+
}
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseTransports(
|
|
173
|
+
raw: unknown,
|
|
174
|
+
processEnv: NodeJS.ProcessEnv,
|
|
175
|
+
): Record<string, TransportConfig> {
|
|
176
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
177
|
+
throw new Error('transports: must be a mapping with at least one entry')
|
|
178
|
+
}
|
|
179
|
+
const r = raw as Record<string, unknown>
|
|
180
|
+
const names = Object.keys(r)
|
|
181
|
+
if (names.length === 0) {
|
|
182
|
+
throw new Error('transports: at least one transport must be declared')
|
|
183
|
+
}
|
|
184
|
+
const out: Record<string, TransportConfig> = {}
|
|
185
|
+
for (const name of names) {
|
|
186
|
+
out[name] = parseTransport(name, r[name], processEnv)
|
|
187
|
+
}
|
|
188
|
+
const matrixEntries = Object.entries(out).filter(
|
|
189
|
+
(e): e is [string, MatrixTransportConfig] => e[1].type === 'matrix',
|
|
190
|
+
)
|
|
191
|
+
if (matrixEntries.length === 1) {
|
|
192
|
+
const [, mt] = matrixEntries[0]!
|
|
193
|
+
if (mt.as_token === '__INFER__') {
|
|
194
|
+
const v = interpolateString('${MATRIX_AS_TOKEN}', processEnv)
|
|
195
|
+
if (v.length === 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
'transports.matrix.as_token: env var MATRIX_AS_TOKEN is not set ' +
|
|
198
|
+
'(set it in your shell or .env, or declare as_token explicitly in zooid.yaml)',
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
mt.as_token = v
|
|
202
|
+
}
|
|
203
|
+
if (mt.hs_token === '__INFER__') {
|
|
204
|
+
const v = interpolateString('${MATRIX_HS_TOKEN}', processEnv)
|
|
205
|
+
if (v.length === 0) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
'transports.matrix.hs_token: env var MATRIX_HS_TOKEN is not set ' +
|
|
208
|
+
'(set it in your shell or .env, or declare hs_token explicitly in zooid.yaml)',
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
mt.hs_token = v
|
|
212
|
+
}
|
|
213
|
+
} else if (matrixEntries.length > 1) {
|
|
214
|
+
for (const [tname, mt] of matrixEntries) {
|
|
215
|
+
if (mt.as_token === '__INFER__' || mt.hs_token === '__INFER__') {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`transports.${tname}: as_token / hs_token must be set explicitly when more than one matrix transport is declared ` +
|
|
218
|
+
`(no sensible default env var across multiple transports)`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseTransport(
|
|
227
|
+
name: string,
|
|
228
|
+
raw: unknown,
|
|
229
|
+
processEnv: NodeJS.ProcessEnv,
|
|
230
|
+
): TransportConfig {
|
|
231
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
232
|
+
throw new Error(`transports.${name}: must be a mapping`)
|
|
233
|
+
}
|
|
234
|
+
const r = raw as Record<string, unknown>
|
|
235
|
+
const inferredType =
|
|
236
|
+
r.type ?? (name === 'matrix' || name === 'http' ? name : undefined)
|
|
237
|
+
if (inferredType !== 'matrix' && inferredType !== 'http') {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`transports.${name}.type must be "matrix" or "http" (got ${JSON.stringify(r.type)})`,
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
if (inferredType === 'matrix') {
|
|
243
|
+
if (r.sender_localpart === undefined) r.sender_localpart = 'zooid'
|
|
244
|
+
if (r.user_namespace === undefined && typeof r.homeserver === 'string') {
|
|
245
|
+
try {
|
|
246
|
+
const host = new URL(
|
|
247
|
+
interpolateString(r.homeserver as string, processEnv),
|
|
248
|
+
).hostname
|
|
249
|
+
if (host) r.user_namespace = `@.*:${host}`
|
|
250
|
+
} catch {
|
|
251
|
+
// Fall through: the required-fields loop will fire its "must be a
|
|
252
|
+
// non-empty string" error, which is the same message today's parser
|
|
253
|
+
// would emit for a bad homeserver URL.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (r.as_token === undefined) r.as_token = '__INFER__'
|
|
257
|
+
if (r.hs_token === undefined) r.hs_token = '__INFER__'
|
|
258
|
+
const fields = [
|
|
259
|
+
'homeserver',
|
|
260
|
+
'as_token',
|
|
261
|
+
'hs_token',
|
|
262
|
+
'sender_localpart',
|
|
263
|
+
'user_namespace',
|
|
264
|
+
] as const
|
|
265
|
+
for (const f of fields) {
|
|
266
|
+
if (typeof r[f] !== 'string' || (r[f] as string).length === 0) {
|
|
267
|
+
throw new Error(`transports.${name}.${f} must be a non-empty string`)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const out: MatrixTransportConfig = {
|
|
271
|
+
type: 'matrix',
|
|
272
|
+
homeserver: interpolateString(r.homeserver as string, processEnv),
|
|
273
|
+
as_token: interpolateString(r.as_token as string, processEnv),
|
|
274
|
+
hs_token: interpolateString(r.hs_token as string, processEnv),
|
|
275
|
+
sender_localpart: r.sender_localpart as string,
|
|
276
|
+
user_namespace: r.user_namespace as string,
|
|
277
|
+
}
|
|
278
|
+
if (r.port !== undefined) {
|
|
279
|
+
if (!Number.isInteger(r.port)) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`transports.${name}.port must be an integer (got ${JSON.stringify(r.port)})`,
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
out.port = r.port as number
|
|
285
|
+
}
|
|
286
|
+
if (r.space !== undefined) {
|
|
287
|
+
if (typeof r.space !== 'string' || r.space.length === 0) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`transports.${name}.space must be a non-empty string (got ${JSON.stringify(r.space)})`,
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
out.space = r.space
|
|
293
|
+
}
|
|
294
|
+
return out
|
|
295
|
+
}
|
|
296
|
+
// type: 'http'
|
|
297
|
+
const port = (r.port ?? 8080) as number
|
|
298
|
+
if (!Number.isInteger(port)) {
|
|
299
|
+
throw new Error(`transports.${name}.port must be an integer (got ${JSON.stringify(port)})`)
|
|
300
|
+
}
|
|
301
|
+
return { type: 'http', port }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseTransportBinding(
|
|
305
|
+
name: string,
|
|
306
|
+
entry: Record<string, unknown>,
|
|
307
|
+
transports: Record<string, TransportConfig>,
|
|
308
|
+
): { matrix?: MatrixBinding; http?: HttpBinding } {
|
|
309
|
+
const present = TRANSPORT_KINDS.filter(
|
|
310
|
+
(k) => entry[k] !== undefined && entry[k] !== null,
|
|
311
|
+
)
|
|
312
|
+
if (present.length === 0) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`agents.${name}: must declare exactly one transport-kind block ` +
|
|
315
|
+
`(e.g. 'matrix:' or 'http:'). Saw none.`,
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
if (present.length > 1) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`agents.${name}: must declare exactly one transport-kind block. ` +
|
|
321
|
+
`Saw: ${present.join(', ')}. To run "the same agent" on two transports, ` +
|
|
322
|
+
`declare two agents (e.g. ${name}-matrix and ${name}-http).`,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
const kind = present[0] as TransportKind
|
|
326
|
+
const blockRaw = entry[kind]
|
|
327
|
+
if (typeof blockRaw !== 'object' || blockRaw === null || Array.isArray(blockRaw)) {
|
|
328
|
+
throw new Error(`agents.${name}.${kind}: must be a mapping`)
|
|
329
|
+
}
|
|
330
|
+
const block = blockRaw as Record<string, unknown>
|
|
331
|
+
let refName: string
|
|
332
|
+
if (typeof block.transport === 'string' && block.transport.length > 0) {
|
|
333
|
+
refName = block.transport
|
|
334
|
+
} else {
|
|
335
|
+
const matches = Object.entries(transports).filter(
|
|
336
|
+
([, t]) => t.type === kind,
|
|
337
|
+
)
|
|
338
|
+
if (matches.length === 0) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`agents.${name}.${kind}: no transport of type ${kind} declared (add one under transports:)`,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
if (matches.length > 1) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`agents.${name}.${kind}.transport is required when more than one ${kind} transport is declared ` +
|
|
346
|
+
`(saw: ${matches.map(([n]) => n).join(', ')})`,
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
refName = matches[0]![0]
|
|
350
|
+
}
|
|
351
|
+
const refTransport = transports[refName]
|
|
352
|
+
if (!refTransport) {
|
|
353
|
+
throw new Error(
|
|
354
|
+
`agents.${name}.${kind}.transport "${refName}" is not declared in transports`,
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
if (refTransport.type !== kind) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`agents.${name}.${kind} references transport "${refName}" of type: ${refTransport.type}. ` +
|
|
360
|
+
`Block name and referenced transport's type must match.`,
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (kind === 'matrix') {
|
|
365
|
+
if (refTransport.type !== 'matrix') {
|
|
366
|
+
throw new Error(`agents.${name}.matrix: transport must be matrix`)
|
|
367
|
+
}
|
|
368
|
+
const serverName = deriveServerName(refTransport.user_namespace)
|
|
369
|
+
|
|
370
|
+
const rawUserId =
|
|
371
|
+
typeof block.user_id === 'string' && block.user_id.length > 0
|
|
372
|
+
? block.user_id
|
|
373
|
+
: `@${name}`
|
|
374
|
+
let userId = rawUserId
|
|
375
|
+
if (!userId.includes(':') && MATRIX_USER_LOCALPART_RE.test(userId)) {
|
|
376
|
+
userId = `${userId}:${serverName}`
|
|
377
|
+
}
|
|
378
|
+
if (!MATRIX_USER_ID_RE.test(userId)) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`agents.${name}.matrix.user_id must look like @localpart:server (got ${JSON.stringify(block.user_id)})`,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (!Array.isArray(block.rooms) || block.rooms.length === 0) {
|
|
385
|
+
throw new Error(`agents.${name}.matrix.rooms is required and must be a non-empty array`)
|
|
386
|
+
}
|
|
387
|
+
const rooms: string[] = []
|
|
388
|
+
for (const r of block.rooms) {
|
|
389
|
+
if (typeof r !== 'string' || r.length === 0) {
|
|
390
|
+
throw new Error(`agents.${name}.matrix.rooms[] must be a non-empty string`)
|
|
391
|
+
}
|
|
392
|
+
if (!MATRIX_ROOM_IDENT_RE.test(r)) {
|
|
393
|
+
throw new Error(
|
|
394
|
+
`agents.${name}.matrix.rooms[] must start with '#' or '!' (got ${JSON.stringify(r)})`,
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
rooms.push(r.includes(':') ? r : `${r}:${serverName}`)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
let displayName: string | undefined
|
|
401
|
+
if (block.display_name !== undefined) {
|
|
402
|
+
if (typeof block.display_name !== 'string') {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`agents.${name}.matrix.display_name must be a string (got ${JSON.stringify(block.display_name)})`,
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
const trimmed = block.display_name.trim()
|
|
408
|
+
if (trimmed.length === 0) {
|
|
409
|
+
throw new Error(`agents.${name}.matrix.display_name must be non-empty after trim`)
|
|
410
|
+
}
|
|
411
|
+
if (trimmed.length > 256) {
|
|
412
|
+
throw new Error(`agents.${name}.matrix.display_name must be 256 characters or fewer`)
|
|
413
|
+
}
|
|
414
|
+
displayName = trimmed
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const tr = block.trigger ?? 'mention'
|
|
418
|
+
if (tr !== 'mention' && tr !== 'any') {
|
|
419
|
+
throw new Error(
|
|
420
|
+
`agents.${name}.matrix.trigger must be "mention" or "any" (got ${JSON.stringify(tr)})`,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
const matrix: MatrixBinding = {
|
|
424
|
+
transport: refName,
|
|
425
|
+
user_id: userId,
|
|
426
|
+
rooms,
|
|
427
|
+
trigger: tr,
|
|
428
|
+
}
|
|
429
|
+
if (displayName !== undefined) matrix.display_name = displayName
|
|
430
|
+
return { matrix }
|
|
431
|
+
}
|
|
432
|
+
// kind === 'http'
|
|
433
|
+
return { http: { transport: refName } }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function parseAgents(
|
|
437
|
+
raw: unknown,
|
|
438
|
+
runtime: 'local' | 'docker' | 'podman',
|
|
439
|
+
transports: Record<string, TransportConfig>,
|
|
440
|
+
daemonHooks: { pre_turn?: string; post_turn?: string },
|
|
441
|
+
processEnv: NodeJS.ProcessEnv,
|
|
442
|
+
): Record<string, AgentConfig> {
|
|
443
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
444
|
+
throw new Error('agents: must be a mapping')
|
|
445
|
+
}
|
|
446
|
+
const entries = Object.entries(raw as Record<string, unknown>)
|
|
447
|
+
if (entries.length === 0) {
|
|
448
|
+
throw new Error('agents: must have at least one entry')
|
|
449
|
+
}
|
|
450
|
+
const result: Record<string, AgentConfig> = {}
|
|
451
|
+
for (const [name, val] of entries) {
|
|
452
|
+
if (!AGENT_NAME_RE.test(name)) {
|
|
453
|
+
throw new Error(`agents.${name}: name must match /^[a-z][a-z0-9-]{0,31}$/`)
|
|
454
|
+
}
|
|
455
|
+
if (!val || typeof val !== 'object' || Array.isArray(val)) {
|
|
456
|
+
throw new Error(`agents.${name} must be a mapping`)
|
|
457
|
+
}
|
|
458
|
+
const entry = val as Record<string, unknown>
|
|
459
|
+
let workdir: string
|
|
460
|
+
if (entry.workdir === undefined) {
|
|
461
|
+
workdir = `./agents/${name}`
|
|
462
|
+
} else if (typeof entry.workdir !== 'string' || entry.workdir.length === 0) {
|
|
463
|
+
throw new Error(`agents.${name}.workdir must be a non-empty string`)
|
|
464
|
+
} else {
|
|
465
|
+
workdir = entry.workdir
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (entry.adapter !== undefined) {
|
|
469
|
+
throw new Error(
|
|
470
|
+
`agents.${name}: "adapter" is no longer supported; use "acp" — see epics/003-ZOD025-acp-migration/SPEC.md`,
|
|
471
|
+
)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (entry.acp === undefined) {
|
|
475
|
+
throw new Error(`agents.${name}: missing required "acp" block`)
|
|
476
|
+
}
|
|
477
|
+
const acp = parseAcpBlock(name, entry.acp)
|
|
478
|
+
const approval_timeout_ms = parseApprovalTimeout(name, entry.approval_timeout)
|
|
479
|
+
|
|
480
|
+
// Reject legacy fields up front with pointers to [ZOD043].
|
|
481
|
+
if (entry.docker !== undefined) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`agents.${name}.docker is no longer supported. ` +
|
|
484
|
+
`Move 'image' to agents.${name}.container.image, and 'forward_env' entries to ` +
|
|
485
|
+
`agents.${name}.container.env with \${VAR} interpolation. See [ZOD043].`,
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
if (typeof entry.transport === 'string') {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`agents.${name}.transport (string) is no longer supported at the agent level. ` +
|
|
491
|
+
`Move it inside a transport-kind block, e.g.:\n` +
|
|
492
|
+
` matrix:\n transport: <name>\n user_id: "@..."\n rooms: [...]\n` +
|
|
493
|
+
`See [ZOD043].`,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
for (const k of ['matrix_user_id', 'rooms', 'trigger'] as const) {
|
|
497
|
+
if (entry[k] !== undefined) {
|
|
498
|
+
throw new Error(
|
|
499
|
+
`agents.${name}.${k} is no longer supported as a flat field. ` +
|
|
500
|
+
`Move it inside a 'matrix:' block on the agent. See [ZOD043].`,
|
|
501
|
+
)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const agentHooks: AgentConfig['hooks'] = {}
|
|
506
|
+
if (daemonHooks.pre_turn !== undefined) agentHooks.pre_turn = daemonHooks.pre_turn
|
|
507
|
+
if (daemonHooks.post_turn !== undefined) agentHooks.post_turn = daemonHooks.post_turn
|
|
508
|
+
if (entry.hooks !== undefined && entry.hooks !== null) {
|
|
509
|
+
if (typeof entry.hooks !== 'object' || Array.isArray(entry.hooks)) {
|
|
510
|
+
throw new Error(`agents.${name}.hooks must be a mapping`)
|
|
511
|
+
}
|
|
512
|
+
const h = entry.hooks as Record<string, unknown>
|
|
513
|
+
if (Object.prototype.hasOwnProperty.call(h, 'pre_turn')) {
|
|
514
|
+
if (typeof h.pre_turn === 'string') agentHooks.pre_turn = h.pre_turn
|
|
515
|
+
else delete agentHooks.pre_turn
|
|
516
|
+
}
|
|
517
|
+
if (Object.prototype.hasOwnProperty.call(h, 'post_turn')) {
|
|
518
|
+
if (typeof h.post_turn === 'string') agentHooks.post_turn = h.post_turn
|
|
519
|
+
else delete agentHooks.post_turn
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let containerBlock: ContainerConfig | undefined
|
|
524
|
+
if (entry.container !== undefined && entry.container !== null) {
|
|
525
|
+
if (runtime === 'local') {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`agents.${name}.container is only valid when runtime is 'docker' or 'podman'. ` +
|
|
528
|
+
`runtime: local spawns agents as host child processes — there is no container, ` +
|
|
529
|
+
`so 'image' is inert and 'env' would silently lie (the agent inherits the daemon's ` +
|
|
530
|
+
`full process.env regardless).`,
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
containerBlock = parseAgentContainer(name, entry.container, processEnv)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const binding = parseTransportBinding(name, entry, transports)
|
|
537
|
+
|
|
538
|
+
const agentCfg: AgentConfig = {
|
|
539
|
+
name,
|
|
540
|
+
workdir,
|
|
541
|
+
hooks: agentHooks,
|
|
542
|
+
acp,
|
|
543
|
+
approval_timeout_ms,
|
|
544
|
+
}
|
|
545
|
+
if (containerBlock) agentCfg.container = containerBlock
|
|
546
|
+
if (binding.matrix) agentCfg.matrix = binding.matrix
|
|
547
|
+
if (binding.http) agentCfg.http = binding.http
|
|
548
|
+
result[name] = agentCfg
|
|
549
|
+
}
|
|
550
|
+
return result
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function parseRuntime(raw: unknown): 'local' | 'docker' | 'podman' {
|
|
554
|
+
const runtime = raw ?? 'docker'
|
|
555
|
+
if (runtime !== 'local' && runtime !== 'docker' && runtime !== 'podman') {
|
|
556
|
+
throw new Error(`runtime must be "local", "docker", or "podman" (got "${runtime}")`)
|
|
557
|
+
}
|
|
558
|
+
return runtime
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function zooidHooks(raw: Record<string, unknown>): { pre_turn?: string; post_turn?: string } {
|
|
562
|
+
const out: { pre_turn?: string; post_turn?: string } = {}
|
|
563
|
+
if (raw.hooks && typeof raw.hooks === 'object') {
|
|
564
|
+
const h = raw.hooks as Record<string, unknown>
|
|
565
|
+
if (typeof h.pre_turn === 'string') out.pre_turn = h.pre_turn
|
|
566
|
+
if (typeof h.post_turn === 'string') out.post_turn = h.post_turn
|
|
567
|
+
}
|
|
568
|
+
return out
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
export function loadZooidConfig(yamlText: string): ZooidConfig {
|
|
572
|
+
const raw = parse(yamlText) ?? {}
|
|
573
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
574
|
+
throw new Error('zooid.yaml must be a YAML object')
|
|
575
|
+
}
|
|
576
|
+
const r = raw as Record<string, unknown>
|
|
577
|
+
|
|
578
|
+
if (r.transport !== undefined) {
|
|
579
|
+
throw new Error(
|
|
580
|
+
'zooid.yaml: top-level "transport:" is no longer supported; declare entries under "transports:" instead',
|
|
581
|
+
)
|
|
582
|
+
}
|
|
583
|
+
if (r.matrix !== undefined) {
|
|
584
|
+
throw new Error(
|
|
585
|
+
'zooid.yaml: top-level "matrix:" is no longer supported; move it under "transports.<name>: { type: matrix, ... }"',
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
if (r.workdir !== undefined) {
|
|
589
|
+
throw new Error(
|
|
590
|
+
'top-level workdir is not supported; define agents: { <name>: { workdir: ... } } instead',
|
|
591
|
+
)
|
|
592
|
+
}
|
|
593
|
+
if (r.docker !== undefined) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
"Top-level 'docker' block is no longer supported. " +
|
|
596
|
+
"Move 'image' to top-level 'container.image'. See [ZOD043].",
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
if (r.agents === undefined) {
|
|
600
|
+
throw new Error('agents: is required — zooid.yaml must define at least one agent')
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const runtime = parseRuntime(r.runtime)
|
|
604
|
+
const processEnv = process.env
|
|
605
|
+
const transports = parseTransports(r.transports, processEnv)
|
|
606
|
+
const hooks = zooidHooks(r)
|
|
607
|
+
const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv)
|
|
608
|
+
|
|
609
|
+
const cfg: ZooidConfig = {
|
|
610
|
+
runtime,
|
|
611
|
+
transports,
|
|
612
|
+
agents,
|
|
613
|
+
hooks,
|
|
614
|
+
}
|
|
615
|
+
if (r.container !== undefined && r.container !== null) {
|
|
616
|
+
if (runtime === 'local') {
|
|
617
|
+
throw new Error(
|
|
618
|
+
"container is only valid when runtime is 'docker' or 'podman'. " +
|
|
619
|
+
'runtime: local does not run agents in containers; image is ignored. See [ZOD043].',
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
cfg.container = parseZooidContainer(r.container)
|
|
623
|
+
}
|
|
624
|
+
return cfg
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function findTransport(
|
|
628
|
+
cfg: ZooidConfig,
|
|
629
|
+
name: string,
|
|
630
|
+
): TransportConfig | undefined {
|
|
631
|
+
return cfg.transports[name]
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
export function findMatrixTransport(
|
|
635
|
+
cfg: ZooidConfig,
|
|
636
|
+
): { name: string; transport: MatrixTransportConfig } | null {
|
|
637
|
+
const matrices = Object.entries(cfg.transports).filter(
|
|
638
|
+
(e): e is [string, MatrixTransportConfig] => e[1].type === 'matrix',
|
|
639
|
+
)
|
|
640
|
+
if (matrices.length === 0) return null
|
|
641
|
+
if (matrices.length > 1) {
|
|
642
|
+
throw new Error(
|
|
643
|
+
`findMatrixTransport: multiple matrix transports declared (${matrices
|
|
644
|
+
.map((m) => m[0])
|
|
645
|
+
.join(', ')}). Per-agent matrix routing is not supported yet.`,
|
|
646
|
+
)
|
|
647
|
+
}
|
|
648
|
+
const [name, transport] = matrices[0]!
|
|
649
|
+
return { name, transport }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function findHttpTransport(
|
|
653
|
+
cfg: ZooidConfig,
|
|
654
|
+
): { name: string; transport: HttpTransportConfig } | null {
|
|
655
|
+
const https = Object.entries(cfg.transports).filter(
|
|
656
|
+
(e): e is [string, HttpTransportConfig] => e[1].type === 'http',
|
|
657
|
+
)
|
|
658
|
+
if (https.length === 0) return null
|
|
659
|
+
if (https.length > 1) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
`findHttpTransport: multiple http transports declared (${https
|
|
662
|
+
.map((h) => h[0])
|
|
663
|
+
.join(', ')}). Per-agent http routing is not supported yet.`,
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
const [name, transport] = https[0]!
|
|
667
|
+
return { name, transport }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export interface FoundConfigFile {
|
|
671
|
+
path: string
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function findConfigFile(cwd: string): FoundConfigFile | null {
|
|
675
|
+
const z = join(cwd, 'zooid.yaml')
|
|
676
|
+
if (existsSync(z)) return { path: z }
|
|
677
|
+
const legacy = join(cwd, 'workforce.yaml')
|
|
678
|
+
if (existsSync(legacy)) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
`workforce.yaml is no longer supported. Rename it to zooid.yaml. See [ZOD045].`,
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
return null
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export function mergeCliFlags(base: ZooidConfig, flags: CliFlags): ZooidConfig {
|
|
687
|
+
const runtimeFlag = flags.runtime as 'local' | 'docker' | 'podman' | undefined
|
|
688
|
+
if (
|
|
689
|
+
runtimeFlag !== undefined &&
|
|
690
|
+
runtimeFlag !== 'local' &&
|
|
691
|
+
runtimeFlag !== 'docker' &&
|
|
692
|
+
runtimeFlag !== 'podman'
|
|
693
|
+
) {
|
|
694
|
+
throw new Error(`runtime must be "local", "docker", or "podman" (got "${flags.runtime}")`)
|
|
695
|
+
}
|
|
696
|
+
const runtime = runtimeFlag ?? base.runtime
|
|
697
|
+
const merged: ZooidConfig = {
|
|
698
|
+
runtime,
|
|
699
|
+
transports: base.transports,
|
|
700
|
+
agents: base.agents,
|
|
701
|
+
hooks: { ...base.hooks },
|
|
702
|
+
}
|
|
703
|
+
if (runtime === 'docker' || runtime === 'podman') {
|
|
704
|
+
const image = flags.image ?? base.container?.image
|
|
705
|
+
if (image !== undefined) {
|
|
706
|
+
merged.container = { image }
|
|
707
|
+
} else if (base.container !== undefined) {
|
|
708
|
+
merged.container = { ...base.container }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return merged
|
|
712
|
+
}
|