@itmetsam/samurai-framework 0.22.4 → 0.22.5

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 (46) hide show
  1. package/dist/agent/generate-kind.d.ts +5 -0
  2. package/dist/agent/generate-kind.js +40 -0
  3. package/dist/agent/index.d.ts +11 -3
  4. package/dist/agent/index.js +13 -2
  5. package/dist/agent/plan/guard.d.ts +7 -0
  6. package/dist/agent/plan/guard.js +32 -0
  7. package/dist/agent/plan/plan-file.d.ts +59 -0
  8. package/dist/agent/plan/plan-file.js +201 -0
  9. package/dist/agent/plan/plan-kind.d.ts +5 -0
  10. package/dist/agent/plan/plan-kind.js +28 -0
  11. package/dist/agent/plan/prompt.d.ts +3 -0
  12. package/dist/agent/plan/prompt.js +37 -0
  13. package/dist/agent/plan/read-plan.d.ts +5 -0
  14. package/dist/agent/plan/read-plan.js +28 -0
  15. package/dist/agent/plan/recorder.d.ts +30 -0
  16. package/dist/agent/plan/recorder.js +60 -0
  17. package/dist/agent/plan/run.d.ts +8 -0
  18. package/dist/agent/plan/run.js +10 -0
  19. package/dist/agent/plan/tools.d.ts +4 -0
  20. package/dist/agent/plan/tools.js +95 -0
  21. package/dist/agent/prompt.d.ts +6 -0
  22. package/dist/agent/prompt.js +20 -6
  23. package/dist/agent/recording.d.ts +4 -0
  24. package/dist/agent/recording.js +10 -0
  25. package/dist/agent/run-kind.d.ts +48 -0
  26. package/dist/agent/run-kind.js +1 -0
  27. package/dist/agent/run.d.ts +7 -3
  28. package/dist/agent/run.js +19 -27
  29. package/dist/agent/sdk-tools.js +10 -2
  30. package/dist/agent/tools/context.d.ts +12 -5
  31. package/dist/agent/tools/index.js +2 -0
  32. package/dist/agent/tools/undo.d.ts +1 -0
  33. package/dist/agent/tools/undo.js +23 -0
  34. package/dist/agent/types.d.ts +31 -7
  35. package/dist/cli/agent-run.d.ts +43 -0
  36. package/dist/cli/agent-run.js +132 -0
  37. package/dist/cli/args.d.ts +3 -0
  38. package/dist/cli/args.js +3 -0
  39. package/dist/cli/generate.d.ts +4 -8
  40. package/dist/cli/generate.js +18 -109
  41. package/dist/cli/main.js +3 -0
  42. package/dist/cli/plan.d.ts +6 -0
  43. package/dist/cli/plan.js +28 -0
  44. package/dist/cli/usage.d.ts +1 -1
  45. package/dist/cli/usage.js +9 -1
  46. package/package.json +1 -1
@@ -0,0 +1,5 @@
1
+ import { StepRecorder } from "./recording.js";
2
+ import type { RunKind } from "./run-kind.js";
3
+ import type { AgentOptions } from "./types.js";
4
+ /** The run of `runAgent`: typed steps go into a spec file */
5
+ export declare const generateKind: RunKind<AgentOptions, StepRecorder>;
@@ -0,0 +1,40 @@
1
+ import { resultSecrets } from "../recorder/session-secrets.js";
2
+ import { buildSystemPrompt } from "./prompt.js";
3
+ import { StepRecorder } from "./recording.js";
4
+ import { personRecording } from "./take-over.js";
5
+ import { createTools } from "./tools/index.js";
6
+ /** The run of `runAgent`: typed steps go into a spec file */
7
+ export const generateKind = {
8
+ file: (options) => options.file,
9
+ sink: (options, { baseURL, emit }) => new StepRecorder({
10
+ file: options.file,
11
+ create: options.create,
12
+ test: options.test,
13
+ baseURL,
14
+ onStep: (event, code, source) => emit({
15
+ type: "step",
16
+ event,
17
+ ...(code !== undefined && { code }),
18
+ source,
19
+ }),
20
+ }),
21
+ opened: (steps, url) => {
22
+ if (steps.count === 0)
23
+ steps.observe({ type: "goto", url });
24
+ },
25
+ tools: (context) => createTools(context),
26
+ prompt: (input, options) => buildSystemPrompt({
27
+ ...input,
28
+ ...(options.plan !== undefined && { hasPlan: true }),
29
+ }),
30
+ opening: (options, first) => options.plan !== undefined
31
+ ? `Goal: ${options.goal}\n\nPlan (written earlier by another agent; follow it, but check each step on the live page):\n${options.plan.trim()}\n\n${first}`
32
+ : `Goal: ${options.goal}\n\n${first}`,
33
+ secrets: (steps) => resultSecrets(steps.events, []),
34
+ person: ({ page, sink, snapshots, baseURL }) => personRecording({
35
+ page,
36
+ steps: sink,
37
+ snapshots,
38
+ ...(baseURL !== undefined && { baseURL }),
39
+ }),
40
+ };
@@ -1,11 +1,19 @@
1
+ import type { AgentOptions, AgentSession, PlanOptions, PlanSession } from "./types.js";
1
2
  export { DEFAULT_LIMITS, resolveLimits, type AgentLimits } from "./limits.js";
