Skip to content

Commit 257bc7f

Browse files
abrichrclaude
andauthored
fix: eval infra, forced keyboard override, Outlines constrained decoding (#197)
* fix: per-step milestone tracking, forced keyboard override, eval infra Evaluation infrastructure: - Per-step milestone high-water mark: milestones checked after each step, once passed they stay passed. Fixes transient states (open dialogs) being missed by end-of-episode-only evaluation. - evaluate_checks_local() fallback: when /evaluate endpoint is down, uses task config's own command/screenshot checks via /execute_windows - iptables retry loop in start_with_evaluate.sh: ensures port 5050 exemption persists even if DNAT rule is (re)applied later Anti-loop forced override: - After 6 consecutive identical actions (planner ignoring warnings), bypasses planner entirely and emits first keyboard shortcut from demo guidance (e.g., Ctrl+Shift+Delete). This breaks click loops where the grounder places clicks incorrectly. Task setup fixes: - Chrome popup: registry policies, First Run sentinel, launch flags - Single-line PowerShell commands (fixes YAML escaping for /execute_windows) - Redesigned milestones: combined settings/dialog check, evidence-based Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove Alt+F4 from demo, add Outlines constrained decoding Demo fix: - Remove step 4 (Alt+F4 close Chrome) from clear-browsing-data demo. Alt+F4 on desktop triggers Windows Shutdown dialog when Chrome loses focus. The task goal is clearing data, not closing Chrome. - Updated step 2 description to include "Delete from this device" button text (newer Chrome versions changed the label). Constrained decoding (GRPO trainer): - Add `constrained_decoding` config flag (default False) - When enabled, uses Outlines RegexLogitsProcessor to force model output to match the action format regex (CLICK/TYPE/WAIT/DONE). Eliminates 5-15% of rollouts wasted on unparseable output. - Allows free-form Thought prefix before the action. - DFA compilation cached after first call (~2s one-time cost). - Graceful fallback if outlines not installed. - Added outlines>=0.1.0 to training optional dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 23a1a50 commit 257bc7f

10 files changed

Lines changed: 328 additions & 68 deletions

File tree

demos/custom-clear-chrome-data/manual/demo.json

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,19 +41,7 @@
4141
"action_value": "",
4242
"x": 0.5,
4343
"y": 0.7,
44-
"description": "Click the Clear data button in the Clear Browsing Data dialog",
45-
"metadata": {}
46-
},
47-
{
48-
"step_index": 3,
49-
"screenshot_path": "",
50-
"action_type": "key",
51-
"action_description": "KEY(alt+f4)",
52-
"target_description": "",
53-
"action_value": "alt+f4",
54-
"x": null,
55-
"y": null,
56-
"description": "Close Chrome after clearing data",
44+
"description": "Click the 'Delete from this device' or 'Clear data' button in the Clear Browsing Data dialog",
5745
"metadata": {}
5846
}
5947
]

example_tasks/clear-browsing-data-chrome.yaml

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@ name: "Clear browsing data in Google Chrome"
44
id: custom-clear-chrome-data
55

