The tasks build the agent loop on M2a's client: channel messages and the usage record in proto, four config tables, the tool port and registry with find_tool and call_tool, the baseline and replay, the session store, the turn loop with its limits and the append-only property test, the channel server, loopd serve, bxctl chat, and the device checks including a four-turn conversation with a restart. Checked against a private reference implementation: the gate passes after every task in order, the new suites pass under CPU load, and the reference passes make verify-device on straylight with no cache loss. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5.0 KiB
M2b task 09: bxctl chat
Branch: m2b (run git switch m2b; git status --short must be empty, otherwise stop)
Commit subject: Add bxctl chat
Goal
The owner's chat client: a line in, a turn over loop.sock, the stream shown as it arrives. It
is also the scripting tool the device checks use (--say, --json).
Files
- Copy:
crates/bxctl/tests/chat.rs - Create:
crates/bxctl/src/chat.rs - Modify:
crates/bxctl/Cargo.toml(addserde_json.workspace = true),crates/bxctl/src/lib.rs(addpub mod chat;),crates/bxctl/src/main.rs,docs/implementer-log.md
Interfaces
// crates/bxctl/src/chat.rs
pub enum ChatError { Connect(std::io::Error), Frame(FrameError), Refused(WireError), Protocol(String) } // Debug; Display; Error
// Refused displays as "<code name>: <detail>", where the code name is lowercase words: "session full", "turn limit", "no such session", …
pub fn new_session_id() -> SessionId; // "chat-<unix seconds>-<nanos>"
/// Sends one turn and reads the reply. `on_event` sees every event frame as it arrives.
pub fn run_turn(socket: &Path, session: &SessionId, content: &str, resume: bool, on_event: &mut dyn FnMut(&TurnEvent)) -> Result<TurnDone, ChatError>;
pub struct Printer { pub show_thinking: bool, pub json: bool, pub stream_content: bool, /* private: whether a dimmed block is open */ }
impl Printer {
pub fn new(show_thinking: bool, json: bool) -> Printer; // stream_content: true
pub fn event(&mut self, out: &mut impl Write, event: &TurnEvent) -> std::io::Result<()>;
pub fn end_reasoning(&mut self, out: &mut impl Write) -> std::io::Result<()>; // closes an open dimmed block
}
run_turn: connect; send Turn { session, content, resume } as an envelope with id: 1,
final: true; then read frames. A frame whose id is not 1 is Protocol. TurnEvent with
final: false goes to on_event; TurnDone with final: true is the result; Error with
final: true is Refused; anything else is Protocol.
Printer::event, per event, when json is false:
| Event | Output |
|---|---|
Reasoning { text } |
if show_thinking: open a dimmed block with \x1b[2m on the first, then the text as it is |
Content { text } |
close the dimmed block if open (\x1b[0m and a newline); then the text as it is, only if stream_content |
ToolCallStarted { name } |
close the block if open; [tool <name>] and a newline |
ToolResult { name, class, truncated } |
[<name>: <class as Debug>] or [<name>: <class>, truncated], newline |
Waiting { slot_busy } |
[waiting: slot busy] or [waiting: slot idle] |
Retrying { attempt, after_ms, error } |
[retrying: attempt <n> in <ms> ms: <error>] |
ThinkingCapped { tokens } |
[thinking capped at <n> tokens] |
CacheLoss { expected, got } |
[cache loss: <got> of <expected>] |
Queued, Progress |
nothing |
Flush after every event. When json is true: every event is one line of JSON (serde_json
of the TurnEvent), nothing is skipped, no escape codes.
main: bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json].
The socket defaults to $BOXMAKER_HOME/run/loop/loop.sock, or /var/lib/boxmaker/… when the
variable is unset. An invalid --session, an unknown option, or a first argument other than
chat prints a usage line and exits 2.
- With
--session, the first turn is sent withresume: true; ifloopdanswersno_such_session, it is sent again withresume: false. Without--session, a new id is made and the first turn creates it. Every later turn resumes. --say <text>: one turn. Events go to stderr, withstream_contentoff; the answer goes to stdout with a newline; exit 0. On error:bxctl: <error>on stderr, nothing on stdout, exit 1.- Otherwise: print
session <id>, then repeat: print>, read a line; EOF or/quitends with exit 0; a blank line is skipped; anything else is a turn, with events and content on stdout, and a newline after the answer if it did not end with one. A failed turn printsbxctl: <error>and the loop goes on. --jsonwith--sayalso prints theTurnDoneas one JSON line after the events.
Steps
- 1. Copy.
git switch m2b, thenmkdir -p crates/bxctl/tests && cp docs/plans/M2b/files/crates/bxctl/tests/chat.rs crates/bxctl/tests/ - 2. See the test fail.
cargo test -p bxctl --test chat. Expected: it does not compile. - 3. Write
chat.rs,main.rs, and the two one-line changes. Runcargo fmt --all. - 4. See the tests pass.
cargo test -p bxctl. Expected:11 passed. - 5. Run the gate.
make gate. Expected last line:gate: ok. - 6. Log and commit.
git add crates/bxctl docs/implementer-log.md && git commit
Done when
cargo test -p bxctlreports 11 passed;make gateprintsgate: ok.bxctldepends onprotoandserde_jsononly.
Stop and report if
- A test in
chat.rsneeds the exact wording of an output line that this task does not give.