Skip to content

Commit 786a75f

Browse files
committed
refactor(agent): Claude behind an Agent trait for launch and input (A1a)
The process arguments, the extra environment and the encoding of a turn on stdin move from the supervisor and the courier into agent::ClaudeAgent; those two call agent::of(bot.agent). No change for Claude bots.
1 parent 733ab9a commit 786a75f

7 files changed

Lines changed: 306 additions & 93 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
//! Claude Code as an agent (spec 7.4, 9.2): `claude -p` with `stream-json` in
2+
//! both directions.
3+
4+
use std::ffi::OsString;
5+
6+
use botloft_core::protocol::AgentKind;
7+
use bytes::Bytes;
8+
use serde_json::json;
9+
10+
use super::{Agent, LaunchPlan, Turn};
11+
12+
/// Tools the bot uses without asking: its crew tools (spec 7.4).
13+
const ALLOWED_TOOLS: &str = "mcp__botloft";
14+
/// Claude Code's own schedulers: they die with the process and Botloft never
15+
/// sees them. Work at set times is a routine (spec 7.4, 20).
16+
const DISALLOWED_TOOLS: &str = "CronCreate,CronDelete,CronList,ScheduleWakeup,RemoteTrigger";
17+
/// Makes Claude Code load `CLAUDE.md` from `--add-dir` folders (spec 5).
18+
const ADDITIONAL_MEMORY: &str = "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD";
19+
/// Where permission requests go (spec 10.1).
20+
const PERMISSION_TOOL: &str = "mcp__botloft__permission_prompt";
21+
22+
pub struct ClaudeAgent;
23+
24+
impl Agent for ClaudeAgent {
25+
fn kind(&self) -> AgentKind {
26+
AgentKind::Claude
27+
}
28+
29+
fn args(&self, plan: &LaunchPlan<'_>) -> Vec<OsString> {
30+
let mut args: Vec<OsString> = [
31+
"-p",
32+
"--input-format",
33+
"stream-json",
34+
"--output-format",
35+
"stream-json",
36+
"--verbose",
37+
"--include-partial-messages",
38+
"--replay-user-messages",
39+
]
40+
.into_iter()
41+
.map(OsString::from)
42+
.collect();
43+
args.push(
44+
if plan.resumed {
45+
"--resume"
46+
} else {
47+
"--session-id"
48+
}
49+
.into(),
50+
);
51+
args.push(plan.session.into());
52+
for arg in [
53+
"--setting-sources",
54+
"project,local",
55+
"--strict-mcp-config",
56+
"--permission-mode",
57+
plan.permission_mode.cli_value(),
58+
"--permission-prompt-tool",
59+
PERMISSION_TOOL,
60+
"--allowedTools",
61+
ALLOWED_TOOLS,
62+
"--disallowedTools",
63+
DISALLOWED_TOOLS,
64+
"--mcp-config",
65+
] {
66+
args.push(arg.into());
67+
}
68+
args.push(plan.mcp_config.as_os_str().to_owned());
69+
args.push("--add-dir".into());
70+
args.push(plan.work_folder.as_os_str().to_owned());
71+
// Without the flag, Claude Code uses the default of the owner's plan.
72+
if let Some(model) = plan.model.cli_value() {
73+
args.push("--model".into());
74+
args.push(model.into());
75+
}
76+
// Without the flag, Claude Code uses the level it sets for the model.
77+
if let Some(effort) = plan.effort.cli_value() {
78+
args.push("--effort".into());
79+
args.push(effort.into());
80+
}
81+
args
82+
}
83+
84+
fn extra_env(&self) -> Vec<(OsString, OsString)> {
85+
// Loads the work folder's CLAUDE.md with the bot's memory.
86+
vec![(OsString::from(ADDITIONAL_MEMORY), OsString::from("1"))]
87+
}
88+
89+
fn encode_turn(&self, uuid: &str, turn: &Turn) -> Bytes {
90+
let mut content = vec![json!({ "type": "text", "text": turn.text })];
91+
content.extend(turn.images.iter().map(|image| {
92+
json!({
93+
"type": "image",
94+
"source": {
95+
"type": "base64",
96+
"media_type": image.media_type,
97+
"data": image.data,
98+
},
99+
})
100+
}));
101+
let event = json!({
102+
"type": "user",
103+
"uuid": uuid,
104+
"message": { "role": "user", "content": content },
105+
});
106+
let mut line = event.to_string().into_bytes();
107+
line.push(b'\n');
108+
Bytes::from(line)
109+
}
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use std::path::Path;
115+
116+
use botloft_core::protocol::{BotEffort, BotModel, PermissionMode};
117+
118+
use super::*;
119+
use crate::agent::TurnImage;
120+
121+
fn plan(resumed: bool, model: BotModel, effort: BotEffort) -> LaunchPlan<'static> {
122+
LaunchPlan {
123+
session: "abc",
124+
resumed,
125+
mcp_config: Path::new("w/.botloft/mcp.json"),
126+
work_folder: Path::new("w/shared"),
127+
permission_mode: PermissionMode::Default,
128+
model,
129+
effort,
130+
}
131+
}
132+
133+
fn strings(args: Vec<OsString>) -> Vec<String> {
134+
args.into_iter()
135+
.map(|arg| arg.to_string_lossy().into_owned())
136+
.collect()
137+
}
138+
139+
#[test]
140+
fn a_new_bot_gets_a_session_id_and_the_defaults_leave_model_and_effort_out() {
141+
let args = strings(ClaudeAgent.args(&plan(false, BotModel::Default, BotEffort::Default)));
142+
let at = args.iter().position(|a| a == "--session-id").expect("flag");
143+
assert_eq!(args[at + 1], "abc");
144+
assert!(!args.contains(&"--resume".to_owned()));
145+
assert!(!args.contains(&"--model".to_owned()));
146+
assert!(!args.contains(&"--effort".to_owned()));
147+
assert!(args.contains(&"--permission-prompt-tool".to_owned()));
148+
}
149+
150+
#[test]
151+
fn a_resumed_bot_with_a_model_and_effort_passes_them() {
152+
let args = strings(ClaudeAgent.args(&plan(true, BotModel::Sonnet, BotEffort::High)));
153+
assert!(args.contains(&"--resume".to_owned()));
154+
let at = args.iter().position(|a| a == "--model").expect("model");
155+
assert_eq!(args[at + 1], "sonnet");
156+
let at = args.iter().position(|a| a == "--effort").expect("effort");
157+
assert_eq!(args[at + 1], "high");
158+
}
159+
160+
#[test]
161+
fn a_turn_is_one_user_line_with_its_images() {
162+
let turn = Turn {
163+
text: "hi".into(),
164+
images: vec![TurnImage {
165+
media_type: "image/png".into(),
166+
data: "AAAA".into(),
167+
}],
168+
};
169+
let line = ClaudeAgent.encode_turn("u-1", &turn);
170+
assert!(line.ends_with(b"\n"));
171+
let json: serde_json::Value = serde_json::from_slice(&line).expect("json");
172+
assert_eq!(json["type"], "user");
173+
assert_eq!(json["uuid"], "u-1");
174+
assert_eq!(json["message"]["content"][0]["text"], "hi");
175+
assert_eq!(json["message"]["content"][1]["source"]["data"], "AAAA");
176+
}
177+
}

