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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,9 @@ itself.

Claude Code is the full experience. Antigravity and Codex are off until you list them in Botloft's
`config.toml` (`experimental_agents = ["agy"]`, `["codex"]` or both); then a new bot can be made on
them, and Settings has "Agent for new bots". A Codex bot reads files, uses the crew's tools and
answers, with the exact size of its conversation; it cannot change files or run commands yet, and
asking you before it acts is the next step. See [spec section 30](docs/spec.md) for what was
them, and Settings has "Agent for new bots". A Codex bot asks you before it changes a file or runs a
command, like Claude Code, and shows the exact size of its conversation; it can still read any file,
and it has no model picker or images yet. See [spec section 30](docs/spec.md) for what was
checked.

## Get started
Expand Down
4 changes: 3 additions & 1 deletion app/src/features/bots/agentPicker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ describe("Codex in the new-bot dialog", () => {
fireEvent.click(button as HTMLElement);
const dialog = within(await screen.findByRole("dialog", { name: "New bot" }));
fireEvent.change(dialog.getByLabelText("Agent"), { target: { value: "codex" } });
expect(dialog.getByRole("note").textContent).toMatch(/cannot change files or run commands yet/);
expect(dialog.getByRole("note").textContent).toMatch(
/asks you before it changes a file or runs a command/,
);
expect(dialog.queryByLabelText("Model")).toBeNull();
});
});
2 changes: 1 addition & 1 deletion app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const bots = {
experimental:
"Experimental. This bot cannot ask you before it acts. It can work with files in its own folders and use the crew's tools, but it cannot run commands, and it has no usage meter or model choice.",
experimentalCodex:
"Experimental. This bot can read files, use the crew's tools and answer, but it cannot change files or run commands yet. Asking you before it acts is coming next.",
"Experimental. This bot asks you before it changes a file or runs a command, like Claude Code. It can still read any file, and it has no model or image choice yet.",
},
color: "Color",
swatch: (color: string) => `Color ${color}`,
Expand Down
2 changes: 1 addition & 1 deletion app/src/i18n/es/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export const bots: Messages["bots"] = {
experimental:
"Experimental. Este bot no puede preguntarte antes de actuar. Trabaja con archivos de sus propias carpetas y usa las herramientas del equipo, pero no ejecuta comandos, y no tiene medidor de uso ni elección de modelo.",
experimentalCodex:
"Experimental. Este bot lee archivos, usa las herramientas del equipo y responde, pero todavía no cambia archivos ni ejecuta comandos. Preguntarte antes de actuar viene a continuación.",
"Experimental. Este bot te pregunta antes de cambiar un archivo o ejecutar un comando, como Claude Code. Todavía puede leer cualquier archivo, y no tiene elección de modelo ni de imagen aún.",
},
color: "Color",
swatch: (color: string) => `Color ${color}`,
Expand Down
2 changes: 1 addition & 1 deletion app/src/i18n/pt-BR/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const bots: Messages["bots"] = {
experimental:
"Experimental. Este bot não consegue perguntar a você antes de agir. Ele trabalha com arquivos das próprias pastas e usa as ferramentas da equipe, mas não roda comandos, e não tem medidor de uso nem escolha de modelo.",
experimentalCodex:
"Experimental. Este bot lê arquivos, usa as ferramentas da equipe e responde, mas ainda não muda arquivos nem roda comandos. Perguntar a você antes de agir vem a seguir.",
"Experimental. Este bot pergunta a você antes de mudar um arquivo ou rodar um comando, como o Claude Code. Ele ainda pode ler qualquer arquivo, e não tem escolha de modelo nem de imagem ainda.",
},
color: "Cor",
swatch: (color: string) => `Cor ${color}`,
Expand Down
3 changes: 2 additions & 1 deletion crates/botloftd/src/agent/agy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use botloft_core::ids::BotId;
use botloft_core::protocol::{AgentKind, TokenUsage};
Expand Down Expand Up @@ -199,7 +200,7 @@ impl OutputDecoder for AgyDecoder {
Some(text)
}

fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
fn handle(&mut self, daemon: &Arc<Daemon>, bot: &BotId, generation: u64, event: &Value) {
if !daemon.supervisor.is_current(bot, generation) {
return;
}
Expand Down
3 changes: 2 additions & 1 deletion crates/botloftd/src/agent/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use botloft_core::ids::BotId;
use botloft_core::protocol::AgentKind;
Expand Down Expand Up @@ -143,7 +144,7 @@ impl OutputDecoder for ClaudeDecoder {
claude::live_text(event)
}

fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
fn handle(&mut self, daemon: &Arc<Daemon>, bot: &BotId, generation: u64, event: &Value) {
claude::apply(daemon, bot, generation, event);
}
}
Expand Down
253 changes: 10 additions & 243 deletions crates/botloftd/src/agent/codex.rs
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
//! OpenAI's Codex as an agent (spec 30), through `codex app-server`: one
//! process per bot, JSON-RPC on stdin and stdout. Experimental. The first
//! slice runs the thread in a read-only sandbox that asks for nothing, so the
//! bot reads and answers; approvals come next (spec 30.1).
//! process per bot, JSON-RPC on stdin and stdout. Experimental. The thread
//! runs in a read-only sandbox; whatever needs more is asked of the owner in
//! the chat (`codex_decoder.rs`).

