Show and answer approvals in bxctl chat
Implemented-By: Grok 4.6
This commit is contained in:
+146
-10
@@ -1,14 +1,17 @@
|
||||
//! `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::io::{BufRead, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::Path;
|
||||
|
||||
use proto::{
|
||||
DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, SessionId, Timestamp, Turn,
|
||||
TurnDone, TurnEvent, WireError, read_frame, write_frame,
|
||||
};
|
||||
|
||||
use crate::admin;
|
||||
use crate::escape::escape_model_text;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ChatError {
|
||||
Connect(std::io::Error),
|
||||
@@ -142,7 +145,7 @@ impl Printer {
|
||||
out.write_all(b"\x1b[2m")?;
|
||||
self.dimmed_open = true;
|
||||
}
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(escape_model_text(text).as_bytes())?;
|
||||
}
|
||||
}
|
||||
TurnEvent::Content { text } => {
|
||||
@@ -151,11 +154,12 @@ impl Printer {
|
||||
self.dimmed_open = false;
|
||||
}
|
||||
if self.stream_content {
|
||||
out.write_all(text.as_bytes())?;
|
||||
out.write_all(escape_model_text(text).as_bytes())?;
|
||||
}
|
||||
}
|
||||
TurnEvent::ToolCallStarted { name } => {
|
||||
self.close_dimmed(out)?;
|
||||
let name = escape_model_text(name);
|
||||
writeln!(out, "[tool {name}]")?;
|
||||
}
|
||||
TurnEvent::ToolResult {
|
||||
@@ -164,6 +168,7 @@ impl Printer {
|
||||
truncated,
|
||||
} => {
|
||||
self.close_dimmed(out)?;
|
||||
let name = escape_model_text(name);
|
||||
if *truncated {
|
||||
writeln!(out, "[{name}: {class:?}, truncated]")?;
|
||||
} else {
|
||||
@@ -195,8 +200,32 @@ impl Printer {
|
||||
writeln!(out, "[cache loss: {got} of {expected}]")?;
|
||||
}
|
||||
TurnEvent::Queued { .. } | TurnEvent::Progress { .. } => {}
|
||||
// Printed from task 20 on; until then these events are not sent.
|
||||
TurnEvent::ApprovalPending { .. } | TurnEvent::ToolDenied { .. } => {}
|
||||
TurnEvent::ApprovalPending { .. } => {
|
||||
self.close_dimmed(out)?;
|
||||
}
|
||||
TurnEvent::ToolDenied { name, reason } => {
|
||||
self.close_dimmed(out)?;
|
||||
let shown = escape_model_text(name);
|
||||
writeln!(out, "[denied {shown}: {}]", admin::reason_name(*reason))?;
|
||||
match reason {
|
||||
DenyReason::GrantsInvalid => {
|
||||
writeln!(out, "see docs/runbook.md#grants-invalid")?;
|
||||
}
|
||||
DenyReason::AuditUnavailable => {
|
||||
writeln!(out, "see docs/runbook.md#audit-unavailable")?;
|
||||
}
|
||||
DenyReason::StateUnreadable => {
|
||||
writeln!(out, "see docs/runbook.md#broker-state-damaged")?;
|
||||
}
|
||||
DenyReason::NoGrant
|
||||
| DenyReason::GrantExpired
|
||||
| DenyReason::TaintTooHigh
|
||||
| DenyReason::DeniedByGrant
|
||||
| DenyReason::ApprovalRefused
|
||||
| DenyReason::ApprovalExpired
|
||||
| DenyReason::InvalidArguments => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.flush()
|
||||
}
|
||||
@@ -213,3 +242,110 @@ impl Printer {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OnPending {
|
||||
EventOnly,
|
||||
Show,
|
||||
Ask,
|
||||
}
|
||||
|
||||
pub struct Approvals<'a> {
|
||||
pub admin_socket: &'a Path,
|
||||
pub on_pending: OnPending,
|
||||
}
|
||||
|
||||
pub struct TurnIo<'a> {
|
||||
pub printer: &'a mut Printer,
|
||||
pub input: &'a mut dyn BufRead,
|
||||
pub out: &'a mut dyn Write,
|
||||
}
|
||||
|
||||
pub fn handle_pending(
|
||||
admin_socket: &Path,
|
||||
approval: u64,
|
||||
ask: bool,
|
||||
now: Timestamp,
|
||||
input: &mut dyn BufRead,
|
||||
out: &mut dyn Write,
|
||||
) -> std::io::Result<()> {
|
||||
let items = match admin::list(admin_socket) {
|
||||
Ok(items) => items,
|
||||
Err(e) => {
|
||||
writeln!(out, "approval {approval}: cannot ask brokerd: {e}")?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let Some(item) = items.iter().find(|item| item.approval == approval) else {
|
||||
writeln!(out, "approval {approval} is no longer pending")?;
|
||||
return Ok(());
|
||||
};
|
||||
out.write_all(b"\x1b[0m")?;
|
||||
admin::write_block(out, item, now)?;
|
||||
if !ask {
|
||||
return Ok(());
|
||||
}
|
||||
write!(out, "type {approval} to approve, anything else refuses: ")?;
|
||||
out.flush()?;
|
||||
let mut line = String::new();
|
||||
input.read_line(&mut line)?;
|
||||
if line.ends_with('\n') {
|
||||
line.pop();
|
||||
}
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
let result = if line == approval.to_string() {
|
||||
admin::cmd_approve(admin_socket, approval, out)
|
||||
} else {
|
||||
admin::cmd_refuse(admin_socket, approval, None, out)
|
||||
};
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
writeln!(out, "approval {approval}: {e}")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The outer error is a failed write to `io.out`; the inner one is the turn's.
|
||||
pub fn stream_turn(
|
||||
socket: &Path,
|
||||
session: &SessionId,
|
||||
text: &str,
|
||||
resume: bool,
|
||||
approvals: &Approvals<'_>,
|
||||
io: &mut TurnIo<'_>,
|
||||
) -> std::io::Result<Result<TurnDone, ChatError>> {
|
||||
let mut err = None;
|
||||
let outcome = run_turn(socket, session, text, resume, &mut |event| {
|
||||
if err.is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = io.printer.event(io.out, event) {
|
||||
err = Some(e);
|
||||
return;
|
||||
}
|
||||
if let TurnEvent::ApprovalPending { approval, .. } = event
|
||||
&& approvals.on_pending != OnPending::EventOnly
|
||||
{
|
||||
let ask = approvals.on_pending == OnPending::Ask;
|
||||
if let Err(e) = handle_pending(
|
||||
approvals.admin_socket,
|
||||
*approval,
|
||||
ask,
|
||||
Timestamp::now(),
|
||||
io.input,
|
||||
io.out,
|
||||
) {
|
||||
err = Some(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(e) = err {
|
||||
return Err(e);
|
||||
}
|
||||
io.printer.end_reasoning(io.out)?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
+48
-56
@@ -5,9 +5,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
|
||||
use bxctl::admin::{self, AdminError};
|
||||
use bxctl::chat::{ChatError, Printer, new_session_id, run_turn};
|
||||
use bxctl::chat::{Approvals, ChatError, OnPending, Printer, TurnIo, new_session_id, stream_turn};
|
||||
use bxctl::cli::{self, ChatOptions, Command, USAGE};
|
||||
use proto::{ErrorCode, SessionId, Timestamp, TurnDone, TurnEvent};
|
||||
use bxctl::escape::escape_model_text;
|
||||
use proto::{ErrorCode, SessionId, Timestamp, TurnDone};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -85,58 +86,39 @@ fn cmd_grants_check(admin: &Path) -> Result<bool, AdminError> {
|
||||
admin::cmd_grants_check(admin, &mut out)
|
||||
}
|
||||
|
||||
// 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 on_pending(opts: &ChatOptions) -> OnPending {
|
||||
if opts.json {
|
||||
OnPending::EventOnly
|
||||
} else if opts.say.is_some() {
|
||||
OnPending::Show
|
||||
} else {
|
||||
OnPending::Ask
|
||||
}
|
||||
}
|
||||
|
||||
fn run(opts: &ChatOptions) -> ExitCode {
|
||||
let stdin = std::io::stdin();
|
||||
let mut input = BufReader::new(stdin.lock());
|
||||
match &opts.say {
|
||||
Some(text) => run_say(opts, text),
|
||||
None => run_interactive(opts),
|
||||
Some(text) => run_say(opts, text, &mut input),
|
||||
None => run_interactive(opts, &mut input),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_say(opts: &ChatOptions, text: &str) -> ExitCode {
|
||||
fn run_say(opts: &ChatOptions, text: &str, input: &mut dyn BufRead) -> 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);
|
||||
let code = run_turn_twice(
|
||||
opts,
|
||||
&session,
|
||||
text,
|
||||
opts.session.is_some(),
|
||||
&mut printer,
|
||||
input,
|
||||
);
|
||||
match code {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(RunError::Io(e)) => {
|
||||
@@ -157,14 +139,20 @@ fn run_turn_twice(
|
||||
text: &str,
|
||||
resume: bool,
|
||||
printer: &mut Printer,
|
||||
input: &mut dyn BufRead,
|
||||
) -> Result<(), RunError> {
|
||||
let stderr = std::io::stderr();
|
||||
let mut handle = stderr.lock();
|
||||
let mut sink = Sink {
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
let approvals = Approvals {
|
||||
admin_socket: &opts.admin_socket,
|
||||
on_pending: on_pending(opts),
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, resume, &mut sink, printer) {
|
||||
let mut io = TurnIo {
|
||||
printer,
|
||||
input,
|
||||
out: &mut handle,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, resume, &approvals, &mut io) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
@@ -172,11 +160,12 @@ fn run_turn_twice(
|
||||
}
|
||||
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 {
|
||||
let mut io = TurnIo {
|
||||
printer,
|
||||
input,
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
};
|
||||
match stream_turn(&opts.socket, session, text, false, &mut sink, printer) {
|
||||
match stream_turn(&opts.socket, session, text, false, &approvals, &mut io) {
|
||||
Err(e) => Err(RunError::Io(e)),
|
||||
Ok(Ok(done)) => {
|
||||
write_answer(opts, &done).map_err(RunError::Io)?;
|
||||
@@ -189,11 +178,9 @@ fn run_turn_twice(
|
||||
}
|
||||
}
|
||||
|
||||
fn run_interactive(opts: &ChatOptions) -> ExitCode {
|
||||
fn run_interactive(opts: &ChatOptions, reader: &mut dyn BufRead) -> 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();
|
||||
@@ -222,17 +209,22 @@ fn run_interactive(opts: &ChatOptions) -> ExitCode {
|
||||
}
|
||||
let stderr = std::io::stderr();
|
||||
let mut handle = stderr.lock();
|
||||
let mut sink = Sink {
|
||||
let approvals = Approvals {
|
||||
admin_socket: &opts.admin_socket,
|
||||
on_pending: on_pending(opts),
|
||||
};
|
||||
let mut io = TurnIo {
|
||||
printer: &mut printer,
|
||||
input: reader,
|
||||
out: &mut handle,
|
||||
err: None,
|
||||
};
|
||||
match stream_turn(
|
||||
&opts.socket,
|
||||
this_session,
|
||||
text,
|
||||
resume,
|
||||
&mut sink,
|
||||
&mut printer,
|
||||
&approvals,
|
||||
&mut io,
|
||||
) {
|
||||
Err(e) => {
|
||||
eprintln!("bxctl: {e}");
|
||||
@@ -264,7 +256,7 @@ fn write_answer(opts: &ChatOptions, done: &TurnDone) -> std::io::Result<()> {
|
||||
}
|
||||
let stdout = std::io::stdout();
|
||||
let mut out = stdout.lock();
|
||||
out.write_all(done.content.as_bytes())?;
|
||||
out.write_all(escape_model_text(&done.content).as_bytes())?;
|
||||
out.write_all(b"\n")?;
|
||||
out.flush()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user