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
package/boat/prima/src/prima.ts
CHANGED
|
@@ -1,36 +1,80 @@
|
|
|
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';
|
|
5
8
|
import type { Browser } from 'playwright';
|
|
9
|
+
import { z } from 'zod';
|
|
6
10
|
import { ActionResult } from '../../../src/action-result.ts';
|
|
7
|
-
import
|
|
11
|
+
import { getPreviousResearch } from '../../../src/ai/researcher/cache.ts';
|
|
8
12
|
import { actionRule, locatorRule } from '../../../src/ai/rules.ts';
|
|
9
|
-
import { createCodeceptJSTools } from '../../../src/ai/tools.ts';
|
|
13
|
+
import { createAgentTools, createCodeceptJSTools } from '../../../src/ai/tools.ts';
|
|
10
14
|
import { getAliveEndpoint, launchServer, listInstances, stopServer } from '../../../src/browser-server.ts';
|
|
15
|
+
import { ConfigCommand } from '../../../src/commands/config-command.ts';
|
|
11
16
|
import { ConfigMissingError, ConfigParser, type ExplorbotConfig, outputPath } from '../../../src/config.ts';
|
|
12
17
|
import { ExplorBot } from '../../../src/explorbot.ts';
|
|
18
|
+
import { listSites } from '../../../src/global-config.ts';
|
|
19
|
+
import { Reporter } from '../../../src/reporter.ts';
|
|
13
20
|
import type { WebPageState } from '../../../src/state-manager.ts';
|
|
14
|
-
import {
|
|
21
|
+
import { Stats } from '../../../src/stats.ts';
|
|
22
|
+
import { Task, Test, TestResult } from '../../../src/test-plan.ts';
|
|
15
23
|
import { compactAriaSnapshot } from '../../../src/utils/aria.ts';
|
|
16
24
|
import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
|
|
17
25
|
import { pluralize } from '../../../src/utils/logger.ts';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
26
|
+
import { mdq } from '../../../src/utils/markdown-query.ts';
|
|
27
|
+
import { safeFilename } from '../../../src/utils/strings.ts';
|
|
28
|
+
import { type EnvelopeData, type InstanceInfo, writeArtifacts } from './envelope.ts';
|
|
29
|
+
import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
|
|
20
30
|
import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
|
|
31
|
+
import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
|
|
21
32
|
|
|
22
|
-
const
|
|
33
|
+
const TESTER_ONLY_TOOLS = ['learnExperience', 'askUser'];
|
|
34
|
+
const ITERATIONS_PER_INSTRUCTION = 2;
|
|
35
|
+
const MAX_INSTRUCTION_ITERATIONS = 24;
|
|
36
|
+
const DEFAULT_RESEARCH_AFTER_VISITS = 3;
|
|
37
|
+
const CONTEXT_HTML_CAP = 6000;
|
|
23
38
|
const MAX_TOOL_ROUNDTRIPS = 5;
|
|
24
39
|
const AI_AGENT_NAME = 'prima';
|
|
25
40
|
const CONNECT_TIMEOUT = 3000;
|
|
26
41
|
const requireLib = createRequire(import.meta.url);
|
|
27
42
|
|
|
43
|
+
const VOLATILE_COLUMNS = ['CSS', 'XPath', 'Coordinates', 'eidx'];
|
|
44
|
+
const UNACCOUNTED: Record<string, string> = { open: 'the run ended without confirming this one — the actions above are everything that ran' };
|
|
45
|
+
|
|
46
|
+
function dropVolatileColumns(markdown: string): string {
|
|
47
|
+
return mdq(markdown)
|
|
48
|
+
.query('table')
|
|
49
|
+
.replaceEach((table) => {
|
|
50
|
+
const rows = table.toJson();
|
|
51
|
+
if (!rows.length) return table.text();
|
|
52
|
+
|
|
53
|
+
const columns = Object.keys(rows[0]).filter((name) => !VOLATILE_COLUMNS.includes(name));
|
|
54
|
+
if (!columns.length) return table.text();
|
|
55
|
+
|
|
56
|
+
const header = `| ${columns.join(' | ')} |`;
|
|
57
|
+
const divider = `|${columns.map(() => '------').join('|')}|`;
|
|
58
|
+
const body = rows.map((row) => `| ${columns.map((name) => row[name] || '-').join(' | ')} |`);
|
|
59
|
+
return [header, divider, ...body, ''].join('\n');
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cap(text: string, max: number): string {
|
|
64
|
+
if (text.length <= max) return text;
|
|
65
|
+
return `${text.slice(0, max)}\n[...truncated; ${text.length - max} chars omitted...]`;
|
|
66
|
+
}
|
|
67
|
+
|
|
28
68
|
export class Prima {
|
|
29
69
|
private options: PrimaOptions;
|
|
30
70
|
private bot: ExplorBot;
|
|
31
71
|
private artifactsDir?: string;
|
|
72
|
+
private hash?: string;
|
|
73
|
+
private sessionUrl?: string;
|
|
32
74
|
private server: { close: () => Promise<void> } | null = null;
|
|
33
75
|
private attached: string | null = null;
|
|
76
|
+
private session: SessionRun | null = null;
|
|
77
|
+
private artifacts?: EnvelopeData['artifacts'];
|
|
34
78
|
|
|
35
79
|
constructor(options: PrimaOptions = {}) {
|
|
36
80
|
this.options = options;
|
|
@@ -38,17 +82,23 @@ export class Prima {
|
|
|
38
82
|
config: options.config,
|
|
39
83
|
path: options.path,
|
|
40
84
|
baseUrl: this.configBaseUrl(),
|
|
41
|
-
verbose: options.verbose,
|
|
42
85
|
session: options.session,
|
|
43
86
|
instance: options.instance,
|
|
44
87
|
headless: true,
|
|
45
88
|
optionalAi: true,
|
|
89
|
+
reporter: { enabled: false },
|
|
46
90
|
});
|
|
47
91
|
}
|
|
48
92
|
|
|
49
93
|
async start(): Promise<void> {
|
|
94
|
+
let discovery: Discovery | undefined;
|
|
95
|
+
if (!this.options.endpoint) {
|
|
96
|
+
discovery = await this.discover();
|
|
97
|
+
this.adoptSessionUrl(discovery);
|
|
98
|
+
}
|
|
99
|
+
|
|
50
100
|
const config = await this.loadConfig();
|
|
51
|
-
await this.resolveBrowser(config);
|
|
101
|
+
await this.resolveBrowser(config, discovery);
|
|
52
102
|
await this.bot.start();
|
|
53
103
|
|
|
54
104
|
if (!this.options.url) return;
|
|
@@ -65,46 +115,65 @@ export class Prima {
|
|
|
65
115
|
const validation = isFunctionExpression(expression);
|
|
66
116
|
if (!validation.valid) return this.toolFailureEnvelope(command, validation.error!);
|
|
67
117
|
|
|
68
|
-
const previousState = this.
|
|
118
|
+
const previousState = await this.baselineState();
|
|
69
119
|
let result: ActionResult | null = null;
|
|
120
|
+
let returnedValue: unknown;
|
|
70
121
|
let executionError: unknown = null;
|
|
71
122
|
|
|
72
123
|
try {
|
|
73
124
|
const executed = await this.bot.getExplorer().action().execute(toCodeceptWrapper(expression), { verbatim: true });
|
|
74
125
|
result = executed.actionResult;
|
|
126
|
+
returnedValue = executed.lastValue;
|
|
75
127
|
} catch (error) {
|
|
76
128
|
executionError = error;
|
|
77
129
|
}
|
|
78
130
|
|
|
79
|
-
if (executionError) return this.
|
|
131
|
+
if (executionError) return this.failureEnvelope(command, executionError, previousState);
|
|
80
132
|
|
|
81
133
|
result ||= await this.capturedResult(previousState);
|
|
82
|
-
|
|
134
|
+
const envelope = await this.successEnvelope(command, [expression], result, previousState);
|
|
135
|
+
envelope.value = takePwValue(returnedValue);
|
|
136
|
+
return envelope;
|
|
83
137
|
}
|
|
84
138
|
|
|
85
|
-
async do(instructions: string[]): Promise<EnvelopeData> {
|
|
86
|
-
const command = `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
|
|
139
|
+
async do(instructions: string[], label?: string): Promise<EnvelopeData> {
|
|
140
|
+
const command = label || `do ${instructions.map((instruction) => `"${instruction}"`).join(' ')}`;
|
|
87
141
|
const guard = await this.aiGuard(command);
|
|
88
142
|
if (guard) return guard;
|
|
89
143
|
|
|
90
144
|
const provider = this.bot.getProvider();
|
|
91
|
-
const previousState = this.
|
|
145
|
+
const previousState = await this.baselineState();
|
|
92
146
|
const conversation = provider.startConversation(this.instructionSystemPrompt(), AI_AGENT_NAME);
|
|
93
147
|
const task = new Task(instructions.join('; '), previousState?.url || '');
|
|
94
|
-
const
|
|
95
|
-
|
|
148
|
+
const deps = { explorer: this.bot.getExplorer(), stateManager: this.bot.stateManager(), ai: provider };
|
|
149
|
+
const ledger: LedgerEntry[] = instructions.map((text) => ({ text, status: 'open', proof: '' }));
|
|
150
|
+
const descent = { markup: false };
|
|
151
|
+
const tools = { ...createCodeceptJSTools(deps, task), ...this.testerTools(deps), context: this.contextTool(descent), completed: this.completedTool(), blocked: this.blockedTool() };
|
|
152
|
+
conversation.addUserText(await this.instructionPrompt(instructions, await this.capturedResult(previousState)));
|
|
96
153
|
|
|
97
154
|
const used: string[] = [];
|
|
155
|
+
const trace: Array<{ label: string; ok: boolean; proof: string }> = [];
|
|
98
156
|
let failure: { code: string; message: string } | null = null;
|
|
99
157
|
let aiError: unknown = null;
|
|
100
158
|
let narration = '';
|
|
159
|
+
let nudged = false;
|
|
101
160
|
let contextHash = this.bot.stateManager().getCurrentState()?.hash;
|
|
102
161
|
|
|
103
|
-
for (let iteration = 1; iteration <= Math.min(instructions.length + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
|
|
162
|
+
for (let iteration = 1; iteration <= Math.min(instructions.length * ITERATIONS_PER_INSTRUCTION + 2, MAX_INSTRUCTION_ITERATIONS); iteration++) {
|
|
104
163
|
const state = this.bot.stateManager().getCurrentState();
|
|
105
164
|
if (iteration > 1 && state && state.hash !== contextHash) {
|
|
106
165
|
contextHash = state.hash;
|
|
107
|
-
conversation.addUserText(this.pageContext(ActionResult.fromState(state)));
|
|
166
|
+
conversation.addUserText(await this.pageContext(ActionResult.fromState(state)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (iteration > 1) {
|
|
170
|
+
conversation.addUserText(dedent`
|
|
171
|
+
<progress>
|
|
172
|
+
${this.ledgerProgress(ledger)}
|
|
173
|
+
</progress>
|
|
174
|
+
|
|
175
|
+
Call completed() now for every open instruction the page already shows is satisfied, before you act again.
|
|
176
|
+
`);
|
|
108
177
|
}
|
|
109
178
|
|
|
110
179
|
const invoked = await provider.invokeConversation(conversation, tools, { maxToolRoundtrips: MAX_TOOL_ROUNDTRIPS, agentName: AI_AGENT_NAME }).catch((error: unknown) => {
|
|
@@ -116,42 +185,199 @@ export class Prima {
|
|
|
116
185
|
const executions = invoked.toolExecutions || [];
|
|
117
186
|
if (!executions.length) {
|
|
118
187
|
narration = invoked.response?.text?.trim() || '';
|
|
119
|
-
|
|
188
|
+
const unreported = this.openInstructions(ledger);
|
|
189
|
+
if (!unreported || nudged) break;
|
|
190
|
+
nudged = true;
|
|
191
|
+
conversation.addUserText(dedent`
|
|
192
|
+
These instructions are still unreported:
|
|
193
|
+
${unreported}
|
|
194
|
+
|
|
195
|
+
Report each one with completed() or blocked(). Do not act again on anything you have already carried out.
|
|
196
|
+
`);
|
|
197
|
+
continue;
|
|
120
198
|
}
|
|
121
199
|
|
|
122
200
|
for (const execution of executions) {
|
|
201
|
+
const output = execution.output || {};
|
|
202
|
+
|
|
203
|
+
if (this.applyLedgerReport(execution, ledger, trace)) continue;
|
|
204
|
+
|
|
205
|
+
if (output.action === 'verify' && !output.inexpressible) {
|
|
206
|
+
const claim = execution.input?.assertion || 'verification';
|
|
207
|
+
let passed = execution.wasSuccessful;
|
|
208
|
+
if (output.alreadyVerified) passed = output.verifications?.[claim] === true;
|
|
209
|
+
trace.push({ label: `verify: ${claim}`, ok: passed, proof: output.code || '' });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
123
213
|
if (!execution.wasSuccessful) {
|
|
124
|
-
failure = { code:
|
|
214
|
+
failure = { code: output.code || '', message: output.message || 'action failed' };
|
|
215
|
+
trace.push({ label: output.code || execution.toolName || 'action', ok: false, proof: output.message || '' });
|
|
216
|
+
await this.writeStepFiles(trace.length, output.code || execution.toolName || 'action', '');
|
|
125
217
|
continue;
|
|
126
218
|
}
|
|
127
|
-
|
|
219
|
+
|
|
220
|
+
const codes = this.executedCodes(output.code);
|
|
221
|
+
used.push(...codes);
|
|
222
|
+
trace.push({ label: codes.join('; ') || execution.toolName || 'action', ok: true, proof: '' });
|
|
223
|
+
await this.writeStepFiles(trace.length, codes.join(' ') || execution.toolName || 'action', output.pageDiff?.ariaChanges || '');
|
|
128
224
|
failure = null;
|
|
129
225
|
}
|
|
226
|
+
|
|
227
|
+
if (ledger.every((entry) => entry.status !== 'open')) break;
|
|
130
228
|
}
|
|
131
229
|
|
|
132
230
|
if (aiError) return this.failureEnvelope(command, aiError, previousState);
|
|
133
231
|
|
|
134
|
-
if (
|
|
135
|
-
|
|
136
|
-
|
|
232
|
+
if (trace.length && ledger.some((entry) => entry.status === 'open')) {
|
|
233
|
+
await this.settleLedger(conversation, provider, ledger, trace);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const unfinished = ledger.filter((entry) => entry.status !== 'done');
|
|
237
|
+
const steps = [...trace, ...ledger.filter((entry) => entry.status === 'open').map((entry) => ({ label: entry.text, ok: false, unconfirmed: true, proof: UNACCOUNTED.open }))];
|
|
238
|
+
|
|
239
|
+
if (failure && unfinished.length) {
|
|
240
|
+
const envelope = await this.failureEnvelope(command, failure.message, previousState);
|
|
241
|
+
envelope.steps = steps;
|
|
242
|
+
envelope.stepFiles = this.statusDir();
|
|
137
243
|
return envelope;
|
|
138
244
|
}
|
|
139
245
|
|
|
140
|
-
if (!
|
|
246
|
+
if (!trace.length && unfinished.length === ledger.length) {
|
|
141
247
|
const reason = ['No action was performed for these instructions on the current page.', narration].filter(Boolean).join(' ');
|
|
142
248
|
return this.failureEnvelope(command, reason, previousState);
|
|
143
249
|
}
|
|
144
250
|
|
|
145
251
|
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
146
|
-
|
|
252
|
+
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
253
|
+
envelope.steps = steps;
|
|
254
|
+
envelope.stepFiles = this.statusDir();
|
|
255
|
+
// the step log already reports every action and what it changed
|
|
256
|
+
envelope.used = undefined;
|
|
257
|
+
envelope.changes = undefined;
|
|
258
|
+
|
|
259
|
+
const blocked = ledger.filter((entry) => entry.status === 'blocked');
|
|
260
|
+
if (blocked.length) {
|
|
261
|
+
envelope.ok = false;
|
|
262
|
+
envelope.failure = { error: blocked.map((entry) => `blocked: ${entry.text}${entry.proof ? ` — ${entry.proof}` : ''}`).join('\n') };
|
|
263
|
+
}
|
|
264
|
+
return envelope;
|
|
147
265
|
}
|
|
148
266
|
|
|
149
|
-
|
|
150
|
-
return
|
|
267
|
+
private openInstructions(ledger: LedgerEntry[]): string {
|
|
268
|
+
return ledger
|
|
269
|
+
.map((entry, index) => ({ entry, number: index + 1 }))
|
|
270
|
+
.filter(({ entry }) => entry.status === 'open')
|
|
271
|
+
.map(({ entry, number }) => `${number}. ${entry.text}`)
|
|
272
|
+
.join('\n');
|
|
151
273
|
}
|
|
152
274
|
|
|
153
|
-
|
|
154
|
-
|
|
275
|
+
private applyLedgerReport(execution: any, ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): boolean {
|
|
276
|
+
const action = execution.output?.action;
|
|
277
|
+
|
|
278
|
+
if (action === 'completed') {
|
|
279
|
+
const closed: string[] = [];
|
|
280
|
+
for (const number of execution.input?.numbers || []) {
|
|
281
|
+
const entry = ledger[number - 1];
|
|
282
|
+
if (entry?.status !== 'open') continue;
|
|
283
|
+
entry.status = 'done';
|
|
284
|
+
entry.proof = execution.input?.proof || '';
|
|
285
|
+
closed.push(entry.text);
|
|
286
|
+
}
|
|
287
|
+
// one report carries one proof, however many instructions it closed
|
|
288
|
+
if (closed.length) trace.push({ label: `done: ${closed.join('; ')}`, ok: true, proof: execution.input?.proof || '' });
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (action !== 'blocked') return false;
|
|
293
|
+
|
|
294
|
+
const entry = ledger[(execution.input?.instruction || 0) - 1];
|
|
295
|
+
if (entry?.status === 'open') {
|
|
296
|
+
entry.status = 'blocked';
|
|
297
|
+
entry.proof = execution.input?.reason || '';
|
|
298
|
+
trace.push({ label: `blocked: ${entry.text}`, ok: false, proof: entry.proof });
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private async settleLedger(conversation: any, provider: any, ledger: LedgerEntry[], trace: Array<{ label: string; ok: boolean; proof: string }>): Promise<void> {
|
|
304
|
+
conversation.addUserText(dedent`
|
|
305
|
+
The run is over and these instructions were never reported:
|
|
306
|
+
|
|
307
|
+
${this.openInstructions(ledger)}
|
|
308
|
+
|
|
309
|
+
Judge each one against what you saw at the time it was due, not against the page as it stands now — later
|
|
310
|
+
instructions have moved it on, and something you confirmed earlier stays confirmed even if it is gone.
|
|
311
|
+
completed() for those, blocked() for the ones the page could not do. Report every one — nothing else runs after this.
|
|
312
|
+
`);
|
|
313
|
+
|
|
314
|
+
let settleError: unknown = null;
|
|
315
|
+
const invoked = await provider.invokeConversation(conversation, { completed: this.completedTool(), blocked: this.blockedTool() }, { maxToolRoundtrips: 2, toolChoice: 'required', agentName: AI_AGENT_NAME }).catch((error: unknown) => {
|
|
316
|
+
settleError = error;
|
|
317
|
+
return null;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
if (settleError) trace.push({ label: 'settling which instructions were satisfied', ok: false, proof: browserErrorMessage(settleError) });
|
|
321
|
+
|
|
322
|
+
for (const execution of invoked?.toolExecutions || []) {
|
|
323
|
+
this.applyLedgerReport(execution, ledger, trace);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private ledgerProgress(ledger: LedgerEntry[]): string {
|
|
328
|
+
return ledger
|
|
329
|
+
.map((entry, index) => {
|
|
330
|
+
const head = `${index + 1}. ${entry.status} — ${entry.text}`;
|
|
331
|
+
if (entry.status === 'open') return head;
|
|
332
|
+
return `${head} (${entry.proof})`;
|
|
333
|
+
})
|
|
334
|
+
.join('\n');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async check(scenario: string, expected: string[] = []): Promise<EnvelopeData> {
|
|
338
|
+
const command = `check ${scenario}`;
|
|
339
|
+
const guard = await this.aiGuard(command);
|
|
340
|
+
if (guard) return guard;
|
|
341
|
+
|
|
342
|
+
const previousState = await this.baselineState();
|
|
343
|
+
const outcomes = expected.length ? expected : [scenario];
|
|
344
|
+
const test = new Test(scenario, 'normal', outcomes, previousState?.url || this.options.url || '');
|
|
345
|
+
const tester = this.bot.agentTester();
|
|
346
|
+
|
|
347
|
+
await tester.test(test, { startOnCurrentPage: true });
|
|
348
|
+
|
|
349
|
+
const notes = Object.values(test.notes || {}) as Array<{ message: string; status?: string; log?: string; observation?: boolean }>;
|
|
350
|
+
const result = await this.capturedResult(this.bot.stateManager().getCurrentState(), { screenshot: this.visionEnabled() });
|
|
351
|
+
const envelope = await this.reportEnvelope(command, result, previousState, {});
|
|
352
|
+
const recorded = notes.filter((note) => !note.observation && !outcomes.includes(note.message));
|
|
353
|
+
const failed = recorded.filter((note) => note.status === TestResult.FAILED);
|
|
354
|
+
envelope.steps = failed.map((note) => ({ label: note.message, ok: false, proof: note.log || '' }));
|
|
355
|
+
|
|
356
|
+
const routine = recorded.length - failed.length;
|
|
357
|
+
if (routine) envelope.steps.push({ label: `${routine} further ${pluralize(routine, 'step')} ran without failing — prima status ${envelope.status} for the full log`, ok: true, proof: '' });
|
|
358
|
+
|
|
359
|
+
envelope.expectations = await this.bot.agentPilot().settleExpectations(test, result);
|
|
360
|
+
|
|
361
|
+
if (!result.screenshot || !this.visionEnabled()) {
|
|
362
|
+
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.';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const unreached = envelope.expectations.filter((expectation) => expectation.status === 'failed');
|
|
366
|
+
const contradicted = envelope.expectations.filter((expectation) => expectation.status === 'contradiction');
|
|
367
|
+
envelope.ok = !unreached.length && !contradicted.length;
|
|
368
|
+
|
|
369
|
+
const problems = [...unreached.map((expectation) => `not reached: ${expectation.text}`), ...contradicted.map((expectation) => `the picture and the run disagree about: ${expectation.text}`)];
|
|
370
|
+
if (problems.length) envelope.failure = { error: problems.join('\n') };
|
|
371
|
+
if (contradicted.length) envelope.artifacts = this.artifacts;
|
|
372
|
+
|
|
373
|
+
if (!test.hasFinished || test.isSkipped) {
|
|
374
|
+
envelope.ok = false;
|
|
375
|
+
envelope.failure = { error: `the run did not complete, so it established nothing about the app: ${notes.at(-1)?.message || 'no steps were recorded'}` };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const observations = notes.filter((note) => note.observation).map((note) => note.message);
|
|
379
|
+
if (observations.length) envelope.answer = ['Page problems noticed while running, not step failures:', ...observations.map((line) => `- ${line}`)].join('\n');
|
|
380
|
+
return envelope;
|
|
155
381
|
}
|
|
156
382
|
|
|
157
383
|
async ask(question: string): Promise<EnvelopeData> {
|
|
@@ -172,9 +398,15 @@ export class Prima {
|
|
|
172
398
|
const previousState = this.bot.stateManager().getCurrentState();
|
|
173
399
|
const result = await this.capturedResult(previousState);
|
|
174
400
|
const verification = await this.bot.agentNavigator().verifyState(assertion, result);
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
401
|
+
const outcome: Partial<EnvelopeData> = { assertions: verification.results || [] };
|
|
402
|
+
|
|
403
|
+
if (verification.inexpressible) {
|
|
404
|
+
const question = `Judging only from the screenshot, is this true of the page: "${assertion}"? Answer true, false or undetermined, and say what settles it.`;
|
|
405
|
+
const seen = await this.visionAnswer(question, await this.capturedResult(previousState, { screenshot: this.visionEnabled() }));
|
|
406
|
+
if (seen) outcome.answer = `No assertion could express this claim, so it was judged from a screenshot instead.\n\n${seen}`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return this.reportEnvelope(command, result, previousState, outcome);
|
|
178
410
|
}
|
|
179
411
|
|
|
180
412
|
async research(opts: { data?: boolean; deep?: boolean; fresh?: boolean } = {}): Promise<EnvelopeData> {
|
|
@@ -186,7 +418,7 @@ export class Prima {
|
|
|
186
418
|
const previousState = this.bot.stateManager().getCurrentState();
|
|
187
419
|
const result = await this.capturedResult(previousState);
|
|
188
420
|
const uiMap = await this.bot.agentResearcher().research(result, { screenshot: true, data: opts.data, deep: opts.deep, force: opts.fresh });
|
|
189
|
-
return this.reportEnvelope(command, result, previousState, { research: uiMap });
|
|
421
|
+
return this.reportEnvelope(command, result, previousState, { research: dropVolatileColumns(uiMap) });
|
|
190
422
|
}
|
|
191
423
|
|
|
192
424
|
async go(target: string): Promise<EnvelopeData> {
|
|
@@ -208,7 +440,7 @@ export class Prima {
|
|
|
208
440
|
navigationError = error;
|
|
209
441
|
}
|
|
210
442
|
|
|
211
|
-
if (navigationError) return this.
|
|
443
|
+
if (navigationError) return this.failureEnvelope(command, navigationError, previousState);
|
|
212
444
|
|
|
213
445
|
const used: string[] = [];
|
|
214
446
|
if (isUrl) used.push(code);
|
|
@@ -237,6 +469,54 @@ export class Prima {
|
|
|
237
469
|
return stopped;
|
|
238
470
|
}
|
|
239
471
|
|
|
472
|
+
async config(json?: boolean): Promise<string> {
|
|
473
|
+
const [site] = listSites();
|
|
474
|
+
if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
|
|
475
|
+
const config = await this.loadConfig();
|
|
476
|
+
const parser = ConfigParser.getInstance();
|
|
477
|
+
|
|
478
|
+
return ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json });
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
record(envelope: EnvelopeData, durationMs: number): void {
|
|
482
|
+
if (!this.session) return;
|
|
483
|
+
recordCommand(sessionFile(this.session.key), this.session, envelope, durationMs);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async report(): Promise<string> {
|
|
487
|
+
const [site] = listSites();
|
|
488
|
+
if (site && !this.configBaseUrl()) this.sessionUrl = site.url;
|
|
489
|
+
await this.loadConfig();
|
|
490
|
+
|
|
491
|
+
let file = latestSessionFile();
|
|
492
|
+
if (this.options.pwSession) file = sessionFile(this.options.pwSession);
|
|
493
|
+
if (!file || !existsSync(file)) return `No prima session was recorded under ${sessionsDir()}. Commands are recorded as they run.`;
|
|
494
|
+
|
|
495
|
+
const session = readSession(file);
|
|
496
|
+
if (!session.tests.length) return `No commands are recorded in ${file}`;
|
|
497
|
+
|
|
498
|
+
Stats.sessionName = path.basename(file, '.jsonl');
|
|
499
|
+
process.env.TESTOMATIO_TITLE = session.title;
|
|
500
|
+
const reporter = new Reporter({ html: true, markdown: true });
|
|
501
|
+
|
|
502
|
+
// the report pipes narrate themselves on console.log; prima prints the paths itself
|
|
503
|
+
const speak = console.log;
|
|
504
|
+
console.log = () => {};
|
|
505
|
+
try {
|
|
506
|
+
for (const test of session.tests) await reporter.reportTestData(test.status, test);
|
|
507
|
+
await reporter.finishRun();
|
|
508
|
+
} finally {
|
|
509
|
+
console.log = speak;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return [
|
|
513
|
+
`${session.tests.length} ${pluralize(session.tests.length, 'command')} from ${file}`,
|
|
514
|
+
`html: ${outputPath('reports', `${Stats.sessionLabel()}.html`)}`,
|
|
515
|
+
`markdown: ${outputPath('reports', `${Stats.sessionLabel()}-tests.md`)}`,
|
|
516
|
+
`upload: TESTOMATIO=<apiKey> npx @testomatio/reporter replay ${file}`,
|
|
517
|
+
].join('\n');
|
|
518
|
+
}
|
|
519
|
+
|
|
240
520
|
async browserStatus(): Promise<string> {
|
|
241
521
|
await this.loadConfig();
|
|
242
522
|
const info = await this.instanceInfo();
|
|
@@ -282,7 +562,7 @@ export class Prima {
|
|
|
282
562
|
async toolFailureEnvelope(command: string, error: unknown): Promise<EnvelopeData> {
|
|
283
563
|
const state = this.bot.getCurrentState();
|
|
284
564
|
const instance = await this.instanceInfo().catch(() => ({ name: this.instanceName(), tabs: 0, others: [] }));
|
|
285
|
-
const failure: EnvelopeData['failure'] = { error: `tool: ${browserErrorMessage(error)}
|
|
565
|
+
const failure: EnvelopeData['failure'] = { error: `tool: ${browserErrorMessage(error)}` };
|
|
286
566
|
if (error instanceof ConfigMissingError) failure.error = browserErrorMessage(error);
|
|
287
567
|
if (state?.ariaSnapshot) failure.compactAria = compactAriaSnapshot(state.ariaSnapshot, true);
|
|
288
568
|
|
|
@@ -300,31 +580,41 @@ export class Prima {
|
|
|
300
580
|
}
|
|
301
581
|
|
|
302
582
|
private configBaseUrl(): string | undefined {
|
|
303
|
-
const url = this.options.baseUrl || this.options.url;
|
|
583
|
+
const url = this.options.baseUrl || this.options.url || this.sessionUrl;
|
|
304
584
|
if (!url) return undefined;
|
|
305
585
|
if (!URL.canParse(url)) return undefined;
|
|
306
586
|
return url;
|
|
307
587
|
}
|
|
308
588
|
|
|
309
|
-
private
|
|
589
|
+
private adoptSessionUrl(discovery: Discovery): void {
|
|
590
|
+
if (this.options.baseUrl || this.options.url) return;
|
|
591
|
+
|
|
592
|
+
const url = discovery.browser?.contexts()[0]?.pages()[0]?.url();
|
|
593
|
+
if (!url?.startsWith('http')) return;
|
|
594
|
+
this.sessionUrl = new URL(url).origin;
|
|
595
|
+
this.bot.getOptions().baseUrl = this.sessionUrl;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
private async resolveBrowser(config: ExplorbotConfig, discovered?: Discovery): Promise<void> {
|
|
310
599
|
if (this.options.endpoint) {
|
|
311
600
|
const endpoint = this.options.endpoint;
|
|
312
601
|
const browserName = config.playwright.browser || 'chromium';
|
|
313
|
-
|
|
602
|
+
const known = readDescriptors().find((descriptor) => descriptor.endpoint === endpoint);
|
|
603
|
+
if (await this.attachToEndpoint({ file: '', title: '', endpoint, workspaceDir: '', browserName, playwrightLib: known?.playwrightLib || '' })) return;
|
|
314
604
|
throw new Error(dedent`
|
|
315
605
|
No browser answered at ${endpoint}.
|
|
316
|
-
Check the endpoint of the running session, or drop --endpoint to
|
|
317
|
-
playwright-cli
|
|
606
|
+
Check the endpoint of the running session, or drop --endpoint to let prima pick
|
|
607
|
+
the playwright-cli session itself.
|
|
318
608
|
`);
|
|
319
609
|
}
|
|
320
610
|
|
|
321
|
-
const { match, candidates, browser } = await this.discover();
|
|
611
|
+
const { match, candidates, browser } = discovered || (await this.discover());
|
|
322
612
|
if (match && (await this.attachToEndpoint(match, browser))) return;
|
|
323
613
|
|
|
324
614
|
if (!match && candidates.length) {
|
|
325
615
|
const titles = candidates.map((candidate) => candidate.title).join(', ');
|
|
326
616
|
throw new Error(dedent`
|
|
327
|
-
Several playwright-cli sessions are open
|
|
617
|
+
Several playwright-cli sessions are open: ${titles}
|
|
328
618
|
Pick one with --pw-session <title>.
|
|
329
619
|
`);
|
|
330
620
|
}
|
|
@@ -342,7 +632,7 @@ export class Prima {
|
|
|
342
632
|
|
|
343
633
|
private async discover(descriptors = readDescriptors()): Promise<Discovery> {
|
|
344
634
|
const title = this.options.pwSession ?? process.env.PLAYWRIGHT_CLI_SESSION;
|
|
345
|
-
const opts = {
|
|
635
|
+
const opts = { title };
|
|
346
636
|
|
|
347
637
|
const alive = new Map<PwServerDescriptor, Browser>();
|
|
348
638
|
for (const candidate of selectDescriptor(descriptors, opts).candidates) {
|
|
@@ -368,13 +658,14 @@ export class Prima {
|
|
|
368
658
|
|
|
369
659
|
this.bot.attachBrowser(browser);
|
|
370
660
|
this.attached = this.attachmentLabel(descriptor);
|
|
661
|
+
this.session = { key: descriptor.title || this.instanceName(), endpoint: descriptor.endpoint, title: `prima session "${descriptor.title || this.instanceName()}"` };
|
|
371
662
|
return true;
|
|
372
663
|
}
|
|
373
664
|
|
|
374
665
|
private async connectDescriptor(descriptor: PwServerDescriptor): Promise<Browser | null> {
|
|
375
|
-
const connected = await this.connectWith(
|
|
666
|
+
const connected = await this.connectWith(this.descriptorLib(descriptor), descriptor);
|
|
376
667
|
if (connected) return connected;
|
|
377
|
-
return this.connectWith(
|
|
668
|
+
return this.connectWith(playwright, descriptor);
|
|
378
669
|
}
|
|
379
670
|
|
|
380
671
|
private async connectWith(lib: any, descriptor: PwServerDescriptor): Promise<Browser | null> {
|
|
@@ -394,15 +685,15 @@ export class Prima {
|
|
|
394
685
|
|
|
395
686
|
private attachmentLabel(descriptor: PwServerDescriptor): string {
|
|
396
687
|
if (!descriptor.title) return `endpoint ${descriptor.endpoint}`;
|
|
688
|
+
if (!descriptor.workspaceDir) return `playwright-cli session "${descriptor.title}"`;
|
|
397
689
|
return `playwright-cli session "${descriptor.title}", workspace ${descriptor.workspaceDir}`;
|
|
398
690
|
}
|
|
399
691
|
|
|
400
|
-
private workspaceDir(): string {
|
|
401
|
-
return path.resolve(this.options.path || process.cwd());
|
|
402
|
-
}
|
|
403
|
-
|
|
404
692
|
private async connectOwnInstance(): Promise<boolean> {
|
|
405
|
-
|
|
693
|
+
const endpoint = await getAliveEndpoint(this.instanceName());
|
|
694
|
+
if (!endpoint) return false;
|
|
695
|
+
this.session = { key: this.instanceName(), endpoint, title: `prima instance "${this.instanceName()}"` };
|
|
696
|
+
return true;
|
|
406
697
|
}
|
|
407
698
|
|
|
408
699
|
private async launchOwnServer(opts: { browser?: string; show?: boolean }, instance: string): Promise<{ close: () => Promise<void> }> {
|
|
@@ -425,49 +716,6 @@ export class Prima {
|
|
|
425
716
|
return URL.canParse(value);
|
|
426
717
|
}
|
|
427
718
|
|
|
428
|
-
private async heal(command: string, expression: string, error: unknown, previousState: WebPageState | null): Promise<EnvelopeData> {
|
|
429
|
-
if (this.options.heal === false) return this.failureEnvelope(command, error, previousState);
|
|
430
|
-
|
|
431
|
-
const navigator = this.healNavigator();
|
|
432
|
-
if (!navigator) {
|
|
433
|
-
const envelope = await this.failureEnvelope(command, error, previousState);
|
|
434
|
-
envelope.healed = false;
|
|
435
|
-
envelope.healNote = this.aiUnavailableNote();
|
|
436
|
-
return envelope;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
const message = dedent`
|
|
440
|
-
I tried to run this command on the page: ${expression}
|
|
441
|
-
But it failed with: ${browserErrorMessage(error)}
|
|
442
|
-
Reach the same outcome on the current page in a different way.
|
|
443
|
-
`;
|
|
444
|
-
|
|
445
|
-
const attempts: Array<{ code: string; error?: string }> = [];
|
|
446
|
-
const failedResult = await this.capturedResult(previousState);
|
|
447
|
-
const resolved = await navigator.resolveState(message, failedResult, { onAttempt: (attempt) => attempts.push(attempt) }).catch(() => false);
|
|
448
|
-
|
|
449
|
-
if (!resolved) {
|
|
450
|
-
const healAttempts = attempts.map((attempt) => ({ code: attempt.code, outcome: attempt.error || 'ok' }));
|
|
451
|
-
return this.failureEnvelope(command, error, previousState, healAttempts);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
const used = attempts.filter((attempt) => !attempt.error).map((attempt) => attempt.code);
|
|
455
|
-
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
456
|
-
const envelope = await this.successEnvelope(command, used, result, previousState);
|
|
457
|
-
envelope.healed = true;
|
|
458
|
-
envelope.healNote = `recovered after ${attempts.length} ${pluralize(attempts.length, 'attempt')}`;
|
|
459
|
-
return envelope;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
private healNavigator(): Navigator | null {
|
|
463
|
-
if (this.aiUnavailable()) return null;
|
|
464
|
-
try {
|
|
465
|
-
return this.bot.agentNavigator?.() ?? null;
|
|
466
|
-
} catch {
|
|
467
|
-
return null;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
719
|
private aiUnavailable(): string | null {
|
|
472
720
|
try {
|
|
473
721
|
if (this.bot.getProvider?.()) return null;
|
|
@@ -477,12 +725,6 @@ export class Prima {
|
|
|
477
725
|
return this.bot.aiFailureReason?.() || 'no AI model is configured';
|
|
478
726
|
}
|
|
479
727
|
|
|
480
|
-
private aiUnavailableNote(): string {
|
|
481
|
-
const reason = this.aiUnavailable();
|
|
482
|
-
if (!reason) return 'ai unavailable';
|
|
483
|
-
return `ai unavailable: ${reason}`;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
728
|
private async aiGuard(command: string): Promise<EnvelopeData | null> {
|
|
487
729
|
const reason = this.aiUnavailable();
|
|
488
730
|
if (!reason) return null;
|
|
@@ -497,51 +739,197 @@ export class Prima {
|
|
|
497
739
|
</role>
|
|
498
740
|
|
|
499
741
|
<approach>
|
|
500
|
-
1. Read the page context and
|
|
742
|
+
1. Read the page context and carry out the instructions in the order they are listed.
|
|
501
743
|
2. Interact with the page only through the provided tools.
|
|
502
744
|
3. Pick the smallest interaction that fulfills an instruction, then move to the next one.
|
|
503
745
|
4. After the page changes, work from the updated context you are given, not from the earlier one.
|
|
504
|
-
5.
|
|
746
|
+
5. Account for every instruction: completed() as soon as one is satisfied, blocked() when the page cannot do what it asks.
|
|
505
747
|
</approach>
|
|
506
748
|
|
|
749
|
+
<ledger>
|
|
750
|
+
Instructions are numbered and those numbers never change. Report by number.
|
|
751
|
+
Saying in your reply that something is done does not report it — only completed() does. Nothing you write is read as a report.
|
|
752
|
+
Report an instruction the moment the page shows it is satisfied, before moving on. Waiting until later is how work gets repeated.
|
|
753
|
+
An instruction you have reported is finished. Never act on it again, and never report it twice.
|
|
754
|
+
After each turn you are shown every instruction with its state. Act only on the ones still open — repeating an action that already
|
|
755
|
+
landed can undo it, since a control that opened something will close it again.
|
|
756
|
+
Reaching for blocked() after a couple of honest attempts costs less than a third attempt that fails the same way.
|
|
757
|
+
</ledger>
|
|
758
|
+
|
|
759
|
+
<scope>
|
|
760
|
+
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.
|
|
761
|
+
Continuing past the last instruction is a failure, even when the next step seems obvious.
|
|
762
|
+
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.
|
|
763
|
+
</scope>
|
|
764
|
+
|
|
765
|
+
<pace>
|
|
766
|
+
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.
|
|
767
|
+
Only stop to look again when what you find changes what you would do next.
|
|
768
|
+
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.
|
|
769
|
+
</pace>
|
|
770
|
+
|
|
771
|
+
<proof>
|
|
772
|
+
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.
|
|
773
|
+
That part is what completed() takes as its proof. Do not restate the action as if it were the outcome.
|
|
774
|
+
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.
|
|
775
|
+
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.
|
|
776
|
+
</proof>
|
|
777
|
+
|
|
778
|
+
<targets>
|
|
779
|
+
The page context lists every element with a ref, like [ref=e14]. To click one, pass that ref to clickRef — a ref names one
|
|
780
|
+
exact element, so it cannot match several by mistake and costs nothing to resolve. This is the cheapest way to act.
|
|
781
|
+
Use click() with a role and name for anything clickRef cannot take, and narrow with the container it sits in when a name
|
|
782
|
+
appears more than once, rather than guessing at an id or a class.
|
|
783
|
+
Refs belong to the context you were given. Use the ones in your newest context, never one you invented or remembered from
|
|
784
|
+
an older page. When the element an instruction needs is missing from that context, call context() and act on what it returns.
|
|
785
|
+
</targets>
|
|
786
|
+
|
|
507
787
|
${locatorRule}
|
|
508
788
|
|
|
509
789
|
${actionRule}
|
|
790
|
+
|
|
791
|
+
<targets_first>
|
|
792
|
+
Everything above about composing locators applies to click() and the other locator tools. It does not apply when the
|
|
793
|
+
element carries a ref: pass that ref to clickRef instead and compose nothing. Reach for a locator only for elements
|
|
794
|
+
that have no ref, or when a ref has stopped resolving.
|
|
795
|
+
</targets_first>
|
|
510
796
|
`;
|
|
511
797
|
}
|
|
512
798
|
|
|
513
|
-
private instructionPrompt(instructions: string[], result: ActionResult): string {
|
|
799
|
+
private async instructionPrompt(instructions: string[], result: ActionResult): Promise<string> {
|
|
514
800
|
const list = instructions.map((instruction, index) => `${index + 1}. ${instruction}`).join('\n');
|
|
515
801
|
return dedent`
|
|
516
802
|
<instructions>
|
|
517
803
|
${list}
|
|
518
804
|
</instructions>
|
|
519
805
|
|
|
520
|
-
${this.pageContext(result)}
|
|
806
|
+
${await this.pageContext(result)}
|
|
521
807
|
`;
|
|
522
808
|
}
|
|
523
809
|
|
|
524
|
-
private
|
|
810
|
+
private testerTools(deps: any): any {
|
|
811
|
+
const researcher = this.bot.agentResearcher?.();
|
|
812
|
+
const navigator = this.bot.agentNavigator?.();
|
|
813
|
+
if (!researcher || !navigator) return {};
|
|
814
|
+
|
|
815
|
+
const tools = createAgentTools({ ...deps, researcher, navigator, withExperience: false });
|
|
816
|
+
for (const name of TESTER_ONLY_TOOLS) delete tools[name];
|
|
817
|
+
return tools;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private completedTool(): any {
|
|
821
|
+
return tool({
|
|
822
|
+
description: dedent`
|
|
823
|
+
Report the instructions you have just satisfied, by their number. Report several together when one turn satisfied several.
|
|
824
|
+
A reported instruction is finished — you will not be asked for it again and must not act on it again.
|
|
825
|
+
`,
|
|
826
|
+
inputSchema: z.object({
|
|
827
|
+
numbers: z.array(z.number()).describe('Numbers of the instructions now satisfied, as they are numbered in the instruction list'),
|
|
828
|
+
proof: z.string().describe('What on the page shows they are satisfied'),
|
|
829
|
+
}),
|
|
830
|
+
execute: async () => ({ success: true, action: 'completed' }),
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private blockedTool(): any {
|
|
835
|
+
return tool({
|
|
836
|
+
description: dedent`
|
|
837
|
+
Report one instruction that cannot be carried out on this page, by its number. Reach for this instead of trying the same thing again.
|
|
838
|
+
The rest of the sequence continues without it.
|
|
839
|
+
`,
|
|
840
|
+
inputSchema: z.object({
|
|
841
|
+
instruction: z.number().describe('Number of the instruction that cannot be carried out'),
|
|
842
|
+
reason: z.string().describe('What stopped it — what you looked for and what the page showed instead'),
|
|
843
|
+
}),
|
|
844
|
+
execute: async () => ({ success: true, action: 'blocked' }),
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private contextTool(descent: { markup: boolean }): any {
|
|
849
|
+
let refreshed = false;
|
|
850
|
+
return tool({
|
|
851
|
+
description: dedent`
|
|
852
|
+
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.
|
|
853
|
+
The first call returns the page as it is now, with fresh refs that replace every ref you were holding.
|
|
854
|
+
A later call on the same page drops to the raw markup, for elements the accessibility tree does not describe.
|
|
855
|
+
Do not call it to confirm an action worked — the change is already reported back to you.
|
|
856
|
+
`,
|
|
857
|
+
inputSchema: z.object({
|
|
858
|
+
reason: z.string().describe('Which element you cannot reach and what you already tried'),
|
|
859
|
+
}),
|
|
860
|
+
execute: async () => {
|
|
861
|
+
const result = await this.capturedResult(this.bot.stateManager().getCurrentState());
|
|
862
|
+
if (!refreshed) {
|
|
863
|
+
refreshed = true;
|
|
864
|
+
return { success: true, context: await this.pageContext(result) };
|
|
865
|
+
}
|
|
866
|
+
descent.markup = true;
|
|
867
|
+
return { success: true, context: cap(await result.simplifiedHtml(), CONTEXT_HTML_CAP) };
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
private async pageContext(result: ActionResult): Promise<string> {
|
|
525
873
|
const experience = this.bot.experienceTracker?.()?.renderExperienceTocFor?.(result) || '';
|
|
874
|
+
const map = this.researchMap(result);
|
|
875
|
+
if (map) {
|
|
876
|
+
return dedent`
|
|
877
|
+
<page_ui_map url="${result.url}" title="${result.title}">
|
|
878
|
+
${map}
|
|
879
|
+
</page_ui_map>
|
|
880
|
+
|
|
881
|
+
${experience}
|
|
882
|
+
`;
|
|
883
|
+
}
|
|
884
|
+
|
|
526
885
|
return dedent`
|
|
527
886
|
<page url="${result.url}" title="${result.title}">
|
|
528
|
-
${compactAriaSnapshot(result
|
|
887
|
+
${compactAriaSnapshot(await this.refAriaSnapshot(result), true, (value) => this.offloadValue(value))}
|
|
529
888
|
</page>
|
|
530
889
|
|
|
531
890
|
${experience}
|
|
532
891
|
`;
|
|
533
892
|
}
|
|
534
893
|
|
|
894
|
+
private researchMap(result: ActionResult): string {
|
|
895
|
+
if (this.bot.stateManager().getVisitCount(result.url) < this.researchAfterVisits()) return '';
|
|
896
|
+
return getPreviousResearch(result.getStateHash());
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
private researchAfterVisits(): number {
|
|
900
|
+
const configured = this.bot.getConfig?.()?.ai?.agents?.prima?.researchAfterVisits;
|
|
901
|
+
if (typeof configured === 'number') return configured;
|
|
902
|
+
return DEFAULT_RESEARCH_AFTER_VISITS;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
private offloadValue(value: string): string | undefined {
|
|
906
|
+
const dir = this.statusDir();
|
|
907
|
+
const name = `value-${createHash('sha1').update(value).digest('hex').slice(0, 8)}.txt`;
|
|
908
|
+
try {
|
|
909
|
+
mkdirSync(dir, { recursive: true });
|
|
910
|
+
writeFileSync(path.join(dir, name), value, 'utf-8');
|
|
911
|
+
} catch {
|
|
912
|
+
return undefined;
|
|
913
|
+
}
|
|
914
|
+
return path.join(path.basename(dir), name);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private async refAriaSnapshot(result: ActionResult): Promise<string | null> {
|
|
918
|
+
const snapshot = await Promise.resolve(this.bot.getExplorer()?.withPage?.((page: any) => page.locator('body').ariaSnapshot({ mode: 'ai' }))).catch(() => null);
|
|
919
|
+
return snapshot || result.ariaSnapshot;
|
|
920
|
+
}
|
|
921
|
+
|
|
535
922
|
private executedCodes(code: unknown): string[] {
|
|
536
923
|
if (typeof code !== 'string') return [];
|
|
537
924
|
return code
|
|
538
925
|
.split('\n')
|
|
539
926
|
.map((line) => line.trim())
|
|
540
|
-
.filter((line) => line);
|
|
927
|
+
.filter((line) => line && !line.startsWith('//'));
|
|
541
928
|
}
|
|
542
929
|
|
|
543
930
|
private visionEnabled(): boolean {
|
|
544
931
|
if (this.options.noVision) return false;
|
|
932
|
+
if (Stats.visionDisabled) return false;
|
|
545
933
|
return this.bot.getProvider().hasVision?.() === true;
|
|
546
934
|
}
|
|
547
935
|
|
|
@@ -585,12 +973,6 @@ export class Prima {
|
|
|
585
973
|
return response?.text || '';
|
|
586
974
|
}
|
|
587
975
|
|
|
588
|
-
private verdictEvidence(verified: boolean, codes: string[]): string {
|
|
589
|
-
if (!verified) return 'no assertion held on the current page';
|
|
590
|
-
if (!codes.length) return 'already verified on this page';
|
|
591
|
-
return `${codes[0]} passed`;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
976
|
private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
|
|
595
977
|
return {
|
|
596
978
|
ok: true,
|
|
@@ -599,15 +981,14 @@ export class Prima {
|
|
|
599
981
|
page: this.pageBlock(result, previousState),
|
|
600
982
|
changes: await this.pageChanges(result, previousState, used[0]),
|
|
601
983
|
instance: await this.instanceInfo(),
|
|
602
|
-
|
|
984
|
+
status: await this.saveStatus(result),
|
|
603
985
|
};
|
|
604
986
|
}
|
|
605
987
|
|
|
606
|
-
private async failureEnvelope(command: string, error: unknown, previousState: WebPageState | null
|
|
988
|
+
private async failureEnvelope(command: string, error: unknown, previousState: WebPageState | null): Promise<EnvelopeData> {
|
|
607
989
|
const result = await this.capturedResult(previousState);
|
|
608
|
-
const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error)
|
|
990
|
+
const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
|
|
609
991
|
if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);
|
|
610
|
-
if (attempts.length) failure.reasoning = [...new Set(attempts.map((attempt) => attempt.outcome))].join('; ');
|
|
611
992
|
|
|
612
993
|
return {
|
|
613
994
|
ok: false,
|
|
@@ -615,7 +996,7 @@ export class Prima {
|
|
|
615
996
|
page: this.pageBlock(result, previousState),
|
|
616
997
|
failure,
|
|
617
998
|
instance: await this.instanceInfo(),
|
|
618
|
-
|
|
999
|
+
status: await this.saveStatus(result),
|
|
619
1000
|
};
|
|
620
1001
|
}
|
|
621
1002
|
|
|
@@ -626,7 +1007,7 @@ export class Prima {
|
|
|
626
1007
|
page: this.pageBlock(result, previousState),
|
|
627
1008
|
...outcome,
|
|
628
1009
|
instance: await this.instanceInfo(),
|
|
629
|
-
|
|
1010
|
+
status: await this.saveStatus(result),
|
|
630
1011
|
};
|
|
631
1012
|
}
|
|
632
1013
|
|
|
@@ -650,23 +1031,77 @@ export class Prima {
|
|
|
650
1031
|
};
|
|
651
1032
|
}
|
|
652
1033
|
|
|
653
|
-
private async
|
|
654
|
-
|
|
1034
|
+
private async baselineState(): Promise<WebPageState | null> {
|
|
1035
|
+
const existing = this.bot.stateManager?.()?.getCurrentState();
|
|
1036
|
+
if (existing) return existing;
|
|
1037
|
+
|
|
1038
|
+
const result = await Promise.resolve(this.bot.getExplorer?.()?.capture?.()).catch(() => null);
|
|
1039
|
+
if (!result) return null;
|
|
1040
|
+
return this.bot.stateManager?.()?.updateState(result) ?? null;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
private async pageChanges(result: ActionResult, previousState: WebPageState | null, code: string): Promise<string> {
|
|
1044
|
+
if (!previousState) return 'no snapshot was captured before this command, so nothing could be compared';
|
|
655
1045
|
const toolResult = await result.toToolResult(ActionResult.fromState(previousState), code);
|
|
656
|
-
return toolResult.pageDiff?.ariaChanges
|
|
1046
|
+
return toolResult.pageDiff?.ariaChanges || 'no change';
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
async status(hash: string): Promise<EnvelopeData> {
|
|
1050
|
+
const dir = this.statusDir(hash);
|
|
1051
|
+
const statusFile = path.join(dir, 'status.json');
|
|
1052
|
+
if (!existsSync(statusFile)) return this.toolFailureEnvelope(`status ${hash}`, `No command was recorded under ${hash}. Every envelope prints its own hash on the Instance line.`);
|
|
1053
|
+
|
|
1054
|
+
const saved = JSON.parse(readFileSync(statusFile, 'utf-8'));
|
|
1055
|
+
return {
|
|
1056
|
+
ok: true,
|
|
1057
|
+
command: `status ${hash}`,
|
|
1058
|
+
page: saved.page,
|
|
1059
|
+
instance: await this.instanceInfo(),
|
|
1060
|
+
artifacts: { aria: path.join(dir, 'aria.yml'), html: path.join(dir, 'page.html') },
|
|
1061
|
+
};
|
|
657
1062
|
}
|
|
658
1063
|
|
|
659
|
-
private async
|
|
660
|
-
|
|
1064
|
+
private async saveStatus(result: ActionResult): Promise<string> {
|
|
1065
|
+
const hash = this.statusHash();
|
|
1066
|
+
await this.writeSnapshot(result);
|
|
1067
|
+
writeFileSync(path.join(this.statusDir(hash), 'status.json'), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
|
|
1068
|
+
return hash;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
private async writeStepFiles(index: number, label: string, diff: string): Promise<void> {
|
|
1072
|
+
const state = this.bot.stateManager().getCurrentState();
|
|
1073
|
+
if (!state) return;
|
|
1074
|
+
|
|
1075
|
+
const dir = this.statusDir();
|
|
1076
|
+
mkdirSync(dir, { recursive: true });
|
|
1077
|
+
const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
|
|
1078
|
+
const result = ActionResult.fromState(state);
|
|
1079
|
+
|
|
1080
|
+
writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
|
|
1081
|
+
writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
|
|
1082
|
+
if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
private async writeSnapshot(result: ActionResult): Promise<void> {
|
|
1086
|
+
this.artifacts = writeArtifacts(this.statusDir(), {
|
|
661
1087
|
aria: result.ariaSnapshot,
|
|
662
1088
|
html: await result.combinedHtml(),
|
|
1089
|
+
screenshot: result.screenshot,
|
|
663
1090
|
requests: this.bot.requestStore().getRequests(),
|
|
664
1091
|
});
|
|
665
1092
|
}
|
|
666
1093
|
|
|
667
|
-
private
|
|
1094
|
+
private statusHash(): string {
|
|
1095
|
+
this.hash ||= createHash('sha1')
|
|
1096
|
+
.update(`${this.options.path || process.cwd()}-${Date.now()}`)
|
|
1097
|
+
.digest('hex')
|
|
1098
|
+
.slice(0, 15);
|
|
1099
|
+
return this.hash;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private statusDir(hash = this.statusHash()): string {
|
|
668
1103
|
this.artifactsDir ||= outputPath('prima');
|
|
669
|
-
return path.join(this.artifactsDir,
|
|
1104
|
+
return path.join(this.artifactsDir, hash);
|
|
670
1105
|
}
|
|
671
1106
|
|
|
672
1107
|
private tabCount(): number {
|
|
@@ -686,13 +1121,17 @@ interface Discovery {
|
|
|
686
1121
|
browser?: Browser;
|
|
687
1122
|
}
|
|
688
1123
|
|
|
1124
|
+
interface LedgerEntry {
|
|
1125
|
+
text: string;
|
|
1126
|
+
status: 'open' | 'done' | 'blocked';
|
|
1127
|
+
proof: string;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
689
1130
|
export interface PrimaOptions {
|
|
690
|
-
verbose?: boolean;
|
|
691
1131
|
config?: string;
|
|
692
1132
|
path?: string;
|
|
693
1133
|
instance?: string;
|
|
694
1134
|
session?: string | boolean;
|
|
695
|
-
heal?: boolean;
|
|
696
1135
|
ephemeral?: boolean;
|
|
697
1136
|
framework?: 'codeceptjs' | 'playwright';
|
|
698
1137
|
noVision?: boolean;
|