Merge pull request #872 from fabro-sh/exec-verbose-rendering

Pin pebble main 6d802a9 and print tool calls and the transcript under fabro exec --verbose
This commit is contained in:
Bryan Helmkamp 2026-09-14 13:07:37 -04:00 • committed by GitHub
commit 5264227ca8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 104 additions and 17 deletions

6
Cargo.lock generated
View file

@ -5869,7 +5869,7 @@ dependencies = [
[[package]]
name = "pebble-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=69969420c9017ca15ac6c175c820a0cb8090866a#69969420c9017ca15ac6c175c820a0cb8090866a"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d802a9d2e9c356e76371089e94d4d80c0ba16c5#6d802a9d2e9c356e76371089e94d4d80c0ba16c5"
dependencies = [
"async-trait",
"futures-util",
@ -5886,7 +5886,7 @@ dependencies = [
[[package]]
name = "pebble-cli-core"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=69969420c9017ca15ac6c175c820a0cb8090866a#69969420c9017ca15ac6c175c820a0cb8090866a"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d802a9d2e9c356e76371089e94d4d80c0ba16c5#6d802a9d2e9c356e76371089e94d4d80c0ba16c5"
dependencies = [
"anyhow",
"async-trait",
@ -5915,7 +5915,7 @@ dependencies = [
[[package]]
name = "pebble-coding-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=69969420c9017ca15ac6c175c820a0cb8090866a#69969420c9017ca15ac6c175c820a0cb8090866a"
source = "git+https://github.com/lithoscomputer/pebble?rev=6d802a9d2e9c356e76371089e94d4d80c0ba16c5#6d802a9d2e9c356e76371089e94d4d80c0ba16c5"
dependencies = [
"async-trait",
"futures-util",

View file

@ -122,9 +122,9 @@ sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driv
# sandbox, so the pebble and sandbox-driver pins move independently. 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 = "69969420c9017ca15ac6c175c820a0cb8090866a" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "69969420c9017ca15ac6c175c820a0cb8090866a", features = ["mcp", "search-providers"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "69969420c9017ca15ac6c175c820a0cb8090866a" }
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d802a9d2e9c356e76371089e94d4d80c0ba16c5" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d802a9d2e9c356e76371089e94d4d80c0ba16c5", features = ["mcp", "search-providers"] }
pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "6d802a9d2e9c356e76371089e94d4d80c0ba16c5" }
sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] }
fork = "0.2"
exec = "0.3"

View file

@ -1198,7 +1198,8 @@ pub(crate) struct AgentArgs {
#[arg(long)]
pub(crate) debug: bool,
/// Print full LLM request/response JSON to stderr
/// Print tool results, the transcript, and full LLM request/response JSON
/// to stderr
#[arg(long)]
pub(crate) verbose: bool,

View file

@ -2,9 +2,12 @@
//!
//! 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
//! and terminal approval prompt. `--verbose` asks that renderer for each
//! tool call's arguments and result in full and for the transcript, and adds
//! fabro's own dump of every model request and response; without it the
//! renderer prints what it always has. 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;
@ -31,8 +34,8 @@ use fabro_util::terminal::Styles;
use fabro_workflow::web_search::{self, SearchSecrets};
use lithos_llm::catalog::ProviderId;
use pebble_cli_core::approval::TerminalApproval;
use pebble_cli_core::render::{self, JsonStream, Style};
use pebble_cli_core::session::{SessionOptions, run_prompt};
use pebble_cli_core::render::{self, JsonStream, RenderOptions, Style};
use pebble_cli_core::session::{SessionOptions, run_prompt_with};
use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::subagents::SubagentOptions;
use pebble_coding_agent::tools::{PermissionLevelPolicy, PermissionMiddleware};
@ -497,7 +500,16 @@ async fn run_session(
sigint_token.cancel();
});
let report = run_prompt(agent, args.prompt.as_str(), &cancel_token, session).await?;
// `--verbose` asks the renderer for everything it can say: each tool
// call's arguments and result in full, and the transcript. The answer on
// stdout is the session's and is not changed by it.
let render = if args.verbose {
RenderOptions::verbose()
} else {
RenderOptions::default()
};
let report =
run_prompt_with(agent, args.prompt.as_str(), &cancel_token, session, render).await?;
report
.result
.map(|_| ())

View file

@ -260,7 +260,7 @@ fn help() {
--quiet Suppress non-essential output [env: FABRO_QUIET=]
--auto-approve Skip interactive prompts; deny tools outside permission level
--debug Print LLM request/response debug info to stderr
--verbose Print full LLM request/response JSON to stderr
--verbose Print tool results, the transcript, and full LLM request/response JSON to stderr
--skills-dir <SKILLS_DIR> Directory containing skill files (overrides default discovery)
--output-format <OUTPUT_FORMAT> Output format (text for human-readable, json for NDJSON event stream) [possible values: text, json]
-h, --help Print help
@ -914,7 +914,10 @@ async fn twin_exec_shell_command() {
.input_contains(
"Run the shell command `echo hello_from_shell` and tell me what it printed",
)
.tool_call(fabro_test::TwinToolCall::shell("echo hello_from_shell"))
.tool_call(fabro_test::TwinToolCall::shell("echo hello_from_shell")),
)
.scenario(
fabro_test::TwinScenario::responses("gpt-5.4-mini")
.text("It printed hello_from_shell."),
)
.load(twin)
@ -934,9 +937,80 @@ async fn twin_exec_shell_command() {
]);
let output = run_success_output(cmd).await;
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
let stderr = console::strip_ansi_codes(&stderr);
assert_eq!(
stdout, "It printed hello_from_shell.\n",
"without --verbose, stdout carries the final answer only; stderr:\n{stderr}"
);
for absent in ["[result]", "[reasoning]", "[verbose]"] {
assert!(
!stderr.contains(absent),
"without --verbose, stderr should not carry {absent}:\n{stderr}"
);
}
}
#[fabro_macros::e2e_test(twin)]
async fn twin_exec_verbose_prints_tool_calls_and_results() {
let context = test_context!();
let twin = fabro_test::twin_openai().await;
let namespace = format!("{}::{}", module_path!(), line!());
fabro_test::TwinScenarios::new(namespace.clone())
.scenario(
fabro_test::TwinScenario::responses("gpt-5.4-mini")
.input_contains(
"Run the shell command `echo verbose_marker` and tell me what it printed",
)
.tool_call(fabro_test::TwinToolCall::shell("echo verbose_marker")),
)
.scenario(
fabro_test::TwinScenario::responses("gpt-5.4-mini").text("It printed verbose_marker."),
)
.load(twin)
.await;
let mut cmd = context.exec_cmd();
twin.configure_command(&mut cmd, &namespace);
cmd.args([
"--auto-approve",
"--permissions",
"full",
"--verbose",
"--provider",
"openai",
"--model",
"gpt-5.4-mini",
"Run the shell command `echo verbose_marker` and tell me what it printed",
]);
let output = run_success_output(cmd).await;
let stdout = String::from_utf8(output.stdout).expect("valid utf8");
let stderr = String::from_utf8(output.stderr).expect("valid utf8");
let stderr = console::strip_ansi_codes(&stderr);
assert_eq!(
stdout, "It printed verbose_marker.\n",
"--verbose should leave the answer on stdout unchanged; stderr:\n{stderr}"
);
// Pebble prints the call's arguments in full under its `[tool]` line and
// what the call answered under a `[result]` line; fabro's middleware
// still dumps each model request. (A streamed response is not dumped.)
for expected in [
"[tool] shell\n",
"\"command\": \"echo verbose_marker\"",
"[result] shell\n",
"[verbose] request:",
] {
assert!(
stderr.contains(expected),
"--verbose should print {expected:?} on stderr, got:\n{stderr}"
);
}
let (_, result) = stderr
.split_once("[result] shell\n")
.expect("the result block should follow the tool line");
assert!(
stdout.contains("hello_from_shell"),
"expected shell marker in output, got: {stdout}"
result.contains("verbose_marker"),
"the [result] block should carry what the shell printed, got:\n{result}"
);
}