Skip to content

Commit 60742e5

Browse files
committed
refactor(agent): each agent reads its own output (A1b)
Agent gains live_text and handle_output; chat::events becomes chat::claude, the Claude decoder, and the generic parts (reply, tool start and end, end of turn) go through chat::sink. StreamReader takes the bot's agent. No change for Claude bots.
1 parent ffa574f commit 60742e5

8 files changed

Lines changed: 148 additions & 70 deletions

File tree

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
44
use std::ffi::OsString;
55

6+
use botloft_core::ids::BotId;
67
use botloft_core::protocol::AgentKind;
78
use bytes::Bytes;
8-
use serde_json::json;
9+
use serde_json::{Value, json};
910

1011
use super::{Agent, LaunchPlan, Turn};
12+
use crate::chat::claude;
13+
use crate::state::Daemon;
1114

1215
/// Tools the bot uses without asking: its crew tools (spec 7.4).
1316
const ALLOWED_TOOLS: &str = "mcp__botloft";
@@ -107,6 +110,14 @@ impl Agent for ClaudeAgent {
107110
line.push(b'\n');
108111
Bytes::from(line)
109112
}
113+
114+
fn live_text<'a>(&self, event: &'a Value) -> Option<&'a str> {
115+
claude::live_text(event)
116+
}
117+
118+
fn handle_output(&self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
119+
claude::apply(daemon, bot, generation, event);
120+
}
110121
}
111122

112123
#[cfg(test)]

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ mod claude;
99
use std::ffi::OsString;
1010
use std::path::Path;
1111

12+
use botloft_core::ids::BotId;
1213
use botloft_core::protocol::{AgentKind, BotEffort, BotModel, PermissionMode};
1314
use bytes::Bytes;
15+
use serde_json::Value;
1416

1517
pub use claude::ClaudeAgent;
1618

19+
use crate::state::Daemon;
20+
1721
/// One image that goes in the turn, already read and encoded.
1822
#[derive(Debug, Clone, PartialEq, Eq)]
1923
pub struct TurnImage {
@@ -55,6 +59,16 @@ pub trait Agent: Send + Sync + 'static {
5559
/// One turn as the bytes written to the process's stdin; `uuid` is what
5660
/// the agent gives back when the turn begins.
5761
fn encode_turn(&self, uuid: &str, turn: &Turn) -> Bytes;
62+
63+
/// The piece of reply text one line of output carries while the reply is
64+
/// still being written, if that is what it is (spec 8.3).
65+
fn live_text<'a>(&self, event: &'a Value) -> Option<&'a str>;
66+
67+
/// What one line of output (already JSON) means for the chat and the
68+
/// bot's state: it saves items and tells the supervisor, through
69+
/// `chat::sink` for what every agent has and its own code for the rest.
70+
/// Lines from a process that was replaced are ignored (spec 30).
71+
fn handle_output(&self, daemon: &Daemon, bot: &BotId, generation: u64, event: &Value);
5872
}
5973

6074
static CLAUDE: ClaudeAgent = ClaudeAgent;

‎crates/botloftd/src/chat/account.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use botloft_core::protocol::{
77
};
88
use serde_json::Value;
99

10-
use super::{events, items};
10+
use super::{claude, items};
1111
use crate::state::Daemon;
1212

1313
/// Errors that restarting cannot fix: Claude Code needs the owner.
@@ -31,7 +31,7 @@ pub(super) fn failed_turn(
3131
error: &str,
3232
event: &Value,
3333
) {
34-
let detail = events::blocks(event)
34+
let detail = claude::blocks(event)
3535
.iter()
3636
.filter_map(|block| block["text"].as_str())
3737
.collect::<Vec<_>>()
Lines changed: 19 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
1-
//! What each stream-json event means for the chat and the bot's state
1+
//! Claude Code's stream-json, decoded into the chat (spec 8.1, 30): what each
2+
//! event means for the chat and the bot's state
23
//! (spec 8.1). Fields are read defensively: the format is not documented
34
//! and grows between Claude Code versions.
45
5-
use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary};
6-
use botloft_core::command::tool_explanation;
76
use botloft_core::ids::BotId;
8-
use botloft_core::protocol::{ChatBody, ReplyItem, TokenUsage, ToolItem, ToolStatus, TurnItem};
7+
use botloft_core::protocol::TokenUsage;
98
use serde_json::Value;
109
use tracing::{debug, warn};
1110

