mod core;
mod linux;
mod macos;
mod windows;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
mod stub;
use self::core::{
AppInfo, Backend, Capture, ElementAct, MouseButton, Region, ScrollDirection, TargetSpec,
};
use crate::util::{TOOL_OUTPUT_BUDGET_BYTES, UnwrapPoison};
use crate::{Tool, Workspace};
use anyhow::Context;
use async_trait::async_trait;
use serde::Deserialize;
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use strum::IntoEnumIterator;
use tracing::debug;
#[derive(Debug, Clone, Deserialize, Serialize, strum::EnumIter)]
#[serde(rename_all = "snake_case")]
enum ComputerAction {
Observe {
target: Option<String>,
},
Screenshot {
target: Option<String>,
},
Zoom {
target: Option<String>,
region: Region,
},
Apps {},
Windows {
app: Option<String>,
},
Click {
target: Option<String>,
#[serde(rename = "ref")]
reference: Option<String>,
x: Option<f64>,
y: Option<f64>,
button: Option<MouseButton>,
double: Option<bool>,
modifiers: Option<Vec<core::Modifier>>,
},
Type {
text: String,
target: Option<String>,
#[serde(rename = "ref")]
reference: Option<String>,
},
Press {
keys: String,
},
Scroll {
direction: ScrollDirection,
amount: Option<u32>,
target: Option<String>,
x: Option<f64>,
y: Option<f64>,
},
Drag {
from: (f64, f64),
to: (f64, f64),
target: Option<String>,
},
Cursor {},
Wait {
seconds: f64,
},
}
static ACTION_WIRE_NAMES: LazyLock<Vec<String>> = LazyLock::new(|| {
<ComputerAction as IntoEnumIterator>::iter()
.map(|action| wire_name(&action))
.collect()
});
fn wire_name(action: &ComputerAction) -> String {
let v = serde_json::to_value(action).expect("ComputerAction serializes");
match v {
Value::String(s) => s,
Value::Object(obj) => {
assert_eq!(obj.len(), 1, "ComputerAction must serialize to one tag");
obj.keys().next().expect("one tag").clone()
}
other => panic!("unexpected ComputerAction serialization: {other}"),
}
}
const EXPECTED_ACTION_SHAPE: &str = "one of: {\"observe\":{\"target\":\"a1\"}}, \
{\"screenshot\":{\"target\":\"screen\"}}, \
{\"zoom\":{\"target\":\"a1\",\"region\":[x0,y0,x1,y1]}}, {\"apps\":{}}, \
{\"windows\":{\"app\":\"Safari\"}}, \
{\"click\":{\"ref\":\"e3\"} or {\"x\":<0-1000>,\"y\":<0-1000>}}, \
{\"type\":{\"text\":\"...\"}}, {\"press\":{\"keys\":\"cmd+shift+t\"}}, \
{\"scroll\":{\"direction\":\"down\",\"amount\":3}}, \
{\"drag\":{\"from\":[x,y],\"to\":[x,y]}}, {\"cursor\":{}}, {\"wait\":{\"seconds\":2}}";
const DEFAULT_SCROLL_AMOUNT: u32 = 3;
const MAX_WAIT_SECS: f64 = 10.0;
const MODIFIERS: [&str; 4] = ["cmd", "ctrl", "alt", "shift"];
static ACTION_ENTRIES: LazyLock<Vec<Value>> = LazyLock::new(|| {
vec![
super::action_entry_schema(
"observe",
"Read the tree with element refs.",
&[],
&json!({
"target": {"type": "string"}
}),
),
super::action_entry_schema(
"screenshot",
"Capture a surface as an image.",
&[],
&json!({
"target": {"type": "string"}
}),
),
super::action_entry_schema(
"zoom",
"Capture a crop; [x0, y0, x1, y1] normalized.",
&["region"],
&json!({
"target": {"type": "string"},
"region": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "number"}}
}),
),
super::action_entry_schema("apps", "List applications as a1 ids.", &[], &json!({})),
super::action_entry_schema(
"windows",
"List windows as w1 ids, optionally per app.",
&[],
&json!({
"app": {"type": "string"}
}),
),
super::action_entry_schema(
"click",
"Click a ref, or a point by x and y; button, double and modifiers apply to a \
point only.",
&[],
&json!({
"target": {"type": "string"},
"ref": {"type": "string"},
"x": {"type": "number"},
"y": {"type": "number"},
"button": {"type": "string", "enum": ["left", "right", "middle"]},
"double": {"type": "boolean"},
"modifiers": {"type": "array", "maxItems": MODIFIERS.len(), "items": {"enum": MODIFIERS}}
}),
),
super::action_entry_schema(
"type",
"Set an element's value, or type into focus.",
&["text"],
&json!({
"text": {"type": "string"},
"target": {"type": "string"},
"ref": {"type": "string"}
}),
),
super::action_entry_schema(
"press",
"Press a key chord, e.g. \"ctrl+c\".",
&["keys"],
&json!({
"keys": {"type": "string"}
}),
),
super::action_entry_schema(
"scroll",
"Scroll, optionally at a point.",
&["direction"],
&json!({
"direction": {"type": "string", "enum": ["up", "down", "left", "right"]},
"amount": {"type": "integer", "minimum": 1, "maximum": core::MAX_SCROLL_AMOUNT},
"target": {"type": "string"},
"x": {"type": "number"},
"y": {"type": "number"}
}),
),
super::action_entry_schema(
"drag",
"Drag between two normalized points.",
&["from", "to"],
&json!({
"from": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}},
"to": {"type": "array", "minItems": 2, "maxItems": 2, "items": {"type": "number"}},
"target": {"type": "string"}
}),
),
super::action_entry_schema("cursor", "Report the pointer position.", &[], &json!({})),
super::action_entry_schema(
"wait",
"Pause before the next action.",
&["seconds"],
&json!({
"seconds": {"type": "number", "exclusiveMinimum": 0, "maximum": MAX_WAIT_SECS}
}),
),
]
});
fn check_action_fields(action_value: &Value) -> anyhow::Result<()> {
let Some((name, payload)) = action_value.as_object().and_then(|o| o.iter().next()) else {
return Ok(());
};
let Some(payload) = payload.as_object() else {
return Ok(());
};
let Some(entry) = ACTION_ENTRIES
.iter()
.find(|entry| entry["properties"].get(name.as_str()).is_some())
else {
return Ok(());
};
let accepted = entry["properties"][name.as_str()]["properties"]
.as_object()
.map(|props| props.keys().map(String::as_str).collect::<Vec<_>>())
.unwrap_or_default();
let Some(extra) = payload.keys().find(|key| !accepted.contains(&key.as_str())) else {
return Ok(());
};
anyhow::bail!(
"{name} does not take '{extra}' — its arguments are: {}",
if accepted.is_empty() {
"none".to_string()
} else {
accepted.join(", ")
}
)
}
fn validate_action(action: &ComputerAction) -> anyhow::Result<()> {
match action {
ComputerAction::Observe { target } | ComputerAction::Screenshot { target } => {
check_opt_arg("target", target.as_deref())?;
}
ComputerAction::Zoom { target, region } => {
check_opt_arg("target", target.as_deref())?;
core::check_region(region)?;
}
ComputerAction::Apps {} | ComputerAction::Cursor {} => {}
ComputerAction::Windows { app } => check_opt_arg("app", app.as_deref())?,
ComputerAction::Click {
target,
reference,
x,
y,
button,
double,
modifiers,
} => {
check_opt_arg("target", target.as_deref())?;
check_opt_arg("ref", reference.as_deref())?;
match (reference, x, y) {
(Some(reference), None, None) => {
if modifiers.as_ref().is_some_and(|m| !m.is_empty())
|| button.is_some_and(|b| b != MouseButton::Left)
|| double.is_some_and(|d| d)
{
anyhow::bail!(
"click on element {reference} takes no button, double or modifiers \
beyond the plain left click — those belong to coordinate clicks; \
drop them, or drop ref and give x and y to click a point"
);
}
}
(Some(_), _, _) => anyhow::bail!(
"click takes a ref or x/y, never both — drop x and y to click the element, \
or drop ref to click the point"
),
(None, Some(x), Some(y)) => {
core::check_point("click point", *x, *y)?;
check_modifiers(modifiers.as_deref())?;
}
(None, x, y) => anyhow::bail!(
"click needs a ref or both x and y — got {}",
match (x, y) {
(Some(_), None) => "x alone",
(None, Some(_)) => "y alone",
_ => "neither",
}
),
}
}
ComputerAction::Type {
text,
target,
reference,
} => {
core::check_arg_len("text", text)?;
check_opt_arg("target", target.as_deref())?;
check_opt_arg("ref", reference.as_deref())?;
}
ComputerAction::Press { keys } => core::check_arg_len("keys", keys)?,
ComputerAction::Scroll {
amount,
target,
x,
y,
..
} => {
core::check_scroll_amount(amount.unwrap_or(DEFAULT_SCROLL_AMOUNT))?;
check_opt_arg("target", target.as_deref())?;
if coord_pair(*x, *y, "scroll anchor")?.is_none() && target.is_some() {
anyhow::bail!(
"scroll without x and y does not address a surface — the wheel goes to \
whatever is under the pointer; pass x and y to scroll the surface you \
name, or drop target"
);
}
}
ComputerAction::Drag { from, to, target } => {
core::check_point("drag start", from.0, from.1)?;
core::check_point("drag end", to.0, to.1)?;
check_opt_arg("target", target.as_deref())?;
}
ComputerAction::Wait { seconds } => {
if !seconds.is_finite() || *seconds <= 0.0 || *seconds > MAX_WAIT_SECS {
anyhow::bail!("wait seconds must be in (0, {MAX_WAIT_SECS}] — got {seconds}");
}
}
}
Ok(())
}
fn coord_pair(x: Option<f64>, y: Option<f64>, what: &str) -> anyhow::Result<Option<(f64, f64)>> {
match (x, y) {
(Some(x), Some(y)) => {
core::check_point(what, x, y)?;
Ok(Some((x, y)))
}
(None, None) => Ok(None),
(Some(_), None) | (None, Some(_)) => {
anyhow::bail!("{what} needs both x and y, or neither")
}
}
}
fn check_opt_arg(field: &str, value: Option<&str>) -> anyhow::Result<()> {
value.map_or(Ok(()), |value| core::check_arg_len(field, value))
}
fn check_modifiers(modifiers: Option<&[core::Modifier]>) -> anyhow::Result<()> {
let Some(modifiers) = modifiers else {
return Ok(());
};
if modifiers.len() > MODIFIERS.len() {
anyhow::bail!(
"click modifiers take at most {} entries, one for each of {} — got {}",
MODIFIERS.len(),
MODIFIERS.join(", "),
modifiers.len()
);
}
Ok(())
}
fn corrective_action_error(received: &Value) -> String {
format!(
"Invalid computer action arguments. Expected action to be {EXPECTED_ACTION_SHAPE}. \
Received: {received}"
)
}
fn normalize_action(action: Value, args: &Value) -> Result<(Value, Option<String>), String> {
match action {
Value::Object(map) if map.len() == 1 => {
let (name, inner) = map.into_iter().next().expect("len == 1");
if !ACTION_WIRE_NAMES.iter().any(|n| n == &name) {
return Err(corrective_action_error(&json!({name: inner})));
}
if let Some(extra) = args
.as_object()
.and_then(|o| o.keys().find(|k| k.as_str() != "action"))
{
return Err(format!(
"unexpected argument '{extra}' beside the action — send the action and \
nothing else at the top level"
));
}
match inner {
Value::Object(o) => Ok((json!({name: o}), None)),
_ => Err(corrective_action_error(&json!({name: inner}))),
}
}
Value::String(s) => {
let s = s.trim();
if s.starts_with('{') {
let parsed: Value = serde_json::from_str(s)
.map_err(|_| corrective_action_error(&Value::String(s.to_string())))?;
return normalize_action(parsed, args);
}
if ACTION_WIRE_NAMES.iter().any(|n| n == s) {
return build_action_from_siblings(s, args);
}
Err(corrective_action_error(&Value::String(s.to_string())))
}
other => Err(corrective_action_error(&other)),
}
}
fn build_action_from_siblings(name: &str, args: &Value) -> Result<(Value, Option<String>), String> {
let Some(obj) = args.as_object() else {
return Err(corrective_action_error(args));
};
let mut siblings = serde_json::Map::new();
for (k, v) in obj {
if k == "action" {
continue;
}
siblings.insert(k.clone(), v.clone());
}
if let Some(payload) = siblings.remove(name) {
let note = format!("sibling '{name}' object used as action payload");
return match payload {
Value::Object(o) => {
if let Some(extra) = siblings.keys().next() {
return Err(format!(
"unexpected argument '{extra}' beside the '{name}' payload — send the \
payload or the flattened fields, not both"
));
}
Ok((json!({name: o}), Some(note)))
}
_ => Err(corrective_action_error(&Value::Object(siblings))),
};
}
Ok((
json!({name: siblings}),
Some(format!("flattened fields wrapped into {name} action")),
))
}
fn backend() -> &'static dyn Backend {
#[cfg(target_os = "linux")]
{
&linux::LINUX_BACKEND
}
#[cfg(target_os = "macos")]
{
&macos::MACOS_BACKEND
}
#[cfg(target_os = "windows")]
{
&windows::WINDOWS_BACKEND
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
&stub::STUB_BACKEND
}
}
pub(crate) fn is_advertised() -> bool {
backend().is_supported()
}
type CaptureProbe = (Instant, bool);
static CAPTURE_CACHE: LazyLock<std::sync::Mutex<Option<CaptureProbe>>> =
LazyLock::new(|| std::sync::Mutex::new(None));
const CAPTURE_PROBE_TTL: Duration = Duration::from_secs(60);
pub async fn warm_capture_probe() {
let ok = backend().capture_available().await;
set_capture_cache(ok);
}
fn set_capture_cache(ok: bool) {
let mut guard = CAPTURE_CACHE.lock().unwrap_poison();
*guard = Some((Instant::now(), ok));
}
pub(crate) fn cached_capture_available() -> Option<bool> {
let guard = CAPTURE_CACHE.lock().unwrap_poison();
let (ts, ok) = guard.as_ref()?;
if ts.elapsed() > CAPTURE_PROBE_TTL {
return None;
}
Some(*ok)
}
pub(crate) fn capture_known_available() -> bool {
cached_capture_available() == Some(true)
}
static ACTION_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
static CAPTURES: LazyLock<std::sync::Mutex<HashMap<String, CaptureRecord>>> =
LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
#[derive(Clone)]
struct CaptureRecord {
path: PathBuf,
width: u32,
height: u32,
}
pub(crate) fn cleanup_agent_state(agent_key: &str) {
CAPTURES.lock().unwrap_poison().remove(agent_key);
core::clear_agent_state(agent_key);
#[cfg(target_os = "macos")]
macos::forget_agent_windows(agent_key);
}
const PLATFORM_DESCRIPTION: Option<&str> = if cfg!(target_os = "linux") {
Some("tool/computer_linux.md")
} else if cfg!(target_os = "macos") {
Some("tool/computer_macos.md")
} else if cfg!(target_os = "windows") {
Some("tool/computer_windows.md")
} else {
None
};
#[derive(Default)]
pub(crate) struct ComputerTool;
#[async_trait]
impl Tool for ComputerTool {
fn name(&self) -> &'static str {
"computer"
}
fn description(&self) -> String {
let mut sections = vec![crate::prompt::load_prompt("tool/computer.md")];
if let Some(asset) = PLATFORM_DESCRIPTION {
sections.push(crate::prompt::load_prompt(asset));
}
sections
.iter()
.map(|s| s.trim_end())
.collect::<Vec<_>>()
.join("\n\n")
}
fn should_scrub_output(&self, _args: &serde_json::Value) -> bool {
false
}
fn is_advertised(&self) -> bool {
self::is_advertised()
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"description": format!(
"One action per call; each string argument is capped at {} characters.",
core::MAX_ARG_CHARS
),
"oneOf": Value::Array(ACTION_ENTRIES.clone()),
}
},
"required": ["action"],
})
}
async fn execute(&self, _ws: &Workspace, args: Value) -> anyhow::Result<String> {
let action_value = args
.get("action")
.cloned()
.ok_or_else(|| anyhow::anyhow!(corrective_action_error(&args)))?;
let (action_value, normalized_note) =
normalize_action(action_value, &args).map_err(anyhow::Error::msg)?;
if let Some(note) = normalized_note {
super::log_normalization_note("computer", ¬e);
}
check_action_fields(&action_value)?;
let action: ComputerAction = serde_json::from_value(action_value).map_err(|e| {
anyhow::anyhow!(
"Invalid computer action arguments. Expected action to be {EXPECTED_ACTION_SHAPE}. \
Serde error: {e}"
)
})?;
validate_action(&action)?;
debug!(action = ?action, "computer action");
if needs_desktop(&action) {
ensure_interactive_desktop().await?;
}
let _lock = ACTION_LOCK.lock().await;
let output = dispatch(&action)
.await
.map_err(|e| framework_next_step(&action, e))?;
Ok(output)
}
async fn execute_with_payloads(
&self,
ws: &crate::Workspace,
args: serde_json::Value,
) -> anyhow::Result<crate::tools::ToolOutput> {
let text = self.execute(ws, args.clone()).await?;
let (text, image_payloads) = match captured_payload(&args).await {
Ok(Some(payload)) => (text, vec![payload]),
Ok(None) => (text, Vec::new()),
Err(e) => (
format!("{text}\nno image could be attached to this result: {e}"),
Vec::new(),
),
};
Ok(crate::tools::ToolOutput {
text,
image_payloads,
text_is_content: true,
})
}
}
async fn captured_payload(
args: &serde_json::Value,
) -> anyhow::Result<Option<crate::tools::ImagePayload>> {
let Some(action_value) = args.get("action").cloned() else {
return Ok(None);
};
let Ok((action_value, _)) = normalize_action(action_value, args) else {
return Ok(None);
};
let Ok(action) = serde_json::from_value::<ComputerAction>(action_value) else {
return Ok(None);
};
if !matches!(
action,
ComputerAction::Screenshot { .. } | ComputerAction::Zoom { .. }
) {
return Ok(None);
}
let Some(record) = CAPTURES
.lock()
.unwrap_poison()
.get(&core::agent_key())
.cloned()
else {
return Ok(None);
};
let meta = crate::util::local_image_to_compressed_data_uri_with_meta(&record.path).await?;
let mut payload = crate::tools::ImagePayload::from_compressed_meta(
&record.path,
meta,
None,
crate::tools::ImagePayloadSource::Computer,
);
let longest = record.width.max(record.height);
if longest > crate::util::INBOUND_IMAGE_MAX_SIDE {
payload.note = Some(format!(
"reduced from the captured {}x{} to {}x{} — an attached image is capped at {} px on \
its long edge",
record.width,
record.height,
payload.width,
payload.height,
crate::util::INBOUND_IMAGE_MAX_SIDE
));
}
Ok(Some(payload))
}
fn needs_desktop(action: &ComputerAction) -> bool {
!matches!(
action,
ComputerAction::Apps {} | ComputerAction::Windows { .. } | ComputerAction::Wait { .. }
)
}
async fn ensure_interactive_desktop() -> anyhow::Result<()> {
let Some(reason) = backend().desktop_unavailable().await else {
return Ok(());
};
Err(core::taxonomy_error(
core::ERR_DESKTOP_UNAVAILABLE,
format!(
"{reason} — this call needs an interactive desktop, so nothing was delivered or read; \
tell the owner and run it again only once the session is back. Listing apps and \
windows, and wait, still work"
),
))
}
fn framework_next_step(action: &ComputerAction, err: anyhow::Error) -> anyhow::Error {
if !core::is_framework_failed(&err) {
return err;
}
let next = match action {
ComputerAction::Apps {} | ComputerAction::Windows { .. } | ComputerAction::Cursor {} => {
"retry it once, and report it if it repeats"
}
ComputerAction::Screenshot { .. } | ComputerAction::Zoom { .. } => {
"re-observe the target if it is a window, then take another route or report it"
}
_ => {
if capture_known_available() {
"re-observe or screenshot to check the target's state, then take another route \
or report it"
} else {
"re-observe to check the target's state, then take another route or report it"
}
}
};
anyhow::anyhow!("{err} — the call was abandoned, so {next}")
}
async fn dispatch(action: &ComputerAction) -> anyhow::Result<String> {
let output = match action {
ComputerAction::Apps {} => apps().await?,
ComputerAction::Windows { app } => windows(app.as_deref()).await?,
ComputerAction::Observe { target } => return observe(target.as_deref()).await,
ComputerAction::Screenshot { target } => capture(target.as_deref(), None).await?,
ComputerAction::Zoom { target, region } => capture(target.as_deref(), Some(region)).await?,
ComputerAction::Click {
target,
reference,
x,
y,
button,
double,
modifiers,
} => {
click(
target.as_deref(),
reference.as_deref(),
*x,
*y,
*button,
*double,
modifiers.as_deref(),
)
.await?
}
ComputerAction::Type {
text,
target,
reference,
} => type_text(text, target.as_deref(), reference.as_deref()).await?,
ComputerAction::Press { keys } => {
let impact = backend()
.raw_input(
&TargetSpec::Focused,
core::RawInput::KeyChord {
chord: keys.clone(),
},
)
.await?;
core::impact_text(
format!("pressed chord: {keys} — a chord goes to the focused application"),
impact,
)
}
ComputerAction::Scroll {
direction,
amount,
target,
x,
y,
} => scroll(*direction, *amount, target.as_deref(), *x, *y).await?,
ComputerAction::Drag { from, to, target } => {
let target = resolve_action_target(target.as_deref())?;
let gesture = core::RawInput::Drag {
from: *from,
to: *to,
};
let impact = backend().raw_input(&target, gesture.clone()).await?;
core::impact_text(
format!(
"dragged from {from:?} to {to:?}{}{}",
on_surface(&target),
unpinned_clause(&target, &gesture)
),
impact,
)
}
ComputerAction::Cursor {} => cursor().await?,
ComputerAction::Wait { seconds } => {
tokio::time::sleep(Duration::from_secs_f64(*seconds)).await;
format!("waited {seconds}s")
}
};
Ok(crate::util::scrub_credentials(&output))
}
fn on_surface(target: &TargetSpec) -> String {
match target {
TargetSpec::Focused => String::new(),
other => format!(" on {}", other.describe()),
}
}
fn unpinned_clause(target: &TargetSpec, input: &core::RawInput) -> &'static str {
if core::pinned_input(target, input) {
""
} else {
" — the input goes to whatever is under its coordinates"
}
}
fn resolve_action_target(target: Option<&str>) -> anyhow::Result<TargetSpec> {
if let Some(t) = target {
return core::resolve_target(Some(t));
}
Ok(core::last_surface().unwrap_or(TargetSpec::Focused))
}
async fn resolve_surface(target: Option<&str>) -> anyhow::Result<TargetSpec> {
match resolve_action_target(target)? {
TargetSpec::Focused => {
let win = backend().focused_window().await?;
Ok(TargetSpec::from_window(&win))
}
other => Ok(other),
}
}
async fn apps() -> anyhow::Result<String> {
let apps = backend().list_apps().await?;
let surfaces = apps
.iter()
.map(|app| TargetSpec::App {
app_pid: app.pid,
app_name: app.name.clone(),
})
.collect::<Vec<_>>();
let ids = core::store_app_targets(&surfaces);
let mut lines = Vec::with_capacity(apps.len());
for (app, id) in apps.iter().zip(&ids) {
let pid = app.pid.map_or_else(|| "?".to_string(), |p| p.to_string());
lines.push(format!("[{id}] {} (pid {pid})", app.name));
}
if lines.is_empty() {
Ok("no apps found".to_string())
} else {
Ok(lines.join("\n"))
}
}
async fn windows(app: Option<&str>) -> anyhow::Result<String> {
let app_info = match app {
Some(arg) => Some(resolve_app(arg).await?),
None => None,
};
let listing = backend().list_windows(app_info.as_ref()).await?;
let surfaces = listing
.windows
.iter()
.map(TargetSpec::from_window)
.collect::<Vec<_>>();
let ids = core::store_window_targets(&surfaces);
Ok(render_windows(&listing, &ids))
}
fn render_windows(listing: &core::WindowListing, ids: &[String]) -> String {
let mut lines = listing
.windows
.iter()
.zip(ids)
.map(|(win, id)| {
format!(
"[{id}] {} — \"{}\" {}x{} @ ({}, {})",
win.app_name,
win.title,
win.surface.width,
win.surface.height,
win.surface.x,
win.surface.y
)
})
.collect::<Vec<_>>();
if lines.is_empty() {
lines.push(
if listing.unread.is_empty() {
"no windows found"
} else {
"no windows could be read"
}
.to_string(),
);
}
lines.extend(listing.unread.iter().map(|unread| {
format!(
"windows not listed for {}: {}",
unread.app_name, unread.reason
)
}));
lines.join("\n")
}
async fn resolve_app(arg: &str) -> anyhow::Result<AppInfo> {
let trimmed = arg.trim();
if trimmed == "screen" {
anyhow::bail!("'screen' is a surface, not an app — use the 'apps' action to list apps");
}
if let Some((pid, name)) = core::get_target(trimmed)
.and_then(|target| target.app().map(|(pid, name)| (pid, name.to_string())))
{
return Ok(AppInfo { pid, name });
}
let apps = backend().list_apps().await?;
if let Ok(pid) = trimmed.parse::<u32>() {
return apps
.iter()
.find(|a| a.pid == Some(pid))
.cloned()
.ok_or_else(|| no_app_error(trimmed, &apps));
}
if let Some(app) = apps.iter().find(|a| a.name.eq_ignore_ascii_case(trimmed)) {
return Ok(app.clone());
}
Err(no_app_error(trimmed, &apps))
}
fn no_app_error(name: &str, apps: &[AppInfo]) -> anyhow::Error {
if apps.is_empty() {
anyhow::anyhow!(
"no running applications found — start '{name}' or list apps with the 'apps' action"
)
} else {
let candidates = apps
.iter()
.take(3)
.map(|a| a.name.as_str())
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!(
"no running application matches '{name}' — running apps: {candidates}; use the \
'apps' action for exact ids"
)
}
}
fn ensure_ref_target_match(
reference: &str,
target: Option<&str>,
cache_target: &TargetSpec,
) -> anyhow::Result<()> {
let Some(t) = target else {
return Ok(());
};
let given = core::resolve_target(Some(t))?;
if given != *cache_target {
anyhow::bail!(
"ref {reference} belongs to the surface observed for {} — drop the target argument \
or re-observe the target you want",
cache_target.describe()
);
}
Ok(())
}
async fn observe(target: Option<&str>) -> anyhow::Result<String> {
let target = resolve_surface(target).await?;
let obs = backend().observe(&target).await?;
core::set_last_surface(target.clone());
core::store_observation(target, &obs);
let mut output = core::render_tree(&obs);
if core::is_ax_thin(&obs) {
output.push_str("surface exposes few actionable elements — ");
output.push_str(if capture_known_available() {
"use screenshot"
} else {
"act on the references it lists, or use apps/windows for another surface"
});
output.push('\n');
}
Ok(crate::tools::shell::try_spill_to_file(
crate::util::scrub_credentials(&output),
TOOL_OUTPUT_BUDGET_BYTES,
))
}
async fn click(
target: Option<&str>,
reference: Option<&str>,
x: Option<f64>,
y: Option<f64>,
button: Option<MouseButton>,
double: Option<bool>,
modifiers: Option<&[core::Modifier]>,
) -> anyhow::Result<String> {
if let Some(reference) = reference {
let (locator, cache_target) = core::resolve_ref(reference)?;
ensure_ref_target_match(reference, target, &cache_target)?;
let impact = backend()
.act_on_element(&cache_target, &locator, ElementAct::Press)
.await?;
return Ok(core::impact_text(
format!("clicked element {reference}{}", on_surface(&cache_target)),
impact,
));
}
let Some((x, y)) = coord_pair(x, y, "click point")? else {
anyhow::bail!("click requires an element ref (preferred) or normalized x/y coordinates");
};
let target = resolve_action_target(target)?;
let input = core::RawInput::Click {
point: (x, y),
button: button.unwrap_or(MouseButton::Left),
double: double.unwrap_or(false),
modifiers: modifiers.unwrap_or_default().to_vec(),
};
let impact = backend().raw_input(&target, input.clone()).await?;
Ok(core::impact_text(
format!(
"clicked at ({x}, {y}){}{}",
on_surface(&target),
unpinned_clause(&target, &input)
),
impact,
))
}
async fn type_text(
text: &str,
target: Option<&str>,
reference: Option<&str>,
) -> anyhow::Result<String> {
if let Some(reference) = reference {
let (locator, cache_target) = core::resolve_ref(reference)?;
ensure_ref_target_match(reference, target, &cache_target)?;
let impact = backend()
.act_on_element(
&cache_target,
&locator,
ElementAct::SetValue {
text: text.to_string(),
},
)
.await?;
return Ok(core::impact_text(
format!(
"set value on element {reference} in {}",
cache_target.describe()
),
impact,
));
}
let target = resolve_action_target(target)?;
let input = core::RawInput::TypeText {
text: text.to_string(),
};
let impact = backend().raw_input(&target, input.clone()).await?;
Ok(core::impact_text(
format!(
"typed text ({} chars){}",
text.chars().count(),
if core::pinned_input(&target, &input) {
on_surface(&target)
} else {
" — the text goes to the focused element".to_string()
}
),
impact,
))
}
async fn scroll(
direction: ScrollDirection,
amount: Option<u32>,
target: Option<&str>,
x: Option<f64>,
y: Option<f64>,
) -> anyhow::Result<String> {
let amount = amount.unwrap_or(DEFAULT_SCROLL_AMOUNT);
let Some(point) = coord_pair(x, y, "scroll anchor")? else {
let impact = backend()
.raw_input(
&TargetSpec::Focused,
core::RawInput::Scroll {
point: None,
direction,
amount,
},
)
.await?;
return Ok(core::impact_text(
format!(
"scrolled {amount} ticks {direction:?} — a point-less wheel goes to whatever is \
under the pointer"
),
impact,
));
};
let target = resolve_action_target(target)?;
let input = core::RawInput::Scroll {
point: Some(point),
direction,
amount,
};
let impact = backend().raw_input(&target, input.clone()).await?;
Ok(core::impact_text(
format!(
"scrolled {amount} ticks {direction:?} at ({}, {}){}{}",
point.0,
point.1,
on_surface(&target),
unpinned_clause(&target, &input)
),
impact,
))
}
async fn cursor_surface() -> anyhow::Result<(TargetSpec, core::SurfaceGeometry)> {
let target = resolve_surface(None).await?;
let geo = backend().surface_geometry(&target).await?;
Ok((target, geo))
}
async fn cursor() -> anyhow::Result<String> {
let (ax, ay) = backend().cursor_position().await?;
let (target, geo) = match cursor_surface().await {
Ok(resolved) => resolved,
Err(e) if !core::is_permission_denied(&e) => {
return Ok(format!(
"cursor at absolute ({ax:.1}, {ay:.1}) — no surface to normalize against: {e}"
));
}
Err(e) => return Err(e),
};
if core::contains_point(&geo, ax, ay) {
let (nx, ny) = core::surface_to_normalized(ax, ay, &geo)?;
Ok(format!(
"cursor at normalized ({nx:.1}, {ny:.1}){} — absolute ({ax:.1}, {ay:.1})",
on_surface(&target)
))
} else {
Ok(format!(
"cursor outside {surface} ({ax:.1}, {ay:.1}) — surface bounds x:[{:.1}, {:.1}] \
y:[{:.1}, {:.1}]",
geo.x,
geo.x + geo.width,
geo.y,
geo.y + geo.height,
surface = target.describe()
))
}
}
async fn capture(target: Option<&str>, region: Option<&Region>) -> anyhow::Result<String> {
let target = resolve_surface(target).await?;
let budget = core::CaptureBudget::default();
let capture_result = backend().capture(&target, &budget).await;
let cap = match capture_result {
Ok(cap) => {
set_capture_cache(true);
cap
}
Err(e) => {
if cached_capture_available().is_none() {
let ok = backend().capture_available().await;
set_capture_cache(ok);
}
if cached_capture_available() == Some(false) {
return Err(backend().capture_unavailable_error());
}
return Err(e);
}
};
let (cropped, rect_opt) = match region {
Some(region) => {
let rect = core::region_to_pixels(region, cap.width, cap.height)?;
(
core::crop_rgba(&cap, rect, &budget, "the zoom crop")?,
Some(rect),
)
}
None => (cap, None),
};
let (cw, ch) = (cropped.width, cropped.height);
let path = encode_png(cropped)?;
core::set_last_surface(target.clone());
CAPTURES.lock().unwrap_poison().insert(
core::agent_key(),
CaptureRecord {
path: path.clone(),
width: cw,
height: ch,
},
);
let kind = if rect_opt.is_some() {
"zoom"
} else {
"screenshot"
};
let rect_note = rect_opt.map_or_else(String::new, |(x, y, w, h)| {
format!(" (cropped rect {x},{y},{w},{h})")
});
let summary = format!(
"{kind} of {}: {cw}x{ch} — {}{rect_note}",
target.describe(),
path.display()
);
Ok(summary)
}
fn encode_png(cap: Capture) -> anyhow::Result<PathBuf> {
let (width, height, rgba, _claim) = cap.into_parts();
crate::util::with_block_in_place(move || {
let img = image::RgbaImage::from_raw(width, height, rgba)
.ok_or_else(|| anyhow::anyhow!("capture dimensions mismatch"))?;
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.map_err(|e| anyhow::anyhow!("failed to encode screen capture as PNG: {e}"))?;
let dir = crate::tools::shell::agent_temp_dir()
.ok_or_else(|| anyhow::anyhow!("no agent temp dir available for screenshots"))?
.join("computer");
std::fs::create_dir_all(&dir)
.with_context(|| format!("failed to create screenshot dir {}", dir.display()))?;
let nonce = rand::random::<u64>();
let path = dir.join(format!("screenshot_{nonce:016x}.png"));
crate::tools::shell::write_owned_spill(&path, &buf)
.with_context(|| format!("failed to write screenshot {}", path.display()))?;
Ok::<PathBuf, anyhow::Error>(path)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn action_payload(name: &str) -> Value {
match name {
"observe" | "screenshot" | "windows" | "apps" | "cursor" => json!({}),
"zoom" => json!({"region": [0, 0, 1, 1]}),
"click" => json!({"x": 1, "y": 1}),
"type" => json!({"text": "x"}),
"press" => json!({"keys": "a"}),
"scroll" => json!({"direction": "down"}),
"drag" => json!({"from": [0, 0], "to": [1, 1]}),
"wait" => json!({"seconds": 1}),
other => panic!("no lockstep payload for {other}"),
}
}
fn unread_explorer() -> core::UnreadWindows {
core::UnreadWindows {
app_name: "Explorer".to_string(),
reason: "one read timed out".to_string(),
}
}
#[test]
fn window_listing_puts_each_unread_note_on_its_own_line() {
let listing = core::WindowListing {
windows: vec![core::WindowInfo {
app_pid: Some(10),
app_name: "Chrome".to_string(),
title: "Start".to_string(),
key: core::WindowKey::new("k"),
surface: core::SurfaceGeometry {
x: 0.0,
y: 0.0,
width: 800.0,
height: 600.0,
},
}],
unread: vec![unread_explorer()],
};
assert_eq!(
render_windows(&listing, &["w1".to_string()]),
"[w1] Chrome — \"Start\" 800x600 @ (0, 0)\n\
windows not listed for Explorer: one read timed out"
);
let nothing = core::WindowListing {
windows: Vec::new(),
unread: vec![unread_explorer()],
};
assert_eq!(
render_windows(¬hing, &[]),
"no windows could be read\nwindows not listed for Explorer: one read timed out"
);
}
#[expect(clippy::needless_pass_by_value)] fn parsed_action(name: &str, payload: Value) -> ComputerAction {
let args = json!({"action": {name: payload}});
let (normalized, _) = normalize_action(args["action"].clone(), &args)
.unwrap_or_else(|e| panic!("'{name}' should normalize: {e}"));
serde_json::from_value(normalized).unwrap_or_else(|e| panic!("'{name}' should parse: {e}"))
}
fn refusal(action: &ComputerAction) -> String {
validate_action(action)
.expect_err("the action should be refused")
.to_string()
}
#[test]
fn an_argument_outside_its_range_is_refused_with_the_range() {
let err = refusal(&parsed_action(
"scroll",
json!({"direction": "down", "amount": core::MAX_SCROLL_AMOUNT + 1}),
));
assert!(
err.contains(&format!("1-{}", core::MAX_SCROLL_AMOUNT)),
"{err}"
);
assert!(
validate_action(&parsed_action(
"scroll",
json!({"direction": "down", "amount": 0})
))
.is_err()
);
assert!(validate_action(&parsed_action("scroll", json!({"direction": "down"}))).is_ok());
let err = refusal(&parsed_action(
"type",
json!({"text": "Z".repeat(core::MAX_ARG_CHARS + 1)}),
));
assert!(
err.contains(&format!("max {}", core::MAX_ARG_CHARS)),
"{err}"
);
assert!(
!err.contains('Z'),
"the refused text is counted, never echoed: {err}"
);
assert!(validate_action(&parsed_action("type", json!({"text": "ok"}))).is_ok());
for seconds in [json!(0), json!(-1), json!(MAX_WAIT_SECS + 1.0)] {
assert!(
validate_action(&parsed_action("wait", json!({ "seconds": seconds }))).is_err()
);
}
assert!(validate_action(&parsed_action("wait", json!({"seconds": 1}))).is_ok());
assert!(
validate_action(&parsed_action(
"drag",
json!({"from": [0, core::NORMALIZED_MAX + 1.0], "to": [1, 1]})
))
.is_err()
);
assert!(
validate_action(&parsed_action(
"zoom",
json!({"region": [0, 0, core::NORMALIZED_MAX + 1.0, 10]})
))
.is_err()
);
assert!(
validate_action(&parsed_action(
"zoom",
json!({"region": [10, 10, 1000, 10]})
))
.is_err()
);
assert!(
validate_action(&parsed_action(
"zoom",
json!({"region": [1000, 1000, 0, 0]})
))
.is_ok()
);
assert!(
validate_action(&parsed_action(
"click",
json!({"x": 1, "y": 1, "modifiers": ["cmd", "cmd", "ctrl", "alt", "shift"]})
))
.is_err()
);
assert!(
validate_action(&parsed_action(
"click",
json!({"x": 1, "y": 1, "modifiers": ["cmd", "shift"]})
))
.is_ok()
);
}
#[test]
fn an_argument_the_action_cannot_honour_is_refused() {
let err = {
let args = json!({"action": {"press": {"keys": "cmd+t", "target": "screen"}}});
let (normalized, _) = normalize_action(args["action"].clone(), &args).unwrap();
check_action_fields(&normalized).unwrap_err().to_string()
};
assert!(
err.contains("does not take 'target'") && err.contains("keys"),
"{err}"
);
let err = refusal(&parsed_action(
"click",
json!({"ref": "e3", "x": 10, "y": 10}),
));
assert!(err.contains("never both"), "{err}");
assert!(
validate_action(&parsed_action(
"click",
json!({"ref": "e3", "double": true})
))
.is_err()
);
assert!(
validate_action(&parsed_action(
"click",
json!({"ref": "e3", "button": "left", "double": false, "modifiers": []})
))
.is_ok()
);
assert!(validate_action(&parsed_action("click", json!({"x": 5}))).is_err());
assert!(
validate_action(&parsed_action(
"scroll",
json!({"direction": "down", "x": 5})
))
.is_err()
);
assert!(
validate_action(&parsed_action(
"scroll",
json!({"direction": "down", "target": "a1"})
))
.is_err()
);
let flattened = json!({"action": "click", "x": 1, "y": 1, "text": "hi"});
let (normalized, note) = normalize_action(flattened["action"].clone(), &flattened).unwrap();
assert!(
note.is_some(),
"the flattened form is still tolerated with its note"
);
assert!(check_action_fields(&normalized).is_err());
let tagged = json!({"action": {"click": {"x": 1, "y": 1}}, "foo": 2});
assert!(normalize_action(tagged["action"].clone(), &tagged).is_err());
}
#[test]
fn parameters_schema_enum_lockstep() {
let schema = ComputerTool.parameters_schema();
let action_schemas = schema["properties"]["action"]["oneOf"]
.as_array()
.expect("oneOf should be an array");
let enum_names: Vec<&str> = ACTION_WIRE_NAMES.iter().map(String::as_str).collect();
let one_of_names: Vec<&str> = action_schemas
.iter()
.filter_map(|s| {
s.get("properties")
.and_then(|p| p.as_object())
.and_then(|props| props.keys().next())
.map(String::as_str)
})
.collect();
assert_eq!(
enum_names.len(),
one_of_names.len(),
"oneOf count differs from the enum variant count"
);
for name in &enum_names {
assert!(
one_of_names.contains(name),
"oneOf entry is missing '{name}'"
);
}
for name in &one_of_names {
assert!(
enum_names.contains(name),
"oneOf has an entry '{name}' that is not an enum variant"
);
}
for action_schema in action_schemas {
let Some((name, inner)) = action_schema["properties"]
.as_object()
.and_then(|props| props.iter().next())
else {
panic!("every schema entry names its action");
};
let declared = inner["properties"]
.as_object()
.expect("every action entry declares its arguments")
.keys()
.cloned()
.collect::<std::collections::BTreeSet<_>>();
let payload = action_payload(name);
let wrapped = json!({name.as_str(): payload});
let action: ComputerAction =
serde_json::from_value(wrapped).expect("the schema payload deserializes");
let carried = match serde_json::to_value(&action).expect("a parsed action serializes") {
Value::Object(mut map) => map
.remove(name.as_str())
.and_then(|inner| {
inner
.as_object()
.map(|fields| fields.keys().cloned().collect())
})
.unwrap_or_default(),
other => panic!("an action serializes to one tag, not {other}"),
};
assert_eq!(
declared, carried,
"'{name}': the schema declares {declared:?} but the variant carries {carried:?}"
);
}
for name in &enum_names {
let payload = action_payload(name);
let wrapped_args = json!({"action": {*name: payload.clone()}});
let (wrapped_norm, _) = normalize_action(wrapped_args["action"].clone(), &wrapped_args)
.unwrap_or_else(|_| panic!("wrapped '{name}' should normalize"));
let mut bare_args = json!({"action": *name});
if let Value::Object(payload_obj) = &payload {
bare_args
.as_object_mut()
.expect("bare args is an object")
.extend(payload_obj.clone());
}
let (bare_norm, _) = normalize_action(bare_args["action"].clone(), &bare_args)
.unwrap_or_else(|_| panic!("bare '{name}' should normalize"));
for normalized in [&wrapped_norm, &bare_norm] {
let action: ComputerAction = serde_json::from_value(normalized.clone())
.unwrap_or_else(|e| {
panic!("normalized payload does not deserialize: {normalized} — {e}")
});
assert_eq!(
wire_name(&action),
*name,
"deserialized variant wire name does not match '{name}'"
);
}
}
}
}