Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/agent/mcts.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/* Statistical-memo MCTS: this tree records which (state, action) pairs have been
explored and their cumulative rewards, but does NOT re-navigate the browser to
match the selected node before action execution. The tree bounds exploration via
UCB1 scores but does not enforce state-graph consistency. Full re-navigation
is deferred — it increases per-step cost 3-10x. See DECISION_LOG #021. */
import { selectChild } from './policy.js';

export class MctsNode {
Expand Down
4 changes: 2 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ async function runArm({ role, page, seed, config, rng, tracer, breadcrumbs, step
const macroFireProb = config.macros?.fireProbability ?? 0;
const macroList = config.macros?.list ?? [];
const stepTimeoutMs = config.run?.stepTimeoutMs ?? 10000;
const recentStateIds = [];
const recentStateIds = new Set();
const noveltyDenylist = (config.novelty?.nameDenylist ?? []).map((s) => new RegExp(s, 'i'));
let prevA11y = null;
let prevUrl = null;
Expand All @@ -321,7 +321,7 @@ async function runArm({ role, page, seed, config, rng, tracer, breadcrumbs, step
const fileInputs = await getFileInputs(page.raw);
const forms = await detectFillableForms(page.raw);
const stateId = clusterId(a11y, config.mcts.abstractionGranularity);
recentStateIds.push(stateId);
recentStateIds.add(stateId);
const cands = candidateActions(a11y, {
weights: config.actions.weights,
blockedSelectors: config.target.blockedSelectors,
Expand Down
64 changes: 64 additions & 0 deletions src/llm/prompts.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,67 @@ export function buildPredictPrompt({ a11yTree, action, recentActions = [] }) {
.filter(Boolean)
.join('\n');
}

export function buildSurprisePrompt({
prediction,
observed,
hardSignals = [],
recentActions = [],
recentStateIds = new Set(),
currentStateId = null,
}) {
const observedJson = JSON.stringify(observed).slice(0, 4000);
const recentList = recentActions.length
? recentActions.map((a, i) => `- ${i + 1}. ${a}`).join('\n')
: '- (none)';
const recentIdsView = recentStateIds.size
? JSON.stringify([...recentStateIds].slice(-8))
: '[]';
const repeated = Boolean(currentStateId) && recentStateIds.has(currentStateId);

return [
'# QA Surprise Evaluator',
'',
'You score how surprising a UI action result is. Pick exactly one bucket from the rubric below. Do not blend, average, or interpolate.',
'',
'## SCORE RUBRIC — pick exactly one of {0.0, 0.2, 0.5, 0.8, 1.0}',
'',
'| Score | Use when |',
'|---|---|',
'| 0.0 | Observed state matches (or is a near-duplicate of) a recent state, OR there is no visible change beyond text/timestamps. Refresh / scroll / no-op loops are ALWAYS 0.0. |',
'| 0.2 | Predicted change happened but with no novelty: same interactive roles, same URL fragment, only inner text shifted within the same widgets. |',
'| 0.5 | New widgets appeared or disappeared — a role/name not present in any recent state. Same screen, different controls. |',
'| 0.8 | Distinct new screen reached: URL/route changed, a dialog/modal/drawer opened, or a new section is now interactive. |',
'| 1.0 | Page broke, threw, froze, or rendered an unexpected error visible from the tree alone (hard signals are scored separately by the harness). |',
'',
'## ANTI-LOOP RULES (strict — violating them is a wrong answer)',
'',
'1. **Repetition is never surprising.** If the current state cluster id appears in the recent state ids list, you MUST return 0.0.',
'2. **No hedging.** The only legal scores are 0.0, 0.2, 0.5, 0.8, 1.0. Never invent intermediate values to feel "safe".',
'3. **Novelty must be evidenced.** Any score ≥ 0.5 must name, in `reason`, the specific role / accessible name / URL fragment that was not present in recent states.',
'4. **Ignore prediction quality.** A vague or stub prediction (e.g. "the page will change") cannot justify surprise. Only the observed tree counts.',
'5. **Same-cluster refreshes score 0.0**, even if numeric counters, timestamps, or analytics pixels changed.',
'',
'## INPUTS',
'',
`Prediction: ${prediction}`,
'',
'Recent actions (oldest → newest):',
recentList,
'',
`Recent state cluster ids (most recent last): ${recentIdsView}`,
`Current state cluster id: ${currentStateId ?? 'unknown'}`,
`Repeated state detected by harness: ${repeated ? 'YES — score MUST be 0.0' : 'no'}`,
`Page-level hard signals fired: ${JSON.stringify(hardSignals)}`,
'',
'Observed accessibility tree (pruned, truncated to 4000 chars):',
observedJson,
'',
'## OUTPUT',
'',
'Reply with JSON on a single line, no markdown fence, no surrounding prose. Schema:',
'{"score": <0.0|0.2|0.5|0.8|1.0>, "reason": "<≤ 14 words; name the new element or write \\"repeat\\">"}',
'',
'JSON:',
].join('\n');
}
19 changes: 19 additions & 0 deletions tests/unit/mcts.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,22 @@ describe('descend', () => {
expect(result).toBe(childA);
});
});

describe('recentStateIds Set contract', () => {
it('does not duplicate on repeated add', () => {
const s = new Set();
s.add('state-a');
s.add('state-a');
expect(s.size).toBe(1);
});
it('.has() returns true for added items', () => {
const s = new Set();
s.add('state-1');
expect(s.has('state-1')).toBe(true);
});
it('.has() returns false for non-added items', () => {
const s = new Set();
s.add('state-1');
expect(s.has('state-99')).toBe(false);
});
});
Loading