use std::ffi::OsString;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use botloft_core::ids::BotId;
use botloft_core::protocol::{AgentKind, TokenUsage};
use botloft_core::protocol::AgentKind;
use bytes::Bytes;
use serde_json::{Value, json};
use serde_json::Value;

use super::codex_wire::{Answer, CodexControl, Shared};
use super::codex_decoder::CodexDecoder;
use super::codex_wire::{CodexControl, Shared};
use super::{Agent, AttachInput, LaunchFiles, LaunchPlan, OutputDecoder, Turn};
use crate::chat::sink;
use crate::runtime::ProcessControl;
use crate::state::Daemon;

Expand Down Expand Up @@ -114,14 +114,9 @@ impl Agent for CodexAgent {
control: Box<dyn ProcessControl>,
input: AttachInput,
) -> (Box<dyn ProcessControl>, Box<dyn OutputDecoder>) {
let fenced = input.fenced.clone();
let shared = Shared::begin(control, input);
let decoder = CodexDecoder {
shared: Arc::clone(&shared),
thread: None,
window: 0,
base: (0, 0, 0, 0),
usage: (0, 0, 0, 0),
};
let decoder = CodexDecoder::new(Arc::clone(&shared), fenced);
(Box::new(CodexControl { shared }), Box::new(decoder))
}
}
Expand All @@ -133,7 +128,7 @@ impl OutputDecoder for Silent {
None
}

fn handle(&mut self, _daemon: &Daemon, _bot: &BotId, _generation: u64, _event: &Value) {}
fn handle(&mut self, _daemon: &Arc<Daemon>, _bot: &BotId, _generation: u64, _event: &Value) {}
}

/// The names of the MCP servers in the owner's `config.toml`, to switch off.
Expand Down Expand Up @@ -164,231 +159,3 @@ fn owner_mcp_servers() -> Vec<String> {
})
.unwrap_or_default()
}

/// Reads what the server prints and tells the daemon.
struct CodexDecoder {
shared: Arc<Shared>,
thread: Option<String>,
/// The model's context window, from `thread/tokenUsage/updated`.
window: u64,
/// What the thread has used (input, cached, output, reasoning), and what
/// it had used when this turn began.
usage: (u64, u64, u64, u64),
base: (u64, u64, u64, u64),
}

impl OutputDecoder for CodexDecoder {
fn live_text<'a>(&mut self, event: &'a Value) -> Option<&'a str> {
if event["method"] != "item/agentMessage/delta" {
return None;
}
event["params"]["delta"]
.as_str()
.filter(|text| !text.is_empty())
}

fn handle(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
if !daemon.supervisor.is_current(bot, generation) {
return;
}
let method = event["method"].as_str();
match (method, event.get("id")) {
(None, Some(_)) => self.response(daemon, bot, generation, event),
(Some(method), Some(id)) => self.server_request(method, id, &event["params"]),
(Some(method), None) => {
self.notification(daemon, bot, generation, method, &event["params"])
}
(None, None) => {}
}
}
}

impl CodexDecoder {
fn response(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
match self.shared.answered(event) {
Answer::Thread { thread, model } => {
self.thread = Some(thread);
if let Some(model) = model {
crate::service::models::reported(daemon, bot, &model);
}
}
Answer::TurnRefused(message) => {
// No turn began, so the message is read and the turn failed.
if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) {
sink::message_read(daemon, bot, &uuid);
}
let error = message.unwrap_or_else(|| "the turn was refused".to_owned());
sink::turn_finished(daemon, bot, 0, None, Some(error));
sink::finish_turn(daemon, bot, generation, true);
}
Answer::Failed(message) => {
tracing::warn!(bot = %bot, "codex could not start a thread: {message:?}");
}
Answer::Lost | Answer::None => {}
}
}

/// Requests the daemon does not take yet (A3a) are declined, except the
/// call of Botloft's own tools, which the bot needs to work in the crew.
fn server_request(&self, method: &str, id: &Value, params: &Value) {
let result = match method {
"mcpServer/elicitation/request" if params["serverName"] == "botloft" => {
json!({ "action": "accept", "content": null })
}
"mcpServer/elicitation/request" => json!({ "action": "decline", "content": null }),
"item/commandExecution/requestApproval" | "item/fileChange/requestApproval" => {
json!({ "decision": "decline" })
}
"applyPatchApproval" | "execCommandApproval" => json!({ "decision": "denied" }),
_ => {
self.shared.refuse(id, "not supported by Botloft yet");
return;
}
};
self.shared.respond(id, result);
}

