mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-14 23:22:51 +00:00
Run fabro exec through pebble's command-line session
Pebble's command line is a library now. `fabro exec` builds its agent as before, with fabro's client, sandbox, MCP servers, skills, search, and redaction, and hands it to pebble's session: the events rendered as they happen, the answer on standard output, the summary after it, the agent shut down for the reason the prompt ended with, and the terminal approval prompt for tools the permission level does not allow. Fabro's own progress printer, approval prompt, summary, and MCP report are gone. The event stream of `--output-format json` stays on standard output. Standard output now carries the final answer alone rather than every assistant message; `--verbose` no longer prints tool results, since the session's renderer shows tool failures only. The lockfile moves tempfile to the version pebble pins, and the SQLite backup migration uses the replacement for the constructor that version deprecates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
a33c9069cf
commit
66360c2896
5 changed files with 81 additions and 415 deletions
64
Cargo.lock
generated
64
Cargo.lock
generated
|
|
@ -235,9 +235,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
|
|
@ -2063,7 +2063,7 @@ dependencies = [
|
|||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2190,7 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2424,6 +2424,7 @@ dependencies = [
|
|||
"openssl",
|
||||
"paste",
|
||||
"pebble-agent",
|
||||
"pebble-cli-core",
|
||||
"pebble-coding-agent",
|
||||
"predicates",
|
||||
"progenitor-client",
|
||||
|
|
@ -4346,7 +4347,7 @@ dependencies = [
|
|||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5292,7 +5293,7 @@ version = "0.50.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -5857,7 +5858,7 @@ dependencies = [
|
|||
[[package]]
|
||||
name = "pebble-agent"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=3a76aeee74d6c4aef349b927b09455c5616b296e#3a76aeee74d6c4aef349b927b09455c5616b296e"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=cb6b24e26eaab24fa94a53babeafaccf20b29ee8#cb6b24e26eaab24fa94a53babeafaccf20b29ee8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
@ -5871,10 +5872,39 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pebble-cli-core"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=cb6b24e26eaab24fa94a53babeafaccf20b29ee8#cb6b24e26eaab24fa94a53babeafaccf20b29ee8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"clap",
|
||||
"crossterm",
|
||||
"fs2",
|
||||
"futures-util",
|
||||
"humantime",
|
||||
"lithos-llm",
|
||||
"pebble-agent",
|
||||
"pebble-coding-agent",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"similar",
|
||||
"tempfile",
|
||||
"termimad",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.2",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pebble-coding-agent"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=3a76aeee74d6c4aef349b927b09455c5616b296e#3a76aeee74d6c4aef349b927b09455c5616b296e"
|
||||
source = "git+https://github.com/lithoscomputer/pebble?rev=cb6b24e26eaab24fa94a53babeafaccf20b29ee8#cb6b24e26eaab24fa94a53babeafaccf20b29ee8"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures-util",
|
||||
|
|
@ -6244,7 +6274,7 @@ dependencies = [
|
|||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6721,7 +6751,7 @@ dependencies = [
|
|||
"errno 0.3.14",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -6780,7 +6810,7 @@ dependencies = [
|
|||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -7424,7 +7454,7 @@ version = "1.4.8"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno 0.2.8",
|
||||
"errno 0.3.14",
|
||||
"libc",
|
||||
]
|
||||
|
||||
|
|
@ -7981,15 +8011,15 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.26.0"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -8024,7 +8054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||
dependencies = [
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -9091,7 +9121,7 @@ version = "0.1.11"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -112,8 +112,9 @@ futures-util = "0.3"
|
|||
# the merge commit once it lands. Pebble pins the same lithos-llm rev as
|
||||
# fabro, and its lockfile policy is that every shared crate resolves to the
|
||||
# version lithos-llm locks.
|
||||
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "3a76aeee74d6c4aef349b927b09455c5616b296e" }
|
||||
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "3a76aeee74d6c4aef349b927b09455c5616b296e", features = ["mcp", "search-providers"] }
|
||||
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "cb6b24e26eaab24fa94a53babeafaccf20b29ee8" }
|
||||
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "cb6b24e26eaab24fa94a53babeafaccf20b29ee8", features = ["mcp", "search-providers"] }
|
||||
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "cb6b24e26eaab24fa94a53babeafaccf20b29ee8" }
|
||||
sandbox-driver = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
|
||||
sandbox-driver-protocol = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
|
||||
sandbox-driver-host = { git = "https://github.com/lithoscomputer/sandbox-driver", rev = "23062b6ad62ff4665cbbcb7dce037ec9c4c34318" }
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ fabro-oauth = { path = "../../foundation/fabro-oauth" }
|
|||
fabro-github = { path = "../../components/fabro-github" }
|
||||
pebble-agent.workspace = true
|
||||
pebble-coding-agent.workspace = true
|
||||
pebble-cli-core.workspace = true
|
||||
fabro-dump = { path = "../../components/fabro-dump" }
|
||||
fabro-hooks = { path = "../../components/fabro-hooks" }
|
||||
fabro-install = { path = "../../components/fabro-install" }
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
//! `fabro exec`: one agentic coding session in the current directory.
|
||||
//!
|
||||
//! The session is pebble's coding agent over a local sandbox. Model calls go
|
||||
//! either straight to the provider with the CLI's credentials or through a
|
||||
//! Fabro server's completions endpoint when a server target is set.
|
||||
//! The session is pebble's coding agent over a local sandbox, run through
|
||||
//! pebble's own command-line session: its event renderer, closing summary,
|
||||
//! and terminal approval prompt. What is fabro's here is the client (model
|
||||
//! calls go either straight to the provider with the CLI's credentials or
|
||||
//! through a Fabro server's completions endpoint when a server target is
|
||||
//! set), the sandbox, the MCP servers, skills, search, and redaction.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::IsTerminal as _;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result as AnyResult};
|
||||
use async_trait::async_trait;
|
||||
|
|
@ -20,7 +23,6 @@ use fabro_mcp::config::McpServerSettings;
|
|||
use fabro_mcp::pebble::pebble_servers;
|
||||
use fabro_sandbox::{RunSandbox, SecretRedactor, local_sandbox};
|
||||
use fabro_static::EnvVars;
|
||||
use fabro_types::PermissionLevel;
|
||||
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
|
||||
use fabro_types::settings::run::ResolvedMcpEntry;
|
||||
use fabro_util::exit::{self, ErrorExt, ExitClass};
|
||||
|
|
@ -28,20 +30,14 @@ use fabro_util::home::Home;
|
|||
use fabro_util::terminal::Styles;
|
||||
use fabro_workflow::web_search::{self, SearchSecrets};
|
||||
use lithos_llm::catalog::ProviderId;
|
||||
use pebble_agent::{ToolCallRequest, ToolSystemError};
|
||||
use pebble_cli_core::approval::TerminalApproval;
|
||||
use pebble_cli_core::render::{self, JsonStream, Style};
|
||||
use pebble_cli_core::session::{SessionOptions, run_prompt};
|
||||
use pebble_coding_agent::environment::Environment;
|
||||
use pebble_coding_agent::events::{CodingAgentEvent, CodingEvent};
|
||||
use pebble_coding_agent::state::Message;
|
||||
use pebble_coding_agent::subagents::SubagentOptions;
|
||||
use pebble_coding_agent::tools::{
|
||||
ApprovalDecision, PermissionLevelPolicy, PermissionMiddleware, ToolApprovalService,
|
||||
};
|
||||
use pebble_coding_agent::{
|
||||
CodingAgent, CodingAgentOptions, MemoryDiscovery, ShutdownReason, SkillDiscovery,
|
||||
};
|
||||
use tokio::io::{AsyncWriteExt, stdout};
|
||||
use pebble_coding_agent::tools::{PermissionLevelPolicy, PermissionMiddleware};
|
||||
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, MemoryDiscovery, SkillDiscovery};
|
||||
use tokio::signal;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::args::{AgentArgs, ExecArgs, ExecOutputFormat};
|
||||
|
|
@ -325,162 +321,6 @@ fn summarizer_model(catalog: &Catalog, provider_id: &ProviderId, selected_model:
|
|||
format!("{provider_id}/{model}")
|
||||
}
|
||||
|
||||
/// Interactive approval for tools the permission level does not allow
|
||||
/// outright. Without a terminal, or with `--auto-approve`, such tools are
|
||||
/// refused.
|
||||
struct CliApproval {
|
||||
level: Mutex<PermissionLevel>,
|
||||
is_interactive: bool,
|
||||
styles: &'static Styles,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolApprovalService for CliApproval {
|
||||
async fn approve(
|
||||
&self,
|
||||
request: &ToolCallRequest,
|
||||
) -> Result<ApprovalDecision, ToolSystemError> {
|
||||
let tool_name = request.call().name.clone();
|
||||
let current_level = *self
|
||||
.level
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if current_level.auto_approves_tool(&tool_name) {
|
||||
return Ok(ApprovalDecision::Allow);
|
||||
}
|
||||
if !self.is_interactive {
|
||||
return Ok(ApprovalDecision::Deny {
|
||||
reason: format!("{tool_name} tool denied at current permission level"),
|
||||
});
|
||||
}
|
||||
let styles = self.styles;
|
||||
let answer = spawn_blocking(move || prompt_for_approval(&tool_name, styles))
|
||||
.await
|
||||
.map_err(|error| ToolSystemError::new(format!("approval prompt failed: {error}")))?;
|
||||
match answer {
|
||||
Ok(ApprovalAnswer::Allow) => Ok(ApprovalDecision::Allow),
|
||||
Ok(ApprovalAnswer::AllowAlways) => {
|
||||
*self
|
||||
.level
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = PermissionLevel::Full;
|
||||
Ok(ApprovalDecision::Allow)
|
||||
}
|
||||
Ok(ApprovalAnswer::Deny { tool_name }) => Ok(ApprovalDecision::Deny {
|
||||
reason: format!("{tool_name} tool denied by user"),
|
||||
}),
|
||||
Err(reason) => Ok(ApprovalDecision::Deny { reason }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ApprovalAnswer {
|
||||
Allow,
|
||||
AllowAlways,
|
||||
Deny { tool_name: String },
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "Interactive approval prompts belong on stderr, not assistant output."
|
||||
)]
|
||||
#[expect(
|
||||
clippy::disallowed_methods,
|
||||
clippy::disallowed_types,
|
||||
reason = "Interactive tool approval blocks on stdin and stderr by design, on a blocking task."
|
||||
)]
|
||||
fn prompt_for_approval(tool_name: &str, styles: &Styles) -> Result<ApprovalAnswer, String> {
|
||||
use std::io::Write as _;
|
||||
|
||||
eprint!(
|
||||
"Allow {}? [y]es / [n]o / [a]lways: ",
|
||||
styles.bold.apply_to(tool_name),
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut input = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut input)
|
||||
.map_err(|e| format!("Failed to read input: {e}"))?;
|
||||
Ok(match input.trim().to_lowercase().as_str() {
|
||||
"y" | "yes" => ApprovalAnswer::Allow,
|
||||
"a" | "always" => ApprovalAnswer::AllowAlways,
|
||||
_ => ApprovalAnswer::Deny {
|
||||
tool_name: tool_name.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String {
|
||||
let cwd_prefix = if cwd.ends_with('/') {
|
||||
cwd.to_string()
|
||||
} else {
|
||||
format!("{cwd}/")
|
||||
};
|
||||
let Some(obj) = args.as_object() else {
|
||||
return args.to_string();
|
||||
};
|
||||
obj.iter()
|
||||
.map(|(k, v)| match v {
|
||||
serde_json::Value::String(s) => {
|
||||
let s = s.strip_prefix(&cwd_prefix).unwrap_or(s);
|
||||
let display = if s.len() > 80 {
|
||||
format!("{}...", &s[..s.floor_char_boundary(77)])
|
||||
} else {
|
||||
s.to_string()
|
||||
};
|
||||
format!("{k}={display:?}")
|
||||
}
|
||||
other => format!("{k}={other}"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::print_stdout,
|
||||
reason = "Assistant responses are the CLI's primary stdout output."
|
||||
)]
|
||||
fn print_output(agent: &CodingAgent, styles: &Styles) {
|
||||
for turn in agent.history().turns() {
|
||||
if let Message::Assistant { content, .. } = turn {
|
||||
if !content.is_empty() {
|
||||
println!("{}", styles.render_markdown(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "Session summaries are diagnostic metadata, not assistant output."
|
||||
)]
|
||||
fn print_summary(agent: &CodingAgent, styles: &Styles) {
|
||||
let (mut turn_count, mut tool_call_count, mut total_tokens) = (0usize, 0usize, 0u64);
|
||||
for turn in agent.history().turns() {
|
||||
if let Message::Assistant {
|
||||
tool_calls, usage, ..
|
||||
} = turn
|
||||
{
|
||||
turn_count += 1;
|
||||
tool_call_count += tool_calls.len();
|
||||
total_tokens = total_tokens.saturating_add(usage.input.saturating_add(usage.output));
|
||||
}
|
||||
}
|
||||
let token_str = if total_tokens >= 1_000_000 {
|
||||
format!("{:.1}m", total_tokens as f64 / 1_000_000.0)
|
||||
} else if total_tokens >= 1000 {
|
||||
format!("{}k", total_tokens / 1000)
|
||||
} else {
|
||||
total_tokens.to_string()
|
||||
};
|
||||
eprintln!(
|
||||
"{}",
|
||||
styles.dim.apply_to(format!(
|
||||
"Done ({turn_count} turns, {tool_call_count} tools, {token_str} toks)"
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Middleware that logs LLM request/response summaries to stderr.
|
||||
struct DebugMiddleware {
|
||||
styles: &'static Styles,
|
||||
|
|
@ -554,9 +394,8 @@ impl Middleware for VerboseMiddleware {
|
|||
}
|
||||
|
||||
#[allow(
|
||||
clippy::print_stdout,
|
||||
clippy::print_stderr,
|
||||
reason = "Assistant output stays on stdout while prompts and diagnostics use stderr."
|
||||
reason = "The model line is a diagnostic for the person running the CLI."
|
||||
)]
|
||||
async fn run_session(
|
||||
args: AgentArgs,
|
||||
|
|
@ -587,7 +426,6 @@ async fn run_session(
|
|||
eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}")));
|
||||
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
let cwd_str = cwd.to_string_lossy().to_string();
|
||||
let sandbox: Arc<RunSandbox> = Arc::new(
|
||||
local_sandbox(cwd)
|
||||
.await
|
||||
|
|
@ -600,14 +438,9 @@ async fn run_session(
|
|||
reason = "is_terminal() on stdin is a non-blocking fstat; no actual I/O performed"
|
||||
)]
|
||||
let is_interactive = std::io::stdin().is_terminal() && !args.auto_approve;
|
||||
let approval = Arc::new(CliApproval {
|
||||
level: Mutex::new(permissions),
|
||||
is_interactive,
|
||||
styles,
|
||||
});
|
||||
let permission_middleware =
|
||||
PermissionMiddleware::new(Arc::new(PermissionLevelPolicy::new(permissions)))
|
||||
.with_approval(approval);
|
||||
.with_approval(Arc::new(TerminalApproval::new(permissions, is_interactive)));
|
||||
|
||||
// The profile's own instruction files from the repository root down, and
|
||||
// fabro's skill directories: pebble knows the files and does the walk.
|
||||
|
|
@ -639,16 +472,22 @@ async fn run_session(
|
|||
if let Some(search) = web_search::search_provider(&cli_search_secrets()) {
|
||||
builder = builder.search_provider(search);
|
||||
}
|
||||
let mut agent = builder
|
||||
let agent = builder
|
||||
.build()
|
||||
.await
|
||||
.context("failed to start the agent session")?;
|
||||
if matches!(
|
||||
args.output_format.unwrap_or(ExecOutputFormat::Text),
|
||||
ExecOutputFormat::Text
|
||||
) {
|
||||
print_mcp_servers(&agent, styles);
|
||||
}
|
||||
|
||||
// Text puts progress on stderr and the answer on stdout; JSON puts the
|
||||
// event stream itself on stdout, as scripts that read it expect.
|
||||
let session = match args.output_format.unwrap_or(ExecOutputFormat::Text) {
|
||||
ExecOutputFormat::Text => SessionOptions::default(),
|
||||
ExecOutputFormat::Json => SessionOptions {
|
||||
style: Style::Json,
|
||||
json_to: JsonStream::Stdout,
|
||||
write_answer: false,
|
||||
},
|
||||
};
|
||||
render::report_mcp_servers(&agent, session.style);
|
||||
|
||||
// SIGINT ends the prompt; the session shuts down as cancelled.
|
||||
let cancel_token = CancellationToken::new();
|
||||
|
|
@ -658,204 +497,13 @@ async fn run_session(
|
|||
sigint_token.cancel();
|
||||
});
|
||||
|
||||
let verbose = args.verbose;
|
||||
let output_format = args.output_format.unwrap_or(ExecOutputFormat::Text);
|
||||
let mut rx = agent.subscribe();
|
||||
let printer = tokio::spawn(async move {
|
||||
match output_format {
|
||||
ExecOutputFormat::Json => {
|
||||
let mut stdout = stdout();
|
||||
while let Ok(event) = rx.recv().await {
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
let _ = stdout.write_all(json.as_bytes()).await;
|
||||
let _ = stdout.write_all(b"\n").await;
|
||||
let _ = stdout.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
ExecOutputFormat::Text => {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
print_progress(&event, verbose, &cwd_str, styles);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let report = agent
|
||||
.prompt_with_cancellation(args.prompt.as_str(), &cancel_token)
|
||||
.await;
|
||||
let shutdown_reason = match &report.result {
|
||||
Ok(_) => ShutdownReason::Completed,
|
||||
Err(_) if cancel_token.is_cancelled() => ShutdownReason::Cancelled,
|
||||
Err(_) => ShutdownReason::Error,
|
||||
};
|
||||
if let Err(error) = agent.shutdown(shutdown_reason).await {
|
||||
tracing::debug!(error = %error, "agent session did not shut down cleanly");
|
||||
}
|
||||
// The stream ends with the shutdown, so the printer drains everything.
|
||||
let _ = printer.await;
|
||||
|
||||
if matches!(output_format, ExecOutputFormat::Text) {
|
||||
print_output(&agent, styles);
|
||||
print_summary(&agent, styles);
|
||||
}
|
||||
|
||||
let report = run_prompt(agent, args.prompt.as_str(), &cancel_token, session).await?;
|
||||
report
|
||||
.result
|
||||
.map(|_| ())
|
||||
.map_err(|error| anyhow::Error::new(SessionError::from(error)))
|
||||
}
|
||||
|
||||
/// Report what became of each configured MCP server on stderr: pebble
|
||||
/// started them while the agent was built, so the outcomes are read from the
|
||||
/// agent rather than from a stream that had no subscriber yet.
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "MCP connection outcomes are diagnostics for the person running the CLI."
|
||||
)]
|
||||
fn print_mcp_servers(agent: &CodingAgent, styles: &Styles) {
|
||||
for status in agent.snapshot().mcp_servers() {
|
||||
match &status.error {
|
||||
None => eprintln!(
|
||||
"{}",
|
||||
styles.dim.apply_to(format!(
|
||||
"[mcp] {}: {} tools",
|
||||
status.server,
|
||||
status.tools.len()
|
||||
))
|
||||
),
|
||||
Some(error) => eprintln!(
|
||||
"{}",
|
||||
styles
|
||||
.red
|
||||
.apply_to(format!("[mcp] {} failed: {error}", status.server))
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::print_stderr,
|
||||
reason = "Progress lines are diagnostics on stderr; assistant output stays on stdout."
|
||||
)]
|
||||
fn print_progress(event: &CodingAgentEvent, verbose: bool, cwd: &str, s: &Styles) {
|
||||
let child_prefix = if event.parent_session_id.is_some() {
|
||||
format!("[child {}] ", event.session_id)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
match &event.event {
|
||||
CodingEvent::ToolCallStarted {
|
||||
tool_name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
eprintln!(
|
||||
" {} {}{}",
|
||||
s.dim.apply_to("\u{25cf}"),
|
||||
s.bold_cyan.apply_to(format!("{child_prefix}{tool_name}")),
|
||||
s.dim
|
||||
.apply_to(format!("({})", format_tool_args(arguments, cwd))),
|
||||
);
|
||||
}
|
||||
CodingEvent::ToolCallCompleted {
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
..
|
||||
} if verbose => {
|
||||
let label = if *is_error {
|
||||
"tool error"
|
||||
} else {
|
||||
"tool result"
|
||||
};
|
||||
eprintln!(
|
||||
" {}\n{}",
|
||||
s.dim
|
||||
.apply_to(format!("[{label}] {child_prefix}{tool_name}:")),
|
||||
serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string()),
|
||||
);
|
||||
}
|
||||
CodingEvent::Error { error } => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.red
|
||||
.apply_to(format!("\u{2717} {child_prefix}{}", error.message)),
|
||||
);
|
||||
}
|
||||
CodingEvent::SubAgentSpawned {
|
||||
agent_id,
|
||||
depth,
|
||||
task,
|
||||
generation,
|
||||
}
|
||||
| CodingEvent::SubAgentTurnStarted {
|
||||
agent_id,
|
||||
depth,
|
||||
task,
|
||||
generation,
|
||||
} => {
|
||||
let started = if matches!(event.event, CodingEvent::SubAgentSpawned { .. }) {
|
||||
"spawned"
|
||||
} else {
|
||||
"turn started"
|
||||
};
|
||||
let task_preview = if task.len() > 60 {
|
||||
&task[..task.floor_char_boundary(60)]
|
||||
} else {
|
||||
task
|
||||
};
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!(
|
||||
"{child_prefix}\u{25b6} subagent {agent_id} {started} (depth={depth}, generation={generation}) task={task_preview:?}"
|
||||
)),
|
||||
);
|
||||
}
|
||||
CodingEvent::SubAgentCompleted {
|
||||
agent_id,
|
||||
depth,
|
||||
generation,
|
||||
success,
|
||||
turns_used,
|
||||
} => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!(
|
||||
"{child_prefix}\u{25a0} subagent {agent_id} completed (depth={depth}, generation={generation}, success={success}, turns={turns_used})"
|
||||
)),
|
||||
);
|
||||
}
|
||||
CodingEvent::SubAgentFailed {
|
||||
agent_id,
|
||||
depth,
|
||||
generation,
|
||||
error,
|
||||
} => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.red.apply_to(format!(
|
||||
"{child_prefix}\u{2717} subagent {agent_id} failed (depth={depth}, generation={generation}): {}",
|
||||
error.message
|
||||
)),
|
||||
);
|
||||
}
|
||||
CodingEvent::SubAgentClosed {
|
||||
agent_id,
|
||||
depth,
|
||||
generation,
|
||||
} => {
|
||||
eprintln!(
|
||||
" {}",
|
||||
s.dim.apply_to(format!(
|
||||
"{child_prefix}\u{25a0} subagent {agent_id} closed (depth={depth}, generation={generation})"
|
||||
)),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -864,10 +512,7 @@ mod tests {
|
|||
use fabro_types::settings::run::{McpServerRef, McpServerSettings, ResolvedMcpEntry};
|
||||
use lithos_llm::catalog::builtin;
|
||||
|
||||
use super::{
|
||||
AgentArgs, format_tool_args, resolve_provider_id, run_mcp_servers_for_exec,
|
||||
summarizer_model,
|
||||
};
|
||||
use super::{AgentArgs, resolve_provider_id, run_mcp_servers_for_exec, summarizer_model};
|
||||
use crate::args::{ExecOutputFormat, PermissionsArg};
|
||||
|
||||
fn args(provider: Option<&str>, model: Option<&str>) -> AgentArgs {
|
||||
|
|
@ -946,15 +591,4 @@ mod tests {
|
|||
assert!(selector.starts_with("anthropic/"), "{selector}");
|
||||
assert_ne!(selector, "anthropic/claude-opus-4-6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_args_strip_the_working_directory_prefix() {
|
||||
let rendered = format_tool_args(
|
||||
&serde_json::json!({"file_path": "/work/src/main.rs", "limit": 20}),
|
||||
"/work",
|
||||
);
|
||||
|
||||
assert!(rendered.contains("file_path=\"src/main.rs\""), "{rendered}");
|
||||
assert!(rendered.contains("limit=20"), "{rendered}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ pub(crate) async fn create_backup(
|
|||
let publish_staging = staging_path.clone();
|
||||
let publish_backup = backup_path.to_path_buf();
|
||||
let already_exists = spawn_blocking(move || {
|
||||
let staging = tempfile::TempPath::from_path(publish_staging);
|
||||
let staging = tempfile::TempPath::try_from_path(publish_staging)?;
|
||||
match staging.persist_noclobber(&publish_backup) {
|
||||
Ok(()) => {
|
||||
// Make the rename's directory entry durable: the retained
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue