diff --git a/crates/botloftd/src/agent/claude.rs b/crates/botloftd/src/agent/claude.rs new file mode 100644 index 00000000..1543ee96 --- /dev/null +++ b/crates/botloftd/src/agent/claude.rs @@ -0,0 +1,177 @@ +//! Claude Code as an agent (spec 7.4, 9.2): `claude -p` with `stream-json` in +//! both directions. + +use std::ffi::OsString; + +use botloft_core::protocol::AgentKind; +use bytes::Bytes; +use serde_json::json; + +use super::{Agent, LaunchPlan, Turn}; + +/// Tools the bot uses without asking: its crew tools (spec 7.4). +const ALLOWED_TOOLS: &str = "mcp__botloft"; +/// Claude Code's own schedulers: they die with the process and Botloft never +/// sees them. Work at set times is a routine (spec 7.4, 20). +const DISALLOWED_TOOLS: &str = "CronCreate,CronDelete,CronList,ScheduleWakeup,RemoteTrigger"; +/// Makes Claude Code load `CLAUDE.md` from `--add-dir` folders (spec 5). +const ADDITIONAL_MEMORY: &str = "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"; +/// Where permission requests go (spec 10.1). +const PERMISSION_TOOL: &str = "mcp__botloft__permission_prompt"; + +pub struct ClaudeAgent; + +impl Agent for ClaudeAgent { + fn kind(&self) -> AgentKind { + AgentKind::Claude + } + + fn args(&self, plan: &LaunchPlan<'_>) -> Vec { + let mut args: Vec = [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--replay-user-messages", + ] + .into_iter() + .map(OsString::from) + .collect(); + args.push( + if plan.resumed { + "--resume" + } else { + "--session-id" + } + .into(), + ); + args.push(plan.session.into()); + for arg in [ + "--setting-sources", + "project,local", + "--strict-mcp-config", + "--permission-mode", + plan.permission_mode.cli_value(), + "--permission-prompt-tool", + PERMISSION_TOOL, + "--allowedTools", + ALLOWED_TOOLS, + "--disallowedTools", + DISALLOWED_TOOLS, + "--mcp-config", + ] { + args.push(arg.into()); + } + args.push(plan.mcp_config.as_os_str().to_owned()); + args.push("--add-dir".into()); + args.push(plan.work_folder.as_os_str().to_owned()); + // Without the flag, Claude Code uses the default of the owner's plan. + if let Some(model) = plan.model.cli_value() { + args.push("--model".into()); + args.push(model.into()); + } + // Without the flag, Claude Code uses the level it sets for the model. + if let Some(effort) = plan.effort.cli_value() { + args.push("--effort".into()); + args.push(effort.into()); + } + args + } + + fn extra_env(&self) -> Vec<(OsString, OsString)> { + // Loads the work folder's CLAUDE.md with the bot's memory. + vec![(OsString::from(ADDITIONAL_MEMORY), OsString::from("1"))] + } + + fn encode_turn(&self, uuid: &str, turn: &Turn) -> Bytes { + let mut content = vec![json!({ "type": "text", "text": turn.text })]; + content.extend(turn.images.iter().map(|image| { + json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": image.media_type, + "data": image.data, + }, + }) + })); + let event = json!({ + "type": "user", + "uuid": uuid, + "message": { "role": "user", "content": content }, + }); + let mut line = event.to_string().into_bytes(); + line.push(b'\n'); + Bytes::from(line) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use botloft_core::protocol::{BotEffort, BotModel, PermissionMode}; + + use super::*; + use crate::agent::TurnImage; + + fn plan(resumed: bool, model: BotModel, effort: BotEffort) -> LaunchPlan<'static> { + LaunchPlan { + session: "abc", + resumed, + mcp_config: Path::new("w/.botloft/mcp.json"), + work_folder: Path::new("w/shared"), + permission_mode: PermissionMode::Default, + model, + effort, + } + } + + fn strings(args: Vec) -> Vec { + args.into_iter() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn a_new_bot_gets_a_session_id_and_the_defaults_leave_model_and_effort_out() { + let args = strings(ClaudeAgent.args(&plan(false, BotModel::Default, BotEffort::Default))); + let at = args.iter().position(|a| a == "--session-id").expect("flag"); + assert_eq!(args[at + 1], "abc"); + assert!(!args.contains(&"--resume".to_owned())); + assert!(!args.contains(&"--model".to_owned())); + assert!(!args.contains(&"--effort".to_owned())); + assert!(args.contains(&"--permission-prompt-tool".to_owned())); + } + + #[test] + fn a_resumed_bot_with_a_model_and_effort_passes_them() { + let args = strings(ClaudeAgent.args(&plan(true, BotModel::Sonnet, BotEffort::High))); + assert!(args.contains(&"--resume".to_owned())); + let at = args.iter().position(|a| a == "--model").expect("model"); + assert_eq!(args[at + 1], "sonnet"); + let at = args.iter().position(|a| a == "--effort").expect("effort"); + assert_eq!(args[at + 1], "high"); + } + + #[test] + fn a_turn_is_one_user_line_with_its_images() { + let turn = Turn { + text: "hi".into(), + images: vec![TurnImage { + media_type: "image/png".into(), + data: "AAAA".into(), + }], + }; + let line = ClaudeAgent.encode_turn("u-1", &turn); + assert!(line.ends_with(b"\n")); + let json: serde_json::Value = serde_json::from_slice(&line).expect("json"); + assert_eq!(json["type"], "user"); + assert_eq!(json["uuid"], "u-1"); + assert_eq!(json["message"]["content"][0]["text"], "hi"); + assert_eq!(json["message"]["content"][1]["source"]["data"], "AAAA"); + } +} diff --git a/crates/botloftd/src/agent/mod.rs b/crates/botloftd/src/agent/mod.rs new file mode 100644 index 00000000..2ceff246 --- /dev/null +++ b/crates/botloftd/src/agent/mod.rs @@ -0,0 +1,69 @@ +//! The agent behind a bot (spec 30): Claude Code today, Codex and Antigravity +//! later. What is specific to one agent lives behind [`Agent`]; the rest of the +//! daemon asks it and never writes an agent's command line or stdin framing +//! itself. This first slice owns the process arguments and the encoding of a +//! turn; reading the output and the approvals follow (spec 30.1, A1). + +mod claude; + +use std::ffi::OsString; +use std::path::Path; + +use botloft_core::protocol::{AgentKind, BotEffort, BotModel, PermissionMode}; +use bytes::Bytes; + +pub use claude::ClaudeAgent; + +/// One image that goes in the turn, already read and encoded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TurnImage { + pub media_type: String, + /// The file's bytes, base64. + pub data: String, +} + +/// What a bot is told in one turn, whatever the agent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Turn { + pub text: String, + pub images: Vec, +} + +/// What an agent needs to build the command line of a bot's process. +pub struct LaunchPlan<'a> { + /// The conversation: resumed when `resumed`, new with this id otherwise. + pub session: &'a str, + pub resumed: bool, + /// The bot's MCP configuration (the Botloft server and the owner's). + pub mcp_config: &'a Path, + /// The crew's work folder, which the bot edits like its own (spec 5). + pub work_folder: &'a Path, + pub permission_mode: PermissionMode, + pub model: BotModel, + pub effort: BotEffort, +} + +pub trait Agent: Send + Sync + 'static { + fn kind(&self) -> AgentKind; + + /// The arguments of the process, after the program. + fn args(&self, plan: &LaunchPlan<'_>) -> Vec; + + /// Variables the agent needs beyond the ones every bot gets. + fn extra_env(&self) -> Vec<(OsString, OsString)>; + + /// One turn as the bytes written to the process's stdin; `uuid` is what + /// the agent gives back when the turn begins. + fn encode_turn(&self, uuid: &str, turn: &Turn) -> Bytes; +} + +static CLAUDE: ClaudeAgent = ClaudeAgent; + +/// The agent that runs bots of `kind`; `None` while the daemon cannot run +/// that agent yet (spec 30.1). +pub fn of(kind: AgentKind) -> Option<&'static dyn Agent> { + match kind { + AgentKind::Claude => Some(&CLAUDE), + AgentKind::Codex | AgentKind::Agy => None, + } +} diff --git a/crates/botloftd/src/courier/mod.rs b/crates/botloftd/src/courier/mod.rs index 92f8dc07..c399b72d 100644 --- a/crates/botloftd/src/courier/mod.rs +++ b/crates/botloftd/src/courier/mod.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use std::time::Duration; use botloft_core::ids::BotId; -use botloft_core::protocol::{BotState, Delivery, Message, SenderKind, Task}; +use botloft_core::protocol::{AgentKind, BotState, Delivery, Message, SenderKind, Task}; use botloft_store::{DeliveryOutcome, Store, StoreError}; use tokio::sync::Notify; use tracing::{debug, warn}; @@ -20,6 +20,7 @@ use tracing::{debug, warn}; use self::render::{Context, Rendered, render}; pub use self::settings::CourierSettings; use self::sleep::{became_ready, hasten, next_wait}; +use crate::agent::{self, ClaudeAgent}; use crate::clock; use crate::service::tasks; use crate::state::{Daemon, Event}; @@ -228,6 +229,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result Result, @@ -271,7 +274,9 @@ impl Draft { .map(|(id, text)| (id.as_str(), text.as_str())), reactions: &self.reactions, }; - render(&self.message, &context, now) + // Only agents the daemon runs get this far; the bot was checked at launch. + let agent = agent::of(self.agent).unwrap_or(&ClaudeAgent); + render(&self.message, &context, now, agent) } } diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs index 69d9e886..115ad721 100644 --- a/crates/botloftd/src/courier/render.rs +++ b/crates/botloftd/src/courier/render.rs @@ -8,10 +8,10 @@ use botloft_core::envelope::{self, Envelope, Sender}; use botloft_core::ids::random_uuid; use botloft_core::protocol::{Attachment, Message, SenderKind, Task}; use bytes::Bytes; -use serde_json::{Value, json}; use tracing::debug; use super::context::RoutineContext; +use crate::agent::{Agent, Turn, TurnImage}; /// Images the API accepts inline (spec 9.5). const INLINE_IMAGES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; @@ -44,7 +44,12 @@ pub(super) struct Rendered { pub uuid: String, } -pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rendered { +pub(super) fn render( + message: &Message, + context: &Context<'_>, + now: i64, + agent: &dyn Agent, +) -> Rendered { let text = match message.from_kind { // The owner is the session's user: their words go as written. SenderKind::Owner => { @@ -72,25 +77,14 @@ pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rend None => envelope(message, context, Sender::Botloft, now), }, }; - let mut content = vec![json!({ "type": "text", "text": text })]; - content.extend( - message - .attachments - .iter() - .filter_map(|attachment| inline_image(attachment, context.workspace)), - ); + let images = message + .attachments + .iter() + .filter_map(|attachment| inline_image(attachment, context.workspace)) + .collect(); let uuid = random_uuid(); - let event = json!({ - "type": "user", - "uuid": uuid, - "message": { "role": "user", "content": content }, - }); - let mut line = event.to_string().into_bytes(); - line.push(b'\n'); - Rendered { - line: Bytes::from(line), - uuid, - } + let line = agent.encode_turn(&uuid, &Turn { text, images }); + Rendered { line, uuid } } fn envelope(message: &Message, context: &Context<'_>, from: Sender<'_>, now: i64) -> String { @@ -119,21 +113,17 @@ fn with_attachment_list(body: &str, attachments: &[Attachment]) -> String { /// An image block for a small enough image; `None` for anything else or /// a file that cannot be read. -fn inline_image(attachment: &Attachment, workspace: &Path) -> Option { +fn inline_image(attachment: &Attachment, workspace: &Path) -> Option { if !INLINE_IMAGES.contains(&attachment.media_type.as_str()) || attachment.size > INLINE_IMAGE_MAX { return None; } match std::fs::read(workspace.join(&attachment.path)) { - Ok(bytes) => Some(json!({ - "type": "image", - "source": { - "type": "base64", - "media_type": attachment.media_type, - "data": BASE64.encode(bytes), - }, - })), + Ok(bytes) => Some(TurnImage { + media_type: attachment.media_type.clone(), + data: BASE64.encode(bytes), + }), Err(err) => { debug!(attachment = %attachment.id, "attachment not inlined: {err}"); None @@ -146,7 +136,10 @@ mod tests { use botloft_core::ids::{AttachmentId, BotId, ChatItemId, CrewId, MessageId}; use botloft_core::protocol::{MessageKind, MessageReply}; + use serde_json::Value; + use super::*; + use crate::agent::ClaudeAgent; fn message(from_kind: SenderKind, body: &str) -> Message { Message { @@ -184,13 +177,23 @@ mod tests { question: None, reactions: &[], }; - let owner = render(&message(SenderKind::Owner, "Ship it"), &context, 0); + let owner = render( + &message(SenderKind::Owner, "Ship it"), + &context, + 0, + &ClaudeAgent, + ); let line = parse(&owner); assert_eq!(line["type"], "user"); assert_eq!(line["uuid"], owner.uuid); assert_eq!(line["message"]["content"][0]["text"], "Ship it"); - let bot = parse(&render(&message(SenderKind::Bot, "hi"), &context, 0)); + let bot = parse(&render( + &message(SenderKind::Bot, "hi"), + &context, + 0, + &ClaudeAgent, + )); let text = bot["message"]["content"][0]["text"].as_str().expect("text"); assert!( text.starts_with("[botloft] from @scout · crew Ops"), @@ -217,7 +220,7 @@ mod tests { item_id: ChatItemId::generate(), text: "Acme signs monthly.".into(), }); - let line = parse(&render(&owner, &context, 0)); + let line = parse(&render(&owner, &context, 0, &ClaudeAgent)); assert_eq!( line["message"]["content"][0]["text"], "Replying to: \"Acme signs monthly.\" @@ -258,7 +261,7 @@ Make it yearly" question: None, reactions: &[], }; - let line = parse(&render(&owner, &context, 0)); + let line = parse(&render(&owner, &context, 0, &ClaudeAgent)); let content = line["message"]["content"].as_array().expect("blocks"); assert_eq!(content.len(), 2, "text and the one image"); assert_eq!( diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 863cc9d1..c352008e 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -2,6 +2,7 @@ //! serves the app over JSON-RPC on 127.0.0.1. The binary in `main.rs` only //! parses the command line and wires these modules together. +pub mod agent; pub mod approvals; pub mod autobackup; pub mod autostart; diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index 4ee700df..f64b4cc5 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -13,6 +13,7 @@ use tracing::debug; use super::slot::StopIntent; use super::{Inner, STABLE_AFTER, Supervisor}; +use crate::agent::{self, LaunchPlan}; use crate::chat::{LIVE_TEXT_EVERY, StreamReader}; use crate::platform; use crate::runtime::claude::Claude; @@ -21,16 +22,6 @@ use crate::secrets::{self, TokenHash}; use crate::state::Daemon; use crate::{approvals, context, courier, mcp_secrets, routines, workspace}; -/// Tools the bot uses without asking: its crew tools (spec 7.4). -const ALLOWED_TOOLS: &str = "mcp__botloft"; -/// Claude Code's own schedulers: they die with the process and Botloft never -/// sees them. Work at set times is a routine (spec 7.4, 20). -const DISALLOWED_TOOLS: &str = "CronCreate,CronDelete,CronList,ScheduleWakeup,RemoteTrigger"; -/// Makes Claude Code load `CLAUDE.md` from `--add-dir` folders (spec 5). -const ADDITIONAL_MEMORY: &str = "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD"; -/// Where permission requests go (spec 10.1). -const PERMISSION_TOOL: &str = "mcp__botloft__permission_prompt"; - impl Supervisor { /// The process of `generation` ended: restart it, or settle if the /// daemon stopped it on purpose (spec 7.3). What it had not begun goes @@ -122,52 +113,20 @@ pub(super) fn launch_spec( None => (random_uuid(), false), }; + let agent = agent::of(bot.agent).ok_or_else(|| { + io::Error::other(format!("bots on {} cannot start yet", bot.agent.as_str())) + })?; let mcp = workspace.join(".botloft").join("mcp.json"); - let mut args: Vec = [ - "-p", - "--input-format", - "stream-json", - "--output-format", - "stream-json", - "--verbose", - "--include-partial-messages", - "--replay-user-messages", - ] - .into_iter() - .map(OsString::from) - .collect(); - args.push(if resumed { "--resume" } else { "--session-id" }.into()); - args.push(session.into()); - for arg in [ - "--setting-sources", - "project,local", - "--strict-mcp-config", - "--permission-mode", - bot.permission_mode.cli_value(), - "--permission-prompt-tool", - PERMISSION_TOOL, - "--allowedTools", - ALLOWED_TOOLS, - "--disallowedTools", - DISALLOWED_TOOLS, - "--mcp-config", - ] { - args.push(arg.into()); - } - args.push(mcp.into_os_string()); - // The crew's work folder, which the bot edits like its own (spec 5). - args.push("--add-dir".into()); - args.push(daemon.paths.work_folder(crew).into_os_string()); - // Without the flag, Claude Code uses the default of the owner's plan. - if let Some(model) = bot.model.cli_value() { - args.push("--model".into()); - args.push(model.into()); - } - // Without the flag, Claude Code uses the level it sets for the model. - if let Some(effort) = bot.effort.cli_value() { - args.push("--effort".into()); - args.push(effort.into()); - } + let work_folder = daemon.paths.work_folder(crew); + let args = agent.args(&LaunchPlan { + session: &session, + resumed, + mcp_config: &mcp, + work_folder: &work_folder, + permission_mode: bot.permission_mode, + model: bot.model, + effort: bot.effort, + }); let mut env = platform::user_environment()?; env.retain(|(name, _)| { @@ -180,11 +139,10 @@ pub(super) fn launch_spec( ("BOTLOFT_BOT_ID", OsString::from(bot.id.as_str())), ("BOTLOFT_BOT_TOKEN", OsString::from(&token)), ("BOTLOFT_PORT", OsString::from(daemon.port.to_string())), - // Loads the work folder's CLAUDE.md with the bot's memory. - (ADDITIONAL_MEMORY, OsString::from("1")), ] { env.push((OsString::from(name), value)); } + env.extend(agent.extra_env()); // The values of the headers and variables of the bot's connected tools, // which its `mcp.json` expands (spec 25.2). diff --git a/docs/spec.md b/docs/spec.md index beeaadd1..7a21de52 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2643,7 +2643,7 @@ Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot. | Passo | O que entra | Estado | |---|---|---| | **A0** | ADR, esta seção, `bots.agent` no banco e no protocolo; `bots.create` com `codex` ou `agy` dá `-32004` ("not available yet") | feito | -| **A1** | A interface `Agent` no daemon, com o Claude por trás e os testes de hoje sem mudança | a fazer | +| **A1** | A interface `Agent` no daemon (`crates/botloftd/src/agent`), com o Claude por trás e os testes de hoje sem mudança. Em fatias: **A1a** argumentos do processo, ambiente extra e codificação do turno no stdin (feito); **A1b** leitura da saída em eventos normalizados; **A1c** descoberta e login, arquivos da pasta do bot e aprovações | em andamento | | **A2** | AGY: lançamento, entrada e saída, MCP, aprovações, isolamento, modelos e esforço, testados com o `agy` real | a fazer | | **A3** | Codex por `codex app-server` | a fazer | | **A4** | Seletor de agente no app, conta e login de cada agente, paridade de rotinas, navegador, desktop e perguntas | a fazer |