@boardwalk-labs/engine 0.1.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 +202 -0
- package/README.md +69 -0
- package/bin/boardwalk-server.js +16 -0
- package/dist/agent/conversation.d.ts +42 -0
- package/dist/agent/conversation.js +4 -0
- package/dist/agent/leaf.d.ts +81 -0
- package/dist/agent/leaf.js +190 -0
- package/dist/agent/providers.d.ts +23 -0
- package/dist/agent/providers.js +347 -0
- package/dist/agent/rates.d.ts +13 -0
- package/dist/agent/rates.js +35 -0
- package/dist/agent/redact.d.ts +9 -0
- package/dist/agent/redact.js +27 -0
- package/dist/agent/resolve.d.ts +58 -0
- package/dist/agent/resolve.js +153 -0
- package/dist/agent/sse.d.ts +2 -0
- package/dist/agent/sse.js +30 -0
- package/dist/agent/tools.d.ts +57 -0
- package/dist/agent/tools.js +324 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +32 -0
- package/dist/cron/cron.d.ts +34 -0
- package/dist/cron/cron.js +331 -0
- package/dist/engine.d.ts +106 -0
- package/dist/engine.js +183 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +40 -0
- package/dist/ids.d.ts +7 -0
- package/dist/ids.js +42 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/json_value.d.ts +7 -0
- package/dist/json_value.js +29 -0
- package/dist/mcp/client.d.ts +39 -0
- package/dist/mcp/client.js +112 -0
- package/dist/mcp/jsonrpc.d.ts +57 -0
- package/dist/mcp/jsonrpc.js +117 -0
- package/dist/mcp/oauth.d.ts +72 -0
- package/dist/mcp/oauth.js +337 -0
- package/dist/mcp/token_store.d.ts +30 -0
- package/dist/mcp/token_store.js +101 -0
- package/dist/mcp/transport_http.d.ts +38 -0
- package/dist/mcp/transport_http.js +143 -0
- package/dist/mcp/transport_stdio.d.ts +27 -0
- package/dist/mcp/transport_stdio.js +94 -0
- package/dist/run/child.d.ts +1 -0
- package/dist/run/child.js +139 -0
- package/dist/run/child_host.d.ts +26 -0
- package/dist/run/child_host.js +124 -0
- package/dist/run/idempotency.d.ts +5 -0
- package/dist/run/idempotency.js +31 -0
- package/dist/run/ipc.d.ts +159 -0
- package/dist/run/ipc.js +150 -0
- package/dist/run/run_dir.d.ts +31 -0
- package/dist/run/run_dir.js +106 -0
- package/dist/run/supervisor.d.ts +107 -0
- package/dist/run/supervisor.js +676 -0
- package/dist/scheduler/scheduler.d.ts +54 -0
- package/dist/scheduler/scheduler.js +215 -0
- package/dist/server/http.d.ts +42 -0
- package/dist/server/http.js +183 -0
- package/dist/server/routes/api.d.ts +17 -0
- package/dist/server/routes/api.js +107 -0
- package/dist/server/routes/hooks.d.ts +2 -0
- package/dist/server/routes/hooks.js +88 -0
- package/dist/server/routes/router.d.ts +15 -0
- package/dist/server/routes/router.js +75 -0
- package/dist/server/routes/stream.d.ts +2 -0
- package/dist/server/routes/stream.js +79 -0
- package/dist/server/routes/ui.d.ts +2 -0
- package/dist/server/routes/ui.js +120 -0
- package/dist/server/server.d.ts +25 -0
- package/dist/server/server.js +67 -0
- package/dist/server_main.d.ts +46 -0
- package/dist/server_main.js +203 -0
- package/dist/store/migrations.d.ts +21 -0
- package/dist/store/migrations.js +159 -0
- package/dist/store/store.d.ts +194 -0
- package/dist/store/store.js +567 -0
- package/package.json +57 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Server-Sent Events parsing, shared by the provider adapters (streamed model turns) and the
|
|
2
|
+
// MCP streamable-HTTP transport (a server may answer any POST with an SSE stream). One parser
|
|
3
|
+
// so the two consumers can't drift on framing edge cases (CRLF, split chunks, [DONE]).
|
|
4
|
+
/** Iterate the `data:` payloads of an SSE response body. */
|
|
5
|
+
export async function* sseDataLines(response) {
|
|
6
|
+
const body = response.body;
|
|
7
|
+
if (body === null)
|
|
8
|
+
return;
|
|
9
|
+
const decoder = new TextDecoder();
|
|
10
|
+
let buffer = "";
|
|
11
|
+
// Why the explicit AsyncIterable<unknown>: undici types the stream's chunks as `any`;
|
|
12
|
+
// narrowing each chunk keeps the no-unsafe rules honest.
|
|
13
|
+
const chunks = body;
|
|
14
|
+
for await (const chunk of chunks) {
|
|
15
|
+
if (!(chunk instanceof Uint8Array))
|
|
16
|
+
continue;
|
|
17
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
18
|
+
let newline = buffer.indexOf("\n");
|
|
19
|
+
while (newline >= 0) {
|
|
20
|
+
const line = buffer.slice(0, newline).trimEnd();
|
|
21
|
+
buffer = buffer.slice(newline + 1);
|
|
22
|
+
if (line.startsWith("data:")) {
|
|
23
|
+
const data = line.slice(5).trim();
|
|
24
|
+
if (data.length > 0 && data !== "[DONE]")
|
|
25
|
+
yield data;
|
|
26
|
+
}
|
|
27
|
+
newline = buffer.indexOf("\n");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AgentOptions, McpServerRef } from "@boardwalk-labs/workflow";
|
|
2
|
+
import type { ToolSpec } from "./conversation.js";
|
|
3
|
+
import type { Redactor } from "./redact.js";
|
|
4
|
+
/** A tool the loop can actually run. `execute` resolves to model-bound text (pre-redaction). */
|
|
5
|
+
export interface ExecutableTool extends ToolSpec {
|
|
6
|
+
execute(input: Record<string, unknown>): Promise<string>;
|
|
7
|
+
}
|
|
8
|
+
export interface ToolSetContext {
|
|
9
|
+
/** The run's working directory (memory dirs are workspace-relative). */
|
|
10
|
+
workspaceDir: string;
|
|
11
|
+
/** Where this workflow's deployed skills live, or null when none were deployed. */
|
|
12
|
+
skillsDir: string | null;
|
|
13
|
+
}
|
|
14
|
+
export interface ToolSet {
|
|
15
|
+
tools: ExecutableTool[];
|
|
16
|
+
/** Context blocks (skills, memory index) prepended to the first user message. */
|
|
17
|
+
preamble: string[];
|
|
18
|
+
/** The memory dir the call uses (workspace-relative) — the engine auto-persists it. */
|
|
19
|
+
memoryDir: string | null;
|
|
20
|
+
/** Shape-validated MCP server refs; connecting them is the async step (connectMcpServers). */
|
|
21
|
+
mcp: readonly McpServerRef[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the call's per-agent capability selection into an executable tool set. Sync by
|
|
25
|
+
* design — every selection is shape-validated here so misconfiguration fails BEFORE anything
|
|
26
|
+
* spawns a process or opens a connection; the async MCP step is `connectMcpServers`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildToolSet(opts: AgentOptions | undefined, ctx: ToolSetContext): ToolSet;
|
|
29
|
+
/** Tool names must be unique across the WHOLE advertised set — providers reject duplicates. */
|
|
30
|
+
export declare function assertUniqueToolNames(tools: readonly ExecutableTool[]): void;
|
|
31
|
+
/** What the engine answered when the child asked for an MCP bearer token (see ipc.ts). */
|
|
32
|
+
export interface McpTokenResult {
|
|
33
|
+
accessToken: string | null;
|
|
34
|
+
hint?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The child-side effects MCP connection needs (the OAuth broker hook + the redactor). */
|
|
37
|
+
export interface McpConnectIo {
|
|
38
|
+
/** Broker a bearer token from the engine; `invalidateToken` names a just-rejected token. */
|
|
39
|
+
mcpToken(serverUrl: string, invalidateToken?: string): Promise<McpTokenResult>;
|
|
40
|
+
/** Brokered tokens are credentials — register them so they can never reach model context. */
|
|
41
|
+
redactor: Redactor;
|
|
42
|
+
}
|
|
43
|
+
export interface ConnectedMcpServers {
|
|
44
|
+
tools: ExecutableTool[];
|
|
45
|
+
/** Tear down every connection (kill stdio children, DELETE HTTP sessions). Never throws. */
|
|
46
|
+
disconnect(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The async half of MCP capability assembly: connect each validated ref, list its tools, and
|
|
50
|
+
* wrap them as ExecutableTools named `<server>__<tool>`. Runs in the program process (tool
|
|
51
|
+
* calls execute here); OAuth token STATE stays parent-side behind `io.mcpToken`. Connection
|
|
52
|
+
* or listing failure fails the call loudly — per the capability-presence rule, a server the
|
|
53
|
+
* agent named must resolve. Callers must invoke `disconnect()` in a finally.
|
|
54
|
+
*/
|
|
55
|
+
export declare function connectMcpServers(refs: readonly McpServerRef[], io: McpConnectIo): Promise<ConnectedMcpServers>;
|
|
56
|
+
/** Workspace-relative, no `..`/`.`/empty segments, no leading slash. */
|
|
57
|
+
export declare const MEMORY_PATH_RE: RegExp;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Capability assembly for an agent() call (SDK SPEC §2.1.1). Capabilities are PER-AGENT
|
|
2
|
+
// (decided 2026-06-11): each call brings its own tools/skills/memory — there is nothing to
|
|
3
|
+
// check against the manifest, but everything the call names must RESOLVE (fail loudly —
|
|
4
|
+
// never silently degrade).
|
|
5
|
+
//
|
|
6
|
+
// Trust model: tool `execute` runs in the program process (the trusted layer); only RETURN
|
|
7
|
+
// VALUES enter model context, and the loop redacts them. Memory tools are filesystem-contained
|
|
8
|
+
// to their directory — model-chosen paths are untrusted input.
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { EngineError } from "../errors.js";
|
|
13
|
+
import { McpConnection } from "../mcp/client.js";
|
|
14
|
+
import { HttpTransport } from "../mcp/transport_http.js";
|
|
15
|
+
import { StdioTransport } from "../mcp/transport_stdio.js";
|
|
16
|
+
/**
|
|
17
|
+
* Built-in tools this engine implements, selected by name. Deliberately empty in v0: the
|
|
18
|
+
* hosted platform's curated built-ins don't exist locally yet, and the capability-presence
|
|
19
|
+
* rule demands a loud failure over a silent stub. Program-defined ToolDefs and memory tools
|
|
20
|
+
* cover the local story.
|
|
21
|
+
*/
|
|
22
|
+
const BUILTIN_TOOLS = new Map();
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the call's per-agent capability selection into an executable tool set. Sync by
|
|
25
|
+
* design — every selection is shape-validated here so misconfiguration fails BEFORE anything
|
|
26
|
+
* spawns a process or opens a connection; the async MCP step is `connectMcpServers`.
|
|
27
|
+
*/
|
|
28
|
+
export function buildToolSet(opts, ctx) {
|
|
29
|
+
const mcp = validateMcpRefs(opts?.mcp);
|
|
30
|
+
const tools = [];
|
|
31
|
+
const preamble = [];
|
|
32
|
+
for (const entry of opts?.tools ?? []) {
|
|
33
|
+
if (typeof entry === "string") {
|
|
34
|
+
tools.push(resolveBuiltinTool(entry));
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
tools.push(wrapProgramTool(entry));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const name of opts?.skills ?? []) {
|
|
41
|
+
preamble.push(loadSkill(name, ctx));
|
|
42
|
+
}
|
|
43
|
+
let memoryDir = null;
|
|
44
|
+
if (opts?.memory !== undefined) {
|
|
45
|
+
const memory = resolveMemoryDir(opts.memory, ctx);
|
|
46
|
+
tools.push(...memoryTools(memory.absoluteDir, opts.memory));
|
|
47
|
+
preamble.push(memoryIndex(memory.absoluteDir, opts.memory));
|
|
48
|
+
memoryDir = opts.memory;
|
|
49
|
+
}
|
|
50
|
+
assertUniqueToolNames(tools);
|
|
51
|
+
return { tools, preamble, memoryDir, mcp };
|
|
52
|
+
}
|
|
53
|
+
/** Tool names must be unique across the WHOLE advertised set — providers reject duplicates. */
|
|
54
|
+
export function assertUniqueToolNames(tools) {
|
|
55
|
+
const seen = new Set();
|
|
56
|
+
for (const tool of tools) {
|
|
57
|
+
if (seen.has(tool.name)) {
|
|
58
|
+
throw new EngineError("VALIDATION", `Duplicate tool name in agent() call: "${tool.name}".`);
|
|
59
|
+
}
|
|
60
|
+
seen.add(tool.name);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// ----------------------------------------------------------------------------
|
|
64
|
+
// Built-in names + program-defined tools
|
|
65
|
+
// ----------------------------------------------------------------------------
|
|
66
|
+
function resolveBuiltinTool(name) {
|
|
67
|
+
const builtin = BUILTIN_TOOLS.get(name);
|
|
68
|
+
if (builtin === undefined) {
|
|
69
|
+
throw new EngineError("UNSUPPORTED", `Built-in tool "${name}" is not available on this engine.`, "This engine ships no built-in tools yet — define the tool in your program (an inline " +
|
|
70
|
+
"ToolDef with an execute function) for identical behavior on every engine.");
|
|
71
|
+
}
|
|
72
|
+
return builtin;
|
|
73
|
+
}
|
|
74
|
+
function wrapProgramTool(def) {
|
|
75
|
+
if (def.name.length === 0) {
|
|
76
|
+
throw new EngineError("VALIDATION", "A program-defined tool has an empty name.");
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
name: def.name,
|
|
80
|
+
description: def.description,
|
|
81
|
+
inputSchema: def.inputSchema,
|
|
82
|
+
async execute(input) {
|
|
83
|
+
const result = await def.execute(input);
|
|
84
|
+
if (result === undefined || result === null)
|
|
85
|
+
return "";
|
|
86
|
+
return typeof result === "string" ? result : JSON.stringify(result);
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
// ----------------------------------------------------------------------------
|
|
91
|
+
// MCP servers (inline McpServerRefs — stdio + streamable HTTP)
|
|
92
|
+
// ----------------------------------------------------------------------------
|
|
93
|
+
// Server names prefix tool names (`<server>__<tool>`) — keep them tool-name-shaped.
|
|
94
|
+
const MCP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
95
|
+
// AgentOptions comes straight from user program code — the TS types are aspirational at
|
|
96
|
+
// runtime, so each ref is Zod-checked before anything spawns or connects (CODE_QUALITY §2.1).
|
|
97
|
+
const mcpServerRefSchema = z.discriminatedUnion("transport", [
|
|
98
|
+
z.strictObject({
|
|
99
|
+
name: z.string().regex(MCP_NAME_RE),
|
|
100
|
+
transport: z.literal("stdio"),
|
|
101
|
+
command: z.string().min(1),
|
|
102
|
+
args: z.array(z.string()).optional(),
|
|
103
|
+
env: z.record(z.string(), z.string()).optional(),
|
|
104
|
+
}),
|
|
105
|
+
z.strictObject({
|
|
106
|
+
name: z.string().regex(MCP_NAME_RE),
|
|
107
|
+
transport: z.literal("http"),
|
|
108
|
+
url: z
|
|
109
|
+
.string()
|
|
110
|
+
.min(1)
|
|
111
|
+
.refine((value) => /^https?:\/\//.test(value), { error: "must be an http(s) URL" }),
|
|
112
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
113
|
+
}),
|
|
114
|
+
]);
|
|
115
|
+
function validateMcpRefs(refs) {
|
|
116
|
+
const out = refs ?? [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
for (const ref of out) {
|
|
119
|
+
const parsed = mcpServerRefSchema.safeParse(ref);
|
|
120
|
+
if (!parsed.success) {
|
|
121
|
+
throw new EngineError("VALIDATION", `agent() got a malformed MCP server ref${typeof ref.name === "string" ? ` "${ref.name}"` : ""}: ` +
|
|
122
|
+
`${parsed.error.issues.map((issue) => issue.message).join("; ")}.`, 'An MCP server is { name, transport: "stdio", command, args?, env? } or ' +
|
|
123
|
+
'{ name, transport: "http", url, headers? }.');
|
|
124
|
+
}
|
|
125
|
+
if (seen.has(ref.name)) {
|
|
126
|
+
throw new EngineError("VALIDATION", `Duplicate MCP server name in agent() call: "${ref.name}".`);
|
|
127
|
+
}
|
|
128
|
+
seen.add(ref.name);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The async half of MCP capability assembly: connect each validated ref, list its tools, and
|
|
134
|
+
* wrap them as ExecutableTools named `<server>__<tool>`. Runs in the program process (tool
|
|
135
|
+
* calls execute here); OAuth token STATE stays parent-side behind `io.mcpToken`. Connection
|
|
136
|
+
* or listing failure fails the call loudly — per the capability-presence rule, a server the
|
|
137
|
+
* agent named must resolve. Callers must invoke `disconnect()` in a finally.
|
|
138
|
+
*/
|
|
139
|
+
export async function connectMcpServers(refs, io) {
|
|
140
|
+
const connections = [];
|
|
141
|
+
const tools = [];
|
|
142
|
+
const disconnect = async () => {
|
|
143
|
+
for (const connection of connections) {
|
|
144
|
+
try {
|
|
145
|
+
await connection.close();
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Teardown is best-effort: a dead server must not mask the run's real outcome.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
for (const ref of refs) {
|
|
154
|
+
const connection = new McpConnection(transportFor(ref, io), { serverName: ref.name });
|
|
155
|
+
connections.push(connection);
|
|
156
|
+
await connection.initialize();
|
|
157
|
+
for (const tool of await connection.listTools()) {
|
|
158
|
+
tools.push({
|
|
159
|
+
name: `${ref.name}__${tool.name}`,
|
|
160
|
+
description: tool.description,
|
|
161
|
+
inputSchema: tool.inputSchema,
|
|
162
|
+
execute: async (input) => {
|
|
163
|
+
const result = await connection.callTool(tool.name, input);
|
|
164
|
+
// isError throws so the loop's standard tool-failure path (tool_call_error event,
|
|
165
|
+
// error result back to the model) handles MCP and program tools identically.
|
|
166
|
+
if (result.isError) {
|
|
167
|
+
throw new EngineError("PROVIDER_ERROR", result.content.length > 0
|
|
168
|
+
? result.content
|
|
169
|
+
: `MCP tool "${tool.name}" reported an error with no content.`);
|
|
170
|
+
}
|
|
171
|
+
return result.content;
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
await disconnect(); // never leak spawned server processes when a later server fails
|
|
179
|
+
throw err;
|
|
180
|
+
}
|
|
181
|
+
return { tools, disconnect };
|
|
182
|
+
}
|
|
183
|
+
function transportFor(ref, io) {
|
|
184
|
+
if (ref.transport === "stdio") {
|
|
185
|
+
return new StdioTransport({
|
|
186
|
+
serverName: ref.name,
|
|
187
|
+
command: ref.command,
|
|
188
|
+
args: ref.args,
|
|
189
|
+
env: ref.env,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return new HttpTransport({
|
|
193
|
+
serverName: ref.name,
|
|
194
|
+
url: ref.url,
|
|
195
|
+
headers: ref.headers,
|
|
196
|
+
// Program-supplied headers are the first line of credentials; this hook only ever fires
|
|
197
|
+
// after the server answered 401 (the transport owns that escalation order).
|
|
198
|
+
acquireToken: async (failedToken) => {
|
|
199
|
+
const result = await io.mcpToken(ref.url, failedToken ?? undefined);
|
|
200
|
+
if (result.accessToken === null) {
|
|
201
|
+
throw new EngineError("PROVIDER_ERROR", `MCP server "${ref.name}" (${ref.url}) requires OAuth authorization and this engine ` +
|
|
202
|
+
"holds no usable token.", result.hint ?? `Authorize once with engine.authorizeMcpServer("${ref.url}").`);
|
|
203
|
+
}
|
|
204
|
+
io.redactor.add(`mcp:${ref.name}`, result.accessToken);
|
|
205
|
+
return result.accessToken;
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// ----------------------------------------------------------------------------
|
|
210
|
+
// Skills
|
|
211
|
+
// ----------------------------------------------------------------------------
|
|
212
|
+
function loadSkill(name, ctx) {
|
|
213
|
+
// Skill names become file names — keep them shape-safe before touching the filesystem.
|
|
214
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) {
|
|
215
|
+
throw new EngineError("VALIDATION", `Skill name "${name}" is not a valid skill name.`);
|
|
216
|
+
}
|
|
217
|
+
const path = ctx.skillsDir === null ? null : join(ctx.skillsDir, `${name}.md`);
|
|
218
|
+
if (path === null || !existsSync(path)) {
|
|
219
|
+
throw new EngineError("VALIDATION", `agent() selected skill "${name}" but no skills/${name}.md was deployed with this workflow.`, `Deploy the workflow with a skills/${name}.md file alongside the program.`);
|
|
220
|
+
}
|
|
221
|
+
return `<skill name="${name}">\n${readFileSync(path, "utf8")}\n</skill>`;
|
|
222
|
+
}
|
|
223
|
+
// ----------------------------------------------------------------------------
|
|
224
|
+
// Memory (a persistent workspace directory + scoped file tools — not a separate system)
|
|
225
|
+
// ----------------------------------------------------------------------------
|
|
226
|
+
/** Workspace-relative, no `..`/`.`/empty segments, no leading slash. */
|
|
227
|
+
export const MEMORY_PATH_RE = /^(?!.*(?:^|\/)\.\.?(?:\/|$))[^/\\].*$/;
|
|
228
|
+
function resolveMemoryDir(memory, ctx) {
|
|
229
|
+
// Per-agent memory needs NO declaration — but the path is runtime input and must be a
|
|
230
|
+
// clean workspace-relative directory (the engine auto-persists exactly this path).
|
|
231
|
+
if (!MEMORY_PATH_RE.test(memory) || memory.includes("\\")) {
|
|
232
|
+
throw new EngineError("VALIDATION", `agent() memory path "${memory}" must be a workspace-relative directory without "..".`);
|
|
233
|
+
}
|
|
234
|
+
return { absoluteDir: join(ctx.workspaceDir, memory) };
|
|
235
|
+
}
|
|
236
|
+
/** Contain a model-chosen relative path inside the memory dir (untrusted input). */
|
|
237
|
+
function containedPath(baseDir, relativePath) {
|
|
238
|
+
const candidate = resolve(baseDir, relativePath);
|
|
239
|
+
if (candidate !== baseDir && !candidate.startsWith(baseDir + sep)) {
|
|
240
|
+
throw new EngineError("VALIDATION", `Memory path escapes the memory directory.`);
|
|
241
|
+
}
|
|
242
|
+
return candidate;
|
|
243
|
+
}
|
|
244
|
+
const memoryReadInput = z.object({ path: z.string().min(1) });
|
|
245
|
+
const memoryWriteInput = z.object({ path: z.string().min(1), content: z.string() });
|
|
246
|
+
function memoryTools(absoluteDir, label) {
|
|
247
|
+
return [
|
|
248
|
+
{
|
|
249
|
+
name: "memory_list",
|
|
250
|
+
description: `List every file in the persistent memory directory (${label}).`,
|
|
251
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
252
|
+
execute: () => Promise.resolve(listingOf(absoluteDir) || "(memory is empty)"),
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: "memory_read",
|
|
256
|
+
description: `Read a file from the persistent memory directory (${label}).`,
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: { path: { type: "string", description: "Memory-relative file path" } },
|
|
260
|
+
required: ["path"],
|
|
261
|
+
additionalProperties: false,
|
|
262
|
+
},
|
|
263
|
+
execute: (input) => {
|
|
264
|
+
const { path } = memoryReadInput.parse(input);
|
|
265
|
+
const file = containedPath(absoluteDir, path);
|
|
266
|
+
if (!existsSync(file))
|
|
267
|
+
return Promise.resolve(`(no such memory file: ${path})`);
|
|
268
|
+
return Promise.resolve(readFileSync(file, "utf8"));
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "memory_write",
|
|
273
|
+
description: `Write (create or replace) a file in the persistent memory directory (${label}). ` +
|
|
274
|
+
"It survives across runs.",
|
|
275
|
+
inputSchema: {
|
|
276
|
+
type: "object",
|
|
277
|
+
properties: {
|
|
278
|
+
path: { type: "string", description: "Memory-relative file path" },
|
|
279
|
+
content: { type: "string" },
|
|
280
|
+
},
|
|
281
|
+
required: ["path", "content"],
|
|
282
|
+
additionalProperties: false,
|
|
283
|
+
},
|
|
284
|
+
execute: (input) => {
|
|
285
|
+
const { path, content } = memoryWriteInput.parse(input);
|
|
286
|
+
const file = containedPath(absoluteDir, path);
|
|
287
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
288
|
+
writeFileSync(file, content, "utf8");
|
|
289
|
+
return Promise.resolve(`wrote ${path} (${String(content.length)} chars)`);
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
];
|
|
293
|
+
}
|
|
294
|
+
/** The memory context loaded at turn start: the file index plus index.md when present. */
|
|
295
|
+
function memoryIndex(absoluteDir, label) {
|
|
296
|
+
const listing = listingOf(absoluteDir);
|
|
297
|
+
const indexPath = join(absoluteDir, "index.md");
|
|
298
|
+
const index = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : null;
|
|
299
|
+
return [
|
|
300
|
+
`<memory dir="${label}">`,
|
|
301
|
+
`Files:\n${listing || "(memory is empty)"}`,
|
|
302
|
+
...(index !== null ? [`index.md:\n${index}`] : []),
|
|
303
|
+
"Use memory_list / memory_read / memory_write to work with this directory; it persists across runs.",
|
|
304
|
+
"</memory>",
|
|
305
|
+
].join("\n");
|
|
306
|
+
}
|
|
307
|
+
function listingOf(dir, prefix = "") {
|
|
308
|
+
if (!existsSync(dir))
|
|
309
|
+
return "";
|
|
310
|
+
const lines = [];
|
|
311
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
312
|
+
const full = join(dir, entry);
|
|
313
|
+
const relative = prefix === "" ? entry : `${prefix}/${entry}`;
|
|
314
|
+
if (statSync(full).isDirectory()) {
|
|
315
|
+
const nested = listingOf(full, relative);
|
|
316
|
+
if (nested !== "")
|
|
317
|
+
lines.push(nested);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
lines.push(`${relative} (${String(statSync(full).size)} bytes)`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return lines.join("\n");
|
|
324
|
+
}
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface Clock {
|
|
2
|
+
/** Current time, ms since epoch. */
|
|
3
|
+
now(): number;
|
|
4
|
+
/** Resolve after `ms` (a cancellable timer under the hood). */
|
|
5
|
+
sleep(ms: number, signal?: AbortSignal): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
/** The real wall clock. */
|
|
8
|
+
export declare const systemClock: Clock;
|
package/dist/clock.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Injectable time source. The scheduler and lifecycle take a Clock instead of calling
|
|
2
|
+
// Date.now()/setTimeout directly so tests can drive time deterministically (scheduler clock
|
|
3
|
+
// tests, DST cases, catch-up policy) without real waits.
|
|
4
|
+
/** The real wall clock. */
|
|
5
|
+
export const systemClock = {
|
|
6
|
+
now: () => Date.now(),
|
|
7
|
+
sleep(ms, signal) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
if (signal === undefined) {
|
|
10
|
+
setTimeout(resolve, ms);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const sig = signal;
|
|
14
|
+
if (sig.aborted) {
|
|
15
|
+
reject(abortError(sig));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
sig.removeEventListener("abort", onAbort);
|
|
20
|
+
resolve();
|
|
21
|
+
}, ms);
|
|
22
|
+
const onAbort = () => {
|
|
23
|
+
clearTimeout(timer);
|
|
24
|
+
reject(abortError(sig));
|
|
25
|
+
};
|
|
26
|
+
sig.addEventListener("abort", onAbort, { once: true });
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
function abortError(signal) {
|
|
31
|
+
return signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A parsed, validated cron expression. Opaque to callers — the fields exist so `nextFire`
|
|
3
|
+
* can enumerate matches without re-parsing; their layout may change without notice.
|
|
4
|
+
*/
|
|
5
|
+
export interface CronSchedule {
|
|
6
|
+
/** Sorted ascending for in-day enumeration. 5-field expressions get `[0]`. */
|
|
7
|
+
readonly seconds: readonly number[];
|
|
8
|
+
readonly minutes: readonly number[];
|
|
9
|
+
readonly hours: readonly number[];
|
|
10
|
+
readonly daysOfMonth: ReadonlySet<number>;
|
|
11
|
+
readonly months: ReadonlySet<number>;
|
|
12
|
+
/** 0–6, Sunday = 0 (a literal 7 is normalized at parse time). */
|
|
13
|
+
readonly daysOfWeek: ReadonlySet<number>;
|
|
14
|
+
/** Why track the literal `*` prefix: Vixie's dom/dow OR rule keys off it, not off set contents. */
|
|
15
|
+
readonly domIsStar: boolean;
|
|
16
|
+
readonly dowIsStar: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parse a 5-field (min hour dom mon dow) or 6-field (sec min hour dom mon dow) cron
|
|
20
|
+
* expression. Throws `EngineError("VALIDATION", …)` naming the bad field, so deploy-time
|
|
21
|
+
* errors point the author at exactly what to fix in their `meta` trigger.
|
|
22
|
+
*/
|
|
23
|
+
export declare function parseCron(expr: string): CronSchedule;
|
|
24
|
+
/**
|
|
25
|
+
* The next fire time STRICTLY AFTER `afterMs`, computed in the given IANA timezone (default
|
|
26
|
+
* "UTC"), as epoch ms. Returns null when no fire occurs within the ~5-year search horizon
|
|
27
|
+
* (an impossible schedule like Feb 30). Throws `EngineError("VALIDATION", …)` on an invalid
|
|
28
|
+
* timezone.
|
|
29
|
+
*
|
|
30
|
+
* Why day-first enumeration instead of stepping minute-by-minute: a sparse schedule (e.g.
|
|
31
|
+
* `0 0 29 2 *`) would otherwise scan millions of minutes across years; scanning calendar
|
|
32
|
+
* days needs only ~1.8k cheap iterations, and Intl is consulted only on days that match.
|
|
33
|
+
*/
|
|
34
|
+
export declare function nextFire(schedule: CronSchedule, afterMs: number, timezone?: string): number | null;
|