@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.
- package/LICENSE +13 -0
- package/README.md +32 -3
- package/bin/bevel-agent.mjs +15 -0
- package/executor-entrypoint.sh +41 -0
- package/executor.Dockerfile +37 -0
- package/package.json +61 -6
- package/src/agent/agent-file.ts +242 -0
- package/src/cli/main.ts +81 -0
- package/src/cli/run-local.ts +224 -0
- package/src/cli/run-step.ts +147 -0
- package/src/executor/activities.ts +295 -0
- package/src/executor/docker-cli.ts +148 -0
- package/src/executor/driver.ts +80 -0
- package/src/executor/harness-mount.ts +66 -0
- package/src/executor/in-place.ts +180 -0
- package/src/executor/main.ts +230 -0
- package/src/executor/personal.ts +355 -0
- package/src/executor/slots.ts +60 -0
- package/src/harness/claude-code.ts +397 -0
- package/src/harness/pi-in-process.ts +444 -0
- package/src/harness/session-state.ts +86 -0
- package/src/harness/stall-guard.ts +97 -0
- package/src/index.ts +164 -0
- package/src/interpreter/contract.ts +403 -0
- package/src/interpreter/dispatcher.workflow.ts +412 -0
- package/src/interpreter/interpret.workflow.ts +907 -0
- package/src/interpreter/scheduled.workflow.ts +28 -0
- package/src/interpreter/workflows.ts +8 -0
- package/src/run/contract.ts +150 -0
- package/src/run/outcome.ts +63 -0
- package/src/run/reporter.ts +127 -0
- package/src/run/system-prompt.ts +35 -0
- package/src/run/verdict.ts +46 -0
- package/src/shared/front-matter.ts +27 -0
- package/src/shared/redact.ts +78 -0
- package/src/shared/schedule.ts +93 -0
- package/src/temporal/payload-codec.ts +84 -0
- package/src/transcript/render.ts +106 -0
- package/src/workflow/definition.ts +196 -0
- package/src/workflow/failure-policy.ts +100 -0
- package/src/workflow/parameters.ts +175 -0
- package/src/workflow/workflow-file.ts +457 -0
- 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
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/cli/main.ts
ADDED
|
@@ -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
|
+
}
|