feat. Finishing sound & agent loop fixes
CI / test (push) Failing after 33s

This commit is contained in:
2026-06-04 15:16:31 +02:00
parent 97641b354e
commit 643320a9de
6 changed files with 271 additions and 42 deletions
+73 -42
View File
@@ -126,16 +126,13 @@ def build_initial_action_prompt(
"Explicitly watch for #32770 dialogs, Explorer open/save pickers, browser download/upload flows, taskbar/start menu focus traps, context menus, disabled controls, and permission prompts.\n"
"If focus or the foreground app may be wrong, call get_active_window, find_window, focus_window, or a focus wait helper.\n"
"If an unexpected modal appears, pause the prior plan and resolve the modal first.\n"
"Do not invent new subgoals. Prefer non-visual verification when available.\n"
"Use wait_for_focus_change when focus transfer is expected but not yet confirmed.\n"
"When a fresh focus check or a clear retained visual already proves the target editor or field is ready, act directly; do not re-capture the screen just to reconfirm an obvious large input area.\n"
"You may use more than one tool in one step when that improves certainty, such as get_active_window plus detect_dialog, see_screen then enhance, or click then see_screen.\n"
"When done, do a fresh verification pass with see_screen and add enhance if the proof is small or text-heavy.\n"
"Then call task_complete(return=..., data={\"observed_result\": ...}).\n"
"Include useful structured output in data.",
CORE_OPERATING_DOCTRINE,
WINDOWS_ENVIRONMENT_RULES,
BROWSER_WORKFLOW_RULES,
DIALOG_HANDLING_RULES,
COMPLETION_VERIFICATION_RULES,
)
@@ -149,10 +146,6 @@ def build_no_tool_prompt(prohibited_key_combos: list[str] | tuple[str, ...] | se
"Do not assume execute_command launches changed the foreground window; verify focus before typing.\n"
"If a modal, picker, or browser download/upload surface is likely, resolve that first.\n"
"Before task_complete, do a fresh verification pass with see_screen, add enhance if needed, and include data.observed_result.",
CORE_OPERATING_DOCTRINE,
WINDOWS_ENVIRONMENT_RULES,
DIALOG_HANDLING_RULES,
COMPLETION_VERIFICATION_RULES,
)
@@ -190,11 +183,6 @@ def build_context_compaction_prompt(
"If a fresh focus check or retained visual already proves a text field or editor is ready, act without demanding another screenshot.\n"
"Treat execute_command app or URL launches as background until focus is explicitly verified.\n"
"Use tools only. Finish only after a fresh verification pass with see_screen and explicit data.observed_result in task_complete.",
CORE_OPERATING_DOCTRINE,
WINDOWS_ENVIRONMENT_RULES,
BROWSER_WORKFLOW_RULES,
DIALOG_HANDLING_RULES,
COMPLETION_VERIFICATION_RULES,
)
@@ -216,11 +204,6 @@ def build_blocked_action_prompt(
"If secure desktop or UAC is suspected, stop blind retries and report the blocked state explicitly.\n"
"Switch strategy after the fresh classification: native control instead of pixels, keyboard instead of mouse, mouse instead of keyboard, commands instead of UI, UI instead of commands, or finish if the job is already done.\n"
f"Confirm what changed before choosing a new action. Use classify -> choose control channel -> execute one meaningful transition -> verify.{extra}",
CORE_OPERATING_DOCTRINE,
WINDOWS_ENVIRONMENT_RULES,
BROWSER_WORKFLOW_RULES,
DIALOG_HANDLING_RULES,
COMPLETION_VERIFICATION_RULES,
)
@@ -235,14 +218,11 @@ def build_observation_loop_prompt(
f"{summary_text}\n"
"Do not keep calling broad observation tools like see_screen or get_active_window on the same unchanged state.\n"
"Change method now: use a native window/dialog/element tool for this surface, interact with the visible control, resolve the modal, or finish if you already have proof.\n"
"If the requested navigation or status check already happened, do one final verification pass and then call task_complete instead of waiting or re-clicking.\n"
f"{_prohibited_key_combo_prompt(prohibited_key_combos)}"
"Use enhance only if a small or text-heavy control must be read before acting.\n"
"If this window is a #32770 dialog or picker, act on the dialog instead of re-scanning the whole screen.\n"
"Use classify -> choose control channel -> execute one meaningful transition -> verify.",
CORE_OPERATING_DOCTRINE,
WINDOWS_ENVIRONMENT_RULES,
DIALOG_HANDLING_RULES,
COMPLETION_VERIFICATION_RULES,
)
@@ -260,7 +240,6 @@ def build_finish_likely_prompt(
f"{_prohibited_key_combo_prompt(prohibited_key_combos)}"
"Do not reopen menus, repeat save/export/download actions, or re-search the filesystem unless a new contradiction appears.\n"
"Call task_complete now with a concise return string and explicit data.observed_result from the latest verification.",
COMPLETION_VERIFICATION_RULES,
)
return _compose_prompt(
"Runtime completion evidence indicates the objective is likely already satisfied.\n"
@@ -268,7 +247,6 @@ def build_finish_likely_prompt(
"Do one fresh verification pass now: call see_screen, add enhance only if the proof is small or text-heavy, then call task_complete.\n"
f"{_prohibited_key_combo_prompt(prohibited_key_combos)}"
"Do not reopen menus, repeat save/export/download actions, or re-search the filesystem unless a new contradiction appears.",
COMPLETION_VERIFICATION_RULES,
)
@@ -332,10 +310,25 @@ OBSERVATION_TOOL_NAMES = VISUAL_TOOL_NAMES | WINDOW_TOOL_NAMES | DIALOG_TOOL_NAM
"clipboard_get",
"get_cursor_position",
}
OBSERVATION_NON_PROGRESS_TOOL_NAMES = VISUAL_TOOL_NAMES | {
"list_windows",
"find_window",
"wait_for_window",
"wait_for_focus_change",
"get_active_window",
"detect_dialog",
"wait_for_dialog_close",
"list_ui_elements",
"wait_for_ui_element",
"clipboard_get",
"get_cursor_position",
"sleep",
}
WINDOWS_ONLY_TOOL_NAMES = WINDOW_TOOL_NAMES | DIALOG_TOOL_NAMES | UI_ELEMENT_TOOL_NAMES
MAX_ACTION_SIGNATURE_ATTEMPTS = 3
MAX_STABLE_OBSERVATION_STEPS = 3
FINISH_LIKELY_OBSERVATION_TOOLS = {"see_screen", "enhance", "get_active_window", "detect_dialog"}
BROAD_REOBSERVATION_TOOL_NAMES = {"see_screen", "get_active_window", "detect_dialog", "list_windows", "sleep"}
def normalize_disabled_tools(tool_names: set[str] | list[str] | tuple[str, ...] | None) -> list[str]:
@@ -1896,42 +1889,74 @@ class ScreenJobAgent:
self,
tool_names: list[str],
active_window: dict[str, Any] | None,
visual_signature: str | None = None,
) -> None:
if not tool_names:
return
contains_action = any(name not in OBSERVATION_NON_PROGRESS_TOOL_NAMES for name in tool_names)
window_for_history = active_window
if not isinstance(window_for_history, dict) or not bool(window_for_history.get("available")):
fallback_window = self.last_observed_window if isinstance(self.last_observed_window, dict) else None
if isinstance(fallback_window, dict) and bool(fallback_window.get("available")):
window_for_history = fallback_window
self.step_history.append(
{
"step": self.step,
"tool_names": list(tool_names),
"window_signature": self._window_signature(active_window),
"window_summary": self._window_summary(active_window) if active_window else "",
"window_signature": self._window_signature(window_for_history),
"window_summary": self._window_summary(window_for_history) if window_for_history else "",
"had_visual": any(name in VISUAL_TOOL_NAMES for name in tool_names),
"contains_action": contains_action,
"visual_signature": str(visual_signature or "").strip(),
}
)
self.step_history = self.step_history[-12:]
def _stable_observation_loop(self) -> dict[str, Any] | None:
recent = self.step_history[-MAX_STABLE_OBSERVATION_STEPS:]
recent = list(reversed(self.step_history))
if len(recent) < MAX_STABLE_OBSERVATION_STEPS:
return None
if any(not entry.get("tool_names") for entry in recent):
return None
if any(not set(entry["tool_names"]).issubset(OBSERVATION_TOOL_NAMES) for entry in recent):
return None
if any(not bool(entry.get("had_visual")) for entry in recent):
return None
signatures = {str(entry.get("window_signature") or "").strip() for entry in recent}
signatures.discard("")
if len(signatures) != 1:
window_signature = ""
repeated_steps = 0
visual_signatures: set[str] = set()
for entry in recent:
tool_names = list(entry.get("tool_names") or [])
if not tool_names:
break
entry_signature = str(entry.get("window_signature") or "").strip()
if not entry_signature:
break
if not window_signature:
window_signature = entry_signature
elif entry_signature != window_signature:
break
if bool(entry.get("contains_action")):
break
if any(name in OBSERVATION_TOOL_NAMES for name in tool_names):
repeated_steps += 1
visual_signature = str(entry.get("visual_signature") or "").strip()
if visual_signature:
visual_signatures.add(visual_signature)
if repeated_steps >= MAX_STABLE_OBSERVATION_STEPS:
break
if repeated_steps < MAX_STABLE_OBSERVATION_STEPS or not window_signature:
return None
return {
"signature": next(iter(signatures)),
"window_summary": str(recent[-1].get("window_summary") or "").strip(),
"repeated_steps": len(recent),
"signature": window_signature,
"window_summary": str(recent[0].get("window_summary") or "").strip(),
"repeated_steps": repeated_steps,
"visual_signature_count": len(visual_signatures),
}
def _check_observation_loop_gate(self, tool_name: str) -> dict[str, Any] | None:
if tool_name not in {"see_screen", "get_active_window"}:
if tool_name not in BROAD_REOBSERVATION_TOOL_NAMES:
return None
stable_loop = self._stable_observation_loop()
if stable_loop is None:
@@ -4214,6 +4239,7 @@ class ScreenJobAgent:
next_input: list[dict[str, Any]] = []
step_tool_names: list[str] = []
step_active_window: dict[str, Any] | None = None
step_visual_signature: str | None = None
for tool_call in tool_calls:
if self._is_cancelled():
cancelled = True
@@ -4246,6 +4272,11 @@ class ScreenJobAgent:
self._emit("tool_result", {"step": self.step, "tool": name, "result": result})
if name == "get_active_window" and bool(result.get("ok")) and isinstance(result.get("window"), dict):
step_active_window = dict(result["window"])
if name in VISUAL_TOOL_NAMES and bool(result.get("ok")):
meta = result.get("meta") if isinstance(result.get("meta"), dict) else {}
visual_signature = str(meta.get("visual_signature") or "").strip()
if visual_signature:
step_visual_signature = visual_signature
next_input.append(
{
"type": "function_call_output",
@@ -4306,7 +4337,7 @@ class ScreenJobAgent:
if cancelled:
break
self._record_step_history(step_tool_names, step_active_window)
self._record_step_history(step_tool_names, step_active_window, step_visual_signature)
if bool(self.finish_likely_state.get("active")):
next_input.append(
{
+10
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from .agent import normalize_disabled_tools
from .config import load_app_config
from .desktop_overlay import get_desktop_overlay_manager
from .models import RuntimeOptions
from .runtime import create_openai_client, run_job
from .safety import assess_task_safety
@@ -175,6 +176,15 @@ def main(argv: list[str] | None = None) -> int:
)
return 1
if result.completed:
get_desktop_overlay_manager().show_completion(
job_id=artifacts.run_id,
objective=args.job,
return_message=result.return_message,
steps=result.steps,
elapsed_seconds=max(0.0, float(result.ended_at - result.started_at)),
)
payload = {
"completed": result.completed,
"result": result.return_message,
+14
View File
@@ -7,6 +7,11 @@ import threading
from dataclasses import dataclass
from typing import Any
try:
import winsound
except Exception: # noqa: BLE001
winsound = None
@dataclass(frozen=True)
class CompletionOverlayPayload:
@@ -28,6 +33,14 @@ class DesktopOverlayManager:
self._warned = False
self._auto_dismiss_ms = max(0, int(round(float(auto_dismiss_seconds) * 1000)))
def _play_completion_sound(self) -> None:
if os.name != "nt" or winsound is None:
return
try:
winsound.MessageBeep(winsound.MB_ICONASTERISK)
except Exception as exc: # noqa: BLE001
self.logger.debug("Completion sound failed (%s: %s)", type(exc).__name__, exc)
def show_completion(
self,
*,
@@ -37,6 +50,7 @@ class DesktopOverlayManager:
steps: int,
elapsed_seconds: float,
) -> None:
self._play_completion_sound()
if os.name != "nt":
self._disable_once("Desktop completion HUD is only enabled on Windows.")
return