66
setup:
7-
# Disable "Make Chrome faster" popup via Chrome policy registry key
8-
- execute: "powershell -c \"New-Item -Path 'HKLM:\\SOFTWARE\\Policies\\Google\\Chrome' -Force; Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\Policies\\Google\\Chrome' -Name 'SpeedComparisonEnabled' -Value 0 -Type DWord\""
7+
# Kill any existing Chrome processes
8+
- execute: "powershell -c 'Stop-Process -Name chrome -Force -ErrorAction SilentlyContinue'"
9+
- sleep: 1
10+
11+
# Disable "Make Chrome faster" and other popups via Chrome policies
12+
- execute: "powershell -c \"New-Item -Path 'HKLM:\\SOFTWARE\\Policies\\Google\\Chrome' -Force | Out-Null; Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\Policies\\Google\\Chrome' -Name 'SpeedComparisonEnabled' -Value 0 -Type DWord; Set-ItemProperty -Path 'HKLM:\\SOFTWARE\\Policies\\Google\\Chrome' -Name 'PromotionalTabsEnabled' -Value 0 -Type DWord\""
913
- sleep: 1
10-
# Populate Chrome with browsing history so there's data to clear
11-
# Launch with flags to suppress popups (no-first-run, disable SpeedComparison, etc.)
12-
- execute: "powershell -c \"Start-Process chrome @('https://example.com', '--no-first-run', '--disable-popup-blocking', '--disable-default-apps', '--disable-features=SpeedComparison')\""
13-
- sleep: 3
14+
15+
# Create Chrome "First Run" sentinel to prevent first-run UI
16+
- execute: "powershell -c \"$p = \\\"$env:LOCALAPPDATA\\Google\\Chrome\\User Data\\First Run\\\"; if (-not (Test-Path $p)) { New-Item -ItemType File -Path $p -Force | Out-Null }\""
17+
- sleep: 1
18+
19+
# Populate Chrome with browsing history (launch with popup-suppression flags)
20+
- execute: "powershell -c \"Start-Process chrome @('https://example.com', '--no-first-run', '--disable-popup-blocking', '--disable-default-apps', '--disable-features=SpeedComparison,ChromeWhatsNewUI', '--disable-component-update')\""
21+
- sleep: 4
1422
- execute: "powershell -c \"Start-Process chrome 'https://wikipedia.org'\""
1523
- sleep: 2
16-
# Dismiss any remaining Chrome popups by pressing Escape
24+
# Dismiss any remaining popups with Escape
1725
- execute: "powershell -c \"Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait('{ESCAPE}')\""
1826
- sleep: 1
1927
# Close Chrome so the agent starts fresh
@@ -23,14 +31,7 @@ setup:
2331
evaluate:
2432
# Check 1: Chrome history is empty after clearing
2533
- check: command
26-
run: |
27-
powershell -c "
28-
$histPath = \"$env:LOCALAPPDATA\\Google\\Chrome\\User Data\\Default\\History\"
29-
if (Test-Path $histPath) {
30-
$size = (Get-Item $histPath).Length
31-
if ($size -lt 50000) { Write-Output 'cleared' } else { Write-Output 'not_cleared' }
32-
} else { Write-Output 'no_history_file' }
33-
"
34+
run: "powershell -c \"$h = \\\"$env:LOCALAPPDATA\\Google\\Chrome\\User Data\\Default\\History\\\"; if (Test-Path $h) { if ((Get-Item $h).Length -lt 50000) { Write-Output 'cleared' } else { Write-Output 'not_cleared' } } else { Write-Output 'no_history_file' }\""
3435
expect: "cleared"
3536
match: contains
3637

@@ -42,29 +43,26 @@ combine: or
4243
max_steps: 20
4344

4445
milestones:
46+
# Milestone 1: Chrome is running
4547
- name: "Chrome is open"
4648
check: command
47-
run: "powershell -c \"Get-Process chrome -ErrorAction SilentlyContinue | Measure | Select -ExpandProperty Count\""
48-
expect: "1"
49+
run: "powershell -c \"if (Get-Process chrome -ErrorAction SilentlyContinue) { Write-Output 'running' } else { Write-Output 'not_running' }\""
50+
expect: "running"
4951
match: contains
5052

51-
- name: "Settings page is open"
53+
# Milestone 2: Settings or clear-data page is visible (transient — captured per-step)
54+
- name: "Clear browsing data UI is visible"
5255
check: screenshot
53-
description: "Chrome Settings page is visible, or chrome://settings is in the address bar"
56+
description: "Chrome Settings page OR the 'Clear browsing data' dialog/panel is visible in Chrome. The address bar may show chrome://settings or the Clear Browsing Data dialog may be open."
5457

55-
- name: "Clear browsing data dialog is open"
58+
# Milestone 3: Evidence that clear data was initiated
59+
- name: "Clear data action initiated"
5660
check: screenshot
57-
description: "The 'Clear browsing data' dialog or panel is visible in Chrome"
61+
description: "Chrome shows evidence that browsing data clearing was initiated: either a 'Clearing browsing data...' spinner, a confirmation message, or the settings page AFTER the clear dialog was dismissed (no dialog visible, clean settings page)."
5862