‎crates/botloftd/src/agent/mod.rs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
//! The agent behind a bot (spec 30): Claude Code today, Codex and Antigravity
2+
//! later. What is specific to one agent lives behind [`Agent`]; the rest of the
3+
//! daemon asks it and never writes an agent's command line or stdin framing
4+
//! itself. This first slice owns the process arguments and the encoding of a
5+
//! turn; reading the output and the approvals follow (spec 30.1, A1).
6+
7+
mod claude;
8+
9+
use std::ffi::OsString;
10+
use std::path::Path;
11+
12+
use botloft_core::protocol::{AgentKind, BotEffort, BotModel, PermissionMode};
13+
use bytes::Bytes;
14+
15+
pub use claude::ClaudeAgent;
16+
17+
/// One image that goes in the turn, already read and encoded.
18+
#[derive(Debug, Clone, PartialEq, Eq)]
19+
pub struct TurnImage {
20+
pub media_type: String,
21+
/// The file's bytes, base64.
22+
pub data: String,
23+
}
24+
25+
/// What a bot is told in one turn, whatever the agent.
26+
#[derive(Debug, Clone, PartialEq, Eq)]
27+
pub struct Turn {
28+
pub text: String,
29+
pub images: Vec<TurnImage>,
30+
}
31+
32+
/// What an agent needs to build the command line of a bot's process.
33+
pub struct LaunchPlan<'a> {
34+
/// The conversation: resumed when `resumed`, new with this id otherwise.
35+
pub session: &'a str,
36+
pub resumed: bool,
37+
/// The bot's MCP configuration (the Botloft server and the owner's).
38+
pub mcp_config: &'a Path,
39+
/// The crew's work folder, which the bot edits like its own (spec 5).
40+
pub work_folder: &'a Path,
41+
pub permission_mode: PermissionMode,
42+
pub model: BotModel,
43+
pub effort: BotEffort,
44+
}
45+
46+
pub trait Agent: Send + Sync + 'static {
47+
fn kind(&self) -> AgentKind;
48+
49+
/// The arguments of the process, after the program.
50+
fn args(&self, plan: &LaunchPlan<'_>) -> Vec<OsString>;
51+
52+
/// Variables the agent needs beyond the ones every bot gets.
53+
fn extra_env(&self) -> Vec<(OsString, OsString)>;
54+
55+
/// One turn as the bytes written to the process's stdin; `uuid` is what
56+
/// the agent gives back when the turn begins.
57+
fn encode_turn(&self, uuid: &str, turn: &Turn) -> Bytes;
58+
}
59+
60+
static CLAUDE: ClaudeAgent = ClaudeAgent;
61+
62+
/// The agent that runs bots of `kind`; `None` while the daemon cannot run
63+
/// that agent yet (spec 30.1).
64+
pub fn of(kind: AgentKind) -> Option<&'static dyn Agent> {
65+
match kind {
66+
AgentKind::Claude => Some(&CLAUDE),
67+
AgentKind::Codex | AgentKind::Agy => None,
68+
}
69+
}

