lazyBoy/crates/api/src/runs.rs

1081 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use lazyboy_contracts::ModelProvider;
use lazyboy_harness::{
CredentialChain, DynModel, ResolveModelRequest, connect_model, resolve_backend,
};
use rig_core::completion::message::{
AssistantContent, ImageDetail, ImageMediaType, Message, ToolResultContent, UserContent,
};
use rig_core::completion::{CompletionModel, ToolDefinition};
use serde_json::{Value, json};
use uuid::Uuid;
use crate::computer::{self, adapter_context_for};
use crate::db::{Actor, parse_mode};
use crate::state::AppState;
use crate::tools::{ToolCtx, dispatch, tool_definitions};
const SCREENSHOT_CAPTION: &str = "Desktop screenshot (1280x800) with yellow numbered marks. Click by those element ids. The live VNC view has no marks.";
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.
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)
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.
computer_act examples (native windows only):
- {\"kind\":\"click\",\"element\":1}
- {\"kind\":\"click\",\"x\":N,\"y\":N}
- {\"kind\":\"type\",\"text\":\"...\"}
- {\"kind\":\"key\",\"key\":\"Return\"}
- {\"kind\":\"focus\",\"title\":\"Open File\"}
On a Team Computer, relative files live in your bot folder; use shared/ for shared work. Finish the user's task.";
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> {
let mut tx = state
.pool()
.begin()
.await
.map_err(|error| error.to_string())?;
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
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())?;
let Some((current_title, room_id)) = scoped else {
return Err("session not found".into());
};
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)
.await
.map_err(|error| error.to_string())?;
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
}));
}
}
let run_id = Uuid::new_v4().to_string();
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())?;
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())?;
}
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())?;
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())?;
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)
.await
.map_err(|error| error.to_string())?;
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())?;
}
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
}))
}
pub async fn worker_loop(state: AppState) {
let inflight = Arc::new(tokio::sync::Semaphore::new(16));
let lease_owner = format!("api-{}", Uuid::new_v4());
loop {
tokio::time::sleep(Duration::from_millis(200)).await;
let Ok(permit) = inflight.clone().try_acquire_owned() else {
continue;
};
let queued: Result<Option<(String, String, String, String, String, String)>, _> =
sqlx::query_as(
"WITH candidate AS (
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",
)
.bind(&lease_owner)
.fetch_optional(state.pool())
.await;
let Ok(Some((run_id, bot_id, thread_id, prompt, user_id, space_id))) = queued else {
drop(permit);
continue;
};
let state = state.clone();
let owner = lease_owner.clone();
tokio::spawn(async move {
let _permit = permit;
let actor = Actor { user_id, space_id };
if let Err(error) = execute_run(
&state, &actor, &owner, &run_id, &bot_id, &thread_id, &prompt,
)
.await
{
tracing::error!("run {run_id} failed: {error}");
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()
WHERE id=$1 AND lease_owner=$3 AND status IN ('leased','running') RETURNING status",
)
.bind(&run_id)
.bind(&error)
.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,
&bot_id,
&format!("Run failed after retries: {error}"),
)
.await;
let _ = crate::sessions::append_event(
&state,
&thread_id,
"run.failed",
json!({"runId":run_id,"error":error}),
)
.await;
}
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,
actor: &Actor,
lease_owner: &str,
run_id: &str,
bot_id: &str,
thread_id: &str,
prompt: &str,
) -> Result<(), String> {
let started = sqlx::query(
"UPDATE runs SET status='running', started_at=COALESCE(started_at,now()), updated_at=now()
WHERE id=$1 AND lease_owner=$2 AND lease_expires_at>now() AND status='leased'",
)
.bind(run_id)
.bind(lease_owner)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
if started.rows_affected() != 1 {
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(());
}
return Err("run lease was lost before execution".into());
}
let _ = crate::sessions::append_event(state, thread_id, "run.started", json!({"runId":run_id}))
.await;
computer::boot(state, actor, bot_id).await?;
let bot = state
.db
.get_bot(actor, bot_id)
.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())?;
let computer_ref =
computer::computer_ref(&computer).ok_or_else(|| "computer is not running".to_string())?;
let bound = computer::ensure_bot_screen(state, actor, bot_id, &computer, Some(run_id)).await?;
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() {
gui_block =
computer::take_profile_lock(state, &computer, bot_id, &bot.name, run_id, &row)
.await?;
}
Some(row)
} else {
None
};
let space = state
.db
.get_space(actor)
.await
.map_err(|error| error.to_string())?
.ok_or_else(|| "workspace not found".to_string())?;
let provider = bot
.model_provider
.as_deref()
.or(Some(space.default_model_provider.as_str()))
.unwrap_or("xai")
.parse::<ModelProvider>()
.map_err(|error| error.to_string())?;
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()));
let backend = resolve_backend(ResolveModelRequest {
provider,
model_id,
base_url: space.default_model_base_url.clone(),
credentials: CredentialChain {
bot: None,
space: space.default_model_api_key.clone(),
env: lazyboy_harness::credential_from_env(provider),
},
})
.map_err(|error| error.to_string())?;
let model = connect_model(&backend).map_err(|error| error.to_string())?;
let ctx = Arc::new(ToolCtx {
sandbox: state.sandbox.clone(),
computer: computer_ref,
context: adapter_context_for(actor, bot_id, "run", screen.as_ref(), Some(run_id)),
mode: parse_mode(&computer.scope),
bot_id: bot_id.to_string(),
vision: backend.capabilities.vision,
gui_block,
previous_frame: std::sync::Mutex::new(None),
elements: std::sync::Mutex::new(Vec::new()),
miss_streak: std::sync::Mutex::new(0),
click_misses: std::sync::Mutex::new(0),
takeover_requested: std::sync::Mutex::new(false),
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(),
mcp: state.mcp.clone(),
});
let mut defs = tool_definitions(ctx.memory_enabled);
let mcp_defs = state.mcp.definitions().await;
if !mcp_defs.is_empty() {
defs.extend(mcp_defs);
}
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())?;
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
};
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
) history ORDER BY seq ASC",
)
.bind(thread_id)
.bind(history_window_start(summary_seq, current_seq))
.bind(history_end)
.fetch_all(state.pool())
.await
.map_err(|error| error.to_string())?;
let mut history: Vec<Message> = Vec::new();
if !summary.trim().is_empty() {
history.push(Message::User {
content: vec![UserContent::text(format!(
"Conversation summary through message {summary_seq}:\n{summary}"
))],
});
}
for (role, body, speaker_id, speaker_name) in recent {
if role == "user" {
history.push(Message::User {
content: vec![UserContent::text(body)],
});
} else if speaker_id.as_deref() == Some(bot_id) || speaker_id.is_none() {
history.push(Message::Assistant {
id: None,
content: vec![AssistantContent::text(body)],
});
} else {
let name = speaker_name.unwrap_or_else(|| "agent".into());
history.push(Message::User {
content: vec![UserContent::text(format!("[{name}]: {body}"))],
});
}
}
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));
}
}
let mut pending = Message::User { content: first };
let mut final_text = String::new();
let mut turns: u32 = 0;
let mut used_gui = false;
let memory = if ctx.memory_enabled {
match state
.memory
.recall(state.pool(), actor, bot_id, prompt, None)
.await
{
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 {
format!(
"{SYSTEM}\n\nBot-specific instructions:\n{}",
bot.instructions.trim()
)
};
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(", ")
));
}
if !memory.is_empty() {
preamble.push_str("\n\n");
preamble.push_str(&memory);
}
for _ in 0..24 {
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;
}
drop_history_screenshots(&mut history);
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
}?;
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 {
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;
}
let name = call.function.name.clone();
used_gui |= matches!(
name.as_str(),
"computer_observe" | "computer_act" | "open_path" | "launch_app" | "browser"
);
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,
},
}
};
// xAI rejects images inside tool results. Attach a changed
// 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 {
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())?;
append_bot_message(state, thread_id, run_id, bot_id, &outcome.text).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;
return Ok(());
}
}
if let Some(png) = screen {
screenshot_bytes += png.len() as u64;
screenshots += 1;
results.extend(screenshot_parts(png));
}
pending = Message::User { content: results };
}
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())?;
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;
}
append_bot_message(state, thread_id, run_id, bot_id, &final_text).await?;
let completed = sqlx::query(
"UPDATE runs
SET status='completed', completed_at=now(), updated_at=now(),
lease_owner=NULL, lease_expires_at=NULL
WHERE id=$1 AND lease_owner=$2 AND status='running'",
)
.bind(run_id)
.bind(lease_owner)
.execute(state.pool())
.await
.map_err(|error| error.to_string())?;
if completed.rows_affected() != 1 {
return Err("run lease was lost before completion".into());
}
let click_misses = *ctx.click_misses.lock().unwrap();
let takeover = *ctx.takeover_requested.lock().unwrap();
record_run_metrics(
state,
thread_id,
run_id,
"run.completed",
turns,
screenshots,
screenshot_bytes,
click_misses,
used_gui,
takeover,
)
.await;
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(())
}
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);
vec![
UserContent::text(SCREENSHOT_CAPTION),
UserContent::image_base64(encoded, Some(media), Some(ImageDetail::Low)),
]
}
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,
});
}
}
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())?;
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(
"INSERT INTO messages (id,thread_id,seq,role,body,run_id,speaker_bot_id)
VALUES ($1,$2,$3,'assistant',$4,$5,$6)",
)
.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())?;
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;
Ok(())
}
#[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(())
}
}
}
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)
}
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;
}
#[cfg(test)]
mod tests {
use super::{RunHalt, halt_from_status, history_window_start, screenshot_parts};
use rig_core::completion::message::UserContent;
#[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);
}
#[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);
}
}