//! Multi-step OpenAI-compatible tool-calling ReAct loop
//! Tool results drive continuation; request budgets are runtime stop conditions.
use crate::config::Config;
use crate::model::{ChatMessage, Role, ToolCall};
use crate::runtime::AgentEvent;
use crate::tools::{
execute_tool, is_completion_tool, is_delivery_tool, is_parallel_safe, tool_definitions_for,
ToolContext,
};
use futures_util::future::join_all;
use anyhow::Result;
use serde_json::Value;
use std::collections::HashMap;
use std::future::Future;
/// Local progress beat. No extra model call is made for progress.
pub const DEFAULT_MAX_ROUNDS: usize = 12;
/// Absolute model-request ceiling per user turn (Grok Bot Sand is 5000 steps).
pub const DEFAULT_MAX_ROUNDS_TOTAL: usize = 5_000;
pub const DEFAULT_CONTEXT_CHARS: usize = 100_000;
pub const LOOP_GUARD_REMIND: usize = 2;
pub const LOOP_GUARD_REPEAT: usize = 3;
pub const SEND_MESSAGE_SILENCE_THRESHOLD: usize = 6;
pub const EMPTY_RESPONSE_RETRIES: usize = 3;
const START_OF_TURN_ACK_REMINDER: &str = "\nYou opened this turn by calling tools without first acknowledging the user. Invoke send_message NOW with a one-line text acknowledgement before any further tool call. Plain assistant text is never shown; only send_message reaches the user.\n";
const SILENCE_REMINDER: &str = "\nYou have made several tool calls without send_message, so the user is watching silence. Invoke send_message with a brief, specific update on what you are doing or just found, then continue.\n";
const EMPTY_RESPONSE_CONTINUATION: &str = "Please continue. Send a send_message to the user or make tool calls.";
const LOOP_REMINDER: &str = "Your last tool calls and results repeated. Change approach, inspect new evidence, or call report_blocked. Do not retry the same action unchanged.";
const DELIVER_THEN_YIELD_REMINDER: &str = "If you have a result the user is waiting on, send it with send_message, then end the turn by responding with no tool calls. Plain assistant text is not delivered.";
const PLAN_STEP_DONE_REMINDER: &str = "\nYou marked a plan step completed. send_message the user NOW with what you found and which step is next. Do not continue silent tool work until you have reported this step.\n";
pub const AGENT_SYSTEM: &str = "\
You are GrokBoy, a warm, concise local Grok Bot-style agent.
## How a turn works
1. Reply first. On any turn a person opened, your first action is send_message (plain text): answer directly if it is quick, or acknowledge and name the first step if it is real work. Never open such a turn with a tool call. A hidden revival (a background command finishing) may start work silently if there is nothing to report.
2. Pick the surface. Default to shell and read on your Docker computer; web_search/web_fetch for public information; the box browser for login-gated sites; shell/read to inspect box files; send_message widget or request_user_input only when the user must decide.
3. Work out loud. Keep the user posted with send_message on meaningful beats (a result, decision, blocker, change of plan). Do not vanish into a long silent run of tool calls.
4. Close the loop. Deliver the result in send_message. Then end the turn by responding with NO tool calls.
## Plans
When the work has stages, keep a short update_plan. After you mark a step completed, send_message in that same moment with the finding (what you learned) and the next step. The user should see a report after every completed line, not one dump at the end. Do not mark completed until you actually have the result.
## send_message is your only voice
Plain assistant text is a private scratchpad the user never sees. Nothing is delivered until it is send_message content — replies, progress, questions, and results. An opening acknowledgement does not discharge delivery: ack ≠ delivery. If you ran something for the user, send the actual output in send_message before you yield.
Deciding to send is not sending. When a message is owed, invoke send_message in that same step.
## Ending a turn
Do not call report_done to finish ordinary work. After send_message, respond with no tools. report_done is accepted but unnecessary. report_blocked is for being stuck, with 2–3 concrete alternative routes.
A send_message widget, request_user_input, request_user_confirm, browser_handoff, or request_box_help must be called ALONE. They end the turn; the user's next message is the answer. Do not sit in a timeout waiting for a human.
## Commands
shell is the default command tool on your box. It waits briefly then returns a session_id if still running; the command is not killed. Use await_shell for its result when needed. Never rerun a slow box command with an external tool.
external_exec_command waits up to block_until_ms (default 30000ms) then backgrounds. Set 0 to background immediately (dev servers, watchers). Use external_await_command or external_write_stdin to observe. Do not sit in a tight poll loop if you can keep working or end the turn; the command keeps running. If you yield while a command is still running, you are revived automatically when it exits.
## Delegating background work
Use spawn_subagent for a self-contained chunk (research, files, a multi-step investigation). It returns immediately with subagent_id. After dispatch, do not sit idle: send_message that you kicked it off, keep working, or end the turn with no tool calls. You are revived automatically when it finishes — never poll check_subagent for completion. check_subagent inspects a running child that may be stuck. message_subagent injects an instruction without aborting. stop_subagent aborts one. This revival is self-triggered, not someone reaching out; if the result is irrelevant and the user was not waiting, end with no send_message.
kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot, click, drag, type, key, scroll, and wait. Only one computerUse may run at a time (they share the screen); while it runs, leave the screen to it and use screenshot only to check in. Scope the goal tightly — site, exact values, success criteria, stopping point. If it needs a human (password, 2FA, captcha, payment), it stops and reports; then you call request_box_help and dispatch it again to continue.
## Two computers
You have two machines. To the user, call the box \"my computer\" and the launch machine \"your computer\". Never mix paths.
- shell / read / await_shell / screenshot run on MY computer (Docker Linux). Scratch is /workspace; profile is /home/box. Use this for installs, experiments, and login-gated sites that have no MCP. First use may take a while to boot — tell the user.
- external_exec_command / external_read_file / external_edit_file / external_write_file run on the USER's computer only when the user specifically asks for their local files or machine. Do not use external tools for ordinary research, scratch files, installs or experiments. The default filesystem is the box; transfer finished deliverables with copy_from_box.
- copy_to_box / copy_from_box move files between the two filesystems. A box path is invisible to external_exec_command; a user path is invisible to shell.
- screenshot is read-only. You cannot click, move, type, press keys, scroll, or wait on the desktop yourself. Delegate every desktop interaction to spawn_subagent kind=computerUse; like any subagent it runs in the background, so you keep working and are revived with its result. Do not bypass this boundary with shell-driven GUI automation such as xdotool, or by driving the box browser from shell — no CDP attach, no Playwright/Puppeteer from the parent, no /json/new. Prefer browser_* (DOM snapshot + selector/role) for ordinary web; use computerUse for GUI apps, file dialogs, drag, or a site that defeats page-level automation.
- When the user must type a password or 2FA, call request_box_help (ends the turn) and give a one-line instruction; they open the same computer in a browser. Do not first ask whether to hand it over. Do not launch a separate browser/profile. Browser upload/download paths are box paths under /workspace; use copy_to_box/copy_from_box for local files.
## Files and web
external_read_file pages by line. external_grep uses regex; external_glob finds names; external_search_files is literal. external_edit_file needs one unique exact match. web_search/web_fetch use a separate remote service (configured AiService gateway or native xAI web search with the existing official xAI model configuration) and never carry browser cookies. xAI fetched content is model-rendered, not verbatim source bytes. If that service is unconfigured, report the configuration error; do not silently search using a logged-in browser. browser_* defaults to Docker Chromium and shares its /home/box/chrome-profile with request_box_help and the desktop. Your normal local Chrome profile is not imported. GROKBOY_BROWSER_SURFACE=local explicitly selects the legacy separate local browser, never an automatic fallback. Use the box browser for authenticated pages. Prefer DOM snapshot + selector/role, not pixel clicks. Re-observe after navigation or resume.
## MCP
Installed connectors are structured access to services (mail, issues, docs). Read a tool's schema with get_mcp_tools first, then invoke it with call_mcp_tool — every call is live. Prefer a service's MCP over its browser UI, including a connector you would have to add first (add_mcp_server). If a call fails or looks like a no-op, refetch the descriptor with get_mcp_tools and compare; if the schema changed, rebuild arguments. Ask the user for secrets rather than guessing. Config lives in ~/.grokboy/mcp.json.
## Collaboration and safety
A blocked step is not a failed task. For login/OTP/captcha use browser_handoff on the SAME browser. Never collect passwords in chat. Never publish or take irreversible public actions without explicit approval this turn or request_user_confirm. Traditional Chinese is welcome when the user writes in Chinese. Do not invent tool results.";
/// Final verdict from the agent loop (fail-closed when stuck).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentVerdict {
/// Model called `report_done`.
Done(String),
/// Model called `report_blocked`, or repeated observations showed no progress.
Blocked(String),
/// Model returned final text without a completion tool.
Answer(String),
/// Runtime ceiling, not a model claim that the task is blocked.
BudgetExhausted(String),
/// Provider/protocol/context failure; conversation can be resumed.
Failed(String),
Cancelled(String),
/// Parked for a human reply or background work. Next user line or a revival continues.
Waiting(String),
}
impl AgentVerdict {
pub fn message(&self) -> &str {
match self {
Self::Done(s)
| Self::Blocked(s)
| Self::Answer(s)
| Self::BudgetExhausted(s)
| Self::Failed(s)
| Self::Cancelled(s)
| Self::Waiting(s) => s,
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::Done(_) => "done",
Self::Blocked(_) => "blocked",
Self::Answer(_) => "answer",
Self::BudgetExhausted(_) => "budget_exhausted",
Self::Failed(_) => "failed",
Self::Cancelled(_) => "cancelled",
Self::Waiting(_) => "waiting",
}
}
}
/// Resolve context budget from `GROKBOY_CONTEXT_CHARS` or default.
pub fn context_char_budget() -> usize {
std::env::var("GROKBOY_CONTEXT_CHARS")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_CONTEXT_CHARS)
}
/// Resolve progress interval from `GROKBOY_MAX_ROUNDS` or default.
pub fn max_rounds_budget() -> usize {
std::env::var("GROKBOY_MAX_ROUNDS")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_MAX_ROUNDS)
}
/// Resolve absolute round ceiling from `GROKBOY_MAX_ROUNDS_TOTAL` or default.
pub fn max_rounds_total_budget() -> usize {
std::env::var("GROKBOY_MAX_ROUNDS_TOTAL")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_MAX_ROUNDS_TOTAL)
}
fn emit_progress_line(msg: &str, runtime: &crate::Runtime, on_progress: &mut impl FnMut(&str)) {
runtime.emit(crate::AgentEvent::Status {
message: msg.into(),
});
on_progress(msg);
}
/// Short success/fail line for a finished tool invocation.
fn tool_progress_line(name: &str, result_json: &str) -> String {
let v: Value = serde_json::from_str(result_json).unwrap_or(Value::Null);
if let Some(err) = v.get("error") {
let s = err
.as_str()
.map(|x| x.to_string())
.unwrap_or_else(|| err.to_string());
return format!("〔失敗〕{name}: {}", preview_progress(&s, 80));
}
if v.get("blocked").and_then(|b| b.as_bool()) == Some(true) {
let reason = v
.get("reason")
.and_then(|r| r.as_str())
.unwrap_or("blocked");
return format!("〔失敗〕{name}: {}", preview_progress(reason, 80));
}
if v["exit_code"].as_i64().is_some_and(|n| n != 0) || v["approved"] == false {
return format!("〔失敗〕{name}: {}", preview_progress(&v.to_string(), 240));
}
if v["running"] == true {
return format!("〔執行中〕{name}");
}
format!("〔完成〕{name}")
}
/// Largest byte index ≤ `max` that sits on a UTF-8 char boundary.
fn floor_char_boundary(s: &str, max: usize) -> usize {
if max >= s.len() {
return s.len();
}
let mut end = max;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
end
}
fn preview_progress(text: &str, max_chars: usize) -> String {
let text = text.trim();
let preview: String = text.chars().take(max_chars).collect();
if text.chars().count() > max_chars {
format!("{preview}…")
} else {
preview
}
}
/// Stable signature for a single tool call (name + args).
pub fn tool_call_signature(call: &ToolCall) -> String {
let args = serde_json::from_str::(&call.function.arguments)
.map(|v| v.to_string())
.unwrap_or_else(|_| call.function.arguments.clone());
format!("{}:{}", call.function.name, args)
}
/// Signature for a whole round of tool calls (order-preserving).
pub fn round_signature(calls: &[ToolCall]) -> String {
calls
.iter()
.map(tool_call_signature)
.collect::>()
.join("\n")
}
/// Approximate serialized size in UTF-8 bytes (legacy config name uses chars).
pub fn message_char_len(msg: &ChatMessage) -> usize {
let mut n = 8; // role overhead
if let Some(c) = &msg.content {
n += c.len();
}
if let Some(id) = &msg.tool_call_id {
n += id.len();
}
if let Some(calls) = &msg.tool_calls {
for c in calls {
n += c.id.len() + c.function.name.len() + c.function.arguments.len() + 16;
}
}
n
}
pub fn messages_char_len(messages: &[ChatMessage]) -> usize {
messages.iter().map(message_char_len).sum()
}
/// Truncate older tool results / middle turns while keeping system + recent tail + last user goal.
/// Mutates in place. Keeps it simple: shrink old tool contents first, then drop middle messages.
pub fn truncate_messages(messages: &mut Vec, budget: usize) {
if budget == 0 || messages_char_len(messages) <= budget {
return;
}
// 1) Shrink older tool message contents (keep last few tool results intact).
let tool_indices: Vec = messages
.iter()
.enumerate()
.filter(|(_, m)| m.role == Role::Tool)
.map(|(i, _)| i)
.collect();
let keep_recent_tools = 4usize;
let shrink_until = tool_indices.len().saturating_sub(keep_recent_tools);
for &idx in tool_indices.iter().take(shrink_until) {
if let Some(content) = messages[idx].content.as_mut() {
if content.len() > 120 {
let omitted = content.len();
*content = format!("[truncated tool result; was {omitted} chars]");
}
}
if messages_char_len(messages) <= budget {
return;
}
}
// Keep system and every user constraint. Remove complete older tool groups only.
while messages_char_len(messages) > budget {
let candidate = messages
.iter()
.enumerate()
.find(|(i, m)| {
*i + 2 < messages.len()
&& m.role == Role::Assistant
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
})
.map(|(i, _)| i);
let Some(i) = candidate else { break };
let ids = messages[i]
.tool_calls
.as_ref()
.unwrap()
.iter()
.map(|c| c.id.clone())
.collect::>();
messages.remove(i);
while messages.get(i).is_some_and(|m| {
m.role == Role::Tool && m.tool_call_id.as_ref().is_some_and(|id| ids.contains(id))
}) {
messages.remove(i);
}
}
if messages_char_len(messages) > budget {
for msg in messages.iter_mut().filter(|m| m.role == Role::Tool) {
if let Some(content) = msg.content.as_mut() {
if content.len() > 200 {
let keep = floor_char_boundary(content, 200);
*content = format!("{}… [truncated]", &content[..keep]);
}
}
}
}
}
/// Run the agent with a live HTTP model client.
/// `max_rounds` is the progress interval; total request ceiling comes from env.
pub async fn run_agent(
config: &Config,
messages: &mut Vec,
tool_ctx: &ToolContext,
max_rounds: usize,
) -> Result {
let budget = context_char_budget();
let total = max_rounds_total_budget();
let config = config.clone();
let shared: crate::tools::SharedCompleter = std::sync::Arc::new(move |msgs, tools| {
let config = config.clone();
Box::pin(async move {
crate::model::chat_completion_for(&config, &msgs, tools.as_ref()).await
})
});
*tool_ctx.model.lock().unwrap() = Some(shared.clone());
run_agent_with(
messages,
tool_ctx,
max_rounds,
total,
budget,
move |msgs, tools| shared(msgs, tools),
)
.await
}
/// Core loop with injectable completer (for offline tests).
/// `max_rounds` = progress interval; `max_rounds_total` = absolute model-request ceiling.
/// `complete` receives a snapshot of messages and optional tool defs each round.
/// Every model request receives tools and counts against the total budget.
pub async fn run_agent_with(
messages: &mut Vec,
tool_ctx: &ToolContext,
max_rounds: usize,
max_rounds_total: usize,
context_budget: usize,
complete: F,
) -> Result
where
F: FnMut(Vec, Option) -> Fut,
Fut: Future