lazyBoy/crates/api/src/tools.rs

873 lines
29 KiB
Rust
Raw Normal View History

2026-09-03 12:46:14 +00:00
use std::sync::Mutex;
2026-09-04 09:08:56 +00:00
use lazyboy_contracts::{
ComputerAction, ComputerMode, ComputerObservation, PointerType, UiElement,
};
2026-09-03 12:46:14 +00:00
use lazyboy_control::{
2026-09-04 09:08:56 +00:00
ActionError, ActionRequest, AdapterContext, CdpPage, CommandRequest, ComputerRef,
SandboxProvider, apply_element_targets, cdp_command_on, format_ui_elements, frames_match,
merge_page_elements, overlay_elements, parse_cdp_page, parse_computer_actions,
resolve_bot_workspace_cwd, resolve_bot_workspace_path,
2026-09-03 12:46:14 +00:00
};
use rig_core::completion::ToolDefinition;
2026-09-04 09:08:56 +00:00
use serde_json::{Value, json};
2026-09-03 23:43:37 +00:00
use sqlx::PgPool;
use uuid::Uuid;
use crate::db::Actor;
2026-09-04 05:41:09 +00:00
use crate::mcp::McpHub;
2026-09-03 23:43:37 +00:00
use crate::memory::{CreateMemoryInput, MemoryService};
2026-09-03 12:46:14 +00:00
pub struct ToolCtx {
pub sandbox: std::sync::Arc<dyn SandboxProvider>,
pub computer: ComputerRef,
pub context: AdapterContext,
pub mode: ComputerMode,
pub bot_id: String,
pub vision: bool,
pub gui_block: Option<String>,
pub previous_frame: Mutex<Option<String>>,
2026-09-04 09:08:56 +00:00
pub elements: Mutex<Vec<UiElement>>,
pub miss_streak: Mutex<u32>,
pub click_misses: Mutex<u32>,
2026-09-03 12:46:14 +00:00
pub takeover_requested: Mutex<bool>,
2026-09-03 23:43:37 +00:00
pub pool: PgPool,
pub memory: MemoryService,
pub actor: Actor,
pub session_id: String,
pub run_id: String,
pub memory_enabled: bool,
2026-09-04 05:41:09 +00:00
pub mcp: McpHub,
2026-09-03 12:46:14 +00:00
}
2026-09-03 23:43:37 +00:00
pub fn tool_definitions(memory_enabled: bool) -> Vec<ToolDefinition> {
let mut definitions = vec![
2026-09-03 12:46:14 +00:00
ToolDefinition {
name: "computer_observe".into(),
2026-09-04 09:08:56 +00:00
description: "Capture a fresh desktop screenshot plus numbered targets. When Chromium is open this is the page DOM (click those ids); otherwise native windows. The image attaches only if the screen changed.".into(),
2026-09-03 12:46:14 +00:00
parameters: json!({"type":"object","properties":{}}),
},
ToolDefinition {
name: "computer_act".into(),
2026-09-04 09:08:56 +00:00
description: "Drive native desktop GUI (dialogs, canvas, XFCE). Prefer element id from the latest observation. For Chromium pages use the browser tool instead of clicking the window. x,y are 1280x800 fallback.".into(),
2026-09-03 12:46:14 +00:00
parameters: json!({
"type":"object",
"properties":{
"actions":{
"type":"array",
"items":{
"type":"object",
"properties":{
"kind":{"type":"string","enum":["click","move","down","up","hover","drag","type","key","scroll","wait","focus"]},
2026-09-04 09:08:56 +00:00
"element":{"type":"number"},
2026-09-03 12:46:14 +00:00
"title":{"type":"string"},
"x2":{"type":"number"},
"y2":{"type":"number"},
"x":{"type":"number"},
"y":{"type":"number"},
"text":{"type":"string"},
"key":{"type":"string"},
"modifiers":{"type":"array","items":{"type":"string"}},
"button":{"type":"string","enum":["left","right"]},
"double":{"type":"boolean"},
"direction":{"type":"string","enum":["up","down"]},
"amount":{"type":"number"},
"ms":{"type":"number"}
},
"required":["kind"]
}
},
"observe":{"type":"boolean"},
"settle_ms":{"type":"number"}
},
"required":["actions"]
}),
},
ToolDefinition {
name: "shell".into(),
description: "Run a command inside this bot's computer.".into(),
parameters: json!({
"type":"object",
"properties":{
"command":{"type":"string"},
"cwd":{"type":"string"}
},
"required":["command"]
}),
},
ToolDefinition {
name: "list_files".into(),
description: "List files in this bot's home.".into(),
parameters: json!({"type":"object","properties":{"path":{"type":"string"}}}),
},
ToolDefinition {
name: "read_file".into(),
description: "Read a UTF-8 text file from this bot's home.".into(),
parameters: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
},
ToolDefinition {
name: "write_file".into(),
description: "Write a UTF-8 file into this bot's home.".into(),
parameters: json!({
"type":"object",
"properties":{"path":{"type":"string"},"content":{"type":"string"}},
"required":["path","content"]
}),
},
ToolDefinition {
name: "open_path".into(),
description: "Open a workspace file or http(s) URL on the desktop.".into(),
parameters: json!({"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}),
},
2026-09-04 09:08:56 +00:00
ToolDefinition {
name: "browser".into(),
description: "Control Chromium through the page DOM. Prefer this over computer_act for anything in the browser. snapshot returns numbered elements and visible text (no screenshot). click/type/navigate by element id or CSS selector. The human still sees the live window.".into(),
parameters: json!({
"type":"object",
"properties":{
"action":{"type":"string","enum":["snapshot","click","type","press","navigate","wait"]},
"element":{"type":"number"},
"selector":{"type":"string"},
"text":{"type":"string"},
"key":{"type":"string"},
"url":{"type":"string"},
"ms":{"type":"number"}
},
"required":["action"]
}),
},
2026-09-03 12:46:14 +00:00
ToolDefinition {
name: "launch_app".into(),
description: "Launch browser or terminal on the desktop.".into(),
parameters: json!({
"type":"object",
"properties":{"application":{"type":"string"},"uri":{"type":"string"}},
"required":["application"]
}),
},
ToolDefinition {
name: "request_takeover".into(),
2026-09-04 09:08:56 +00:00
description: "Ask the user to take over for passwords, 2FA, CAPTCHA, login walls, or when the right on-screen control cannot be found. Never ask them to paste secrets in chat.".into(),
2026-09-03 12:46:14 +00:00
parameters: json!({"type":"object","properties":{"reason":{"type":"string"}},"required":["reason"]}),
},
2026-09-03 23:43:37 +00:00
];
if memory_enabled {
definitions.extend([
ToolDefinition {
name: "remember".into(),
description: "Explicitly save a durable user preference or fact for this agent only. Never store passwords, tokens, private keys, or other secrets.".into(),
parameters: json!({
"type":"object",
"properties":{
"content":{"type":"string"},
"importance":{"type":"number","minimum":0,"maximum":1}
},
"required":["content"]
}),
},
ToolDefinition {
name: "recall_memory".into(),
description: "Search durable memories belonging only to this agent.".into(),
parameters: json!({
"type":"object",
"properties":{"query":{"type":"string"},"limit":{"type":"integer","minimum":1,"maximum":20}},
"required":["query"]
}),
},
ToolDefinition {
name: "forget_memory".into(),
description: "Soft-delete one durable memory belonging to this agent by its ID.".into(),
parameters: json!({
"type":"object",
"properties":{"memory_id":{"type":"string","format":"uuid"}},
"required":["memory_id"]
}),
},
]);
}
definitions
2026-09-03 12:46:14 +00:00
}
pub struct ToolOutcome {
pub text: String,
pub image: Option<Vec<u8>>,
pub pause: bool,
}
pub async fn dispatch(ctx: &ToolCtx, name: &str, args: &Value) -> ToolOutcome {
match name {
"computer_observe" => observe(ctx).await,
"computer_act" => act(ctx, args).await,
2026-09-04 09:08:56 +00:00
"browser" => browser(ctx, args).await,
2026-09-03 12:46:14 +00:00
"shell" => shell(ctx, args).await,
"list_files" => list_files(ctx, args).await,
"read_file" => read_file(ctx, args).await,
"write_file" => write_file(ctx, args).await,
"open_path" => open_path(ctx, args).await,
"launch_app" => launch_app(ctx, args).await,
2026-09-03 23:43:37 +00:00
"remember" => remember(ctx, args).await,
"recall_memory" => recall_memory(ctx, args).await,
"forget_memory" => forget_memory(ctx, args).await,
2026-09-03 12:46:14 +00:00
"request_takeover" => {
*ctx.takeover_requested.lock().unwrap() = true;
ToolOutcome {
text: args
.get("reason")
.and_then(Value::as_str)
.unwrap_or("The bot asked you to take control.")
.to_string(),
image: None,
pause: true,
}
}
2026-09-04 05:41:09 +00:00
other if other.starts_with("mcp_") => match ctx.mcp.call(other, args).await {
Ok(text) => text_outcome(text),
Err(error) => text_outcome(format!("MCP 工具失敗:{error}")),
},
2026-09-03 12:46:14 +00:00
other => ToolOutcome {
text: format!("unknown tool {other}"),
image: None,
pause: false,
},
}
}
2026-09-03 23:43:37 +00:00
async fn remember(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if !ctx.memory_enabled {
return text_outcome("memory is disabled for this agent");
}
let input = CreateMemoryInput {
2026-09-04 09:08:56 +00:00
content: args
.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
importance: args
.get("importance")
.and_then(Value::as_f64)
.unwrap_or(0.5) as f32,
2026-09-03 23:43:37 +00:00
session_id: Some(ctx.session_id.clone()),
source_run_id: Some(ctx.run_id.clone()),
source_message_id: None,
};
2026-09-04 09:08:56 +00:00
match ctx
.memory
.remember(&ctx.pool, &ctx.actor, &ctx.bot_id, input)
.await
{
2026-09-03 23:43:37 +00:00
Ok(item) => text_outcome(json!({"ok":true,"memory":item}).to_string()),
Err(error) => text_outcome(error),
}
}
async fn recall_memory(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if !ctx.memory_enabled {
return text_outcome("memory is disabled for this agent");
}
let query = args.get("query").and_then(Value::as_str).unwrap_or("");
let limit = args.get("limit").and_then(Value::as_i64);
2026-09-04 09:08:56 +00:00
match ctx
.memory
.recall(&ctx.pool, &ctx.actor, &ctx.bot_id, query, limit)
.await
{
2026-09-03 23:43:37 +00:00
Ok(items) => text_outcome(serde_json::to_string(&items).unwrap_or_else(|_| "[]".into())),
Err(error) => text_outcome(error),
}
}
async fn forget_memory(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if !ctx.memory_enabled {
return text_outcome("memory is disabled for this agent");
}
2026-09-04 09:08:56 +00:00
let Some(id) = args
.get("memory_id")
.and_then(Value::as_str)
.and_then(|id| Uuid::parse_str(id).ok())
else {
2026-09-03 23:43:37 +00:00
return text_outcome("memory_id must be a UUID");
};
2026-09-04 09:08:56 +00:00
match ctx
.memory
.forget(&ctx.pool, &ctx.actor, &ctx.bot_id, id)
.await
{
2026-09-03 23:43:37 +00:00
Ok(deleted) => text_outcome(json!({"ok":deleted}).to_string()),
Err(error) => text_outcome(error),
}
}
fn text_outcome(text: impl Into<String>) -> ToolOutcome {
2026-09-04 09:08:56 +00:00
ToolOutcome {
text: text.into(),
image: None,
pause: false,
}
2026-09-03 23:43:37 +00:00
}
2026-09-03 12:46:14 +00:00
fn vision_guard(ctx: &ToolCtx) -> Option<ToolOutcome> {
if let Some(message) = &ctx.gui_block {
return Some(ToolOutcome {
text: message.clone(),
image: None,
pause: false,
});
}
if ctx.vision {
None
} else {
Some(ToolOutcome {
text: "This model cannot see the screen. Use shell and file tools, or pick a vision model.".into(),
image: None,
pause: false,
})
}
}
fn observation_text(note: &str, observation: &ComputerObservation, unchanged: bool) -> String {
2026-09-04 09:08:56 +00:00
let label = if observation
.elements
.iter()
.any(|element| element.kind.as_deref() == Some("dom"))
{
"Clickable page elements"
} else {
"Clickable windows"
};
2026-09-03 12:46:14 +00:00
format!(
2026-09-04 09:08:56 +00:00
"{note}{}\n{label}: {}\n{}",
2026-09-03 12:46:14 +00:00
if unchanged { " (screen unchanged)" } else { "" },
2026-09-04 09:08:56 +00:00
format_ui_elements(&observation.elements),
2026-09-03 12:46:14 +00:00
json!({
"frameId": observation.frame_id,
"width": observation.width,
"height": observation.height,
"capturedAt": observation.captured_at,
"cursor": observation.cursor,
"activeWindow": observation.active_window,
2026-09-04 09:08:56 +00:00
"elements": observation.elements,
2026-09-03 12:46:14 +00:00
})
)
}
async fn observe(ctx: &ToolCtx) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
2026-09-04 09:08:56 +00:00
Ok(observation) => {
let (observation, note) =
attach_page_elements(ctx, observation, "computer observed").await;
pack_observation(ctx, &note, observation)
}
2026-09-03 12:46:14 +00:00
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
2026-09-04 09:08:56 +00:00
async fn attach_page_elements(
ctx: &ToolCtx,
mut observation: ComputerObservation,
note: &str,
) -> (ComputerObservation, String) {
let Some(page) = cdp_snapshot(ctx, false).await else {
return (observation, note.to_string());
};
observation.elements = merge_page_elements(observation.elements, &page.elements);
let mut note = note.to_string();
if !page.url.is_empty() || !page.title.is_empty() {
note.push_str(&format!("\nPage: {} {}", page.title, page.url));
}
if !page.text.is_empty() {
note.push_str("\nVisible text:\n");
note.push_str(&page.text);
}
(observation, note)
}
async fn cdp_snapshot(ctx: &ToolCtx, ensure: bool) -> Option<CdpPage> {
let page = cdp_call(ctx, json!({"action": "snapshot", "ensure": ensure})).await;
if page.ok { Some(page) } else { None }
}
async fn cdp_call(ctx: &ToolCtx, request: Value) -> CdpPage {
let display = ctx.context.display.as_deref().unwrap_or(":1");
let argv = cdp_command_on(display, ctx.context.profile_path.as_deref(), &request);
match ctx
.sandbox
.execute(
&ctx.computer,
CommandRequest {
argv,
cwd: None,
timeout_ms: Some(20_000),
},
&ctx.context,
)
.await
{
Ok(result) => {
let raw = if result.stdout.trim().is_empty() {
result.stderr
} else {
result.stdout
};
parse_cdp_page(&raw)
}
Err(error) => CdpPage {
ok: false,
error: Some(error.to_string()),
..CdpPage::default()
},
}
}
async fn browser(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
let action = args
.get("action")
.and_then(Value::as_str)
.unwrap_or("snapshot");
let mut request = json!({
"action": action,
"ensure": true,
});
for key in ["url", "text", "key", "ms", "selector"] {
if let Some(value) = args.get(key) {
request[key] = value.clone();
}
}
if request.get("selector").and_then(Value::as_str).is_none() {
if let Some(id) = args.get("element").and_then(Value::as_u64) {
let elements = ctx.elements.lock().unwrap().clone();
match elements
.iter()
.find(|element| u64::from(element.id) == id)
.and_then(|element| element.selector.clone())
{
Some(selector) => request["selector"] = json!(selector),
None if matches!(action, "click" | "type") => {
return pause_unknown_element(ctx, id as u32, &elements);
}
None => {}
}
}
}
if matches!(action, "click") && request.get("selector").and_then(Value::as_str).is_none() {
return text_outcome(
"browser click needs element id or selector. Call browser snapshot first.",
);
}
let page = cdp_call(ctx, request).await;
if !page.ok {
return text_outcome(page.error.unwrap_or_else(|| "browser failed".into()));
}
if !page.elements.is_empty() {
*ctx.elements.lock().unwrap() = page.elements.clone();
}
let text = browser_result_text(action, &page);
if action == "snapshot" {
return ToolOutcome {
text,
image: None,
pause: false,
};
}
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
Ok(observation) => {
let (observation, note) = attach_page_elements(ctx, observation, &text).await;
pack_observation(ctx, &note, observation)
}
Err(_) => ToolOutcome {
text,
image: None,
pause: false,
},
}
}
fn browser_result_text(action: &str, page: &CdpPage) -> String {
if matches!(action, "snapshot" | "navigate") {
format!(
"browser {action}\nPage: {} {}\nClickable page elements: {}\nVisible text:\n{}",
page.title,
page.url,
format_ui_elements(&page.elements),
page.text
)
} else {
format!("browser {action} ok")
}
}
2026-09-03 12:46:14 +00:00
async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
2026-09-04 09:08:56 +00:00
let mut args = args.clone();
let elements = ctx.elements.lock().unwrap().clone();
if let Some(actions) = args.get_mut("actions") {
if let Err(error) = apply_element_targets(actions, &elements) {
if let ActionError::UnknownElement(id) = error {
return pause_unknown_element(ctx, id, &elements);
}
return text_outcome(error.to_string());
}
if let Some(items) = actions.as_array_mut() {
for item in items {
let Some(kind) = item.get("kind").and_then(Value::as_str) else {
continue;
};
if kind != "click" {
continue;
}
let Some(id) = item.get("element").and_then(Value::as_u64) else {
continue;
};
let Some(selector) = elements
.iter()
.find(|element| u64::from(element.id) == id)
.and_then(|element| element.selector.clone())
else {
continue;
};
let page = cdp_call(
ctx,
json!({"action":"click","selector":selector,"ensure":false}),
)
.await;
if page.ok {
if let Some(object) = item.as_object_mut() {
object.insert("kind".into(), json!("wait"));
object.insert("ms".into(), json!(80));
}
}
}
}
}
2026-09-03 12:46:14 +00:00
let actions = match parse_computer_actions(args.get("actions").unwrap_or(&Value::Null)) {
Ok(actions) => actions,
Err(error) => {
return ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
};
}
};
2026-09-04 09:08:56 +00:00
let had_click = actions.iter().any(action_is_click);
2026-09-03 12:46:14 +00:00
match ctx
.sandbox
.act(
&ctx.computer,
ActionRequest {
actions,
observe: args.get("observe").and_then(Value::as_bool) != Some(false),
settle_ms: args.get("settle_ms").and_then(Value::as_u64).unwrap_or(120) as u32,
display: ctx.context.display.clone(),
profile_path: ctx.context.profile_path.clone(),
},
&ctx.context,
)
.await
{
Ok(result) => {
if let Some(observation) = result.observation {
2026-09-04 09:08:56 +00:00
let unchanged =
frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
let mut outcome = pack_observation(
2026-09-03 12:46:14 +00:00
ctx,
&format!("completed {} computer action(s)", result.completed),
observation,
2026-09-04 09:08:56 +00:00
);
note_click_result(ctx, had_click, unchanged, &mut outcome);
outcome
2026-09-03 12:46:14 +00:00
} else {
ToolOutcome {
text: json!({"ok": true, "completed": result.completed}).to_string(),
image: None,
pause: false,
}
}
}
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
2026-09-04 09:08:56 +00:00
fn action_is_click(action: &ComputerAction) -> bool {
matches!(
action,
ComputerAction::Pointer {
pointer_type: PointerType::Click | PointerType::Down,
..
}
)
}
fn pause_unknown_element(ctx: &ToolCtx, id: u32, elements: &[UiElement]) -> ToolOutcome {
*ctx.takeover_requested.lock().unwrap() = true;
ToolOutcome {
text: format!(
"找不到畫面上的元素 {id}。目前可見:{}。請接手操作,完成後釋放控制權,我會從目前畫面繼續。",
format_ui_elements(elements)
),
image: None,
pause: true,
}
}
fn note_click_result(ctx: &ToolCtx, had_click: bool, unchanged: bool, outcome: &mut ToolOutcome) {
if !had_click {
return;
}
let mut streak = ctx.miss_streak.lock().unwrap();
if unchanged {
*streak += 1;
*ctx.click_misses.lock().unwrap() += 1;
if *streak >= 2 {
*ctx.takeover_requested.lock().unwrap() = true;
outcome.pause = true;
outcome.text.push_str(
"\n連續兩次點擊後畫面沒有變化。請接手確認,完成後釋放控制權,我會從目前畫面繼續。",
);
}
} else {
*streak = 0;
}
}
2026-09-03 12:46:14 +00:00
fn pack_observation(ctx: &ToolCtx, note: &str, observation: ComputerObservation) -> ToolOutcome {
let unchanged = frames_match(ctx.previous_frame.lock().unwrap().as_deref(), &observation);
*ctx.previous_frame.lock().unwrap() = Some(observation.frame_id.clone());
2026-09-04 09:08:56 +00:00
*ctx.elements.lock().unwrap() = observation.elements.clone();
2026-09-03 12:46:14 +00:00
ToolOutcome {
text: observation_text(note, &observation, unchanged),
2026-09-04 09:08:56 +00:00
image: if unchanged {
None
} else {
Some(overlay_elements(&observation.image, &observation.elements))
},
2026-09-03 12:46:14 +00:00
pause: false,
}
}
async fn shell(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
let command = args.get("command").and_then(Value::as_str).unwrap_or("");
let cwd = args.get("cwd").and_then(Value::as_str);
let cwd = resolve_bot_workspace_cwd(ctx.mode, &ctx.bot_id, cwd)
.ok()
.flatten();
match ctx
.sandbox
.execute(
&ctx.computer,
CommandRequest {
argv: vec!["bash".into(), "-lc".into(), command.into()],
cwd,
timeout_ms: Some(60_000),
},
&ctx.context,
)
.await
{
Ok(result) => ToolOutcome {
text: format!("exit {}\n{}\n{}", result.code, result.stdout, result.stderr),
image: None,
pause: false,
},
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
async fn list_files(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
let requested = args.get("path").and_then(Value::as_str).unwrap_or("");
let stored = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, requested) {
Ok(path) => path,
Err(error) => {
return ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
};
}
};
2026-09-04 09:08:56 +00:00
match ctx
.sandbox
.list_files(&ctx.computer, &stored, &ctx.context)
.await
{
2026-09-03 12:46:14 +00:00
Ok(entries) => ToolOutcome {
text: serde_json::to_string(&entries).unwrap_or_else(|_| "[]".into()),
image: None,
pause: false,
},
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
async fn read_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
let requested = args.get("path").and_then(Value::as_str).unwrap_or("");
let stored = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, requested) {
Ok(path) => path,
Err(error) => {
return ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
};
}
};
2026-09-04 09:08:56 +00:00
match ctx
.sandbox
.read_file(&ctx.computer, &stored, &ctx.context)
.await
{
2026-09-03 12:46:14 +00:00
Ok(bytes) => ToolOutcome {
text: String::from_utf8_lossy(&bytes).into_owned(),
image: None,
pause: false,
},
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
async fn write_file(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
2026-09-04 09:08:56 +00:00
let requested = args
.get("path")
.and_then(Value::as_str)
.unwrap_or("notes.txt");
2026-09-03 12:46:14 +00:00
let content = args.get("content").and_then(Value::as_str).unwrap_or("");
let stored = match resolve_bot_workspace_path(ctx.mode, &ctx.bot_id, requested) {
Ok(path) => path,
Err(error) => {
return ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
};
}
};
match ctx
.sandbox
.write_file(&ctx.computer, &stored, content.as_bytes(), &ctx.context)
.await
{
Ok(()) => ToolOutcome {
text: json!({"ok": true, "path": requested}).to_string(),
image: None,
pause: false,
},
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
async fn open_path(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
let path = args.get("path").and_then(Value::as_str).unwrap_or("");
match ctx
.sandbox
.act(
&ctx.computer,
ActionRequest {
actions: vec![lazyboy_contracts::ComputerAction::Open { path: path.into() }],
observe: true,
settle_ms: 400,
display: ctx.context.display.clone(),
profile_path: ctx.context.profile_path.clone(),
},
&ctx.context,
)
.await
{
Ok(result) => {
if let Some(observation) = result.observation {
pack_observation(ctx, "opened path", observation)
} else {
ToolOutcome {
text: "opened".into(),
image: None,
pause: false,
}
}
}
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
async fn launch_app(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
2026-09-04 09:08:56 +00:00
let application = args
.get("application")
.and_then(Value::as_str)
.unwrap_or("browser");
2026-09-03 12:46:14 +00:00
let uri = args.get("uri").and_then(Value::as_str).map(str::to_string);
match ctx
.sandbox
.act(
&ctx.computer,
ActionRequest {
actions: vec![lazyboy_contracts::ComputerAction::Launch {
application: application.into(),
uri,
}],
observe: true,
settle_ms: 500,
display: ctx.context.display.clone(),
profile_path: ctx.context.profile_path.clone(),
},
&ctx.context,
)
.await
{
Ok(result) => {
if let Some(observation) = result.observation {
pack_observation(ctx, "launched app", observation)
} else {
ToolOutcome {
text: "launched".into(),
image: None,
pause: false,
}
}
}
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}