@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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import dotenvExpand from 'dotenv-expand'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Matches compose-style env references inside a value string:
|
|
5
|
+
* `${NAME}`, `$NAME`, `${NAME:-default}`, `${NAME-default}`,
|
|
6
|
+
* `${NAME:+alt}`, `${NAME+alt}`.
|
|
7
|
+
*
|
|
8
|
+
* The captured group is always the referenced variable name.
|
|
9
|
+
*/
|
|
10
|
+
const REF_RE =
|
|
11
|
+
/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?[-+][^}]*)?\}|\$([A-Za-z_][A-Za-z0-9_]*)/g
|
|
12
|
+
|
|
13
|
+
export class EnvInterpolationError extends Error {}
|
|
14
|
+
|
|
15
|
+
const isDenied = (name: string): boolean =>
|
|
16
|
+
name === 'ZOOID_TOKEN' || name.startsWith('ZOOID_')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Run compose-style interpolation over a `{ KEY: literal-or-${ref} }` map.
|
|
20
|
+
*
|
|
21
|
+
* - Rejects keys in the `ZOOID_*` namespace.
|
|
22
|
+
* - Rejects any `${ZOOID_*}` reference, including inside composed values
|
|
23
|
+
* (e.g. `"prefix-${ZOOID_INTERNAL}"`).
|
|
24
|
+
* - Otherwise delegates to `dotenv-expand` for the actual substitution,
|
|
25
|
+
* preserving compose semantics (missing → empty string,
|
|
26
|
+
* `${VAR:-default}`, `${VAR-default}`, `$$` escape).
|
|
27
|
+
*/
|
|
28
|
+
export function interpolateEnv(
|
|
29
|
+
parsed: Record<string, string>,
|
|
30
|
+
processEnv: NodeJS.ProcessEnv,
|
|
31
|
+
scope: string,
|
|
32
|
+
): Record<string, string> {
|
|
33
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
34
|
+
if (isDenied(key)) {
|
|
35
|
+
throw new EnvInterpolationError(
|
|
36
|
+
`${scope}.${key}: keys in the ZOOID_* namespace are not allowed`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
if (typeof val !== 'string') {
|
|
40
|
+
throw new EnvInterpolationError(
|
|
41
|
+
`${scope}.${key}: env values must be strings (got ${typeof val})`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
REF_RE.lastIndex = 0
|
|
45
|
+
let m: RegExpExecArray | null
|
|
46
|
+
while ((m = REF_RE.exec(val)) !== null) {
|
|
47
|
+
const ref = (m[1] ?? m[2]) as string
|
|
48
|
+
if (isDenied(ref)) {
|
|
49
|
+
throw new EnvInterpolationError(
|
|
50
|
+
`${scope}.${key}: references to ZOOID_* vars are not allowed (saw \${${ref}})`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Process each value through interpolateString. Per-key processing avoids
|
|
56
|
+
// dotenv-expand's "processEnv shadows parsed" behaviour, which would let the
|
|
57
|
+
// daemon's `LOG_LEVEL=debug` overwrite a literal `LOG_LEVEL: info` declared in
|
|
58
|
+
// zooid.yaml. References still resolve against the full processEnv.
|
|
59
|
+
const out: Record<string, string> = {}
|
|
60
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
61
|
+
out[key] = interpolateString(val, processEnv)
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run compose-style interpolation over a single string value (e.g. a
|
|
68
|
+
* transport's `as_token`). No denylist — transports legitimately reference
|
|
69
|
+
* `ZOOID_*` tokens for the daemon itself (see [ZOD043] §Denylist note).
|
|
70
|
+
*/
|
|
71
|
+
export function interpolateString(
|
|
72
|
+
value: string,
|
|
73
|
+
processEnv: NodeJS.ProcessEnv,
|
|
74
|
+
): string {
|
|
75
|
+
// Fast path: literals with no `$` need no expansion. Skipping the dotenv-expand
|
|
76
|
+
// call also avoids the library's "processEnv shadows parsed" merge behaviour,
|
|
77
|
+
// which can leak unrelated env vars through a sentinel key.
|
|
78
|
+
if (!value.includes('$')) return value
|
|
79
|
+
const sentinel = '__zooid_interp_v1__'
|
|
80
|
+
const env: Record<string, string> = {}
|
|
81
|
+
for (const [k, v] of Object.entries(processEnv)) {
|
|
82
|
+
if (typeof v === 'string') env[k] = v
|
|
83
|
+
}
|
|
84
|
+
delete env[sentinel]
|
|
85
|
+
const result = dotenvExpand.expand({
|
|
86
|
+
parsed: { [sentinel]: value },
|
|
87
|
+
processEnv: env,
|
|
88
|
+
})
|
|
89
|
+
return result.parsed?.[sentinel] ?? ''
|
|
90
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { loadZooidConfig } from './config.js'
|
|
6
|
+
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
|
|
9
|
+
describe('zooid/examples/zooid-dev/zooid.yaml', () => {
|
|
10
|
+
it('parses with MATRIX_AS_TOKEN / MATRIX_HS_TOKEN set', () => {
|
|
11
|
+
const prev = {
|
|
12
|
+
MATRIX_AS_TOKEN: process.env.MATRIX_AS_TOKEN,
|
|
13
|
+
MATRIX_HS_TOKEN: process.env.MATRIX_HS_TOKEN,
|
|
14
|
+
}
|
|
15
|
+
process.env.MATRIX_AS_TOKEN = 'as-tok'
|
|
16
|
+
process.env.MATRIX_HS_TOKEN = 'hs-tok'
|
|
17
|
+
try {
|
|
18
|
+
const path = join(__dirname, '__fixtures__', 'zooid-dev.yaml')
|
|
19
|
+
const yamlText = readFileSync(path, 'utf8')
|
|
20
|
+
const cfg = loadZooidConfig(yamlText)
|
|
21
|
+
expect(cfg.runtime).toBe('local')
|
|
22
|
+
const mt = cfg.transports.matrix
|
|
23
|
+
if (mt.type !== 'matrix') throw new Error('not matrix')
|
|
24
|
+
expect(mt.sender_localpart).toBe('zooid')
|
|
25
|
+
expect(mt.user_namespace).toBe('@.*:localhost')
|
|
26
|
+
expect(cfg.agents.echo.workdir).toBe('./agents/echo')
|
|
27
|
+
expect(cfg.agents.echo.matrix?.user_id).toBe('@echo:localhost')
|
|
28
|
+
} finally {
|
|
29
|
+
if (prev.MATRIX_AS_TOKEN === undefined) delete process.env.MATRIX_AS_TOKEN
|
|
30
|
+
else process.env.MATRIX_AS_TOKEN = prev.MATRIX_AS_TOKEN
|
|
31
|
+
if (prev.MATRIX_HS_TOKEN === undefined) delete process.env.MATRIX_HS_TOKEN
|
|
32
|
+
else process.env.MATRIX_HS_TOKEN = prev.MATRIX_HS_TOKEN
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export {
|
|
2
|
+
loadZooidConfig,
|
|
3
|
+
mergeCliFlags,
|
|
4
|
+
findTransport,
|
|
5
|
+
findMatrixTransport,
|
|
6
|
+
findHttpTransport,
|
|
7
|
+
findConfigFile,
|
|
8
|
+
} from './config.js'
|
|
9
|
+
export {
|
|
10
|
+
AcpAgentRegistry,
|
|
11
|
+
resolveAcpAgentSpec,
|
|
12
|
+
} from './acp-registry.js'
|
|
13
|
+
export {
|
|
14
|
+
ApprovalCorrelator,
|
|
15
|
+
type RegisteredApproval,
|
|
16
|
+
type RegisterOptions,
|
|
17
|
+
} from './approval-correlator.js'
|
|
18
|
+
export type {
|
|
19
|
+
AcpRegistry,
|
|
20
|
+
AcpAgentRegistryOptions,
|
|
21
|
+
AcpRegistryEventHandler,
|
|
22
|
+
AcpRegistryApprovalHandler,
|
|
23
|
+
ContextSpawnFactory,
|
|
24
|
+
} from './acp-registry.js'
|
|
25
|
+
export type { TapEvent } from '@zooid/acp-client'
|
|
26
|
+
export type {
|
|
27
|
+
AcpAgentSpec,
|
|
28
|
+
AcpMount,
|
|
29
|
+
AcpRuntime,
|
|
30
|
+
AcpSpawnSpec,
|
|
31
|
+
} from './acp-types.js'
|
|
32
|
+
export type {
|
|
33
|
+
AgentConfig,
|
|
34
|
+
ContainerConfig,
|
|
35
|
+
ZooidContainerConfig,
|
|
36
|
+
MatrixBinding,
|
|
37
|
+
HttpBinding,
|
|
38
|
+
ZooidConfig,
|
|
39
|
+
TransportConfig,
|
|
40
|
+
MatrixTransportConfig,
|
|
41
|
+
HttpTransportConfig,
|
|
42
|
+
CliFlags,
|
|
43
|
+
Transport,
|
|
44
|
+
InboundMessage,
|
|
45
|
+
ThreadRef,
|
|
46
|
+
} from './types.js'
|
|
47
|
+
export type {
|
|
48
|
+
HistoryOptions,
|
|
49
|
+
HistoryPage,
|
|
50
|
+
Message,
|
|
51
|
+
Member,
|
|
52
|
+
ChannelInfo,
|
|
53
|
+
ThreadOverview,
|
|
54
|
+
ThreadOverviewPage,
|
|
55
|
+
TransportContextProvider,
|
|
56
|
+
} from './transport-context.js'
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
2
|
+
import type {
|
|
3
|
+
TransportContextProvider,
|
|
4
|
+
HistoryPage,
|
|
5
|
+
Message,
|
|
6
|
+
Member,
|
|
7
|
+
ChannelInfo,
|
|
8
|
+
ThreadOverviewPage,
|
|
9
|
+
} from './transport-context.js'
|
|
10
|
+
|
|
11
|
+
describe('TransportContextProvider', () => {
|
|
12
|
+
it('declares the navigation methods with the spec shape', () => {
|
|
13
|
+
expectTypeOf<TransportContextProvider['getRoomHistory']>().parameters.toEqualTypeOf<
|
|
14
|
+
[string, { limit?: number; before?: string }]
|
|
15
|
+
>()
|
|
16
|
+
expectTypeOf<TransportContextProvider['getRoomHistory']>().returns.resolves.toEqualTypeOf<HistoryPage>()
|
|
17
|
+
expectTypeOf<TransportContextProvider['getRecentThreads']>().returns.resolves.toEqualTypeOf<ThreadOverviewPage>()
|
|
18
|
+
expectTypeOf<TransportContextProvider['getThreadHistory']>().parameters.toEqualTypeOf<
|
|
19
|
+
[string, string, { limit?: number; before?: string }]
|
|
20
|
+
>()
|
|
21
|
+
expectTypeOf<TransportContextProvider['getThreadHistory']>().returns.resolves.toEqualTypeOf<HistoryPage>()
|
|
22
|
+
expectTypeOf<TransportContextProvider['getChannelMembers']>().returns.resolves.toEqualTypeOf<Member[]>()
|
|
23
|
+
expectTypeOf<TransportContextProvider['getChannelInfo']>().returns.resolves.toEqualTypeOf<ChannelInfo>()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('ChannelInfo.transport is the two-transport MVP union', () => {
|
|
27
|
+
expectTypeOf<ChannelInfo['transport']>().toEqualTypeOf<'http' | 'matrix'>()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('Message and Member carry the is_agent flag', () => {
|
|
31
|
+
expectTypeOf<Message['is_agent']>().toEqualTypeOf<boolean>()
|
|
32
|
+
expectTypeOf<Member['is_agent']>().toEqualTypeOf<boolean>()
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface HistoryOptions {
|
|
2
|
+
/** Max messages to return. Default 50, max 200 (enforced by the MCP server). */
|
|
3
|
+
limit?: number
|
|
4
|
+
/** Pagination cursor — opaque to the agent. Provider-defined shape. */
|
|
5
|
+
before?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Message {
|
|
9
|
+
id: string
|
|
10
|
+
sender: string
|
|
11
|
+
text: string
|
|
12
|
+
timestamp: string
|
|
13
|
+
is_agent: boolean
|
|
14
|
+
agent_name?: string
|
|
15
|
+
/**
|
|
16
|
+
* Thread root event id when this message belongs to a thread. Absent for
|
|
17
|
+
* top-level messages. Lets agents group messages by thread or drill into
|
|
18
|
+
* a specific thread root.
|
|
19
|
+
*/
|
|
20
|
+
thread_id?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Member {
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
is_agent: boolean
|
|
27
|
+
agent_name?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ChannelInfo {
|
|
31
|
+
id: string
|
|
32
|
+
name: string
|
|
33
|
+
transport: 'http' | 'matrix'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface HistoryPage {
|
|
37
|
+
messages: Message[]
|
|
38
|
+
next_before?: string
|
|
39
|
+
has_more: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A top-level entry in a room — either a standalone message or a thread
|
|
44
|
+
* root. Lets the agent scan a room without thread-reply noise. Drill into
|
|
45
|
+
* a thread with `getThreadHistory(thread_id)` where `thread_id` is this
|
|
46
|
+
* entry's `id`.
|
|
47
|
+
*/
|
|
48
|
+
export interface ThreadOverview {
|
|
49
|
+
id: string
|
|
50
|
+
sender: string
|
|
51
|
+
text: string
|
|
52
|
+
timestamp: string
|
|
53
|
+
is_agent: boolean
|
|
54
|
+
agent_name?: string
|
|
55
|
+
/** Number of replies in the thread under this entry. 0 = no replies yet. */
|
|
56
|
+
reply_count: number
|
|
57
|
+
/**
|
|
58
|
+
* ISO 8601 of the latest activity in the thread (latest reply, or this
|
|
59
|
+
* entry's own timestamp when there are no replies). Useful for sorting.
|
|
60
|
+
*/
|
|
61
|
+
last_activity_at: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ThreadOverviewPage {
|
|
65
|
+
threads: ThreadOverview[]
|
|
66
|
+
next_before?: string
|
|
67
|
+
has_more: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read-only conversation-context surface for a single transport.
|
|
72
|
+
* Implemented per-transport when the transport owns (or fronts) durable
|
|
73
|
+
* conversation context. Transports that don't (e.g. current HTTP) return
|
|
74
|
+
* `null` from `Transport.getContextProvider()`.
|
|
75
|
+
*
|
|
76
|
+
* Lets agents navigate a room the way a human does:
|
|
77
|
+
* - `getRoomHistory` — every message chronologically
|
|
78
|
+
* - `getRecentThreads` — top-level entries only (scan-the-room view)
|
|
79
|
+
* - `getThreadHistory` — drill into one specific thread
|
|
80
|
+
*/
|
|
81
|
+
export interface TransportContextProvider {
|
|
82
|
+
getRoomHistory(channelId: string, opts: HistoryOptions): Promise<HistoryPage>
|
|
83
|
+
getRecentThreads(channelId: string, opts: HistoryOptions): Promise<ThreadOverviewPage>
|
|
84
|
+
getThreadHistory(
|
|
85
|
+
channelId: string,
|
|
86
|
+
threadId: string,
|
|
87
|
+
opts: HistoryOptions,
|
|
88
|
+
): Promise<HistoryPage>
|
|
89
|
+
getChannelMembers(channelId: string): Promise<Member[]>
|
|
90
|
+
getChannelInfo(channelId: string): Promise<ChannelInfo>
|
|
91
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { AcpAgentSpec } from './acp-types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One classified line of agent stdout. Currently unused (legacy adapter
|
|
5
|
+
* machinery is gone); kept for forward compatibility with future on-disk
|
|
6
|
+
* stream parsing.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A Transport handles inbound messages and outbound replies. Implemented in
|
|
11
|
+
* future epics (HTTP, Slack, Zooid). Declared here so the core knows the
|
|
12
|
+
* shape downstream packages will plug in to.
|
|
13
|
+
*/
|
|
14
|
+
export interface InboundMessage {
|
|
15
|
+
id: string
|
|
16
|
+
text: string
|
|
17
|
+
sender: string
|
|
18
|
+
thread: ThreadRef
|
|
19
|
+
isFollowUp: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ThreadRef {
|
|
23
|
+
channelId: string
|
|
24
|
+
threadId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Transport {
|
|
28
|
+
listen(channel: string, onMessage: (msg: InboundMessage) => void): void
|
|
29
|
+
reply(thread: ThreadRef, message: string): Promise<void> | void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Per-agent container configuration. Holds runtime-neutral container
|
|
34
|
+
* concerns — image, env. Rejected at parse time when `runtime: local`.
|
|
35
|
+
*/
|
|
36
|
+
export interface ContainerConfig {
|
|
37
|
+
image?: string
|
|
38
|
+
/**
|
|
39
|
+
* Env vars passed to the spawned agent container. Values support
|
|
40
|
+
* compose-style `${VAR}` / `${VAR:-default}` / `${VAR-default}`
|
|
41
|
+
* interpolation, resolved at parse time against the daemon's `process.env`.
|
|
42
|
+
* `ZOOID_*` references and `ZOOID_*` keys are rejected.
|
|
43
|
+
*/
|
|
44
|
+
env?: Record<string, string>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Workforce-level container defaults. Currently `image` only — see
|
|
49
|
+
* [ZOD043] §Non-goals on workforce-level env.
|
|
50
|
+
*/
|
|
51
|
+
export interface ZooidContainerConfig {
|
|
52
|
+
image?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Matrix transport binding. Lives under `agents.<name>.matrix:` in
|
|
57
|
+
* zooid.yaml. The block name (`matrix`) is the transport-kind
|
|
58
|
+
* discriminator: `transports[transport].type` must equal `"matrix"`.
|
|
59
|
+
*/
|
|
60
|
+
export interface MatrixBinding {
|
|
61
|
+
/**
|
|
62
|
+
* Ref into `ZooidConfig.transports`. Resolved transport must have type: 'matrix'.
|
|
63
|
+
* @default if exactly one matrix transport is declared, that transport's key
|
|
64
|
+
*/
|
|
65
|
+
transport: string
|
|
66
|
+
/**
|
|
67
|
+
* Full Matrix user ID for this agent's bot, e.g. `@architect:example.com`.
|
|
68
|
+
* @default `@<agent name>:<server>` — server is derived from the resolved
|
|
69
|
+
* transport's `user_namespace`
|
|
70
|
+
*/
|
|
71
|
+
user_id: string
|
|
72
|
+
/**
|
|
73
|
+
* Optional human-readable display name. Written to the agent's Matrix
|
|
74
|
+
* profile on bootstrap. Falls back to the user_id localpart when absent.
|
|
75
|
+
*/
|
|
76
|
+
display_name?: string
|
|
77
|
+
/** Room IDs / aliases this agent watches. */
|
|
78
|
+
rooms: string[]
|
|
79
|
+
/**
|
|
80
|
+
* Routing rule. `mention` requires the bot to be tagged; `any` triggers
|
|
81
|
+
* on every message.
|
|
82
|
+
* @default 'mention'
|
|
83
|
+
*/
|
|
84
|
+
trigger: 'mention' | 'any'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* HTTP transport binding. Lives under `agents.<name>.http:` in
|
|
89
|
+
* zooid.yaml. Reserved for future HTTP-specific binding fields.
|
|
90
|
+
*/
|
|
91
|
+
export interface HttpBinding {
|
|
92
|
+
/** Ref into `ZooidConfig.transports`. Resolved transport must have type: 'http'. */
|
|
93
|
+
transport: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Per-agent config inside a multi-agent zooid.yaml. Each agent has its
|
|
98
|
+
* own workspace, hooks, an ACP block describing the shim to spawn, and
|
|
99
|
+
* exactly one transport-kind block (`matrix` or `http`).
|
|
100
|
+
*/
|
|
101
|
+
export interface AgentConfig {
|
|
102
|
+
/**
|
|
103
|
+
* Routing name. Always the agent's key in `ZooidConfig.agents` — cannot
|
|
104
|
+
* be overridden, and a `name:` field under an agent is silently ignored.
|
|
105
|
+
* The key must match /^[a-z][a-z0-9-]{0,31}$/.
|
|
106
|
+
*/
|
|
107
|
+
name: string
|
|
108
|
+
/**
|
|
109
|
+
* Host directory for the agent's workspace.
|
|
110
|
+
* @default `./agents/<name>`
|
|
111
|
+
*/
|
|
112
|
+
workdir: string
|
|
113
|
+
/** Per-agent hooks. Workforce-wide hooks are merged in at load time. */
|
|
114
|
+
hooks: {
|
|
115
|
+
pre_turn?: string
|
|
116
|
+
post_turn?: string
|
|
117
|
+
}
|
|
118
|
+
/** Required: how to launch this agent's ACP shim. */
|
|
119
|
+
acp: AcpAgentSpec
|
|
120
|
+
/**
|
|
121
|
+
* Wall-clock timeout for pending permission requests, in milliseconds.
|
|
122
|
+
* 0 = no timeout. The paused agent's idle cost is negligible, so opt-in
|
|
123
|
+
* is the right shape — set this only if you're running in a
|
|
124
|
+
* scale-to-zero / serverless context where unbounded waits would hold
|
|
125
|
+
* resources.
|
|
126
|
+
* @default 0
|
|
127
|
+
*/
|
|
128
|
+
approval_timeout_ms: number
|
|
129
|
+
/** Container config. Rejected at parse time when runtime: local. */
|
|
130
|
+
container?: ContainerConfig
|
|
131
|
+
/** Exactly one of matrix / http is set per agent. */
|
|
132
|
+
matrix?: MatrixBinding
|
|
133
|
+
http?: HttpBinding
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Matrix application-service transport. The CLI binds the AS HTTP listener
|
|
138
|
+
* to `port` (defaults to 8080).
|
|
139
|
+
*/
|
|
140
|
+
export interface MatrixTransportConfig {
|
|
141
|
+
type: 'matrix'
|
|
142
|
+
homeserver: string
|
|
143
|
+
/**
|
|
144
|
+
* Application-service token. Sent on every Client-Server API call.
|
|
145
|
+
* @default value of `$MATRIX_AS_TOKEN` in the daemon's env
|
|
146
|
+
*/
|
|
147
|
+
as_token: string
|
|
148
|
+
/**
|
|
149
|
+
* Homeserver-to-AS token. The homeserver presents this on push.
|
|
150
|
+
* @default value of `$MATRIX_HS_TOKEN` in the daemon's env
|
|
151
|
+
*/
|
|
152
|
+
hs_token: string
|
|
153
|
+
/**
|
|
154
|
+
* Localpart of the AS sender user (the bridge bot).
|
|
155
|
+
* @default 'zooid'
|
|
156
|
+
*/
|
|
157
|
+
sender_localpart: string
|
|
158
|
+
/**
|
|
159
|
+
* Regex covering all bot users, e.g. `@.*:example.com`.
|
|
160
|
+
* @default `@.*:<host>` — host derived from `homeserver`
|
|
161
|
+
*/
|
|
162
|
+
user_namespace: string
|
|
163
|
+
/**
|
|
164
|
+
* AS HTTP listener port.
|
|
165
|
+
* @default 8080
|
|
166
|
+
*/
|
|
167
|
+
port?: number
|
|
168
|
+
/**
|
|
169
|
+
* Workforce space localpart. Resolves to alias `#<space>:<server>`.
|
|
170
|
+
* @default 'dev'
|
|
171
|
+
*/
|
|
172
|
+
space?: string
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Plain HTTP API transport.
|
|
177
|
+
*/
|
|
178
|
+
export interface HttpTransportConfig {
|
|
179
|
+
type: 'http'
|
|
180
|
+
/**
|
|
181
|
+
* HTTP listener port.
|
|
182
|
+
* @default 8080
|
|
183
|
+
*/
|
|
184
|
+
port: number
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type TransportConfig = MatrixTransportConfig | HttpTransportConfig
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Parsed zooid.yaml shape. Always multi-agent — `agents:` is required and
|
|
191
|
+
* must have at least one entry. At least one transport must be declared and
|
|
192
|
+
* each agent must reference one by name.
|
|
193
|
+
*/
|
|
194
|
+
export interface ZooidConfig {
|
|
195
|
+
runtime: 'local' | 'docker' | 'podman'
|
|
196
|
+
/** Workforce-wide container defaults. Image only — no workforce-level env. Rejected when runtime: local. */
|
|
197
|
+
container?: ZooidContainerConfig
|
|
198
|
+
/** Required. Map of operator-chosen names → transport config. At least one entry. */
|
|
199
|
+
transports: Record<string, TransportConfig>
|
|
200
|
+
/** Required. Must have at least one entry. */
|
|
201
|
+
agents: Record<string, AgentConfig>
|
|
202
|
+
/** Workforce-wide hook defaults. Merged into each agent.hooks at load time. */
|
|
203
|
+
hooks: {
|
|
204
|
+
pre_turn?: string
|
|
205
|
+
post_turn?: string
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface CliFlags {
|
|
210
|
+
runtime?: string
|
|
211
|
+
/** Container image override (shorthand for container.image). */
|
|
212
|
+
image?: string
|
|
213
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { loadZooidConfig } from './config.js'
|
|
3
|
+
|
|
4
|
+
const minimal = `
|
|
5
|
+
runtime: docker
|
|
6
|
+
|
|
7
|
+
transports:
|
|
8
|
+
matrix-local:
|
|
9
|
+
type: matrix
|
|
10
|
+
homeserver: http://localhost:8448
|
|
11
|
+
as_token: as-x
|
|
12
|
+
hs_token: hs-x
|
|
13
|
+
sender_localpart: zooid
|
|
14
|
+
user_namespace: '@.*:localhost'
|
|
15
|
+
|
|
16
|
+
agents:
|
|
17
|
+
assistant:
|
|
18
|
+
workdir: .
|
|
19
|
+
acp: { preset: claude }
|
|
20
|
+
matrix:
|
|
21
|
+
transport: matrix-local
|
|
22
|
+
user_id: '@assistant:localhost'
|
|
23
|
+
rooms: ['!general:localhost']
|
|
24
|
+
trigger: mention
|
|
25
|
+
`
|
|
26
|
+
|
|
27
|
+
describe('loadZooidConfig — new shape', () => {
|
|
28
|
+
it('parses a single-matrix-transport workforce', () => {
|
|
29
|
+
const c = loadZooidConfig(minimal)
|
|
30
|
+
expect(c.transports['matrix-local']).toMatchObject({
|
|
31
|
+
type: 'matrix',
|
|
32
|
+
homeserver: 'http://localhost:8448',
|
|
33
|
+
as_token: 'as-x',
|
|
34
|
+
hs_token: 'hs-x',
|
|
35
|
+
sender_localpart: 'zooid',
|
|
36
|
+
user_namespace: '@.*:localhost',
|
|
37
|
+
})
|
|
38
|
+
expect(c.agents.assistant!.matrix?.transport).toBe('matrix-local')
|
|
39
|
+
expect(c.agents.assistant!.matrix?.user_id).toBe('@assistant:localhost')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('runtime defaults to docker when omitted', () => {
|
|
43
|
+
const yaml = `
|
|
44
|
+
transports:
|
|
45
|
+
http-only:
|
|
46
|
+
type: http
|
|
47
|
+
port: 8080
|
|
48
|
+
agents:
|
|
49
|
+
one:
|
|
50
|
+
workdir: .
|
|
51
|
+
acp: { preset: claude }
|
|
52
|
+
http:
|
|
53
|
+
transport: http-only
|
|
54
|
+
`
|
|
55
|
+
expect(loadZooidConfig(yaml).runtime).toBe('docker')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('errors when transports is empty', () => {
|
|
59
|
+
expect(() =>
|
|
60
|
+
loadZooidConfig(`
|
|
61
|
+
transports: {}
|
|
62
|
+
agents:
|
|
63
|
+
a:
|
|
64
|
+
workdir: .
|
|
65
|
+
acp: { preset: claude }
|
|
66
|
+
http:
|
|
67
|
+
transport: x
|
|
68
|
+
`),
|
|
69
|
+
).toThrow(/transports.*at least one/i)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("errors when an agent's transport reference doesn't exist", () => {
|
|
73
|
+
expect(() =>
|
|
74
|
+
loadZooidConfig(`
|
|
75
|
+
transports:
|
|
76
|
+
matrix-local:
|
|
77
|
+
type: matrix
|
|
78
|
+
homeserver: http://localhost:8448
|
|
79
|
+
as_token: as-x
|
|
80
|
+
hs_token: hs-x
|
|
81
|
+
sender_localpart: zooid
|
|
82
|
+
user_namespace: '@.*:localhost'
|
|
83
|
+
agents:
|
|
84
|
+
oops:
|
|
85
|
+
workdir: .
|
|
86
|
+
acp: { preset: claude }
|
|
87
|
+
matrix:
|
|
88
|
+
transport: ghost
|
|
89
|
+
user_id: '@oops:localhost'
|
|
90
|
+
rooms: ['!r:localhost']
|
|
91
|
+
`),
|
|
92
|
+
).toThrow(/agents\.oops\.matrix\.transport.*ghost.*not declared/i)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('errors when a transport has an unknown type', () => {
|
|
96
|
+
expect(() =>
|
|
97
|
+
loadZooidConfig(`
|
|
98
|
+
transports:
|
|
99
|
+
bad:
|
|
100
|
+
type: smtp
|
|
101
|
+
agents:
|
|
102
|
+
a:
|
|
103
|
+
workdir: .
|
|
104
|
+
acp: { preset: claude }
|
|
105
|
+
http:
|
|
106
|
+
transport: bad
|
|
107
|
+
`),
|
|
108
|
+
).toThrow(/transports\.bad\.type.*matrix.*http/i)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('errors when a matrix-typed transport is referenced from an http: block', () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
loadZooidConfig(`
|
|
114
|
+
transports:
|
|
115
|
+
m:
|
|
116
|
+
type: matrix
|
|
117
|
+
homeserver: http://localhost:8448
|
|
118
|
+
as_token: t
|
|
119
|
+
hs_token: h
|
|
120
|
+
sender_localpart: z
|
|
121
|
+
user_namespace: '@.*:l'
|
|
122
|
+
agents:
|
|
123
|
+
one:
|
|
124
|
+
workdir: .
|
|
125
|
+
acp: { preset: claude }
|
|
126
|
+
http:
|
|
127
|
+
transport: m
|
|
128
|
+
`),
|
|
129
|
+
).toThrow(/http.*references transport.*type: matrix/i)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('allows multiple transports of mixed types', () => {
|
|
133
|
+
const yaml = `
|
|
134
|
+
transports:
|
|
135
|
+
matrix-local:
|
|
136
|
+
type: matrix
|
|
137
|
+
homeserver: http://localhost:8448
|
|
138
|
+
as_token: as-x
|
|
139
|
+
hs_token: hs-x
|
|
140
|
+
sender_localpart: zooid
|
|
141
|
+
user_namespace: '@.*:localhost'
|
|
142
|
+
http-direct:
|
|
143
|
+
type: http
|
|
144
|
+
port: 8080
|
|
145
|
+
agents:
|
|
146
|
+
m:
|
|
147
|
+
workdir: .
|
|
148
|
+
acp: { preset: claude }
|
|
149
|
+
matrix:
|
|
150
|
+
transport: matrix-local
|
|
151
|
+
user_id: '@m:localhost'
|
|
152
|
+
rooms: ['!a:localhost']
|
|
153
|
+
h:
|
|
154
|
+
workdir: .
|
|
155
|
+
acp: { preset: claude }
|
|
156
|
+
http:
|
|
157
|
+
transport: http-direct
|
|
158
|
+
`
|
|
159
|
+
const c = loadZooidConfig(yaml)
|
|
160
|
+
expect(Object.keys(c.transports)).toEqual(['matrix-local', 'http-direct'])
|
|
161
|
+
expect(c.agents.m!.matrix?.transport).toBe('matrix-local')
|
|
162
|
+
expect(c.agents.h!.http?.transport).toBe('http-direct')
|
|
163
|
+
})
|
|
164
|
+
})
|