@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
@@ -0,0 +1,28 @@
1
+ import { ParentClosePolicy, executeChild, proxyActivities, uuid4 } from '@temporalio/workflow';
2
+ import { PLATFORM_TASK_QUEUE, type InterpreterActivities } from './contract.js';
3
+ import { interpret } from './interpret.workflow.js';
4
+
5
+ const platform = proxyActivities<Pick<InterpreterActivities, 'startTriggeredRun'>>({
6
+ taskQueue: PLATFORM_TASK_QUEUE,
7
+ startToCloseTimeout: '5 minutes',
8
+ retry: { maximumAttempts: 3, initialInterval: '5 seconds' },
9
+ });
10
+
11
+ /**
12
+ * What a Temporal schedule starts: one workflow per firing that asks the
13
+ * platform to prepare the run (thread, work item) and then runs the
14
+ * interpreter as its child, waiting for it. The wait is the point: the
15
+ * schedule's overlap policy is buffer-one, and it judges overlap by THIS
16
+ * workflow — a firing that lands while the run is still going is kept and
17
+ * starts right after it, a second one is dropped. Were this workflow to end
18
+ * once the run had merely started, every firing would overlap a run in
19
+ * flight. Abandoned on close, so a schedule deleted mid-run (the trigger
20
+ * paused) leaves the run to finish. The run's id is chosen here, replay-safe,
21
+ * so the preparing activity's retries make one run, not one per attempt.
22
+ */
23
+ export async function startScheduledRun(input: { triggerId: string }): Promise<{ runId: string | null }> {
24
+ const { run } = await platform.startTriggeredRun({ triggerId: input.triggerId, firedAt: new Date().toISOString(), runId: uuid4() });
25
+ if (!run) return { runId: null };
26
+ await executeChild(interpret, { workflowId: `run-${run.runId}`, args: [run], taskQueue: PLATFORM_TASK_QUEUE, parentClosePolicy: ParentClosePolicy.ABANDON });
27
+ return { runId: run.runId };
28
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The workflows a Temporal worker registers: point `workflowsPath` at this
3
+ * file. Everything it imports is sandbox-safe (no `node:*`, no third-party
4
+ * modules besides `@temporalio/workflow`).
5
+ */
6
+ export { cancelRunSignal, gateOutcomeSignal, interpret } from './interpret.workflow.js';
7
+ export { dispatch, dispatcherRunsQuery, dispatcherStateQuery, enqueueSignal, settingsSignal, stopDispatcherSignal, type DispatchInput, type DispatchSettings, type DispatcherState, type Enqueued } from './dispatcher.workflow.js';
8
+ export { startScheduledRun } from './scheduled.workflow.js';
@@ -0,0 +1,150 @@
1
+ import type { AgentDefinition } from '../agent/agent-file.js';
2
+
3
+ /**
4
+ * The run contract: what the platform hands a harness to run one step, and
5
+ * what every harness answers in the same shape. Nothing downstream — the
6
+ * interpreter's failure policy, the thread store, the live view — knows which
7
+ * harness ran.
8
+ */
9
+
10
+ export type Verdict = 'PASS' | 'FAIL' | 'BLOCKED';
11
+
12
+ /** What a run is given: the trigger's input, or a step's instructions. */
13
+ export interface RunInput {
14
+ /** The trigger's words: a webhook body, Run now text, the kb node, the `ask` prompt. Not a place for a secret — nothing redacts it; a configuration's `env:` is where secrets go. */
15
+ text: string;
16
+ images?: Array<{ mediaType: string; dataBase64: string }>;
17
+ }
18
+
19
+ /**
20
+ * The run key: a connection key minted for the actor and scoped to the step's
21
+ * configuration. The only credential a run holds. The harness uses it for the
22
+ * platform's MCP endpoint and, in-process, for the model gateway.
23
+ */
24
+ export interface ActorKey {
25
+ token: string;
26
+ /** The key's id, for the audit log and for revocation at run end. */
27
+ keyId?: string;
28
+ }
29
+
30
+ /** Where the platform is, from the session's point of view. */
31
+ export interface PlatformEndpoints {
32
+ /** The platform's MCP endpoint (streamable HTTP). Tools are called as the actor through it. */
33
+ mcpUrl: string;
34
+ /** The platform's OpenAI-compatible model gateway, authenticated with the run key. */
35
+ gatewayUrl: string;
36
+ /** The gateway model a configuration without `model` runs on. */
37
+ gatewayModel: string;
38
+ /** The thinking level such a session runs with (off | minimal | low | medium | high); off when absent. A configuration's `model: …:<level>` overrides it. */
39
+ gatewayThinking?: string;
40
+ /** The context window of the gateway model, in tokens, as the platform's model configuration reports it; 200k when absent. The output budget is cut to what fits, so a window smaller than the model's starves it. */
41
+ gatewayContextWindow?: number;
42
+ /**
43
+ * In a run container: the local Hexis MCP server the session reaches its
44
+ * tools through instead of `mcpUrl`, as the execution layer always ran it.
45
+ * It serves the platform's tools (still executed on the platform, so the
46
+ * vault never moves) and materializes and runs the LOCAL ones in the
47
+ * container — `git` among them, its credential resolved where it runs and
48
+ * never in the session's environment. Absent in the platform process,
49
+ * where a session has no shell to run a local tool in.
50
+ */
51
+ localMcp?: LocalMcpServer;
52
+ }
53
+
54
+ /** A stdio MCP server: the command, and its environment (the key travels here, never in argv). */
55
+ export interface LocalMcpServer {
56
+ command: string;
57
+ args: string[];
58
+ env: Record<string, string>;
59
+ }
60
+
61
+ /** The name sessions see the knowledge base's MCP server under; tool ids are `mcp__knowledge-base__<tool>`, as in the execution layer. */
62
+ export const MCP_SERVER_NAME = 'knowledge-base';
63
+
64
+ /**
65
+ * The harness's own session state, opaque to everyone but the harness that
66
+ * wrote it. Stored on the thread after every turn; a crashed or moved step is
67
+ * restored from it, anywhere.
68
+ */
69
+ export interface SessionState {
70
+ harness: string;
71
+ version: number;
72
+ data: unknown;
73
+ }
74
+
75
+ /**
76
+ * The event vocabulary the thread store keeps. Every harness translates its
77
+ * own stream into this one; the transcript renderer and the live view read
78
+ * nothing else. `at` is an ISO timestamp.
79
+ */
80
+ export type RunEvent =
81
+ | { type: 'session_started'; at: string; harness: string; model?: string; sessionId?: string; resumed: boolean; /** The tools the session may call, built-in and platform, once the harness has activated them. */ tools?: string[] }
82
+ | { type: 'user'; at: string; text: string }
83
+ | { type: 'assistant'; at: string; text: string }
84
+ /**
85
+ * What the model weighed before one of its messages, as its provider summarised it (when the gateway asked for a
86
+ * summary): kept with the run so a reply can be read against its reasons afterwards. Never part of the reply.
87
+ */
88
+ | { type: 'reasoning'; at: string; text: string }
89
+ | { type: 'tool_call'; at: string; id: string; name: string; input: unknown }
90
+ | { type: 'tool_result'; at: string; id: string; name: string; output: unknown; isError: boolean }
91
+ | { type: 'verdict'; at: string; verdict: Verdict }
92
+ | { type: 'outputs'; at: string; values: Record<string, unknown> }
93
+ | { type: 'error'; at: string; message: string }
94
+ | { type: 'session_ended'; at: string; ok: boolean; finalText: string; usage?: { input?: number; output?: number } }
95
+ /** The run moved: on the RUN's own thread, one per transition the interpreter records — the step entered, a step's verdict, a gate parked on, the end. */
96
+ | { type: 'transition'; at: string; log: string; status: string; step: string | null; waiting: boolean };
97
+
98
+ export interface SessionStartParams {
99
+ input: RunInput;
100
+ systemPrompt: string;
101
+ /** The configuration the session runs as. The harness reads its own concerns from it. */
102
+ agent: AgentDefinition;
103
+ actorKey: ActorKey;
104
+ platform: PlatformEndpoints;
105
+ /** The configuration's `env:` block, resolved. Visible to the agent by construction; the caller redacts it. */
106
+ env?: Record<string, string>;
107
+ /** A working directory, for harnesses that have one. In-process sessions have none. */
108
+ cwd?: string;
109
+ /** The key the caller follows the session by (the run's id, not a step thread's id): a message handed to a running session finds it by this. */
110
+ runKey?: string;
111
+ /** The state to resume from, when the thread holds one. */
112
+ state?: SessionState;
113
+ timeoutMs?: number;
114
+ /** Fires when the run is cancelled: the session is aborted where it is. */
115
+ signal?: AbortSignal;
116
+ /** Ask once for a `RESULT:` line when the session ends cleanly without one. */
117
+ expectVerdict?: boolean;
118
+ /** Every event as it happens, in order. The caller stores it on the thread. */
119
+ onEvent?: (event: RunEvent) => void;
120
+ /** The state after every turn, for the thread. The caller stores it; the harness never persists it. */
121
+ onState?: (state: SessionState) => void;
122
+ }
123
+
124
+ export interface SessionResult {
125
+ ok: boolean;
126
+ /** The session exceeded its budget — a `Timeout`, not a failed step. */
127
+ timedOut?: boolean;
128
+ finalText: string;
129
+ verdict?: Verdict;
130
+ outputs?: Record<string, unknown>;
131
+ /**
132
+ * The provider refused the ACCOUNT, not this step: a subscription or rate
133
+ * limit. Never a step failure; the executor pauses until `resetAt` and the
134
+ * work item is left as it was.
135
+ */
136
+ accountLimit?: { kind: 'session-limit' | 'not-logged-in'; resetAt?: number; text: string };
137
+ /** The harness produced no event at all: the step did not run, nothing here is evidence about the item. */
138
+ sessionNotStarted?: boolean;
139
+ /** The state to store, so the next turn or a resumed step continues from here. */
140
+ state?: SessionState;
141
+ events: RunEvent[];
142
+ }
143
+
144
+ /** Something that can run one step as a session. One per harness. */
145
+ export interface Harness {
146
+ readonly name: string;
147
+ run(params: SessionStartParams): Promise<SessionResult>;
148
+ /** Hand a message to the session running under `runKey`: answered in that session's own reply. 'absent' when none runs here. */
149
+ followUp?(runKey: string, text: string): Promise<'queued' | 'absent'>;
150
+ }
@@ -0,0 +1,63 @@
1
+ import type { AgentStepOutcome } from '../interpreter/contract.js';
2
+ import type { Redactor } from '../shared/redact.js';
3
+ import type { SessionResult, Verdict } from './contract.js';
4
+
5
+ /**
6
+ * A session's result as the interpreter reads it. One rule for every place a
7
+ * step runs — the platform's own worker, a container on an executor — so a
8
+ * step's fate never depends on where it ran.
9
+ */
10
+ export function outcomeFromResult(result: SessionResult, opts: { threadId: string; expectVerdict: boolean; redactor: Redactor }): AgentStepOutcome {
11
+ const { threadId, redactor } = opts;
12
+ if (result.accountLimit) {
13
+ return { kind: 'account-limit', threadId, resetAt: result.accountLimit.resetAt ?? null, text: redactor.text(result.accountLimit.text) };
14
+ }
15
+ if (result.sessionNotStarted) {
16
+ return { kind: 'harness-error', threadId, reason: 'the session produced no event' };
17
+ }
18
+ if (result.timedOut) {
19
+ return { kind: 'timeout', threadId, reason: 'the step exceeded its budget' };
20
+ }
21
+ if (!result.ok) {
22
+ const error = result.events.find((e) => e.type === 'error');
23
+ return { kind: 'harness-error', threadId, reason: redactor.text(error && error.type === 'error' ? error.message : 'the session ended with an error') };
24
+ }
25
+ // A step with no verdict line declared (the short form) passes when its
26
+ // session ends cleanly; one that declared a verdict must give it.
27
+ const verdict: Verdict | undefined = result.verdict ?? (opts.expectVerdict ? undefined : 'PASS');
28
+ if (!verdict) {
29
+ return { kind: 'no-verdict', threadId, reason: 'the session ended without a RESULT line' };
30
+ }
31
+ return {
32
+ kind: 'verdict',
33
+ verdict,
34
+ threadId,
35
+ outputs: result.outputs ? redactor.json(result.outputs) : null,
36
+ reason:
37
+ verdict === 'PASS'
38
+ ? null
39
+ : redactor.text(
40
+ result.finalText
41
+ .split(/RESULT:\s*(?:FAIL|BLOCKED)/i)
42
+ .pop()
43
+ ?.replace(/^\s*[—-]\s*/, '')
44
+ .trim() || `verdict ${verdict}`,
45
+ ),
46
+ };
47
+ }
48
+
49
+ /** The thread status an outcome leaves a step thread in. */
50
+ export function threadStatusOf(outcome: AgentStepOutcome): 'running' | 'waiting' | 'done' | 'failed' | 'blocked' {
51
+ switch (outcome.kind) {
52
+ case 'verdict':
53
+ return outcome.verdict === 'PASS' ? 'done' : outcome.verdict === 'BLOCKED' ? 'blocked' : 'failed';
54
+ case 'blocked':
55
+ return 'blocked';
56
+ case 'account-limit':
57
+ return 'waiting';
58
+ case 'no-verdict':
59
+ case 'timeout':
60
+ case 'harness-error':
61
+ return 'failed';
62
+ }
63
+ }
@@ -0,0 +1,127 @@
1
+ import type { RunEvent, SessionState } from './contract.js';
2
+
3
+ /**
4
+ * How a session outside the platform process reports to it: every event and
5
+ * every turn's state go to the platform's run API with the run key, so the
6
+ * thread store is the record wherever the step ran, and a moved or restarted
7
+ * step restores from there.
8
+ *
9
+ * Events are sent in order, one request after the other, so the store's
10
+ * sequence is the session's; the state is sent after the events that led to
11
+ * it. A request that fails is retried a few times; a request that keeps
12
+ * failing, or that the platform refuses for good, is logged and dropped
13
+ * rather than stalling the session — and remembered: the thread is the
14
+ * record, and `flush` says when the record is incomplete, so the step is
15
+ * not reported finished with its answer or its saved context gone.
16
+ */
17
+ /** The HTTP call the reporter makes: `fetch`, narrowed to what the reporter uses of it. */
18
+ export type FetchLike = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; status: number }>;
19
+
20
+ export interface RunReporterOptions {
21
+ /** The platform's API root, e.g. `https://kb.example.com/api`. */
22
+ apiUrl: string;
23
+ /** The step thread the events and the state belong to. */
24
+ threadId: string;
25
+ /** The run key. */
26
+ token: string;
27
+ fetch?: FetchLike;
28
+ log?: (line: string) => void;
29
+ /** Attempts per request before it is dropped. */
30
+ attempts?: number;
31
+ /** Backoff base in milliseconds. */
32
+ backoffMs?: number;
33
+ }
34
+
35
+ export class PlatformRunReporter {
36
+ private queue: Promise<void> = Promise.resolve();
37
+ private pending: RunEvent[] = [];
38
+ private flushing = false;
39
+ /** The first report that did not land, kept for `flush` to tell. */
40
+ private lost: string | null = null;
41
+ private readonly fetchImpl: FetchLike;
42
+ private readonly log: (line: string) => void;
43
+ private readonly attempts: number;
44
+ private readonly backoffMs: number;
45
+ private readonly sleep: (ms: number) => Promise<void>;
46
+
47
+ constructor(
48
+ private readonly options: RunReporterOptions,
49
+ sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
50
+ ) {
51
+ this.fetchImpl = options.fetch ?? fetch;
52
+ this.log = options.log ?? (() => undefined);
53
+ this.attempts = options.attempts ?? 4;
54
+ this.backoffMs = options.backoffMs ?? 500;
55
+ this.sleep = sleep;
56
+ }
57
+
58
+ /** Queue an event; events are batched into one request per flush, in order. */
59
+ onEvent(event: RunEvent): void {
60
+ this.pending.push(event);
61
+ if (!this.flushing) this.enqueue(() => this.flushEvents());
62
+ }
63
+
64
+ /** Send the state after everything queued before it. */
65
+ onState(state: SessionState): void {
66
+ this.enqueue(async () => {
67
+ await this.flushEvents();
68
+ await this.send('PUT', `/agents/runs/${encodeURIComponent(this.options.threadId)}/state`, state);
69
+ });
70
+ }
71
+
72
+ /**
73
+ * Wait until everything reported so far has been sent or dropped; throws
74
+ * when any report was dropped, naming the first, since a thread missing
75
+ * events or state is not the step's record.
76
+ */
77
+ async flush(): Promise<void> {
78
+ this.enqueue(() => this.flushEvents());
79
+ await this.queue;
80
+ if (this.lost) throw new Error(`a report did not reach the platform (${this.lost})`);
81
+ }
82
+
83
+ private enqueue(task: () => Promise<void>): void {
84
+ this.queue = this.queue.then(task, task);
85
+ }
86
+
87
+ private async flushEvents(): Promise<void> {
88
+ if (this.pending.length === 0) return;
89
+ this.flushing = true;
90
+ try {
91
+ while (this.pending.length > 0) {
92
+ const batch = this.pending;
93
+ this.pending = [];
94
+ await this.send('POST', `/agents/runs/${encodeURIComponent(this.options.threadId)}/events`, { events: batch });
95
+ }
96
+ } finally {
97
+ this.flushing = false;
98
+ }
99
+ }
100
+
101
+ private async send(method: 'POST' | 'PUT', path: string, body: unknown): Promise<void> {
102
+ const url = `${this.options.apiUrl.replace(/\/$/, '')}${path}`;
103
+ for (let attempt = 1; attempt <= this.attempts; attempt++) {
104
+ try {
105
+ const res = await this.fetchImpl(url, {
106
+ method,
107
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.options.token}` },
108
+ body: JSON.stringify(body),
109
+ });
110
+ if (res.ok) return;
111
+ // The platform refused this report for good (the key is gone, the
112
+ // thread is not ours): retrying would not change the answer.
113
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
114
+ this.log(`${method} ${path}: ${res.status} — dropped`);
115
+ this.lost ??= `${method} ${path}: ${res.status}`;
116
+ return;
117
+ }
118
+ this.log(`${method} ${path}: ${res.status} (attempt ${attempt})`);
119
+ } catch (err) {
120
+ this.log(`${method} ${path}: ${err instanceof Error ? err.message : String(err)} (attempt ${attempt})`);
121
+ }
122
+ if (attempt < this.attempts) await this.sleep(this.backoffMs * 2 ** (attempt - 1));
123
+ }
124
+ this.log(`${method} ${path}: gave up after ${this.attempts} attempts`);
125
+ this.lost ??= `${method} ${path}: gave up after ${this.attempts} attempts`;
126
+ }
127
+ }
@@ -0,0 +1,35 @@
1
+ import type { SystemPromptSpec } from '../workflow/definition.js';
2
+
3
+ /**
4
+ * A step's system prompt, composed the one way wherever the step runs: the
5
+ * configuration's `systemPrompt` (a skill, an extension, or both), then the
6
+ * step's own, each skill read from the knowledge base — as the actor on the
7
+ * platform, from the checkout in a local run. A skill that cannot be read
8
+ * is a refusal, not an omission: running without it would be a different
9
+ * step from the one the file describes.
10
+ */
11
+ export class SkillNotReadable extends Error {
12
+ constructor(readonly skill: string) {
13
+ super(`skill ${skill} is not there, or not readable`);
14
+ }
15
+ }
16
+
17
+ export async function composeSystemPrompt(
18
+ agent: { name: string; description: string; systemPrompt?: SystemPromptSpec },
19
+ step: { systemPrompt?: SystemPromptSpec | null },
20
+ /** A repo-relative file's text, or null when it is not there or not readable by whoever asks. */
21
+ readRepoFile: (repoPath: string) => Promise<string | null>,
22
+ ): Promise<string> {
23
+ const parts: string[] = [];
24
+ for (const spec of [agent.systemPrompt, step.systemPrompt]) {
25
+ if (!spec) continue;
26
+ if (spec.skill) {
27
+ const skill = (await readRepoFile(`${spec.skill.replace(/\/$/, '')}/SKILL.md`)) ?? (await readRepoFile(spec.skill));
28
+ if (skill === null) throw new SkillNotReadable(spec.skill);
29
+ parts.push(skill.replace(/^---[\s\S]*?\n---\n/, '').trim());
30
+ }
31
+ if (spec.extend) parts.push(spec.extend);
32
+ }
33
+ if (parts.length === 0) parts.push(`You are ${agent.name}. ${agent.description}`.trim());
34
+ return parts.join('\n\n');
35
+ }
@@ -0,0 +1,46 @@
1
+ import type { Verdict } from './contract.js';
2
+
3
+ /**
4
+ * What a session that finished without a verdict is asked. It names the three
5
+ * legal lines and forbids further work: the session has done its step, and
6
+ * the only thing missing is the sentence the interpreter routes on.
7
+ */
8
+ export const VERDICT_NUDGE =
9
+ 'Your last message did not end with the verdict line this step requires, so the runner cannot tell how it went. ' +
10
+ 'Do no further work and call no tools. Judge the work you just finished and reply with exactly one line: ' +
11
+ '`RESULT: PASS`, `RESULT: FAIL — <reason>` or `RESULT: BLOCKED — <reason>`.';
12
+
13
+ /** Extract the last `RESULT: PASS|FAIL|BLOCKED` line the agent emitted. */
14
+ export function parseVerdict(text: string): Verdict | undefined {
15
+ const matches = [...text.matchAll(/RESULT:\s*(PASS|FAIL|BLOCKED)/gi)];
16
+ if (matches.length === 0) return undefined;
17
+ return matches[matches.length - 1][1].toUpperCase() as Verdict;
18
+ }
19
+
20
+ /**
21
+ * The step's `OUTPUTS`: a fenced JSON object after the word `OUTPUTS`, the
22
+ * last one in the text when there are several. Null when there is none or it
23
+ * does not parse as an object; a step that wanted to record something and got
24
+ * the fence wrong is logged by the caller, not failed.
25
+ */
26
+ export function parseOutputs(text: string): Record<string, unknown> | null {
27
+ const matches = [...text.matchAll(/OUTPUTS[^\n]*\n```(?:json)?\s*\n([\s\S]*?)\n```/gi)];
28
+ if (matches.length === 0) return null;
29
+ try {
30
+ const parsed: unknown = JSON.parse(matches[matches.length - 1][1]);
31
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function tail(text: string, max = 2000): string {
38
+ const t = text.trim();
39
+ return t.length > max ? t.slice(-max) : t || '(no output)';
40
+ }
41
+
42
+ /** The START of what a session that never ran had to say: the cause of an early exit is its first complaint. */
43
+ export function head(text: string, max = 2000): string {
44
+ const t = text.trim();
45
+ return t.length > max ? t.slice(0, max) : t;
46
+ }
@@ -0,0 +1,27 @@
1
+ import { parse as parseYaml } from 'yaml';
2
+
3
+ /**
4
+ * The document shape every file this library reads shares — `.agent` and
5
+ * `.workflow` alike (and `.tool`, which the platform reads): one `---` fenced
6
+ * YAML block, prose after the fence. A file without a fence is read as YAML
7
+ * whole. The one place the fence is spelled, so the two parsers cannot drift
8
+ * on what counts as a document.
9
+ */
10
+ const FENCE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
11
+
12
+ /** The YAML document of a fenced file (or of the whole text when there is no fence). Throws the YAML parser's own error. */
13
+ export function yamlDocumentOf(content: string): unknown {
14
+ const m = FENCE.exec(content);
15
+ return parseYaml(m ? m[1] : content);
16
+ }
17
+
18
+ /** A non-empty string value, trimmed; `undefined` for anything else. */
19
+ export function optionalStr(value: unknown): string | undefined {
20
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
21
+ }
22
+
23
+ /** A plain object, or the parser's own error (`fail`) for anything else. */
24
+ export function plainObject(value: unknown, what: string, fail: (message: string) => Error): Record<string, unknown> {
25
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw fail(`${what} must be an object`);
26
+ return value as Record<string, unknown>;
27
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Scrubs injected secret values out of anything that gets persisted.
3
+ *
4
+ * Load-bearing, not tidy. A run's events are stored and shown, and an `.agent`
5
+ * may have injected vault values into the session's environment — values the
6
+ * agent can read by construction. A value that reaches a stored event can
7
+ * only be remediated by rotating it.
8
+ *
9
+ * Exact substring replacement, deliberately: a pattern-matching redactor misses
10
+ * every credential shaped differently from the patterns someone thought of,
11
+ * and this one knows exactly which strings it injected.
12
+ */
13
+
14
+ /**
15
+ * Values shorter than this are not redacted. A three-character password would
16
+ * turn every event into confetti and hide the context needed to debug a run;
17
+ * the fix for a short secret is a longer secret.
18
+ */
19
+ export const MIN_REDACTABLE_LENGTH = 8;
20
+
21
+ export class Redactor {
22
+ private readonly values: string[];
23
+ /**
24
+ * The JSON spelling of each value whose spelling differs from the value
25
+ * (a quote, a backslash or a control character in it): what the value
26
+ * looks like inside serialised JSON, where `values` would not match it.
27
+ */
28
+ private readonly escaped: string[];
29
+
30
+ constructor(values: Iterable<string>) {
31
+ // Longest first: a value that contains another must be replaced before the
32
+ // shorter one eats part of it and leaves a recognisable tail behind.
33
+ this.values = [...new Set([...values].filter((v) => v && v.length >= MIN_REDACTABLE_LENGTH))].sort(
34
+ (a, b) => b.length - a.length,
35
+ );
36
+ this.escaped = this.values.map((v) => JSON.stringify(v).slice(1, -1)).filter((e, i) => e !== this.values[i]);
37
+ }
38
+
39
+ /** Replace every occurrence of every known value with a marker. */
40
+ text(input: string): string {
41
+ let out = input;
42
+ for (const value of this.values) out = out.split(value).join('[redacted]');
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Redact through a JSON structure, keys and values alike: serialise, scrub
48
+ * (the values' JSON spellings first, then the values), parse back, so a
49
+ * secret anywhere in a tool input or output is caught. A structure JSON
50
+ * cannot carry as it is (a cycle, a BigInt) is serialised with those parts
51
+ * replaced, never returned as it came: unredacted is the one outcome this
52
+ * must not have.
53
+ */
54
+ json<T>(input: T): T {
55
+ if (this.values.length === 0 || input === undefined) return input;
56
+ let serialised = safeStringify(input);
57
+ for (const value of this.escaped) serialised = serialised.split(value).join('[redacted]');
58
+ return JSON.parse(this.text(serialised)) as T;
59
+ }
60
+ }
61
+
62
+ /** `JSON.stringify` that never throws: a cycle becomes a marker, a BigInt its digits. */
63
+ function safeStringify(input: unknown): string {
64
+ const seen = new WeakSet<object>();
65
+ return (
66
+ JSON.stringify(input, (_key, value: unknown) => {
67
+ if (typeof value === 'bigint') return value.toString();
68
+ if (value && typeof value === 'object') {
69
+ if (seen.has(value)) return '[cycle]';
70
+ seen.add(value);
71
+ }
72
+ return value;
73
+ }) ?? 'null'
74
+ );
75
+ }
76
+
77
+ /** A redactor that knows nothing — used where no values were injected. */
78
+ export const NO_REDACTION = new Redactor([]);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The grammar of a schedule trigger's two keys — durations, cron lines and
3
+ * ISO date-times — in one place, read by the platform that validates a
4
+ * trigger and by the app that edits one, so neither mirrors the other.
5
+ * Browser-safe on purpose: `Intl` and regular expressions, nothing of Node.
6
+ */
7
+
8
+ const DURATION = /^\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?\s*$/i;
9
+ const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/i;
10
+ const CRON_SHORTHAND = /^@(yearly|annually|monthly|weekly|daily|hourly)$/i;
11
+
12
+ /** `15m`, `1h`, `2h30m`, `1d`, `90s` → milliseconds; null for anything else. */
13
+ export function durationMs(text: string): number | null {
14
+ const m = DURATION.exec(text);
15
+ if (!m || !m.slice(1).some(Boolean)) return null;
16
+ const [d, h, min, s] = m.slice(1).map((v) => Number(v ?? 0));
17
+ return (((d! * 24 + h!) * 60 + min!) * 60 + s!) * 1000;
18
+ }
19
+
20
+ /** Whether `text` is a duration: the one spelling of a start or a repeat that needs no time zone. */
21
+ export function isDuration(text: string): boolean {
22
+ return durationMs(text) !== null;
23
+ }
24
+
25
+ /** Whether `text` is a cron line Temporal reads: five to seven fields, or one of the `@` words. */
26
+ export function isCron(text: string): boolean {
27
+ const fields = text.trim().split(/\s+/).length;
28
+ return CRON_SHORTHAND.test(text.trim()) || (fields >= 5 && fields <= 7);
29
+ }
30
+
31
+ /** Whether `name` is a time zone this runtime knows (an IANA name such as `Europe/Berlin`, or `UTC`). */
32
+ export function isTimeZone(name: string): boolean {
33
+ try {
34
+ new Intl.DateTimeFormat('en-US', { timeZone: name });
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /** A wall-clock date-time, as an ISO string spells it; `offset` is `Z` or `±HH:MM` when the text carries one, null when it is to be read in some zone. */
42
+ export interface IsoDateTime {
43
+ year: number;
44
+ month: number;
45
+ day: number;
46
+ hour: number;
47
+ minute: number;
48
+ second: number;
49
+ offset: string | null;
50
+ }
51
+
52
+ /** `2026-11-20T09:00`, `2026-11-20T09:00:00.000+01:00` → its parts; null for anything else. */
53
+ export function parseIsoDateTime(text: string): IsoDateTime | null {
54
+ const m = ISO_DATE_TIME.exec(text.trim());
55
+ if (!m) return null;
56
+ return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]), hour: Number(m[4]), minute: Number(m[5]), second: Number(m[6] ?? 0), offset: m[7] ?? null };
57
+ }
58
+
59
+ /**
60
+ * The instant a wall-clock time names in a zone — what `new Date()` cannot
61
+ * tell without a library: the zone's offset at that moment is found by
62
+ * formatting a first guess in the zone and correcting by the difference,
63
+ * twice, which settles across a DST change.
64
+ */
65
+ export function wallClockToInstant(parts: Omit<IsoDateTime, 'offset'>, timeZone: string): Date {
66
+ const wanted = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
67
+ let guess = wanted;
68
+ for (let i = 0; i < 2; i++) guess -= zonedUtcMs(guess, timeZone) - wanted;
69
+ return new Date(guess);
70
+ }
71
+
72
+ /** The wall clock in `timeZone` at `instantMs`, as if it were UTC — for the offset arithmetic above. */
73
+ function zonedUtcMs(instantMs: number, timeZone: string): number {
74
+ const fields = new Intl.DateTimeFormat('en-US', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(new Date(instantMs));
75
+ const n = (type: string) => Number(fields.find((f) => f.type === type)?.value ?? 0);
76
+ return Date.UTC(n('year'), n('month') - 1, n('day'), n('hour') % 24, n('minute'), n('second'));
77
+ }
78
+
79
+ /**
80
+ * An ISO date-time's absolute instant: its own offset when it carries one,
81
+ * `timeZone` otherwise. Null when the text is not a date-time, or carries no
82
+ * offset and no zone was given.
83
+ */
84
+ export function isoToInstant(text: string, timeZone: string | undefined): Date | null {
85
+ const parts = parseIsoDateTime(text);
86
+ if (!parts) return null;
87
+ if (parts.offset) {
88
+ const parsed = Date.parse(text.trim());
89
+ return Number.isNaN(parsed) ? null : new Date(parsed);
90
+ }
91
+ if (!timeZone) return null;
92
+ return wallClockToInstant(parts, timeZone);
93
+ }