2
3
  export { assertModelObject, type AgentModel } from "./model.js";
4
+ export { readPlan } from "./plan/read-plan.js";
3
5
  export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
4
- export type { AgentEndReason, AgentEvent, AgentOptions, AgentResult, AgentSession, } from "./types.js";
5
- import type { AgentOptions, AgentSession } from "./types.js";
6
+ export type { AgentEndReason, AgentEvent, AgentOptions, AgentResult, AgentSession, PlanOptions, PlanResult, PlanSession, } from "./types.js";
6
7
  /**
7
8
  * Starts the agent on a goal. The model is an AI SDK model object; the run opens a browser, writes typed steps
8
9
  * into the spec file as they succeed, and ends with an `ended` event whatever happens. Throws at once for a model
9
- * that is a string. Needs the optional packages `ai` and `zod`.
10
+ * that is a string. Needs the optional packages `ai` and `zod`. With `plan` (the text of a plan from `runPlan`)
11
+ * the agent follows that plan, checking each step on the live page.
10
12
  */
11
13
  export declare function runAgent(options: AgentOptions): AgentSession;
14
+ /**
15
+ * Starts the planning agent on a goal: it explores the page and writes a Markdown plan into `out`, a step at a
16
+ * time, for `runAgent`'s `plan` option. The page is read-only: every request that is not a GET is stopped. Same
17
+ * events and `ended` event as `runAgent`, plus `plan-step`. Needs the optional packages `ai` and `zod`.
18
+ */
19
+ export declare function runPlan(options: PlanOptions): PlanSession;
@@ -1,12 +1,23 @@
1
+ import { startPlan } from "./plan/run.js";
2
+ import { startAgent } from "./run.js";
1
3
  export { DEFAULT_LIMITS, resolveLimits } from "./limits.js";
2
4
  export { assertModelObject } from "./model.js";
5
+ export { readPlan } from "./plan/read-plan.js";
3
6
  export { defaultModelSpec, ModelSetupError, modelFromSpec, } from "./providers.js";
4
- import { startAgent } from "./run.js";
5
7
  /**
6
8
  * Starts the agent on a goal. The model is an AI SDK model object; the run opens a browser, writes typed steps
7
9
  * into the spec file as they succeed, and ends with an `ended` event whatever happens. Throws at once for a model
8
- * that is a string. Needs the optional packages `ai` and `zod`.
10
+ * that is a string. Needs the optional packages `ai` and `zod`. With `plan` (the text of a plan from `runPlan`)
11
+ * the agent follows that plan, checking each step on the live page.
9
12
  */
10
13
  export function runAgent(options) {
11
14
  return startAgent(options);
12
15
  }
