@bevel-software/ael 0.0.0-stage → 0.2.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 (43) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +32 -3
  3. package/bin/bevel-agent.mjs +15 -0
  4. package/executor-entrypoint.sh +41 -0
  5. package/executor.Dockerfile +37 -0
  6. package/package.json +61 -6
  7. package/src/agent/agent-file.ts +242 -0
  8. package/src/cli/main.ts +81 -0
  9. package/src/cli/run-local.ts +224 -0
  10. package/src/cli/run-step.ts +147 -0
  11. package/src/executor/activities.ts +295 -0
  12. package/src/executor/docker-cli.ts +148 -0
  13. package/src/executor/driver.ts +80 -0
  14. package/src/executor/harness-mount.ts +66 -0
  15. package/src/executor/in-place.ts +180 -0
  16. package/src/executor/main.ts +230 -0
  17. package/src/executor/personal.ts +355 -0
  18. package/src/executor/slots.ts +60 -0
  19. package/src/harness/claude-code.ts +397 -0
  20. package/src/harness/pi-in-process.ts +444 -0
  21. package/src/harness/session-state.ts +86 -0
  22. package/src/harness/stall-guard.ts +97 -0
  23. package/src/index.ts +164 -0
  24. package/src/interpreter/contract.ts +403 -0
  25. package/src/interpreter/dispatcher.workflow.ts +412 -0
  26. package/src/interpreter/interpret.workflow.ts +907 -0
  27. package/src/interpreter/scheduled.workflow.ts +28 -0
  28. package/src/interpreter/workflows.ts +8 -0
  29. package/src/run/contract.ts +150 -0
  30. package/src/run/outcome.ts +63 -0
  31. package/src/run/reporter.ts +127 -0
  32. package/src/run/system-prompt.ts +35 -0
  33. package/src/run/verdict.ts +46 -0
  34. package/src/shared/front-matter.ts +27 -0
  35. package/src/shared/redact.ts +78 -0
  36. package/src/shared/schedule.ts +93 -0
  37. package/src/temporal/payload-codec.ts +84 -0
  38. package/src/transcript/render.ts +106 -0
  39. package/src/workflow/definition.ts +196 -0
  40. package/src/workflow/failure-policy.ts +100 -0
  41. package/src/workflow/parameters.ts +175 -0
  42. package/src/workflow/workflow-file.ts +457 -0
  43. package/src/workitem/node.ts +170 -0
