lazyBoy/crates/api/src/tools.rs

561 lines
20 KiB
Rust
Raw Normal View History

2026-09-03 12:46:14 +00:00
use std::sync::Mutex;
use lazyboy_contracts::{ComputerMode, ComputerObservation};
use lazyboy_control::{
frames_match, parse_computer_actions, resolve_bot_workspace_cwd, resolve_bot_workspace_path,
ActionRequest, AdapterContext, CommandRequest, ComputerRef, SandboxProvider,
};
use rig_core::completion::ToolDefinition;
use serde_json::{json, Value};
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>>,
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(),
description: "Capture a fresh desktop screenshot. Frame metadata comes back as text; the image is attached to the next model turn.".into(),
parameters: json!({"type":"object","properties":{}}),
},
ToolDefinition {
name: "computer_act".into(),
description: "Drive the desktop like a person: move to the target, click, type, drag, and scroll. Coordinates are 1280x800 from the top-left. Always include x,y for click/scroll/drag. Batch a whole gesture in one call. The resulting screenshot is attached to the next turn.".into(),
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"]},
"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"]}),
},
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(),
description: "Ask the user to take over for passwords, 2FA, CAPTCHA, or protected input. Never ask them to paste secrets in chat.".into(),
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,
"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 {
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,
session_id: Some(ctx.session_id.clone()),
source_run_id: Some(ctx.run_id.clone()),
source_message_id: None,
};
match ctx.memory.remember(&ctx.pool, &ctx.actor, &ctx.bot_id, input).await {
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);
match ctx.memory.recall(&ctx.pool, &ctx.actor, &ctx.bot_id, query, limit).await {
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");
}
let Some(id) = args.get("memory_id").and_then(Value::as_str).and_then(|id| Uuid::parse_str(id).ok()) else {
return text_outcome("memory_id must be a UUID");
};
match ctx.memory.forget(&ctx.pool, &ctx.actor, &ctx.bot_id, id).await {
Ok(deleted) => text_outcome(json!({"ok":deleted}).to_string()),
Err(error) => text_outcome(error),
}
}
fn text_outcome(text: impl Into<String>) -> ToolOutcome {
ToolOutcome { text: text.into(), image: None, pause: false }
}
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 {
format!(
"{note}{}\n{}",
if unchanged { " (screen unchanged)" } else { "" },
json!({
"frameId": observation.frame_id,
"width": observation.width,
"height": observation.height,
"capturedAt": observation.captured_at,
"cursor": observation.cursor,
"activeWindow": observation.active_window,
})
)
}
async fn observe(ctx: &ToolCtx) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
match ctx.sandbox.observe(&ctx.computer, &ctx.context).await {
Ok(observation) => pack_observation(ctx, "computer observed", observation),
Err(error) => ToolOutcome {
text: error.to_string(),
image: None,
pause: false,
},
}
}
async fn act(ctx: &ToolCtx, args: &Value) -> ToolOutcome {
if let Some(blocked) = vision_guard(ctx) {
return blocked;
}
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,
};
}
};
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 {
pack_observation(
ctx,
&format!("completed {} computer action(s)", result.completed),
observation,
)
} 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,
},
}
}
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());
ToolOutcome {
text: observation_text(note, &observation, unchanged),
image: if unchanged { None } else { Some(observation.image) },
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,
};
}
};
match ctx.sandbox.list_files(&ctx.computer, &stored, &ctx.context).await {
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,
};
}
};
match ctx.sandbox.read_file(&ctx.computer, &stored, &ctx.context).await {
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 {
let requested = args.get("path").and_then(Value::as_str).unwrap_or("notes.txt");
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;
}
let application = args.get("application").and_then(Value::as_str).unwrap_or("browser");
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,
},
}
}