fix herness
This commit is contained in:
parent
3a854bfa3d
commit
91d4027a91
|
|
@ -114,6 +114,7 @@ The guides below are currently in Traditional Chinese.
|
||||||
| [Interactive diagram](./docs/workflow.html) | Zoomable, searchable HTML chart; download and open |
|
| [Interactive diagram](./docs/workflow.html) | Zoomable, searchable HTML chart; download and open |
|
||||||
| [Operations](./docs/operations.md) | Resources, env vars, security, site checks, sudo |
|
| [Operations](./docs/operations.md) | Resources, env vars, security, site checks, sudo |
|
||||||
| [Agent experience](./docs/agent-experience.md) | Turn limits, persistent terminal, live chat |
|
| [Agent experience](./docs/agent-experience.md) | Turn limits, persistent terminal, live chat |
|
||||||
|
| [hermes-agent review](./docs/hermes-agent-cua-review.md) | Cua harness smoothness: comparison with hermes-agent |
|
||||||
| [Development](./docs/development.md) | Local dev, checks and tests, directory layout |
|
| [Development](./docs/development.md) | Local dev, checks and tests, directory layout |
|
||||||
| [Env example](./.env.example) | Environment variables and defaults |
|
| [Env example](./.env.example) | Environment variables and defaults |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ Use tools only when the user wants something done on the computer: open a site,
|
||||||
4) Opening a local file or non-browser app: use open_path or launch_app.
|
4) Opening a local file or non-browser app: use open_path or launch_app.
|
||||||
5) Native GUI with no DOM (dialogs, file manager, XFCE): use computer_act by element id. Those ids are AT-SPI controls, not window boxes.
|
5) Native GUI with no DOM (dialogs, file manager, XFCE): use computer_act by element id. Those ids are AT-SPI controls, not window boxes.
|
||||||
|
|
||||||
The shell is a visible Cua-controlled terminal on the shared VNC screen. The same session keeps its directory, exports and background jobs. Results are screenshots, not hidden stdout: inspect the prompt to decide whether a command finished. A timeout does not stop the job. Omit command to inspect it again, use keys \"C-c\" to interrupt, and never type a second command while busy. File tools also work through this visible terminal; read_file supports start_line and lines, and you can scroll to inspect longer output. These computer tools require a vision model.
|
The shell is a visible Cua-controlled terminal on the shared VNC screen. The same session keeps its directory, exports and background jobs. Results are screenshots, not hidden stdout: inspect the prompt to decide whether a command finished. A timeout does not stop the job. Omit command to inspect it again, use keys \"C-c\" to interrupt, and never type a second command while busy. File tools also work through this visible terminal; read_file supports start_line and lines, and you can scroll to inspect longer output. Clicking, typing, and browsing need a vision model; a text-only model can still read computer_observe as an element tree.
|
||||||
|
|
||||||
When you ARE using the desktop: the human can interact with the same live screen while you work; this does not pause your task. Prefer browser/native element actions over moving the shared pointer. If the screen changes unexpectedly, observe again and continue from the current state; do not undo human changes or replay an uncertain click. Request human assistance only when the task needs it. Only the latest screenshot you received is current; they may have interacted since. Call computer_observe before coordinate clicks, after navigation, when the outcome is uncertain, and before describing what is on screen. Never guess the screen state from files, history or memory. Never kill or restart the browser, display, or desktop processes; if the browser tool reports it is unavailable, use computer_observe / computer_act on the existing window instead.
|
When you ARE using the desktop: the human can interact with the same live screen while you work; this does not pause your task. Prefer browser/native element actions over moving the shared pointer. If the screen changes unexpectedly, observe again and continue from the current state; do not undo human changes or replay an uncertain click. Request human assistance only when the task needs it. Only the latest screenshot you received is current; they may have interacted since. Call computer_observe before coordinate clicks, after navigation, when the outcome is uncertain, and before describing what is on screen. Never guess the screen state from files, history or memory. Never kill or restart the browser, display, or desktop processes; if the browser tool reports it is unavailable, use computer_observe / computer_act on the existing window instead.
|
||||||
|
|
||||||
|
|
@ -512,6 +512,7 @@ async fn execute_run(
|
||||||
vision,
|
vision,
|
||||||
gui_block: std::sync::Mutex::new(None),
|
gui_block: std::sync::Mutex::new(None),
|
||||||
previous_frame: std::sync::Mutex::new(None),
|
previous_frame: std::sync::Mutex::new(None),
|
||||||
|
previous_signature: std::sync::Mutex::new(None),
|
||||||
elements: std::sync::Mutex::new(Vec::new()),
|
elements: std::sync::Mutex::new(Vec::new()),
|
||||||
miss_streak: std::sync::Mutex::new(0),
|
miss_streak: std::sync::Mutex::new(0),
|
||||||
last_click_key: std::sync::Mutex::new(None),
|
last_click_key: std::sync::Mutex::new(None),
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@ use lazyboy_contracts::{
|
||||||
ComputerAction, ComputerMode, ComputerObservation, PointerType, UiElement,
|
ComputerAction, ComputerMode, ComputerObservation, PointerType, UiElement,
|
||||||
};
|
};
|
||||||
use lazyboy_control::{
|
use lazyboy_control::{
|
||||||
ActionError, ActionRequest, AdapterContext, BrowserPage, BrowserRequest, ComputerRef,
|
ActionDecision, ActionError, ActionRequest, ActionVerdict, AdapterContext, BrowserPage,
|
||||||
SandboxProvider, apply_element_targets, click_fingerprint, element_id, format_ui_elements,
|
BrowserRequest, ComputerRef, SandboxProvider, ScreenChange, apply_element_targets,
|
||||||
frames_match, merge_ui_elements, overlay_elements, parse_computer_actions,
|
click_fingerprint, element_id, format_ui_element_lines, format_ui_elements, frame_signature,
|
||||||
resolve_bot_workspace_cwd, resolve_bot_workspace_path, should_block_stale_click,
|
merge_ui_elements, overlay_elements, parse_computer_actions, resolve_bot_workspace_cwd,
|
||||||
|
resolve_bot_workspace_path, screen_change_between, should_block_stale_click,
|
||||||
};
|
};
|
||||||
use rig_core::completion::ToolDefinition;
|
use rig_core::completion::ToolDefinition;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
@ -27,6 +28,10 @@ pub struct ToolCtx {
|
||||||
pub vision: bool,
|
pub vision: bool,
|
||||||
pub gui_block: Mutex<Option<String>>,
|
pub gui_block: Mutex<Option<String>>,
|
||||||
pub previous_frame: Mutex<Option<String>>,
|
pub previous_frame: Mutex<Option<String>>,
|
||||||
|
/// Coarse signature of the previous capture. `frame_id` is a sha256, so on
|
||||||
|
/// a live desktop every panel clock tick is a "new" frame; the signature is
|
||||||
|
/// what makes "nothing actually happened" detectable.
|
||||||
|
pub previous_signature: Mutex<Option<Vec<u8>>>,
|
||||||
pub elements: Mutex<Vec<UiElement>>,
|
pub elements: Mutex<Vec<UiElement>>,
|
||||||
pub miss_streak: Mutex<u32>,
|
pub miss_streak: Mutex<u32>,
|
||||||
pub last_click_key: Mutex<Option<String>>,
|
pub last_click_key: Mutex<Option<String>>,
|
||||||
|
|
@ -430,28 +435,40 @@ fn text_outcome(text: impl Into<String>) -> ToolOutcome {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> {
|
/// A human holds the desktop, so nothing may touch it or describe it — no
|
||||||
if let Some(message) = ctx.gui_block.lock().unwrap().clone() {
|
/// matter what the model can see.
|
||||||
return Some(ToolOutcome {
|
fn gui_blocked(ctx: &ToolCtx) -> Option<ToolOutcome> {
|
||||||
|
ctx.gui_block
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.map(|message| ToolOutcome {
|
||||||
text: message,
|
text: message,
|
||||||
image: None,
|
image: None,
|
||||||
pause: false,
|
pause: false,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
});
|
|
||||||
}
|
|
||||||
if ctx.vision {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(ToolOutcome {
|
|
||||||
text: "This model cannot see the shared desktop. Pick a vision model for Cua computer tools.".into(),
|
|
||||||
image: None,
|
|
||||||
pause: false,
|
|
||||||
blocks: Vec::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything that acts on pixels. Observation deliberately does not use this:
|
||||||
|
/// an element tree is text, so a text-only model can still read the desktop.
|
||||||
|
fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> {
|
||||||
|
gui_blocked(ctx).or_else(|| {
|
||||||
|
(!ctx.vision).then(|| {
|
||||||
|
text_outcome(
|
||||||
|
"This model cannot see the shared desktop, so it cannot drive it. computer_observe still reports the element tree as text; pick a vision model to click, type, or browse.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bool) -> String {
|
/// Controls listed in one observation. A busy native desktop lands near this
|
||||||
|
/// number and the tail is scrolled-off controls and duplicated windows.
|
||||||
|
const MAX_LISTED_ELEMENTS: usize = 120;
|
||||||
|
|
||||||
|
/// The element list replaces the element array: models address controls by id
|
||||||
|
/// (see `element_id`), so serializing both doubled every observation.
|
||||||
|
fn observation_text(note: &str, observation: &ComputerObservation, change: ScreenChange) -> String {
|
||||||
let label = if observation
|
let label = if observation
|
||||||
.elements
|
.elements
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -461,10 +478,21 @@ fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bo
|
||||||
} else {
|
} else {
|
||||||
"Clickable windows"
|
"Clickable windows"
|
||||||
};
|
};
|
||||||
|
// Coverage belongs on the list, not in a separate warning: this is the
|
||||||
|
// exact place where a model decides that a control does not exist.
|
||||||
|
let coverage = if observation.native_observation_complete {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
" (partial: some windows did not report controls, so a missing entry is not proof)"
|
||||||
|
};
|
||||||
format!(
|
format!(
|
||||||
"{note}{}\n{label}: {}\n{}",
|
"{note}{}\n{label}{coverage}:\n{}\n{}",
|
||||||
if unchanged { " (screen unchanged)" } else { "" },
|
match change {
|
||||||
format_ui_elements(&observation.elements),
|
ScreenChange::Identical => " (screen unchanged)",
|
||||||
|
ScreenChange::Similar => " (no visible change)",
|
||||||
|
ScreenChange::Changed => "",
|
||||||
|
},
|
||||||
|
format_ui_element_lines(&observation.elements, MAX_LISTED_ELEMENTS),
|
||||||
json!({
|
json!({
|
||||||
"frameId": observation.frame_id,
|
"frameId": observation.frame_id,
|
||||||
"width": observation.width,
|
"width": observation.width,
|
||||||
|
|
@ -472,13 +500,12 @@ fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bo
|
||||||
"capturedAt": observation.captured_at,
|
"capturedAt": observation.captured_at,
|
||||||
"cursor": observation.cursor,
|
"cursor": observation.cursor,
|
||||||
"activeWindow": observation.active_window,
|
"activeWindow": observation.active_window,
|
||||||
"elements": observation.elements,
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn observe(ctx: &ToolCtx) -> ToolOutcome {
|
async fn observe(ctx: &ToolCtx) -> ToolOutcome {
|
||||||
if let Some(blocked) = vision_guard(ctx) {
|
if let Some(blocked) = gui_blocked(ctx) {
|
||||||
return blocked;
|
return blocked;
|
||||||
}
|
}
|
||||||
match ctx
|
match ctx
|
||||||
|
|
@ -510,7 +537,7 @@ async fn wait_then_observe(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
|
||||||
.unwrap_or(5.0)
|
.unwrap_or(5.0)
|
||||||
.clamp(1.0, MAX_WAIT_SECS);
|
.clamp(1.0, MAX_WAIT_SECS);
|
||||||
tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await;
|
tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await;
|
||||||
if vision_guard(ctx).is_some() {
|
if gui_blocked(ctx).is_some() {
|
||||||
return text_outcome(format!("waited {seconds:.0}s"));
|
return text_outcome(format!("waited {seconds:.0}s"));
|
||||||
}
|
}
|
||||||
match ctx
|
match ctx
|
||||||
|
|
@ -872,6 +899,11 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
|
let confirmed = result
|
||||||
|
.verdict
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|verdict| verdict.decision == ActionDecision::Done);
|
||||||
|
let verdict = result.verdict.as_ref().map(verdict_note);
|
||||||
if let Some(observation) = result.observation {
|
if let Some(observation) = result.observation {
|
||||||
let (observation, note) = attach_ui_elements(
|
let (observation, note) = attach_ui_elements(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
@ -879,18 +911,28 @@ async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
|
||||||
&format!("completed {} computer action(s)", result.completed),
|
&format!("completed {} computer action(s)", result.completed),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let unchanged =
|
let change = screen_change(ctx, &observation);
|
||||||
frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
|
|
||||||
let mut outcome = pack_observation(ctx, ¬e, observation);
|
let mut outcome = pack_observation(ctx, ¬e, observation);
|
||||||
note_click_result(ctx, had_click, unchanged, &mut outcome);
|
note_click_result(
|
||||||
|
ctx,
|
||||||
|
had_click,
|
||||||
|
// A confirmed effect is a change even when a ticking clock
|
||||||
|
// hid it in the pixels; counting it as a miss would coach
|
||||||
|
// the model away from a click that worked.
|
||||||
|
change != ScreenChange::Changed && !confirmed,
|
||||||
|
&mut outcome,
|
||||||
|
);
|
||||||
|
with_verdict(&mut outcome, verdict);
|
||||||
outcome
|
outcome
|
||||||
} else {
|
} else {
|
||||||
ToolOutcome {
|
let mut outcome = ToolOutcome {
|
||||||
text: json!({"ok": true, "completed": result.completed}).to_string(),
|
text: json!({"ok": true, "completed": result.completed}).to_string(),
|
||||||
image: None,
|
image: None,
|
||||||
pause: false,
|
pause: false,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
}
|
};
|
||||||
|
with_verdict(&mut outcome, verdict);
|
||||||
|
outcome
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => ToolOutcome {
|
Err(error) => ToolOutcome {
|
||||||
|
|
@ -1008,6 +1050,8 @@ fn pause_unknown_element(_ctx: &ToolCtx, id: u32, elements: &[UiElement]) -> Too
|
||||||
/// Clicks that change nothing are common and usually recoverable (disabled
|
/// Clicks that change nothing are common and usually recoverable (disabled
|
||||||
/// button, video still playing, slightly off target). Coach the model instead
|
/// button, video still playing, slightly off target). Coach the model instead
|
||||||
/// of pausing; it can still call request_takeover when it is truly stuck.
|
/// of pausing; it can still call request_takeover when it is truly stuck.
|
||||||
|
/// `unchanged` is the perceptual comparison on purpose: with a byte-exact one a
|
||||||
|
/// ticking clock would reset the streak and this advice would never fire.
|
||||||
fn note_click_result(ctx: &ToolCtx, had_click: bool, unchanged: bool, outcome: &mut ToolOutcome) {
|
fn note_click_result(ctx: &ToolCtx, had_click: bool, unchanged: bool, outcome: &mut ToolOutcome) {
|
||||||
if !had_click {
|
if !had_click {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1026,13 +1070,52 @@ fn note_click_result(ctx: &ToolCtx, had_click: bool, unchanged: bool, outcome: &
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the driver could prove about the actions it just ran, as one line the
|
||||||
|
/// model can act on. `None` from the driver stays `None` here: inventing
|
||||||
|
/// "it worked" is exactly the mistake that makes a model retype a field that
|
||||||
|
/// was already filled.
|
||||||
|
fn verdict_note(verdict: &ActionVerdict) -> String {
|
||||||
|
let detail = verdict
|
||||||
|
.effect
|
||||||
|
.as_deref()
|
||||||
|
.map(|effect| format!(" (driver said: {effect})"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
match verdict.decision {
|
||||||
|
ActionDecision::Done => "verdict: effect confirmed. Do not repeat this action.".into(),
|
||||||
|
ActionDecision::VerifyFreshState => format!(
|
||||||
|
"verdict: effect not confirmed{detail}. Read the new screenshot before you retry anything, and never repeat input that may already have worked."
|
||||||
|
),
|
||||||
|
ActionDecision::Escalate => format!(
|
||||||
|
"verdict: the driver reports no effect{detail}. Re-observe, then change the approach (fresh coordinates, focus the window first, another control) instead of repeating the same input."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First line of the tool result: the verdict decides whether the model looks
|
||||||
|
/// at the new frame or fires the same input again, so it goes before the frame.
|
||||||
|
fn with_verdict(outcome: &mut ToolOutcome, verdict: Option<String>) {
|
||||||
|
if let Some(note) = verdict {
|
||||||
|
outcome.text = format!("{note}\n{}", outcome.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation) -> ToolOutcome {
|
fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation) -> ToolOutcome {
|
||||||
let unchanged = frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
|
let change = screen_change(ctx, &observation);
|
||||||
|
let signature = frame_signature(&observation.image);
|
||||||
*ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone());
|
*ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone());
|
||||||
|
*ctx.previous_signature.lock().unwrap() = signature;
|
||||||
*ctx.elements.lock().unwrap() = observation.elements.clone();
|
*ctx.elements.lock().unwrap() = observation.elements.clone();
|
||||||
|
let mut text = observation_text(note, &observation, change);
|
||||||
|
if !ctx.vision {
|
||||||
|
// The list is the whole payload for this model, so say so: left unsaid
|
||||||
|
// it keeps waiting for a picture that is never coming.
|
||||||
|
text.push_str("\n(elements only: this model cannot see the screen)");
|
||||||
|
}
|
||||||
ToolOutcome {
|
ToolOutcome {
|
||||||
text: observation_text(note, &observation, unchanged),
|
text,
|
||||||
image: if unchanged {
|
// Only a byte-identical frame drops the picture. A change too small to
|
||||||
|
// move the signature is still one the model gets to look at.
|
||||||
|
image: if ctx.vision && change != ScreenChange::Identical {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(overlay_elements(&observation.image, &observation.elements))
|
Some(overlay_elements(&observation.image, &observation.elements))
|
||||||
|
|
@ -1042,6 +1125,18 @@ fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How this capture differs from the one the Agent saw last: byte-exact for the
|
||||||
|
/// screenshot contract, perceptual for advice.
|
||||||
|
fn screen_change(ctx: &ToolCtx, observation: &ComputerObservation) -> ScreenChange {
|
||||||
|
let previous_frame = ctx.previous_frame.lock().unwrap().clone();
|
||||||
|
let previous_signature = ctx.previous_signature.lock().unwrap().clone();
|
||||||
|
screen_change_between(
|
||||||
|
previous_frame.as_deref(),
|
||||||
|
previous_signature.as_deref(),
|
||||||
|
observation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Quote literal shell text typed by Cua into the visible terminal.
|
/// Quote literal shell text typed by Cua into the visible terminal.
|
||||||
fn shell_quote(value: &str) -> String {
|
fn shell_quote(value: &str) -> String {
|
||||||
format!("'{}'", value.replace('\'', "'\\''"))
|
format!("'{}'", value.replace('\'', "'\\''"))
|
||||||
|
|
@ -1817,3 +1912,165 @@ mod saved_login_tests {
|
||||||
assert!(login_field(&page, "username").is_none());
|
assert!(login_field(&page, "username").is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod observation_text_tests {
|
||||||
|
use super::*;
|
||||||
|
use lazyboy_control::observation_from_png;
|
||||||
|
|
||||||
|
fn observation(count: usize) -> ComputerObservation {
|
||||||
|
let mut observation = observation_from_png(vec![0xFF, 0xD8, 0xFF], 1920, 1080, None, None);
|
||||||
|
observation.elements = (1..=count as u32)
|
||||||
|
.map(|index| UiElement {
|
||||||
|
id: index,
|
||||||
|
title: format!("Window {index}"),
|
||||||
|
x: index,
|
||||||
|
y: index,
|
||||||
|
w: 100,
|
||||||
|
h: 40,
|
||||||
|
kind: Some("window".into()),
|
||||||
|
..UiElement::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
observation
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_each_control_once_and_names_what_was_dropped() {
|
||||||
|
let text = observation_text(
|
||||||
|
"computer observed",
|
||||||
|
&observation(150),
|
||||||
|
ScreenChange::Changed,
|
||||||
|
);
|
||||||
|
// A model clicks by id, so the element array must not ride along with
|
||||||
|
// the list: it used to double every observation.
|
||||||
|
assert!(!text.contains("\"selector\""));
|
||||||
|
assert!(!text.contains("\"elements\""));
|
||||||
|
assert_eq!(text.matches("[1]").count(), 1);
|
||||||
|
assert!(text.contains("+30 more not listed"));
|
||||||
|
// Screen metadata is small and the model needs the frame id.
|
||||||
|
assert!(text.contains("\"frameId\""));
|
||||||
|
assert!(text.contains("\"width\":1920"));
|
||||||
|
|
||||||
|
let small = observation_text("computer observed", &observation(3), ScreenChange::Changed);
|
||||||
|
assert!(!small.contains("more not listed"));
|
||||||
|
assert!(small.contains("[3] window \"Window 3\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_the_three_screen_states_apart() {
|
||||||
|
let observed = |change| observation_text("observed", &observation(1), change);
|
||||||
|
assert!(observed(ScreenChange::Identical).starts_with("observed (screen unchanged)"));
|
||||||
|
assert!(observed(ScreenChange::Similar).starts_with("observed (no visible change)"));
|
||||||
|
assert!(observed(ScreenChange::Changed).starts_with("observed\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_incomplete_sweep_says_a_missing_control_is_not_proof() {
|
||||||
|
// `observation_from_png` starts out incomplete, and a window that
|
||||||
|
// never answered is not evidence that its buttons are gone.
|
||||||
|
let text = observation_text("observed", &observation(1), ScreenChange::Changed);
|
||||||
|
assert!(text.contains("Clickable windows (partial: some windows did not report controls"));
|
||||||
|
let mut complete = observation(1);
|
||||||
|
complete.native_observation_complete = true;
|
||||||
|
let complete = observation_text("observed", &complete, ScreenChange::Changed);
|
||||||
|
assert!(!complete.contains("partial"));
|
||||||
|
assert!(complete.starts_with("observed\nClickable windows:\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod verdict_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn verdict(decision: ActionDecision, effect: Option<&str>) -> ActionVerdict {
|
||||||
|
ActionVerdict {
|
||||||
|
decision,
|
||||||
|
effect: effect.map(str::to_string),
|
||||||
|
verified: None,
|
||||||
|
escalation: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unproven_effect_says_look_first_never_type_again() {
|
||||||
|
let note = verdict_note(&verdict(
|
||||||
|
ActionDecision::VerifyFreshState,
|
||||||
|
Some("unverifiable"),
|
||||||
|
));
|
||||||
|
assert!(note.contains("effect not confirmed"));
|
||||||
|
assert!(note.contains("never repeat input"));
|
||||||
|
assert!(note.contains("driver said: unverifiable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_confirmed_effect_forbids_repeating_the_action() {
|
||||||
|
let note = verdict_note(&verdict(ActionDecision::Done, Some("confirmed")));
|
||||||
|
assert!(note.starts_with("verdict: effect confirmed"));
|
||||||
|
assert!(note.contains("Do not repeat"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_verdict_is_the_first_line_and_invents_no_reason() {
|
||||||
|
let mut outcome = text_outcome("completed 1 computer action(s)");
|
||||||
|
with_verdict(
|
||||||
|
&mut outcome,
|
||||||
|
Some(verdict_note(&verdict(ActionDecision::Escalate, None))),
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
outcome
|
||||||
|
.text
|
||||||
|
.starts_with("verdict: the driver reports no effect")
|
||||||
|
);
|
||||||
|
assert!(outcome.text.ends_with("completed 1 computer action(s)"));
|
||||||
|
// The driver gave no `effect` field, so nothing may claim one.
|
||||||
|
assert!(!outcome.text.contains("driver said"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tool schema rides in front of every request, so its bytes are the
|
||||||
|
/// prompt cache key. Collapsing the tools into one `action`-tagged schema
|
||||||
|
/// (hermes-style) would strand the `tool_calls` stored in existing
|
||||||
|
/// checkpoints; keeping this serialization byte-stable is the half of the
|
||||||
|
/// cache win that costs nothing.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tool_schema_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn schema(memory_enabled: bool) -> String {
|
||||||
|
serde_json::to_string(&tool_definitions(memory_enabled)).expect("tool schema is json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tool_schema_is_byte_stable_across_calls() {
|
||||||
|
for memory_enabled in [false, true] {
|
||||||
|
assert_eq!(schema(memory_enabled), schema(memory_enabled));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_names_are_unique_and_open_with_the_computer_pair() {
|
||||||
|
let tools = tool_definitions(true);
|
||||||
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect();
|
||||||
|
let unique: std::collections::HashSet<&str> = names.iter().copied().collect();
|
||||||
|
assert_eq!(unique.len(), names.len());
|
||||||
|
assert_eq!(&names[..2], ["computer_observe", "computer_act"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_tools_are_appended_so_the_desktop_schema_never_moves() {
|
||||||
|
let desktop = |memory_enabled: bool| -> Vec<String> {
|
||||||
|
tool_definitions(memory_enabled)
|
||||||
|
.iter()
|
||||||
|
.filter(|tool| {
|
||||||
|
!matches!(
|
||||||
|
tool.name.as_str(),
|
||||||
|
"remember" | "recall_memory" | "forget_memory"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(|tool| serde_json::to_string(tool).expect("tool schema is json"))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
assert_eq!(desktop(false), desktop(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,9 @@ impl UiElement {
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ComputerObservation {
|
pub struct ComputerObservation {
|
||||||
/// The controller already populated native semantics; skip duplicate enrichment.
|
/// The controller already populated native semantics; skip duplicate
|
||||||
|
/// enrichment. False also means the element list is only partial, so "I
|
||||||
|
/// cannot see that control" is not yet evidence that it is not there.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub native_observation_complete: bool,
|
pub native_observation_complete: bool,
|
||||||
pub frame_id: String,
|
pub frame_id: String,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,22 @@ pub enum ActionError {
|
||||||
NotObject,
|
NotObject,
|
||||||
#[error("unsupported computer action {0}")]
|
#[error("unsupported computer action {0}")]
|
||||||
Unsupported(String),
|
Unsupported(String),
|
||||||
|
#[error(
|
||||||
|
"computer action needs a \"kind\": one of click, move, down, up, hover, drag, type, key, scroll, focus, wait"
|
||||||
|
)]
|
||||||
|
MissingKind,
|
||||||
|
#[error("unsupported computer action \"{0}\". Did you mean \"{1}\"?")]
|
||||||
|
DidYouMean(String, &'static str),
|
||||||
|
#[error("computer_act has no action \"{0}\". Use the {1} tool instead.")]
|
||||||
|
WrongTool(String, &'static str),
|
||||||
|
#[error(
|
||||||
|
"blocked key combo {0}. It ends the desktop session the Agent is driving; ask the user with request_takeover instead."
|
||||||
|
)]
|
||||||
|
BlockedKeyCombo(String),
|
||||||
|
#[error(
|
||||||
|
"blocked text ({0}). Nothing was typed. If the task really needs this, call request_takeover and let the user run it."
|
||||||
|
)]
|
||||||
|
BlockedText(&'static str),
|
||||||
#[error("computer action {0} must be a non-negative coordinate")]
|
#[error("computer action {0} must be a non-negative coordinate")]
|
||||||
BadCoordinate(&'static str),
|
BadCoordinate(&'static str),
|
||||||
#[error("computer action element {0} is not on the current screen")]
|
#[error("computer action element {0} is not on the current screen")]
|
||||||
|
|
@ -124,6 +140,171 @@ pub fn should_block_stale_click(miss_streak: u32, last: Option<&str>, next: Opti
|
||||||
miss_streak >= 2 && next.is_some() && next == last
|
miss_streak >= 2 && next.is_some() && next == last
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Action names the model writes instead of the ones in the schema. Aliasing
|
||||||
|
/// is a fixed table on purpose: an unknown action is never repaired by guess,
|
||||||
|
/// only answered with its closest neighbour.
|
||||||
|
const KIND_ALIASES: &[(&str, &str)] = &[
|
||||||
|
("left_click", "click"),
|
||||||
|
("right_click", "click"),
|
||||||
|
("middle_click", "click"),
|
||||||
|
("mouse_click", "click"),
|
||||||
|
("double_click", "click"),
|
||||||
|
("tap", "click"),
|
||||||
|
("mouse_move", "move"),
|
||||||
|
("move_mouse", "move"),
|
||||||
|
("cursor_move", "move"),
|
||||||
|
("mouse_down", "down"),
|
||||||
|
("button_down", "down"),
|
||||||
|
("mouse_up", "up"),
|
||||||
|
("button_up", "up"),
|
||||||
|
("release", "up"),
|
||||||
|
("type_text", "type"),
|
||||||
|
("input_text", "type"),
|
||||||
|
("insert_text", "type"),
|
||||||
|
("write_text", "type"),
|
||||||
|
("press_key", "key"),
|
||||||
|
("keypress", "key"),
|
||||||
|
("hotkey", "key"),
|
||||||
|
("shortcut", "key"),
|
||||||
|
("keyboard", "key"),
|
||||||
|
("scroll_up", "scroll"),
|
||||||
|
("scroll_down", "scroll"),
|
||||||
|
("wheel", "scroll"),
|
||||||
|
("raise_window", "focus"),
|
||||||
|
("bring_to_front", "focus"),
|
||||||
|
("activate", "focus"),
|
||||||
|
("sleep", "wait"),
|
||||||
|
("delay", "wait"),
|
||||||
|
("pause", "wait"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Screenshots do not come from this tool, and suggesting "wait" for
|
||||||
|
/// `screenshot` would waste a turn.
|
||||||
|
const OTHER_TOOL_HINTS: &[(&str, &str)] = &[
|
||||||
|
("screenshot", "computer_observe"),
|
||||||
|
("capture", "computer_observe"),
|
||||||
|
("observe", "computer_observe"),
|
||||||
|
("snapshot", "browser"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fn nearest_action_kind(spelled: &str) -> Option<&'static str> {
|
||||||
|
if let Some((_, kind)) = KIND_ALIASES.iter().find(|(from, _)| *from == spelled) {
|
||||||
|
return Some(*kind);
|
||||||
|
}
|
||||||
|
[
|
||||||
|
"click", "move", "down", "up", "hover", "drag", "type", "key", "scroll", "focus", "wait",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|kind| edit_distance(spelled, kind) <= 2)
|
||||||
|
.min_by_key(|kind| edit_distance(spelled, kind))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same neighbour search, for the tools that do live elsewhere. Checked first:
|
||||||
|
/// a model asking for `screenshot` wants a different tool, not the closest
|
||||||
|
/// action of this one.
|
||||||
|
fn nearest_hint(spelled: &str) -> Option<&'static str> {
|
||||||
|
OTHER_TOOL_HINTS
|
||||||
|
.iter()
|
||||||
|
.find(|(word, _)| edit_distance(spelled, word) <= 2)
|
||||||
|
.map(|(_, tool)| *tool)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded edit distance, for suggestions only. These are action names, so the
|
||||||
|
/// quadratic form over characters is the cheapest thing that behaves.
|
||||||
|
fn edit_distance(source: &str, target: &str) -> usize {
|
||||||
|
let target: Vec<char> = target.chars().collect();
|
||||||
|
let mut previous: Vec<usize> = (0..=target.len()).collect();
|
||||||
|
let mut current = vec![0usize; target.len() + 1];
|
||||||
|
for (row, from) in source.chars().enumerate() {
|
||||||
|
current[0] = row + 1;
|
||||||
|
for (column, to) in target.iter().enumerate() {
|
||||||
|
let substitution = previous[column] + usize::from(*to != from);
|
||||||
|
current[column + 1] = substitution
|
||||||
|
.min(previous[column + 1] + 1)
|
||||||
|
.min(current[column] + 1);
|
||||||
|
}
|
||||||
|
[previous, current] = [current, previous];
|
||||||
|
}
|
||||||
|
previous[target.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key combos that end the very desktop session the Agent is driving, or that
|
||||||
|
/// destroy input nobody can recover without a human. Narrow on purpose: every
|
||||||
|
/// entry has to be a shortcut no real task needs.
|
||||||
|
const BLOCKED_KEY_COMBOS: &[&[&str]] = &[
|
||||||
|
&["ctrl", "alt", "delete"],
|
||||||
|
&["ctrl", "alt", "backspace"],
|
||||||
|
&["super", "l"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The driver accepts `ctrl-alt-delete` as readily as `ctrl+alt+delete`, so
|
||||||
|
/// comparison happens only after splitting and folding the modifier names.
|
||||||
|
fn canonical_keys(spelling: &str) -> Vec<String> {
|
||||||
|
spelling
|
||||||
|
.to_lowercase()
|
||||||
|
.split(['+', '-', '_', ' ', '\t'])
|
||||||
|
.filter(|part| !part.is_empty())
|
||||||
|
.map(|part| {
|
||||||
|
match part {
|
||||||
|
"control" => "ctrl",
|
||||||
|
"option" | "altgr" => "alt",
|
||||||
|
"super" | "meta" | "win" | "windows" | "cmd" | "command" | "hyper" => "super",
|
||||||
|
"del" => "delete",
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocked_key_combo(key: &str, modifiers: Option<&[String]>) -> Option<&'static [&'static str]> {
|
||||||
|
let mut pressed = canonical_keys(key);
|
||||||
|
for modifier in modifiers.unwrap_or_default() {
|
||||||
|
pressed.extend(canonical_keys(modifier));
|
||||||
|
}
|
||||||
|
BLOCKED_KEY_COMBOS.iter().copied().find(|combo| {
|
||||||
|
combo
|
||||||
|
.iter()
|
||||||
|
.all(|part| pressed.iter().any(|held| held.as_str() == *part))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text that means "pipe the network into a shell", "delete the filesystem" or
|
||||||
|
/// "fork until the machine dies". Whitespace is removed first because the
|
||||||
|
/// point is the shape, not the spacing. Narrow on purpose: a false positive
|
||||||
|
/// stops a real task, and this is a guard against an accidental typing, not a
|
||||||
|
/// security boundary — `shell` still runs commands.
|
||||||
|
fn blocked_text(text: &str) -> Option<&'static str> {
|
||||||
|
let compact: String = text
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.filter(|character| !character.is_whitespace())
|
||||||
|
.collect();
|
||||||
|
if compact.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let downloads = compact.contains("curl") || compact.contains("wget");
|
||||||
|
let into_shell = ["|bash", "|sh", "|zsh", "|fish", "|python", "|perl"]
|
||||||
|
.iter()
|
||||||
|
.any(|tail| compact.contains(tail));
|
||||||
|
if downloads && into_shell {
|
||||||
|
return Some("piping a download into a shell");
|
||||||
|
}
|
||||||
|
if ["rm-rf/", "rm-fr/", "rm-rf~", "rm-fr~", "rm-rf*", "rm-fr*"]
|
||||||
|
.iter()
|
||||||
|
.any(|shape| compact.contains(shape))
|
||||||
|
{
|
||||||
|
return Some("a recursive delete aimed at the filesystem root");
|
||||||
|
}
|
||||||
|
if compact.contains(":(){:") {
|
||||||
|
return Some("a fork bomb");
|
||||||
|
}
|
||||||
|
if compact.contains("of=/dev/") {
|
||||||
|
return Some("a raw write to a block device");
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> {
|
pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, ActionError> {
|
||||||
let Value::Array(items) = value else {
|
let Value::Array(items) = value else {
|
||||||
return Err(ActionError::Empty);
|
return Err(ActionError::Empty);
|
||||||
|
|
@ -250,6 +431,9 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_string();
|
.to_string();
|
||||||
|
if let Some(reason) = blocked_text(&text) {
|
||||||
|
return Err(ActionError::BlockedText(reason));
|
||||||
|
}
|
||||||
if let Some(target) = ref_target(action) {
|
if let Some(target) = ref_target(action) {
|
||||||
actions.push(ComputerAction::Ref {
|
actions.push(ComputerAction::Ref {
|
||||||
verb: RefVerb::SetValue,
|
verb: RefVerb::SetValue,
|
||||||
|
|
@ -277,6 +461,9 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect()
|
.collect()
|
||||||
});
|
});
|
||||||
|
if let Some(combo) = blocked_key_combo(&key, modifiers.as_deref()) {
|
||||||
|
return Err(ActionError::BlockedKeyCombo(combo.join("+")));
|
||||||
|
}
|
||||||
actions.push(ComputerAction::Key { key, modifiers });
|
actions.push(ComputerAction::Key { key, modifiers });
|
||||||
}
|
}
|
||||||
"scroll" => {
|
"scroll" => {
|
||||||
|
|
@ -315,11 +502,17 @@ pub fn parse_computer_actions(value: &Value) -> Result<Vec<ComputerAction>, Acti
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
return Err(ActionError::Unsupported(if other.is_empty() {
|
if other.is_empty() {
|
||||||
"(missing)".to_string()
|
return Err(ActionError::MissingKind);
|
||||||
} else {
|
}
|
||||||
other.to_string()
|
let spelled = other.to_lowercase();
|
||||||
}));
|
if let Some(tool) = nearest_hint(&spelled) {
|
||||||
|
return Err(ActionError::WrongTool(other.to_string(), tool));
|
||||||
|
}
|
||||||
|
return Err(match nearest_action_kind(&spelled) {
|
||||||
|
Some(kind) => ActionError::DidYouMean(other.to_string(), kind),
|
||||||
|
None => ActionError::Unsupported(other.to_string()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -579,4 +772,97 @@ mod tests {
|
||||||
Some("p10,20")
|
Some("p10,20")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_killing_shortcuts_are_blocked_however_they_are_spelled() {
|
||||||
|
let blocked = Err(ActionError::BlockedKeyCombo("ctrl+alt+delete".into()));
|
||||||
|
for spelling in [
|
||||||
|
json!({"kind": "key", "key": "ctrl+alt+delete"}),
|
||||||
|
json!({"kind": "key", "key": "ctrl-alt-delete"}),
|
||||||
|
json!({"kind": "key", "key": "Delete", "modifiers": ["Control", "Alt"]}),
|
||||||
|
json!({"kind": "key", "key": "CONTROL-ALT-DEL"}),
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_computer_actions(&json!([spelling])),
|
||||||
|
blocked,
|
||||||
|
"{spelling} must never reach the desktop"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(matches!(
|
||||||
|
parse_computer_actions(&json!([{"kind": "key", "key": "l", "modifiers": ["Super"]}])),
|
||||||
|
Err(ActionError::BlockedKeyCombo(_))
|
||||||
|
));
|
||||||
|
// The shortcuts a real task needs stay available.
|
||||||
|
for spelling in [
|
||||||
|
json!({"kind": "key", "key": "c", "modifiers": ["ctrl"]}),
|
||||||
|
json!({"kind": "key", "key": "t", "modifiers": ["ctrl", "alt"]}),
|
||||||
|
json!({"kind": "key", "key": "page-down"}),
|
||||||
|
json!({"kind": "key", "key": "l", "modifiers": ["ctrl"]}),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
parse_computer_actions(&json!([spelling])).is_ok(),
|
||||||
|
"{spelling} is an ordinary shortcut"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn destructive_typed_text_is_blocked_and_the_work_is_not() {
|
||||||
|
for text in [
|
||||||
|
"curl https://get.example.com/install.sh | bash",
|
||||||
|
"wget -qO- https://example.com/x | sh",
|
||||||
|
"sudo rm -rf /",
|
||||||
|
"rm -rf ~",
|
||||||
|
":(){ :|:& };:",
|
||||||
|
"dd if=/dev/zero of=/dev/sda",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
parse_computer_actions(&json!([{"kind": "type", "text": text}])),
|
||||||
|
Err(ActionError::BlockedText(_))
|
||||||
|
),
|
||||||
|
"{text} must be blocked"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for text in [
|
||||||
|
"rm -rf ./build",
|
||||||
|
"curl -O https://example.com/report.pdf",
|
||||||
|
"echo 'the manual warns about rm -rf as an example'",
|
||||||
|
"https://example.com/install.sh",
|
||||||
|
"npm install",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
parse_computer_actions(&json!([{"kind": "type", "text": text}])).is_ok(),
|
||||||
|
"{text} is ordinary typing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unknown_action_name_points_at_the_right_thing() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_computer_actions(&json!([{"kind": "left_click", "x": 1, "y": 1}])),
|
||||||
|
Err(ActionError::DidYouMean("left_click".into(), "click"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_computer_actions(&json!([{"kind": "hoverr"}])),
|
||||||
|
Err(ActionError::DidYouMean("hoverr".into(), "hover"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_computer_actions(&json!([{"kind": "press_key", "key": "return"}])),
|
||||||
|
Err(ActionError::DidYouMean("press_key".into(), "key"))
|
||||||
|
);
|
||||||
|
// A picture does not come from this tool at all.
|
||||||
|
assert_eq!(
|
||||||
|
parse_computer_actions(&json!([{"kind": "screnshot"}])),
|
||||||
|
Err(ActionError::WrongTool(
|
||||||
|
"screnshot".into(),
|
||||||
|
"computer_observe"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_computer_actions(&json!([{}])),
|
||||||
|
Err(ActionError::MissingKind)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,11 @@ pub enum ControlError {
|
||||||
StaleReference,
|
StaleReference,
|
||||||
#[error("permission denied")]
|
#[error("permission denied")]
|
||||||
PermissionDenied,
|
PermissionDenied,
|
||||||
#[error("computer action timed out")]
|
/// Fail closed, in the same words the tool-layer timeout uses
|
||||||
|
/// (`runs.rs`): the caller must not read a timeout as "nothing happened".
|
||||||
|
#[error(
|
||||||
|
"computer action timed out. Its effects are unknown: it may already have been applied. Observe the current screen before anything else, and never repeat a step that already worked."
|
||||||
|
)]
|
||||||
Timeout,
|
Timeout,
|
||||||
#[error("unsupported computer action")]
|
#[error("unsupported computer action")]
|
||||||
Unsupported,
|
Unsupported,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -10,6 +10,7 @@ use tokio::process::Command;
|
||||||
|
|
||||||
use crate::controller::ControlError;
|
use crate::controller::ControlError;
|
||||||
use crate::screen::normalize_display;
|
use crate::screen::normalize_display;
|
||||||
|
use crate::{ActionDecision, ActionVerdict};
|
||||||
|
|
||||||
pub const PRIMARY_SOCKET: &str = "/tmp/lazyboy/cua.sock";
|
pub const PRIMARY_SOCKET: &str = "/tmp/lazyboy/cua.sock";
|
||||||
|
|
||||||
|
|
@ -17,6 +18,9 @@ pub const PRIMARY_SOCKET: &str = "/tmp/lazyboy/cua.sock";
|
||||||
pub struct CuaClient {
|
pub struct CuaClient {
|
||||||
bin: PathBuf,
|
bin: PathBuf,
|
||||||
motion_sessions: Arc<tokio::sync::Mutex<HashMap<(PathBuf, String), SystemTime>>>,
|
motion_sessions: Arc<tokio::sync::Mutex<HashMap<(PathBuf, String), SystemTime>>>,
|
||||||
|
/// Displays whose driver session saw a timed-out mutation. The socket state
|
||||||
|
/// of such a session is unknown, so the next mutation gets a fresh one.
|
||||||
|
suspect: Arc<tokio::sync::Mutex<HashSet<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CuaClient {
|
impl Default for CuaClient {
|
||||||
|
|
@ -24,6 +28,7 @@ impl Default for CuaClient {
|
||||||
Self {
|
Self {
|
||||||
bin: PathBuf::from("cua-driver"),
|
bin: PathBuf::from("cua-driver"),
|
||||||
motion_sessions: Arc::default(),
|
motion_sessions: Arc::default(),
|
||||||
|
suspect: Arc::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +99,45 @@ impl CuaClient {
|
||||||
Ok(text)
|
Ok(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `cua-driver manifest`: the driver's own description of its CLI surface.
|
||||||
|
/// `None` when the binary predates the verb or answers with anything but a
|
||||||
|
/// JSON object, so an unexpected driver stays as opaque as it was.
|
||||||
|
pub async fn manifest(&self) -> Option<Value> {
|
||||||
|
let mut command = Command::new(&self.bin);
|
||||||
|
command.arg("manifest");
|
||||||
|
let output = bounded_output(&mut command, Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// A telemetry banner can precede the payload, so take the line that
|
||||||
|
// actually parses instead of trusting stdout to be one object.
|
||||||
|
String::from_utf8_lossy(&output.stdout)
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| serde_json::from_str(line.trim()).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `cua-driver list-tools`: every tool this driver build can dispatch.
|
||||||
|
/// Read-only and daemon-free, which makes it safe on the health path.
|
||||||
|
pub async fn tool_names(&self) -> Option<Vec<String>> {
|
||||||
|
let mut command = Command::new(&self.bin);
|
||||||
|
command.arg("list-tools");
|
||||||
|
let output = bounded_output(&mut command, Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
let names = parse_tool_names(&text);
|
||||||
|
(!names.is_empty()).then_some(names)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply Cua's supported motion settings once per named session/daemon.
|
// Apply Cua's supported motion settings once per named session/daemon.
|
||||||
// Short, tight glides avoid the driver's 750 ms default flight.
|
// Short, tight glides avoid the driver's 750 ms default flight.
|
||||||
async fn configure_cursor_motion(&self, screen: &str, body: &Value, force: bool) {
|
async fn configure_cursor_motion(&self, screen: &str, body: &Value, force: bool) {
|
||||||
|
|
@ -141,13 +185,31 @@ impl CuaClient {
|
||||||
extra: &[&str],
|
extra: &[&str],
|
||||||
) -> Result<Value, ControlError> {
|
) -> Result<Value, ControlError> {
|
||||||
let mut body = with_session_label(screen, payload);
|
let mut body = with_session_label(screen, payload);
|
||||||
|
if !read_after_session_restart(tool) {
|
||||||
|
self.repair_suspect_session(screen, &body).await?;
|
||||||
|
}
|
||||||
if needs_cursor_motion(tool) {
|
if needs_cursor_motion(tool) {
|
||||||
self.configure_cursor_motion(screen, &body, false).await;
|
self.configure_cursor_motion(screen, &body, false).await;
|
||||||
}
|
}
|
||||||
let mut escalated = false;
|
let mut escalated = false;
|
||||||
let mut revived = false;
|
let mut revived = false;
|
||||||
loop {
|
loop {
|
||||||
let outcome = self.attempt(screen, tool, &body, extra).await?;
|
let outcome = match self.attempt(screen, tool, &body, extra).await {
|
||||||
|
Ok(outcome) => outcome,
|
||||||
|
Err(ControlError::Timeout) => {
|
||||||
|
// A mutation that ran out of clock may still have landed, so
|
||||||
|
// it is never replayed: mark the session unusable and report
|
||||||
|
// the unknown outcome instead of trying again.
|
||||||
|
if tool != "start_session" {
|
||||||
|
self.suspect
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(normalize_display(screen).to_string());
|
||||||
|
}
|
||||||
|
return Err(ControlError::Timeout);
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
if outcome.session_ended && !revived && tool != "start_session" {
|
if outcome.session_ended && !revived && tool != "start_session" {
|
||||||
let session = json!({"session":body["session"]});
|
let session = json!({"session":body["session"]});
|
||||||
let started = self.attempt(screen, "start_session", &session, &[]).await?;
|
let started = self.attempt(screen, "start_session", &session, &[]).await?;
|
||||||
|
|
@ -177,6 +239,37 @@ impl CuaClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One timed-out call is enough to distrust a session: the request may
|
||||||
|
/// still be queued, half-written, or already applied. The next mutation
|
||||||
|
/// therefore opens a fresh named session instead of inheriting that state.
|
||||||
|
/// Reads skip the extra round trip — a stale read cannot do damage, and a
|
||||||
|
/// replayed mutation can.
|
||||||
|
async fn repair_suspect_session(&self, screen: &str, body: &Value) -> Result<(), ControlError> {
|
||||||
|
let key = normalize_display(screen).to_string();
|
||||||
|
if !self.suspect.lock().await.remove(&key) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let session = json!({"session": body.get("session")});
|
||||||
|
match self.attempt(screen, "start_session", &session, &[]).await {
|
||||||
|
Ok(outcome) => {
|
||||||
|
// A driver that answers, even to refuse, proved the transport is
|
||||||
|
// alive; the mutation's own error is the better signal then.
|
||||||
|
if outcome.error.is_none() {
|
||||||
|
// A new session does not inherit the cursor glide tuning.
|
||||||
|
self.configure_cursor_motion(screen, body, true).await;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error @ ControlError::Timeout) => {
|
||||||
|
// Still unknown: keep the marker and do not push a mutation into
|
||||||
|
// the same state that just timed out.
|
||||||
|
self.suspect.lock().await.insert(key);
|
||||||
|
Err(error)
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn attempt(
|
async fn attempt(
|
||||||
&self,
|
&self,
|
||||||
screen: &str,
|
screen: &str,
|
||||||
|
|
@ -242,6 +335,34 @@ fn public_agent_name(name: &str) -> String {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `list-tools` prints `tool_name: one-line description`. The CLI also prints
|
||||||
|
/// banners and usage text whose leading token is lowercase-shaped, so these
|
||||||
|
/// words are dropped before they can pass for a capability.
|
||||||
|
const TOOL_LIST_NOISE: &[&str] = &[
|
||||||
|
"usage",
|
||||||
|
"error",
|
||||||
|
"warning",
|
||||||
|
"warn",
|
||||||
|
"note",
|
||||||
|
"help",
|
||||||
|
"subcommands",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn parse_tool_names(text: &str) -> Vec<String> {
|
||||||
|
text.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (name, _) = line.split_once(':')?;
|
||||||
|
let name = name.trim();
|
||||||
|
let shaped = name.len() > 2
|
||||||
|
&& !TOOL_LIST_NOISE.contains(&name)
|
||||||
|
&& name
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_');
|
||||||
|
shaped.then(|| name.to_string())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn needs_cursor_motion(tool: &str) -> bool {
|
fn needs_cursor_motion(tool: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
tool,
|
tool,
|
||||||
|
|
@ -314,6 +435,43 @@ fn recommended_delivery(value: &Value) -> Option<String> {
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What the driver says about the effect of its own reply. Every field is
|
||||||
|
/// additive in the driver contract, so an older build yields `None`: a reply
|
||||||
|
/// with no semantic evidence is not proof that anything happened, and claiming
|
||||||
|
/// `done` would be the difference between one look and one more blind click.
|
||||||
|
pub fn action_verdict(value: &Value) -> Option<ActionVerdict> {
|
||||||
|
let effect = ["effect", "status"]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| value.get(key).and_then(Value::as_str))
|
||||||
|
// `status: ok` is the driver's "no error", the same reading
|
||||||
|
// `response_error` takes, so it carries no evidence either.
|
||||||
|
.find(|text| !text.is_empty() && *text != "ok")
|
||||||
|
.map(str::to_string);
|
||||||
|
let verified = value.get("verified").and_then(Value::as_bool);
|
||||||
|
let degraded = value.get("degraded").and_then(Value::as_bool) == Some(true);
|
||||||
|
let escalation = recommended_delivery(value);
|
||||||
|
let code = ["code", "reason_code"]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|key| value.get(key).and_then(Value::as_str))
|
||||||
|
.find(|code| !code.is_empty() && *code != "ok");
|
||||||
|
|
||||||
|
let decision = if effect.as_deref() == Some("confirmed") || verified == Some(true) {
|
||||||
|
ActionDecision::Done
|
||||||
|
} else if effect.as_deref() == Some("suspected_noop") || code.is_some() {
|
||||||
|
ActionDecision::Escalate
|
||||||
|
} else if effect.is_some() || verified == Some(false) || degraded || escalation.is_some() {
|
||||||
|
ActionDecision::VerifyFreshState
|
||||||
|
} else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(ActionVerdict {
|
||||||
|
decision,
|
||||||
|
effect,
|
||||||
|
verified,
|
||||||
|
escalation,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn with_session_label(display: &str, payload: &Value) -> Value {
|
fn with_session_label(display: &str, payload: &Value) -> Value {
|
||||||
let mut body = payload.clone();
|
let mut body = payload.clone();
|
||||||
if let Some(map) = body.as_object_mut() {
|
if let Some(map) = body.as_object_mut() {
|
||||||
|
|
@ -707,4 +865,73 @@ mod tests {
|
||||||
let parsed = parse_jsonish("✅ ok\n{\"status\":\"ok\",\"x\":1}\n").unwrap();
|
let parsed = parse_jsonish("✅ ok\n{\"status\":\"ok\",\"x\":1}\n").unwrap();
|
||||||
assert_eq!(parsed["status"], "ok");
|
assert_eq!(parsed["status"], "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_confirmed_effect_outranks_an_advisory_escalation() {
|
||||||
|
let verdict = action_verdict(&json!({
|
||||||
|
"effect": "confirmed",
|
||||||
|
"verified": true,
|
||||||
|
"escalation": {"recommended": "foreground"}
|
||||||
|
}))
|
||||||
|
.expect("verdict");
|
||||||
|
assert_eq!(verdict.decision, ActionDecision::Done);
|
||||||
|
assert_eq!(verdict.escalation.as_deref(), Some("foreground"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unverifiable_effect_becomes_verify_fresh_state() {
|
||||||
|
assert_eq!(
|
||||||
|
action_verdict(&json!({"effect": "unverifiable"}))
|
||||||
|
.expect("verdict")
|
||||||
|
.decision,
|
||||||
|
ActionDecision::VerifyFreshState
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
action_verdict(&json!({"verified": false}))
|
||||||
|
.expect("verdict")
|
||||||
|
.decision,
|
||||||
|
ActionDecision::VerifyFreshState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suspected_noop_and_refusal_codes_escalate() {
|
||||||
|
assert_eq!(
|
||||||
|
action_verdict(&json!({"effect": "suspected_noop"}))
|
||||||
|
.expect("verdict")
|
||||||
|
.decision,
|
||||||
|
ActionDecision::Escalate
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
action_verdict(&json!({"reason_code": "target_gone"}))
|
||||||
|
.expect("verdict")
|
||||||
|
.decision,
|
||||||
|
ActionDecision::Escalate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_reply_without_semantic_evidence_claims_nothing() {
|
||||||
|
// `status: ok` only means the driver did not error, so it proves no
|
||||||
|
// effect and must not be reported as one.
|
||||||
|
assert!(action_verdict(&json!({"status": "ok", "windows": []})).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
action_verdict(&json!({"degraded": true}))
|
||||||
|
.expect("verdict")
|
||||||
|
.decision,
|
||||||
|
ActionDecision::VerifyFreshState
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_tool_list_keeps_names_and_drops_banner_noise() {
|
||||||
|
let text = "Cua Driver sends content-free product telemetry by default.\n\
|
||||||
|
usage: cua-driver [SUBCOMMAND]\n\
|
||||||
|
click: Click against a target pid\n\
|
||||||
|
start_session: Open a named session\n";
|
||||||
|
assert_eq!(
|
||||||
|
parse_tool_names(text),
|
||||||
|
vec!["click".to_string(), "start_session".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,11 @@ use crate::controller::{
|
||||||
ComputerController, ComputerDriver, ControlContext, ControlError, ControllerHealth,
|
ComputerController, ComputerDriver, ControlContext, ControlError, ControllerHealth,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
ActionRequest, ActionResult, BrowserPage, BrowserRequest, RecordingRequest, RecordingResult,
|
ActionRequest, ActionResult, ActionVerdict, BrowserPage, BrowserRequest, RecordingRequest,
|
||||||
RecordingSession, action_pause_ms, image_dimensions, normalize_display, observation_from_png,
|
RecordingResult, RecordingSession, action_pause_ms, image_dimensions, normalize_display,
|
||||||
observation_with_elements, teach_trajectory_dir,
|
observation_from_png, observation_with_elements, teach_trajectory_dir,
|
||||||
};
|
};
|
||||||
use client::first_array_of_objects;
|
use client::{action_verdict, first_array_of_objects};
|
||||||
|
|
||||||
pub use client::CuaClient;
|
pub use client::CuaClient;
|
||||||
|
|
||||||
|
|
@ -47,6 +47,114 @@ fn driver_release(version: &str) -> Option<(u32, u32)> {
|
||||||
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
|
Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// CLI surface LazyBoy spawns. `call` writes screenshots to a file because a
|
||||||
|
/// base64 frame in a pipe cannot survive a busy desktop.
|
||||||
|
const REQUIRED_CLI_ARGS: &[(&str, &[&str])] = &[
|
||||||
|
("call", &["--socket", "--screenshot-out-file"]),
|
||||||
|
("status", &["--socket"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every driver tool the harness dispatches. Naming them turns a pin failure
|
||||||
|
/// into "this driver cannot do X" instead of a bare "unhealthy".
|
||||||
|
const REQUIRED_TOOLS: &[&str] = &[
|
||||||
|
"bring_to_front",
|
||||||
|
"browser_click",
|
||||||
|
"browser_navigate",
|
||||||
|
"browser_prepare",
|
||||||
|
"browser_type",
|
||||||
|
"click",
|
||||||
|
"drag",
|
||||||
|
"get_browser_state",
|
||||||
|
"get_cursor_position",
|
||||||
|
"get_desktop_state",
|
||||||
|
"get_screen_size",
|
||||||
|
"get_window_state",
|
||||||
|
"health_report",
|
||||||
|
"hotkey",
|
||||||
|
"launch_app",
|
||||||
|
"list_windows",
|
||||||
|
"move_cursor",
|
||||||
|
"press_key",
|
||||||
|
"scroll",
|
||||||
|
"set_agent_cursor_motion",
|
||||||
|
"set_value",
|
||||||
|
"start_recording",
|
||||||
|
"start_session",
|
||||||
|
"stop_recording",
|
||||||
|
"type_text",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// `subcommands: [{ "name", "args": [{ "name" }] }]` from `cua-driver manifest`.
|
||||||
|
fn advertised_flags(manifest: &Value) -> HashMap<&str, Vec<&str>> {
|
||||||
|
manifest
|
||||||
|
.get("subcommands")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|verb| Some((verb["name"].as_str()?, verb)))
|
||||||
|
.map(|(name, verb)| {
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
verb.get("args")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|arg| arg["name"].as_str())
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Required `verb --flag` pairs this driver does not advertise.
|
||||||
|
fn cli_contract_gap(manifest: &Value) -> Vec<String> {
|
||||||
|
let advertised = advertised_flags(manifest);
|
||||||
|
let mut gap = Vec::new();
|
||||||
|
for (verb, flags) in REQUIRED_CLI_ARGS {
|
||||||
|
let offered: &[&str] = advertised.get(verb).map(Vec::as_slice).unwrap_or_default();
|
||||||
|
gap.extend(
|
||||||
|
flags
|
||||||
|
.iter()
|
||||||
|
.filter(|flag| !offered.contains(flag))
|
||||||
|
.map(|flag| format!("{verb} {flag}")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
gap
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Required tools the driver cannot dispatch.
|
||||||
|
fn missing_tools(advertised: &[String]) -> Vec<&'static str> {
|
||||||
|
REQUIRED_TOOLS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|tool| !advertised.iter().any(|named| named == tool))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a driver that failed the pin is actually missing. Both probes are
|
||||||
|
/// read-only and degrade to silence on drivers that predate them.
|
||||||
|
async fn capability_gap(client: &CuaClient) -> Vec<String> {
|
||||||
|
let mut gap = Vec::new();
|
||||||
|
if let Some(manifest) = client.manifest().await {
|
||||||
|
let missing = cli_contract_gap(&manifest);
|
||||||
|
if !missing.is_empty() {
|
||||||
|
gap.push(format!("driver CLI is missing: {}", missing.join(", ")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(tools) = client.tool_names().await {
|
||||||
|
let missing = missing_tools(&tools);
|
||||||
|
gap.push(if missing.is_empty() {
|
||||||
|
format!(
|
||||||
|
"all {} driver tools LazyBoy needs are present, so only the version series differs",
|
||||||
|
REQUIRED_TOOLS.len()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!("driver tools are missing: {}", missing.join(", "))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
gap
|
||||||
|
}
|
||||||
|
|
||||||
pub use translate::{TranslatedAction, translate_action};
|
pub use translate::{TranslatedAction, translate_action};
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
|
|
@ -92,6 +200,9 @@ impl ComputerController for CuaController {
|
||||||
PINNED_DRIVER.0,
|
PINNED_DRIVER.0,
|
||||||
PINNED_DRIVER.1
|
PINNED_DRIVER.1
|
||||||
));
|
));
|
||||||
|
// Only the failing path pays for the probes; a healthy
|
||||||
|
// desktop is never asked about its own surface again.
|
||||||
|
details.extend(capability_gap(&self.client).await);
|
||||||
}
|
}
|
||||||
Ok(ControllerHealth {
|
Ok(ControllerHealth {
|
||||||
backend: "cua".into(),
|
backend: "cua".into(),
|
||||||
|
|
@ -130,6 +241,7 @@ impl ComputerController for CuaController {
|
||||||
completed: 1,
|
completed: 1,
|
||||||
clipboard_text: Some(text),
|
clipboard_text: Some(text),
|
||||||
observation: None,
|
observation: None,
|
||||||
|
verdict: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let display = ctx.display.as_str();
|
let display = ctx.display.as_str();
|
||||||
|
|
@ -143,7 +255,7 @@ impl ComputerController for CuaController {
|
||||||
.run_actions(request, display, profile, &mut targets)
|
.run_actions(request, display, profile, &mut targets)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(completed) => {
|
Ok((completed, verdict)) => {
|
||||||
let observation = if request.observe {
|
let observation = if request.observe {
|
||||||
Some(self.observe_display(display).await?)
|
Some(self.observe_display(display).await?)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -154,6 +266,7 @@ impl ComputerController for CuaController {
|
||||||
clipboard_text: None,
|
clipboard_text: None,
|
||||||
completed,
|
completed,
|
||||||
observation,
|
observation,
|
||||||
|
verdict,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
|
|
@ -305,11 +418,15 @@ impl CuaController {
|
||||||
display: &str,
|
display: &str,
|
||||||
profile: Option<&str>,
|
profile: Option<&str>,
|
||||||
targets: &mut HashMap<String, native::NativeTarget>,
|
targets: &mut HashMap<String, native::NativeTarget>,
|
||||||
) -> Result<usize, ControlError> {
|
) -> Result<(usize, Option<ActionVerdict>), ControlError> {
|
||||||
let mut completed = 0usize;
|
let mut completed = 0usize;
|
||||||
|
// One batch, one answer: keep the most urgent verdict the driver gave.
|
||||||
|
let mut verdict = None;
|
||||||
while completed < request.actions.len() {
|
while completed < request.actions.len() {
|
||||||
let action = &request.actions[completed];
|
let action = &request.actions[completed];
|
||||||
if let Some(payload) = drag_payload(&request.actions[completed..]) {
|
if let Some(payload) = drag_payload(&request.actions[completed..]) {
|
||||||
|
merge_verdict(
|
||||||
|
&mut verdict,
|
||||||
self.dispatch(
|
self.dispatch(
|
||||||
display,
|
display,
|
||||||
TranslatedAction::Cua {
|
TranslatedAction::Cua {
|
||||||
|
|
@ -317,7 +434,8 @@ impl CuaController {
|
||||||
payload,
|
payload,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?,
|
||||||
|
);
|
||||||
completed += 5;
|
completed += 5;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -335,10 +453,13 @@ impl CuaController {
|
||||||
.get(target)
|
.get(target)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or(ControlError::StaleReference)?;
|
.ok_or(ControlError::StaleReference)?;
|
||||||
native::act(&self.client, display, target, *verb, text.as_deref()).await?;
|
merge_verdict(
|
||||||
|
&mut verdict,
|
||||||
|
native::act(&self.client, display, target, *verb, text.as_deref()).await?,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
let translated = translate_action(action, display, profile)?;
|
let translated = translate_action(action, display, profile)?;
|
||||||
self.dispatch(display, translated).await?;
|
merge_verdict(&mut verdict, self.dispatch(display, translated).await?);
|
||||||
}
|
}
|
||||||
let pause = action_pause_ms(action);
|
let pause = action_pause_ms(action);
|
||||||
if pause > 0 {
|
if pause > 0 {
|
||||||
|
|
@ -349,7 +470,7 @@ impl CuaController {
|
||||||
if request.settle_ms > 0 {
|
if request.settle_ms > 0 {
|
||||||
sleep(Duration::from_millis(u64::from(request.settle_ms))).await;
|
sleep(Duration::from_millis(u64::from(request.settle_ms))).await;
|
||||||
}
|
}
|
||||||
Ok(completed)
|
Ok((completed, verdict))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn lock_screen(&self, display: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
async fn lock_screen(&self, display: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
||||||
|
|
@ -386,8 +507,16 @@ impl CuaController {
|
||||||
Some(dimensions) => dimensions,
|
Some(dimensions) => dimensions,
|
||||||
None => self.screen_size(display).await,
|
None => self.screen_size(display).await,
|
||||||
};
|
};
|
||||||
let cursor = self.cursor(display).await;
|
// Cursor position and window list are two independent reads of the same
|
||||||
let windows = self.windows(display).await.unwrap_or_default();
|
// frame: run them as two CLI processes at once instead of end to end.
|
||||||
|
let (cursor, windows) = tokio::join!(self.cursor(display), self.windows(display));
|
||||||
|
// A window list that failed is not a desktop with no windows. Telling
|
||||||
|
// those apart is what keeps a driver hiccup from reaching the model as
|
||||||
|
// "the screen is empty".
|
||||||
|
let (windows, windows_listed) = match windows {
|
||||||
|
Ok(windows) => (windows, true),
|
||||||
|
Err(_) => (Vec::new(), false),
|
||||||
|
};
|
||||||
let active = windows
|
let active = windows
|
||||||
.iter()
|
.iter()
|
||||||
.max_by_key(|window| window.z)
|
.max_by_key(|window| window.z)
|
||||||
|
|
@ -432,7 +561,7 @@ impl CuaController {
|
||||||
observation_from_png(png, width, height, cursor, active),
|
observation_from_png(png, width, height, cursor, active),
|
||||||
elements,
|
elements,
|
||||||
);
|
);
|
||||||
observation.native_observation_complete = complete;
|
observation.native_observation_complete = complete && windows_listed;
|
||||||
Ok(observation)
|
Ok(observation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,20 +625,24 @@ impl CuaController {
|
||||||
&self,
|
&self,
|
||||||
display: &str,
|
display: &str,
|
||||||
translated: TranslatedAction,
|
translated: TranslatedAction,
|
||||||
) -> Result<(), ControlError> {
|
) -> Result<Option<ActionVerdict>, ControlError> {
|
||||||
match translated {
|
match translated {
|
||||||
TranslatedAction::Sleep { ms } => {
|
TranslatedAction::Sleep { ms } => {
|
||||||
sleep(Duration::from_millis(ms)).await;
|
sleep(Duration::from_millis(ms)).await;
|
||||||
Ok(())
|
Ok(None)
|
||||||
|
}
|
||||||
|
TranslatedAction::Launch { argv } => {
|
||||||
|
launch::run(&self.client, display, &argv).await?;
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
TranslatedAction::Launch { argv } => launch::run(&self.client, display, &argv).await,
|
|
||||||
TranslatedAction::FocusTitle { title } => self.focus_title(display, &title).await,
|
TranslatedAction::FocusTitle { title } => self.focus_title(display, &title).await,
|
||||||
TranslatedAction::Cua { tool, mut payload } => {
|
TranslatedAction::Cua { tool, mut payload } => {
|
||||||
if tool == "type_text"
|
if tool == "type_text"
|
||||||
&& let Some(text) = payload.get("text").and_then(Value::as_str)
|
&& let Some(text) = payload.get("text").and_then(Value::as_str)
|
||||||
&& (!text.is_ascii() || text.contains(['\n', '\r']))
|
&& (!text.is_ascii() || text.contains(['\n', '\r']))
|
||||||
{
|
{
|
||||||
return clipboard::paste(&self.client, display, text).await;
|
clipboard::paste(&self.client, display, text).await?;
|
||||||
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
if tool == "scroll" && payload.get("x").is_none() {
|
if tool == "scroll" && payload.get("x").is_none() {
|
||||||
|
|
@ -536,16 +669,21 @@ impl CuaController {
|
||||||
payload["window_id"] = json!(window.id);
|
payload["window_id"] = json!(window.id);
|
||||||
payload["delivery_mode"] = json!("foreground");
|
payload["delivery_mode"] = json!("foreground");
|
||||||
}
|
}
|
||||||
self.client.call(display, tool, &payload, &[]).await?;
|
let reply = self.client.call(display, tool, &payload, &[]).await?;
|
||||||
Ok(())
|
Ok(action_verdict(&reply))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn focus_title(&self, display: &str, title: &str) -> Result<(), ControlError> {
|
async fn focus_title(
|
||||||
|
&self,
|
||||||
|
display: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<Option<ActionVerdict>, ControlError> {
|
||||||
let windows = self.windows(display).await?;
|
let windows = self.windows(display).await?;
|
||||||
let window = window_matching_title(&windows, title).ok_or(ControlError::TargetNotFound)?;
|
let window = window_matching_title(&windows, title).ok_or(ControlError::TargetNotFound)?;
|
||||||
self.client
|
let reply = self
|
||||||
|
.client
|
||||||
.call(
|
.call(
|
||||||
display,
|
display,
|
||||||
"bring_to_front",
|
"bring_to_front",
|
||||||
|
|
@ -553,7 +691,20 @@ impl CuaController {
|
||||||
&[],
|
&[],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(action_verdict(&reply))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The more urgent verdict wins: one suspected no-op makes the whole batch
|
||||||
|
/// unproven, and the model has to hear about that one, not about the steps
|
||||||
|
/// that happened to report cleanly.
|
||||||
|
fn merge_verdict(current: &mut Option<ActionVerdict>, step: Option<ActionVerdict>) {
|
||||||
|
if let Some(step) = step
|
||||||
|
&& current
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|best| step.decision > best.decision)
|
||||||
|
{
|
||||||
|
*current = Some(step);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -802,6 +953,36 @@ mod tests {
|
||||||
assert_eq!(driver_release("cua-driver 0.23.9"), Some(PINNED_DRIVER));
|
assert_eq!(driver_release("cua-driver 0.23.9"), Some(PINNED_DRIVER));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_older_driver_is_named_by_the_verb_flag_it_lacks() {
|
||||||
|
let manifest = json!({"subcommands": [
|
||||||
|
{"name": "call", "args": [{"name": "tool"}, {"name": "--socket"}]},
|
||||||
|
{"name": "status", "args": [{"name": "--socket"}]},
|
||||||
|
]});
|
||||||
|
assert_eq!(cli_contract_gap(&manifest), ["call --screenshot-out-file"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_pinned_driver_surface_passes_the_cli_contract() {
|
||||||
|
let manifest = json!({"subcommands": [
|
||||||
|
{"name": "call", "args": [{"name": "tool"}, {"name": "json-args"},
|
||||||
|
{"name": "--screenshot-out-file"}, {"name": "--socket"}]},
|
||||||
|
{"name": "status", "args": [{"name": "--socket"}]},
|
||||||
|
]});
|
||||||
|
assert!(cli_contract_gap(&manifest).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_tool_is_named_instead_of_a_bare_unhealthy() {
|
||||||
|
let all: Vec<String> = REQUIRED_TOOLS.iter().map(|tool| tool.to_string()).collect();
|
||||||
|
assert!(missing_tools(&all).is_empty());
|
||||||
|
let advertised: Vec<String> = all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|tool| tool != "get_window_state")
|
||||||
|
.collect();
|
||||||
|
assert_eq!(missing_tools(&advertised), ["get_window_state"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn normalized_drag_uses_one_driver_gesture() {
|
fn normalized_drag_uses_one_driver_gesture() {
|
||||||
let actions = crate::parse_computer_actions(
|
let actions = crate::parse_computer_actions(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ use std::collections::HashMap;
|
||||||
use lazyboy_contracts::{RefVerb, UiElement};
|
use lazyboy_contracts::{RefVerb, UiElement};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use super::{ListedWindow, client::CuaClient};
|
use super::{
|
||||||
|
ListedWindow,
|
||||||
|
client::{CuaClient, action_verdict},
|
||||||
|
};
|
||||||
|
use crate::ActionVerdict;
|
||||||
use crate::ControlError;
|
use crate::ControlError;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -117,7 +121,7 @@ pub(super) async fn act(
|
||||||
target: NativeTarget,
|
target: NativeTarget,
|
||||||
verb: RefVerb,
|
verb: RefVerb,
|
||||||
text: Option<&str>,
|
text: Option<&str>,
|
||||||
) -> Result<(), ControlError> {
|
) -> Result<Option<ActionVerdict>, ControlError> {
|
||||||
client
|
client
|
||||||
.call(
|
.call(
|
||||||
display,
|
display,
|
||||||
|
|
@ -141,6 +145,6 @@ pub(super) async fn act(
|
||||||
}
|
}
|
||||||
RefVerb::Focus => return Err(ControlError::Unsupported),
|
RefVerb::Focus => return Err(ControlError::Unsupported),
|
||||||
};
|
};
|
||||||
client.call(display, tool, &payload, &[]).await?;
|
let reply = client.call(display, tool, &payload, &[]).await?;
|
||||||
Ok(())
|
Ok(action_verdict(&reply))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,51 @@ pub fn format_ui_elements(elements: &[UiElement]) -> String {
|
||||||
.join(" ")
|
.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Titles longer than this are cut with an ellipsis. AT-SPI labels in chat and
|
||||||
|
/// Electron clients can be entire message bodies, and a label only has to
|
||||||
|
/// identify a control.
|
||||||
|
const MAX_ELEMENT_TITLE_CHARS: usize = 60;
|
||||||
|
|
||||||
|
/// One line per control: `[id] kind "title" @ x,y`. Models address controls by
|
||||||
|
/// id (see `actions::element_id`), so this list is meant to replace the element
|
||||||
|
/// array in a tool result, not to sit next to it; `max` keeps a dense desktop
|
||||||
|
/// inside the per-turn budget and says what was dropped.
|
||||||
|
pub fn format_ui_element_lines(elements: &[UiElement], max: usize) -> String {
|
||||||
|
if elements.is_empty() {
|
||||||
|
return "none".into();
|
||||||
|
}
|
||||||
|
let mut lines: Vec<String> = elements
|
||||||
|
.iter()
|
||||||
|
.take(max)
|
||||||
|
.map(|element| {
|
||||||
|
let (x, y) = element.center();
|
||||||
|
let kind = element
|
||||||
|
.role
|
||||||
|
.as_deref()
|
||||||
|
.or(element.kind.as_deref())
|
||||||
|
.unwrap_or("control");
|
||||||
|
let title: String = element
|
||||||
|
.title
|
||||||
|
.chars()
|
||||||
|
.filter(|character| *character != '\n' && *character != '\r')
|
||||||
|
.take(MAX_ELEMENT_TITLE_CHARS)
|
||||||
|
.collect();
|
||||||
|
let ellipsis = if element.title.chars().count() > MAX_ELEMENT_TITLE_CHARS {
|
||||||
|
"…"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("[{}] {kind} \"{title}{ellipsis}\" @ {x},{y}", element.id)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if let Some(dropped) = elements.len().checked_sub(max) {
|
||||||
|
lines.push(format!(
|
||||||
|
"+{dropped} more not listed: re-observe after scrolling, or aim at the coordinates above"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn sniff_image_mime(image: &[u8]) -> &'static str {
|
fn sniff_image_mime(image: &[u8]) -> &'static str {
|
||||||
if image.len() >= 3 && image[0] == 0xFF && image[1] == 0xD8 && image[2] == 0xFF {
|
if image.len() >= 3 && image[0] == 0xFF && image[1] == 0xD8 && image[2] == 0xFF {
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
|
|
@ -120,6 +165,41 @@ pub fn signatures_similar(a: &[u8], b: &[u8]) -> bool {
|
||||||
changed * 50 < a.len()
|
changed * 50 < a.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a capture differs from the frame the Agent last saw. `Identical` is the
|
||||||
|
/// transport fact: the same picture is already in the model's context. Byte
|
||||||
|
/// equality alone is too strict for advice, because a panel clock or a blinking
|
||||||
|
/// caret makes every capture a new sha256 and a click that did nothing would
|
||||||
|
/// never be recognised; `Similar` is the perceptual answer.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum ScreenChange {
|
||||||
|
Identical,
|
||||||
|
Similar,
|
||||||
|
Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare a capture against the previous frame id and signature. Skipping a
|
||||||
|
/// screenshot is still only correct for `Identical`: a change too small to see
|
||||||
|
/// is a change the model should be allowed to look at, while "nothing moved,
|
||||||
|
/// do not repeat this" may use `Similar`.
|
||||||
|
pub fn screen_change_between(
|
||||||
|
previous_frame: Option<&str>,
|
||||||
|
previous_signature: Option<&[u8]>,
|
||||||
|
observation: &ComputerObservation,
|
||||||
|
) -> ScreenChange {
|
||||||
|
if frames_match(previous_frame, observation) {
|
||||||
|
return ScreenChange::Identical;
|
||||||
|
}
|
||||||
|
let (Some(previous), Some(current)) = (previous_signature, frame_signature(&observation.image))
|
||||||
|
else {
|
||||||
|
return ScreenChange::Changed;
|
||||||
|
};
|
||||||
|
if signatures_similar(previous, ¤t) {
|
||||||
|
ScreenChange::Similar
|
||||||
|
} else {
|
||||||
|
ScreenChange::Changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -196,6 +276,111 @@ mod tests {
|
||||||
assert!(frame_signature(b"not an image").is_none());
|
assert!(frame_signature(b"not an image").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screen_change_calls_a_clock_tick_similar_not_identical() {
|
||||||
|
fn png(paint: impl Fn(u32, u32) -> Rgb<u8>) -> Vec<u8> {
|
||||||
|
let img = RgbImage::from_fn(320, 180, paint);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
PngEncoder::new(&mut out)
|
||||||
|
.write_image(img.as_raw(), 320, 180, ExtendedColorType::Rgb8)
|
||||||
|
.unwrap();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
let plain = png(|_, _| Rgb([240, 240, 240]));
|
||||||
|
let clock = png(|x, y| {
|
||||||
|
if x < 6 && y < 6 {
|
||||||
|
Rgb([0, 0, 0])
|
||||||
|
} else {
|
||||||
|
Rgb([240, 240, 240])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let dialog = png(|x, y| {
|
||||||
|
if x < 160 && y < 90 {
|
||||||
|
Rgb([20, 20, 20])
|
||||||
|
} else {
|
||||||
|
Rgb([240, 240, 240])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let before = observation_from_png(plain.clone(), 320, 180, None, None);
|
||||||
|
let signature = frame_signature(&plain);
|
||||||
|
|
||||||
|
// Byte for byte the same frame: the model already holds this picture.
|
||||||
|
let same = observation_from_png(plain.clone(), 320, 180, None, None);
|
||||||
|
assert_eq!(
|
||||||
|
screen_change_between(Some(before.frame_id.as_str()), signature.as_deref(), &same),
|
||||||
|
ScreenChange::Identical
|
||||||
|
);
|
||||||
|
|
||||||
|
// A flipping panel clock is a new sha256 of the same screen. Only the
|
||||||
|
// perceptual answer lets the "stop repeating this click" advice fire.
|
||||||
|
let tick = observation_from_png(clock, 320, 180, None, None);
|
||||||
|
assert_ne!(tick.frame_id, before.frame_id);
|
||||||
|
assert_eq!(
|
||||||
|
screen_change_between(Some(before.frame_id.as_str()), signature.as_deref(), &tick),
|
||||||
|
ScreenChange::Similar
|
||||||
|
);
|
||||||
|
|
||||||
|
let covered = observation_from_png(dialog, 320, 180, None, None);
|
||||||
|
assert_eq!(
|
||||||
|
screen_change_between(
|
||||||
|
Some(before.frame_id.as_str()),
|
||||||
|
signature.as_deref(),
|
||||||
|
&covered
|
||||||
|
),
|
||||||
|
ScreenChange::Changed
|
||||||
|
);
|
||||||
|
|
||||||
|
// With no signature to compare, only byte equality counts.
|
||||||
|
assert_eq!(
|
||||||
|
screen_change_between(None, None, &same),
|
||||||
|
ScreenChange::Changed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn element_lines_cap_the_count_and_long_labels() {
|
||||||
|
let elements: Vec<UiElement> = (1..=5)
|
||||||
|
.map(|index| UiElement {
|
||||||
|
id: index,
|
||||||
|
title: format!("Window {index}"),
|
||||||
|
x: index * 10,
|
||||||
|
y: index * 20,
|
||||||
|
w: 40,
|
||||||
|
h: 20,
|
||||||
|
kind: Some("window".into()),
|
||||||
|
..UiElement::default()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let listing = format_ui_element_lines(&elements, 3);
|
||||||
|
let lines: Vec<&str> = listing.lines().collect();
|
||||||
|
assert_eq!(lines.len(), 4);
|
||||||
|
assert_eq!(lines[0], "[1] window \"Window 1\" @ 30,30");
|
||||||
|
assert!(listing.contains("+2 more not listed"));
|
||||||
|
assert!(!listing.contains("Window 5"));
|
||||||
|
|
||||||
|
// A label identifies a control; it is not a text channel.
|
||||||
|
let chatty = vec![UiElement {
|
||||||
|
id: 7,
|
||||||
|
title: "x".repeat(200),
|
||||||
|
..UiElement::default()
|
||||||
|
}];
|
||||||
|
let line = format_ui_element_lines(&chatty, 10);
|
||||||
|
assert_eq!(line.matches('x').count(), MAX_ELEMENT_TITLE_CHARS);
|
||||||
|
assert!(line.ends_with("\" @ 0,0"));
|
||||||
|
|
||||||
|
// A role tells the model more than the internal kind does.
|
||||||
|
let control = vec![UiElement {
|
||||||
|
id: 3,
|
||||||
|
title: "Send".into(),
|
||||||
|
kind: Some("dom".into()),
|
||||||
|
role: Some("button".into()),
|
||||||
|
..UiElement::default()
|
||||||
|
}];
|
||||||
|
assert!(format_ui_element_lines(&control, 10).starts_with("[3] button \"Send\""));
|
||||||
|
assert_eq!(format_ui_element_lines(&[], 10), "none");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn jpeg_magic_sets_mime() {
|
fn jpeg_magic_sets_mime() {
|
||||||
let observation = observation_from_png(vec![0xFF, 0xD8, 0xFF, 0x00], 1, 1, None, None);
|
let observation = observation_from_png(vec![0xFF, 0xD8, 0xFF, 0x00], 1, 1, None, None);
|
||||||
|
|
|
||||||
|
|
@ -142,12 +142,52 @@ pub struct EnsureScreenResult {
|
||||||
pub view_port: u16,
|
pub view_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How much the driver could prove about an action. Variants are ordered from
|
||||||
|
/// least to most urgent so a batch can keep its worst verdict. `Done` requires
|
||||||
|
/// the driver to confirm an effect: a driver that reports nothing stays
|
||||||
|
/// `None` rather than being reported as success.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ActionDecision {
|
||||||
|
Done,
|
||||||
|
VerifyFreshState,
|
||||||
|
Escalate,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActionDecision {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Done => "done",
|
||||||
|
Self::VerifyFreshState => "verify_fresh_state",
|
||||||
|
Self::Escalate => "escalate",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The driver's own verdict on the last action, kept verbatim where it exists
|
||||||
|
/// so a refusal can still be traced back to the field that produced it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ActionVerdict {
|
||||||
|
pub decision: ActionDecision,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub effect: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub verified: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub escalation: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ActionResult {
|
pub struct ActionResult {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub clipboard_text: Option<String>,
|
pub clipboard_text: Option<String>,
|
||||||
pub completed: usize,
|
pub completed: usize,
|
||||||
pub observation: Option<ComputerObservation>,
|
pub observation: Option<ComputerObservation>,
|
||||||
|
/// Absent when the driver gave no semantic fields, when nothing was sent to
|
||||||
|
/// the driver (sleep, launch, clipboard), and from an older controld.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub verdict: Option<ActionVerdict>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,11 @@ async fn act(
|
||||||
match app.controller.act(&request, &ctx).await {
|
match app.controller.act(&request, &ctx).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let mut body = serde_json::json!({ "completed": result.completed, "clipboardText": result.clipboard_text });
|
let mut body = serde_json::json!({ "completed": result.completed, "clipboardText": result.clipboard_text });
|
||||||
|
if let Some(verdict) = &result.verdict
|
||||||
|
&& let Ok(value) = serde_json::to_value(verdict)
|
||||||
|
{
|
||||||
|
body["verdict"] = value;
|
||||||
|
}
|
||||||
if let Some(observation) = result.observation
|
if let Some(observation) = result.observation
|
||||||
&& let serde_json::Value::Object(map) = observation_to_control_json(&observation)
|
&& let serde_json::Value::Object(map) = observation_to_control_json(&observation)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,10 @@ impl SandboxProvider for DockerSandbox {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
// An older controld simply omits the verdict.
|
||||||
|
verdict: body
|
||||||
|
.get("verdict")
|
||||||
|
.and_then(|value| serde_json::from_value(value.clone()).ok()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ impl SandboxProvider for FakeSandbox {
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
},
|
},
|
||||||
|
verdict: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
# hermes-agent 對照:harness 與 Cua 合作的順暢度(2026-09-08,更新:2026-09-09)
|
||||||
|
|
||||||
|
研究對象:[`NousResearch/hermes-agent`](https://github.com/NousResearch/hermes-agent),commit `abd83ab560327c58f17f8e2ecd9be0307d5c9cf9`(12,269 檔)。本輪擷取 `tools/computer_use/` 全 14 檔與官網 computer-use 使用者手冊;下文 hermes 路徑皆為該 repo 相對路徑,LazyBoy 路徑附 `file:line`。
|
||||||
|
|
||||||
|
要回答的問題不是「要不要換一套 Cua 用法」,而是:**hermes-agent 在同樣的 cua-driver 限制下做了哪些選擇,是我們的 harness 可以拿來讓模型用得更順的?**
|
||||||
|
|
||||||
|
## 結論
|
||||||
|
|
||||||
|
**可以改善,而且不需要改動 Cua 串接層。** hermes-agent 的 `computer_use` 同樣落到 cua-driver(差別是它走 MCP over stdio,我們走 per-call CLI),兩邊面對的是同一個 Linux AT-SPI/X11 桌面、同一批 driver 工具、同一種「動作可能送達但無法證明生效」的不確定性。差距幾乎全在 **harness 這一層**:
|
||||||
|
|
||||||
|
1. hermes 把 Cua 回給我們的**語義結果(effect / escalation / verified)誠實地換算成一個 verdict 交給模型**,並在 prompt 裡明令「已確認生效不得重打輸入」。我們讀了這些欄位,只用來決定要不要自己重試一次,**模型本身看不到**。
|
||||||
|
2. 我們用 **byte-exact 的 `frame_id`** 判斷「畫面沒變」,真實桌面上時鐘與游標一直動,等於這個判斷永遠不成立——重複點同一個按鈕的教練機制因此形同虛設。repo 裡已有感知級簽名,但只用在教學影格。
|
||||||
|
3. 我們的 observation 文字**同時**列出元素清單與完整 `elements` JSON,兩份內容、皆無上限,dense 桌面一輪就能燒掉數千 token。hermes 對元素數量與標題長度都有預算,多出來的 spill 到檔、讓模型自行 `read_file`。
|
||||||
|
|
||||||
|
其餘是次要項(串行觀察呼叫、非視覺模型、driver 版本策略、safety 硬擋)。以下逐項附證據。
|
||||||
|
|
||||||
|
## 比較基準
|
||||||
|
|
||||||
|
| | LazyBoy | hermes-agent |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 呼叫方式 | 每次動作 spawn 一個 `cua-driver call` CLI 行程(`crates/control/src/cua/client.rs:273`) | 常駐 MCP session over stdio,CLI 僅當 fallback(`tools/computer_use/cua_backend_session.py`) |
|
||||||
|
| 驅動版本 | 硬 pin `0.23.x`,不符即 unhealthy(`crates/control/src/cua/mod.rs:39`) | 不 pin,改成 runtime contract gate(version floor + 必要 argv 集合)+ 逐工具 capability(`cua_backend_driver.py:154`) |
|
||||||
|
| 工具數 | 21 個獨立 tool(`crates/api/src/tools.rs:64`) | 單一 tool + `action` 判別參數,省每輪 schema token(`tools/computer_use/schema.py`) |
|
||||||
|
| 結果語義 | 語義結果換算成 `ActionVerdict`,並寫進 tool result 第一行(`crates/control/src/cua/client.rs:442`) | `verified`/`effect`/`escalation`/`degraded` 換算成 `verdict.decision`(`tool.py:409`) |
|
||||||
|
| 失敗後重放 | 工具層 150s 與底層 120s 都 fail closed:不重放、訊息講明效果不明(`crates/api/src/runs.rs:1288`、`crates/control/src/cua/client.rs:199`),並把 session 標 suspect、下次呼叫前重建(`crates/control/src/cua/client.rs:247`) | 傳輸/逾時一律 fail closed、**絕不重放 mutation**,並將 session 標記 suspect、下次呼叫前重建(`cua_backend_session.py:71`, `:181`, `:495`) |
|
||||||
|
|
||||||
|
## 逐項對照
|
||||||
|
|
||||||
|
每一節講的都是**診斷當下**的狀態;已落地的章節在標題標明,位置與對應測試統一列在〈已交付〉。
|
||||||
|
|
||||||
|
### 1. 逐動作 verdict 沒有回到模型(最高價值,已實作)
|
||||||
|
|
||||||
|
hermes 的做法(`tool.py:409 _classify_action_result`、`tool.py:429 _action_payload`、`cua_backend_parse.py:_action_result_from`):把 driver 的 `verified`、`effect ∈ {confirmed, unverifiable, suspected_noop}`、`escalation.recommended`、`path`、`degraded`、`code` 原帶回傳,再換算成一個模型看得懂的決定:
|
||||||
|
|
||||||
|
- `confirmed` → `{"decision": "done"}`
|
||||||
|
- `unverifiable` → `{"decision": "verify_fresh_state"}`,附「先重擷再決定,**不可以只因為有 escalation 建議就重打輸入**」
|
||||||
|
- `suspected_noop` / 有 `code` → `{"decision": "escalate"}` + 建議模式
|
||||||
|
- 傳輸成功但無語義證據 → 同樣是 `verify_fresh_state`(原文註解:*transport success without semantic proof is not proof of effect*)
|
||||||
|
|
||||||
|
LazyBoy 現況:`recommended_delivery()` 讀 `/escalation/recommended`(`crates/control/src/cua/client.rs:430`)後自己換 `delivery_mode` 重試一次(`crates/control/src/cua/client.rs:226`);`effect` 只在 `refused|error` 時當失敗(`crates/control/src/cua/client.rs:484`)。往上一層就斷了:`ActionResult { clipboard_text, completed, observation }` 原本**沒有逐動作訊號欄位**,模型只會看到「completed N computer action(s)」加下一張截圖。**(已修:`ActionResult.verdict`,`crates/control/src/sandbox.rs:190`,見〈已交付 A〉。)**
|
||||||
|
|
||||||
|
後果:一個 `unverifiable` 的輸入(Linux AT-SPI 很常見)在我們這裡跟 `confirmed` 完全一樣。模型無法判斷該重看還是重打,只能從下一張截圖猜——這就是重複點擊的起點。
|
||||||
|
|
||||||
|
### 2. 「畫面沒變」是 sha256 byte-exact(最高價值,已修正)
|
||||||
|
|
||||||
|
`frame_id = sha256(PNG)`(`crates/control/src/observe.rs:16`),比較用 `frames_match`(`crates/control/src/observe.rs:133`)。桌面只要有時鐘、游標閃爍、notification、動畫,sha256 就不同 → `unchanged` 永遠是 false → `miss_streak` 永不累積(`crates/api/src/tools.rs:1059`)。兩個教練因此失靈:
|
||||||
|
|
||||||
|
- `note_click_result`(`crates/api/src/tools.rs:1055`)的「別再重複同一個點擊」建議
|
||||||
|
- `should_block_stale_click`(`crates/control/src/actions.rs:139`)的硬性擋掉(streak ≥ 2 且同一目標)
|
||||||
|
|
||||||
|
失靈後模型會一直重複同一個無效點擊,直到 LoopGuard 的輪次政策(`crates/harness/src/policy.rs`)把整個 run 停下——花掉整輪預算才換來一句本來可以在第一輪就講的話。
|
||||||
|
|
||||||
|
repo 裡**已經有**對的工具:`frame_signature` / `signatures_similar`(`crates/control/src/observe.rs:143`, `:156`,32×18 灰階、變動 < 2% 視為相似),但目前只用在教學關鍵影格(`crates/api/src/skills.rs:858`)。
|
||||||
|
|
||||||
|
### 3. Observation payload 沒有預算,而且重複兩份(已修正重複與上限)
|
||||||
|
|
||||||
|
`observation_text`(`crates/api/src/tools.rs:471`)先印 `format_ui_elements`(`crates/control/src/observe.rs:69`,`[id] title` 串接、無上限),**接著**又 dump 整個 `elements` JSON(含 `selector`、`role`、幾何)。上層 `merge_ui_elements`(`crates/control/src/a11y.rs:6`)也不設上限。Windows 標題允許 256 字(`crates/control/src/cua/mod.rs:542`),Chromium 的 a11y 樹還會把整段訊息內文當成 label。只有 checkpoint 寫入時才會被 `shrink_checkpoint` 截到 48KB(`crates/api/src/runs.rs:1933`)——那是存檔保護,不是送給模型的預算。
|
||||||
|
|
||||||
|
hermes 的預算(`tool.py:441`):`_DEFAULT_MAX_ELEMENTS = 100`、`_MAX_ELEMENT_LABEL_CHARS = 120`、summary 只列 40 行,被截到的**完整樹 spill 到檔**並告知模型可用 `read_file`/`search_files` 取回;原因是「Discord/Slack via UIA 把整個訊息內文當 label,沒上限會爆掉 tool-result 預算並外洩聊天文字」。它甚至刻意讓 **multimodal 回應不附 `elements` array**(已有截圖時兩份是浪費,`tool.py:549 _capture_response`)。
|
||||||
|
|
||||||
|
### 4. 未知結果沒有徹底 fail closed(已實作)
|
||||||
|
|
||||||
|
hermes 把「效果不明確」當成獨立類別(`cua_backend_session.py:71 _UNKNOWN_OUTCOME_MESSAGES`):傳輸失敗或 MCP 逾時都回 `isError` + `next_step: "fresh_state"`,訊息裡明講「動作**可能已經生效**,所以 Hermes 沒有重放它」。`_TRANSPORT_REPLAY_SAFE_TOOLS`(`:181`)是一組**唯讀**工具白名單,只有它們允許在傳輸錯誤後重試(`:495`, `:504`);mutation 一律不重放。
|
||||||
|
|
||||||
|
LazyBoy:工具層 150 秒逾時的措辭已經對了(`crates/api/src/runs.rs:1288`:*Its effects are unknown … never repeat a step that already worked*)。但底層 CLI 的 120 秒逾時只拋 `ControlError::Timeout`(`crates/control/src/cua/client.rs:381`),訊息是 `computer action timed out`——沒有任何指示,模型的自然反應就是重打一次。同一個不確定性有兩種處理標準。**(已修:`crates/control/src/controller.rs:59` 的 `Timeout` 訊息現在明講效果不明、先觀察、不得重放成功的步驟。)**
|
||||||
|
|
||||||
|
### 5. 逾時後不重建 driver session(已實作)
|
||||||
|
|
||||||
|
hermes 一次 timeout 就把 session 標成 suspect,下次 computer-use 呼叫前先重建。LazyBoy 只在 driver 回報 `session_ended` 時 revive(重發 `start_session`,`crates/control/src/cua/client.rs:151-163`)。逾時/半死 socket 不會進那個分支,於是同一個可疑 session 會繼續被用下去,後續每個動作的結果都不可信。**(已修:逾時即標記 suspect,下次 mutation 前重建 session,`crates/control/src/cua/client.rs:247`。)**
|
||||||
|
|
||||||
|
### 6. 一次觀察要 spawn 四次 CLI(已併行)
|
||||||
|
|
||||||
|
`observe_display` 原本串行做:`get_desktop_state`(寫檔讀 PNG)→ `screen_size`(必要時)→ `cursor` → `windows` → `native::observe`,每次都是一個行程 + socket 往返,全部加在模型等待時間裡。**(已修:`crates/control/src/cua/mod.rs:487`,cursor 與 windows 用 `tokio::join!` 併行(`crates/control/src/cua/mod.rs:512`)。cursor 不省——`scroll_point` 要靠它瞄準,而且併行之後已不額外花時間。)**
|
||||||
|
|
||||||
|
hermes 另外示範了「用本機命令取代一次 driver 呼叫」:`_select_capture_target`(`cua_backend_capture.py:58`)用 `xprop _NET_ACTIVE_WINDOW`(2 秒 timeout)判作用中視窗,只在 X11 `z_index` 同值時才採用,避免多一次 driver 往返。
|
||||||
|
|
||||||
|
### 7. 空結果會跨 transport 重取(已區分「失敗」與「空」)
|
||||||
|
|
||||||
|
hermes `_fetch_or_refetch`(`cua_backend_capture.py:150`):MCP 成功但內容為空時,改用 CLI 再取一次——因為「成功但空」在 Linux 上常常是 transport 問題,不是桌面真的沒東西。我們原本也沒有防禦:`windows(...).unwrap_or_default()` 把失敗直接變成「沒有視窗」,模型看到的是空桌面。**(已修:失敗與「真的沒有視窗」分開,觀察會標 partial,見〈已交付 E〉;跨 transport 重取本身不做,理由見〈不做〉。)**
|
||||||
|
|
||||||
|
### 8. 非視覺模型完全不能用電腦(已給 ax-only 觀察)
|
||||||
|
|
||||||
|
hermes 支援 `capture(mode='ax')`(不附截圖、只有元素樹)與 `vision_routing.py`:不確定主模型能否吃圖時 **fail closed 到 aux vision** 先把截圖轉文字。LazyBoy 的 `vision_guard`(`crates/api/src/tools.rs:455`)原本對非視覺模型直接回「請改選視覺模型」,連純元素樹的觀察都一起擋掉。**(已修:ax-only 降級路徑已做,非視覺模型保留元素樹觀察、擋掉像素工作,見〈已交付 H〉;aux vision 需要第二模型的呼叫管線,延後。)**
|
||||||
|
|
||||||
|
### 9. 驅動相容性:硬 pin vs contract gate(已加 capability probe)
|
||||||
|
|
||||||
|
hermes 明確拒絕 version pin(`cua_backend_driver.py:19` 註解:上游安裝器一律抓最新版,pin 只是幻象),改檢查「version floor + 必要 argv 集合 + 逐工具 capability tokens」,舊 driver 不支援 foreground 就**明確 refused 而不是降級假成功**。LazyBoy 只認 `0.23.x`(`crates/control/src/cua/mod.rs:39`,且有測試驗證 `0.24.0` 視為不符,`crates/control/src/cua/mod.rs:951`)。
|
||||||
|
|
||||||
|
取捨要講清楚:**在生產環境我們現在這樣比較好**(可重現、驗收過就是驗收過)。hermes 的教訓是「pin 要配 capability probe」——當我們升到 0.24 時,不該只改常數,而該在 unhealthy 訊息裡點名**缺哪個 argv**,否則現場只會看到「driver unhealthy」這種查不出原因的話。**(已做:`cua-driver manifest` + `list-tools` 的 capability probe,見〈已交付 G〉;pin 本身保留。)**
|
||||||
|
|
||||||
|
### 10. Safety 與 schema 細節(已實作硬擋與建議)
|
||||||
|
|
||||||
|
hermes 有幾件我們沒有的硬擋(`tool.py:47`、`:51`、`:57`、`:68`):
|
||||||
|
|
||||||
|
- 危險 `type` 內容模式擋掉(`curl … | bash`、`sudo rm -rf`、fork bomb)
|
||||||
|
- 危險按鍵組合硬擋,且 `_canon_key_combo` 會把 `ctrl-alt-delete` 這種底線寫法正規化後才比對
|
||||||
|
- `_input_target_mismatch`:輸入落到別的視窗時不報 ok(防「打錯視窗還說成功」)
|
||||||
|
- 未知 action 回「did you mean X?」,而不是丟一個 schema 錯誤讓模型亂猜
|
||||||
|
- tool schema **byte-frozen** 以保 prompt cache 命中
|
||||||
|
- `capture_after=true`:動作回應直接附新截圖,省一次 round-trip
|
||||||
|
|
||||||
|
LazyBoy 已有同級品的部分:SOM 數字疊字 `overlay_elements`(`crates/control/src/overlay.rs:47`)、容許 `#12`/`[7]` 等寫法的 `element_id`(`crates/control/src/actions.rs:49`)、歷史裡只留最近一張截圖(`crates/api/src/runs.rs:1905`)、批次動作 `MAX_COMPUTER_ACTIONS`。
|
||||||
|
|
||||||
|
### 11. LazyBoy 已經比較強的地方(不用移植)
|
||||||
|
|
||||||
|
輪次政策與停看聽(`crates/harness/src/policy.rs`)、takeover/resume、checkpoint 續跑、記憶 recall 預算、steering、`make cua-smoke` 端到端驗收。hermes 沒有多使用者、容器隔離、真人接管這層——它的問題意識是單一使用者長期佔用一台機器。
|
||||||
|
|
||||||
|
## 改造清單
|
||||||
|
|
||||||
|
### P0(順暢度差異最直接,改動侷限在 harness)
|
||||||
|
|
||||||
|
| 項目 | 改動點 | 預期效益 | 風險 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| verdict 上層給模型(已做) | `ActionResult` 增 `verdict`/`effect`/`escalation`(`crates/control/src/sandbox.rs:146`)→ `crates/sandbox/src/docker.rs` 帶過 controld wire → `observation_text` 一行結論 | 模型知道「已送達未證明」該重看、不該重打;少一整個重複點擊循環 | 動到 controld wire format(向後相容:全部 `#[serde(default)]`);措辭要短,否則反而變長 |
|
||||||
|
| 感知級 unchanged 用於教練(已做) | `ToolCtx` 加簽名欄位,`pack_observation` 算 `frame_signature`;截圖是否附行的 byte-exact 邏輯**不動** | 同一目標的重複點擊在累計兩次「無可見變化」後被擋掉;不再靠 LoopGuard 收場 | 簽名比對是啟發式;只影響教練建議、不影響送不送圖,風險可控 |
|
||||||
|
|
||||||
|
### P1
|
||||||
|
|
||||||
|
- **observation payload 預算(已完成)**:元素逐行(`[id] role "標題" @ x,y`)+ 數量上限 + `+N more not listed` 提示,**移除重複的 `elements` JSON**(模型只需要 id,見 `element_id`);保留 frameId/尺寸/activeWindow 小 metadata。代價:一旦截斷,模型無法直接看到被省略的元素——先靠提示叫它捲動重看,之後再考慮 hermes 式的「寫到檔 + `read_file`」。
|
||||||
|
- **底層 timeout 改成 fail closed 措辭(已完成)**:`ControlError::Timeout` 的訊息加上「效果不明、可能已生效、先觀察再決定、不得重放」,並與 `runs.rs:1288` 的措辭統一。
|
||||||
|
- **逾時後標記 suspect 並重建 session(已完成)**:在 `CuaClient` 記一次逾時,下次 mutation 前先 `start_session`;失敗就明確報 driver unavailable。
|
||||||
|
- **空結果不當成空桌面(已完成)**:`list_windows` 失敗要與「真的沒有視窗」區分(現在是 `unwrap_or_default()`)。
|
||||||
|
|
||||||
|
### P2
|
||||||
|
|
||||||
|
- **(已完成)** `observe_display` 的 cursor 與 window 清單改成 `tokio::join!` 併行(`crates/control/src/cua/mod.rs:512`)。cursor **不省**:`scroll_point` 靠游標位置瞄準,併行之後它已經不額外花時間。
|
||||||
|
- **(ax-only 已完成,aux vision 延後)** 非視覺模型保留元素樹觀察,只有像素工作仍擋(〈已交付 H〉)。
|
||||||
|
- **(已完成)** driver 不相容時點名缺少哪個 argv/哪個工具,而不是只說 unhealthy(〈已交付 G〉)。
|
||||||
|
- **(已完成,`capture_after` 除外)** 危險 `type` 內容與按鍵組合硬擋、未知 action 給建議(〈已交付 F〉);`capture_after` 評估後不做(〈不做〉)。
|
||||||
|
- **(改做守門測試)** 21 個 tool 的 schema 合併會弄壞 checkpoint 裡舊的 `tool_calls`,改以序列化穩定性測試保住快取鍵(〈已交付 I〉、〈不做〉)。
|
||||||
|
|
||||||
|
## 已交付
|
||||||
|
|
||||||
|
清單來自本節的診斷,P0/P1/P2 全部落地或在〈不做〉裡記錄理由。每項給位置與測試名稱,測試都可在 repo 內直接搜尋。
|
||||||
|
|
||||||
|
### A. verdict 回到模型(P0)
|
||||||
|
|
||||||
|
- driver 回包的 `effect` / `verified` / `escalation` / `degraded` / `code` 換算成 `ActionDecision{Done, VerifyFreshState, Escalate}`(`crates/control/src/sandbox.rs:151`)與 `ActionVerdict`(`crates/control/src/sandbox.rs:171`),掛在 `ActionResult.verdict`(`crates/control/src/sandbox.rs:190`)。
|
||||||
|
- 換算規則在 `crates/control/src/cua/client.rs:442 action_verdict`:`confirmed` 壓過任何 escalation 建議、`suspected_noop` 或拒答 code → `escalate`、其餘證據 → `verify_fresh_state`,**完全沒有語義證據就回 `None`**(transport 成功不等於生效)。測試:`a_confirmed_effect_outranks_an_advisory_escalation`、`unverifiable_effect_becomes_verify_fresh_state`、`suspected_noop_and_refusal_codes_escalate`、`a_reply_without_semantic_evidence_claims_nothing`。
|
||||||
|
- 批次動作取最嚴重的一筆(`crates/control/src/cua/mod.rs:701 merge_verdict`,`ActionDecision` 的列舉順序就是嚴重度),經 controld wire(`crates/controld/src/main.rs:166`)回到 `crates/sandbox/src/docker.rs:263`;兩側都是 `Option` + `#[serde(default)]`,**舊 controld 只是不带 verdict,不會解析失敗**。
|
||||||
|
- 模型看到的是 tool result 的第一行:`crates/api/src/tools.rs:1077 verdict_note` 加 `crates/api/src/tools.rs:1096 with_verdict`(前置,不會被截圖說明蓋掉)。已確認 → 「不得重複這個動作」;未證明 → 「先讀新截圖,絕不重打可能已生效的輸入」;無效果 → 「換做法,別重打同一輸入」。測試:`crates/api/src/tools.rs:1983 verdict_tests`。
|
||||||
|
- 「已確認生效」同時算進 miss streak 的判斷(`crates/api/src/tools.rs:902`),免得面板時鐘把一次成功的點擊當成「畫面沒變」。
|
||||||
|
- **代價**:verdict 要 desktop image 裡的 controld 重建後才會出現;本輪**沒有重啟 live 容器**,所以線上暫時仍是「沒有 verdict」而非錯誤答案。
|
||||||
|
|
||||||
|
### B. 感知級 unchanged 只供教練判斷(P0)
|
||||||
|
|
||||||
|
- `ScreenChange{Identical, Similar, Changed}` 與 `screen_change_between`(`crates/control/src/observe.rs:174`、`crates/control/src/observe.rs:184`),`ToolCtx.previous_signature` 每輪更新;觀察文字分三种標記(byte-exact `(screen unchanged)`、感知 `(no visible change)`、無標記)。
|
||||||
|
- 附不附截圖**仍然只看 byte-exact**,細微視覺變化不會被藏起來;miss streak 與 `should_block_stale_click` 讀感知狀態,所以時鐘不再把 streak 歸零。測試:`screen_change_calls_a_clock_tick_similar_not_identical`。
|
||||||
|
|
||||||
|
### C. 觀察清單有預算(P1)
|
||||||
|
|
||||||
|
- `format_ui_element_lines`(`crates/control/src/observe.rs:89`)取代「清單 + 完整 `elements` JSON」兩份內容:每個控制一行 `[id] role "標題" @ x,y`,標題 60 字、元素 120 個(`crates/api/src/tools.rs:467 MAX_LISTED_ELEMENTS`),超出以 `+N more not listed` 說明;frameId/尺寸/activeWindow 小 metadata 保留。測試:`lists_each_control_once_and_names_what_was_dropped`、`element_lines_cap_the_count_and_long_labels`。
|
||||||
|
|
||||||
|
### D. 未知結果徹底 fail closed(P1)
|
||||||
|
|
||||||
|
- `ControlError::Timeout` 的訊息(`crates/control/src/controller.rs:59`)改成「效果不明、可能已生效、先觀察再決定、不得重放已成功的步驟」,與 `crates/api/src/runs.rs` 工具層 150 秒的措辭一致;保留 `timed out` 子串,`monitor.rs`/`runs.rs`/`mcp.rs` 的失敗分類不受影響。
|
||||||
|
- `CuaClient` 記 suspect 顯示(`crates/control/src/cua/client.rs:23`):mutation 逾時即標記該 display **且永不重放**;下一次 mutation 前先 `repair_suspect_session`(`crates/control/src/cua/client.rs:247`)重建命名 session,成功的話補一次游標動態設定,再逾時就保留標記繼續 fail closed。唯讀工具(`read_after_session_restart`)不付這多出來的 round-trip。
|
||||||
|
|
||||||
|
### E. 空視窗清單不等於空桌面(P1)
|
||||||
|
|
||||||
|
- `list_windows` 失敗不再 `unwrap_or_default()` 變成「沒視窗」:失敗時把 `native_observation_complete` 關掉(`crates/control/src/cua/mod.rs:564`),欄位語義寫進 `crates/contracts/src/action.rs:145`。
|
||||||
|
- 觀察文字在清單標題後加 coverage(`crates/api/src/tools.rs:483`):`(partial: some windows did not report controls, so a missing entry is not proof)`——模型正是在這一行決定「按鈕不存在」。測試:`an_incomplete_sweep_says_a_missing_control_is_not_proof`。
|
||||||
|
|
||||||
|
### F. Safety 硬擋與「你是不是打錯」(P2)
|
||||||
|
|
||||||
|
- 未知 action 名稱給出最近的正確名稱,或指向正確工具(`crates/control/src/actions.rs:146 KIND_ALIASES`、`:183 OTHER_TOOL_HINTS`、`:190 nearest_action_kind`):`screenshot`/`capture`/`observe` → `computer_observe`,`snapshot` → `browser`。測試:`an_unknown_action_name_points_at_the_right_thing`。
|
||||||
|
- 按鍵組合先正規化再比對(`:234 BLOCKED_KEY_COMBOS`、`:242 canonical_keys`、`:260 blocked_key_combo`),`ctrl+alt+delete`、`ctrl-alt-delete`、`super+l` 一律擋(`session_killing_shortcuts_are_blocked_however_they_are_spelled`)。
|
||||||
|
- 危險 `type` 內容擋掉(`:277 blocked_text`):`curl … | sh`、`rm -rf /`、fork bomb、`of=/dev/`,比對前做空白折疊,所以多空格/換行繞不過(`destructive_typed_text_is_blocked_and_the_work_is_not`)。
|
||||||
|
|
||||||
|
### G. driver 不相容時點名缺什麼(P2)
|
||||||
|
|
||||||
|
- pin 保留(理由見〈不值得照搬〉),但失敗時不再只說 unhealthy:`cua-driver manifest`(`crates/control/src/cua/client.rs:105`)比對我們真的會 spawn 的 argv(`crates/control/src/cua/mod.rs:52 REQUIRED_CLI_ARGS`:`call --socket`、`call --screenshot-out-file`、`status --socket`),`cua-driver list-tools`(`crates/control/src/cua/client.rs:123`)比對我們真的會 call 的 25 個工具(`crates/control/src/cua/mod.rs:59 REQUIRED_TOOLS`,與 0.23.2 逐一对過)。
|
||||||
|
- 輸出是「driver CLI is missing: call --screenshot-out-file」或「driver tools are missing: X」,全部都在時則是「…only the version series differs」(`crates/control/src/cua/mod.rs:136 capability_gap`)。兩個探測都唯讀、10 秒上限、**只在 health 失敗時跑**,老 driver 沒這兩個 verb 就靜默回到舊訊息。測試:`an_older_driver_is_named_by_the_verb_flag_it_lacks`、`the_pinned_driver_surface_passes_the_cli_contract`、`a_missing_tool_is_named_instead_of_a_bare_unhealthy`、`the_tool_list_keeps_names_and_drops_banner_noise`。
|
||||||
|
|
||||||
|
### H. 非視覺模型可以讀桌面(P2)
|
||||||
|
|
||||||
|
- 拆成 `gui_blocked`(`crates/api/src/tools.rs:440`,真人接管時全擋)與 `vision_guard`(`crates/api/src/tools.rs:455`,只有**像素工作**需要視覺模型:`computer_act`、`browser`、`shell`、`open_path`、`launch_app`、`connection_check`、`use_saved_login`)。
|
||||||
|
- `computer_observe` 與 `wait` 改用 `gui_blocked`:非視覺模型照樣拿到元素樹文字,`pack_observation` 不附圖並加註 `crates/api/src/tools.rs:1112`「(elements only: this model cannot see the screen)」,順帶省掉 SOM overlay 與圖片 token。
|
||||||
|
- 系統提示跟一句改寫(`crates/api/src/runs.rs:50`):點擊/輸入/瀏覽需要視覺模型,純文字模型仍可用 `computer_observe` 讀元素樹。
|
||||||
|
|
||||||
|
### I. prompt cache 的守門測試(P2)
|
||||||
|
|
||||||
|
- `tool_definitions`(`crates/api/src/tools.rs:64`)的位元組就是每輪請求的快取鍵。三條測試:`the_tool_schema_is_byte_stable_across_calls`、`tool_names_are_unique_and_open_with_the_computer_pair`、`memory_tools_are_appended_so_the_desktop_schema_never_moves`(記憶工具只能附加在尾端,桌面那半不能因為開關而位移)。位置:`crates/api/src/tools.rs:2037 tool_schema_tests`。
|
||||||
|
|
||||||
|
## 不值得照搬
|
||||||
|
|
||||||
|
- Python 重構、hermes 自己的 MCP session/embedded daemon 管理:我們的 per-call CLI 在容器模型下可重現性更高,換 transport 是另一個案子。
|
||||||
|
- macOS/Windows 專屬路徑(`windows_hide_flags`、Quartz、UIA 特例)與 desktop app 整合:LazyBoy 只做容器內 Linux 桌面。
|
||||||
|
- 取消 driver pin:與我們的驗收政策相反(見第 9 節取捨)。
|
||||||
|
- hermes 的 tool-result spill 到 host 檔:我們的沙箱檔案系統與 workspace 語義不同,先要用再設計。
|
||||||
|
|
||||||
|
## 不做(清單裡剩下的,連理由)
|
||||||
|
|
||||||
|
- **`capture_after`**:`ActionRequest.observe`(預設 true)已經讓動作回應直接附上新觀察,再疊一個 driver 層的 `capture_after` 是把同一件事做兩次,只會多出一個會不一致的開關。
|
||||||
|
- **21 個 tool 合成單一 `action` 判別**:省的是每輪幾 KB 的 schema token,代價是 checkpoint 裡舊的 `tool_calls` 名稱失效——續跑的 run 會開始報 unknown tool。改用 〈已交付 I〉 的序列化穩定測試保住現有的快取鍵。
|
||||||
|
- **aux vision(先用輔助視覺模型把截圖轉文字)**:repo 內沒有第二模型的呼叫管線(provider 選擇、額度、錯誤處理都要一條線),本輪先把「非視覺模型完全不能用」降級成「能讀不能動」,剩下的記在這裡。
|
||||||
|
- **跨 transport 重取(hermes `_fetch_or_refetch`)**:我們只有一條 per-call CLI transport,沒有第二條可以換過去重取;同樣的懷疑改用 〈已交付 E〉 的「不完整」標記表達。
|
||||||
|
- **取消 driver pin**:保留 `PINNED_DRIVER = 0.23`(`crates/control/src/cua/mod.rs:39`,相容性測試在 `mod.rs` 的 `a_new_minor_series_is_not_compatible`)。hermes 的教訓不是「不該 pin」,而是「pin 要配 capability probe」——現在升 0.24 時,unhealthy 訊息會點名缺哪個 argv/工具,而不是只說不相容。
|
||||||
|
|
||||||
|
## 驗證
|
||||||
|
|
||||||
|
- `cargo test --workspace --locked --no-fail-fast`:contracts 4、control **100**、harness 36、sandbox 2、supervisor 2 全綠;api **89 通過、2 失敗**——`memory::tests::database_enforces_agent_scope_and_queries_do_not_leak` 與 `monitor::tests::memory_usage_is_historical_scoped_and_respects_deletion` 需要 `make postgres` 的測試資料庫(`PoolTimedOut`),**改動前即失敗**,與本輪無關。
|
||||||
|
- 新增測試:control 端 verdict 4 條、safety 3 條、capability probe 4 條;api 端 `verdict_tests` 3 條、觀察清單/coverage 3 條、`tool_schema_tests` 3 條(名單見〈已交付〉)。
|
||||||
|
- `cargo clippy --workspace --all-targets --locked -- -D warnings`、`cargo fmt --all --check`:乾淨。
|
||||||
|
- capability probe 的比對基準用 `image/computer` 那份 image 離線跑 `cua-driver manifest` 與 `list-tools` 取得(0.23.2),`REQUIRED_TOOLS` 25 項全部有廣告;探測本身不在 live 容器上驗證。
|
||||||
|
- 端到端順暢度仍須 `make cua-smoke`/`make computer`。**本輪未重啟、未重建 live 容器**(`lazyboy-api-1`、`lazyboy-supervisor-1`、`lazyboy-postgres-1`、paused 的 `lb-team-local-space`),所以 verdict 上層與 wire 變更要在下一次鏡像重建+驗收桌面重跑後,才會在線上看見;重複點擊的實際改善同樣要那一次才算數。
|
||||||
Loading…
Reference in New Issue