Skip to content

Commit 923d288

Browse files
authored
Merge pull request #253 from httpsphl/feat/codex-gaps
feat(agents): Codex login and limit states, compaction and connected tools (A3d)
2 parents 0ffafec + 6dbd914 commit 923d288

15 files changed

Lines changed: 497 additions & 33 deletions

File tree

‎app/src/features/chat/ContextMeter.tsx‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export function ContextMeter({ bot, onLater }: { bot: Bot; onLater(text: string)
3939
const near = usedTokens >= limit * NEAR;
4040
const left = autoCompactTokens === null ? null : autoCompactTokens - usedTokens;
4141
const claude = bot.agent === "claude";
42+
// Claude Code and Codex answer a request to compact (spec 30).
43+
const compactable = claude || bot.agent === "codex";
4244
const running = bot.state === "idle" || bot.state === "busy" || bot.state === "needs_approval";
4345

4446
const compact = async () => {
@@ -77,17 +79,19 @@ export function ContextMeter({ bot, onLater }: { bot: Bot; onLater(text: string)
7779
)}
7880
</div>
7981
<p className="mt-2 text-ink-soft text-sm">
80-
{!claude
82+
{!compactable
8183
? m.estimate
82-
: left === null
83-
? m.noAuto
84-
: left > 0
85-
? m.autoLeft(tokens(left), tokens(autoCompactTokens ?? 0))
86-
: m.autoNow}
84+
: bot.agent === "codex"
85+
? m.byItself
86+
: left === null
87+
? m.noAuto
88+
: left > 0
89+
? m.autoLeft(tokens(left), tokens(autoCompactTokens ?? 0))
90+
: m.autoNow}
8791
</p>
8892
<p className="mt-1.5 text-muted text-xs">{m.about(bot.name)}</p>
8993
{/* Only Claude Code answers a request to compact (spec 30). */}
90-
{claude && (
94+
{compactable && (
9195
<div className="mt-3 border-line border-t pt-3">
9296
<Button
9397
size="sm"

‎app/src/features/chat/agentBot.test.tsx‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,27 @@ describe("a Codex bot", () => {
104104
fireEvent.click(screen.getByRole("button", { name: "Model: Default" }));
105105
expect(await screen.findByRole("menu", { name: "Model" })).toBeDefined();
106106
});
107+
108+
test("can compact its conversation, and says Codex does it by itself", async () => {
109+
const fake = new FakeBotloft();
110+
const crew = fake.addCrew("Ops");
111+
const bot = fake.addBot(crew.id, "Cody");
112+
fake.bot(bot.id).agent = "codex";
113+
fake.setBotState(bot.id, "idle");
114+
fake.setContext(bot.id, {
115+
usedTokens: 90_000,
116+
windowTokens: 258_400,
117+
autoCompactTokens: null,
118+
compacting: false,
119+
updatedAt: 0,
120+
});
121+
renderApp(fake);
122+
await crewOpened("Ops");
123+
openBot("Cody");
124+
await screen.findByRole("list", { name: "Messages" });
125+
fireEvent.click(screen.getByRole("button", { name: /Conversation space/ }));
126+
const panel = within(await screen.findByRole("dialog", { name: "Conversation space" }));
127+
expect(panel.getByText(/Codex compacts it by itself/)).toBeDefined();
128+
expect(panel.getByRole("button", { name: "Compact now" })).toBeDefined();
129+
});
107130
});

‎app/src/i18n/en/chat.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ export const chat = {
252252
autoLeft: (left: string, at: string) => `${left} left before it compacts by itself, at ${at}.`,
253253
autoNow: "It is full enough to compact by itself on the next message.",
254254
noAuto: "It does not compact by itself.",
255+
byItself: "Codex compacts it by itself when it gets close to full.",
255256
estimate:
256257
"An estimate: this agent does not say how large its window is, so the size comes from its model family.",
257258
compact: "Compact now",

‎app/src/i18n/es/chat.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export const chat: Messages["chat"] = {
244244
autoLeft: (left: string, at: string) => `Faltan ${left} para que se compacte sola, en ${at}.`,
245245
autoNow: "Ya está lo bastante llena para compactarse sola en el próximo mensaje.",
246246
noAuto: "No se compacta sola.",
247+
byItself: "Codex la compacta solo cuando se acerca a llenarse.",
247248
estimate:
248249
"Una estimación: este agente no dice el tamaño de su ventana, así que el tamaño sale de la familia del modelo.",
249250
compact: "Compactar ahora",

‎app/src/i18n/pt-BR/chat.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ export const chat: Messages["chat"] = {
241241
`Faltam ${left} para ela se compactar sozinha, em ${at}.`,
242242
autoNow: "Já está cheia o bastante para se compactar sozinha na próxima mensagem.",
243243
noAuto: "Ela não se compacta sozinha.",
244+
byItself: "O Codex compacta sozinho quando a conversa chega perto de encher.",
244245
estimate:
245246
"Uma estimativa: este agente não informa o tamanho da janela, então o tamanho vem da família do modelo.",
246247
compact: "Compactar agora",

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ impl Agent for CodexAgent {
9999
false
100100
}
101101

102+
fn can_compact(&self) -> bool {
103+
true
104+
}
105+
102106
fn encode_turn(&self, _uuid: &str, turn: &Turn) -> Bytes {
103107
// The wrapper turns this neutral line into a `turn/start`.
104108
super::neutral_turn_with_images(turn)

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

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ use std::path::PathBuf;
99
use std::sync::Arc;
1010

1111
use botloft_core::ids::BotId;
12-
use botloft_core::protocol::{PermissionMode, TokenUsage};
12+
use botloft_core::protocol::{
13+
ChatBody, NoticeCode, NoticeItem, NoticeLevel, PermissionMode, TokenUsage,
14+
};
1315
use serde_json::{Value, json};
1416

1517
use super::OutputDecoder;
1618
use super::codex_wire::{Answer, Shared};
1719
use crate::approvals::{self, Verdict};
18-
use crate::chat::sink;
20+
use crate::chat::{items, sink};
1921
use crate::state::Daemon;
2022

2123
/// Reads what the server prints and tells the daemon.
@@ -33,6 +35,10 @@ pub(super) struct CodexDecoder {
3335
/// command, or the files a change touches.
3436
commands: HashMap<String, String>,
3537
changes: HashMap<String, Vec<(String, bool)>>,
38+
/// When the usage limit resets, in Unix ms, from `account/rateLimits/updated`.
39+
limit_resets_at: Option<i64>,
40+
/// The running turn is a compaction: no line of its own for it.
41+
compacting: bool,
3642
}
3743

3844
impl CodexDecoder {
@@ -46,6 +52,8 @@ impl CodexDecoder {
4652
base: (0, 0, 0, 0),
4753
commands: HashMap::new(),
4854
changes: HashMap::new(),
55+
limit_resets_at: None,
56+
compacting: false,
4957
}
5058
}
5159
}
@@ -109,6 +117,7 @@ impl CodexDecoder {
109117
Answer::Failed(message) => {
110118
tracing::warn!(bot = %bot, "codex could not start a thread: {message:?}");
111119
}
120+
Answer::SignedOut => self.signed_out(daemon, bot, generation),
112121
Answer::Lost | Answer::None => {}
113122
}
114123
}
@@ -133,7 +142,7 @@ impl CodexDecoder {
133142
}
134143
"mcpServer/elicitation/request" => {
135144
let server = params["serverName"].as_str().unwrap_or("mcp");
136-
let tool = params["_meta"]["tool_name"].as_str().unwrap_or("tool");
145+
let tool = tool_in(params["message"].as_str().unwrap_or_default());
137146
(
138147
format!("mcp__{server}__{tool}"),
139148
params["_meta"]["tool_params"].clone(),
@@ -206,6 +215,13 @@ impl CodexDecoder {
206215
"item/started" => self.item_started(daemon, bot, generation, params),
207216
"item/completed" => self.item_completed(daemon, bot, &params["item"]),
208217
"thread/tokenUsage/updated" => self.token_usage(daemon, bot, &params["tokenUsage"]),
218+
"account/rateLimits/updated" => {
219+
// Seconds in Codex, milliseconds here.
220+
self.limit_resets_at = params["rateLimits"]["primary"]["resetsAt"]
221+
.as_i64()
222+
.map(|seconds| seconds * 1000)
223+
.or(self.limit_resets_at);
224+
}
209225
"turn/completed" => self.turn_completed(daemon, bot, generation, &params["turn"]),
210226
_ => {}
211227
}
@@ -260,6 +276,10 @@ impl CodexDecoder {
260276
&json!({ "file_path": change["path"] }),
261277
);
262278
}
279+
Some("contextCompaction") => {
280+
self.compacting = true;
281+
crate::context::status(daemon, bot, &json!({ "status": "compacting" }));
282+
}
263283
Some("mcpToolCall") => {
264284
let name = format!(
265285
"mcp__{}__{}",
@@ -276,6 +296,16 @@ impl CodexDecoder {
276296
let id = item["id"].as_str().unwrap_or_default();
277297
let ok = item["status"].as_str() == Some("completed");
278298
match item["type"].as_str() {
299+
Some("contextCompaction") => {
300+
// The owner asked, or it was full and Codex did it by itself.
301+
let asked = daemon.contexts.get(bot).is_some_and(|c| c.compacting);
302+
let trigger = if asked { "manual" } else { "auto" };
303+
crate::context::compacted(
304+
daemon,
305+
bot,
306+
&json!({ "compact_metadata": { "trigger": trigger } }),
307+
);
308+
}
279309
Some("agentMessage") => {
280310
sink::reply(daemon, bot, item["text"].as_str().unwrap_or_default());
281311
}
@@ -326,6 +356,26 @@ impl CodexDecoder {
326356

327357
fn turn_completed(&mut self, daemon: &Daemon, bot: &BotId, generation: u64, turn: &Value) {
328358
let failed = turn["status"].as_str() != Some("completed");
359+
// A compaction ends like a turn; its notice says what happened.
360+
if std::mem::take(&mut self.compacting) && !failed {
361+
self.usage_base();
362+
self.shared.turn_done();
363+
sink::finish_turn(daemon, bot, generation, false);
364+
return;
365+
}
366+
match turn["error"]["codexErrorInfo"].as_str() {
367+
Some("unauthorized") => {
368+
self.signed_out(daemon, bot, generation);
369+
self.shared.turn_done();
370+
return;
371+
}
372+
Some("usageLimitExceeded") => {
373+
self.limit_reached(daemon, bot, generation);
374+
self.shared.turn_done();
375+
return;
376+
}
377+
_ => {}
378+
}
329379
let error = failed.then(|| {
330380
turn["error"]["message"]
331381
.as_str()
@@ -353,6 +403,58 @@ impl CodexDecoder {
353403
}
354404
}
355405

406+
impl CodexDecoder {
407+
/// What the thread used so far is the base of the next turn.
408+
fn usage_base(&mut self) {
409+
self.base = self.usage;
410+
}
411+
412+
fn notice(&self, daemon: &Daemon, bot: &BotId, text: &str) {
413+
items::add(
414+
daemon,
415+
bot,
416+
ChatBody::Notice(NoticeItem {
417+
level: NoticeLevel::Error,
418+
code: Some(NoticeCode::TurnFailed),
419+
text: text.to_owned(),
420+
}),
421+
);
422+
}
423+
424+
/// Nobody is signed in to Codex: only the owner can fix that, so the bot
425+
/// stops until they restart it (spec 7.3).
426+
fn signed_out(&self, daemon: &Daemon, bot: &BotId, generation: u64) {
427+
self.notice(
428+
daemon,
429+
bot,
430+
"Codex is not signed in. Sign in to Codex (codex login), then restart this bot.",
431+
);
432+
daemon.supervisor.signed_out(bot, generation);
433+
}
434+
435+
/// The account reached its usage limit: messages wait until it resets.
436+
fn limit_reached(&self, daemon: &Daemon, bot: &BotId, generation: u64) {
437+
self.notice(
438+
daemon,
439+
bot,
440+
"Codex reached its usage limit. Messages wait until it resets.",
441+
);
442+
let until = self
443+
.limit_resets_at
444+
.unwrap_or_else(|| daemon.clock.now_ms() + 5 * 60 * 1000);
445+
daemon.supervisor.rate_limited(bot, generation, until);
446+
}
447+
}
448+
449+
/// The tool a request of an MCP server is about: the name in quotes in its
450+
/// message ("Allow the x MCP server to run tool \"y\"?").
451+
fn tool_in(message: &str) -> &str {
452+
message
453+
.split_once("tool \"")
454+
.and_then(|(_, rest)| rest.split_once('"'))
455+
.map_or("tool", |(name, _)| name)
456+
}
457+
356458
/// Whether the request goes ahead: the bot's mode, a rule of the bot, or the
357459
/// owner (spec 10.1).
358460
async fn decide(
@@ -383,6 +485,15 @@ async fn decide(
383485
mod tests {
384486
use super::*;
385487

488+
#[test]
489+
fn the_tool_is_the_name_in_quotes_of_the_message() {
490+
assert_eq!(
491+
tool_in("Allow the linkedin MCP server to run tool \"read_profile\"?"),
492+
"read_profile"
493+
);
494+
assert_eq!(tool_in("something else"), "tool");
495+
}
496+
386497
#[test]
387498
fn a_text_that_names_a_fenced_folder_is_caught_whatever_the_slashes_and_case() {
388499
let fenced = vec![PathBuf::from(r"C:\Users\Ana\Botloft\Other")];

0 commit comments

Comments
 (0)