Pin the merged pebble main and adopt the pebble features petri already uses

Step 0 of .ai/plans/pebble-absorbs-embedder-concerns.md. Pebble's
fabro-exec-sink-max-turns branch is merged into its main (pebble 4db661c,
on the embedder-concerns branch until it lands), so fabro and petri pin
one line. Both prompt budgets survive the merge because they count
different things.

- Agent hooks use `with_max_tool_rounds`, which is what the
  `max_tool_rounds` setting names and what petri does: `max_tool_rounds`
  model turns may run, and the hook proceeds on a turn that still asks for
  tools, so pebble gets `max_tool_rounds - 1` rounds and `ToolRoundsExhausted`
  fails open. Zero rounds proceeds without an agent, as the old loop did.
  Agent stages set no turn budget; the stage timeout and stall watchdog
  bound them.
- Prompt stages load project memory through pebble's `ProjectMemory`, the
  loader agent stages already run over `with_memory_files`, instead of a
  hand-rolled copy of its budget, deduplication, and truncation.
- `fabro_sandbox::SecretRedactor` puts fabro's secret scanner on pebble's
  text seams (process output tails, failed tool messages) for agent
  stages, Ask Fabro, hook evaluators, and `fabro exec`. The final redaction
  pass over every stored `RunEvent` stays; this does not replace it.
- Agent stages state their compaction policy explicitly: the 80 percent
  threshold and six preserved turns fabro's own agent loop applied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-11 18:16:03 -06:00
parent 6599f7cdc0
commit 8d32b472db
No known key found for this signature in database
9 changed files with 138 additions and 71 deletions

26
Cargo.lock generated
View file

