@executablemd/test-agent 0.0.0-bootstrap.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/esm/mod.js +21 -0
- package/esm/package.json +3 -0
- package/esm/src/controller.js +295 -0
- package/esm/src/net.js +140 -0
- package/esm/src/protocol.js +144 -0
- package/esm/src/state.js +57 -0
- package/esm/src/template.js +138 -0
- package/esm/src/vocabulary.js +287 -0
- package/esm/src/worker/acp-server.js +138 -0
- package/esm/src/worker/bridge.js +84 -0
- package/esm/src/worker/profile.js +86 -0
- package/esm/src/worker/run.js +291 -0
- package/esm/src/worker/when-prompt.js +147 -0
- package/package.json +35 -8
- package/types/mod.d.ts +28 -0
- package/types/src/controller.d.ts +50 -0
- package/types/src/net.d.ts +51 -0
- package/types/src/protocol.d.ts +89 -0
- package/types/src/state.d.ts +27 -0
- package/types/src/template.d.ts +41 -0
- package/types/src/vocabulary.d.ts +29 -0
- package/types/src/worker/acp-server.d.ts +27 -0
- package/types/src/worker/bridge.d.ts +42 -0
- package/types/src/worker/profile.d.ts +20 -0
- package/types/src/worker/run.d.ts +20 -0
- package/types/src/worker/when-prompt.d.ts +11 -0
- package/README.md +0 -5
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic worker profile (specs/test-agent-spec.md §Deterministic
|
|
3
|
+
* runtime): a capability policy, not a security sandbox. Process and
|
|
4
|
+
* Fetch are denied, env() is undefined, cwd() is the virtual scenario
|
|
5
|
+
* root, and the filesystem is limited to controller-backed reads and
|
|
6
|
+
* stats. Reading a .ts component candidate raises the explicit
|
|
7
|
+
* unsupported-TypeScript error before it could ever be materialized or
|
|
8
|
+
* imported; stats stay honest so an earlier Name.md wins and a missing
|
|
9
|
+
* Name.ts falls through to Name/index.md. Eval blocks are inline-only:
|
|
10
|
+
* core strips static imports into options.imports before compiling, so
|
|
11
|
+
* a non-empty list is rejected, and the transformed source is parsed
|
|
12
|
+
* with acorn to reject dynamic import expressions before compilation.
|
|
13
|
+
*/
|
|
14
|
+
import { parse } from "acorn";
|
|
15
|
+
import { API } from "@executablemd/runtime";
|
|
16
|
+
function hasDynamicImport(node) {
|
|
17
|
+
if (Array.isArray(node)) {
|
|
18
|
+
return node.some(hasDynamicImport);
|
|
19
|
+
}
|
|
20
|
+
if (typeof node !== "object" || node === null) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
if ("type" in node && node.type === "ImportExpression") {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
return Object.values(node).some(hasDynamicImport);
|
|
27
|
+
}
|
|
28
|
+
export function* installWorkerProfile(filesystem) {
|
|
29
|
+
yield* API.Process.around({
|
|
30
|
+
// deno-lint-ignore require-yield
|
|
31
|
+
*exec() {
|
|
32
|
+
throw new Error("process access is denied in behavior documents");
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
yield* API.Fetch.around({
|
|
36
|
+
// deno-lint-ignore require-yield
|
|
37
|
+
*fetch() {
|
|
38
|
+
throw new Error("network access is denied in behavior documents");
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
yield* API.Env.around({
|
|
42
|
+
// deno-lint-ignore require-yield
|
|
43
|
+
*cwd() {
|
|
44
|
+
return "/";
|
|
45
|
+
},
|
|
46
|
+
// deno-lint-ignore require-yield
|
|
47
|
+
*env() {
|
|
48
|
+
return undefined;
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
yield* API.Fs.around({
|
|
52
|
+
*readTextFile([path]) {
|
|
53
|
+
if (path.endsWith(".ts")) {
|
|
54
|
+
throw new Error(`TypeScript components are not supported in behavior documents: ${path}`);
|
|
55
|
+
}
|
|
56
|
+
const source = yield* filesystem.read(path);
|
|
57
|
+
if (source === undefined) {
|
|
58
|
+
throw new Error(`ENOENT: no such file in the scenario filesystem: ${path}`);
|
|
59
|
+
}
|
|
60
|
+
return source;
|
|
61
|
+
},
|
|
62
|
+
*stat([path]) {
|
|
63
|
+
return yield* filesystem.stat(path);
|
|
64
|
+
},
|
|
65
|
+
// deno-lint-ignore require-yield
|
|
66
|
+
*glob() {
|
|
67
|
+
throw new Error("filesystem globbing is denied in behavior documents");
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
yield* API.Compiler.around({
|
|
71
|
+
*compile([source, options], next) {
|
|
72
|
+
const imports = options?.imports ?? [];
|
|
73
|
+
if (imports.length > 0) {
|
|
74
|
+
throw new Error("eval blocks in behavior documents are inline-only — static imports are not allowed");
|
|
75
|
+
}
|
|
76
|
+
const program = parse(`function* __evalBlock__() {${source}\n}`, {
|
|
77
|
+
ecmaVersion: "latest",
|
|
78
|
+
sourceType: "script",
|
|
79
|
+
});
|
|
80
|
+
if (hasDynamicImport(program.body)) {
|
|
81
|
+
throw new Error("eval blocks in behavior documents are inline-only — dynamic import() is not allowed");
|
|
82
|
+
}
|
|
83
|
+
return yield* next(source, options);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `xmd test-agent` worker runtime (specs/test-agent-spec.md §Controller
|
|
3
|
+
* and worker). The worker attaches to its controller, rehydrates the
|
|
4
|
+
* behavior document and journal, and serves ACP on stdio.
|
|
5
|
+
*
|
|
6
|
+
* A stage's buffered durable-event suffix — everything through the next
|
|
7
|
+
* suspension point or the final root Close — is forwarded and
|
|
8
|
+
* acknowledged before the ACP result is returned; a crash inside that
|
|
9
|
+
* final delivery window is outside the supported recovery guarantee.
|
|
10
|
+
*
|
|
11
|
+
* Cancellation is transactional from the controller journal's
|
|
12
|
+
* perspective: a cancelled turn commits nothing — the scenario runtime
|
|
13
|
+
* is halted and rebuilt from the last acknowledged journal before
|
|
14
|
+
* another turn is accepted, so the next prompt re-enters the same
|
|
15
|
+
* stage deterministically.
|
|
16
|
+
*/
|
|
17
|
+
import { race, scoped, spawn, suspend, useScope, withResolvers } from "effection";
|
|
18
|
+
import { RequestError } from "@agentclientprotocol/sdk";
|
|
19
|
+
import { Component, DocumentOutput, execute } from "@executablemd/core";
|
|
20
|
+
import { InMemoryStream } from "@executablemd/durable-streams";
|
|
21
|
+
import { encodeMessage, parseControllerMessage, parseRoute, PROBE_INSTANCE } from "../protocol.js";
|
|
22
|
+
import { createTurnBridge, collectTurn } from "./bridge.js";
|
|
23
|
+
import { useLineClient } from "../net.js";
|
|
24
|
+
import { installWhenPromptVocabulary } from "./when-prompt.js";
|
|
25
|
+
import { installWorkerProfile } from "./profile.js";
|
|
26
|
+
import { serveAcp, useProcessStdio } from "./acp-server.js";
|
|
27
|
+
/**
|
|
28
|
+
* Connect to the controller over the shared line-socket adapter. A socket
|
|
29
|
+
* close ends the inbound stream, so a pending next() throws rather than
|
|
30
|
+
* hanging on a dead controller.
|
|
31
|
+
*/
|
|
32
|
+
function* useControllerClient(host, port) {
|
|
33
|
+
const client = yield* useLineClient(host, port, (line) => {
|
|
34
|
+
const parsed = parseControllerMessage(line);
|
|
35
|
+
return parsed.ok ? parsed.message : undefined;
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
send: (message) => client.send(encodeMessage(message)),
|
|
39
|
+
next: () => client.next(),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function* runTestAgentWorker(options) {
|
|
43
|
+
const route = parseRoute(options.connect);
|
|
44
|
+
if (!route.ok) {
|
|
45
|
+
throw new Error(route.error);
|
|
46
|
+
}
|
|
47
|
+
const { host, port, token, instance } = route.message;
|
|
48
|
+
const client = yield* useControllerClient(host, port);
|
|
49
|
+
client.send({ t: "attach", token, instance });
|
|
50
|
+
const config = yield* client.next();
|
|
51
|
+
if (config.t === "error") {
|
|
52
|
+
throw new Error(`controller rejected this worker: ${config.message}`);
|
|
53
|
+
}
|
|
54
|
+
if (config.t !== "config") {
|
|
55
|
+
throw new Error(`unexpected controller message "${config.t}" before config`);
|
|
56
|
+
}
|
|
57
|
+
if (instance === PROBE_INSTANCE || config.mode !== "scenario") {
|
|
58
|
+
yield* serveAcp({
|
|
59
|
+
// deno-lint-ignore require-yield
|
|
60
|
+
*ready() {
|
|
61
|
+
throw new Error("probe workers only initialize; they never start a behavior document");
|
|
62
|
+
},
|
|
63
|
+
// deno-lint-ignore require-yield
|
|
64
|
+
*runTurn() {
|
|
65
|
+
throw new Error("probe workers do not serve prompts");
|
|
66
|
+
},
|
|
67
|
+
cancel() { },
|
|
68
|
+
}, yield* useProcessStdio());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const scenario = config;
|
|
72
|
+
// The worker-side mirror of the controller's journal: only events the
|
|
73
|
+
// controller has acknowledged. Cancellation rebuilds from exactly
|
|
74
|
+
// this list.
|
|
75
|
+
const acked = [...scenario.journal];
|
|
76
|
+
const workerScope = yield* useScope();
|
|
77
|
+
// Report a failure diagnostic and wait for the controller to record it. The
|
|
78
|
+
// ACP error is only surfaced after this returns, so the diagnostic is always
|
|
79
|
+
// observable on the controller by the time the error is seen.
|
|
80
|
+
function* report(message) {
|
|
81
|
+
client.send(message);
|
|
82
|
+
const reply = yield* client.next();
|
|
83
|
+
if (reply.t !== "recorded") {
|
|
84
|
+
throw new Error(`controller did not record diagnostic "${message.t}"`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function* startRuntime() {
|
|
88
|
+
const snapshot = acked.slice();
|
|
89
|
+
const bridge = createTurnBridge();
|
|
90
|
+
// The subscription must live in the runtime task's scope — restart
|
|
91
|
+
// is invoked from short-lived ACP request tasks, and a subscription
|
|
92
|
+
// created there would die with its request and silently drop
|
|
93
|
+
// events.
|
|
94
|
+
const subscription = withResolvers();
|
|
95
|
+
const readiness = withResolvers();
|
|
96
|
+
const pending = [];
|
|
97
|
+
let exhausted = false;
|
|
98
|
+
function* flush() {
|
|
99
|
+
while (pending.length > 0) {
|
|
100
|
+
const event = pending.shift();
|
|
101
|
+
const seq = acked.length;
|
|
102
|
+
client.send({ t: "journal", seq, event });
|
|
103
|
+
const reply = yield* client.next();
|
|
104
|
+
if (reply.t !== "ack" || reply.seq !== seq) {
|
|
105
|
+
throw new Error(`controller did not acknowledge journal event ${seq}`);
|
|
106
|
+
}
|
|
107
|
+
acked.push(event);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const task = yield* workerScope.spawn(() => scoped(function* () {
|
|
111
|
+
const turnEvents = yield* bridge.events;
|
|
112
|
+
subscription.resolve(turnEvents);
|
|
113
|
+
const stream = new InMemoryStream();
|
|
114
|
+
for (const event of snapshot) {
|
|
115
|
+
yield* stream.append(event);
|
|
116
|
+
}
|
|
117
|
+
stream.onAppend = (event) => {
|
|
118
|
+
pending.push(event);
|
|
119
|
+
};
|
|
120
|
+
// The root behavior document executes from the source snapshot
|
|
121
|
+
// captured at scenario declaration — changing or removing the
|
|
122
|
+
// file afterwards never affects an already-declared scenario.
|
|
123
|
+
// Markdown dependencies still resolve on demand through the
|
|
124
|
+
// controller.
|
|
125
|
+
yield* installWorkerProfile({
|
|
126
|
+
*read(path) {
|
|
127
|
+
if (path === scenario.doc.path) {
|
|
128
|
+
return scenario.doc.source;
|
|
129
|
+
}
|
|
130
|
+
client.send({ t: "read", path });
|
|
131
|
+
const reply = yield* client.next();
|
|
132
|
+
if (reply.t !== "read") {
|
|
133
|
+
throw new Error(`unexpected controller reply "${reply.t}" to read`);
|
|
134
|
+
}
|
|
135
|
+
return reply.missing ? undefined : reply.source;
|
|
136
|
+
},
|
|
137
|
+
*stat(path) {
|
|
138
|
+
if (path === scenario.doc.path) {
|
|
139
|
+
return { exists: true, isFile: true, isDirectory: false };
|
|
140
|
+
}
|
|
141
|
+
client.send({ t: "stat", path });
|
|
142
|
+
const reply = yield* client.next();
|
|
143
|
+
if (reply.t !== "stat") {
|
|
144
|
+
throw new Error(`unexpected controller reply "${reply.t}" to stat`);
|
|
145
|
+
}
|
|
146
|
+
return { exists: reply.exists, isFile: reply.isFile, isDirectory: false };
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
yield* installWhenPromptVocabulary(bridge);
|
|
150
|
+
// Behavior-document errors — eval preflight rejections,
|
|
151
|
+
// unsupported components, matcher configuration — must fail the
|
|
152
|
+
// scenario, never render as comments and let the turn succeed.
|
|
153
|
+
yield* Component.around({
|
|
154
|
+
// deno-lint-ignore require-yield
|
|
155
|
+
*raise([segment]) {
|
|
156
|
+
throw new Error(segment.message);
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
yield* DocumentOutput.around({
|
|
160
|
+
*output([text], next) {
|
|
161
|
+
yield* bridge.events.send({ kind: "output", text });
|
|
162
|
+
yield* next(text);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
const execution = yield* execute({ docPath: scenario.doc.path, stream });
|
|
166
|
+
yield* spawn(function* () {
|
|
167
|
+
const result = yield* execution;
|
|
168
|
+
if (result.ok) {
|
|
169
|
+
yield* bridge.events.send({ kind: "eof" });
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
yield* bridge.events.send({ kind: "failed", error: result.error.message });
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
// Initialization: replay/expand to the first live matcher,
|
|
176
|
+
// persist the pre-matcher events, and only then report
|
|
177
|
+
// readiness.
|
|
178
|
+
yield* spawn(function* () {
|
|
179
|
+
const initial = yield* collectTurn(turnEvents);
|
|
180
|
+
if (initial.end === "failed") {
|
|
181
|
+
yield* report({ t: "fatal", message: initial.error ?? "behavior document failed" });
|
|
182
|
+
readiness.reject(new RequestError(-32603, initial.error ?? "behavior document failed"));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Rehydrated runtimes replay completed stages, so their
|
|
186
|
+
// re-rendered output reaches the init collector; the fresh
|
|
187
|
+
// run already validated the pre-matcher region.
|
|
188
|
+
const rehydrated = snapshot.length > 0;
|
|
189
|
+
if (!rehydrated && initial.text.trim().length > 0) {
|
|
190
|
+
const message = "behavior documents must not render non-whitespace output before the first <WhenPrompt>";
|
|
191
|
+
yield* report({ t: "turn-failure", kind: "config", actual: initial.text });
|
|
192
|
+
readiness.reject(new RequestError(-32603, message));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (initial.end === "eof") {
|
|
196
|
+
exhausted = true;
|
|
197
|
+
}
|
|
198
|
+
yield* flush();
|
|
199
|
+
readiness.resolve();
|
|
200
|
+
});
|
|
201
|
+
yield* suspend();
|
|
202
|
+
}));
|
|
203
|
+
const turnEvents = yield* subscription.operation;
|
|
204
|
+
return {
|
|
205
|
+
task,
|
|
206
|
+
turnEvents,
|
|
207
|
+
ready: readiness.operation,
|
|
208
|
+
offer: (text) => bridge.offer(text),
|
|
209
|
+
flush,
|
|
210
|
+
isExhausted: () => exhausted,
|
|
211
|
+
markExhausted: () => {
|
|
212
|
+
exhausted = true;
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
let current = yield* startRuntime();
|
|
217
|
+
let cancelRequested;
|
|
218
|
+
function* restart() {
|
|
219
|
+
yield* current.task.halt();
|
|
220
|
+
current = yield* startRuntime();
|
|
221
|
+
yield* current.ready;
|
|
222
|
+
}
|
|
223
|
+
yield* serveAcp({
|
|
224
|
+
*ready() {
|
|
225
|
+
yield* current.ready;
|
|
226
|
+
},
|
|
227
|
+
*runTurn(text) {
|
|
228
|
+
if (current.isExhausted()) {
|
|
229
|
+
yield* report({ t: "turn-failure", kind: "exhausted", actual: text });
|
|
230
|
+
throw new RequestError(-32603, `scenario exhausted: no stage remains for prompt: ${text}`);
|
|
231
|
+
}
|
|
232
|
+
const runtime = current;
|
|
233
|
+
const cancellation = withResolvers();
|
|
234
|
+
cancelRequested = () => cancellation.resolve({ cancelled: true });
|
|
235
|
+
try {
|
|
236
|
+
const outcome = yield* race([
|
|
237
|
+
cancellation.operation,
|
|
238
|
+
(function* () {
|
|
239
|
+
const match = yield* runtime.offer(text);
|
|
240
|
+
if (!match.ok) {
|
|
241
|
+
const failure = {
|
|
242
|
+
t: "turn-failure",
|
|
243
|
+
kind: match.kind === "config" ? "config" : "mismatch",
|
|
244
|
+
actual: match.actual,
|
|
245
|
+
};
|
|
246
|
+
if (match.kind === "mismatch") {
|
|
247
|
+
failure.expected = match.expected;
|
|
248
|
+
}
|
|
249
|
+
// Terminal outcome: close cancellation before the diagnostic
|
|
250
|
+
// round-trip so a later cancel cannot strand the recorded ack
|
|
251
|
+
// in the shared controller response queue.
|
|
252
|
+
cancelRequested = undefined;
|
|
253
|
+
yield* report(failure);
|
|
254
|
+
throw new RequestError(-32603, match.message);
|
|
255
|
+
}
|
|
256
|
+
const collected = yield* collectTurn(runtime.turnEvents);
|
|
257
|
+
// Commit is transactional: once the turn reaches its terminal
|
|
258
|
+
// bridge event cancellation can no longer win, so a later
|
|
259
|
+
// session/cancel is ignored and neither the diagnostic nor the
|
|
260
|
+
// journal ack below can be stranded.
|
|
261
|
+
cancelRequested = undefined;
|
|
262
|
+
if (collected.end === "failed") {
|
|
263
|
+
yield* report({
|
|
264
|
+
t: "fatal",
|
|
265
|
+
message: collected.error ?? "behavior document failed",
|
|
266
|
+
});
|
|
267
|
+
throw new RequestError(-32603, collected.error ?? "behavior document failed");
|
|
268
|
+
}
|
|
269
|
+
if (collected.end === "eof") {
|
|
270
|
+
runtime.markExhausted();
|
|
271
|
+
}
|
|
272
|
+
yield* runtime.flush();
|
|
273
|
+
return { cancelled: false, text: collected.text };
|
|
274
|
+
})(),
|
|
275
|
+
]);
|
|
276
|
+
if (outcome.cancelled) {
|
|
277
|
+
yield* restart();
|
|
278
|
+
}
|
|
279
|
+
return outcome;
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
cancelRequested = undefined;
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
cancel() {
|
|
286
|
+
if (cancelRequested) {
|
|
287
|
+
cancelRequested();
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
}, yield* useProcessStdio());
|
|
291
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<WhenPrompt>` vocabulary (specs/test-agent-spec.md §Behavior
|
|
3
|
+
* documents). Each matcher signals the bridge that the previous stage's
|
|
4
|
+
* rendering is complete, then suspends until an offered prompt matches.
|
|
5
|
+
* A match is one durable `when_prompt` operation whose record restores
|
|
6
|
+
* the matched prompt and captures on replay, so a rehydrated worker
|
|
7
|
+
* advances to the active matcher without re-matching.
|
|
8
|
+
*/
|
|
9
|
+
import { scoped } from "effection";
|
|
10
|
+
import { createDurableOperation } from "@executablemd/durable-streams";
|
|
11
|
+
import { Component, env, renderSegments, validateBindingName } from "@executablemd/core";
|
|
12
|
+
import { matchPrompt, parseTemplate } from "../template.js";
|
|
13
|
+
const WHEN_PROMPT = "when_prompt";
|
|
14
|
+
function parseStageRecord(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
if (!("prompt" in value) || !("captures" in value)) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
const { prompt, captures } = value;
|
|
22
|
+
if (typeof prompt !== "string") {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (typeof captures !== "object" || captures === null || Array.isArray(captures)) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
const parsed = {};
|
|
29
|
+
for (const [name, text] of Object.entries(captures)) {
|
|
30
|
+
if (typeof text !== "string") {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
parsed[name] = text;
|
|
34
|
+
}
|
|
35
|
+
return { prompt, captures: parsed };
|
|
36
|
+
}
|
|
37
|
+
function configError(message) {
|
|
38
|
+
return { type: "error", message: `<WhenPrompt> ${message}`, source: "WhenPrompt" };
|
|
39
|
+
}
|
|
40
|
+
function formatLocation(invocation) {
|
|
41
|
+
const position = invocation.position;
|
|
42
|
+
if (!position) {
|
|
43
|
+
return "unknown";
|
|
44
|
+
}
|
|
45
|
+
const at = `${position.line}:${position.column}`;
|
|
46
|
+
return position.path ? `${position.path}:${at}` : at;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Waits for a matching prompt, answering every mismatch through its
|
|
50
|
+
* offer so the ACP turn fails while the stage stays active.
|
|
51
|
+
*/
|
|
52
|
+
function* awaitMatch(bridge, template, bindings) {
|
|
53
|
+
while (true) {
|
|
54
|
+
const offer = yield* bridge.nextOffer();
|
|
55
|
+
const outcome = matchPrompt(template, offer.text, bindings);
|
|
56
|
+
offer.respond(outcome);
|
|
57
|
+
if (outcome.ok) {
|
|
58
|
+
return { prompt: offer.text, captures: outcome.captures };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function* persistStage(identity, live) {
|
|
63
|
+
const stored = yield createDurableOperation({ type: WHEN_PROMPT, name: identity.name, input: identity.input }, function* () {
|
|
64
|
+
const record = yield* live();
|
|
65
|
+
return { prompt: record.prompt, captures: record.captures };
|
|
66
|
+
});
|
|
67
|
+
const parsed = parseStageRecord(stored);
|
|
68
|
+
if (!parsed) {
|
|
69
|
+
throw new Error(`journaled when_prompt "${identity.name}" has an unexpected shape`);
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
export function* installWhenPromptVocabulary(bridge) {
|
|
74
|
+
const ordinals = new Map();
|
|
75
|
+
function* expandWhenPrompt(invocation, ctx) {
|
|
76
|
+
for (const name of Object.keys({ ...invocation.props, ...invocation.expressions })) {
|
|
77
|
+
if (name !== "template" && name !== "as") {
|
|
78
|
+
return [configError(`does not accept a "${name}" prop (allowed: template, as).`)];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const templateProp = invocation.props.template;
|
|
82
|
+
const hasChildren = !invocation.selfClosing && invocation.children.length > 0;
|
|
83
|
+
if (typeof templateProp === "string" && hasChildren) {
|
|
84
|
+
return [configError("accepts either a template prop or children, not both.")];
|
|
85
|
+
}
|
|
86
|
+
let source;
|
|
87
|
+
if (typeof templateProp === "string") {
|
|
88
|
+
source = templateProp;
|
|
89
|
+
}
|
|
90
|
+
else if (hasChildren) {
|
|
91
|
+
source = renderSegments(yield* ctx.expand(invocation.children)).trim();
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
return [configError("requires a template prop or template children.")];
|
|
95
|
+
}
|
|
96
|
+
const parsed = parseTemplate(source);
|
|
97
|
+
if (!parsed.ok) {
|
|
98
|
+
return [configError(parsed.error)];
|
|
99
|
+
}
|
|
100
|
+
const binding = invocation.props.as;
|
|
101
|
+
if (parsed.template.captureNames.length > 0 && typeof binding !== "string") {
|
|
102
|
+
return [configError('captures require an "as" prop.')];
|
|
103
|
+
}
|
|
104
|
+
let bindingName;
|
|
105
|
+
if (binding !== undefined) {
|
|
106
|
+
const validated = validateBindingName(binding);
|
|
107
|
+
if (!validated.ok || validated.value === undefined) {
|
|
108
|
+
return [configError('the "as" prop must be a valid binding name.')];
|
|
109
|
+
}
|
|
110
|
+
bindingName = validated.value;
|
|
111
|
+
}
|
|
112
|
+
const currentEnv = yield* env;
|
|
113
|
+
if (!currentEnv) {
|
|
114
|
+
return [configError("requires an eval scope in context.")];
|
|
115
|
+
}
|
|
116
|
+
const location = formatLocation(invocation);
|
|
117
|
+
const ordinal = ordinals.get(location) ?? 0;
|
|
118
|
+
ordinals.set(location, ordinal + 1);
|
|
119
|
+
// The suspension signal lives inside the durable closure, so ONLY a
|
|
120
|
+
// live matcher emits it: replayed stages resolve from the journal
|
|
121
|
+
// and their re-rendered output never reaches a turn collector. For
|
|
122
|
+
// a live matcher the signal completes the previous stage — it
|
|
123
|
+
// follows all of that stage's output through one ordered channel,
|
|
124
|
+
// so the collector never loses the final chunk.
|
|
125
|
+
const record = yield* persistStage({ name: `when:${location}#${ordinal}`, input: source }, () => scoped(function* () {
|
|
126
|
+
yield* bridge.events.send({ kind: "suspended", stage: source });
|
|
127
|
+
return yield* awaitMatch(bridge, parsed.template, currentEnv.values);
|
|
128
|
+
}));
|
|
129
|
+
if (bindingName !== undefined) {
|
|
130
|
+
const existing = currentEnv.values[bindingName];
|
|
131
|
+
const merged = typeof existing === "object" && existing !== null && !Array.isArray(existing)
|
|
132
|
+
? { ...existing }
|
|
133
|
+
: {};
|
|
134
|
+
Object.assign(merged, record.captures);
|
|
135
|
+
currentEnv.values[bindingName] = merged;
|
|
136
|
+
}
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
yield* Component.around({
|
|
140
|
+
*expandInvocation([invocation, ctx], next) {
|
|
141
|
+
if (invocation.name === "WhenPrompt") {
|
|
142
|
+
return { segments: yield* expandWhenPrompt(invocation, ctx) };
|
|
143
|
+
}
|
|
144
|
+
return yield* next(invocation, ctx);
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@executablemd/test-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "Build reliable ACP integration tests with deterministic, document-driven agent behavior.",
|
|
5
|
+
"homepage": "https://executable.md",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/taras/executable.md.git"
|
|
9
9
|
},
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/taras/executable.md/issues"
|
|
13
|
+
},
|
|
14
|
+
"module": "./esm/mod.js",
|
|
15
|
+
"types": "./types/mod.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": {
|
|
19
|
+
"types": "./types/mod.d.ts",
|
|
20
|
+
"default": "./esm/mod.js"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@agentclientprotocol/sdk": "1.3.0",
|
|
27
|
+
"@effectionx/node": "0.2.4",
|
|
28
|
+
"@effectionx/scope-eval": "0.1.3",
|
|
29
|
+
"@effectionx/stream-helpers": "0.8.3",
|
|
30
|
+
"@executablemd/acp": "^0.5.1",
|
|
31
|
+
"@executablemd/core": "^0.5.1",
|
|
32
|
+
"@executablemd/durable-streams": "^0.5.1",
|
|
33
|
+
"@executablemd/runtime": "^0.5.1",
|
|
34
|
+
"@executablemd/testing": "^0.5.1",
|
|
35
|
+
"acorn": "^8.16.0",
|
|
36
|
+
"acpx": "0.12.0",
|
|
37
|
+
"effection": "4.1.0-alpha.7",
|
|
38
|
+
"zod": "^4.3.6"
|
|
39
|
+
},
|
|
40
|
+
"_generatedBy": "dnt@dev"
|
|
41
|
+
}
|
package/types/mod.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Build reliable ACP integration tests with deterministic,
|
|
4
|
+
* document-driven agent behavior. In place of a probabilistic coding
|
|
5
|
+
* agent, the test agent answers ACP prompts by advancing through a
|
|
6
|
+
* Markdown behavior document, so an integration can be tested against
|
|
7
|
+
* scripted, repeatable responses (specs/test-agent-spec.md).
|
|
8
|
+
*
|
|
9
|
+
* This entry point exposes the `<TestAgent>` vocabulary, the
|
|
10
|
+
* behavior-document engine, and the controller that registers scenarios
|
|
11
|
+
* and serves them to workers over the wire protocol.
|
|
12
|
+
*/
|
|
13
|
+
export { parseTemplate, matchPrompt } from "./src/template.js";
|
|
14
|
+
export type { ParsedTemplate, TemplateMatchResult, TemplateParseResult, TemplateToken, } from "./src/template.js";
|
|
15
|
+
export { createLineSplitter, encodeMessage, formatRoute, parseControllerMessage, parseRoute, parseWorkerMessage, PROBE_INSTANCE, } from "./src/protocol.js";
|
|
16
|
+
export type { ControllerMessage, ParsedRoute, ParseResult, WireDurableEvent, WorkerMessage, } from "./src/protocol.js";
|
|
17
|
+
export { useTestAgentController } from "./src/controller.js";
|
|
18
|
+
export type { InstanceFailure, ScenarioInstance, TestAgentController } from "./src/controller.js";
|
|
19
|
+
export { installTestAgentVocabulary } from "./src/vocabulary.js";
|
|
20
|
+
export type { TestAgentVocabularyOptions } from "./src/vocabulary.js";
|
|
21
|
+
export { createMemorySessionStore, useTestAgentAcpx } from "./src/state.js";
|
|
22
|
+
export type { TestAgentAcpx, TestAgentAcpxOptions } from "./src/state.js";
|
|
23
|
+
export { runTestAgentWorker } from "./src/worker/run.js";
|
|
24
|
+
export { installWhenPromptVocabulary } from "./src/worker/when-prompt.js";
|
|
25
|
+
export { installWorkerProfile } from "./src/worker/profile.js";
|
|
26
|
+
export type { WorkerFilesystem } from "./src/worker/profile.js";
|
|
27
|
+
export { collectTurn, createTurnBridge } from "./src/worker/bridge.js";
|
|
28
|
+
export type { BridgeEvent, PromptOffer, TurnBridge } from "./src/worker/bridge.js";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test-agent controller (specs/test-agent-spec.md §Controller and
|
|
3
|
+
* worker): a localhost line-protocol server owned by the `<TestAgent>`
|
|
4
|
+
* scope. It serves behavior documents, Markdown dependencies (reads
|
|
5
|
+
* restricted to Markdown files whose canonical path stays inside the
|
|
6
|
+
* scenario root), and behavior journals to workers, and records journal
|
|
7
|
+
* appends and turn-failure diagnostics per scenario instance.
|
|
8
|
+
*
|
|
9
|
+
* Each instance admits one worker connection at a time. Unregistering an
|
|
10
|
+
* instance — or tearing the controller down — revokes and awaits its active
|
|
11
|
+
* connection before discarding state, so a revoked worker can no longer
|
|
12
|
+
* append, report failures, or read.
|
|
13
|
+
*/
|
|
14
|
+
import type { Operation } from "effection";
|
|
15
|
+
import type { DurableEvent } from "@executablemd/durable-streams";
|
|
16
|
+
export interface InstanceFailure {
|
|
17
|
+
kind: "mismatch" | "exhausted" | "config";
|
|
18
|
+
expected?: string;
|
|
19
|
+
actual: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ScenarioInstance {
|
|
22
|
+
id: string;
|
|
23
|
+
route: string;
|
|
24
|
+
/** The real directory Markdown dependencies are served from. */
|
|
25
|
+
scenarioDir: string;
|
|
26
|
+
doc: {
|
|
27
|
+
path: string;
|
|
28
|
+
source: string;
|
|
29
|
+
};
|
|
30
|
+
journal: DurableEvent[];
|
|
31
|
+
failure?: InstanceFailure;
|
|
32
|
+
fatal?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface TestAgentController {
|
|
35
|
+
probeRoute: string;
|
|
36
|
+
/**
|
|
37
|
+
* Register a scenario instance as a resource. Its finalizer removes it from
|
|
38
|
+
* the routing index, revokes and awaits any active worker, and clears its
|
|
39
|
+
* journal and diagnostics — so ending the instance's scope tears it down.
|
|
40
|
+
*/
|
|
41
|
+
useInstance(config: {
|
|
42
|
+
doc: {
|
|
43
|
+
path: string;
|
|
44
|
+
source: string;
|
|
45
|
+
};
|
|
46
|
+
scenarioDir: string;
|
|
47
|
+
}): Operation<ScenarioInstance>;
|
|
48
|
+
instance(id: string): ScenarioInstance | undefined;
|
|
49
|
+
}
|
|
50
|
+
export declare function useTestAgentController(): Operation<TestAgentController>;
|