860 lines
30 KiB
Rust
860 lines
30 KiB
Rust
mod team_cli;
|
||
mod ui;
|
||
use anyhow::{anyhow, Context, Result};
|
||
use lazyboy_core::{
|
||
execute_tool, is_completion_tool, load_or_create, max_rounds_budget, max_rounds_total_budget,
|
||
messages_char_len, run_agent, save_session, stream_chat, tool_definitions, truncate_messages,
|
||
AgentEvent, AgentVerdict, BoxHub, ChatMessage, Config, InputBroker, Runtime, Session,
|
||
ToolContext, AGENT_SYSTEM,
|
||
};
|
||
use serde_json::json;
|
||
use std::io::{self, Write};
|
||
use std::path::PathBuf;
|
||
use std::process::ExitCode;
|
||
|
||
const CHAT_SYSTEM: &str = "You are LazyBoy, a concise local coding assistant. Prefer clear, short answers. Traditional Chinese is welcome when the user writes in Chinese.";
|
||
|
||
#[tokio::main]
|
||
async fn main() -> ExitCode {
|
||
if let Err(err) = run().await {
|
||
eprintln!("error: {err:#}");
|
||
return if err.downcast_ref::<CancelledExit>().is_some() {
|
||
ExitCode::from(130)
|
||
} else {
|
||
ExitCode::FAILURE
|
||
};
|
||
}
|
||
ExitCode::SUCCESS
|
||
}
|
||
|
||
#[derive(Debug)]
|
||
struct CancelledExit;
|
||
impl std::fmt::Display for CancelledExit {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str("agent cancelled")
|
||
}
|
||
}
|
||
impl std::error::Error for CancelledExit {}
|
||
|
||
fn render_agent_event(event: &AgentEvent) {
|
||
ui::render_event(event);
|
||
}
|
||
|
||
fn start_input() -> std::sync::Arc<InputBroker> {
|
||
let input = InputBroker::stdin();
|
||
let signals = input.clone();
|
||
tokio::spawn(async move {
|
||
while tokio::signal::ctrl_c().await.is_ok() {
|
||
signals.interrupt();
|
||
}
|
||
});
|
||
input
|
||
}
|
||
|
||
async fn execute_turn(
|
||
config: &Config,
|
||
session: &mut Session,
|
||
ctx: &ToolContext,
|
||
input: &std::sync::Arc<InputBroker>,
|
||
) -> Result<AgentVerdict> {
|
||
input.begin();
|
||
let runtime = Runtime::for_session(session, input.clone());
|
||
runtime.set_event_handler(render_agent_event);
|
||
let turn_ctx = ctx.clone().with_runtime(runtime.clone());
|
||
let result = run_agent(
|
||
config,
|
||
&mut session.messages,
|
||
&turn_ctx,
|
||
max_rounds_budget(),
|
||
)
|
||
.await;
|
||
input.end();
|
||
runtime.sync_session(session);
|
||
session.last_browser_url = turn_ctx
|
||
.last_browser_url_value()
|
||
.or(session.last_browser_url.clone());
|
||
let verdict =
|
||
result.unwrap_or_else(|error| AgentVerdict::Failed(format!("runtime failed: {error:#}")));
|
||
session.last_verdict = Some(verdict.kind().into());
|
||
session.last_message = Some(verdict.message().into());
|
||
session.touch();
|
||
save_session(session)?;
|
||
Ok(verdict)
|
||
}
|
||
|
||
async fn run() -> Result<()> {
|
||
lazyboy_core::bootstrap_env();
|
||
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
|
||
if args.is_empty() {
|
||
print_help();
|
||
return Ok(());
|
||
}
|
||
let cmd = args.remove(0);
|
||
match cmd.as_str() {
|
||
"serve" => lazyboy_core::team::serve().await,
|
||
"agents" => team_cli::agents(&args).await,
|
||
"agent" if args.first().map(String::as_str) == Some("--name") => {
|
||
team_cli::chat(args.get(1).ok_or_else(|| anyhow!("missing agent name"))?).await
|
||
}
|
||
"chat" => cmd_chat().await,
|
||
"run" => cmd_run(&args).await,
|
||
"agent" => cmd_agent(&args).await,
|
||
"smoke" => cmd_smoke().await,
|
||
"computer" => cmd_computer().await,
|
||
"web" => lazyboy_core::serve_web(lazyboy_core::WebListen::default()).await,
|
||
"version" | "-V" | "--version" => {
|
||
println!("lazyboy {}", env!("CARGO_PKG_VERSION"));
|
||
Ok(())
|
||
}
|
||
"help" | "-h" | "--help" => {
|
||
print_help();
|
||
Ok(())
|
||
}
|
||
other => {
|
||
eprintln!("unknown command: {other}\n");
|
||
print_help();
|
||
Err(anyhow!("unknown command"))
|
||
}
|
||
}
|
||
}
|
||
|
||
fn print_help() {
|
||
println!(
|
||
"\
|
||
LazyBoy — minimal local CLI agent
|
||
|
||
USAGE:
|
||
lazyboy serve Start named-agent daemon (Unix socket + HTTPS UI :8787)
|
||
lazyboy agents create <name> Create persistent agent in current workspace
|
||
lazyboy agents list List agent expertise
|
||
lazyboy agent --name <name> Chat with a persistent agent
|
||
lazyboy chat Interactive streaming chat (no tools)
|
||
lazyboy run \"<prompt>\" One-shot agent with tools
|
||
lazyboy run --session <id> \"...\" Continue a saved session
|
||
lazyboy agent Interactive multi-turn agent REPL (with tools)
|
||
lazyboy agent --session <id> Resume an agent session
|
||
lazyboy computer Start my computer (Docker box) and print the viewer URL
|
||
lazyboy web Start (or attach to) serve + HTTPS UI on :8787
|
||
Frontend is independent: cd web && npm run dev
|
||
lazyboy smoke Offline checks (no API key required)
|
||
lazyboy version
|
||
lazyboy help
|
||
|
||
ENV:
|
||
LAZYBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY)
|
||
LAZYBOY_BASE_URL default https://api.x.ai/v1
|
||
LAZYBOY_MODEL default grok-4.6
|
||
LAZYBOY_CONTEXT_CHARS approximate context byte budget (default 100000)
|
||
LAZYBOY_SESSIONS_DIR optional session storage directory
|
||
LAZYBOY_MAX_ROUNDS Progress interval in model requests (default 12)
|
||
LAZYBOY_MAX_ROUNDS_TOTAL Model request ceiling per user turn (default 5000)
|
||
LAZYBOY_PROGRESS 0 = silence live progress on stderr (思考/工具/續跑/結束)
|
||
LAZYBOY_BROWSER_SURFACE Docker by default; local selects the legacy local browser
|
||
LAZYBOY_BROWSER_HEADED 1 = visible Chromium in legacy local mode
|
||
LAZYBOY_WEB_BACKEND_URL Optional AiService-compatible web gateway
|
||
LAZYBOY_WEB_PROVIDER xai = enable native search on an xAI-compatible proxy
|
||
LAZYBOY_WEB_HOST web UI bind (default 0.0.0.0)
|
||
LAZYBOY_WEB_PORT web UI port (default 8787)
|
||
LAZYBOY_TLS 0 = plain HTTP only (default HTTP+HTTPS on the same port)
|
||
LAZYBOY_WEB_TOKEN optional bearer token for the web UI
|
||
LAZYBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort
|
||
LAZYBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny
|
||
(falls back to HANDOFF_AUTO if unset)
|
||
|
||
Tools: send_message, report_progress, update_plan, request_user_input, report_done, report_blocked, grep, glob, web_fetch, await_command,
|
||
exec_command, write_stdin, shell, search_files, list_dir, read_file, edit_file, write_file,
|
||
request_user_confirm, browser_navigate, browser_snapshot, browser_read_page,
|
||
browser_click, browser_type, browser_press, browser_select, browser_scroll, browser_wait,
|
||
browser_tabs, browser_upload, browser_download, browser_eval, browser_handoff
|
||
Sessions: ~/.lazyboy/sessions/<id>.json
|
||
Browser defaults to Docker. Legacy local setup: cd tools/playwright && npm i && npx playwright install chromium
|
||
Handoff: agent pauses on login/OTP/captcha → you fix in headed Chromium → Enter
|
||
REPL: /exit /quit leave; /session show id; /plan show plan; /stop or Ctrl-C stops a turn
|
||
During a task: enter additional instructions to steer the next step.
|
||
"
|
||
);
|
||
}
|
||
|
||
async fn cmd_computer() -> Result<()> {
|
||
let ready = BoxHub::new().ensure_ready().await?;
|
||
println!("我的電腦已就緒");
|
||
println!(
|
||
"{}",
|
||
ready["host_viewer_url"]
|
||
.as_str()
|
||
.unwrap_or(&BoxHub::viewer_url())
|
||
);
|
||
if let Some(msg) = ready["instruction"].as_str() {
|
||
eprintln!("{msg}");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn cmd_chat() -> Result<()> {
|
||
let config = Config::from_env().map_err(anyhow::Error::msg)?;
|
||
println!(
|
||
"LazyBoy chat model={} base={}",
|
||
config.model, config.base_url
|
||
);
|
||
println!("Type a message. Empty line or /exit to quit.\n");
|
||
|
||
let mut history = vec![ChatMessage::system(CHAT_SYSTEM)];
|
||
let stdin = io::stdin();
|
||
let mut stdout = io::stdout();
|
||
|
||
loop {
|
||
ui::print_you_prompt();
|
||
let mut line = String::new();
|
||
if stdin.read_line(&mut line).context("stdin")? == 0 {
|
||
println!();
|
||
break;
|
||
}
|
||
let input = line.trim();
|
||
if input.is_empty() || input == "/exit" || input == "/quit" {
|
||
break;
|
||
}
|
||
history.push(ChatMessage::user(input));
|
||
|
||
println!();
|
||
print!("Grok ");
|
||
stdout.flush().ok();
|
||
let reply = stream_chat(&config, &history, |delta| {
|
||
print!("{delta}");
|
||
let _ = io::stdout().flush();
|
||
})
|
||
.await?;
|
||
println!("\n");
|
||
history.push(ChatMessage::assistant(reply));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
async fn cmd_run(args: &[String]) -> Result<()> {
|
||
let mut session_id: Option<String> = None;
|
||
let mut prompt_parts: Vec<String> = Vec::new();
|
||
let mut i = 0;
|
||
while i < args.len() {
|
||
match args[i].as_str() {
|
||
"--session" | "-s" => {
|
||
i += 1;
|
||
let id = args
|
||
.get(i)
|
||
.ok_or_else(|| anyhow!("--session requires an id"))?;
|
||
session_id = Some(id.clone());
|
||
}
|
||
"--help" | "-h" => {
|
||
println!(
|
||
"Usage: lazyboy run [--session <id>] \"<prompt>\"\n\
|
||
Runs a one-shot tool-using agent. Creates a session under ~/.lazyboy/sessions/."
|
||
);
|
||
return Ok(());
|
||
}
|
||
other => prompt_parts.push(other.to_string()),
|
||
}
|
||
i += 1;
|
||
}
|
||
let prompt = prompt_parts.join(" ");
|
||
if prompt.trim().is_empty() {
|
||
return Err(anyhow!("usage: lazyboy run [--session <id>] \"<prompt>\""));
|
||
}
|
||
|
||
let config = Config::from_env().map_err(anyhow::Error::msg)?;
|
||
let cwd = std::env::current_dir().context("cwd")?;
|
||
let mut session = load_or_create(session_id.as_deref(), &cwd)?;
|
||
|
||
// Ensure system prompt is present once at the start.
|
||
if session
|
||
.messages
|
||
.first()
|
||
.is_some_and(|m| m.role == lazyboy_core::Role::System)
|
||
{
|
||
session.messages[0] = ChatMessage::system(AGENT_SYSTEM);
|
||
} else {
|
||
session
|
||
.messages
|
||
.insert(0, ChatMessage::system(AGENT_SYSTEM));
|
||
}
|
||
session.cwd = cwd.clone();
|
||
if matches!(session.last_verdict.as_deref(), Some("done" | "answer")) {
|
||
session.plan.clear();
|
||
}
|
||
session.push(ChatMessage::user(&prompt));
|
||
|
||
let tool_ctx = ToolContext::new(session.cwd.clone());
|
||
if let Some(url) = &session.last_browser_url {
|
||
*tool_ctx.last_browser_url.lock().unwrap() = Some(url.clone());
|
||
}
|
||
let input = start_input();
|
||
let verdict = execute_turn(&config, &mut session, &tool_ctx, &input).await?;
|
||
tool_ctx.shutdown().await;
|
||
session.last_verdict = Some(verdict.kind().into());
|
||
session.last_message = Some(verdict.message().into());
|
||
|
||
if let Some(url) = tool_ctx.last_browser_url_value() {
|
||
session.last_browser_url = Some(url);
|
||
}
|
||
session.touch();
|
||
let path = save_session(&session)?;
|
||
print_verdict(&verdict);
|
||
ui::print_run_footer(verdict.kind(), &session.id, &path);
|
||
if matches!(verdict, AgentVerdict::Cancelled(_)) {
|
||
return Err(CancelledExit.into());
|
||
}
|
||
// Non-zero exit on blocked so scripts can detect fail-closed.
|
||
if matches!(verdict, AgentVerdict::Waiting(_)) {
|
||
eprintln!(
|
||
"提示:已暫停等你。下一句話就是回覆: lazyboy run --session {} \"你的回覆\"",
|
||
session.id
|
||
);
|
||
return Ok(());
|
||
}
|
||
if matches!(
|
||
verdict,
|
||
AgentVerdict::Blocked(_) | AgentVerdict::BudgetExhausted(_) | AgentVerdict::Failed(_)
|
||
) {
|
||
eprintln_blocked_recovery_hint_run(&session.id);
|
||
return Err(anyhow!("agent {}: {}", verdict.kind(), verdict.message()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Parse `agent` CLI flags. Returns (session_id, show_help).
|
||
fn parse_agent_args(args: &[String]) -> Result<(Option<String>, bool)> {
|
||
let mut session_id: Option<String> = None;
|
||
let mut i = 0;
|
||
while i < args.len() {
|
||
match args[i].as_str() {
|
||
"--session" | "-s" => {
|
||
i += 1;
|
||
let id = args
|
||
.get(i)
|
||
.ok_or_else(|| anyhow!("--session requires an id"))?;
|
||
session_id = Some(id.clone());
|
||
}
|
||
"--help" | "-h" => return Ok((session_id, true)),
|
||
other => {
|
||
return Err(anyhow!(
|
||
"unexpected argument: {other}\nusage: lazyboy agent [--session <id>]"
|
||
));
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
Ok((session_id, false))
|
||
}
|
||
|
||
async fn cmd_agent(args: &[String]) -> Result<()> {
|
||
let (session_id, show_help) = parse_agent_args(args)?;
|
||
if show_help {
|
||
println!(
|
||
"Usage: lazyboy agent [--session <id>]\n\
|
||
Interactive multi-turn ReAct agent with tools.\n\
|
||
Creates/resumes a session under ~/.lazyboy/sessions/.\n\
|
||
Commands: /exit /quit leave; /session show id; empty line ignored.\n\
|
||
Tip: LAZYBOY_BROWSER_HEADED=1 for visible Chromium (handoff)."
|
||
);
|
||
return Ok(());
|
||
}
|
||
|
||
let config = Config::from_env().map_err(anyhow::Error::msg)?;
|
||
let cwd = std::env::current_dir().context("cwd")?;
|
||
let created_new = session_id.is_none();
|
||
let mut session = load_or_create(session_id.as_deref(), &cwd)?;
|
||
|
||
if session
|
||
.messages
|
||
.first()
|
||
.is_some_and(|m| m.role == lazyboy_core::Role::System)
|
||
{
|
||
session.messages[0] = ChatMessage::system(AGENT_SYSTEM);
|
||
} else {
|
||
session
|
||
.messages
|
||
.insert(0, ChatMessage::system(AGENT_SYSTEM));
|
||
}
|
||
session.cwd = cwd.clone();
|
||
|
||
// One ToolContext for the whole REPL so browser URL/state carries across turns.
|
||
let mut tool_ctx = ToolContext::new(session.cwd.clone());
|
||
tool_ctx.hold_background = false;
|
||
if let Some(url) = &session.last_browser_url {
|
||
*tool_ctx.last_browser_url.lock().unwrap() = Some(url.clone());
|
||
}
|
||
|
||
// Persist early so the printed session id is on disk.
|
||
let path = save_session(&session)?;
|
||
|
||
ui::print_session_header(
|
||
&config.model,
|
||
&session.id,
|
||
if created_new {
|
||
"新 session"
|
||
} else {
|
||
"續跑"
|
||
},
|
||
);
|
||
eprintln!("{}", path.display());
|
||
eprintln!("問你時停住,下一句就是答案。背景工作進行中也可先打字。/exit 離開。");
|
||
|
||
let input_broker = start_input();
|
||
|
||
loop {
|
||
let live = tool_ctx.has_live_background().await || tool_ctx.has_parked_background();
|
||
if live {
|
||
eprintln!("背景工作進行中 · 完成後會自動繼續 · 也可以先打字");
|
||
}
|
||
ui::print_you_prompt();
|
||
let incoming = if live {
|
||
tokio::select! {
|
||
line = input_broker.next() => line,
|
||
_ = tool_ctx.wait_background_progress() => {
|
||
println!();
|
||
eprintln!("背景完成,繼續");
|
||
None
|
||
}
|
||
}
|
||
} else {
|
||
input_broker.next().await
|
||
};
|
||
let Some(line) = incoming else {
|
||
if live {
|
||
let verdict =
|
||
execute_turn(&config, &mut session, &tool_ctx, &input_broker).await?;
|
||
session.last_verdict = Some(verdict.kind().into());
|
||
session.last_message = Some(verdict.message().into());
|
||
if let Some(url) = tool_ctx.last_browser_url_value() {
|
||
session.last_browser_url = Some(url);
|
||
}
|
||
session.touch();
|
||
let _path = save_session(&session)?;
|
||
print_verdict(&verdict);
|
||
continue;
|
||
}
|
||
println!();
|
||
break;
|
||
};
|
||
let input = line.trim();
|
||
if input.is_empty() {
|
||
continue;
|
||
}
|
||
if input == "/exit" || input == "/quit" {
|
||
break;
|
||
}
|
||
if input == "/session" {
|
||
println!("session {}", session.id);
|
||
continue;
|
||
}
|
||
|
||
if input == "/plan" {
|
||
println!("{}", serde_json::to_string_pretty(&session.plan)?);
|
||
continue;
|
||
}
|
||
if input == "/stop" {
|
||
continue;
|
||
}
|
||
if matches!(session.last_verdict.as_deref(), Some("done" | "answer")) {
|
||
session.plan.clear();
|
||
}
|
||
session.push(ChatMessage::user(input));
|
||
let verdict = execute_turn(&config, &mut session, &tool_ctx, &input_broker).await?;
|
||
session.last_verdict = Some(verdict.kind().into());
|
||
session.last_message = Some(verdict.message().into());
|
||
|
||
if let Some(url) = tool_ctx.last_browser_url_value() {
|
||
session.last_browser_url = Some(url);
|
||
}
|
||
session.touch();
|
||
let _path = save_session(&session)?;
|
||
|
||
print_verdict(&verdict);
|
||
if matches!(
|
||
verdict,
|
||
AgentVerdict::Blocked(_) | AgentVerdict::BudgetExhausted(_) | AgentVerdict::Failed(_)
|
||
) {
|
||
eprintln_blocked_recovery_hint_agent();
|
||
}
|
||
}
|
||
|
||
tool_ctx.shutdown().await;
|
||
session.touch();
|
||
let _ = save_session(&session)?;
|
||
Ok(())
|
||
}
|
||
|
||
async fn cmd_smoke() -> Result<()> {
|
||
println!("LazyBoy smoke (offline P8 auto-continue)…");
|
||
let stamp = uuid_like();
|
||
let dir = std::env::temp_dir().join(format!("lazyboy-smoke-{stamp}"));
|
||
std::fs::create_dir_all(&dir).context("temp dir")?;
|
||
let ctx = ToolContext::new(dir.clone());
|
||
|
||
// write_file
|
||
let w = execute_tool(
|
||
&ctx,
|
||
"external_write_file",
|
||
&json!({"path": "note.txt", "content": "smoke ok\n第二行"}).to_string(),
|
||
)
|
||
.await;
|
||
let w: serde_json::Value = serde_json::from_str(&w)?;
|
||
assert_ok(&w, "external_write_file")?;
|
||
println!(" write_file ok");
|
||
|
||
// read_file
|
||
let r = execute_tool(&ctx, "external_read_file", &json!({"path": "note.txt"}).to_string()).await;
|
||
let r: serde_json::Value = serde_json::from_str(&r)?;
|
||
assert_ok(&r, "external_read_file")?;
|
||
if r["content"].as_str() != Some("smoke ok\n第二行") {
|
||
return Err(anyhow!("read_file content mismatch: {r}"));
|
||
}
|
||
println!(" read_file ok");
|
||
|
||
// list_dir
|
||
let l = execute_tool(&ctx, "external_list_dir", &json!({"path": "."}).to_string()).await;
|
||
let l: serde_json::Value = serde_json::from_str(&l)?;
|
||
assert_ok(&l, "external_list_dir")?;
|
||
let names: Vec<&str> = l["entries"]
|
||
.as_array()
|
||
.context("entries")?
|
||
.iter()
|
||
.filter_map(|e| e["name"].as_str())
|
||
.collect();
|
||
if !names.contains(&"note.txt") {
|
||
return Err(anyhow!("list_dir missing note.txt: {l}"));
|
||
}
|
||
println!(" list_dir ok");
|
||
|
||
// shell
|
||
let s = execute_tool(
|
||
&ctx,
|
||
"external_shell",
|
||
&json!({"command": "printf 'hi'; wc -c < note.txt"}).to_string(),
|
||
)
|
||
.await;
|
||
let s: serde_json::Value = serde_json::from_str(&s)?;
|
||
assert_ok(&s, "external_shell")?;
|
||
if s["exit_code"] != 0 {
|
||
return Err(anyhow!("shell exit_code != 0: {s}"));
|
||
}
|
||
println!(" shell ok");
|
||
|
||
// path traversal should fail
|
||
let bad = execute_tool(
|
||
&ctx,
|
||
"external_read_file",
|
||
&json!({"path": "../outside.txt"}).to_string(),
|
||
)
|
||
.await;
|
||
let bad: serde_json::Value = serde_json::from_str(&bad)?;
|
||
if bad.get("error").is_none() {
|
||
return Err(anyhow!("expected path traversal error, got {bad}"));
|
||
}
|
||
println!(" sandbox ok");
|
||
|
||
// completion tools
|
||
let done = execute_tool(
|
||
&ctx,
|
||
"report_done",
|
||
&json!({"message": "smoke done"}).to_string(),
|
||
)
|
||
.await;
|
||
let done: serde_json::Value = serde_json::from_str(&done)?;
|
||
if done["status"] != "done" || done["message"] != "smoke done" {
|
||
return Err(anyhow!("report_done mismatch: {done}"));
|
||
}
|
||
let blocked = execute_tool(
|
||
&ctx,
|
||
"report_blocked",
|
||
&json!({"reason": "smoke blocked"}).to_string(),
|
||
)
|
||
.await;
|
||
let blocked: serde_json::Value = serde_json::from_str(&blocked)?;
|
||
if blocked["status"] != "blocked" || blocked["reason"] != "smoke blocked" {
|
||
return Err(anyhow!("report_blocked mismatch: {blocked}"));
|
||
}
|
||
if !is_completion_tool("report_done") || !is_completion_tool("report_blocked") {
|
||
return Err(anyhow!("is_completion_tool failed"));
|
||
}
|
||
println!(" completion ok");
|
||
|
||
// tool definitions present (6 core + confirm + 6 browser incl. handoff)
|
||
let defs = tool_definitions();
|
||
let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0);
|
||
if n_tools < 46 {
|
||
return Err(anyhow!("expected at least 46 tool defs, got {n_tools}"));
|
||
}
|
||
let tool_names: Vec<&str> = defs
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.filter_map(|t| t["function"]["name"].as_str())
|
||
.collect();
|
||
if !tool_names.contains(&"browser_handoff") {
|
||
return Err(anyhow!("browser_handoff missing from tool defs"));
|
||
}
|
||
if !tool_names.contains(&"request_user_confirm") {
|
||
return Err(anyhow!("request_user_confirm missing from tool defs"));
|
||
}
|
||
if !tool_names.contains(&"send_message") {
|
||
return Err(anyhow!("send_message missing from tool defs"));
|
||
}
|
||
if !tool_names.contains(&"web_fetch") {
|
||
return Err(anyhow!("web_fetch missing from tool defs"));
|
||
}
|
||
if !tool_names.contains(&"spawn_subagent") {
|
||
return Err(anyhow!("spawn_subagent missing from tool defs"));
|
||
}
|
||
println!(" tool defs ok");
|
||
|
||
// browser helper protocol (no Chromium required)
|
||
let repo = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||
match lazyboy_core::browser_self_test(&repo) {
|
||
Ok(_) => println!(" browser proto ok"),
|
||
Err(e) => {
|
||
// Soft: smoke still passes if helper missing in weird cwd; warn.
|
||
println!(" browser proto skip ({e:#})");
|
||
}
|
||
}
|
||
// Fail-closed install hint when navigating without ensuring chromium — oneshot missing url already covered in helper self-test.
|
||
let nav = execute_tool(
|
||
&ToolContext::new(repo.clone()),
|
||
"browser_navigate",
|
||
&json!({"url": ""}).to_string(),
|
||
)
|
||
.await;
|
||
let nav_v: serde_json::Value = serde_json::from_str(&nav)?;
|
||
// empty url should error from helper; if helper missing, also error with install hint
|
||
if nav_v.get("error").is_none() && nav_v.get("blocked").is_none() {
|
||
// empty string might still "succeed" oddly — accept either error or blocked
|
||
println!(" browser fail note: {nav_v}");
|
||
} else {
|
||
println!(" browser fail ok");
|
||
}
|
||
|
||
// handoff protocol (auto-resume; no interactive stdin)
|
||
// SAFETY: smoke is single-threaded for this env toggle.
|
||
let prev_auto = std::env::var("LAZYBOY_HANDOFF_AUTO").ok();
|
||
unsafe { std::env::set_var("LAZYBOY_HANDOFF_AUTO", "1") };
|
||
let handoff = execute_tool(
|
||
&ToolContext::new(repo.clone()),
|
||
"browser_handoff",
|
||
&json!({"reason": "smoke handoff check", "timeout_secs": 3}).to_string(),
|
||
)
|
||
.await;
|
||
let handoff_v: serde_json::Value = serde_json::from_str(&handoff)?;
|
||
// Fail-closed without Chromium, or resumed with snapshot if browsers installed.
|
||
if handoff_v.get("error").is_none()
|
||
&& handoff_v.get("blocked").is_none()
|
||
&& handoff_v.get("handoff").and_then(|h| h.as_str()) != Some("resumed")
|
||
{
|
||
return Err(anyhow!("unexpected handoff smoke result: {handoff_v}"));
|
||
}
|
||
println!(" handoff auto ok");
|
||
// abort path
|
||
unsafe { std::env::set_var("LAZYBOY_HANDOFF_AUTO", "abort") };
|
||
let abort_wait = lazyboy_core::wait_for_handoff_resume(1);
|
||
if !matches!(abort_wait, lazyboy_core::HandoffWait::Aborted(_)) {
|
||
return Err(anyhow!("expected HandoffWait::Aborted, got {abort_wait:?}"));
|
||
}
|
||
println!(" handoff abort ok");
|
||
match prev_auto {
|
||
Some(v) => unsafe { std::env::set_var("LAZYBOY_HANDOFF_AUTO", v) },
|
||
None => unsafe { std::env::remove_var("LAZYBOY_HANDOFF_AUTO") },
|
||
}
|
||
|
||
// request_user_confirm (auto approve/deny; no interactive stdin)
|
||
let prev_confirm = std::env::var("LAZYBOY_CONFIRM_AUTO").ok();
|
||
unsafe { std::env::set_var("LAZYBOY_CONFIRM_AUTO", "1") };
|
||
let conf = execute_tool(
|
||
&ctx,
|
||
"request_user_confirm",
|
||
&json!({
|
||
"reason": "smoke confirm check",
|
||
"prompt": "範例草稿",
|
||
"timeout_secs": 2
|
||
})
|
||
.to_string(),
|
||
)
|
||
.await;
|
||
let conf_v: serde_json::Value = serde_json::from_str(&conf)?;
|
||
if conf_v["status"] != "approved" || conf_v["approved"] != true {
|
||
return Err(anyhow!("request_user_confirm approve mismatch: {conf_v}"));
|
||
}
|
||
println!(" confirm auto ok");
|
||
unsafe { std::env::set_var("LAZYBOY_CONFIRM_AUTO", "abort") };
|
||
let deny = execute_tool(
|
||
&ctx,
|
||
"request_user_confirm",
|
||
&json!({"reason": "smoke deny check", "timeout_secs": 2}).to_string(),
|
||
)
|
||
.await;
|
||
let deny_v: serde_json::Value = serde_json::from_str(&deny)?;
|
||
if deny_v["status"] != "denied" || deny_v["approved"] != false {
|
||
return Err(anyhow!("request_user_confirm deny mismatch: {deny_v}"));
|
||
}
|
||
let deny_wait = lazyboy_core::wait_for_user_confirm(1);
|
||
if !matches!(deny_wait, lazyboy_core::ConfirmWait::Denied(_)) {
|
||
return Err(anyhow!("expected ConfirmWait::Denied, got {deny_wait:?}"));
|
||
}
|
||
println!(" confirm deny ok");
|
||
match prev_confirm {
|
||
Some(v) => unsafe { std::env::set_var("LAZYBOY_CONFIRM_AUTO", v) },
|
||
None => unsafe { std::env::remove_var("LAZYBOY_CONFIRM_AUTO") },
|
||
}
|
||
|
||
// context truncation
|
||
let mut msgs = vec![
|
||
ChatMessage::system(AGENT_SYSTEM),
|
||
ChatMessage::user("goal"),
|
||
ChatMessage::tool("t1", "X".repeat(50_000)),
|
||
ChatMessage::tool("t2", "Y".repeat(50_000)),
|
||
ChatMessage::tool("t3", "Z".repeat(50_000)),
|
||
ChatMessage::tool("t4", "W".repeat(50_000)),
|
||
ChatMessage::tool("t5", "V".repeat(50_000)),
|
||
ChatMessage::user("goal again"),
|
||
];
|
||
let before = messages_char_len(&msgs);
|
||
truncate_messages(&mut msgs, 10_000);
|
||
let after = messages_char_len(&msgs);
|
||
if after >= before {
|
||
return Err(anyhow!("truncate did not shrink ({before} -> {after})"));
|
||
}
|
||
if msgs[0].text() != AGENT_SYSTEM {
|
||
return Err(anyhow!("truncate dropped system prompt"));
|
||
}
|
||
println!(" truncation ok");
|
||
|
||
// session roundtrip
|
||
let mut sess = Session::new(dir.clone());
|
||
sess.push(ChatMessage::system(AGENT_SYSTEM));
|
||
sess.push(ChatMessage::user("smoke"));
|
||
let sess_path = dir.join("session.json");
|
||
let data = serde_json::to_vec_pretty(&sess)?;
|
||
std::fs::write(&sess_path, data)?;
|
||
let loaded: Session = serde_json::from_slice(&std::fs::read(&sess_path)?)?;
|
||
if loaded.messages.len() != 2 {
|
||
return Err(anyhow!("session roundtrip failed"));
|
||
}
|
||
println!(" session ok");
|
||
|
||
// agent CLI parse (offline)
|
||
let (sid, help) = parse_agent_args(&[]).expect("empty agent args");
|
||
if sid.is_some() || help {
|
||
return Err(anyhow!("parse_agent_args([]) unexpected"));
|
||
}
|
||
let sess_args = vec!["--session".to_string(), "abc-123".to_string()];
|
||
let (sid, help) = parse_agent_args(&sess_args)?;
|
||
if sid.as_deref() != Some("abc-123") || help {
|
||
return Err(anyhow!("parse_agent_args --session failed"));
|
||
}
|
||
let help_args = vec!["--help".to_string()];
|
||
let (_, help) = parse_agent_args(&help_args)?;
|
||
if !help {
|
||
return Err(anyhow!("parse_agent_args --help failed"));
|
||
}
|
||
// one-turn session plumbing without API: system + user + fake assistant, save/load
|
||
let mut agent_sess = Session::new(dir.clone());
|
||
agent_sess.push(ChatMessage::system(AGENT_SYSTEM));
|
||
agent_sess.push(ChatMessage::user("第一輪"));
|
||
agent_sess.push(ChatMessage::assistant("回覆一"));
|
||
agent_sess.push(ChatMessage::user("第二輪"));
|
||
let agent_path = dir.join("agent-turn.json");
|
||
let data = serde_json::to_vec_pretty(&agent_sess)?;
|
||
std::fs::write(&agent_path, data)?;
|
||
let loaded_agent: Session = serde_json::from_slice(&std::fs::read(&agent_path)?)?;
|
||
if loaded_agent.messages.len() != 4 {
|
||
return Err(anyhow!("agent multi-turn session plumbing failed"));
|
||
}
|
||
if loaded_agent.messages[0].text() != AGENT_SYSTEM {
|
||
return Err(anyhow!("agent session missing system prompt"));
|
||
}
|
||
println!(" agent parse ok");
|
||
println!(" agent session ok");
|
||
|
||
// max rounds env resolver (per chunk default 12; total default 48)
|
||
let rounds = max_rounds_budget();
|
||
if rounds == 0 {
|
||
return Err(anyhow!("max_rounds_budget returned 0"));
|
||
}
|
||
let total = max_rounds_total_budget();
|
||
if total == 0 {
|
||
return Err(anyhow!("max_rounds_total_budget returned 0"));
|
||
}
|
||
println!(" max rounds ok (chunk={rounds}, total={total})");
|
||
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
|
||
if Config::from_env().is_ok() {
|
||
println!(" (API key present — skipping live call in smoke; use `run` to exercise)");
|
||
} else {
|
||
println!(" (no API key — live agent not checked; offline smoke passed)");
|
||
}
|
||
|
||
println!("smoke passed");
|
||
Ok(())
|
||
}
|
||
|
||
fn print_verdict(verdict: &AgentVerdict) {
|
||
ui::print_verdict(verdict);
|
||
}
|
||
|
||
fn eprintln_blocked_recovery_hint_agent() {
|
||
eprintln!(
|
||
"提示:session 已保存。額度用完可輸入「繼續」;卡住請補充缺少的資訊;請求失敗可修正設定後重試。"
|
||
);
|
||
}
|
||
|
||
fn eprintln_blocked_recovery_hint_run(session_id: &str) {
|
||
eprintln!(
|
||
"提示:session 已保存。可用 lazyboy run --session {session_id} \"繼續\" 接續;若卡住或失敗,請先處理上面的原因。"
|
||
);
|
||
}
|
||
|
||
fn assert_ok(v: &serde_json::Value, label: &str) -> Result<()> {
|
||
if let Some(err) = v.get("error") {
|
||
return Err(anyhow!("{label} failed: {err}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn uuid_like() -> String {
|
||
use std::time::{SystemTime, UNIX_EPOCH};
|
||
let n = SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.map(|d| d.as_nanos())
|
||
.unwrap_or(0);
|
||
format!("{n}")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::parse_agent_args;
|
||
|
||
#[test]
|
||
fn agent_args_empty() {
|
||
let (sid, help) = parse_agent_args(&[]).unwrap();
|
||
assert!(sid.is_none());
|
||
assert!(!help);
|
||
}
|
||
|
||
#[test]
|
||
fn agent_args_session() {
|
||
let args = vec!["--session".into(), "sess-1".into()];
|
||
let (sid, help) = parse_agent_args(&args).unwrap();
|
||
assert_eq!(sid.as_deref(), Some("sess-1"));
|
||
assert!(!help);
|
||
}
|
||
|
||
#[test]
|
||
fn agent_args_help() {
|
||
let args = vec!["-h".into()];
|
||
let (_, help) = parse_agent_args(&args).unwrap();
|
||
assert!(help);
|
||
}
|
||
|
||
#[test]
|
||
fn agent_args_rejects_extra() {
|
||
let args = vec!["nope".into()];
|
||
assert!(parse_agent_args(&args).is_err());
|
||
}
|
||
}
|