fn notification(
&mut self,
daemon: &Daemon,
bot: &BotId,
generation: u64,
method: &str,
params: &Value,
) {
match method {
"turn/started" => {
self.base = self.usage;
daemon.supervisor.turn_began(bot, generation);
}
"item/started" => self.item_started(daemon, bot, generation, params),
"item/completed" => self.item_completed(daemon, bot, &params["item"]),
"thread/tokenUsage/updated" => self.token_usage(daemon, bot, &params["tokenUsage"]),
"turn/completed" => self.turn_completed(daemon, bot, generation, &params["turn"]),
_ => {}
}
}

fn item_started(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, params: &Value) {
let item = &params["item"];
let id = item["id"].as_str().unwrap_or_default();
match item["type"].as_str() {
// The message was taken up: the oldest one written.
Some("userMessage") => {
if let Some(thread) = params["threadId"].as_str().or(self.thread.as_deref()) {
sink::session_started(daemon, bot, thread);
}
if let Some(uuid) = daemon.supervisor.oldest_message_began(bot, generation) {
sink::message_read(daemon, bot, &uuid);
}
}
Some("commandExecution") => {
let command = item["commandActions"][0]["command"]
.as_str()
.or_else(|| item["command"].as_str())
.unwrap_or_default();
sink::tool_started(daemon, bot, id, "Bash", &json!({ "command": command }));
}
Some("fileChange") => {
let change = &item["changes"][0];
let name = if change["kind"]["type"] == "add" {
"Write"
} else {
"Edit"
};
sink::tool_started(
daemon,
bot,
id,
name,
&json!({ "file_path": change["path"] }),
);
}
Some("mcpToolCall") => {
let name = format!(
"mcp__{}__{}",
item["server"].as_str().unwrap_or("mcp"),
item["tool"].as_str().unwrap_or("tool")
);
sink::tool_started(daemon, bot, id, &name, &item["arguments"]);
}
_ => {}
}
}

fn item_completed(&mut self, daemon: &Daemon, bot: &BotId, item: &Value) {
let id = item["id"].as_str().unwrap_or_default();
let ok = item["status"].as_str() == Some("completed");
match item["type"].as_str() {
Some("agentMessage") => {
sink::reply(daemon, bot, item["text"].as_str().unwrap_or_default());
}
Some("commandExecution") => {
let failed = !ok || item["exitCode"].as_i64().is_some_and(|code| code != 0);
let output = item["aggregatedOutput"].as_str().unwrap_or_default();
sink::tool_finished(daemon, bot, id, failed, output);
}
Some("fileChange") => sink::tool_finished(daemon, bot, id, !ok, ""),
Some("mcpToolCall") => {
let output = item["result"]["content"]
.as_array()
.map(|parts| {
parts
.iter()
.filter_map(|part| part["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
})
.or_else(|| item["error"]["message"].as_str().map(str::to_owned))
.unwrap_or_default();
sink::tool_finished(daemon, bot, id, !ok, &output);
}
_ => {}
}
}

fn token_usage(&mut self, daemon: &Daemon, bot: &BotId, usage: &Value) {
let count = |value: &Value, key: &str| value[key].as_u64().unwrap_or_default();
self.window = count(usage, "modelContextWindow");
let last = &usage["last"];
self.usage = (
count(&usage["total"], "inputTokens"),
count(&usage["total"], "cachedInputTokens"),
count(&usage["total"], "outputTokens"),
count(&usage["total"], "reasoningOutputTokens"),
);
// The last request held the conversation up to that point.
let held = count(last, "inputTokens") + count(last, "outputTokens");
if held > 0 && self.window > 0 {
crate::context::report(daemon, bot, held, self.window);
}
}

fn turn_completed(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, turn: &Value) {
let failed = turn["status"].as_str() != Some("completed");
let error = failed.then(|| {
turn["error"]["message"]
.as_str()
.or_else(|| turn["status"].as_str())
.unwrap_or("error")
.to_owned()
});
let (input, cached, output, reasoning) = (
self.usage.0.saturating_sub(self.base.0),
self.usage.1.saturating_sub(self.base.1),
self.usage.2.saturating_sub(self.base.2),
self.usage.3.saturating_sub(self.base.3),
);
let tokens = (self.usage != self.base).then(|| TokenUsage {
input: input.saturating_sub(cached),
cache_write: 0,
reloaded: 0,
cache_read: cached,
output: output + reasoning,
});
let duration = turn["durationMs"].as_u64().unwrap_or_default();
sink::turn_finished(daemon, bot, duration, tokens, error);
self.shared.turn_done();
sink::finish_turn(daemon, bot, generation, failed);
}
}
Loading
Loading