‎crates/botloftd/src/courier/mod.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ use std::sync::Arc;
1212
use std::time::Duration;
1313

1414
use botloft_core::ids::BotId;
15-
use botloft_core::protocol::{BotState, Delivery, Message, SenderKind, Task};
15+
use botloft_core::protocol::{AgentKind, BotState, Delivery, Message, SenderKind, Task};
1616
use botloft_store::{DeliveryOutcome, Store, StoreError};
1717
use tokio::sync::Notify;
1818
use tracing::{debug, warn};
1919

2020
use self::render::{Context, Rendered, render};
2121
pub use self::settings::CourierSettings;
2222
use self::sleep::{became_ready, hasten, next_wait};
23+
use crate::agent::{self, ClaudeAgent};
2324
use crate::clock;
2425
use crate::service::tasks;
2526
use crate::state::{Daemon, Event};
@@ -228,6 +229,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result<Step,
228229
.map(|(reaction, quote)| (reaction.emoji, quote))
229230
.collect();
230231
Ok(Step::Send(Box::new(Draft {
232+
agent: bot.agent,
231233
workspace: daemon.paths.bot_workspace(&crew.slug, &bot.slug),
232234
message,
233235
crew_name: crew.name,
@@ -242,6 +244,7 @@ fn prepare(daemon: &Daemon, store: &Store, delivery: &Delivery) -> Result<Step,
242244

243245
/// A delivery about to go, read from the store.
244246
struct Draft {
247+
agent: AgentKind,
245248
message: Message,
246249
crew_name: String,
247250
sender_handle: Option<String>,
@@ -271,7 +274,9 @@ impl Draft {
271274
.map(|(id, text)| (id.as_str(), text.as_str())),
272275
reactions: &self.reactions,
273276
};
274-
render(&self.message, &context, now)
277+
// Only agents the daemon runs get this far; the bot was checked at launch.
278+
let agent = agent::of(self.agent).unwrap_or(&ClaudeAgent);
279+
render(&self.message, &context, now, agent)
275280
}
276281
}
277282

0 commit comments

Comments
 (0)