@ -2063,7 +2063,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2190,7 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5299,7 +5299,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5864,7 +5864,7 @@ dependencies = [
[[package]]
name = "pebble-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=09b17c1b9e6cf7b2f82657d2080f82c73a0064ef#09b17c1b9e6cf7b2f82657d2080f82c73a0064ef"
source = "git+https://github.com/lithoscomputer/pebble?rev=4db661c44b09048db28d892568382408fbbc6c6e#4db661c44b09048db28d892568382408fbbc6c6e"
dependencies = [
"async-trait",
"futures-util",
@ -5881,7 +5881,7 @@ dependencies = [
[[package]]
name = "pebble-coding-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=09b17c1b9e6cf7b2f82657d2080f82c73a0064ef#09b17c1b9e6cf7b2f82657d2080f82c73a0064ef"
source = "git+https://github.com/lithoscomputer/pebble?rev=4db661c44b09048db28d892568382408fbbc6c6e#4db661c44b09048db28d892568382408fbbc6c6e"
dependencies = [
"async-trait",
"futures-util",
@ -6246,7 +6246,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@ -6723,7 +6723,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6782,7 +6782,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -7426,7 +7426,7 @@ version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno 0.3.14",
"errno 0.2.8",
"libc",
]
@ -7988,10 +7988,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8026,7 +8026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -9093,7 +9093,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]

View file

@ -108,12 +108,13 @@ futures-util = "0.3"
# same rev, read from this file.
# pebble: the coding agent loop fabro runs its agent stages, Ask Fabro
# sessions, hook evaluators, and `fabro exec` on. Pinned by rev to the pebble
# branch that added the live exec output sink and `max_turns`
# (`fabro-exec-sink-max-turns`); re-pin to 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 = "09b17c1b9e6cf7b2f82657d2080f82c73a0064ef" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "09b17c1b9e6cf7b2f82657d2080f82c73a0064ef" }
# branch `embedder-concerns`, which merges the live exec output sink and
# `max_turns` into pebble's main (so fabro and petri pin one line); re-pin to
# 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 = "4db661c44b09048db28d892568382408fbbc6c6e" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "4db661c44b09048db28d892568382408fbbc6c6e" }
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" }

View file

@ -19,7 +19,7 @@ use fabro_llm::middleware::{Call, Middleware, Next, Output};
use fabro_llm::{Client, ClientOptions, Error as LlmError, ErrorKind};
use fabro_mcp::config::McpServerSettings;
use fabro_mcp::connection_manager::McpConnectionManager;
use fabro_sandbox::{RunSandbox, local_sandbox};
use fabro_sandbox::{RunSandbox, SecretRedactor, local_sandbox};
use fabro_static::EnvVars;
use fabro_types::settings::cli::OutputFormat as SettingsOutputFormat;
use fabro_types::settings::run::ResolvedMcpEntry;
@ -633,6 +633,7 @@ async fn run_session(
.model(format!("{provider_id}/{model}"))
.options(options)
.tool_middleware(Arc::new(permission_middleware))
.redactor(Arc::new(SecretRedactor))
.web_fetch_summarizer(summarizer_model(&catalog, &provider_id, &model))
.subagents(SubagentOptions::enabled());
if let Some(manager) = &mcp {

View file

@ -16,6 +16,7 @@ use fabro_api::types::{
};
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{FabroClient, ModelSelectionError, selection};
use fabro_sandbox::SecretRedactor;
use fabro_sandbox::reconnect::reconnect_for_run;
use fabro_store::{
EventPayload, ProjectedRunSession, RunDatabase, project_run_session, project_run_sessions,
@ -796,6 +797,7 @@ async fn build_agent(
AskFabroToolPolicy,
))))
.system_prompt_transform(Arc::new(AskFabroPrompt))
.redactor(Arc::new(SecretRedactor))
.build()
.await
.map_err(|err| AskFabroBuildError::Agent(anyhow::Error::new(err)))

View file

@ -8,13 +8,13 @@ use fabro_llm::credentials::CredentialProvider;
use fabro_llm::lithos_catalog::Catalog;
use fabro_llm::{Client, ClientOptions, Request};
use fabro_redact::redacted_url_for_log;
use fabro_sandbox::RunSandbox;
use fabro_sandbox::{RunSandbox, SecretRedactor};
use fabro_types::PermissionLevel;
use fabro_types::settings::{InterpString, ResolveCtx, ResolveError};
use pebble_coding_agent::extensions::{
SystemPromptContext, SystemPromptDecision, SystemPromptTransform,
};
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, ShutdownReason};
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, Error as AgentError, ShutdownReason};
use tokio::process::Command as TokioCommand;
use tokio::time::timeout as tokio_timeout;
@ -25,6 +25,9 @@ use crate::types::{
const HOOK_EVALUATOR_SYSTEM_PROMPT: &str = "You are a hook evaluator for a workflow engine. Given context about a workflow event, evaluate the condition.";
/// How many tool rounds an agent hook may run when its definition names none.
const DEFAULT_MAX_TOOL_ROUNDS: u32 = 50;
static HOOK_RESPONSE_SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
serde_json::json!({
"type": "object",
@ -364,8 +367,16 @@ impl HookExecutorImpl {
///
/// The agent runs pebble's full tool set at `PermissionLevel::Full`, with
/// no memory or skills, the evaluator system prompt in place of the
/// profile's, and `max_tool_rounds` as its turn budget. Exhausting the
/// budget, an LLM failure, or a timeout all fail open.
/// profile's, and `max_tool_rounds` as pebble's tool-round budget.
/// Exhausting the budget, an LLM failure, or a timeout all fail open.
///
/// Fabro's `max_tool_rounds` names how many model turns the hook may
/// take, executing the tools each asks for, before it proceeds on a turn
/// that still asks for tools. Pebble's budget of `rounds` lets `rounds`
/// tool turns run and refuses the next one without running its tools, so
/// `max_tool_rounds - 1` reaches the same decision at the same turn and
/// spares the last, useless tool execution. Zero is a loop that never
/// asks the model: the hook proceeds without an agent.
async fn execute_agent(
definition: &HookDefinition,
prompt: &InterpString,
@ -388,6 +399,14 @@ impl HookExecutorImpl {
let resolved_model = Self::resolve_model(model.as_deref());
let user_msg = Self::build_hook_user_message(&prompt, context);
let Some(rounds) = max_tool_rounds
.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS)
.checked_sub(1)
else {
tracing::warn!("agent hook allows no tool rounds, proceeding");
return HookDecision::Proceed;
};
let rounds = usize::try_from(rounds).unwrap_or(usize::MAX);
Self::execute_llm_with_timeout(definition.timeout(), "agent", || async move {
let client = match Self::build_client(catalog, llm_source).await {
@ -398,14 +417,14 @@ impl HookExecutorImpl {
}
};
let max_turns = usize::try_from(max_tool_rounds.unwrap_or(50).max(1)).unwrap_or(50);
let options = CodingAgentOptions::default()
.with_context_compaction(false)
.with_max_turns(max_turns);
.with_max_tool_rounds(rounds);
let mut agent = match CodingAgent::builder(client, sandbox)
.model(resolved_model)
.permission_level(PermissionLevel::Full)
.system_prompt_transform(Arc::new(HookEvaluatorPrompt))
.redactor(Arc::new(SecretRedactor))
.options(options)
.build()
.await
@ -420,6 +439,10 @@ impl HookExecutorImpl {
let report = agent.prompt(user_msg).await;
let decision = match report.result {
Ok(output) => Self::parse_prompt_response(output.text.as_deref().unwrap_or("")),
Err(AgentError::ToolRoundsExhausted { .. }) => {
tracing::warn!("agent hook exhausted max tool rounds, proceeding");
HookDecision::Proceed
}
Err(e) => {
tracing::warn!(error = %e, "agent hook did not complete, proceeding");
HookDecision::Proceed

View file

@ -61,6 +61,7 @@ pub use push_credentials::RefreshErrorKind;
pub use reconnect::{
reconnect, reconnect_driver_for_run, reconnect_for_run, reconnect_for_run_with_events,
};
pub use redact::SecretRedactor;
pub use sandbox::{
CommandOutputCallback, DEFAULT_EXEC_OUTPUT_TAIL_BYTES, ExecResult, ExecStreamingRequest,
ExecStreamingResult, GitRunInfo, GitSetupIntent, OutputCaptureStats, PushAttempt, PushError,

View file

@ -1,6 +1,53 @@
//! Fabro's secret scanner on the text seams pebble and the sandbox expose.
use std::borrow::Cow;
use pebble_coding_agent::extensions::Redactor;
/// Strips a specific authenticated URL out of `text`, when one is known.
pub fn redact_auth_url(text: &str, auth_url: Option<&fabro_redact::DisplaySafeUrl>) -> String {
let Some(auth_url) = auth_url else {
return text.to_string();
};
text.replace(&auth_url.raw_string(), &auth_url.redacted_string())
}
/// Fabro's secret scanner as pebble's [`Redactor`].
///
/// Pebble calls it where text a process or the operating system wrote leaves
/// a session: the output tail a shell tool puts on the event stream and the
/// model-facing message of a failed tool call. It runs the same
/// `fabro_redact::redact_string` pass the run's stored events go through, so
/// what the model reads back matches what the log keeps. The final pass over
/// every stored `RunEvent` stays in place: this one covers the text pebble
/// hands the model and does not replace redaction of the stored event.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SecretRedactor;
impl Redactor for SecretRedactor {
fn redact<'a>(&self, text: &'a str) -> Cow<'a, str> {
let redacted = fabro_redact::redact_string(text);
if redacted == text {
Cow::Borrowed(text)
} else {
Cow::Owned(redacted)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_secret_redactor_borrows_clean_text_and_masks_secrets() {
let redactor = SecretRedactor;
assert!(matches!(
redactor.redact("plain stderr"),
Cow::Borrowed("plain stderr")
));
let redacted = redactor.redact("key=AKIAYRWQG5EJLPZLBYNP");
assert!(matches!(redacted, Cow::Owned(_)));
assert_eq!(redacted, "key=REDACTED");
}
}

View file

@ -3,17 +3,19 @@
//! Pebble loads memory from explicit paths and looks in no conventional
//! location. Fabro supplies the convention: each coding harness reads the
//! instruction files its vendor's own agent reads, found in the sandbox
//! working directory.
//! working directory. The loading itself — the 32 KB budget, duplicate text
//! skipped, the file that crosses the budget cut and marked — is pebble's
//! [`ProjectMemory`], the same loader an agent stage runs over
//! `with_memory_files`, so a prompt stage reads by the agent's rules.
use fabro_sandbox::RunSandbox;
use fabro_types::AgentProfileKind;
use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::{InterruptReason, ProjectMemory};
use tokio_util::sync::CancellationToken;
use crate::error::Error;
/// The most memory text a stage loads into its system prompt.
pub const MEMORY_BUDGET_BYTES: usize = 32_768;
/// The instruction filenames a harness reads, in load order.
#[must_use]
pub fn memory_filenames(profile_kind: AgentProfileKind) -> &'static [&'static str] {
@ -45,55 +47,25 @@ pub fn memory_paths(working_dir: &str, profile_kind: AgentProfileKind) -> Vec<St
}
/// The memory text a prompt stage inlines into its system prompt: every
/// candidate file's contents, deduplicated and cut to the budget.
/// candidate file's contents, loaded by pebble's [`ProjectMemory`] rules.
///
/// # Errors
///
/// Returns [`Error::Cancelled`] when `cancel` fires between reads.
/// Returns [`Error::Cancelled`] when `cancel` fires around a read.
pub async fn load_memory_text(
sandbox: &RunSandbox,
working_dir: &str,
profile_kind: AgentProfileKind,
cancel: &CancellationToken,
) -> Result<Option<String>, Error> {
let mut documents = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut budget = MEMORY_BUDGET_BYTES;
for path in memory_paths(working_dir, profile_kind) {
if cancel.is_cancelled() {
return Err(Error::Cancelled);
}
let Ok(content) = sandbox.read_file_text(&path).await else {
continue;
};
if content.is_empty() || !seen.insert(content.clone()) {
continue;
}
if budget == 0 {
break;
}
if content.len() <= budget {
budget -= content.len();
documents.push(content);
} else {
documents.push(truncate_to_budget(&content, budget));
budget = 0;
}
}
if documents.is_empty() {
Ok(None)
} else {
Ok(Some(documents.join("\n\n")))
}
}
fn truncate_to_budget(content: &str, budget: usize) -> String {
const MARKER: &str = "[Project instructions truncated at 32KB]";
if budget <= MARKER.len() {
return MARKER[..budget].to_string();
}
let keep = content.floor_char_boundary(budget - MARKER.len());
format!("{}{MARKER}", &content[..keep])
let paths = memory_paths(working_dir, profile_kind);
let memory = ProjectMemory::load(sandbox as &dyn Environment, &paths, cancel)
.await
.map_err(|error| match error {
pebble_coding_agent::Error::Interrupted(InterruptReason::Cancelled) => Error::Cancelled,
other => Error::handler_with_source("Failed to load project memory", other),
})?;
Ok((!memory.is_empty()).then(|| memory.text()))
}
#[cfg(test)]

View file

@ -19,7 +19,7 @@ use fabro_llm::types::ResponseFormat;
use fabro_llm::{Client, ClientOptions, ErrorData, Request, Response};
use fabro_mcp::config::McpServerSettings;
use fabro_mcp::connection_manager::McpConnectionManager;
use fabro_sandbox::RunSandbox;
use fabro_sandbox::{RunSandbox, SecretRedactor};
use fabro_types::settings::run::RunModelControls;
use fabro_types::{
AgentProfileKind, ModelRef, PermissionLevel, Principal, SessionCapability, StageId,
@ -68,6 +68,15 @@ use crate::services::FabroRunToolServices;
use crate::steering_hub::{ActiveControlHandle, SteeringHub, SteeringItem};
use crate::web_search::{SearchBackend, SearchSecrets};
/// The share of the model's context window at which an agent stage compacts
/// its conversation. Fabro's own agent loop used this value; pebble's default
/// is the same, and it is set here so the stage's policy is fabro's to state.
pub const COMPACTION_THRESHOLD_PERCENT: usize = 80;
/// How many recent turns compaction leaves verbatim, as fabro's agent loop
/// did.
pub const COMPACTION_PRESERVE_TURNS: usize = 6;
/// The API backend: pebble coding agents over the workflow's LLM client.
pub struct PebbleBackend {
model: String,
@ -131,6 +140,13 @@ fn classify_agent_error(
"Agent session used every model turn it was allowed".to_string(),
))
}
// Stages set no round budget; the arm names the outcome should one
// ever be configured.
pebble_coding_agent::Error::ToolRoundsExhausted { limit } => {
AgentErrorDisposition::Terminal(Error::Precondition(format!(
"Agent session reached its limit of {limit} tool rounds"
)))
}
pebble_coding_agent::Error::EventSink(sink) => AgentErrorDisposition::Terminal(Error::Io(
format!("Failed to persist agent events: {sink:#}"),
)),
@ -619,6 +635,9 @@ impl PebbleBackend {
))
.with_skill_dirs(self.skill_dirs(sandbox))
.with_recorded_permission_level(PermissionLevel::Full)
.with_context_compaction(true)
.with_compaction_threshold_percent(COMPACTION_THRESHOLD_PERCENT)
.with_compaction_preserve_turns(COMPACTION_PRESERVE_TURNS)
}
/// Start the stage's MCP servers, reporting each as a run event.
@ -691,6 +710,7 @@ impl PebbleBackend {
scope: bindings.stage_scope.clone(),
file_tracking: Arc::clone(bindings.file_tracking),
}))
.redactor(Arc::new(SecretRedactor))
.subagents(SubagentOptions::enabled());
if let Some(provider) = &self.tool_env {
builder = builder.tool_env_provider(Arc::clone(provider));