Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions crates/botloftd/src/agent/claude.rs
Original file line number Diff line number Diff line change
@@ -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<OsString> {
let mut args: Vec<OsString> = [
"-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<OsString>) -> Vec<String> {
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");
}
}
69 changes: 69 additions & 0 deletions crates/botloftd/src/agent/mod.rs
Original file line number Diff line number Diff line change
@@ -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<TurnImage>,
}

/// 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<OsString>;

/// 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,
}
}
9 changes: 7 additions & 2 deletions crates/botloftd/src/courier/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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};

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};
Expand Down Expand Up @@ -228,6 +229,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result<Step,
.map(|(reaction, quote)| (reaction.emoji, quote))
.collect();
Ok(Step::Send(Box::new(Draft {
agent: bot.agent,
workspace: daemon.paths.bot_workspace(&crew.slug, &bot.slug),
message,
crew_name: crew.name,
Expand All @@ -242,6 +244,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result<Step,

/// A delivery about to go, read from the store.
struct Draft {
agent: AgentKind,
message: Message,
crew_name: String,
sender_handle: Option<String>,
Expand Down Expand Up @@ -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)
}
}

Expand Down
Loading
Loading