package/LICENSE ADDED
@@ -0,0 +1,13 @@
1
+ Copyright (c) 2026 Bevelites GmbH. All rights reserved.
2
+
3
+ This software and its source code are the property of Bevelites GmbH.
4
+ They are published so that customers of Bevelites GmbH can install and
5
+ run them. This software may be used only by those who have a contract
6
+ with Bevelites GmbH, and only as that contract allows. No other licence
7
+ to copy, modify, distribute or use it is granted.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
11
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
12
+ BEVELITES GMBH BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY ARISING
13
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.
package/README.md CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # @bevel-software/ael
2
+
3
+ The agentic execution layer of the Bevel platform: everything that reads an
4
+ `.agent` or a `.workflow` file and everything that runs a session from them.
5
+ The platform imports it, run containers mount it, and `bevel-agent` is it.
6
+
7
+ Shipped as TypeScript source (`main` is `src/index.ts`), like the platform's
8
+ core frontend: the platform and the executor image run it with `tsx`.
9
+
10
+ ## What is in it
11
+
12
+ - `.agent` and `.workflow` parsers (`parseAgentFile`, `parseWorkflowFile`):
13
+ the one reader of both file kinds.
14
+ - The run contract: `RunInput`, `RunEvent`, `SessionState`, `Harness`.
15
+ - The interpreter and the dispatchers, Temporal workflows the platform's
16
+ worker bundles from `@bevel-software/ael/workflows`.
17
+ - The pi in-process harness, with the platform's tools over MCP.
18
+ - The executor: `createExecutor` over a `ContainerDriver`, and the Docker
19
+ command driver.
20
+ - `bevel-agent`: `run <file>` (a workflow file, locally, against a platform),
21
+ `run-step` (what an executor execs in a run container), `executor`.
22
+
23
+ ## Running a workflow file locally
24
+
25
+ ```
26
+ BEVEL_API_URL=https://kb.example.com/api BEVEL_CONNECTION_KEY=bevel_… \
27
+ npx bevel-agent run Workflows/nightly.workflow "the input"
28
+ ```
29
+
30
+ Agent steps only, on the happy path, with pi's tools on the working
31
+ directory; the file's `env:` comes from your environment by name. Gates,
32
+ tool chains, choices and work items are the platform's.
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ // `bevel-agent` as npm installs it. The command line is TypeScript, which
3
+ // plain Node does not run, so tsx is registered first and the entry loaded
4
+ // through it — the same tsx the executor image and the run containers use.
5
+ import { register } from 'tsx/esm/api';
6
+
7
+ register();
8
+ const { main } = await import('../src/cli/main.ts');
9
+ main(process.argv.slice(2)).then(
10
+ (code) => process.exit(code),
11
+ (err) => {
12
+ process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
13
+ process.exit(1);
14
+ },
15
+ );
@@ -0,0 +1,41 @@
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # EXECUTOR_ENABLED=false keeps this host out of container steps: the
5
+ # container stays up doing nothing, so the deployment sees a healthy
6
+ # service and nothing restarts it in a loop, and it polls no queue and
7
+ # touches neither the engine nor the harness volume. The platform's own
8
+ # compose file runs an executor beside the platform by default; this is
9
+ # the switch for a host that should run none.
10
+ case "$(printf '%s' "${EXECUTOR_ENABLED:-true}" | tr '[:upper:]' '[:lower:]')" in
11
+ false|0|no|off)
12
+ echo "[executor] disabled on this host (EXECUTOR_ENABLED=${EXECUTOR_ENABLED}): container steps run on other executors"
13
+ exec sleep infinity
14
+ ;;
15
+ esac
16
+
17
+ # No connection key yet: the executor beside the platform comes up with it,
18
+ # before anyone could make one there — said once, then idle, instead of
19
+ # exiting into a restart loop. The key (made by hand on the platform by an
20
+ # Infrastructure Engineer, or by the person for their own executor) turns it
21
+ # on at the next start.
22
+ if [ -z "$(printf '%s' "${EXECUTOR_TOKEN:-}" | tr -d '[:space:]')" ]; then
23
+ echo "[executor] EXECUTOR_TOKEN is not set — this executor is off; make a connection key on the platform, set it as EXECUTOR_TOKEN and restart"
24
+ exec sleep infinity
25
+ fi
26
+
27
+ # Fill the harness volume with this image's bundle, then run the executor.
28
+ # The volume is what run containers mount at /harness; refreshing it at every
29
+ # start is how a new executor image reaches the runs.
30
+ BUNDLE_DIR="${HARNESS_BUNDLE_DIR:-/harness}"
31
+ if [ -d "$BUNDLE_DIR" ]; then
32
+ echo "[executor] refreshing the harness bundle in $BUNDLE_DIR"
33
+ rm -rf "$BUNDLE_DIR/packages" "$BUNDLE_DIR/node_modules" "$BUNDLE_DIR/package.json" "$BUNDLE_DIR/bin"
34
+ cp -R /app/packages /app/node_modules /app/package.json "$BUNDLE_DIR/"
35
+ # This image's own node, so a run image needs none: a glibc image runs the
36
+ # harness on it; an Alpine one uses its own node (see harnessNodeCommand).
37
+ mkdir -p "$BUNDLE_DIR/bin"
38
+ cp "$(command -v node)" "$BUNDLE_DIR/bin/node"
39
+ fi
40
+
41
+ exec node /app/packages/ael/node_modules/tsx/dist/cli.mjs /app/packages/ael/src/cli/main.ts executor "$@"
@@ -0,0 +1,37 @@
1
+ # The executor: one container per server, polling the platform's Temporal
2
+ # and running workflow steps as containers on this host's engine. Built from
3
+ # the repository root:
4
+ #
5
+ # docker build -f packages/ael/executor.Dockerfile -t bevel-executor .
6
+ #
7
+ # It carries the harness bundle (this library, its dependencies, tsx) and
8
+ # copies it into the `bevel-harness` volume at start, with this image's own
9
+ # node, which every run container then mounts read-only at /harness — so a
10
+ # glibc run image needs nothing installed (an Alpine one brings its own Node
11
+ # 22). The engine's socket is mounted into THIS container only, never into a
12
+ # run container. With PLATFORM_URL set it is a person's own executor, asking
13
+ # the platform over HTTPS for their steps (docker-compose.personal-executor.yml).
14
+ FROM node:22-slim
15
+
16
+ RUN corepack enable && corepack prepare pnpm@10.33.3 --activate
17
+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gnupg \
18
+ && install -m 0755 -d /etc/apt/keyrings \
19
+ && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
20
+ && chmod a+r /etc/apt/keyrings/docker.asc \
21
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list \
22
+ && apt-get update && apt-get install -y --no-install-recommends docker-ce-cli \
23
+ && rm -rf /var/lib/apt/lists/*
24
+
25
+ WORKDIR /app
26
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
27
+ COPY packages/ael/package.json packages/ael/
28
+ # The backend and frontend are workspace members pnpm must see to resolve the lockfile.
29
+ COPY packages/backend/package.json packages/backend/
30
+ COPY packages/frontend/package.json packages/frontend/
31
+ RUN pnpm install --frozen-lockfile --prod --filter @bevel-software/ael...
32
+ COPY packages/ael/ packages/ael/
33
+ COPY packages/ael/executor-entrypoint.sh /usr/local/bin/executor-entrypoint
34
+ RUN chmod +x /usr/local/bin/executor-entrypoint
35
+
36
+ ENV HARNESS_BUNDLE_VOLUME=bevel-harness
37
+ ENTRYPOINT ["executor-entrypoint"]
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
- {
2
- "name": "@bevel-software/ael",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@bevel-software/ael",
3
+ "version": "0.2.0",
4
+ "description": "The agentic execution layer of the Bevel platform: the one parser for .agent and .workflow files, the run contract, the harnesses, the interpreter the platform runs on Temporal, the executor, and the bevel-agent command line.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/Bevel-Software/bevel-platform.git",
9
+ "directory": "packages/ael"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "LICENSE",
16
+ "bin",
17
+ "src",
18
+ "!src/**/__tests__",
19
+ "executor.Dockerfile",
20
+ "executor-entrypoint.sh",
21
+ "README.md"
22
+ ],
23
+ "type": "module",
24
+ "main": "./src/index.ts",
25
+ "types": "./src/index.ts",
26
+ "exports": {
27
+ ".": "./src/index.ts",
28
+ "./workflows": "./src/interpreter/workflows.ts",
29
+ "./schedule": "./src/shared/schedule.ts",
30
+ "./contract": "./src/run/contract.ts",
31
+ "./cli": "./src/cli/main.ts"
32
+ },
33
+ "bin": {
34
+ "bevel-agent": "./bin/bevel-agent.mjs"
35
+ },
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "vitest run"
39
+ },
40
+ "dependencies": {
41
+ "@anthropic-ai/claude-code": "2.1.319",
42
+ "@bevel-software/hexis-mcp": "0.37.0",
43
+ "@earendil-works/pi-ai": "1.4.0",
44
+ "@earendil-works/pi-coding-agent": "1.4.0",
45
+ "@temporalio/activity": "1.25.0",
46
+ "@temporalio/client": "1.25.0",
47
+ "@temporalio/common": "1.25.0",
48
+ "@temporalio/worker": "1.25.0",
49
+ "@temporalio/workflow": "1.25.0",
50
+ "tsx": "^4.19.0",
51
+ "yaml": "^2.9.0"
52
+ },
53
+ "devDependencies": {
54
+ "@modelcontextprotocol/sdk": "^1.29.0",
55
+ "@temporalio/testing": "1.25.0",
56
+ "@types/node": "^22.0.0",
57
+ "typescript": "^5.8.0",
58
+ "vitest": "^2.1.9",
59
+ "zod": "^3.24.0"
60
+ }
61
+ }
@@ -0,0 +1,242 @@
1
+ import { optionalStr, plainObject, yamlDocumentOf } from '../shared/front-matter.js';
2
+
3
+ /**
4
+ * The one parser of an `.agent` file: *who* an agent is.
5
+ *
6
+ * It replaces three readers that each took the keys their question needed —
7
+ * the execution layer's (harness, model, tools, env), the platform's `env:`
8
+ * allowlist reader (agent-defs) and the connect-as reader of the four narrowing
9
+ * lists (agent-scope). One definition now carries every field; a caller acts
10
+ * on the part it owns (the platform on `env:` and the narrowing lists, a
11
+ * harness on the model and the tool surface) and ignores the rest.
12
+ *
13
+ * Same document shape as `.workflow` and `.tool`: one `---` fenced YAML block
14
+ * with the access verbs inside it, prose after the fence.
15
+ */
16
+
17
+ export const AGENT_FILE_EXTENSION = '.agent';
18
+
19
+ /** Where an `env:` entry's value comes from. */
20
+ export type EnvSource = 'params' | 'vault';
21
+
22
+ export interface AgentEnvVar {
23
+ name: string;
24
+ from: EnvSource;
25
+ /**
26
+ * `from: params` only: the run parameter to read when it is not spelled like
27
+ * the variable (`STAGING_URL` reads `stagingUrl`). Defaults to the name.
28
+ */
29
+ param?: string;
30
+ /** `from: vault` only: the label the secrets panel shows for the entry. */
31
+ label?: string;
32
+ }
33
+
34
+ /** The harnesses a session may run on. `pi` runs in-process or in a container; `claude-code` in a container. */
35
+ export type HarnessName = 'pi' | 'claude-code';
36
+
37
+ export interface AgentDefinition {
38
+ /** Lowercase snake_case id — also the vault namespace its secrets live under. */
39
+ id: string;
40
+ /** One line, at most `AGENT_NAME_MAX` characters. */
41
+ name: string;
42
+ /** One line, at most `AGENT_DESCRIPTION_MAX` characters; empty when there is none. */
43
+ description: string;
44
+ harness: HarnessName;
45
+ /**
46
+ * In the harness's own spelling: a Claude model id for `claude-code`;
47
+ * `provider/id`, optionally `:<thinking>`, for `pi`. Absent means the
48
+ * deployment's default model on its gateway.
49
+ */
50
+ model?: string;
51
+ /**
52
+ * `pi` only: pi's `models.json` `providers` map, verbatim, for a provider
53
+ * pi's catalog does not carry. Keys, never values: `"$NAME"` reads the
54
+ * session's environment, which is the `env:` block below.
55
+ */
56
+ providers?: Record<string, unknown>;
57
+ systemPrompt?: { skill?: string; extend?: string };
58
+ /** Skill folders the agent may load (a parent folder means every skill under it). */
59
+ skills: string[];
60
+ /** Plugin folders whose tools and MCP servers come along. */
61
+ plugins: string[];
62
+ /** `.tool` files and MCP server definitions (a folder means every connector under it). */
63
+ connectors: string[];
64
+ /**
65
+ * Names of the platform's own tools the agent may call; null when the key is
66
+ * not written (every platform tool), empty when written empty (none).
67
+ */
68
+ platformTools: string[] | null;
69
+ /** Tool names the HARNESS may enable (`tools.allow`, or `harnessTools`). Empty means the harness default. */
70
+ harnessTools: string[];
71
+ env: AgentEnvVar[];
72
+ }
73
+
74
+ /** An `.agent` that cannot be read as a definition. The message never quotes the file's secrets. */
75
+ export class AgentParseError extends Error {}
76
+
77
+ /** The longest `name` an agent is shown by. */
78
+ export const AGENT_NAME_MAX = 80;
79
+ /** The longest `description` an agent is shown with. */
80
+ export const AGENT_DESCRIPTION_MAX = 300;
81
+ /**
82
+ * How many `env:` entries one file may declare. Author-written input: a cap
83
+ * keeps a mistaken paste from turning the secrets panel into a wall.
84
+ */
85
+ export const MAX_ENV_VARIABLES = 50;
86
+
87
+ const SUPPORTED_HARNESSES: readonly HarnessName[] = ['pi', 'claude-code'];
88
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
89
+
90
+ function obj(value: unknown, what: string): Record<string, unknown> {
91
+ return plainObject(value, what, (message) => new AgentParseError(message));
92
+ }
93
+
94
+ /** A frontmatter string folded to one trimmed line of at most `max` characters; empty for anything else. */
95
+ function foldLine(value: unknown, max: number): string {
96
+ if (typeof value !== 'string') return '';
97
+ return value.replace(/\s+/g, ' ').trim().slice(0, max).trim();
98
+ }
99
+
100
+ /** A list written as a YAML list or, for one entry, as a plain string. Anything else is an error, never "empty". */
101
+ function entries(root: Record<string, unknown>, key: string): string[] {
102
+ const value = root[key];
103
+ if (value === undefined || value === null) return [];
104
+ if (typeof value === 'string') return [value];
105
+ if (Array.isArray(value) && value.every((v) => typeof v === 'string')) return value as string[];
106
+ throw new AgentParseError(`\`${key}\` must be a list of names`);
107
+ }
108
+
109
+ function names(root: Record<string, unknown>, key: string): string[] {
110
+ return entries(root, key).map((v) => v.trim()).filter(Boolean);
111
+ }
112
+
113
+ function pathList(root: Record<string, unknown>, key: string): string[] {
114
+ return entries(root, key).flatMap((raw) => {
115
+ const normalized = normalizeEntryPath(raw);
116
+ return normalized === null ? [] : [normalized];
117
+ });
118
+ }
119
+
120
+ /**
121
+ * An entry as a repo-relative path with no leading or trailing slash, or null
122
+ * when it names nothing that can be matched (empty, or climbing out). A `.`
123
+ * segment names the folder it is in, so `./Skills/x` is `Skills/x`.
124
+ */
125
+ export function normalizeEntryPath(raw: string): string | null {
126
+ const p = raw.trim().replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '').replace(/\/{2,}/g, '/');
127
+ if (!p) return null;
128
+ const segments = p.split('/').filter((s) => s !== '.');
129
+ if (segments.length === 0 || segments.some((s) => s === '..')) return null;
130
+ return segments.join('/');
131
+ }
132
+
133
+ /** `delivery-coder` → `delivery_coder`. The vault namespace; must stay injective enough for a listing. */
134
+ export function agentSlug(raw: string): string {
135
+ const slug = raw.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
136
+ if (!slug || /^[0-9]/.test(slug)) return `agent_${slug}`;
137
+ return slug;
138
+ }
139
+
140
+ function parseEnv(value: unknown): AgentEnvVar[] {
141
+ if (value === undefined || value === null) return [];
142
+ if (!Array.isArray(value)) throw new AgentParseError('`env` must be an array');
143
+ if (value.length > MAX_ENV_VARIABLES) {
144
+ throw new AgentParseError(`\`env\` declares ${value.length} variables (max ${MAX_ENV_VARIABLES})`);
145
+ }
146
+ const seen = new Set<string>();
147
+ return value.map((raw, i) => {
148
+ const e = obj(raw, `\`env[${i}]\``);
149
+ const name = optionalStr(e.name) ?? '';
150
+ // POSIX environment variable grammar: anything else cannot be exported to a
151
+ // process reliably, whatever the file says.
152
+ if (!ENV_NAME.test(name)) throw new AgentParseError(`\`env[${i}].name\` "${name}" must match [A-Za-z_][A-Za-z0-9_]*`);
153
+ if (seen.has(name)) throw new AgentParseError(`duplicate env variable "${name}"`);
154
+ seen.add(name);
155
+ const from = optionalStr(e.from)?.toLowerCase();
156
+ if (from !== 'params' && from !== 'vault') {
157
+ throw new AgentParseError(`env variable "${name}" needs \`from: params\` or \`from: vault\``);
158
+ }
159
+ const param = optionalStr(e.param);
160
+ if (param !== undefined && from !== 'params') {
161
+ throw new AgentParseError(`env variable "${name}": \`param\` only applies to \`from: params\``);
162
+ }
163
+ const label = optionalStr(e.label);
164
+ if (label !== undefined && from !== 'vault') {
165
+ throw new AgentParseError(`env variable "${name}": \`label\` only applies to \`from: vault\``);
166
+ }
167
+ return { name, from, ...(param ? { param } : {}), ...(label ? { label } : {}) };
168
+ });
169
+ }
170
+
171
+ /**
172
+ * Parse an `.agent` file. `path` is the file's repo-relative path; it supplies
173
+ * the fallback name and id and appears in error messages.
174
+ */
175
+ export function parseAgentFile(content: string, path: string): AgentDefinition {
176
+ let document: unknown;
177
+ try {
178
+ document = yamlDocumentOf(content);
179
+ } catch (err) {
180
+ throw new AgentParseError(`\`.agent\` ${path} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`);
181
+ }
182
+ const root = obj(document, `\`.agent\` ${path}`);
183
+
184
+ const file = path.slice(path.lastIndexOf('/') + 1);
185
+ const base = file.endsWith(AGENT_FILE_EXTENSION) ? file.slice(0, -AGENT_FILE_EXTENSION.length) : file;
186
+ const name = foldLine(root.name, AGENT_NAME_MAX) || foldLine(base, AGENT_NAME_MAX);
187
+ const id = optionalStr(root.id) ?? agentSlug(name);
188
+ if (!/^[a-z0-9][a-z0-9_]*$/.test(id)) {
189
+ throw new AgentParseError(`\`.agent\` ${path}: id "${id}" must be lowercase snake_case`);
190
+ }
191
+
192
+ const harness = (optionalStr(root.harness) ?? 'pi') as HarnessName;
193
+ if (!SUPPORTED_HARNESSES.includes(harness)) {
194
+ // Loudly, at load time: a harness nothing can run is a configuration
195
+ // error, and the first live step is the wrong place to find it.
196
+ throw new AgentParseError(
197
+ `\`.agent\` ${path} declares harness "${harness}" (supported: ${SUPPORTED_HARNESSES.join(', ')})`,
198
+ );
199
+ }
200
+
201
+ const model = optionalStr(root.model);
202
+ let providers: Record<string, unknown> | undefined;
203
+ if (root.providers !== undefined) {
204
+ if (harness !== 'pi') {
205
+ throw new AgentParseError(`\`.agent\` ${path}: \`providers\` is pi's models.json and applies to harness pi only`);
206
+ }
207
+ providers = obj(root.providers, `\`.agent\` ${path} \`providers\``);
208
+ }
209
+
210
+ let systemPrompt: AgentDefinition['systemPrompt'];
211
+ if (root.systemPrompt !== undefined) {
212
+ const sp = obj(root.systemPrompt, `\`.agent\` ${path} \`systemPrompt\``);
213
+ const skill = optionalStr(sp.skill);
214
+ const extend = optionalStr(sp.extend);
215
+ systemPrompt = { ...(skill ? { skill } : {}), ...(extend ? { extend } : {}) };
216
+ }
217
+
218
+ // `tools.allow` is the spelling the execution layer used; `harnessTools` the
219
+ // one the platform's agent guide documents. Both are read, neither required.
220
+ const tools = root.tools === undefined ? {} : obj(root.tools, `\`.agent\` ${path} \`tools\``);
221
+ const harnessTools = [...names(tools, 'allow'), ...names(root, 'harnessTools')];
222
+
223
+ return {
224
+ id,
225
+ name,
226
+ description: foldLine(root.description, AGENT_DESCRIPTION_MAX),
227
+ harness,
228
+ ...(model ? { model } : {}),
229
+ ...(providers ? { providers } : {}),
230
+ ...(systemPrompt ? { systemPrompt } : {}),
231
+ skills: pathList(root, 'skills').map((p) => p.replace(/\/SKILL\.md$/i, '')),
232
+ plugins: pathList(root, 'plugins'),
233
+ connectors: pathList(root, 'connectors'),
234
+ // Only a key that is not written at all means every platform tool. A key
235
+ // written with no value (`platformTools:`, YAML null) or as `[]` is the
236
+ // author clearing the list: an agent that meant to offer nothing must
237
+ // never be read as offering everything.
238
+ platformTools: Object.prototype.hasOwnProperty.call(root, 'platformTools') ? names(root, 'platformTools') : null,
239
+ harnessTools,
240
+ env: parseEnv(root.env),
241
+ };
242
+ }
@@ -0,0 +1,81 @@
1
+ import { pathToFileURL } from 'node:url';
2
+ import type { ContainerStepInput } from '../interpreter/contract.js';
3
+ import { formatEvent, runLocally } from './run-local.js';
4
+ import { OUTCOME_MARKER, readAll, runStep } from './run-step.js';
5
+
6
+ /**
7
+ * `bevel-agent`: the library's command line.
8
+ *
9
+ * bevel-agent run <file> [input]
10
+ * a workflow file run here, against a platform:
11
+ * BEVEL_API_URL (the API root) and BEVEL_CONNECTION_KEY
12
+ * in the environment, BEVEL_GATEWAY_MODEL optional.
13
+ * Agent steps only, on the happy path; the file's
14
+ * `env:` from this environment by name.
15
+ * bevel-agent run-step one container step: the prepared input on stdin,
16
+ * the outcome as the marked last line of stdout.
17
+ * What an executor execs inside a run container.
18
+ * bevel-agent executor an executor on this host: polls the executor
19
+ * queues and runs container steps on its engine.
20
+ * bevel-agent ci-executor
21
+ * the executor job of a CI pipeline: runs no step,
22
+ * starts a worker pipeline per run with work.
23
+ * bevel-agent ci-worker <run id>
24
+ * a worker job of a CI pipeline: that run's steps,
25
+ * in the job itself, until it waits or is over.
26
+ *
27
+ * Two doors in: `bin/bevel-agent.mjs` (what npm installs; registers tsx and
28
+ * calls `main`), and this file as the script tsx runs directly (the executor
29
+ * image and the run containers).
30
+ */
31
+ export async function main(argv: string[]): Promise<number> {
32
+ const [command] = argv;
33
+ switch (command) {
34
+ case 'run': {
35
+ const file = argv[1];
36
+ const apiUrl = process.env.BEVEL_API_URL?.trim();
37
+ const token = process.env.BEVEL_CONNECTION_KEY?.trim();
38
+ if (!file || !apiUrl || !token) {
39
+ process.stderr.write('usage: BEVEL_API_URL=<api root> BEVEL_CONNECTION_KEY=<key> bevel-agent run <file.workflow> [input]\n');
40
+ return 2;
41
+ }
42
+ const result = await runLocally(file, {
43
+ apiUrl,
44
+ token,
45
+ gatewayModel: process.env.BEVEL_GATEWAY_MODEL?.trim() || undefined,
46
+ input: argv.slice(2).join(' ') || undefined,
47
+ onEvent: (step, event) => process.stderr.write(`${formatEvent(step, event)}\n`),
48
+ });
49
+ for (const step of result.steps) process.stdout.write(`${step.name}: ${step.outcome}${step.reason ? ` — ${step.reason}` : ''}\n`);
50
+ process.stdout.write(`${result.status}\n`);
51
+ return result.status === 'done' ? 0 : 1;
52
+ }
53
+ case 'run-step': {
54
+ const input = JSON.parse(await readAll(process.stdin)) as ContainerStepInput;
55
+ const outcome = await runStep(input, { cwd: process.cwd(), log: (line) => process.stderr.write(`[run-step] ${line}\n`) });
56
+ process.stdout.write(`${OUTCOME_MARKER}${JSON.stringify(outcome)}\n`);
57
+ return 0;
58
+ }
59
+ case 'executor':
60
+ case 'ci-executor':
61
+ case 'ci-worker': {
62
+ const { executorMain } = await import('../executor/main.js');
63
+ return executorMain(command, argv[1]);
64
+ }
65
+ default:
66
+ process.stderr.write('usage: bevel-agent run <file> [input] | run-step | executor | ci-executor | ci-worker <run id>\n');
67
+ return 2;
68
+ }
69
+ }
70
+
71
+ // Run only when this file IS the script (`tsx main.ts …`); imported by the
72
+ // bin wrapper or a test, it does nothing on its own.
73
+ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
74
+ main(process.argv.slice(2)).then(
75
+ (code) => process.exit(code),
76
+ (err) => {
77
+ process.stderr.write(`${err instanceof Error ? (err.stack ?? err.message) : String(err)}\n`);
78
+ process.exit(1);
79
+ },
80
+ );
81
+ }