12-
use super::{account, control, items};
11+
use super::{account, control, sink};
1312
use crate::state::{Daemon, Event};
1413
use crate::{context, routines, service};
1514

16-
pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
15+
pub(crate) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
1716
// Lines from a process that was replaced say nothing about the new one.
1817
if !daemon.supervisor.is_current(bot, generation) {
1918
return;
@@ -74,7 +73,7 @@ fn init(daemon: &Daemon, bot: &BotId, event: &Value) {
7473

7574
/// The piece of reply text a `stream_event` carries, if that is what it is
7675
/// (spec 8.3). A subagent's text stays out of the chat.
77-
pub(super) fn live_text(event: &Value) -> Option<&str> {
76+
pub(crate) fn live_text(event: &Value) -> Option<&str> {
7877
if event["type"] != "stream_event" || !event["parent_tool_use_id"].is_null() {
7978
return None;
8079
}
@@ -105,36 +104,14 @@ fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
105104
context::used(daemon, bot, &event["message"]);
106105
for block in blocks(event) {
107106
match block["type"].as_str() {
108-
Some("text") => {
109-
let text = block["text"].as_str().unwrap_or_default().trim();
110-
if !text.is_empty() {
111-
items::add(
112-
daemon,
113-
bot,
114-
ChatBody::Reply(ReplyItem {
115-
text: text.to_owned(),
116-
}),
117-
);
118-
}
119-
}
120-
Some("tool_use") => {
121-
let name = block["name"].as_str().unwrap_or("tool");
122-
let input = &block["input"];
123-
items::add(
124-
daemon,
125-
bot,
126-
ChatBody::Tool(ToolItem {
127-
tool_use_id: block["id"].as_str().unwrap_or_default().to_owned(),
128-
name: name.to_owned(),
129-
summary: tool_summary(name, input),
130-
explanation: tool_explanation(name, input),
131-
input: clip(&input.to_string(), tool_input_max(name)),
132-
status: ToolStatus::Running,
133-
output: None,
134-
file: tool_file(name, input),
135-
}),
136-
);
137-
}
107+
Some("text") => sink::reply(daemon, bot, block["text"].as_str().unwrap_or_default()),
108+
Some("tool_use") => sink::tool_started(
109+
daemon,
110+
bot,
111+
block["id"].as_str().unwrap_or_default(),
112+
block["name"].as_str().unwrap_or("tool"),
113+
&block["input"],
114+
),
138115
_ => {}
139116
}
140117
}
@@ -167,25 +144,8 @@ fn tool_results(daemon: &Daemon, bot: &BotId, event: &Value) {
167144
continue;
168145
};
169146
crate::screens::tool_done(daemon, bot, id);
170-
let found = daemon.store().tool_item(bot, id);
171-
let Ok(Some(item)) = found else {
172-
continue;
173-
};
174-
let ChatBody::Tool(tool) = item.body else {
175-
continue;
176-
};
177147
let failed = block["is_error"].as_bool() == Some(true);
178-
let output = result_text(&block["content"]);
179-
let body = ChatBody::Tool(ToolItem {
180-
status: if failed {
181-
ToolStatus::Failed
182-
} else {
183-
ToolStatus::Done
184-
},
185-
output: (!output.is_empty()).then(|| clip(&output, TOOL_OUTPUT_MAX)),
186-
..tool
187-
});
188-
items::update(daemon, &item.id, body);
148+
sink::tool_finished(daemon, bot, id, failed, &result_text(&block["content"]));
189149
}
190150
}
191151

@@ -221,14 +181,12 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
221181
});
222182
// A compaction ends like a turn; its notice already says what happened.
223183
if !context::turn_ended(daemon, bot, event) {
224-
items::add(
184+
sink::turn_finished(
225185
daemon,
226186
bot,
227-
ChatBody::Turn(TurnItem {
228-
duration_ms: event["duration_ms"].as_u64().unwrap_or_default(),
229-
tokens: tokens(&event["usage"], reloaded),
230-
error,
231-
}),
187+
event["duration_ms"].as_u64().unwrap_or_default(),
188+
tokens(&event["usage"], reloaded),
189+
error,
232190
);
233191
}
234192
routines::turn_ended(daemon, bot, failed);

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,17 @@
33
//! state changes.
44
55
mod account;
6+
pub(crate) mod claude;
67
pub(crate) mod control;
7-
mod events;
88
pub(crate) mod items;
99
mod session;
10+
pub(crate) mod sink;
1011

1112
use botloft_core::ids::BotId;
1213
use botloft_core::protocol::ChatDelta;
1314
use tracing::debug;
1415

16+
use crate::agent::Agent;
1517
use crate::state::{Daemon, Event};
1618

1719
/// Longest line kept; anything longer is dropped whole (spec 8.1).
@@ -29,6 +31,8 @@ const LIVE_TEXT_MAX: usize = 4 << 10;
2931
pub struct StreamReader {
3032
bot: BotId,
3133
generation: u64,
34+
/// Reads the lines: they are the agent's own protocol.
35+
agent: &'static dyn Agent,
3236
buffer: Vec<u8>,
3337
/// Inside a line that grew past [`LINE_MAX`]; ends at its newline.
3438
skipping: bool,
@@ -37,10 +41,11 @@ pub struct StreamReader {
3741
}
3842

3943
impl StreamReader {
40-
pub fn new(bot: BotId, generation: u64) -> Self {
44+
pub fn new(bot: BotId, generation: u64, agent: &'static dyn Agent) -> Self {
4145
Self {
4246
bot,
4347
generation,
48+
agent,
4449
buffer: Vec::new(),
4550
skipping: false,
4651
live: String::new(),
@@ -105,7 +110,7 @@ impl StreamReader {
105110
return;
106111
}
107112
};
108-
if let Some(text) = events::live_text(&event) {
113+
if let Some(text) = self.agent.live_text(&event) {
109114
// Lines from a process that was replaced say nothing about the
110115
// new one.
111116
if daemon.supervisor.is_current(&self.bot, self.generation) {
@@ -118,6 +123,7 @@ impl StreamReader {
118123
}
119124
// Whatever comes next follows the text before it.
120125
self.flush(daemon);
121-
events::apply(daemon, &self.bot, self.generation, &event);
126+
self.agent
127+
.handle_output(daemon, &self.bot, self.generation, &event);
122128
}
123129
}

‎crates/botloftd/src/chat/sink.rs‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//! What any agent's output turns into in the chat (spec 8, 30): a reply, a
2+
//! tool that starts and finishes, the end of a turn. An agent decodes its own
3+
//! stream and calls these; saving the items and telling the app is the same
4+
//! for all of them.
5+
6+
use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary};
7+
use botloft_core::command::tool_explanation;
8+
use botloft_core::ids::BotId;
9+
use botloft_core::protocol::{ChatBody, ReplyItem, TokenUsage, ToolItem, ToolStatus, TurnItem};
10+
use serde_json::Value;
11+
12+
use super::items;
13+
use crate::state::Daemon;
14+
15+
/// The bot's reply text; nothing for an empty one.
16+
pub(crate) fn reply(daemon: &Daemon, bot: &BotId, text: &str) {
17+
let text = text.trim();
18+
if !text.is_empty() {
19+
items::add(
20+
daemon,
21+
bot,
22+
ChatBody::Reply(ReplyItem {
23+
text: text.to_owned(),
24+
}),
25+
);
26+
}
27+
}
28+
29+
/// A tool the bot began to use, shown as running.
30+
pub(crate) fn tool_started(daemon: &Daemon, bot: &BotId, id: &str, name: &str, input: &Value) {
31+
items::add(
32+
daemon,
33+
bot,
34+
ChatBody::Tool(ToolItem {
35+
tool_use_id: id.to_owned(),
36+
name: name.to_owned(),
37+
summary: tool_summary(name, input),
38+
explanation: tool_explanation(name, input),
39+
input: clip(&input.to_string(), tool_input_max(name)),
40+
status: ToolStatus::Running,
41+
output: None,
42+
file: tool_file(name, input),
43+
}),
44+
);
45+
}
46+
47+
/// The tool `id` finished, with what it printed. Does nothing for a tool the
48+
/// chat does not know.
49+
pub(crate) fn tool_finished(daemon: &Daemon, bot: &BotId, id: &str, failed: bool, output: &str) {
50+
let Ok(Some(item)) = daemon.store().tool_item(bot, id) else {
51+
return;
52+
};
53+
let ChatBody::Tool(tool) = item.body else {
54+
return;
55+
};
56+
let body = ChatBody::Tool(ToolItem {
57+
status: if failed {
58+
ToolStatus::Failed
59+
} else {
60+
ToolStatus::Done
61+
},
62+
output: (!output.is_empty()).then(|| clip(output, TOOL_OUTPUT_MAX)),
63+
..tool
64+
});
65+
items::update(daemon, &item.id, body);
66+
}
67+
68+
/// The line that closes a turn: how long it took, its tokens when the agent
69+
/// says, and the error it ended with, if any.
70+
pub(crate) fn turn_finished(
71+
daemon: &Daemon,
72+
bot: &BotId,
73+
duration_ms: u64,
74+
tokens: Option<TokenUsage>,
75+
error: Option<String>,
76+
) {
77+
items::add(
78+
daemon,
79+
bot,
80+
ChatBody::Turn(TurnItem {
81+
duration_ms,
82+
tokens,
83+
error,
84+
}),
85+
);
86+
}

‎crates/botloftd/src/supervisor/start.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use tracing::{debug, warn};
1414
use super::slot::Running;
1515
use super::spawn::{Launch, launch_spec, pump, settle};
1616
use super::{ClaudeStatus, Inner, Supervisor};
17+
use crate::agent::{self, ClaudeAgent};
1718
use crate::chat::{StreamReader, control};
1819
use crate::context;
1920
use crate::runtime::Process;
@@ -156,7 +157,9 @@ impl Supervisor {
156157
slot.state = BotState::Launching;
157158
self.announce(&bot.id, slot);
158159
debug!(bot = %bot.id, generation, pid = ?process.pid, resumed = launch.resumed, "bot started");
159-
let reader = StreamReader::new(bot.id.clone(), generation);
160+
// The bot launched, so its agent runs.
161+
let agent = agent::of(bot.agent).unwrap_or(&ClaudeAgent);
162+
let reader = StreamReader::new(bot.id.clone(), generation, agent);
160163
tokio::spawn(pump(self.daemon.clone(), reader, process.events));
161164
tokio::spawn(settle(
162165
self.daemon.clone(),

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2643,7 +2643,7 @@ Decisão e contexto: `docs/adr/0003-agentes.md`. Um bot tem um **agente**, `Bot.
26432643
| Passo | O que entra | Estado |
26442644
|---|---|---|
26452645
| **A0** | ADR, esta seção, `bots.agent` no banco e no protocolo; `bots.create` com `codex` ou `agy` dá `-32004` ("not available yet") | feito |
2646-
| **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 |
2646+
| **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: cada agente decodifica o próprio fluxo (`Agent::handle_output`, `Agent::live_text`) e grava no chat por `chat::sink` (resposta, ferramenta que começa e termina, fim de turno), o que todo agente tem; o que é só do Claude (contexto, compactação, parte do plano, rascunho de telas) fica no decodificador dele, `chat/claude.rs` (feito); **A1c** descoberta e login, arquivos da pasta do bot e aprovações | em andamento |
26472647
| **A2** | AGY: lançamento, entrada e saída, MCP, aprovações, isolamento, modelos e esforço, testados com o `agy` real | a fazer |
26482648
| **A3** | Codex por `codex app-server` | a fazer |
26492649
| **A4** | Seletor de agente no app, conta e login de cada agente, paridade de rotinas, navegador, desktop e perguntas | a fazer |

0 commit comments

Comments
 (0)