16
+ /**
17
+ * Starts the planning agent on a goal: it explores the page and writes a Markdown plan into `out`, a step at a
18
+ * time, for `runAgent`'s `plan` option. The page is read-only: every request that is not a GET is stopped. Same
19
+ * events and `ended` event as `runAgent`, plus `plan-step`. Needs the optional packages `ai` and `zod`.
20
+ */
21
+ export function runPlan(options) {
22
+ return startPlan(options);
23
+ }
@@ -0,0 +1,7 @@
1
+ import type Page from "../../browser/page.js";
2
+ /**
3
+ * Makes the page read-only for the planner: every request that is not a `GET`, `HEAD` or `OPTIONS` is aborted
4
+ * before it leaves the browser (form submits, `fetch`, XHR, beacons), and `onBlocked` hears what it was. A `GET`
5
+ * that changes data and a WebSocket message are not covered. Other origins are the origin policy's business.
6
+ */
7
+ export declare function installPlanGuard(page: Pick<Page, "route">, onBlocked: (what: string) => void): Promise<void>;
@@ -0,0 +1,32 @@
1
+ /** Methods that read: they pass. `OPTIONS` is the CORS preflight, which carries no data of the page */
2
+ const READS = new Set(["GET", "HEAD", "OPTIONS"]);
3
+ /** "POST /api/feedback": the method and the path, without the query (it can hold values) */
4
+ function describeRequest(method, url) {
5
+ try {
6
+ return `${method} ${new URL(url).pathname}`;
7
+ }
8
+ catch {
9
+ return method;
10
+ }
11
+ }
12
+ /**
13
+ * Makes the page read-only for the planner: every request that is not a `GET`, `HEAD` or `OPTIONS` is aborted
14
+ * before it leaves the browser (form submits, `fetch`, XHR, beacons), and `onBlocked` hears what it was. A `GET`
15
+ * that changes data and a WebSocket message are not covered. Other origins are the origin policy's business.
16
+ */
17
+ export async function installPlanGuard(page, onBlocked) {
18
+ await page.route("**/*", async (route) => {
19
+ const request = route.request();
20
+ const { method, url } = request;
21
+ if (READS.has(method.toUpperCase())) {
22
+ await route.continue();
23
+ return;
24
+ }
25
+ onBlocked(describeRequest(method.toUpperCase(), url));
26
+ // An aborted form submit leaves Chrome on its error page; an empty 204 answer to a navigation keeps the page
27
+ if (request.resourceType === "document" || request.navigation !== null)
28
+ await route.fulfill({ status: 204 });
29
+ else
30
+ await route.abort();
31
+ });
32
+ }
@@ -0,0 +1,59 @@
1
+ /** A plan file may not be larger than this (bytes), for the planner and for `generate --plan` */
2
+ export declare const MAX_PLAN_BYTES: number;
3
+ interface PlanStep {
4
+ text: string;
5
+ expect?: string;
6
+ }
7
+ /** A plan as `parsePlan` reads it; every part but the steps may be missing in a hand-written file */
8
+ interface ParsedPlan {
9
+ title: string;
10
+ goal: string;
11
+ start: string;
12
+ steps: PlanStep[];
13
+ seen: string[];
14
+ notDone: string[];
15
+ questions: string[];
16
+ }
17
+ /**
18
+ * A plan file being written. The whole file is written again after every call, so it is valid Markdown with its
19
+ * four headings whenever the run stops. Texts are masked and made one line long.
20
+ */
21
+ declare class PlanFile {
22
+ readonly file: string;
23
+ private readonly goal;
24
+ private start;
25
+ private readonly steps;
26
+ private readonly seenItems;
27
+ private readonly notDoneItems;
28
+ private readonly questionItems;
29
+ constructor(file: string, goal: string, start: string);
30
+ /** The title, which is also what the run reports as the name of the test */
31
+ get title(): string;
32
+ get count(): number;
33
+ private render;
34
+ /** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
35
+ private save;
36
+ private add;
37
+ /** Appends a step and returns its number */
38
+ addStep(text: string, expect?: string): number;
39
+ /** Names the page the plan starts on, once the browser is there */
40
+ setStart(url: string): void;
41
+ seen(text: string): void;
42
+ notDone(text: string): void;
43
+ question(text: string): void;
44
+ /** Writes the first version of the file */
45
+ create(): void;
46
+ }
47
+ export type { PlanFile };
48
+ /** Starts a plan file: the headings are written at once. An existing file is an error unless `force` is set */
49
+ export declare function createPlan(input: {
50
+ goal: string;
51
+ start: string;
52
+ out: string;
53
+ force?: boolean;
54
+ }): PlanFile;
55
+ /**
56
+ * Reads a plan, the one `createPlan` wrote or one a person edited: blank lines, a missing title, missing sections
57
+ * and `None.` placeholders are fine. Only the `## Steps` heading is needed.
58
+ */
59
+ export declare function parsePlan(text: string): ParsedPlan;
@@ -0,0 +1,201 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { projectDir } from "../../config/config.js";
4
+ import { maskText } from "../../config/mask.js";
5
+ /** A plan file may not be larger than this (bytes), for the planner and for `generate --plan` */
6
+ export const MAX_PLAN_BYTES = 32 * 1024;
7
+ const NONE = "None.";
8
+ const TITLE_LENGTH = 80;
9
+ /** One line, masked: a new line in a text must not be able to start a heading or a step */
10
+ function oneLine(text) {
11
+ return maskText(text).replace(/\s+/g, " ").trim();
12
+ }
13
+ function bullets(items) {
14
+ return items.length > 0
15
+ ? items.map((item) => `- ${item}`).join("\n")
16
+ : `- ${NONE}`;
17
+ }
18
+ /**
19
+ * A plan file being written. The whole file is written again after every call, so it is valid Markdown with its
20
+ * four headings whenever the run stops. Texts are masked and made one line long.
21
+ */
22
+ class PlanFile {
23
+ constructor(file, goal, start) {
24
+ this.file = file;
25
+ this.goal = goal;
26
+ this.start = start;
27
+ this.steps = [];
28
+ this.seenItems = [];
29
+ this.notDoneItems = [];
30
+ this.questionItems = [];
31
+ }
32
+ /** The title, which is also what the run reports as the name of the test */
33
+ get title() {
34
+ const first = this.goal.slice(0, TITLE_LENGTH).trim();
35
+ return first.length < this.goal.length ? `${first}…` : first;
36
+ }
37
+ get count() {
38
+ return this.steps.length;
39
+ }
40
+ render() {
41
+ const steps = this.steps.map((step, i) => `${i + 1}. ${step.text}${step.expect !== undefined ? `\n Expect: ${step.expect}` : ""}`);
42
+ return [
43
+ `# Plan: ${this.title}`,
44
+ "",
45
+ `Goal: ${this.goal}`,
46
+ `Start: ${this.start}`,
47
+ "",
48
+ "## Steps",
49
+ "",
50
+ steps.length > 0 ? steps.join("\n") : `- ${NONE}`,
51
+ "",
52
+ "## Seen on the page",
53
+ "",
54
+ bullets(this.seenItems),
55
+ "",
56
+ "## Not done",
57
+ "",
58
+ bullets(this.notDoneItems),
59
+ "",
60
+ "## Open questions",
61
+ "",
62
+ bullets(this.questionItems),
63
+ "",
64
+ ].join("\n");
65
+ }
66
+ /** Writes the file; refuses, and keeps the file as it was, when it would pass the size limit */
67
+ save() {
68
+ const text = this.render();
69
+ if (Buffer.byteLength(text) > MAX_PLAN_BYTES)
70
+ throw new Error(`the plan file is full (${MAX_PLAN_BYTES / 1024} KB); finish with what it holds, or shorten the next entry`);
71
+ mkdirSync(path.dirname(this.file), { recursive: true });
72
+ writeFileSync(this.file, text);
73
+ }
74
+ add(list, text) {
75
+ list.push(oneLine(text));
76
+ try {
77
+ this.save();
78
+ }
79
+ catch (error) {
80
+ list.pop();
81
+ throw error;
82
+ }
83
+ }
84
+ /** Appends a step and returns its number */
85
+ addStep(text, expect) {
86
+ const step = { text: oneLine(text) };
87
+ const wanted = expect === undefined ? "" : oneLine(expect);
88
+ if (wanted !== "")
89
+ step.expect = wanted;
90
+ this.steps.push(step);
91
+ try {
92
+ this.save();
93
+ }
94
+ catch (error) {
95
+ this.steps.pop();
96
+ throw error;
97
+ }
98
+ return this.steps.length;
99
+ }
100
+ /** Names the page the plan starts on, once the browser is there */
101
+ setStart(url) {
102
+ this.start = oneLine(url);
103
+ this.save();
104
+ }
105
+ seen(text) {
106
+ this.add(this.seenItems, text);
107
+ }
108
+ notDone(text) {
109
+ this.add(this.notDoneItems, text);
110
+ }
111
+ question(text) {
112
+ this.add(this.questionItems, text);
113
+ }
114
+ /** Writes the first version of the file */
115
+ create() {
116
+ this.save();
117
+ }
118
+ }
119
+ /** Starts a plan file: the headings are written at once. An existing file is an error unless `force` is set */
120
+ export function createPlan(input) {
121
+ const file = path.resolve(projectDir(), input.out);
122
+ if (existsSync(file) && input.force !== true)
123
+ throw new Error(`createPlan(): ${file} already exists; pass force (--force) to replace it`);
124
+ const plan = new PlanFile(file, oneLine(input.goal), oneLine(input.start));
125
+ plan.create();
126
+ return plan;
127
+ }
128
+ const HEADING = /^##\s+(.*?)\s*$/;
129
+ const STEP = /^\s*\d+\.\s+(.*)$/;
130
+ const BULLET = /^\s*[-*]\s+(.*)$/;
131
+ const EXPECT = /^\s+Expect:\s*(.*)$/i;
132
+ const FIELD = /^(Goal|Start):\s*(.*)$/i;
133
+ function sectionOf(heading) {
134
+ const name = heading.toLowerCase();
135
+ if (name === "steps")
136
+ return "steps";
137
+ if (name.startsWith("seen"))
138
+ return "seen";
139
+ if (name === "not done")
140
+ return "notDone";
141
+ if (name.startsWith("open question"))
142
+ return "questions";
143
+ return "other";
144
+ }
145
+ /**
146
+ * Reads a plan, the one `createPlan` wrote or one a person edited: blank lines, a missing title, missing sections
147
+ * and `None.` placeholders are fine. Only the `## Steps` heading is needed.
148
+ */
149
+ export function parsePlan(text) {
150
+ const plan = {
151
+ title: "",
152
+ goal: "",
153
+ start: "",
154
+ steps: [],
155
+ seen: [],
156
+ notDone: [],
157
+ questions: [],
158
+ };
159
+ let section;
160
+ let hasSteps = false;
161
+ for (const line of text.split(/\r?\n/)) {
162
+ const heading = HEADING.exec(line);
163
+ if (heading) {
164
+ section = sectionOf(heading[1]);
165
+ if (section === "steps")
166
+ hasSteps = true;
167
+ continue;
168
+ }
169
+ if (line.trim() === "")
170
+ continue;
171
+ if (section === undefined) {
172
+ const title = /^#\s+(?:Plan:\s*)?(.*)$/i.exec(line);
173
+ const field = FIELD.exec(line);
174
+ if (title && plan.title === "")
175
+ plan.title = title[1].trim();
176
+ else if (field)
177
+ plan[field[1].toLowerCase()] = field[2].trim();
178
+ continue;
179
+ }
180
+ if (section === "other")
181
+ continue;
182
+ if (section === "steps") {
183
+ const last = plan.steps[plan.steps.length - 1];
184
+ const step = STEP.exec(line);
185
+ const expect = EXPECT.exec(line);
186
+ if (step)
187
+ plan.steps.push({ text: step[1].trim() });
188
+ else if (last && expect)
189
+ last.expect = expect[1].trim();
190
+ else if (last && !BULLET.test(line) && /^\s/.test(line))
191
+ last.text = `${last.text} ${line.trim()}`;
192
+ continue;
193
+ }
194
+ const item = BULLET.exec(line)?.[1]?.trim();
195
+ if (item !== undefined && item !== NONE)
196
+ plan[section].push(item);
197
+ }
198
+ if (!hasSteps)
199
+ throw new Error('parsePlan(): the plan has no "## Steps" heading');
200
+ return plan;
201
+ }
@@ -0,0 +1,5 @@
1
+ import type { RunKind } from "../run-kind.js";
2
+ import type { PlanOptions } from "../types.js";
3
+ import { PlanRecorder } from "./recorder.js";
4
+ /** The run of `runPlan`: steps go into a Markdown plan, and the page cannot change data */
5
+ export declare const planKind: RunKind<PlanOptions, PlanRecorder>;
@@ -0,0 +1,28 @@
1
+ import { maskText } from "../../config/mask.js";
2
+ import { installPlanGuard } from "./guard.js";
3
+ import { createPlan } from "./plan-file.js";
4
+ import { buildPlanPrompt } from "./prompt.js";
5
+ import { PlanRecorder } from "./recorder.js";
6
+ import { createPlanTools } from "./tools.js";
7
+ /** The run of `runPlan`: steps go into a Markdown plan, and the page cannot change data */
8
+ export const planKind = {
9
+ file: (options) => options.out,
10
+ sink: (options, { baseURL, emit }) => new PlanRecorder(createPlan({
11
+ goal: options.goal,
12
+ start: options.url ?? baseURL ?? "",
13
+ out: options.out,
14
+ ...(options.force !== undefined && { force: options.force }),
15
+ }), (n, text) => emit({ type: "plan-step", n, text })),
16
+ opened: (plan, url) => plan.setStart(url),
17
+ tools: (context, plan) => createPlanTools(context, plan),
18
+ prompt: (input) => buildPlanPrompt(input),
19
+ opening: (options, first) => `Goal: ${options.goal}\n\n${first}`,
20
+ secrets: () => [],
21
+ guard: (page, plan) => installPlanGuard(page, (what) => plan.block(what)),
22
+ person: ({ snapshots }) => ({
23
+ // The person's clicks write nothing: only the agent writes the plan. It is told the page may have changed
24
+ begin: () => Promise.resolve(),
25
+ end: async () => `The person used the browser while you were stopped. The page may have changed; this is the page now:\n\n${maskText((await snapshots.take()).text)}\n\nYour last call was not run. Call it again only if it is still needed.`,
26
+ dispose: () => Promise.resolve(),
27
+ }),
28
+ };
@@ -0,0 +1,3 @@
1
+ import { type PromptInput } from "../prompt.js";
2
+ /** The planner's system prompt: explore and describe, never submit, write steps another agent can follow */
3
+ export declare function buildPlanPrompt(input: PromptInput): string;
@@ -0,0 +1,37 @@
1
+ import { DATA_RULES, instructionsSection, } from "../prompt.js";
2
+ /** The planner's system prompt: explore and describe, never submit, write steps another agent can follow */
3
+ export function buildPlanPrompt(input) {
4
+ const secrets = input.secrets.length > 0 ? input.secrets.join(", ") : "none are available";
5
+ const sections = [
6
+ `You plan browser tests for SAMURAI, a test framework over WebDriver BiDi. You drive a real browser with the tools below to find out how a page works, and you write a plan: numbered steps, in plain language, that another agent can follow without the page in front of it. That agent writes the test from your plan; you write no test code.`,
7
+ `## How to work
8
+ - State a short plan in one or two sentences first, then act.
9
+ - Call snapshot to read the page. It lists elements as [e12] role "name" with their state; use those references (e12) as the target of act. References are valid until the next snapshot, so take a new one after the page changes.
10
+ - Do one thing at a time: one tool call at a time, and wait for its answer before the next call.
11
+ - After you did something with act or navigate and saw that it worked, write it with step(text, expect?). Name elements the way snapshot shows them: role and accessible name, for example: Click "Create account" (button). Never use internal ids or references (e12) in a step; they change. In expect, say what the page showed afterwards.
12
+ - A step is written only after you did it on the live page. Do not write steps for things you did not try.
13
+ - Use seen for what a test writer needs to know that is not a step (the headings, the form fields, the validation messages), and notDone for everything you did not do or could not see.
14
+ - Never invent credentials, addresses or data. If you need something you do not have, call question with a short question (and options when there are a few); the answer is for your steps.
15
+ - When the plan covers the goal, call finish with a short summary. If you cannot go on, call finish and say why.`,
16
+ `## You never submit
17
+ - The page you drive is read-only: the framework blocks every request that is not a GET, so a form submit, a save, a delete or a payment does nothing, and the tool answer says blocked. This is so that planning cannot change real data.
18
+ - Fill forms and open pages to see what they do, but do not try to get around the block. Write the click that would submit as "Not done" (notDone) and say what you expect it to do, if the page tells you.
19
+ - A link or button that only reads is fine. Nothing here stops a page that changes data with a plain GET, so do not open pages whose names say they delete, log out or buy.`,
20
+ `## Tools
21
+ - snapshot: the page as text.
22
+ - navigate(url): open a URL or a path. Allowed without asking: ${input.origins.length > 0 ? input.origins.join(", ") : "none"}. Any other site asks the person and may be refused.
23
+ - act(kind, target, value?): click, fill, select, check, uncheck, hover, dblclick, clear, press. It writes nothing to the plan.
24
+ - step(text, expect?): write a step you did. Refused when you did nothing since the last step.
25
+ - seen(text), notDone(text): notes for the test writer.
26
+ - question(question, options?): ask the person; it is kept in the plan.
27
+ - finish(summary).`,
28
+ `## Secrets and data
29
+ - You see the names of secrets, never their values: ${secrets}. Password fields show •••• in snapshots. Write a secret in a step as secrets.NAME (for example secrets.PASSWORD), never a value. You cannot type a secret: describe the step and write it as secrets.NAME.
30
+ - Environment variables are env.NAME. Never put a value you were told or saw for a password, token or key in the plan.`,
31
+ DATA_RULES,
32
+ ];
33
+ const project = instructionsSection(input.instructions);
34
+ if (project)
35
+ sections.push(project);
36
+ return sections.join("\n\n");
37
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Reads a plan for `generate --plan`: the text goes to the agent as it is. A missing file, a file over 32 KB and a
3
+ * file without a `## Steps` heading are errors that name the file.
4
+ */
5
+ export declare function readPlan(file: string): string;
@@ -0,0 +1,28 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { projectDir } from "../../config/config.js";
4
+ import { MAX_PLAN_BYTES, parsePlan } from "./plan-file.js";
5
+ /**
6
+ * Reads a plan for `generate --plan`: the text goes to the agent as it is. A missing file, a file over 32 KB and a
7
+ * file without a `## Steps` heading are errors that name the file.
8
+ */
9
+ export function readPlan(file) {
10
+ const full = path.resolve(projectDir(), file);
11
+ let size;
12
+ try {
13
+ size = statSync(full).size;
14
+ }
15
+ catch {
16
+ throw new Error(`readPlan(): ${full} cannot be read`);
17
+ }
18
+ if (size > MAX_PLAN_BYTES)
19
+ throw new Error(`readPlan(): ${full} is ${Math.ceil(size / 1024)} KB, over the ${MAX_PLAN_BYTES / 1024} KB limit`);
20
+ const text = readFileSync(full, "utf8");
21
+ try {
22
+ parsePlan(text);
23
+ }
24
+ catch (error) {
25
+ throw new Error(`readPlan(): ${full}: ${error.message.replace(/^parsePlan\(\): /, "")}`);
26
+ }
27
+ return text;
28
+ }
@@ -0,0 +1,30 @@
1
+ import type { Observation, RecorderEvent } from "../../recorder/normalise.js";
2
+ import type { RunSink } from "../run-kind.js";
3
+ import type { PlanFile } from "./plan-file.js";
4
+ /**
5
+ * What a planning run keeps: the plan file, whether something was done on the page since the last step, and the
6
+ * requests the guard stopped. `observe` is how the shared tools (`act`, `navigate`) report what they did; they
7
+ * write nothing, a plan step is the agent's own words (`step`).
8
+ */
9
+ export declare class PlanRecorder implements RunSink {
10
+ private readonly plan;
11
+ private readonly onStep;
12
+ private acted;
13
+ private blocked;
14
+ constructor(plan: PlanFile, onStep: (n: number, text: string) => void);
15
+ get count(): number;
16
+ get file(): string;
17
+ get testName(): string;
18
+ observe(_observation: Observation): RecorderEvent[];
19
+ undo(_count: number): number;
20
+ /** Records a step the agent has done on the live page; refused when it did nothing since the last step */
21
+ addStep(text: string, expect?: string): number;
22
+ setStart(url: string): void;
23
+ seen(text: string): void;
24
+ notDone(text: string): void;
25
+ question(text: string): void;
26
+ /** The guard stopped a request */
27
+ block(what: string): void;
28
+ /** What was stopped since the last call, once */
29
+ takeBlocked(): string[];
30
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * What a planning run keeps: the plan file, whether something was done on the page since the last step, and the
3
+ * requests the guard stopped. `observe` is how the shared tools (`act`, `navigate`) report what they did; they
4
+ * write nothing, a plan step is the agent's own words (`step`).
5
+ */
6
+ export class PlanRecorder {
7
+ constructor(plan, onStep) {
8
+ this.plan = plan;
9
+ this.onStep = onStep;
10
+ this.acted = false;
11
+ this.blocked = new Set();
12
+ }
13
+ get count() {
14
+ return this.plan.count;
15
+ }
16
+ get file() {
17
+ return this.plan.file;
18
+ }
19
+ get testName() {
20
+ return this.plan.title;
21
+ }
22
+ observe(_observation) {
23
+ this.acted = true;
24
+ return [];
25
+ }
26
+ undo(_count) {
27
+ return 0;
28
+ }
29
+ /** Records a step the agent has done on the live page; refused when it did nothing since the last step */
30
+ addStep(text, expect) {
31
+ if (!this.acted)
32
+ throw new Error("nothing was done since the last step; act first");
33
+ const n = this.plan.addStep(text, expect);
34
+ this.acted = false;
35
+ this.onStep(n, text);
36
+ return n;
37
+ }
38
+ setStart(url) {
39
+ this.plan.setStart(url);
40
+ }
41
+ seen(text) {
42
+ this.plan.seen(text);
43
+ }
44
+ notDone(text) {
45
+ this.plan.notDone(text);
46
+ }
47
+ question(text) {
48
+ this.plan.question(text);
49
+ }
50
+ /** The guard stopped a request */
51
+ block(what) {
52
+ this.blocked.add(what);
53
+ }
54
+ /** What was stopped since the last call, once */
55
+ takeBlocked() {
56
+ const list = [...this.blocked];
57
+ this.blocked = new Set();
58
+ return list;
59
+ }
60
+ }
@@ -0,0 +1,8 @@
1
+ import { type RunDeps } from "../run.js";
2
+ import type { PlanOptions, PlanSession } from "../types.js";
3
+ /**
4
+ * Starts a planning run: the agent explores the page and writes a Markdown plan into `out` after each step it
5
+ * records. It never submits: every request that is not a GET is stopped. The same events and the same `ended`
6
+ * event as `runAgent`, plus `plan-step`. Throws at once for a model that is a string.
7
+ */
8
+ export declare function startPlan(options: PlanOptions, deps?: RunDeps): PlanSession;
@@ -0,0 +1,10 @@
1
+ import { startAgent } from "../run.js";
2
+ import { planKind } from "./plan-kind.js";
3
+ /**
4
+ * Starts a planning run: the agent explores the page and writes a Markdown plan into `out` after each step it
5
+ * records. It never submits: every request that is not a GET is stopped. The same events and the same `ended`
6
+ * event as `runAgent`, plus `plan-step`. Throws at once for a model that is a string.
7
+ */
8
+ export function startPlan(options, deps = {}) {
9
+ return startAgent(options, deps, planKind);
10
+ }
@@ -0,0 +1,4 @@
1
+ import { type AgentTool, type ToolContext } from "../tools/context.js";
2
+ import type { PlanRecorder } from "./recorder.js";
3
+ /** The planner's tools, bound to a run: the shared ones that read and act, and the ones that write the plan */
4
+ export declare function createPlanTools(context: ToolContext, plan: PlanRecorder): AgentTool[];