Files
KVM-privacy/services/kvm_agent/agent.py
T
qiuruiandClaude Opus 4.6 5b50a6b1d3 feat: enhance testing discipline, security model, and multi-UI state support
- Add testing methodology and mouse-first architecture docs to CLAUDE.md
- Harden safety.py with Unicode NFKC normalization, newline injection
  detection, and 10K character length limit
- Create screen_state.py for detecting BIOS, lock screen, desktop, sleep
  states via screenshot hash analysis and OCR keyword matching
- Integrate ScreenStateDetector into agent.py run_task() loop
- Centralize desktop/app keyword constants in screen_state.py
- Add 47 new unit tests (test_screen_state, test_mouse_ops, test_safety
  edge cases)
- Migrate test_integration.py cleanup code from combo keys to mouse_ops
- Fix pre-existing test_shortcut_action test to match blocked behavior
- Add project evaluation table to CLAUDE.md known limitations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 02:54:10 +00:00

462 lines
19 KiB
Python

"""KVM Agent v2 — Computer Use automation with perception, validation, and memory.
Perceive-decide-act loop enhanced with:
- Local OCR perception for precise click targeting
- Pre/post screenshot validation with retry
- Fullscreen window management
- IME toggle for CJK input
- Step recording for template creation
"""
import asyncio
import logging
from dataclasses import dataclass, field
from typing import List, Optional
from .actions import Action
from .kvm_client import KVMClient
from .llm_planner import LLMPlanner
from .screen_state import ScreenStateDetector, PCState
from .validator import ValidationResult
from . import safety
from . import mouse_ops
logger = logging.getLogger(__name__)
@dataclass
class StepRecord:
"""Data captured for each step — used by TemplateRecorder."""
step: int
action: Action
before_screenshot: bytes
after_screenshot: bytes
validation_success: bool
@dataclass
class TaskResult:
"""Result of a completed agent task."""
success: bool
steps_taken: int
final_reason: str
actions: List[str] = field(default_factory=list)
step_records: List[StepRecord] = field(default_factory=list)
class KVMAgent:
"""Main agent orchestrator with perception + validation + retry."""
MAX_WAIT_DELAY: float = 30.0
def __init__(
self,
kvm: KVMClient,
planner: LLMPlanner,
max_steps: int = 30,
step_delay: float = 1.0,
max_wait_delay: Optional[float] = None,
click_delay: float = 0.05,
force_control: bool = True,
# v2 additions — all optional for backward compat
perception=None,
validator=None,
window_manager=None,
config=None,
):
self.kvm = kvm
self.planner = planner
self.max_steps = max_steps
self.step_delay = step_delay
self.max_wait_delay = (
max_wait_delay if max_wait_delay is not None else self.MAX_WAIT_DELAY
)
self.click_delay = click_delay
self.force_control = force_control
self._running = False
# v2 components
self.perception = perception
self.validator = validator
self.window_manager = window_manager
self._config = config
self._ime_english = False # Track whether we've toggled IME to English
self._popup_enabled = True
self.state_detector = ScreenStateDetector()
if config:
self._popup_enabled = config.popup_detection_enabled
# ── Main loop ────────────────────────────────────────────────
async def run_task(self, task_description: str) -> TaskResult:
"""Execute a complete task using the perceive-decide-act loop."""
self._running = True
self._ime_english = False # Reset IME tracking for new task
action_history: List[str] = []
step_records: List[StepRecord] = []
try:
# 1. Enable privacy mode
logger.info("Enabling privacy mode")
await self.kvm.set_privacy_mode(True)
# 2. Acquire exclusive HID control
logger.info("Acquiring HID control (force=%s)", self.force_control)
await self.kvm.acquire_control(force=self.force_control)
# 3. Fullscreen the active window
if self.window_manager and self._should_fullscreen:
await self.window_manager.ensure_fullscreen()
for step in range(self.max_steps):
if not self._running:
return TaskResult(
success=False,
steps_taken=step,
final_reason="Stopped by user",
actions=action_history,
step_records=step_records,
)
# Safety gate
if await self.kvm.is_hid_blocked():
logger.warning("HID blocked by OCR safety — aborting task")
return TaskResult(
success=False,
steps_taken=step,
final_reason="HID blocked by safety system",
actions=action_history,
step_records=step_records,
)
# ── State check ────────────────────────────
before_screenshot = await self.kvm.screenshot()
if self.state_detector and self.perception:
pre_ocr = await self.perception.perceive(before_screenshot)
detection = self.state_detector.detect(
before_screenshot,
pre_ocr.raw_ocr_text,
bool(pre_ocr.elements),
)
if detection.state == PCState.SLEEP:
logger.info(
"Target PC sleeping (%s) — attempting wake",
detection.detail,
)
woke = await self.state_detector.wake_from_sleep(self.kvm)
if woke:
before_screenshot = await self.kvm.screenshot()
else:
logger.warning("Wake failed — aborting")
return TaskResult(
success=False,
steps_taken=step,
final_reason="Target PC asleep and wake failed",
actions=action_history,
step_records=step_records,
)
elif detection.state == PCState.LOCK_SCREEN:
logger.warning(
"Target PC locked (%s) — cannot proceed without PIN",
detection.detail,
)
# ── Perceive ─────────────────────────────────
logger.info(
"Step %d/%d: capturing screenshot", step + 1, self.max_steps
)
# Auto-dismiss popup if detected (does not consume a step)
if self.perception and self.validator and self._popup_enabled:
popup_scene = await self.perception.perceive(before_screenshot)
if self.validator.detect_popup(popup_scene):
logger.warning("Popup detected — auto-dismissing")
if self.window_manager:
await self.window_manager.dismiss_popup(
self.perception, before_screenshot)
await asyncio.sleep(0.5)
before_screenshot = await self.kvm.screenshot()
scene = None
scene_text = ""
if self.perception:
scene = await self.perception.perceive(before_screenshot)
scene_text = scene.to_text_summary()
# ── Decide ───────────────────────────────────
action = await self.planner.plan_action(
before_screenshot,
task_description,
step,
action_history,
max_steps=self.max_steps,
scene_text=scene_text,
)
action_desc = f"[{step + 1}] {action.type}: {action.reason}"
action_history.append(action_desc)
logger.info("Action: %s", action_desc)
# ── Done? ────────────────────────────────────
if action.type == "done":
return TaskResult(
success=True,
steps_taken=step + 1,
final_reason=action.reason,
actions=action_history,
step_records=step_records,
)
# ── Act + Validate ───────────────────────────
validated = await self._execute_with_validation(
action, before_screenshot, scene, step,
)
after_screenshot = await self.kvm.screenshot()
step_records.append(
StepRecord(
step=step,
action=action,
before_screenshot=before_screenshot,
after_screenshot=after_screenshot,
validation_success=validated,
)
)
await asyncio.sleep(self.step_delay)
return TaskResult(
success=False,
steps_taken=self.max_steps,
final_reason="Max steps reached",
actions=action_history,
step_records=step_records,
)
finally:
logger.info("Releasing HID control")
try:
await self.kvm.release_control()
except BaseException as e:
logger.warning("Failed to release control: %s", e)
try:
await self.kvm.set_privacy_mode(False)
except BaseException as e:
logger.warning("Failed to restore privacy mode: %s", e)
def stop(self):
"""Signal the agent to stop after the current step."""
self._running = False
# ── Execution with validation ────────────────────────────────
async def _execute_with_validation(
self, action: Action, before_screenshot: bytes, scene, step: int,
) -> bool:
"""Execute an action and validate it succeeded. Returns True on success."""
if action.type == "click":
return await self._click_with_retry(action, before_screenshot, scene)
# IME toggle before typing or IME-sensitive shortcuts (for CJK Windows)
if self._should_toggle_ime:
if action.type == "type":
await self._ensure_english_ime()
elif (action.type == "shortcut"
and action.shortcut.lower().strip() in self._IME_SENSITIVE_SHORTCUTS):
await self._ensure_english_ime()
await self._execute_action(action)
# Validate non-click actions
if self.validator and action.type in ("type", "shortcut"):
after = await self.kvm.screenshot()
if action.type == "type":
v = await self.validator.validate_type(
before_screenshot, after, action.text,
)
else:
v = await self.validator.validate_shortcut(
before_screenshot, after, action.shortcut,
)
return v.result.value == "success"
return True # No validator or non-validatable action
async def _click_with_retry(
self, action: Action, before: bytes, scene,
) -> bool:
"""Click with OCR-assisted coordinates and fresh-screenshot retry.
Strategy:
Attempt 0: Use OCR from existing scene (zero cost), fall back to LLM coords
Attempt 1+: Take fresh screenshot + re-OCR (screen may have changed)
"""
max_retries = 3
if self._config:
max_retries = self._config.click_max_retries
for attempt in range(max_retries):
x, y = action.x, action.y
# Always prefer OCR coordinates when available
if action.reason and self.perception:
if attempt == 0 and scene:
# First attempt: use existing scene data (zero cost)
matches = scene.find_by_text(action.reason)
if matches and matches[0].bbox != (0.0, 0.0, 0.0, 0.0):
x, y = matches[0].center_x, matches[0].center_y
logger.info(
"OCR-first click (%.3f, %.3f) for '%s'",
x, y, action.reason,
)
else:
# Retry: fresh screenshot + re-OCR (stale data = stale coords)
fresh = await self.kvm.screenshot()
element = await self.perception.find_element(
fresh, target_text=action.reason,
)
if element and element.bbox != (0.0, 0.0, 0.0, 0.0):
x, y = element.center_x, element.center_y
logger.info(
"Retry %d: OCR → (%.3f, %.3f) for '%s'",
attempt, x, y, action.reason,
)
await self.kvm.mouse_move(x, y)
await asyncio.sleep(self.click_delay)
await self.kvm.mouse_click(action.button)
if not self.validator:
return True # No validator — assume success
after = await self.kvm.screenshot()
v = await self.validator.validate_click(
before, after, target_text=action.reason,
)
if v.result.value == "success":
logger.info("Click validated on attempt %d", attempt + 1)
return True
if v.result == ValidationResult.POPUP_DETECTED:
logger.warning("Popup after click — dismissing and retrying")
if self.window_manager:
await self.window_manager.dismiss_popup(
self.perception, after)
before = await self.kvm.screenshot()
continue # Retry the click
logger.info("Click attempt %d: %s", attempt + 1, v.detail)
before = after # Update baseline for next validation
await asyncio.sleep(0.3)
logger.warning("Click failed after %d retries", max_retries)
return False
# ── IME toggle ───────────────────────────────────────────────
# Shortcuts that are intercepted by Chinese IME and need English mode
_IME_SENSITIVE_SHORTCUTS = {"enter", "return", "space"}
async def _ensure_english_ime(self):
"""Detect IME state via OCR and switch to English if Chinese is active.
Delegates to kvm.ensure_english_ime() and updates local tracking.
"""
result = await self.kvm.ensure_english_ime()
self._ime_english = result
# ── Legacy execute (safety-checked) ──────────────────────────
async def _execute_action(self, action: Action):
"""Execute a single action via KVM HID API with safety checks."""
try:
if action.type == "click":
await self.kvm.mouse_move(action.x, action.y)
await asyncio.sleep(self.click_delay)
await self.kvm.mouse_click(action.button)
elif action.type == "type":
if not safety.validate_type_action(action.text):
logger.warning("BLOCKED: unsafe text rejected by safety layer")
return
await self.kvm.keyboard_type(action.text)
elif action.type == "shortcut":
if not safety.validate_shortcut(action.shortcut):
# Try translating blocked combo key to mouse operation
translated = await self._shortcut_to_mouse(action.shortcut)
if not translated:
logger.warning(
"BLOCKED: shortcut '%s' has no mouse equivalent",
action.shortcut,
)
return
await self.kvm.keyboard_shortcut(action.shortcut)
elif action.type == "scroll":
await self.kvm.mouse_scroll(action.delta)
elif action.type == "wait":
capped_delay = max(0, min(action.delay, self.max_wait_delay))
await asyncio.sleep(capped_delay)
else:
logger.warning("Unknown action type: %s", action.type)
except Exception as e:
logger.warning("Action '%s' failed: %s", action.type, e)
# ── Shortcut → mouse translation ─────────────────────────────
async def _shortcut_to_mouse(self, shortcut: str) -> bool:
"""Translate a blocked combo key into an equivalent mouse operation.
Returns True if a mouse equivalent was executed, False if no mapping.
"""
key = shortcut.lower().strip()
if key in ("alt+f4",):
if self.perception:
return await mouse_ops.close_window(self.kvm, self.perception)
elif key in ("win+d",):
await mouse_ops.show_desktop(self.kvm)
return True
elif key in ("ctrl+s",):
if self.perception:
return await mouse_ops.click_element(
self.kvm, "保存", self.perception)
elif key in ("ctrl+w",):
if self.perception:
return await mouse_ops.close_window(self.kvm, self.perception)
elif key in ("win+r", "win+s"):
# Cannot launch without app name — just log
logger.info("Shortcut '%s' blocked; use click-based app launch", key)
return False
elif key in ("ctrl+c",):
if self.perception:
await mouse_ops.right_click(self.kvm, 0.5, 0.5)
await asyncio.sleep(0.3)
return await mouse_ops.click_element(
self.kvm, "复制", self.perception)
elif key in ("ctrl+v",):
if self.perception:
await mouse_ops.right_click(self.kvm, 0.5, 0.5)
await asyncio.sleep(0.3)
return await mouse_ops.click_element(
self.kvm, "粘贴", self.perception)
return False
# ── Property helpers ─────────────────────────────────────────
@property
def _should_fullscreen(self) -> bool:
if self._config:
return self._config.fullscreen_enabled
return True
@property
def _should_toggle_ime(self) -> bool:
if self._config:
return self._config.ime_toggle_before_type
return False