Add bxctl chat
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
//! `bxctl chat`: a turn client over `loop.sock` and a printer for the events it receives.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use proto::{
|
||||
Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Turn, TurnDone, TurnEvent,
|
||||
WireError, read_frame, write_frame,
|
||||
};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ChatError {
|
||||
Connect(std::io::Error),
|
||||
Frame(proto::FrameError),
|
||||
Refused(WireError),
|
||||
Protocol(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ChatError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ChatError::Connect(e) => write!(f, "{e}"),
|
||||
ChatError::Frame(e) => write!(f, "{e}"),
|
||||
ChatError::Refused(w) => write!(f, "{}: {}", code_name(w.code), w.detail),
|
||||
ChatError::Protocol(s) => write!(f, "{s}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChatError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ChatError::Connect(e) => Some(e),
|
||||
ChatError::Frame(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The ErrorCode name in snake_case, spelled out in words for the owner reading a failure.
|
||||
fn code_name(code: ErrorCode) -> &'static str {
|
||||
match code {
|
||||
ErrorCode::BadFrame => "bad frame",
|
||||
ErrorCode::BadVersion => "bad version",
|
||||
ErrorCode::BadMessage => "bad message",
|
||||
ErrorCode::Internal => "internal",
|
||||
ErrorCode::SessionFull => "session full",
|
||||
ErrorCode::TurnLimit => "turn limit",
|
||||
ErrorCode::SessionBusy => "session busy",
|
||||
ErrorCode::NoSuchSession => "no such session",
|
||||
ErrorCode::SessionExists => "session exists",
|
||||
ErrorCode::Inference => "inference",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_session_id() -> SessionId {
|
||||
// The system clock is never before the unix epoch on any machine this runs on.
|
||||
let elapsed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("the system clock is not before the unix epoch");
|
||||
// "chat-<digits>-<digits>" uses only [a-z0-9-] and stays well under 64 bytes, so this cannot fail.
|
||||
let candidate = format!("chat-{}-{}", elapsed.as_secs(), elapsed.subsec_nanos());
|
||||
SessionId::new(&candidate).expect("chat-<digits>-<digits> is always a valid session id")
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut stream = UnixStream::connect(socket).map_err(ChatError::Connect)?;
|
||||
|
||||
let turn = Turn {
|
||||
session: session.clone(),
|
||||
content: content.to_string(),
|
||||
resume,
|
||||
};
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg: Message::Turn(turn),
|
||||
},
|
||||
)
|
||||
.map_err(ChatError::Frame)?;
|
||||
|
||||
loop {
|
||||
let env = read_frame(&mut stream).map_err(ChatError::Frame)?;
|
||||
if env.id != 1 {
|
||||
return Err(ChatError::Protocol(
|
||||
"expected a frame with id 1".to_string(),
|
||||
));
|
||||
}
|
||||
match (env.r#final, &env.msg) {
|
||||
(false, Message::TurnEvent(e)) => on_event(e),
|
||||
(true, Message::TurnDone(d)) => return Ok(d.clone()),
|
||||
(true, Message::Error(w)) => return Err(ChatError::Refused(w.clone())),
|
||||
_ => return Err(ChatError::Protocol("unexpected final frame".to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Printer {
|
||||
pub show_thinking: bool,
|
||||
pub json: bool,
|
||||
pub stream_content: bool,
|
||||
// Whether a dimmed reasoning block is open and needs closing.
|
||||
dimmed_open: bool,
|
||||
}
|
||||
|
||||
impl Printer {
|
||||
pub fn new(show_thinking: bool, json: bool) -> Printer {
|
||||
Printer {
|
||||
show_thinking,
|
||||
json,
|
||||
stream_content: true,
|
||||
dimmed_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn event(&mut self, out: &mut dyn Write, event: &TurnEvent) -> std::io::Result<()> {
|
||||
if self.json {
|
||||
let line = serde_json::to_string(event).map_err(std::io::Error::other)?;
|
||||
out.write_all(line.as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
return out.flush();
|
||||
}
|
||||
|
||||
match event {
|
||||
// Reasoning opens the dimmed block on the first piece; later pieces just continue it.
|
||||
TurnEvent::Reasoning { text } => {
|
||||
if self.show_thinking {
|
||||
if !self.dimmed_open {
|
||||
out.write_all(b"\x1b[2m")?;
|
||||
self.dimmed_open = true;
|
||||
}
|
||||
out.write_all(text.as_bytes())?;
|
||||
}
|
||||
}
|
||||
TurnEvent::Content { text } => {
|
||||
if self.dimmed_open {
|
||||
out.write_all(b"\x1b[0m\n")?;
|
||||
self.dimmed_open = false;
|
||||
}
|
||||
if self.stream_content {
|
||||
out.write_all(text.as_bytes())?;
|
||||
}
|
||||
}
|
||||
TurnEvent::ToolCallStarted { name } => {
|
||||
self.close_dimmed(out)?;
|
||||
writeln!(out, "[tool {name}]")?;
|
||||
}
|
||||
TurnEvent::ToolResult {
|
||||
name,
|
||||
class,
|
||||
truncated,
|
||||
} => {
|
||||
self.close_dimmed(out)?;
|
||||
if *truncated {
|
||||
writeln!(out, "[{name}: {class:?}, truncated]")?;
|
||||
} else {
|
||||
writeln!(out, "[{name}: {class:?}]")?;
|
||||
}
|
||||
}
|
||||
TurnEvent::Waiting { slot_busy } => {
|
||||
self.close_dimmed(out)?;
|
||||
let state = if *slot_busy { "busy" } else { "idle" };
|
||||
writeln!(out, "[waiting: slot {state}]")?;
|
||||
}
|
||||
TurnEvent::Retrying {
|
||||
attempt,
|
||||
after_ms,
|
||||
error,
|
||||
} => {
|
||||
self.close_dimmed(out)?;
|
||||
writeln!(
|
||||
out,
|
||||
"[retrying: attempt {attempt} in {after_ms} ms: {error}]"
|
||||
)?;
|
||||
}
|
||||
TurnEvent::ThinkingCapped { tokens } => {
|
||||
self.close_dimmed(out)?;
|
||||
writeln!(out, "[thinking capped at {tokens} tokens]")?;
|
||||
}
|
||||
TurnEvent::CacheLoss { expected, got } => {
|
||||
self.close_dimmed(out)?;
|
||||
writeln!(out, "[cache loss: {got} of {expected}]")?;
|
||||
}
|
||||
TurnEvent::Queued { .. } | TurnEvent::Progress { .. } => {}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
|
||||
pub fn end_reasoning(&mut self, out: &mut dyn Write) -> std::io::Result<()> {
|
||||
self.close_dimmed(out)
|
||||
}
|
||||
|
||||
fn close_dimmed(&mut self, out: &mut dyn Write) -> std::io::Result<()> {
|
||||
if self.dimmed_open {
|
||||
out.write_all(b"\x1b[0m\n")?;
|
||||
self.dimmed_open = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
//! The owner's command-line tool.
|
||||
|
||||
pub mod chat;
|
||||
|
||||
+273
-3
@@ -1,4 +1,274 @@
|
||||
fn main() {
|
||||
eprintln!("bxctl: not implemented until M2");
|
||||
std::process::exit(2);
|
||||
//! `bxctl`: the owner's chat client for `loopd`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
|
||||
use proto::{ErrorCode, SessionId, TurnDone, TurnEvent};
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: bxctl chat [--socket <path>] [--session <id>] [--no-thinking] [--say <text>] [--json]";
|
||||
|
||||
struct Options {
|
||||
socket: PathBuf,
|
||||
session: Option<SessionId>,
|
||||
show_thinking: bool,
|
||||
say: Option<String>,
|
||||
json: bool,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.first().map(String::as_str) != Some("chat") {
|
||||
eprintln!("{USAGE}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
match parse_chat(&args[1..]) {
|
||||
Ok(opts) => run(&opts),
|
||||
Err(()) => {
|
||||
eprintln!("{USAGE}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_chat(args: &[String]) -> Result<Options, ()> {
|
||||
let mut opts = Options {
|
||||
socket: default_socket(),
|
||||
session: None,
|
||||
show_thinking: true,
|
||||
say: None,
|
||||
json: false,
|
||||
};
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--socket" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(p) => opts.socket = PathBuf::from(p),
|
||||
None => return Err(()),
|
||||
}
|
||||
}
|
||||
"--session" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(s) => match SessionId::new(s) {
|
||||
Ok(id) => opts.session = Some(id),
|
||||
Err(_) => return Err(()),
|
||||
},
|
||||
None => return Err(()),
|
||||
}
|
||||
}
|
||||
"--say" => {
|
||||
i += 1;
|
||||
match args.get(i) {
|
||||
Some(t) => opts.say = Some(t.to_string()),
|
||||
None => return Err(()),
|
||||
}
|
||||
}
|
||||
"--no-thinking" => opts.show_thinking = false,
|
||||
"--json" => opts.json = true,
|
||||
_ => return Err(()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
// The base directory: $BOXMAKER_HOME if set, otherwise the system location the brief fixes.
|
||||
fn default_socket() -> PathBuf {
|
||||
let base = std::env::var_os("BOXMAKER_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/var/lib/boxmaker"));
|
||||
base.join("run").join("loop").join("loop.sock")
|
||||
}
|
||||
|
||||
// A writer that records the first io error it hits, so the on_event closure (which cannot return a
|
||||
// Result) does not lose a write failure. The error is checked after run_turn returns.
|
||||
struct Sink<'a> {
|
||||
out: &'a mut (dyn Write + 'static),
|
||||
err: Option<std::io::Error>,
|
||||
}
|
||||
|
||||
impl Sink<'_> {
|
||||
fn event(&mut self, printer: &mut Printer, event: &TurnEvent) {
|
||||
if self.err.is_none() {
|
||||
self.err = printer.event(self.out, event).err();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runs one turn, streaming events through `printer` to `out`. Returns the turn outcome, or the
|
||||
// first io error that occurred while writing.
|
||||
fn stream_turn(
|
||||
socket: &Path,
|
||||
session: &SessionId,
|
||||
text: &str,
|
||||
resume: bool,
|
||||
sink: &mut Sink,
|
||||
printer: &mut Printer,
|
||||
) -> Result<Result<TurnDone, ChatError>, std::io::Error> {
|
||||
let outcome = run_turn(socket, session, text, resume, &mut |event| {
|
||||
sink.event(printer, event)
|
||||
});
|
||||
// Flush any dimmed block left open before reporting the turn's result.
|
||||
if sink.err.is_none() {
|
||||
printer.end_reasoning(sink.out)?;
|
||||
}
|
||||
match sink.err.take() {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(outcome),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(opts: &Options) -> ExitCode {
|
||||
match &opts.say {
|
||||
Some(text) => run_say(opts, text),
|
||||
None => run_interactive(opts),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_say(opts: &Options, text: &str) -> ExitCode {
|
||||
let session = match &opts.session {
|
||||
Some(s) => s.clone(),
|
||||
None => new_session_id(),
|
||||
};
|
||||
let mut printer = Printer::new(opts.show_thinking, opts.json);
|
||||
let code = run_turn_twice(opts, &session, text, opts.session.is_some(), &mut printer);
|
||||
match code {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(RunError::Io(e)) => {
|
||||
eprintln!("bxctl: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
Err(RunError::Chat(e)) => {
|
||||
eprintln!("bxctl: {e}");
|
||||
ExitCode::from(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runs a --say turn, retrying once when the named session does not exist.
|
||||
fn run_turn_twice(
|
||||
opts: &Options,
|
||||
session: &SessionId,
|
||||
text: &str,
|
||||
resume: bool,
|
||||
printer: &mut Printer,
|
||||
) -> Result<(), RunError> {
|
||||
let stderr = std::io::stderr();
|
||||
let mut handle = stderr.lock();
|
||||
let mut sink = Sink {
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, resume, &mut sink, printer) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(ChatError::Refused(w))) if w.code == ErrorCode::NoSuchSession => {
|
||||
// The named session does not exist: retry once, creating it (resume=false).
|
||||
let mut sink = Sink {
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, false, &mut sink, printer) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(RunError::Chat(e)),
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Err(RunError::Chat(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_interactive(opts: &Options) -> ExitCode {
|
||||
let mut printer = Printer::new(opts.show_thinking, opts.json);
|
||||
let mut session = None;
|
||||
let stdin = std::io::stdin();
|
||||
let mut reader = BufReader::new(stdin.lock());
|
||||
loop {
|
||||
eprint!("> ");
|
||||
let _ = std::io::stderr().flush();
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => break,
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("bxctl: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
}
|
||||
let text = line.trim();
|
||||
if text == "/quit" {
|
||||
break;
|
||||
}
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let just_created = session.is_none();
|
||||
let this_session = session.get_or_insert_with(new_session_id);
|
||||
// The first turn creates the session (resume=false); later turns resume it.
|
||||
let resume = !just_created;
|
||||
if just_created {
|
||||
println!("session {}", this_session.as_str());
|
||||
}
|
||||
let stderr = std::io::stderr();
|
||||
let mut handle = stderr.lock();
|
||||
let mut sink = Sink {
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
};
|
||||
match stream_turn(
|
||||
&opts.socket,
|
||||
this_session,
|
||||
text,
|
||||
resume,
|
||||
&mut sink,
|
||||
&mut printer,
|
||||
) {
|
||||
Err(e) => {
|
||||
eprintln!("bxctl: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
Ok(Ok(done)) => {
|
||||
if let Err(e) = write_answer(opts, &done) {
|
||||
eprintln!("bxctl: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
eprintln!("bxctl: {e}");
|
||||
return ExitCode::from(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
// The plain answer goes to stdout; in --json mode the TurnDone goes to stderr after the events.
|
||||
fn write_answer(opts: &Options, done: &TurnDone) -> std::io::Result<()> {
|
||||
if opts.json {
|
||||
let line = serde_json::to_string(done).map_err(std::io::Error::other)?;
|
||||
let mut err = std::io::stderr().lock();
|
||||
err.write_all(line.as_bytes())?;
|
||||
err.write_all(b"\n")?;
|
||||
}
|
||||
let stdout = std::io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
out.write_all(done.content.as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
out.flush()
|
||||
}
|
||||
|
||||
enum RunError {
|
||||
Io(std::io::Error),
|
||||
Chat(ChatError),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user