explorbot 0.2.3 → 0.2.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.
- package/README.md +1 -1
- package/bin/explorbot-cli.ts +26 -8
- package/boat/api-tester/src/cli.ts +17 -0
- package/boat/api-tester/src/config.ts +4 -2
- package/boat/doc-collector/bin/doc-collector-cli.ts +2 -0
- package/boat/doc-collector/src/ai/documentarian.ts +61 -31
- package/boat/doc-collector/src/cli.ts +14 -1
- package/boat/doc-collector/src/config.ts +4 -2
- package/boat/prima/bin/prima-cli.ts +0 -0
- package/boat/prima/src/activity-line.ts +33 -0
- package/boat/prima/src/cli.ts +127 -86
- package/boat/prima/src/envelope.ts +102 -52
- package/boat/prima/src/prima.ts +567 -128
- package/boat/prima/src/pw-parser.ts +11 -1
- package/boat/prima/src/pw-registry.ts +4 -5
- package/boat/prima/src/session-log.ts +126 -0
- package/dist/bin/explorbot-cli.js +26 -8
- package/dist/boat/api-tester/bin/apibot-cli.js +2 -0
- package/dist/boat/api-tester/src/cli.js +17 -0
- package/dist/boat/api-tester/src/config.js +4 -2
- package/dist/boat/doc-collector/bin/doc-collector-cli.js +2 -0
- package/dist/boat/doc-collector/src/ai/documentarian.js +44 -19
- package/dist/boat/doc-collector/src/cli.js +14 -1
- package/dist/boat/doc-collector/src/config.js +4 -2
- package/dist/boat/prima/src/activity-line.js +30 -0
- package/dist/boat/prima/src/cli.js +109 -77
- package/dist/boat/prima/src/envelope.js +94 -44
- package/dist/boat/prima/src/prima.js +533 -119
- package/dist/boat/prima/src/pw-parser.js +13 -1
- package/dist/boat/prima/src/pw-registry.js +4 -5
- package/dist/boat/prima/src/session-log.js +108 -0
- package/dist/package.json +3 -2
- package/dist/rules/navigator/verification-actions.md +20 -0
- package/dist/src/action-result.d.ts +7 -0
- package/dist/src/action-result.js +4 -0
- package/dist/src/action.d.ts +2 -0
- package/dist/src/action.js +41 -2
- package/dist/src/ai/captain/web-mode.js +6 -3
- package/dist/src/ai/captain.js +2 -0
- package/dist/src/ai/navigator.d.ts +34 -0
- package/dist/src/ai/navigator.js +237 -181
- package/dist/src/ai/pilot.d.ts +7 -0
- package/dist/src/ai/pilot.js +90 -2
- package/dist/src/ai/provider.d.ts +2 -2
- package/dist/src/ai/provider.js +14 -23
- package/dist/src/ai/rerunner.js +2 -1
- package/dist/src/ai/researcher/cache.d.ts +2 -0
- package/dist/src/ai/researcher/cache.js +10 -2
- package/dist/src/ai/researcher.js +3 -2
- package/dist/src/ai/rules.js +17 -10
- package/dist/src/ai/session-analyst.js +2 -0
- package/dist/src/ai/task-agent.js +4 -1
- package/dist/src/ai/tester.d.ts +6 -3
- package/dist/src/ai/tester.js +50 -46
- package/dist/src/ai/tools.d.ts +14 -0
- package/dist/src/ai/tools.js +117 -37
- package/dist/src/commands/config-command.d.ts +51 -0
- package/dist/src/commands/config-command.js +117 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/config.d.ts +9 -1
- package/dist/src/config.js +53 -4
- package/dist/src/execution-controller.d.ts +2 -0
- package/dist/src/execution-controller.js +6 -0
- package/dist/src/explorbot.d.ts +2 -1
- package/dist/src/explorbot.js +7 -2
- package/dist/src/explorer.js +2 -3
- package/dist/src/playwright-recorder.js +30 -0
- package/dist/src/remote.d.ts +55 -0
- package/dist/src/remote.js +235 -0
- package/dist/src/reporter.d.ts +1 -0
- package/dist/src/reporter.js +7 -1
- package/dist/src/state-manager.d.ts +2 -1
- package/dist/src/state-manager.js +3 -1
- package/dist/src/stats.d.ts +1 -0
- package/dist/src/stats.js +1 -0
- package/dist/src/test-plan.d.ts +3 -0
- package/dist/src/test-plan.js +26 -0
- package/dist/src/utils/aria.d.ts +2 -8
- package/dist/src/utils/aria.js +69 -40
- package/dist/src/utils/html.js +1 -0
- package/dist/src/utils/logger.d.ts +7 -1
- package/dist/src/utils/logger.js +32 -0
- package/dist/src/utils/page-readiness.js +18 -1
- package/dist/src/utils/url-matcher.js +3 -0
- package/dist/src/utils/web-element.d.ts +2 -0
- package/dist/src/utils/web-element.js +8 -0
- package/dist/src/utils/web-sandbox.d.ts +1 -1
- package/dist/src/utils/web-sandbox.js +2 -3
- package/docs/api-testing/basics.md +90 -0
- package/docs/api-testing/planning.md +57 -0
- package/docs/api-testing/running-tests.md +55 -0
- package/docs/assets/cloud-report.png +0 -0
- package/docs/assets/html-report.png +0 -0
- package/docs/assets/langfuse-trace.png +0 -0
- package/docs/assets/successful-explore-run.png +0 -0
- package/docs/basics/getting-started.md +140 -0
- package/docs/basics/prerequisites.md +63 -0
- package/docs/basics/providers.md +362 -0
- package/docs/basics/running.md +78 -0
- package/docs/contributing/ai-integration-tests.md +57 -0
- package/docs/contributing/contributing.md +90 -0
- package/docs/contributing/demo-videos.md +36 -0
- package/docs/contributing/npm-package.md +138 -0
- package/docs/contributing/observability.md +227 -0
- package/docs/contributing/regression-tests.md +103 -0
- package/docs/contributing/testing.md +95 -0
- package/docs/doc-collection/basics.md +128 -0
- package/docs/doc-collection/crawling.md +67 -0
- package/docs/doc-collection/interactive-mode.md +99 -0
- package/docs/index.json +87 -0
- package/docs/reference/commands.md +997 -0
- package/docs/reference/configuration.md +569 -0
- package/docs/reference/scripting.md +303 -0
- package/docs/reference/websocket.md +50 -0
- package/docs/superpowers/plans/2026-08-01-actor-boat.md +925 -0
- package/docs/superpowers/plans/2026-08-01-prima-boat.md +1120 -0
- package/docs/superpowers/reviews/2026-08-06-prima-vs-playwright-cli.md +268 -0
- package/docs/superpowers/specs/2026-08-01-actor-boat-design.md +204 -0
- package/docs/superpowers/specs/2026-08-01-prima-boat-design.md +242 -0
- package/docs/superpowers/specs/2026-08-03-global-config-design.md +138 -0
- package/docs/superpowers/specs/2026-08-07-prima-fixes-design.md +394 -0
- package/docs/superpowers/specs/2026-08-18-prima-false-verdicts.md +159 -0
- package/docs/web-testing/agents.md +158 -0
- package/docs/web-testing/automated-tests.md +134 -0
- package/docs/web-testing/basics.md +91 -0
- package/docs/web-testing/customization.md +131 -0
- package/docs/web-testing/hooks.md +238 -0
- package/docs/web-testing/page-interaction.md +84 -0
- package/docs/web-testing/planner.md +122 -0
- package/docs/web-testing/rerun.md +164 -0
- package/docs/web-testing/researcher.md +380 -0
- package/docs/workflow/agentic-usage.md +233 -0
- package/docs/workflow/application-spec.md +73 -0
- package/docs/workflow/ci.md +202 -0
- package/docs/workflow/knowledge.md +310 -0
- package/docs/workflow/planning-styles.md +67 -0
- package/docs/workflow/reporting.md +133 -0
- package/docs/workflow/test-plans.md +90 -0
- package/package.json +3 -2
- package/rules/navigator/verification-actions.md +20 -0
- package/src/action-result.ts +11 -0
- package/src/action.ts +43 -3
- package/src/ai/captain/web-mode.ts +6 -3
- package/src/ai/captain.ts +3 -0
- package/src/ai/navigator.ts +255 -186
- package/src/ai/pilot.ts +104 -2
- package/src/ai/provider.ts +14 -24
- package/src/ai/rerunner.ts +2 -1
- package/src/ai/researcher/cache.ts +12 -2
- package/src/ai/researcher.ts +3 -2
- package/src/ai/rules.ts +17 -10
- package/src/ai/session-analyst.ts +2 -0
- package/src/ai/task-agent.ts +3 -1
- package/src/ai/tester.ts +52 -45
- package/src/ai/tools.ts +136 -37
- package/src/commands/config-command.ts +146 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +60 -5
- package/src/execution-controller.ts +8 -0
- package/src/explorbot.ts +7 -3
- package/src/explorer.ts +2 -2
- package/src/playwright-recorder.ts +23 -0
- package/src/remote.ts +244 -0
- package/src/reporter.ts +7 -1
- package/src/state-manager.ts +6 -2
- package/src/stats.ts +1 -0
- package/src/test-plan.ts +29 -0
- package/src/utils/aria.ts +65 -45
- package/src/utils/html.ts +1 -0
- package/src/utils/logger.ts +33 -2
- package/src/utils/page-readiness.ts +24 -1
- package/src/utils/url-matcher.ts +3 -0
- package/src/utils/web-element.ts +9 -0
- package/src/utils/web-sandbox.ts +3 -4
|
@@ -1,47 +1,95 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
1
3
|
import { createRequire } from 'node:module';
|
|
2
4
|
import path from 'node:path';
|
|
5
|
+
import { tool } from 'ai';
|
|
3
6
|
import dedent from 'dedent';
|
|
4
7
|
import * as playwright from 'playwright';
|
|
8
|
+
import { z } from 'zod';
|
|
5
9
|
import { ActionResult } from "../../../src/action-result.js";
|
|
10
|
+
import { getPreviousResearch } from "../../../src/ai/researcher/cache.js";
|
|
6
11
|
import { actionRule, locatorRule } from "../../../src/ai/rules.js";
|
|
7
|
-
import { createCodeceptJSTools } from "../../../src/ai/tools.js";
|
|
12
|
+
import { createAgentTools, createCodeceptJSTools } from "../../../src/ai/tools.js";
|
|
8
13
|
import { getAliveEndpoint, launchServer, listInstances, stopServer } from "../../../src/browser-server.js";
|
|
14
|
+
import { ConfigCommand } from "../../../src/commands/config-command.js";
|
|
9
15
|
import { ConfigMissingError, ConfigParser, outputPath } from "../../../src/config.js";
|
|
10
16
|
import { ExplorBot } from "../../../src/explorbot.js";
|
|
11
|
-
import {
|
|
17
|
+
import { listSites } from "../../../src/global-config.js";
|
|
18
|
+
import { Reporter } from "../../../src/reporter.js";
|
|
19
|
+
import { Stats } from "../../../src/stats.js";
|
|
20
|
+
import { Task, Test, TestResult } from "../../../src/test-plan.js";
|
|
12
21
|
import { compactAriaSnapshot } from "../../../src/utils/aria.js";
|
|
13
22
|
import { browserErrorMessage } from "../../../src/utils/browser-errors.js";
|
|
14
23
|
import { pluralize } from "../../../src/utils/logger.js";
|
|
24
|
+
import { mdq } from "../../../src/utils/markdown-query.js";
|
|
25
|
+
import { safeFilename } from "../../../src/utils/strings.js";
|
|
15
26
|
import { writeArtifacts } from "./envelope.js";
|
|
16
|
-
import { isFunctionExpression, toCodeceptWrapper } from "./pw-parser.js";
|
|
27
|
+
import { isFunctionExpression, takePwValue, toCodeceptWrapper } from "./pw-parser.js";
|
|
17
28
|
import { readDescriptors, selectDescriptor } from "./pw-registry.js";
|
|
18
|
-
|
|
29
|
+
import { latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from "./session-log.js";
|
|
30
|
+
const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
|
|
31
|
+
const ITERATIONS_PER_INSTRUCTION = 2;
|
|
32
|
+
const MAX_INSTRUCTION_ITERATIONS = 24;
|
|
33
|
+
const DEFAULT_RESEARCH_AFTER_VISITS = 3;
|
|
34
|
+
const CONTEXT_HTML_CAP = 6000;
|
|
19
35
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
20
36
|
const AI_AGENT_NAME = 'prima';
|
|
21
37
|
const CONNECT_TIMEOUT = 3000;
|
|
22
38
|
const requireLib = createRequire(import.meta.url);
|
|
39
|
+
const VOLATILE_COLUMNS = ['CSS', 'XPath', 'Coordinates', 'eidx'];
|
|
40
|
+
const UNACCOUNTED = { open: 'the run ended without confirming this one — the actions above are everything that ran' };
|
|
41
|
+
function dropVolatileColumns(markdown) {
|
|
42
|
+
return mdq(markdown)
|
|
43
|
+
.query('table')
|
|
44
|
+
.replaceEach((table) => {
|
|
45
|
+
const rows = table.toJson();
|
|
46
|
+
if (!rows.length)
|
|
47
|
+
return table.text();
|
|
48
|
+
const columns = Object.keys(rows[0]).filter((name) => !VOLATILE_COLUMNS.includes(name));
|
|
49
|
+
if (!columns.length)
|
|
50
|
+
return table.text();
|
|
51
|
+
const header = `| ${columns.join(' | ')} |`;
|
|
52
|
+
const divider = `|${columns.map(() => '------').join('|')}|`;
|
|
53
|
+
const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
|
|
54
|
+
return [header, divider, ...body, ''].join('\n');
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function cap(text, max) {
|
|
58
|
+
if (text.length <= max)
|
|
59
|
+
return text;
|
|
60
|
+
return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
|
|
61
|
+
}
|
|
23
62
|
export class Prima {
|
|
24
63
|
options;
|
|
25
64
|
bot;
|
|
26
65
|
artifactsDir;
|
|
66
|
+
hash;
|
|
67
|
+
sessionUrl;
|
|
27
68
|
server = null;
|
|
28
69
|
attached = null;
|
|
70
|
+
session = null;
|
|
71
|
+
artifacts;
|
|
29
72
|
constructor(options = {}) {
|
|
30
73
|
this.options = options;
|
|
31
74
|
this.bot = new ExplorBot({
|
|
32
75
|
config: options.config,
|
|
33
76
|
path: options.path,
|
|
34
77
|
baseUrl: this.configBaseUrl(),
|
|
35
|
-
verbose: options.verbose,
|
|
36
78
|
session: options.session,
|
|
37
79
|
instance: options.instance,
|
|
38
80
|
headless: true,
|
|
39
81
|
optionalAi: true,
|
|
82
|
+
reporter: { enabled: false },
|
|
40
83
|
});
|
|
41
84
|
}
|
|
42
85
|
async start() {
|
|
86
|
+
let discovery;
|
|
87
|
+
if (!this.options.endpoint) {
|
|
88
|
+
discovery = await this.discover();
|
|
89
|
+
this.adoptSessionUrl(discovery);
|
|
90
|
+
}
|
|
43
91
|
const config = await this.loadConfig();
|
|
44
|
-
await this.resolveBrowser(config);
|
|
92
|
+
await this.resolveBrowser(config, discovery);
|
|
45
93
|
await this.bot.start();
|
|
46
94
|
if (!this.options.url)
|
|
47
95
|
return;
|
|
@@ -57,42 +105,60 @@ export class Prima {
|
|
|
57
105
|
const validation = isFunctionExpression(expression);
|
|
58
106
|
if (!validation.valid)
|
|
59
107
|
return this.toolFailureEnvelope(command, validation.error);
|
|
60
|
-
const previousState = this.
|
|
108
|
+
const previousState = await this.baselineState();
|
|
61
109
|
let result = null;
|
|
110
|
+
let returnedValue;
|
|
62
111
|
let executionError = null;
|
|
63
112
|
try {
|
|
64
113
|
const executed = await this.bot.getExplorer().action().execute(toCodeceptWrapper(expression), { verbatim: true });
|
|
65
114
|
result = executed.actionResult;
|
|
115
|
+
returnedValue = executed.lastValue;
|
|
66
116
|
}
|
|
67
117
|
catch (error) {
|
|
68
118
|
executionError = error;
|
|
69
119
|
}
|
|
70
120
|
if (executionError)
|
|
71
|
-
return this.
|
|
121
|
+
return this.failureEnvelope(command, executionError, previousState);
|
|
72
122
|
result ||= await this.capturedResult(previousState);
|
|
73
|
-
|
|
123
|
+
const envelope = await this.successEnvelope(command, [expression], result, previousState);
|
|
124
|
+
envelope.value = takePwValue(returnedValue);
|
|
125
|
+
return envelope;
|
|
74
126
|
}
|
|
75
|
-
async do(instructions) {
|
|
76
|
-
const command = `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
|
|
127
|
+
async do(instructions, label) {
|
|
128
|
+
const command = label || `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
|
|
77
129
|
const guard = await this.aiGuard(command);
|
|
78
130
|
if (guard)
|
|
79
131
|
return guard;
|
|
80
132
|
const provider = this.bot.getProvider();
|
|
81
|
-
const previousState = this.
|
|
133
|
+
const previousState = await this.baselineState();
|
|
82
134
|
const conversation = provider.startConversation(this.instructionSystemPrompt(), AI_AGENT_NAME);
|
|
83
135
|
const task = new Task(instructions.join('; '), previousState?.url || '');
|
|
84
|
-
const
|
|
85
|
-
|
|
136
|
+
const deps = { explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider };
|
|
137
|
+
const ledger = instructions.map((text) => ({ text, status: 'open', proof: '' }));
|
|
138
|
+
const descent = { markup: false };
|
|
139
|
+
const tools = { ...createCodeceptJSTools(deps, task), ...this.testerTools(deps), context: this.contextTool(descent), completed: this.completedTool(), blocked: this.blockedTool() };
|
|
140
|
+
conversation.addUserText(await this.instructionPrompt(instructions, await this.capturedResult(previousState)));
|
|
86
141
|
const used = [];
|
|
142
|
+
const trace = [];
|
|
87
143
|
let failure = null;
|
|
88
144
|
let aiError = null;
|
|
89
145
|
let narration = '';
|
|
146
|
+
let nudged = false;
|
|
90
147
|
let contextHash = this.bot.stateManager().getCurrentState()?.hash;
|
|
91
|
-
for (let iteration = 1; iteration <= Math.min(instructions.length + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
|
|
148
|
+
for (let iteration = 1; iteration <= Math.min(instructions.length * ITERATIONS_PER_INSTRUCTION + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
|
|
92
149
|
const state = this.bot.stateManager().getCurrentState();
|
|
93
150
|
if (iteration > 1 && state && state.hash !== contextHash) {
|
|
94
151
|
contextHash = state.hash;
|
|
95
|
-
conversation.addUserText(this.pageContext(ActionResult.fromState(state)));
|
|
152
|
+
conversation.addUserText(await this.pageContext(ActionResult.fromState(state)));
|
|
153
|
+
}
|
|
154
|
+
if (iteration > 1) {
|
|
155
|
+
conversation.addUserText(dedent `
|
|
156
|
+
<progress>
|
|
157
|
+
${this.ledgerProgress(ledger)}
|
|
158
|
+
</progress>
|
|
159
|
+
|
|
160
|
+
Call completed() now for every open instruction the page already shows is satisfied, before you act again.
|
|
161
|
+
`);
|
|
96
162
|
}
|
|
97
163
|
const invoked = await provider.invokeConversation(conversation, tools, { maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS, agentName: AI_AGENT_NAME }).catch((error) => {
|
|
98
164
|
aiError = error;
|
|
@@ -103,36 +169,180 @@ export class Prima {
|
|
|
103
169
|
const executions = invoked.toolExecutions || [];
|
|
104
170
|
if (!executions.length) {
|
|
105
171
|
narration = invoked.response?.text?.trim() || '';
|
|
106
|
-
|
|
172
|
+
const unreported = this.openInstructions(ledger);
|
|
173
|
+
if (!unreported || nudged)
|
|
174
|
+
break;
|
|
175
|
+
nudged = true;
|
|
176
|
+
conversation.addUserText(dedent `
|
|
177
|
+
These instructions are still unreported:
|
|
178
|
+
${unreported}
|
|
179
|
+
|
|
180
|
+
Report each one with completed() or blocked(). Do not act again on anything you have already carried out.
|
|
181
|
+
`);
|
|
182
|
+
continue;
|
|
107
183
|
}
|
|
108
184
|
for (const execution of executions) {
|
|
185
|
+
const output = execution.output || {};
|
|
186
|
+
if (this.applyLedgerReport(execution, ledger, trace))
|
|
187
|
+
continue;
|
|
188
|
+
if (output.action === 'verify' && !output.inexpressible) {
|
|
189
|
+
const claim = execution.input?.assertion || 'verification';
|
|
190
|
+
let passed = execution.wasSuccessful;
|
|
191
|
+
if (output.alreadyVerified)
|
|
192
|
+
passed = output.verifications?.[claim] === true;
|
|
193
|
+
trace.push({ label: `verify: ${claim}`, ok: passed, proof: output.code || '' });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
109
196
|
if (!execution.wasSuccessful) {
|
|
110
|
-
failure = { code:
|
|
197
|
+
failure = { code: output.code || '', message: output.message || 'action failed' };
|
|
198
|
+
trace.push({ label: output.code || execution.toolName || 'action', ok: false, proof: output.message || '' });
|
|
199
|
+
await this.writeStepFiles(trace.length, output.code || execution.toolName || 'action', '');
|
|
111
200
|
continue;
|
|
112
201
|
}
|
|
113
|
-
|
|
202
|
+
const codes = this.executedCodes(output.code);
|
|
203
|
+
used.push(...codes);
|
|
204
|
+
trace.push({ label: codes.join('; ') || execution.toolName || 'action', ok: true, proof: '' });
|
|
205
|
+
await this.writeStepFiles(trace.length, codes.join(' ') || execution.toolName || 'action', output.pageDiff?.ariaChanges || '');
|
|
114
206
|
failure = null;
|
|
115
207
|
}
|
|
208
|
+
if (ledger.every((entry) => entry.status !== 'open'))
|
|
209
|
+
break;
|
|
116
210
|
}
|
|
117
211
|
if (aiError)
|
|
118
212
|
return this.failureEnvelope(command, aiError, previousState);
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
213
|
+
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
214
|
+
await this.settleLedger(conversation, provider, ledger, trace);
|
|
215
|
+
}
|
|
216
|
+
const unfinished = ledger.filter((entry) => entry.status !== 'done');
|
|
217
|
+
const steps = [...trace, ...ledger.filter((entry) => entry.status === 'open').map((entry) => ({ label: entry.text, ok: false, unconfirmed: true, proof: UNACCOUNTED.open }))];
|
|
218
|
+
if (failure && unfinished.length) {
|
|
219
|
+
const envelope = await this.failureEnvelope(command, failure.message, previousState);
|
|
220
|
+
envelope.steps = steps;
|
|
221
|
+
envelope.stepFiles = this.statusDir();
|
|
122
222
|
return envelope;
|
|
123
223
|
}
|
|
124
|
-
if (!
|
|
224
|
+
if (!trace.length && unfinished.length === ledger.length) {
|
|
125
225
|
const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
|
|
126
226
|
return this.failureEnvelope(command, reason, previousState);
|
|
127
227
|
}
|
|
128
228
|
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
129
|
-
|
|
229
|
+
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
230
|
+
envelope.steps = steps;
|
|
231
|
+
envelope.stepFiles = this.statusDir();
|
|
232
|
+
// the step log already reports every action and what it changed
|
|
233
|
+
envelope.used = undefined;
|
|
234
|
+
envelope.changes = undefined;
|
|
235
|
+
const blocked = ledger.filter((entry) => entry.status === 'blocked');
|
|
236
|
+
if (blocked.length) {
|
|
237
|
+
envelope.ok = false;
|
|
238
|
+
envelope.failure = { error: blocked.map((entry) => `blocked: ${entry.text}${entry.proof ? ` — ${entry.proof}` : ''}`).join('\n') };
|
|
239
|
+
}
|
|
240
|
+
return envelope;
|
|
241
|
+
}
|
|
242
|
+
openInstructions(ledger) {
|
|
243
|
+
return ledger
|
|
244
|
+
.map((entry, index) => ({ entry, number: index + 1 }))
|
|
245
|
+
.filter(({ entry }) => entry.status === 'open')
|
|
246
|
+
.map(({ entry, number }) => `${number}. ${entry.text}`)
|
|
247
|
+
.join('\n');
|
|
248
|
+
}
|
|
249
|
+
applyLedgerReport(execution, ledger, trace) {
|
|
250
|
+
const action = execution.output?.action;
|
|
251
|
+
if (action === 'completed') {
|
|
252
|
+
const closed = [];
|
|
253
|
+
for (const number of execution.input?.numbers || []) {
|
|
254
|
+
const entry = ledger[number - 1];
|
|
255
|
+
if (entry?.status !== 'open')
|
|
256
|
+
continue;
|
|
257
|
+
entry.status = 'done';
|
|
258
|
+
entry.proof = execution.input?.proof || '';
|
|
259
|
+
closed.push(entry.text);
|
|
260
|
+
}
|
|
261
|
+
// one report carries one proof, however many instructions it closed
|
|
262
|
+
if (closed.length)
|
|
263
|
+
trace.push({ label: `done: ${closed.join('; ')}`, ok: true, proof: execution.input?.proof || '' });
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
if (action !== 'blocked')
|
|
267
|
+
return false;
|
|
268
|
+
const entry = ledger[(execution.input?.instruction || 0) - 1];
|
|
269
|
+
if (entry?.status === 'open') {
|
|
270
|
+
entry.status = 'blocked';
|
|
271
|
+
entry.proof = execution.input?.reason || '';
|
|
272
|
+
trace.push({ label: `blocked: ${entry.text}`, ok: false, proof: entry.proof });
|
|
273
|
+
}
|
|
274
|
+
return true;
|
|
130
275
|
}
|
|
131
|
-
async
|
|
132
|
-
|
|
276
|
+
async settleLedger(conversation, provider, ledger, trace) {
|
|
277
|
+
conversation.addUserText(dedent `
|
|
278
|
+
The run is over and these instructions were never reported:
|
|
279
|
+
|
|
280
|
+
${this.openInstructions(ledger)}
|
|
281
|
+
|
|
282
|
+
Judge each one against what you saw at the time it was due, not against the page as it stands now — later
|
|
283
|
+
instructions have moved it on, and something you confirmed earlier stays confirmed even if it is gone.
|
|
284
|
+
completed() for those, blocked() for the ones the page could not do. Report every one — nothing else runs after this.
|
|
285
|
+
`);
|
|
286
|
+
let settleError = null;
|
|
287
|
+
const invoked = await provider.invokeConversation(conversation, { completed: this.completedTool(), blocked: this.blockedTool() }, { maxToolRoundtrips: 2, toolChoice: 'required', agentName: AI_AGENT_NAME }).catch((error) => {
|
|
288
|
+
settleError = error;
|
|
289
|
+
return null;
|
|
290
|
+
});
|
|
291
|
+
if (settleError)
|
|
292
|
+
trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: browserErrorMessage(settleError) });
|
|
293
|
+
for (const execution of invoked?.toolExecutions || []) {
|
|
294
|
+
this.applyLedgerReport(execution, ledger, trace);
|
|
295
|
+
}
|
|
133
296
|
}
|
|
134
|
-
|
|
135
|
-
return
|
|
297
|
+
ledgerProgress(ledger) {
|
|
298
|
+
return ledger
|
|
299
|
+
.map((entry, index) => {
|
|
300
|
+
const head = `${index + 1}. ${entry.status} — ${entry.text}`;
|
|
301
|
+
if (entry.status === 'open')
|
|
302
|
+
return head;
|
|
303
|
+
return `${head} (${entry.proof})`;
|
|
304
|
+
})
|
|
305
|
+
.join('\n');
|
|
306
|
+
}
|
|
307
|
+
async check(scenario, expected = []) {
|
|
308
|
+
const command = `check ${scenario}`;
|
|
309
|
+
const guard = await this.aiGuard(command);
|
|
310
|
+
if (guard)
|
|
311
|
+
return guard;
|
|
312
|
+
const previousState = await this.baselineState();
|
|
313
|
+
const outcomes = expected.length ? expected : [scenario];
|
|
314
|
+
const test = new Test(scenario, 'normal', outcomes, previousState?.url || this.options.url || '');
|
|
315
|
+
const tester = this.bot.agentTester();
|
|
316
|
+
await tester.test(test, { startOnCurrentPage: true });
|
|
317
|
+
const notes = Object.values(test.notes || {});
|
|
318
|
+
const result = await this.capturedResult(this.bot.stateManager().getCurrentState(), { screenshot: this.visionEnabled() });
|
|
319
|
+
const envelope = await this.reportEnvelope(command, result, previousState, {});
|
|
320
|
+
const recorded = notes.filter((note) => !note.observation && !outcomes.includes(note.message));
|
|
321
|
+
const failed = recorded.filter((note) => note.status === TestResult.FAILED);
|
|
322
|
+
envelope.steps = failed.map((note) => ({ label: note.message, ok: false, proof: note.log || '' }));
|
|
323
|
+
const routine = recorded.length - failed.length;
|
|
324
|
+
if (routine)
|
|
325
|
+
envelope.steps.push({ label: `${routine} further ${pluralize(routine, 'step')} ran without failing — prima status ${envelope.status} for the full log`, ok: true, proof: '' });
|
|
326
|
+
envelope.expectations = await this.bot.agentPilot().settleExpectations(test, result);
|
|
327
|
+
if (!result.screenshot || !this.visionEnabled()) {
|
|
328
|
+
envelope.warning = 'These outcomes were settled from the run log alone — no screenshot backed them. Set ai.visionModel, or check anything visual with prima ask.';
|
|
329
|
+
}
|
|
330
|
+
const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
|
|
331
|
+
const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
|
|
332
|
+
envelope.ok = !unreached.length && !contradicted.length;
|
|
333
|
+
const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
|
|
334
|
+
if (problems.length)
|
|
335
|
+
envelope.failure = { error: problems.join('\n') };
|
|
336
|
+
if (contradicted.length)
|
|
337
|
+
envelope.artifacts = this.artifacts;
|
|
338
|
+
if (!test.hasFinished || test.isSkipped) {
|
|
339
|
+
envelope.ok = false;
|
|
340
|
+
envelope.failure = { error: `the run did not complete, so it established nothing about the app: ${notes.at(-1)?.message || 'no steps were recorded'}` };
|
|
341
|
+
}
|
|
342
|
+
const observations = notes.filter((note) => note.observation).map((note) => note.message);
|
|
343
|
+
if (observations.length)
|
|
344
|
+
envelope.answer = ['Page problems noticed while running, not step failures:', ...observations.map((line) => `- ${line}`)].join('\n');
|
|
345
|
+
return envelope;
|
|
136
346
|
}
|
|
137
347
|
async ask(question) {
|
|
138
348
|
const command = `ask ${question}`;
|
|
@@ -151,9 +361,14 @@ export class Prima {
|
|
|
151
361
|
const previousState = this.bot.stateManager().getCurrentState();
|
|
152
362
|
const result = await this.capturedResult(previousState);
|
|
153
363
|
const verification = await this.bot.agentNavigator().verifyState(assertion, result);
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
364
|
+
const outcome = { assertions: verification.results || [] };
|
|
365
|
+
if (verification.inexpressible) {
|
|
366
|
+
const question = `Judging only from the screenshot, is this true of the page: "${assertion}"? Answer true, false or undetermined, and say what settles it.`;
|
|
367
|
+
const seen = await this.visionAnswer(question, await this.capturedResult(previousState, { screenshot: this.visionEnabled() }));
|
|
368
|
+
if (seen)
|
|
369
|
+
outcome.answer = `No assertion could express this claim, so it was judged from a screenshot instead.\n\n${seen}`;
|
|
370
|
+
}
|
|
371
|
+
return this.reportEnvelope(command, result, previousState, outcome);
|
|
157
372
|
}
|
|
158
373
|
async research(opts = {}) {
|
|
159
374
|
const flags = [opts.data && '--data', opts.deep && '--deep', opts.fresh && '--fresh'].filter(Boolean);
|
|
@@ -164,7 +379,7 @@ export class Prima {
|
|
|
164
379
|
const previousState = this.bot.stateManager().getCurrentState();
|
|
165
380
|
const result = await this.capturedResult(previousState);
|
|
166
381
|
const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
|
|
167
|
-
return this.reportEnvelope(command, result, previousState, { research: uiMap });
|
|
382
|
+
return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
|
|
168
383
|
}
|
|
169
384
|
async go(target) {
|
|
170
385
|
const command = `go ${target}`;
|
|
@@ -184,7 +399,7 @@ export class Prima {
|
|
|
184
399
|
navigationError = error;
|
|
185
400
|
}
|
|
186
401
|
if (navigationError)
|
|
187
|
-
return this.
|
|
402
|
+
return this.failureEnvelope(command, navigationError, previousState);
|
|
188
403
|
const used = [];
|
|
189
404
|
if (isUrl)
|
|
190
405
|
used.push(code);
|
|
@@ -213,6 +428,53 @@ export class Prima {
|
|
|
213
428
|
}
|
|
214
429
|
return stopped;
|
|
215
430
|
}
|
|
431
|
+
async config(json) {
|
|
432
|
+
const [site] = listSites();
|
|
433
|
+
if (site && !this.configBaseUrl())
|
|
434
|
+
this.sessionUrl = site.url;
|
|
435
|
+
const config = await this.loadConfig();
|
|
436
|
+
const parser = ConfigParser.getInstance();
|
|
437
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json });
|
|
438
|
+
}
|
|
439
|
+
record(envelope, durationMs) {
|
|
440
|
+
if (!this.session)
|
|
441
|
+
return;
|
|
442
|
+
recordCommand(sessionFile(this.session.key), this.session, envelope, durationMs);
|
|
443
|
+
}
|
|
444
|
+
async report() {
|
|
445
|
+
const [site] = listSites();
|
|
446
|
+
if (site && !this.configBaseUrl())
|
|
447
|
+
this.sessionUrl = site.url;
|
|
448
|
+
await this.loadConfig();
|
|
449
|
+
let file = latestSessionFile();
|
|
450
|
+
if (this.options.pwSession)
|
|
451
|
+
file = sessionFile(this.options.pwSession);
|
|
452
|
+
if (!file || !existsSync(file))
|
|
453
|
+
return `No prima session was recorded under ${sessionsDir()}. Commands are recorded as they run.`;
|
|
454
|
+
const session = readSession(file);
|
|
455
|
+
if (!session.tests.length)
|
|
456
|
+
return `No commands are recorded in ${file}`;
|
|
457
|
+
Stats.sessionName = path.basename(file, '.jsonl');
|
|
458
|
+
process.env.TESTOMATIO_TITLE = session.title;
|
|
459
|
+
const reporter = new Reporter({ html: true, markdown: true });
|
|
460
|
+
// the report pipes narrate themselves on console.log; prima prints the paths itself
|
|
461
|
+
const speak = console.log;
|
|
462
|
+
console.log = () => { };
|
|
463
|
+
try {
|
|
464
|
+
for (const test of session.tests)
|
|
465
|
+
await reporter.reportTestData(test.status, test);
|
|
466
|
+
await reporter.finishRun();
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
console.log = speak;
|
|
470
|
+
}
|
|
471
|
+
return [
|
|
472
|
+
`${session.tests.length} ${pluralize(session.tests.length, 'command')} from ${file}`,
|
|
473
|
+
`html: ${outputPath('reports', `${Stats.sessionLabel()}.html`)}`,
|
|
474
|
+
`markdown: ${outputPath('reports', `${Stats.sessionLabel()}-tests.md`)}`,
|
|
475
|
+
`upload: TESTOMATIO=<apiKey> npx @testomatio/reporter replay ${file}`,
|
|
476
|
+
].join('\n');
|
|
477
|
+
}
|
|
216
478
|
async browserStatus() {
|
|
217
479
|
await this.loadConfig();
|
|
218
480
|
const info = await this.instanceInfo();
|
|
@@ -256,7 +518,7 @@ export class Prima {
|
|
|
256
518
|
async toolFailureEnvelope(command, error) {
|
|
257
519
|
const state = this.bot.getCurrentState();
|
|
258
520
|
const instance = await this.instanceInfo().catch(() => ({ name: this.instanceName(), tabs: 0, others: [] }));
|
|
259
|
-
const failure = { error: `tool: ${browserErrorMessage(error)}
|
|
521
|
+
const failure = { error: `tool: ${browserErrorMessage(error)}` };
|
|
260
522
|
if (error instanceof ConfigMissingError)
|
|
261
523
|
failure.error = browserErrorMessage(error);
|
|
262
524
|
if (state?.ariaSnapshot)
|
|
@@ -273,32 +535,42 @@ export class Prima {
|
|
|
273
535
|
return ConfigParser.getInstance().loadConfig({ config: this.options.config, path: this.options.path, baseUrl: this.configBaseUrl() });
|
|
274
536
|
}
|
|
275
537
|
configBaseUrl() {
|
|
276
|
-
const url = this.options.baseUrl || this.options.url;
|
|
538
|
+
const url = this.options.baseUrl || this.options.url || this.sessionUrl;
|
|
277
539
|
if (!url)
|
|
278
540
|
return undefined;
|
|
279
541
|
if (!URL.canParse(url))
|
|
280
542
|
return undefined;
|
|
281
543
|
return url;
|
|
282
544
|
}
|
|
283
|
-
|
|
545
|
+
adoptSessionUrl(discovery) {
|
|
546
|
+
if (this.options.baseUrl || this.options.url)
|
|
547
|
+
return;
|
|
548
|
+
const url = discovery.browser?.contexts()[0]?.pages()[0]?.url();
|
|
549
|
+
if (!url?.startsWith('http'))
|
|
550
|
+
return;
|
|
551
|
+
this.sessionUrl = new URL(url).origin;
|
|
552
|
+
this.bot.getOptions().baseUrl = this.sessionUrl;
|
|
553
|
+
}
|
|
554
|
+
async resolveBrowser(config, discovered) {
|
|
284
555
|
if (this.options.endpoint) {
|
|
285
556
|
const endpoint = this.options.endpoint;
|
|
286
557
|
const browserName = config.playwright.browser || 'chromium';
|
|
287
|
-
|
|
558
|
+
const known = readDescriptors().find((descriptor) => descriptor.endpoint === endpoint);
|
|
559
|
+
if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: known?.playwrightLib || '' }))
|
|
288
560
|
return;
|
|
289
561
|
throw new Error(dedent `
|
|
290
562
|
No browser answered at ${endpoint}.
|
|
291
|
-
Check the endpoint of the running session, or drop --endpoint to
|
|
292
|
-
playwright-cli
|
|
563
|
+
Check the endpoint of the running session, or drop --endpoint to let prima pick
|
|
564
|
+
the playwright-cli session itself.
|
|
293
565
|
`);
|
|
294
566
|
}
|
|
295
|
-
const { match, candidates, browser } = await this.discover();
|
|
567
|
+
const { match, candidates, browser } = discovered || (await this.discover());
|
|
296
568
|
if (match && (await this.attachToEndpoint(match, browser)))
|
|
297
569
|
return;
|
|
298
570
|
if (!match && candidates.length) {
|
|
299
571
|
const titles = candidates.map((candidate) => candidate.title).join(', ');
|
|
300
572
|
throw new Error(dedent `
|
|
301
|
-
Several playwright-cli sessions are open
|
|
573
|
+
Several playwright-cli sessions are open: ${titles}
|
|
302
574
|
Pick one with --pw-session <title>.
|
|
303
575
|
`);
|
|
304
576
|
}
|
|
@@ -314,7 +586,7 @@ export class Prima {
|
|
|
314
586
|
}
|
|
315
587
|
async discover(descriptors = readDescriptors()) {
|
|
316
588
|
const title = this.options.pwSession ?? process.env.PLAYWRIGHT_CLI_SESSION;
|
|
317
|
-
const opts = {
|
|
589
|
+
const opts = { title };
|
|
318
590
|
const alive = new Map();
|
|
319
591
|
for (const candidate of selectDescriptor(descriptors, opts).candidates) {
|
|
320
592
|
const browser = await this.connectDescriptor(candidate);
|
|
@@ -338,13 +610,14 @@ export class Prima {
|
|
|
338
610
|
return false;
|
|
339
611
|
this.bot.attachBrowser(browser);
|
|
340
612
|
this.attached = this.attachmentLabel(descriptor);
|
|
613
|
+
this.session = { key: descriptor.title || this.instanceName(), endpoint: descriptor.endpoint, title: `prima session "${descriptor.title || this.instanceName()}"` };
|
|
341
614
|
return true;
|
|
342
615
|
}
|
|
343
616
|
async connectDescriptor(descriptor) {
|
|
344
|
-
const connected = await this.connectWith(
|
|
617
|
+
const connected = await this.connectWith(this.descriptorLib(descriptor), descriptor);
|
|
345
618
|
if (connected)
|
|
346
619
|
return connected;
|
|
347
|
-
return this.connectWith(
|
|
620
|
+
return this.connectWith(playwright, descriptor);
|
|
348
621
|
}
|
|
349
622
|
async connectWith(lib, descriptor) {
|
|
350
623
|
const launcher = lib?.[descriptor.browserName];
|
|
@@ -365,13 +638,16 @@ export class Prima {
|
|
|
365
638
|
attachmentLabel(descriptor) {
|
|
366
639
|
if (!descriptor.title)
|
|
367
640
|
return `endpoint ${descriptor.endpoint}`;
|
|
641
|
+
if (!descriptor.workspaceDir)
|
|
642
|
+
return `playwright-cli session "${descriptor.title}"`;
|
|
368
643
|
return `playwright-cli session "${descriptor.title}", workspace ${descriptor.workspaceDir}`;
|
|
369
644
|
}
|
|
370
|
-
workspaceDir() {
|
|
371
|
-
return path.resolve(this.options.path || process.cwd());
|
|
372
|
-
}
|
|
373
645
|
async connectOwnInstance() {
|
|
374
|
-
|
|
646
|
+
const endpoint = await getAliveEndpoint(this.instanceName());
|
|
647
|
+
if (!endpoint)
|
|
648
|
+
return false;
|
|
649
|
+
this.session = { key: this.instanceName(), endpoint, title: `prima instance "${this.instanceName()}"` };
|
|
650
|
+
return true;
|
|
375
651
|
}
|
|
376
652
|
async launchOwnServer(opts, instance) {
|
|
377
653
|
return launchServer(opts, instance);
|
|
@@ -390,45 +666,6 @@ export class Prima {
|
|
|
390
666
|
return true;
|
|
391
667
|
return URL.canParse(value);
|
|
392
668
|
}
|
|
393
|
-
async heal(command, expression, error, previousState) {
|
|
394
|
-
if (this.options.heal === false)
|
|
395
|
-
return this.failureEnvelope(command, error, previousState);
|
|
396
|
-
const navigator = this.healNavigator();
|
|
397
|
-
if (!navigator) {
|
|
398
|
-
const envelope = await this.failureEnvelope(command, error, previousState);
|
|
399
|
-
envelope.healed = false;
|
|
400
|
-
envelope.healNote = this.aiUnavailableNote();
|
|
401
|
-
return envelope;
|
|
402
|
-
}
|
|
403
|
-
const message = dedent `
|
|
404
|
-
I tried to run this command on the page: ${expression}
|
|
405
|
-
But it failed with: ${browserErrorMessage(error)}
|
|
406
|
-
Reach the same outcome on the current page in a different way.
|
|
407
|
-
`;
|
|
408
|
-
const attempts = [];
|
|
409
|
-
const failedResult = await this.capturedResult(previousState);
|
|
410
|
-
const resolved = await navigator.resolveState(message, failedResult, { onAttempt: (attempt) => attempts.push(attempt) }).catch(() => false);
|
|
411
|
-
if (!resolved) {
|
|
412
|
-
const healAttempts = attempts.map((attempt) => ({ code: attempt.code, outcome: attempt.error || 'ok' }));
|
|
413
|
-
return this.failureEnvelope(command, error, previousState, healAttempts);
|
|
414
|
-
}
|
|
415
|
-
const used = attempts.filter((attempt) => !attempt.error).map((attempt) => attempt.code);
|
|
416
|
-
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
417
|
-
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
418
|
-
envelope.healed = true;
|
|
419
|
-
envelope.healNote = `recovered after ${attempts.length} ${pluralize(attempts.length, 'attempt')}`;
|
|
420
|
-
return envelope;
|
|
421
|
-
}
|
|
422
|
-
healNavigator() {
|
|
423
|
-
if (this.aiUnavailable())
|
|
424
|
-
return null;
|
|
425
|
-
try {
|
|
426
|
-
return this.bot.agentNavigator?.() ?? null;
|
|
427
|
-
}
|
|
428
|
-
catch {
|
|
429
|
-
return null;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
669
|
aiUnavailable() {
|
|
433
670
|
try {
|
|
434
671
|
if (this.bot.getProvider?.())
|
|
@@ -439,12 +676,6 @@ export class Prima {
|
|
|
439
676
|
}
|
|
440
677
|
return this.bot.aiFailureReason?.() || 'no AI model is configured';
|
|
441
678
|
}
|
|
442
|
-
aiUnavailableNote() {
|
|
443
|
-
const reason = this.aiUnavailable();
|
|
444
|
-
if (!reason)
|
|
445
|
-
return 'ai unavailable';
|
|
446
|
-
return `ai unavailable: ${reason}`;
|
|
447
|
-
}
|
|
448
679
|
async aiGuard(command) {
|
|
449
680
|
const reason = this.aiUnavailable();
|
|
450
681
|
if (!reason)
|
|
@@ -459,49 +690,191 @@ export class Prima {
|
|
|
459
690
|
</role>
|
|
460
691
|
|
|
461
692
|
<approach>
|
|
462
|
-
1. Read the page context and
|
|
693
|
+
1. Read the page context and carry out the instructions in the order they are listed.
|
|
463
694
|
2. Interact with the page only through the provided tools.
|
|
464
695
|
3. Pick the smallest interaction that fulfills an instruction, then move to the next one.
|
|
465
696
|
4. After the page changes, work from the updated context you are given, not from the earlier one.
|
|
466
|
-
5.
|
|
697
|
+
5. Account for every instruction: completed() as soon as one is satisfied, blocked() when the page cannot do what it asks.
|
|
467
698
|
</approach>
|
|
468
699
|
|
|
700
|
+
<ledger>
|
|
701
|
+
Instructions are numbered and those numbers never change. Report by number.
|
|
702
|
+
Saying in your reply that something is done does not report it — only completed() does. Nothing you write is read as a report.
|
|
703
|
+
Report an instruction the moment the page shows it is satisfied, before moving on. Waiting until later is how work gets repeated.
|
|
704
|
+
An instruction you have reported is finished. Never act on it again, and never report it twice.
|
|
705
|
+
After each turn you are shown every instruction with its state. Act only on the ones still open — repeating an action that already
|
|
706
|
+
landed can undo it, since a control that opened something will close it again.
|
|
707
|
+
Reaching for blocked() after a couple of honest attempts costs less than a third attempt that fails the same way.
|
|
708
|
+
</ledger>
|
|
709
|
+
|
|
710
|
+
<scope>
|
|
711
|
+
Do only what the instructions ask. An action that looks helpful but was not asked for is out of scope — report it as something you noticed, never perform it.
|
|
712
|
+
Continuing past the last instruction is a failure, even when the next step seems obvious.
|
|
713
|
+
An instruction worded as a condition — do X if Y appears — is satisfied the moment you can see Y is absent. Say so and move on. Never search for something the page does not show.
|
|
714
|
+
</scope>
|
|
715
|
+
|
|
716
|
+
<pace>
|
|
717
|
+
Work in as few turns as you can. When the next actions are already determined by what you can see, ask for them together in one turn rather than one at a time — each turn costs a full round trip.
|
|
718
|
+
Only stop to look again when what you find changes what you would do next.
|
|
719
|
+
A batch may not run past an instruction that inspects the page — settle that one first, because the actions after it destroy the state it would have read.
|
|
720
|
+
</pace>
|
|
721
|
+
|
|
722
|
+
<proof>
|
|
723
|
+
An instruction is done only when a change on the page shows it. After each action read the reported change and decide which part of it proves the instruction.
|
|
724
|
+
That part is what completed() takes as its proof. Do not restate the action as if it were the outcome.
|
|
725
|
+
How much of the page moved is not evidence of whether it happened — a change confined to one region proves an instruction as well as one that redraws everything.
|
|
726
|
+
An instruction that only inspects the page is satisfied by what you can see, including seeing that something is absent — those need no action at all.
|
|
727
|
+
</proof>
|
|
728
|
+
|
|
729
|
+
<targets>
|
|
730
|
+
The page context lists every element with a ref, like [ref=e14]. To click one, pass that ref to clickRef — a ref names one
|
|
731
|
+
exact element, so it cannot match several by mistake and costs nothing to resolve. This is the cheapest way to act.
|
|
732
|
+
Use click() with a role and name for anything clickRef cannot take, and narrow with the container it sits in when a name
|
|
733
|
+
appears more than once, rather than guessing at an id or a class.
|
|
734
|
+
Refs belong to the context you were given. Use the ones in your newest context, never one you invented or remembered from
|
|
735
|
+
an older page. When the element an instruction needs is missing from that context, call context() and act on what it returns.
|
|
736
|
+
</targets>
|
|
737
|
+
|
|
469
738
|
${locatorRule}
|
|
470
739
|
|
|
471
740
|
${actionRule}
|
|
741
|
+
|
|
742
|
+
<targets_first>
|
|
743
|
+
Everything above about composing locators applies to click() and the other locator tools. It does not apply when the
|
|
744
|
+
element carries a ref: pass that ref to clickRef instead and compose nothing. Reach for a locator only for elements
|
|
745
|
+
that have no ref, or when a ref has stopped resolving.
|
|
746
|
+
</targets_first>
|
|
472
747
|
`;
|
|
473
748
|
}
|
|
474
|
-
instructionPrompt(instructions, result) {
|
|
749
|
+
async instructionPrompt(instructions, result) {
|
|
475
750
|
const list = instructions.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n');
|
|
476
751
|
return dedent `
|
|
477
752
|
<instructions>
|
|
478
753
|
${list}
|
|
479
754
|
</instructions>
|
|
480
755
|
|
|
481
|
-
${this.pageContext(result)}
|
|
756
|
+
${await this.pageContext(result)}
|
|
482
757
|
`;
|
|
483
758
|
}
|
|
484
|
-
|
|
759
|
+
testerTools(deps) {
|
|
760
|
+
const researcher = this.bot.agentResearcher?.();
|
|
761
|
+
const navigator = this.bot.agentNavigator?.();
|
|
762
|
+
if (!researcher || !navigator)
|
|
763
|
+
return {};
|
|
764
|
+
const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
|
|
765
|
+
for (const name of TESTER_ONLY_TOOLS)
|
|
766
|
+
delete tools[name];
|
|
767
|
+
return tools;
|
|
768
|
+
}
|
|
769
|
+
completedTool() {
|
|
770
|
+
return tool({
|
|
771
|
+
description: dedent `
|
|
772
|
+
Report the instructions you have just satisfied, by their number. Report several together when one turn satisfied several.
|
|
773
|
+
A reported instruction is finished — you will not be asked for it again and must not act on it again.
|
|
774
|
+
`,
|
|
775
|
+
inputSchema: z.object({
|
|
776
|
+
numbers: z.array(z.number()).describe('Numbers of the instructions now satisfied, as they are numbered in the instruction list'),
|
|
777
|
+
proof: z.string().describe('What on the page shows they are satisfied'),
|
|
778
|
+
}),
|
|
779
|
+
execute: async () => ({ success: true, action: 'completed' }),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
blockedTool() {
|
|
783
|
+
return tool({
|
|
784
|
+
description: dedent `
|
|
785
|
+
Report one instruction that cannot be carried out on this page, by its number. Reach for this instead of trying the same thing again.
|
|
786
|
+
The rest of the sequence continues without it.
|
|
787
|
+
`,
|
|
788
|
+
inputSchema: z.object({
|
|
789
|
+
instruction: z.number().describe('Number of the instruction that cannot be carried out'),
|
|
790
|
+
reason: z.string().describe('What stopped it — what you looked for and what the page showed instead'),
|
|
791
|
+
}),
|
|
792
|
+
execute: async () => ({ success: true, action: 'blocked' }),
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
contextTool(descent) {
|
|
796
|
+
let refreshed = false;
|
|
797
|
+
return tool({
|
|
798
|
+
description: dedent `
|
|
799
|
+
Look at the page again when the refs you hold no longer resolve, or when the element an instruction needs is not in the context you were given.
|
|
800
|
+
The first call returns the page as it is now, with fresh refs that replace every ref you were holding.
|
|
801
|
+
A later call on the same page drops to the raw markup, for elements the accessibility tree does not describe.
|
|
802
|
+
Do not call it to confirm an action worked — the change is already reported back to you.
|
|
803
|
+
`,
|
|
804
|
+
inputSchema: z.object({
|
|
805
|
+
reason: z.string().describe('Which element you cannot reach and what you already tried'),
|
|
806
|
+
}),
|
|
807
|
+
execute: async () => {
|
|
808
|
+
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
809
|
+
if (!refreshed) {
|
|
810
|
+
refreshed = true;
|
|
811
|
+
return { success: true, context: await this.pageContext(result) };
|
|
812
|
+
}
|
|
813
|
+
descent.markup = true;
|
|
814
|
+
return { success: true, context: cap(await result.simplifiedHtml(), CONTEXT_HTML_CAP) };
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
async pageContext(result) {
|
|
485
819
|
const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
|
|
820
|
+
const map = this.researchMap(result);
|
|
821
|
+
if (map) {
|
|
822
|
+
return dedent `
|
|
823
|
+
<page_ui_map url="${result.url}" title="${result.title}">
|
|
824
|
+
${map}
|
|
825
|
+
</page_ui_map>
|
|
826
|
+
|
|
827
|
+
${experience}
|
|
828
|
+
`;
|
|
829
|
+
}
|
|
486
830
|
return dedent `
|
|
487
831
|
<page url="${result.url}" title="${result.title}">
|
|
488
|
-
${compactAriaSnapshot(result
|
|
832
|
+
${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
|
|
489
833
|
</page>
|
|
490
834
|
|
|
491
835
|
${experience}
|
|
492
836
|
`;
|
|
493
837
|
}
|
|
838
|
+
researchMap(result) {
|
|
839
|
+
if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits())
|
|
840
|
+
return '';
|
|
841
|
+
return getPreviousResearch(result.getStateHash());
|
|
842
|
+
}
|
|
843
|
+
researchAfterVisits() {
|
|
844
|
+
const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
|
|
845
|
+
if (typeof configured === 'number')
|
|
846
|
+
return configured;
|
|
847
|
+
return DEFAULT_RESEARCH_AFTER_VISITS;
|
|
848
|
+
}
|
|
849
|
+
offloadValue(value) {
|
|
850
|
+
const dir = this.statusDir();
|
|
851
|
+
const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
|
|
852
|
+
try {
|
|
853
|
+
mkdirSync(dir, { recursive: true });
|
|
854
|
+
writeFileSync(path.join(dir, name), value, 'utf-8');
|
|
855
|
+
}
|
|
856
|
+
catch {
|
|
857
|
+
return undefined;
|
|
858
|
+
}
|
|
859
|
+
return path.join(path.basename(dir), name);
|
|
860
|
+
}
|
|
861
|
+
async refAriaSnapshot(result) {
|
|
862
|
+
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.((page) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
|
|
863
|
+
return snapshot || result.ariaSnapshot;
|
|
864
|
+
}
|
|
494
865
|
executedCodes(code) {
|
|
495
866
|
if (typeof code !== 'string')
|
|
496
867
|
return [];
|
|
497
868
|
return code
|
|
498
869
|
.split('\n')
|
|
499
870
|
.map((line) => line.trim())
|
|
500
|
-
.filter((line) => line);
|
|
871
|
+
.filter((line) => line && !line.startsWith('//'));
|
|
501
872
|
}
|
|
502
873
|
visionEnabled() {
|
|
503
874
|
if (this.options.noVision)
|
|
504
875
|
return false;
|
|
876
|
+
if (Stats.visionDisabled)
|
|
877
|
+
return false;
|
|
505
878
|
return this.bot.getProvider().hasVision?.() === true;
|
|
506
879
|
}
|
|
507
880
|
async answer(question, result) {
|
|
@@ -542,13 +915,6 @@ export class Prima {
|
|
|
542
915
|
const response = await provider.chat([{ role: 'user', content: prompt }], provider.getModelForAgent?.(AI_AGENT_NAME), { agentName: AI_AGENT_NAME });
|
|
543
916
|
return response?.text || '';
|
|
544
917
|
}
|
|
545
|
-
verdictEvidence(verified, codes) {
|
|
546
|
-
if (!verified)
|
|
547
|
-
return 'no assertion held on the current page';
|
|
548
|
-
if (!codes.length)
|
|
549
|
-
return 'already verified on this page';
|
|
550
|
-
return `${codes[0]} passed`;
|
|
551
|
-
}
|
|
552
918
|
async successEnvelope(command, used, result, previousState) {
|
|
553
919
|
return {
|
|
554
920
|
ok: true,
|
|
@@ -557,23 +923,21 @@ export class Prima {
|
|
|
557
923
|
page: this.pageBlock(result, previousState),
|
|
558
924
|
changes: await this.pageChanges(result, previousState, used[0]),
|
|
559
925
|
instance: await this.instanceInfo(),
|
|
560
|
-
|
|
926
|
+
status: await this.saveStatus(result),
|
|
561
927
|
};
|
|
562
928
|
}
|
|
563
|
-
async failureEnvelope(command, error, previousState
|
|
929
|
+
async failureEnvelope(command, error, previousState) {
|
|
564
930
|
const result = await this.capturedResult(previousState);
|
|
565
|
-
const failure = { error: browserErrorMessage(error)
|
|
931
|
+
const failure = { error: browserErrorMessage(error) };
|
|
566
932
|
if (result.ariaSnapshot)
|
|
567
933
|
failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
|
|
568
|
-
if (attempts.length)
|
|
569
|
-
failure.reasoning = [...new Set(attempts.map((attempt) => attempt.outcome))].join('; ');
|
|
570
934
|
return {
|
|
571
935
|
ok: false,
|
|
572
936
|
command,
|
|
573
937
|
page: this.pageBlock(result, previousState),
|
|
574
938
|
failure,
|
|
575
939
|
instance: await this.instanceInfo(),
|
|
576
|
-
|
|
940
|
+
status: await this.saveStatus(result),
|
|
577
941
|
};
|
|
578
942
|
}
|
|
579
943
|
async reportEnvelope(command, result, previousState, outcome) {
|
|
@@ -583,7 +947,7 @@ export class Prima {
|
|
|
583
947
|
page: this.pageBlock(result, previousState),
|
|
584
948
|
...outcome,
|
|
585
949
|
instance: await this.instanceInfo(),
|
|
586
|
-
|
|
950
|
+
status: await this.saveStatus(result),
|
|
587
951
|
};
|
|
588
952
|
}
|
|
589
953
|
async capturedResult(previousState, opts = {}) {
|
|
@@ -606,22 +970,72 @@ export class Prima {
|
|
|
606
970
|
visits: this.bot.stateManager().getVisitCount(result.url),
|
|
607
971
|
};
|
|
608
972
|
}
|
|
973
|
+
async baselineState() {
|
|
974
|
+
const existing = this.bot.stateManager?.()?.getCurrentState();
|
|
975
|
+
if (existing)
|
|
976
|
+
return existing;
|
|
977
|
+
const result = await Promise.resolve(this.bot.getExplorer?.()?.capture?.()).catch(() => null);
|
|
978
|
+
if (!result)
|
|
979
|
+
return null;
|
|
980
|
+
return this.bot.stateManager?.()?.updateState(result) ?? null;
|
|
981
|
+
}
|
|
609
982
|
async pageChanges(result, previousState, code) {
|
|
610
983
|
if (!previousState)
|
|
611
|
-
return
|
|
984
|
+
return 'no snapshot was captured before this command, so nothing could be compared';
|
|
612
985
|
const toolResult = await result.toToolResult(ActionResult.fromState(previousState), code);
|
|
613
|
-
return toolResult.pageDiff?.ariaChanges
|
|
986
|
+
return toolResult.pageDiff?.ariaChanges || 'no change';
|
|
987
|
+
}
|
|
988
|
+
async status(hash) {
|
|
989
|
+
const dir = this.statusDir(hash);
|
|
990
|
+
const statusFile = path.join(dir, 'status.json');
|
|
991
|
+
if (!existsSync(statusFile))
|
|
992
|
+
return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${hash}. Every envelope prints its own hash on the Instance line.`);
|
|
993
|
+
const saved = JSON.parse(readFileSync(statusFile, 'utf-8'));
|
|
994
|
+
return {
|
|
995
|
+
ok: true,
|
|
996
|
+
command: `status ${hash}`,
|
|
997
|
+
page: saved.page,
|
|
998
|
+
instance: await this.instanceInfo(),
|
|
999
|
+
artifacts: { aria: path.join(dir, 'aria.yml'), html: path.join(dir, 'page.html') },
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
async saveStatus(result) {
|
|
1003
|
+
const hash = this.statusHash();
|
|
1004
|
+
await this.writeSnapshot(result);
|
|
1005
|
+
writeFileSync(path.join(this.statusDir(hash), 'status.json'), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
|
|
1006
|
+
return hash;
|
|
1007
|
+
}
|
|
1008
|
+
async writeStepFiles(index, label, diff) {
|
|
1009
|
+
const state = this.bot.stateManager().getCurrentState();
|
|
1010
|
+
if (!state)
|
|
1011
|
+
return;
|
|
1012
|
+
const dir = this.statusDir();
|
|
1013
|
+
mkdirSync(dir, { recursive: true });
|
|
1014
|
+
const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
|
|
1015
|
+
const result = ActionResult.fromState(state);
|
|
1016
|
+
writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
|
|
1017
|
+
writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
|
|
1018
|
+
if (diff)
|
|
1019
|
+
writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
|
|
614
1020
|
}
|
|
615
1021
|
async writeSnapshot(result) {
|
|
616
|
-
|
|
1022
|
+
this.artifacts = writeArtifacts(this.statusDir(), {
|
|
617
1023
|
aria: result.ariaSnapshot,
|
|
618
1024
|
html: await result.combinedHtml(),
|
|
1025
|
+
screenshot: result.screenshot,
|
|
619
1026
|
requests: this.bot.requestStore().getRequests(),
|
|
620
1027
|
});
|
|
621
1028
|
}
|
|
622
|
-
|
|
1029
|
+
statusHash() {
|
|
1030
|
+
this.hash ||= createHash('sha1')
|
|
1031
|
+
.update(`${this.options.path || process.cwd()}-${Date.now()}`)
|
|
1032
|
+
.digest('hex')
|
|
1033
|
+
.slice(0, 15);
|
|
1034
|
+
return this.hash;
|
|
1035
|
+
}
|
|
1036
|
+
statusDir(hash = this.statusHash()) {
|
|
623
1037
|
this.artifactsDir ||= outputPath('prima');
|
|
624
|
-
return path.join(this.artifactsDir,
|
|
1038
|
+
return path.join(this.artifactsDir, hash);
|
|
625
1039
|
}
|
|
626
1040
|
tabCount() {
|
|
627
1041
|
const page = this.bot.getExplorer()?.page;
|