lazyBoy/crates/api/src/runs.rs

1081 lines
36 KiB
Rust
Raw Normal View History

2026-09-03 12:46:14 +00:00
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use lazyboy_contracts::ModelProvider;
2026-09-04 09:08:56 +00:00
use lazyboy_harness::{
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
};
2026-09-03 12:46:14 +00:00
use rig_core::completion::message::{
AssistantContent, ImageDetail, ImageMediaType, Message, ToolResultContent, UserContent,
};
2026-09-04 09:08:56 +00:00
use rig_core::completion::{CompletionModel, ToolDefinition};
use serde_json::{Value, json};
2026-09-03 12:46:14 +00:00
use uuid::Uuid;
use crate::computer::{self, adapter_context_for};
2026-09-04 09:08:56 +00:00
use crate::db::{Actor, parse_mode};
2026-09-03 12:46:14 +00:00
use crate::state::AppState;
2026-09-04 09:08:56 +00:00
use crate::tools::{ToolCtx, dispatch, tool_definitions};
2026-09-03 12:46:14 +00:00
2026-09-04 09:08:56 +00:00
const SCREENSHOT_CAPTION: &str = "Desktop screenshot (1280x800) with yellow numbered marks. Click by those element ids. The live VNC view has no marks.";
2026-09-03 12:46:14 +00:00
2026-09-04 09:08:56 +00:00
const SYSTEM: &str = "You operate this bot's Linux desktop. The human always sees the live screen. You do not need a screenshot for every step.
2026-09-03 12:46:14 +00:00
2026-09-04 09:08:56 +00:00
Prefer the fast path, in this order:
1) shell, list_files, read_file, write_file
2) MCP tools when they match the task
3) browser for anything in Chromium: snapshot (page text + numbered elements), click/type/press by element id, navigate by URL. Do not pixel-click the Chromium window.
4) launch_app / open_path to open a site or file
5) computer_act only for native GUI that has no DOM (dialogs, canvas, XFCE)
2026-09-03 12:46:14 +00:00
2026-09-04 09:08:56 +00:00
When you use the browser tool:
- snapshot first; click {\"action\":\"click\",\"element\":N}; type {\"action\":\"type\",\"element\":N,\"text\":\"...\"}; open a URL with navigate.
- Yellow numbered marks on the screenshot match the element list. Click the number, not guessed pixels.
- If the control is not in the element list, login/2FA/CAPTCHA, or clicks do nothing, call request_takeover and stop. Do not guess-click.
2026-09-03 12:46:14 +00:00
2026-09-04 09:08:56 +00:00
computer_act examples (native windows only):
- {\"kind\":\"click\",\"element\":1}
2026-09-03 12:46:14 +00:00
- {\"kind\":\"click\",\"x\":N,\"y\":N}
- {\"kind\":\"type\",\"text\":\"...\"}
2026-09-04 09:08:56 +00:00
- {\"kind\":\"key\",\"key\":\"Return\"}
- {\"kind\":\"focus\",\"title\":\"Open File\"}
2026-09-03 12:46:14 +00:00
2026-09-04 09:08:56 +00:00
On a Team Computer, relative files live in your bot folder; use shared/ for shared work. Finish the user's task.";
2026-09-03 12:46:14 +00:00
2026-09-03 23:43:37 +00:00
pub async fn send(
state: &AppState,
actor: &Actor,
bot_id: &str,
thread_id: &str,
text: &str,
client_nonce: Option<&str>,
blocks: &[Value],
) -> Result<Value, String> {
2026-09-04 09:08:56 +00:00
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| error.to_string())?;
2026-09-04 05:41:09 +00:00
let scoped: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT t.title, t.room_id FROM threads t JOIN bots b ON b.id=t.bot_id
2026-09-03 23:43:37 +00:00
WHERE t.id=$1 AND t.bot_id=$2 AND t.space_id=$3 AND t.user_id=$4
AND b.space_id=$3 AND b.user_id=$4 AND t.status='active'
FOR UPDATE OF t",
)
.bind(thread_id)
.bind(bot_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_optional(&mut *tx)
.await
.map_err(|error| error.to_string())?;
2026-09-04 05:41:09 +00:00
let Some((current_title, room_id)) = scoped else {
2026-09-03 23:43:37 +00:00
return Err("session not found".into());
2026-09-04 05:41:09 +00:00
};
2026-09-03 23:43:37 +00:00
if let Some(nonce) = client_nonce {
let existing: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT id, run_id FROM messages WHERE thread_id=$1 AND client_nonce=$2",
)
.bind(thread_id)
.bind(nonce)
.fetch_optional(&mut *tx)
2026-09-03 12:46:14 +00:00
.await
.map_err(|error| error.to_string())?;
2026-09-03 23:43:37 +00:00
if let Some((message_id, run_id)) = existing {
tx.rollback().await.map_err(|error| error.to_string())?;
return Ok(json!({
"messageId": message_id,
"runId": run_id,
"duplicate": true,
"queued": true
}));
2026-09-03 12:46:14 +00:00
}
}
let run_id = Uuid::new_v4().to_string();
2026-09-03 23:43:37 +00:00
let message_id = Uuid::new_v4().to_string();
let seq: i32 = sqlx::query_scalar(
"UPDATE threads
SET next_message_seq=next_message_seq+1, updated_at=now()
WHERE id=$1 RETURNING next_message_seq-1",
)
.bind(thread_id)
.fetch_one(&mut *tx)
.await
.map_err(|error| error.to_string())?;
sqlx::query(
"INSERT INTO messages (id,thread_id,seq,role,body,blocks,run_id,client_nonce)
VALUES ($1,$2,$3,'user',$4,$5,$6,$7)",
)
.bind(&message_id)
.bind(thread_id)
.bind(seq)
.bind(text)
.bind(json!(blocks))
.bind(&run_id)
.bind(client_nonce)
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
2026-09-04 05:41:09 +00:00
if crate::sessions::is_default_session_title(&current_title) {
sqlx::query("UPDATE threads SET title=$2 WHERE id=$1")
.bind(thread_id)
.bind(crate::sessions::title_from_first_message(text))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
}
let mut member_ids: Vec<String> = if let Some(room_id) = room_id.as_deref() {
sqlx::query_scalar("SELECT bot_id FROM room_members WHERE room_id=$1 ORDER BY created_at")
.bind(room_id)
.fetch_all(&mut *tx)
.await
.map_err(|error| error.to_string())?
} else {
vec![bot_id.to_string()]
};
if member_ids.is_empty() {
member_ids.push(bot_id.to_string());
}
for (index, member_id) in member_ids.iter().enumerate() {
let member_run = if index == 0 {
run_id.clone()
} else {
Uuid::new_v4().to_string()
};
sqlx::query(
"INSERT INTO runs (id,space_id,bot_id,thread_id,user_id,status,prompt,checkpoint)
VALUES ($1,$2,$3,$4,$5,'queued',$6,$7)",
)
.bind(&member_run)
.bind(&actor.space_id)
.bind(member_id)
.bind(&thread_id)
.bind(&actor.user_id)
.bind(text)
.bind(json!({"messageSeq":seq}))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
}
2026-09-03 23:43:37 +00:00
let event_seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_event_seq=next_event_seq+1 WHERE id=$1 RETURNING next_event_seq",
)
.bind(thread_id)
.fetch_one(&mut *tx)
.await
.map_err(|error| error.to_string())?;
2026-09-04 09:08:56 +00:00
sqlx::query(
"INSERT INTO events (id,thread_id,seq,type,payload) VALUES ($1,$2,$3,'message.created',$4)",
)
.bind(Uuid::new_v4().to_string())
.bind(thread_id)
.bind(event_seq)
.bind(json!({"id":message_id,"seq":seq,"role":"user","body":text,"runId":run_id}))
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
2026-09-03 23:43:37 +00:00
let queued_behind_active: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM runs WHERE bot_id=$1 AND id<>$2
AND status IN ('queued','leased','running','waiting_input','waiting_takeover'))",
)
.bind(bot_id)
.bind(&run_id)
.fetch_one(&mut *tx)
2026-09-03 12:46:14 +00:00
.await
.map_err(|error| error.to_string())?;
2026-09-04 05:41:09 +00:00
if let Some(room_id) = room_id.as_deref() {
sqlx::query("UPDATE rooms SET updated_at=now() WHERE id=$1")
.bind(room_id)
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
}
2026-09-03 23:43:37 +00:00
tx.commit().await.map_err(|error| error.to_string())?;
Ok(json!({
"messageId": message_id,
"runId": run_id,
"duplicate": false,
"queued": true,
"queuedBehindActive": queued_behind_active
}))
2026-09-03 12:46:14 +00:00
}
pub async fn worker_loop(state: AppState) {
let inflight = Arc::new(tokio::sync::Semaphore::new(16));
2026-09-03 23:43:37 +00:00
let lease_owner = format!("api-{}", Uuid::new_v4());
2026-09-03 12:46:14 +00:00
loop {
tokio::time::sleep(Duration::from_millis(200)).await;
let Ok(permit) = inflight.clone().try_acquire_owned() else {
continue;
};
2026-09-03 23:43:37 +00:00
let queued: Result<Option<(String, String, String, String, String, String)>, _> =
sqlx::query_as(
2026-09-04 09:08:56 +00:00
"WITH candidate AS (
2026-09-03 23:43:37 +00:00
SELECT r.id
FROM runs r
WHERE r.retry_count < r.max_retries
AND (
r.status='queued'
OR (
r.status IN ('leased','running')
AND (r.lease_expires_at IS NULL OR r.lease_expires_at < now())
)
)
AND NOT EXISTS (
SELECT 1 FROM runs a
WHERE a.bot_id=r.bot_id AND a.id<>r.id
AND a.status IN ('leased','running','waiting_input','waiting_takeover')
AND (a.lease_expires_at IS NULL OR a.lease_expires_at >= now())
)
ORDER BY CASE WHEN r.status='queued' THEN 1 ELSE 0 END, r.created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE runs r
SET status='leased', lease_owner=$1,
lease_expires_at=now()+interval '5 minutes',
lease_fence=lease_fence+1, retry_count=retry_count+1, updated_at=now()
FROM candidate c WHERE r.id=c.id
RETURNING r.id,r.bot_id,r.thread_id,r.prompt,r.user_id,r.space_id",
2026-09-04 09:08:56 +00:00
)
.bind(&lease_owner)
.fetch_optional(state.pool())
.await;
2026-09-03 23:43:37 +00:00
let Ok(Some((run_id, bot_id, thread_id, prompt, user_id, space_id))) = queued else {
2026-09-03 12:46:14 +00:00
drop(permit);
continue;
};
let state = state.clone();
2026-09-03 23:43:37 +00:00
let owner = lease_owner.clone();
2026-09-03 12:46:14 +00:00
tokio::spawn(async move {
let _permit = permit;
2026-09-03 23:43:37 +00:00
let actor = Actor { user_id, space_id };
2026-09-04 09:08:56 +00:00
if let Err(error) = execute_run(
&state, &actor, &owner, &run_id, &bot_id, &thread_id, &prompt,
)
.await
2026-09-03 23:43:37 +00:00
{
2026-09-03 12:46:14 +00:00
tracing::error!("run {run_id} failed: {error}");
2026-09-03 23:43:37 +00:00
let next_status: Option<String> = sqlx::query_scalar(
"UPDATE runs
SET status=CASE WHEN retry_count < max_retries THEN 'queued' ELSE 'failed' END,
error=$2, completed_at=CASE WHEN retry_count < max_retries THEN NULL ELSE now() END,
lease_owner=NULL, lease_expires_at=NULL, updated_at=now()
2026-09-04 09:08:56 +00:00
WHERE id=$1 AND lease_owner=$3 AND status IN ('leased','running') RETURNING status",
2026-09-03 23:43:37 +00:00
)
2026-09-03 12:46:14 +00:00
.bind(&run_id)
.bind(&error)
2026-09-03 23:43:37 +00:00
.bind(&owner)
.fetch_optional(state.pool())
.await
.ok()
.flatten();
if next_status.as_deref() == Some("failed") {
let _ = append_bot_message(
&state,
&thread_id,
&run_id,
2026-09-04 05:41:09 +00:00
&bot_id,
2026-09-03 23:43:37 +00:00
&format!("Run failed after retries: {error}"),
)
.await;
let _ = crate::sessions::append_event(
&state,
&thread_id,
"run.failed",
json!({"runId":run_id,"error":error}),
)
2026-09-03 12:46:14 +00:00
.await;
2026-09-03 23:43:37 +00:00
}
2026-09-03 12:46:14 +00:00
let _ = computer::release_screen_execution(&state, &run_id).await;
let _ = sqlx::query(
"UPDATE computers SET execution_bot_id = NULL, execution_run_id = NULL, execution_lease_expires_at = NULL, updated_at = now()
WHERE execution_run_id = $1",
)
.bind(&run_id)
.execute(state.pool())
.await;
}
});
}
}
async fn execute_run(
state: &AppState,
2026-09-03 23:43:37 +00:00
actor: &Actor,
lease_owner: &str,
2026-09-03 12:46:14 +00:00
run_id: &str,
bot_id: &str,
thread_id: &str,
prompt: &str,
) -> Result<(), String> {
2026-09-03 23:43:37 +00:00
let started = sqlx::query(
"UPDATE runs SET status='running', started_at=COALESCE(started_at,now()), updated_at=now()
2026-09-04 09:08:56 +00:00
WHERE id=$1 AND lease_owner=$2 AND lease_expires_at>now() AND status='leased'",
2026-09-03 23:43:37 +00:00
)
2026-09-04 09:08:56 +00:00
.bind(run_id)
.bind(lease_owner)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
2026-09-03 23:43:37 +00:00
if started.rows_affected() != 1 {
2026-09-04 09:08:56 +00:00
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id=$1")
.bind(run_id)
.fetch_optional(state.pool())
.await
.ok()
.flatten();
if halt_from_status(status.as_deref()).is_some() {
return Ok(());
}
2026-09-03 23:43:37 +00:00
return Err("run lease was lost before execution".into());
}
2026-09-04 09:08:56 +00:00
let _ = crate::sessions::append_event(state, thread_id, "run.started", json!({"runId":run_id}))
.await;
2026-09-03 12:46:14 +00:00
2026-09-03 23:43:37 +00:00
computer::boot(state, actor, bot_id).await?;
2026-09-03 12:46:14 +00:00
let bot = state
.db
2026-09-03 23:43:37 +00:00
.get_bot(actor, bot_id)
2026-09-03 12:46:14 +00:00
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "bot not found".to_string())?;
let computer = state
.db
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "computer not found".to_string())?;
2026-09-04 09:08:56 +00:00
let computer_ref =
computer::computer_ref(&computer).ok_or_else(|| "computer is not running".to_string())?;
2026-09-03 23:43:37 +00:00
let bound = computer::ensure_bot_screen(state, actor, bot_id, &computer, Some(run_id)).await?;
2026-09-03 12:46:14 +00:00
let mut gui_block = bound.gui_block;
let screen = if let Some(row) = bound.row {
let row = computer::take_screen_execution(state, &row, run_id).await?;
if gui_block.is_none() {
2026-09-04 09:08:56 +00:00
gui_block =
computer::take_profile_lock(state, &computer, bot_id, &bot.name, run_id, &row)
.await?;
2026-09-03 12:46:14 +00:00
}
Some(row)
} else {
None
};
2026-09-04 09:08:56 +00:00
let space = state
.db
.get_space(actor)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "workspace not found".to_string())?;
2026-09-03 12:46:14 +00:00
let provider = bot
.model_provider
.as_deref()
2026-09-04 09:08:56 +00:00
.or(Some(space.default_model_provider.as_str()))
2026-09-03 12:46:14 +00:00
.unwrap_or("xai")
.parse::<ModelProvider>()
.map_err(|error| error.to_string())?;
2026-09-04 09:08:56 +00:00
let model_id = bot
.model_id
.clone()
.filter(|value| !value.is_empty())
.or_else(|| Some(space.default_model_id.clone()).filter(|value| !value.is_empty()));
2026-09-03 12:46:14 +00:00
let backend = resolve_backend(ResolveModelRequest {
provider,
2026-09-04 09:08:56 +00:00
model_id,
base_url: space.default_model_base_url.clone(),
2026-09-03 12:46:14 +00:00
credentials: CredentialChain {
bot: None,
2026-09-04 09:08:56 +00:00
space: space.default_model_api_key.clone(),
2026-09-03 12:46:14 +00:00
env: lazyboy_harness::credential_from_env(provider),
},
})
.map_err(|error| error.to_string())?;
2026-09-04 09:08:56 +00:00
let model = connect_model(&backend).map_err(|error| error.to_string())?;
2026-09-03 12:46:14 +00:00
let ctx = Arc::new(ToolCtx {
sandbox: state.sandbox.clone(),
computer: computer_ref,
2026-09-03 23:43:37 +00:00
context: adapter_context_for(actor, bot_id, "run", screen.as_ref(), Some(run_id)),
2026-09-03 12:46:14 +00:00
mode: parse_mode(&computer.scope),
bot_id: bot_id.to_string(),
vision: backend.capabilities.vision,
gui_block,
previous_frame: std::sync::Mutex::new(None),
2026-09-04 09:08:56 +00:00
elements: std::sync::Mutex::new(Vec::new()),
miss_streak: std::sync::Mutex::new(0),
click_misses: std::sync::Mutex::new(0),
2026-09-03 12:46:14 +00:00
takeover_requested: std::sync::Mutex::new(false),
2026-09-03 23:43:37 +00:00
pool: state.pool().clone(),
memory: state.memory.clone(),
actor: actor.clone(),
session_id: thread_id.to_string(),
run_id: run_id.to_string(),
memory_enabled: bot.memory_enabled && state.memory.globally_enabled(),
2026-09-04 05:41:09 +00:00
mcp: state.mcp.clone(),
2026-09-03 12:46:14 +00:00
});
2026-09-04 05:41:09 +00:00
let mut defs = tool_definitions(ctx.memory_enabled);
let mcp_defs = state.mcp.definitions().await;
if !mcp_defs.is_empty() {
defs.extend(mcp_defs);
}
2026-09-03 23:43:37 +00:00
let (summary, summary_seq): (String, i32) = sqlx::query_as(
"SELECT history_summary, history_summary_seq FROM threads
WHERE id=$1 AND space_id=$2 AND user_id=$3",
)
.bind(thread_id)
.bind(&actor.space_id)
.bind(&actor.user_id)
.fetch_one(state.pool())
.await
.map_err(|error| error.to_string())?;
2026-09-04 09:08:56 +00:00
let checkpoint: Value = sqlx::query_scalar("SELECT checkpoint FROM runs WHERE id=$1")
.bind(run_id)
.fetch_one(state.pool())
.await
.map_err(|error| error.to_string())?;
let current_seq: i32 = checkpoint
.get("messageSeq")
.and_then(Value::as_i64)
.map(|seq| seq as i32)
.unwrap_or(i32::MAX);
let resume_after_takeover = checkpoint
.get("resumeAfterTakeover")
.and_then(Value::as_bool)
.unwrap_or(false);
if resume_after_takeover {
let _ = sqlx::query(
"UPDATE runs SET checkpoint = checkpoint - 'resumeAfterTakeover' WHERE id=$1",
)
.bind(run_id)
.execute(state.pool())
.await;
}
let history_end = if resume_after_takeover {
i32::MAX
} else {
current_seq
};
2026-09-04 05:41:09 +00:00
let recent: Vec<(String, String, Option<String>, Option<String>)> = sqlx::query_as(
"SELECT role, body, speaker_bot_id, speaker_name FROM (
SELECT m.role, m.body, m.seq, m.speaker_bot_id, b.name AS speaker_name
FROM messages m
LEFT JOIN bots b ON b.id=m.speaker_bot_id
WHERE m.thread_id=$1 AND m.seq>$2 AND m.seq<$3
ORDER BY m.seq DESC LIMIT 30
2026-09-03 23:43:37 +00:00
) history ORDER BY seq ASC",
)
.bind(thread_id)
.bind(history_window_start(summary_seq, current_seq))
2026-09-04 09:08:56 +00:00
.bind(history_end)
2026-09-03 23:43:37 +00:00
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
2026-09-03 12:46:14 +00:00
let mut history: Vec<Message> = Vec::new();
2026-09-03 23:43:37 +00:00
if !summary.trim().is_empty() {
history.push(Message::User {
content: vec![UserContent::text(format!(
"Conversation summary through message {summary_seq}:\n{summary}"
))],
});
}
2026-09-04 05:41:09 +00:00
for (role, body, speaker_id, speaker_name) in recent {
2026-09-03 23:43:37 +00:00
if role == "user" {
history.push(Message::User {
content: vec![UserContent::text(body)],
});
2026-09-04 05:41:09 +00:00
} else if speaker_id.as_deref() == Some(bot_id) || speaker_id.is_none() {
2026-09-03 23:43:37 +00:00
history.push(Message::Assistant {
id: None,
content: vec![AssistantContent::text(body)],
});
2026-09-04 05:41:09 +00:00
} else {
let name = speaker_name.unwrap_or_else(|| "agent".into());
history.push(Message::User {
content: vec![UserContent::text(format!("[{name}]: {body}"))],
});
2026-09-03 23:43:37 +00:00
}
}
2026-09-04 09:08:56 +00:00
let mut first = if resume_after_takeover {
vec![UserContent::text(
"The user finished collaborating and released control. Continue the original task from the CURRENT screen. Do not restart from scratch.",
)]
} else {
vec![UserContent::text(prompt)]
};
let mut screenshots: u32 = 0;
let mut screenshot_bytes: u64 = 0;
if resume_after_takeover && ctx.gui_block.is_none() {
let outcome = dispatch(&ctx, "computer_observe", &json!({})).await;
first.push(UserContent::text(outcome.text));
if let Some(image) = outcome.image {
screenshot_bytes += image.len() as u64;
screenshots += 1;
first.extend(screenshot_parts(image));
2026-09-03 12:46:14 +00:00
}
}
let mut pending = Message::User { content: first };
let mut final_text = String::new();
2026-09-04 09:08:56 +00:00
let mut turns: u32 = 0;
let mut used_gui = false;
2026-09-03 23:43:37 +00:00
let memory = if ctx.memory_enabled {
2026-09-04 09:08:56 +00:00
match state
.memory
.recall(state.pool(), actor, bot_id, prompt, None)
.await
{
2026-09-03 23:43:37 +00:00
Ok(items) => state.memory.durable_block(&items),
Err(error) => {
tracing::warn!("memory retrieval failed for run {run_id}: {error}");
String::new()
}
}
} else {
String::new()
};
let mut preamble = if bot.instructions.trim().is_empty() {
SYSTEM.to_string()
} else {
2026-09-04 09:08:56 +00:00
format!(
"{SYSTEM}\n\nBot-specific instructions:\n{}",
bot.instructions.trim()
)
2026-09-03 23:43:37 +00:00
};
2026-09-04 05:41:09 +00:00
let room_mates: Vec<String> = sqlx::query_scalar(
"SELECT b.name FROM threads t
JOIN room_members m ON m.room_id=t.room_id
JOIN bots b ON b.id=m.bot_id
WHERE t.id=$1 AND b.id<>$2
ORDER BY b.name",
)
.bind(thread_id)
.bind(bot_id)
.fetch_all(state.pool())
.await
.unwrap_or_default();
if !room_mates.is_empty() {
preamble.push_str(&format!(
"\n\nYou are {} in a group chat with: {}. Reply as yourself only. Other agents' lines are prefixed with [Name]. Do not speak for them.",
bot.name,
room_mates.join("")
));
}
let mcp_names: Vec<String> = defs
.iter()
.filter(|tool| tool.name.starts_with("mcp_"))
.map(|tool| tool.name.clone())
.collect();
if !mcp_names.is_empty() {
preamble.push_str(&format!(
"\n\nMCP tools available: {}. Use them when they help complete the user's request.",
mcp_names.join(", ")
));
}
2026-09-03 23:43:37 +00:00
if !memory.is_empty() {
preamble.push_str("\n\n");
preamble.push_str(&memory);
}
2026-09-03 12:46:14 +00:00
for _ in 0..24 {
2026-09-04 09:08:56 +00:00
turns += 1;
if let Some(halt) = renew_or_halt(state, run_id, lease_owner).await? {
return finish_halt(
state,
thread_id,
run_id,
halt,
turns,
screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
}
2026-09-03 12:46:14 +00:00
drop_history_screenshots(&mut history);
2026-09-04 09:08:56 +00:00
let content = tokio::select! {
halt = wait_for_halt(state, run_id) => {
return finish_halt(
state,
thread_id,
run_id,
halt,
turns,
screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
}
result = complete_once(&model, pending.clone(), &preamble, &history, &defs) => result
}?;
2026-09-03 12:46:14 +00:00
let assistant = Message::Assistant {
id: None,
content: content.clone(),
};
history.push(pending.clone());
history.push(assistant);
let mut calls = Vec::new();
for item in &content {
match item {
AssistantContent::Text(text) => final_text.push_str(&text.text),
AssistantContent::ToolCall(call) => calls.push(call.clone()),
_ => {}
}
}
if calls.is_empty() {
break;
}
final_text.clear();
let mut results = Vec::new();
let mut screen: Option<Vec<u8>> = None;
for call in calls {
2026-09-04 09:08:56 +00:00
if let Some(halt) = renew_or_halt(state, run_id, lease_owner).await? {
return finish_halt(
state,
thread_id,
run_id,
halt,
turns,
screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
2026-09-03 16:16:34 +00:00
}
2026-09-03 12:46:14 +00:00
let name = call.function.name.clone();
2026-09-04 09:08:56 +00:00
used_gui |= matches!(
2026-09-03 12:46:14 +00:00
name.as_str(),
2026-09-04 09:08:56 +00:00
"computer_observe" | "computer_act" | "open_path" | "launch_app" | "browser"
2026-09-03 12:46:14 +00:00
);
2026-09-04 09:08:56 +00:00
let outcome = tokio::select! {
halt = wait_for_halt(state, run_id) => {
return finish_halt(
state,
thread_id,
run_id,
halt,
turns,
screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
}
outcome = tokio::time::timeout(
Duration::from_secs(90),
dispatch(&ctx, &name, &call.function.arguments),
) => match outcome {
Ok(outcome) => outcome,
Err(_) => crate::tools::ToolOutcome {
text: format!("工具 {name} 執行逾時90 秒),請稍後重試。"),
image: None,
pause: false,
},
}
2026-09-03 16:16:34 +00:00
};
2026-09-04 09:08:56 +00:00
// xAI rejects images inside tool results. Attach a changed
2026-09-03 12:46:14 +00:00
// screenshot as a following user image instead.
if let Some(image) = outcome.image {
screen = Some(image);
}
results.push(UserContent::tool_result_for(
call.id.clone(),
call.provider.clone(),
name,
vec![ToolResultContent::text(&outcome.text)],
));
if outcome.pause {
2026-09-04 09:08:56 +00:00
sqlx::query(
"UPDATE runs SET status = 'waiting_takeover',
checkpoint = COALESCE(checkpoint, '{}'::jsonb)
|| jsonb_build_object('resumeAfterTakeover', true),
updated_at = now()
WHERE id = $1",
)
.bind(run_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
2026-09-04 05:41:09 +00:00
append_bot_message(state, thread_id, run_id, bot_id, &outcome.text).await?;
2026-09-04 09:08:56 +00:00
let click_misses = *ctx.click_misses.lock().unwrap();
record_run_metrics(
state,
thread_id,
run_id,
"run.paused",
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
true,
)
.await;
2026-09-03 12:46:14 +00:00
return Ok(());
}
}
if let Some(png) = screen {
2026-09-04 09:08:56 +00:00
screenshot_bytes += png.len() as u64;
screenshots += 1;
2026-09-03 12:46:14 +00:00
results.extend(screenshot_parts(png));
}
pending = Message::User { content: results };
}
2026-09-03 16:16:34 +00:00
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id = $1")
.bind(run_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?;
2026-09-04 09:08:56 +00:00
if let Some(halt) = halt_from_status(status.as_deref()) {
return finish_halt(
state,
thread_id,
run_id,
halt,
turns,
screenshots,
screenshot_bytes,
&ctx,
used_gui,
)
.await;
2026-09-03 16:16:34 +00:00
}
2026-09-04 05:41:09 +00:00
append_bot_message(state, thread_id, run_id, bot_id, &final_text).await?;
2026-09-03 23:43:37 +00:00
let completed = sqlx::query(
"UPDATE runs
SET status='completed', completed_at=now(), updated_at=now(),
lease_owner=NULL, lease_expires_at=NULL
2026-09-04 09:08:56 +00:00
WHERE id=$1 AND lease_owner=$2 AND status='running'",
2026-09-03 12:46:14 +00:00
)
.bind(run_id)
2026-09-03 23:43:37 +00:00
.bind(lease_owner)
2026-09-03 12:46:14 +00:00
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
2026-09-03 23:43:37 +00:00
if completed.rows_affected() != 1 {
return Err("run lease was lost before completion".into());
}
2026-09-04 09:08:56 +00:00
let click_misses = *ctx.click_misses.lock().unwrap();
let takeover = *ctx.takeover_requested.lock().unwrap();
record_run_metrics(
2026-09-03 23:43:37 +00:00
state,
thread_id,
2026-09-04 09:08:56 +00:00
run_id,
2026-09-03 23:43:37 +00:00
"run.completed",
2026-09-04 09:08:56 +00:00
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
takeover,
2026-09-03 23:43:37 +00:00
)
.await;
2026-09-03 12:46:14 +00:00
computer::release_screen_execution(state, run_id).await?;
sqlx::query(
"UPDATE computers SET execution_bot_id = NULL, execution_run_id = NULL, execution_lease_expires_at = NULL, updated_at = now()
WHERE execution_run_id = $1",
)
.bind(run_id)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
Ok(())
}
2026-09-04 09:08:56 +00:00
async fn complete_once(
model: &DynModel,
pending: Message,
preamble: &str,
history: &[Message],
defs: &[ToolDefinition],
) -> Result<Vec<AssistantContent>, String> {
match model {
DynModel::Xai(model) => complete_with(model, pending, preamble, history, defs).await,
DynModel::OpenAi(model) => complete_with(model, pending, preamble, history, defs).await,
}
}
async fn complete_with<M>(
model: &M,
pending: Message,
preamble: &str,
history: &[Message],
defs: &[ToolDefinition],
) -> Result<Vec<AssistantContent>, String>
where
M: CompletionModel + Clone,
{
let request = model
.completion_request(pending)
.preamble(preamble.to_string())
.messages(history.to_vec())
.tools(defs.to_vec())
.build();
let response = tokio::time::timeout(Duration::from_secs(120), model.completion(request))
.await
.map_err(|_| "AI 回應逾時120 秒)".to_string())?
.map_err(|error| error.to_string())?;
Ok(response.choice.into_iter().collect())
}
fn screenshot_parts(image: Vec<u8>) -> Vec<UserContent> {
let media = if image.starts_with(&[0xFF, 0xD8, 0xFF]) {
ImageMediaType::JPEG
} else {
ImageMediaType::PNG
};
let encoded = base64::engine::general_purpose::STANDARD.encode(image);
2026-09-03 12:46:14 +00:00
vec![
UserContent::text(SCREENSHOT_CAPTION),
2026-09-04 09:08:56 +00:00
UserContent::image_base64(encoded, Some(media), Some(ImageDetail::Low)),
2026-09-03 12:46:14 +00:00
]
}
fn drop_history_screenshots(history: &mut [Message]) {
for message in history.iter_mut() {
let Message::User { content } = message else {
continue;
};
content.retain(|part| match part {
UserContent::Image(_) => false,
UserContent::Text(text) if text.text == SCREENSHOT_CAPTION => false,
_ => true,
});
}
}
2026-09-04 09:08:56 +00:00
pub(crate) async fn append_bot_message(
state: &AppState,
thread_id: &str,
run_id: &str,
bot_id: &str,
body: &str,
) -> Result<(), String> {
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| error.to_string())?;
2026-09-03 23:43:37 +00:00
let seq: i32 = sqlx::query_scalar(
"UPDATE threads SET next_message_seq=next_message_seq+1,updated_at=now()
WHERE id=$1 RETURNING next_message_seq-1",
)
.bind(thread_id)
.fetch_one(&mut *tx)
.await
.map_err(|error| error.to_string())?;
let message_id = Uuid::new_v4().to_string();
sqlx::query(
2026-09-04 05:41:09 +00:00
"INSERT INTO messages (id,thread_id,seq,role,body,run_id,speaker_bot_id)
VALUES ($1,$2,$3,'assistant',$4,$5,$6)",
2026-09-03 23:43:37 +00:00
)
2026-09-04 09:08:56 +00:00
.bind(&message_id)
.bind(thread_id)
.bind(seq)
.bind(body)
.bind(run_id)
.bind(bot_id)
.execute(&mut *tx)
.await
.map_err(|error| error.to_string())?;
2026-09-03 23:43:37 +00:00
tx.commit().await.map_err(|error| error.to_string())?;
let _ = crate::sessions::append_event(
state,
thread_id,
"message.created",
json!({"id":message_id,"seq":seq,"role":"assistant","body":body,"runId":run_id}),
)
.await;
2026-09-03 12:46:14 +00:00
Ok(())
}
2026-09-03 23:43:37 +00:00
2026-09-04 09:08:56 +00:00
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RunHalt {
Cancelled,
Takeover,
}
fn halt_from_status(status: Option<&str>) -> Option<RunHalt> {
match status {
Some("cancelled") => Some(RunHalt::Cancelled),
Some("waiting_takeover") => Some(RunHalt::Takeover),
_ => None,
}
}
async fn run_status_halt(state: &AppState, run_id: &str) -> Result<Option<RunHalt>, String> {
let status: Option<String> = sqlx::query_scalar("SELECT status FROM runs WHERE id = $1")
.bind(run_id)
.fetch_optional(state.pool())
.await
.map_err(|error| error.to_string())?;
Ok(halt_from_status(status.as_deref()))
}
async fn wait_for_halt(state: &AppState, run_id: &str) -> RunHalt {
loop {
if let Ok(Some(halt)) = run_status_halt(state, run_id).await {
return halt;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
async fn renew_or_halt(
state: &AppState,
run_id: &str,
lease_owner: &str,
) -> Result<Option<RunHalt>, String> {
if let Some(halt) = run_status_halt(state, run_id).await? {
return Ok(Some(halt));
}
match renew_lease(state, run_id, lease_owner).await {
Ok(()) => run_status_halt(state, run_id).await,
Err(error) => {
if let Some(halt) = run_status_halt(state, run_id).await? {
Ok(Some(halt))
} else {
Err(error)
}
}
}
}
async fn finish_halt(
state: &AppState,
thread_id: &str,
run_id: &str,
halt: RunHalt,
turns: u32,
screenshots: u32,
screenshot_bytes: u64,
ctx: &crate::tools::ToolCtx,
used_gui: bool,
) -> Result<(), String> {
match halt {
RunHalt::Cancelled => Ok(()),
RunHalt::Takeover => {
let _ = sqlx::query(
"UPDATE runs SET lease_owner=NULL, lease_expires_at=NULL, updated_at=now()
WHERE id=$1 AND status='waiting_takeover'",
)
.bind(run_id)
.execute(state.pool())
.await;
let click_misses = *ctx.click_misses.lock().unwrap();
record_run_metrics(
state,
thread_id,
run_id,
"run.paused",
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
true,
)
.await;
Ok(())
}
}
}
2026-09-03 23:43:37 +00:00
async fn renew_lease(state: &AppState, run_id: &str, lease_owner: &str) -> Result<(), String> {
let renewed = sqlx::query(
"UPDATE runs SET lease_expires_at=now()+interval '5 minutes',updated_at=now()
WHERE id=$1 AND lease_owner=$2 AND status IN ('leased','running')",
)
.bind(run_id)
.bind(lease_owner)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
if renewed.rows_affected() == 1 {
Ok(())
} else {
Err("run lease was lost".into())
}
}
fn history_window_start(summary_seq: i32, current_seq: i32) -> i32 {
summary_seq.min(current_seq.saturating_sub(1)).max(0)
}
2026-09-04 09:08:56 +00:00
async fn record_run_metrics(
state: &AppState,
thread_id: &str,
run_id: &str,
event: &str,
turns: u32,
screenshots: u32,
screenshot_bytes: u64,
click_misses: u32,
used_gui: bool,
takeover: bool,
) {
let payload = json!({
"runId": run_id,
"turns": turns,
"screenshotsToModel": screenshots,
"screenshotBytes": screenshot_bytes,
"clickMisses": click_misses,
"usedGui": used_gui,
"takeover": takeover,
});
tracing::info!(
run_id,
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
takeover,
"run metrics"
);
let _ = crate::sessions::append_event(state, thread_id, event, payload).await;
}
2026-09-03 23:43:37 +00:00
#[cfg(test)]
mod tests {
2026-09-04 09:08:56 +00:00
use super::{RunHalt, halt_from_status, history_window_start, screenshot_parts};
use rig_core::completion::message::UserContent;
2026-09-03 23:43:37 +00:00
#[test]
fn history_never_reads_past_the_current_prompt() {
assert_eq!(history_window_start(10, 5), 4);
assert_eq!(history_window_start(3, 20), 3);
assert_eq!(history_window_start(-1, 1), 0);
}
2026-09-04 09:08:56 +00:00
#[test]
fn screenshot_parts_keeps_caption_and_image() {
let parts = screenshot_parts(vec![0xFF, 0xD8, 0xFF, 0x00]);
assert_eq!(parts.len(), 2);
assert!(matches!(parts[0], UserContent::Text(_)));
assert!(matches!(parts[1], UserContent::Image(_)));
}
#[test]
fn halt_maps_paused_and_cancelled_runs() {
assert_eq!(
halt_from_status(Some("cancelled")),
Some(RunHalt::Cancelled)
);
assert_eq!(
halt_from_status(Some("waiting_takeover")),
Some(RunHalt::Takeover)
);
assert_eq!(halt_from_status(Some("running")), None);
assert_eq!(halt_from_status(None), None);
}
2026-09-03 23:43:37 +00:00
}