63+
# Milestone 4: History file is actually smaller (ground truth)
5964
- name: "Data is cleared"
6065
check: command
61-
run: |
62-
powershell -c "
63-
$histPath = \"$env:LOCALAPPDATA\\Google\\Chrome\\User Data\\Default\\History\"
64-
if (Test-Path $histPath) {
65-
$size = (Get-Item $histPath).Length
66-
if ($size -lt 50000) { Write-Output 'cleared' } else { Write-Output 'not_cleared' }
67-
} else { Write-Output 'no_history_file' }
68-
"
66+
run: "powershell -c \"$h = \\\"$env:LOCALAPPDATA\\Google\\Chrome\\User Data\\Default\\History\\\"; if (Test-Path $h) { if ((Get-Item $h).Length -lt 50000) { Write-Output 'cleared' } else { Write-Output 'not_cleared' } } else { Write-Output 'no_history_file' }\""
6967
expect: "cleared"
7068
match: contains

openadapt_evals/adapters/rl_env.py

Lines changed: 122 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ def __init__(
146146
self._trajectory: list[RolloutStep] = []
147147
self._last_obs: BenchmarkObservation | None = None
148148

149+
# Per-step milestone tracking: indices of milestones that have
150+
# been observed as passing at any point during the episode.
151+
# This is the "high-water mark" — once a milestone passes, it
152+
# stays passed even if the transient state disappears.
153+
self._milestone_passed: set[int] = set()
154+
149155
@property
150156
def adapter(self) -> BenchmarkAdapter:
151157
"""The underlying benchmark adapter."""
@@ -243,6 +249,7 @@ def reset(self, config: ResetConfig | None = None) -> BenchmarkObservation:
243249
self._done = False
244250
self._trajectory = []
245251
self._last_obs = obs
252+
self._milestone_passed = set()
246253

247254
logger.info(
248255
"Environment reset: task=%s, instruction=%s",
@@ -477,16 +484,87 @@ def evaluate(self) -> float:
477484
)
478485
return result.score
479486

487+
def check_milestones_incremental(
488+
self,
489+
screenshot: bytes | None = None,
490+
) -> tuple[int, int]:
491+
"""Check milestones against the CURRENT state and update high-water mark.
492+
493+
Milestones that have passed at ANY point during the episode stay
494+
passed permanently (high-water mark). This is critical for
495+
transient states like "dialog is open" which disappear after the
496+
agent clicks through them.
497+
498+
Call this after each step to track progress. ``evaluate_dense()``
499+
uses the accumulated high-water mark for the final score.
500+
501+
Args:
502+
screenshot: Current screenshot bytes. If ``None``, takes a
503+
fresh screenshot from the adapter.
504+
505+
Returns:
506+
``(passed, total)`` where *passed* is the number of milestones
507+
that have passed at any point (high-water mark), and *total*
508+
is the total number of milestones.
509+
"""
510+
if not self._task_config or not self._task_config.milestones:
511+
return 0, 0
512+
513+
total = len(self._task_config.milestones)
514+
515+
if screenshot is None:
516+
try:
517+
obs = self._adapter.observe()
518+
screenshot = obs.screenshot if obs else b""
519+
except Exception:
520+
screenshot = b""
521+
522+
server_url = getattr(
523+
getattr(self._adapter, "config", None), "server_url", ""
524+
) or ""
525+
526+
for i, ms in enumerate(self._task_config.milestones):
527+
if i in self._milestone_passed:
528+
continue # already passed — skip expensive checks
529+
try:
530+
if ms.check.check == "screenshot":
531+
from openadapt_evals.vlm_evaluator import vlm_judge
532+
success, _ = vlm_judge(screenshot, ms.check.description or "")
533+
if success:
534+
self._milestone_passed.add(i)
535+
logger.info(
536+
"Milestone %d/%d PASSED (high-water): %s",
537+
i + 1, total, ms.name,
538+
)
539+
elif ms.check.check == "command":
540+
result = self._task_config._run_vm_command(
541+
ms.check.run or "", server_url,
542+
)
543+
if self._task_config._check_match(
544+
result, ms.check.expect or "", ms.check.match,
545+
):
546+
self._milestone_passed.add(i)
547+
logger.info(
548+
"Milestone %d/%d PASSED (high-water): %s",
549+
i + 1, total, ms.name,
550+
)
551+
except Exception as exc:
552+
logger.debug(
553+
"Milestone %d check failed (non-fatal): %s", i, exc,
554+
)
555+
556+
passed = len(self._milestone_passed)
557+
return passed, total
558+
480559
def evaluate_dense(self) -> float:
481560
"""Evaluate using dense partial rewards via milestones.
482561
483-
If a TaskConfig with milestones is set, returns the fraction of
484-
milestones passed (0.0 to 1.0). Falls back to binary evaluate()
485-
if no TaskConfig or no milestones are defined.
562+
Uses the **high-water mark** from ``check_milestones_incremental()``
563+
calls during the episode, plus a final check at episode end.
564+
Milestones that passed at any point stay passed — this correctly
565+
handles transient states like open dialogs.
486566
487-
This gives GRPO gradient signal even when no task fully completes:
488-
an agent that passes 3/5 milestones gets reward 0.6 vs 0.0 for
489-
one that passes 0/5.
567+
Falls back to binary evaluate() if no milestones are defined.
490568
491569
Returns:
492570
Dense reward score between 0.0 and 1.0.
@@ -496,10 +574,7 @@ def evaluate_dense(self) -> float:
496574

497575
# Try milestone evaluation first
498576
if self._task_config and self._task_config.milestones:
499-
# Bug 5 fix: Take a FRESH screenshot for evaluation instead of
500-
# using the cached one from a previous step. The cached screenshot
501-
# may be from a different phase (e.g., Phase 1 state leaking into
502-
# Phase 3 evaluation) or may not reflect the current desktop state.
577+
# Take a FRESH screenshot for the final evaluation pass.
503578
screenshot = b""
504579
try:
505580
fresh_obs = self._adapter.observe()
@@ -517,13 +592,13 @@ def evaluate_dense(self) -> float:
517592
if self._last_obs and self._last_obs.screenshot:
518593
screenshot = self._last_obs.screenshot
519594

520-
server_url = getattr(
521-
getattr(self._adapter, "config", None), "server_url", ""
522-
) or ""
595+
# Run one final milestone check to catch any end-of-episode
596+
# state (e.g., "data is cleared" command checks).
597+
self.check_milestones_incremental(screenshot)
598+
599+
total = len(self._task_config.milestones)
600+
passed = len(self._milestone_passed)
523601

524-
passed, total = self._task_config.evaluate_milestones(
525-
screenshot, server_url
526-
)
527602
if total > 0:
528603
milestone_score = passed / total
529604

@@ -533,8 +608,36 @@ def evaluate_dense(self) -> float:
533608
except Exception:
534609
binary_score = 0.0
535610

611+
# If binary eval returned 0.0 (often means /evaluate is
612+
# down), fall back to the task config's own checks run
613+
# locally via /execute_windows + VLM.
614+
if (
615+
binary_score == 0.0
616+
and self._task_config.checks
617+
and screenshot
618+
):
619+
server_url = getattr(
620+
getattr(self._adapter, "config", None),
621+
"server_url", "",
622+
) or ""
623+
try:
624+
binary_score = (
625+
self._task_config.evaluate_checks_local(
626+
screenshot, server_url,
627+
)
628+
)
629+
if binary_score > 0:
630+
logger.info(
631+
"evaluate_dense: local check fallback "
632+
"returned %.2f", binary_score,
633+
)
634+
except Exception as exc:
635+
logger.debug(
636+
"evaluate_dense: local check fallback "
637+
"failed: %s", exc,
638+
)
639+
536640
# Use the higher of milestone score and binary score
537-
# This way, full task completion (1.0) always beats partial (0.6)
538641
score = max(milestone_score, binary_score)
539642

540643
# Backfill reward on last trajectory step
@@ -546,7 +649,8 @@ def evaluate_dense(self) -> float:
546649
self._trajectory[-1].info["milestones_total"] = total
547650

548651
logger.info(
549-
"Dense evaluation: milestones=%d/%d (%.2f), binary=%.2f, final=%.2f",
652+
"Dense evaluation: milestones=%d/%d (%.2f) [high-water], "
653+
"binary=%.2f, final=%.2f",
550654
passed, total, milestone_score, binary_score, score,
551655
)
552656
return score

openadapt_evals/agents/planner_grounder_agent.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,39 @@ def act(
288288
)
289289
return action
290290

291+
# -- Step 0b: Force keyboard shortcut from demo after extended loop ---
292+
# If the planner has ignored anti-loop warnings for 2+ rounds
293+
# (6+ consecutive identical actions), bypass the planner entirely
294+
# and emit the first unused keyboard shortcut from the demo.
295+
if self.demo_guidance and len(self._action_history) >= 6:
296+
import re as _re
297+
298+
recent6 = self._action_history[-6:]
299+
instrs6 = []
300+
for entry in recent6:
301+
m = _re.search(r"\(instruction:\s*(.+)\)\s*$", entry)
302+
if m:
303+
instrs6.append(m.group(1).strip())
304+
if len(instrs6) == 6 and len(set(instrs6)) == 1:
305+
# Extract keyboard shortcuts from demo guidance text
306+
shortcuts = _re.findall(
307+
r"\[([a-z+]+)\]", self.demo_guidance, _re.IGNORECASE,
308+
)
309+
if shortcuts:
310+
# Pick the first shortcut the agent hasn't tried
311+
shortcut = shortcuts[0]
312+
keys = [k.strip() for k in shortcut.split("+")]
313+
logger.warning(
314+
"Anti-loop FORCED OVERRIDE: bypassing planner, "
315+
"emitting keyboard shortcut %r from demo",
316+
shortcut,
317+
)
318+
action = BenchmarkAction(type="key", key=shortcut)
319+
self._action_history.append(
320+
f"KEY({shortcut}) (forced-override: {shortcut})"
321+
)
322+
return action
323+
291324
# -- Step 1: Call planner ------------------------------------------
292325
planner_output = self._call_planner(observation, task)
293326

openadapt_evals/task_config.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,46 @@ def _run_vm_command(command: str, server_url: str) -> str:
439439
return resp.json().get("output", "").strip()
440440
return ""
441441

442+
def evaluate_checks_local(
443+
self, screenshot: bytes, server_url: str,
444+
) -> float:
445+
"""Evaluate the task's own ``checks`` without the /evaluate endpoint.
446+
447+
Uses the same logic as ``evaluate_milestones`` but on the top-level
448+
``evaluate:`` entries. This is a fallback for when the WAA
449+
``/evaluate`` endpoint is unavailable.
450+
451+
Returns:
452+
1.0 if checks pass (respecting ``combine`` mode), else 0.0.
453+
"""
454+
if not self.checks:
455+
return 0.0
456+
457+
results: list[bool] = []
458+
for check in self.checks:
459+
try:
460+
if check.check == "screenshot":
461+
from openadapt_evals.vlm_evaluator import vlm_judge
462+
success, _ = vlm_judge(screenshot, check.description or "")
463+
results.append(success)
464+
elif check.check == "command":
465+
result = self._run_vm_command(check.run or "", server_url)
466+
results.append(
467+
self._check_match(result, check.expect or "", check.match)
468+
)
469+
else:
470+
results.append(False)
471+
except Exception as exc:
472+
logger.warning("evaluate_checks_local: %s", exc)
473+
results.append(False)
474+
475+
if not results:
476+
return 0.0
477+
478+
if self.combine == "or":
479+
return 1.0 if any(results) else 0.0
480+
return 1.0 if all(results) else 0.0
481+
442482
@staticmethod
443483
def _check_match(actual: str, expected: str, match_type: str) -> bool:
444484
"""Check if actual matches expected using the specified method."""

0 commit comments

Comments
 (0)