Let pebble discover memory and skills, and name the reuse rules

Steps 5 and 8 of .ai/plans/pebble-absorbs-embedder-concerns.md, pinning
pebble 49da137.

Agent stages and `fabro exec` ask pebble for the profile's instruction
files from the repository root down to the working directory
(`MemoryDiscovery::from_git_root`), which fabro lacked: it read the
working directory alone. Skill directories are pebble's to resolve too:
the user's skills directory, then `.fabro/skills` and `skills` under the
repository root. Prompt stages keep reading the working directory alone,
through the same discovery and loader, so `agent_memory.rs` keeps only
that call; the filename table is pebble's now.

A retained thread's export comes from `export_for_reuse`, which closes
the session and hands back an export whose cursor is already past the
close, in place of export, shutdown, and a cursor advance by hand. Ask
Fabro resumes a stored record with `resume_after`, the rule it applied
under the older name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-11 19:40:36 -06:00
parent 64c578d044
commit d4e483925c
No known key found for this signature in database
7 changed files with 101 additions and 145 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]]
@ -4345,7 +4345,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.62.2",
"windows-core 0.61.2",
]
[[package]]
@ -5291,7 +5291,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]]
@ -5856,7 +5856,7 @@ dependencies = [
[[package]]
name = "pebble-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=6cdb30a874010582106bbde80ac91ae000dc13d8#6cdb30a874010582106bbde80ac91ae000dc13d8"
source = "git+https://github.com/lithoscomputer/pebble?rev=49da13798c753eb312b765596840bdc200b819d7#49da13798c753eb312b765596840bdc200b819d7"
dependencies = [
"async-trait",
"futures-util",
@ -5873,7 +5873,7 @@ dependencies = [
[[package]]
name = "pebble-coding-agent"
version = "0.1.0"
source = "git+https://github.com/lithoscomputer/pebble?rev=6cdb30a874010582106bbde80ac91ae000dc13d8#6cdb30a874010582106bbde80ac91ae000dc13d8"
source = "git+https://github.com/lithoscomputer/pebble?rev=49da13798c753eb312b765596840bdc200b819d7#49da13798c753eb312b765596840bdc200b819d7"
dependencies = [
"async-trait",
"futures-util",
@ -6243,7 +6243,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@ -6720,7 +6720,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6779,7 +6779,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -7423,7 +7423,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,7 +7988,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8023,7 +8023,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]]
@ -9090,7 +9090,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

@ -112,8 +112,8 @@ 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 = "6cdb30a874010582106bbde80ac91ae000dc13d8" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6cdb30a874010582106bbde80ac91ae000dc13d8", features = ["mcp"] }
pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "49da13798c753eb312b765596840bdc200b819d7" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "49da13798c753eb312b765596840bdc200b819d7", features = ["mcp"] }
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

@ -11,7 +11,6 @@ use std::sync::{Arc, Mutex};
use anyhow::{Context as _, Result as AnyResult};
use async_trait::async_trait;
use fabro_llm::catalog::agent_profile;
use fabro_llm::credentials::CredentialProvider;
use fabro_llm::gateway::{GatewayAdapter, GatewayError, GatewayTransport};
use fabro_llm::lithos_catalog::{Catalog, CatalogProvider};
@ -21,13 +20,12 @@ 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_types::{AgentProfileKind, PermissionLevel};
use fabro_util::exit::{self, ErrorExt, ExitClass};
use fabro_util::home::Home;
use fabro_util::terminal::Styles;
use fabro_workflow::agent_memory;
use fabro_workflow::web_search::{SearchBackend, SearchSecrets};
use lithos_llm::catalog::ProviderId;
use pebble_agent::{ToolCallRequest, ToolSystemError};
@ -38,7 +36,9 @@ use pebble_coding_agent::subagents::SubagentOptions;
use pebble_coding_agent::tools::{
ApprovalDecision, PermissionLevelPolicy, PermissionMiddleware, ToolApprovalService,
};
use pebble_coding_agent::{CodingAgent, CodingAgentOptions, ShutdownReason};
use pebble_coding_agent::{
CodingAgent, CodingAgentOptions, MemoryDiscovery, ShutdownReason, SkillDiscovery,
};
use tokio::io::{AsyncWriteExt, stdout};
use tokio::signal;
use tokio::task::spawn_blocking;
@ -609,23 +609,20 @@ async fn run_session(
PermissionMiddleware::new(Arc::new(PermissionLevelPolicy::new(permissions)))
.with_approval(approval);
// The profile's own instruction files from the repository root down, and
// fabro's skill directories: pebble knows the files and does the walk.
let mut options = CodingAgentOptions::default()
.with_memory_files(agent_memory::memory_paths(
sandbox.working_directory(),
agent_profile(&catalog, provider_id.as_str(), Some(&model))
.unwrap_or(AgentProfileKind::OpenAi),
))
.with_memory_discovery(MemoryDiscovery::from_git_root())
.with_recorded_permission_level(permissions);
if let Some(skills_dir) = &args.skills_dir {
options = options.with_skill_dirs([skills_dir.clone()]);
} else {
let root = sandbox.working_directory().trim_end_matches('/');
options = options.with_skill_dirs([
Home::from_env().skills_dir().to_string_lossy().into_owned(),
format!("{root}/.fabro/skills"),
format!("{root}/skills"),
]);
}
options = match &args.skills_dir {
Some(skills_dir) => options.with_skill_dirs([skills_dir.clone()]),
None => options.with_skill_discovery(
SkillDiscovery::new()
.search(Home::from_env().skills_dir().to_string_lossy().into_owned())
.search_under_git_root(".fabro/skills")
.search_under_git_root("skills"),
),
};
let environment: Arc<dyn Environment> = Arc::clone(&sandbox) as Arc<dyn Environment>;
let mut builder = CodingAgent::builder(client, environment)

View file

@ -771,7 +771,7 @@ async fn build_agent(
Some(stored) => {
let mut record = stored.record;
if let Ok(Some(last_seq)) = run_store.last_event_seq().await {
record.advance_event_cursor(u64::from(last_seq));
record.resume_after(u64::from(last_seq));
}
CodingAgent::resume(
llm_result.client,

View file

@ -1,92 +1,53 @@
//! Project memory files for agent and prompt stages.
//! Project memory for prompt stages.
//!
//! 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. 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.
//! Agent stages ask pebble to discover the profile's instruction files from
//! the repository root down (`MemoryDiscovery::from_git_root`). A prompt
//! stage reads the working directory alone, as it always has, through the
//! same discovery and the same loader, so the two agree on which files a
//! harness reads and how much of them fits.
use fabro_sandbox::RunSandbox;
use fabro_types::AgentProfileKind;
use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::{InterruptReason, ProjectMemory};
use pebble_coding_agent::{InterruptReason, MemoryDiscovery, ProjectMemory};
use tokio_util::sync::CancellationToken;
use crate::error::Error;
/// The instruction filenames a harness reads, in load order.
#[must_use]
pub fn memory_filenames(profile_kind: AgentProfileKind) -> &'static [&'static str] {
// `AgentProfileKind` is non-exhaustive: a profile pebble adds later reads
// the shared AGENTS.md until fabro says otherwise.
match profile_kind {
AgentProfileKind::Anthropic | AgentProfileKind::Claude5 => &["AGENTS.md", "CLAUDE.md"],
AgentProfileKind::OpenAi | AgentProfileKind::Gpt56 | AgentProfileKind::Gpt6 => {
&["AGENTS.md", ".codex/instructions.md"]
}
AgentProfileKind::Gemini => &["AGENTS.md", "GEMINI.md"],
// Kimi Code reads only AGENTS.md; it has no vendor-specific
// instruction filename of its own.
AgentProfileKind::Kimi | _ => &["AGENTS.md"],
}
}
/// The candidate memory paths for a harness working in `working_dir`.
///
/// Missing and empty files are skipped by the loader, so every candidate can
/// be named without checking the sandbox first.
#[must_use]
pub fn memory_paths(working_dir: &str, profile_kind: AgentProfileKind) -> Vec<String> {
let root = working_dir.trim_end_matches('/');
memory_filenames(profile_kind)
.iter()
.map(|filename| format!("{root}/{filename}"))
.collect()
}
/// The memory text a prompt stage inlines into its system prompt: every
/// candidate file's contents, loaded by pebble's [`ProjectMemory`] rules.
/// The memory text a prompt stage inlines into its system prompt: the
/// profile's instruction files in the sandbox working directory, loaded by
/// pebble's [`ProjectMemory`] rules.
///
/// # Errors
///
/// 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 paths = memory_paths(working_dir, profile_kind);
let memory = ProjectMemory::load(sandbox as &dyn Environment, &paths, cancel)
let environment: &dyn Environment = sandbox;
let paths = MemoryDiscovery::working_directory()
.resolve(environment, profile_kind, 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),
})?;
.map_err(cancelled_or_handler)?;
let memory = ProjectMemory::load(environment, &paths, cancel)
.await
.map_err(cancelled_or_handler)?;
Ok((!memory.is_empty()).then(|| memory.text()))
}
fn cancelled_or_handler(error: pebble_coding_agent::Error) -> Error {
match error {
pebble_coding_agent::Error::Interrupted(InterruptReason::Cancelled) => Error::Cancelled,
other => Error::handler_with_source("Failed to load project memory", other),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn candidate_paths_follow_the_profile() {
assert_eq!(memory_paths("/work/", AgentProfileKind::Claude5), vec![
"/work/AGENTS.md".to_string(),
"/work/CLAUDE.md".to_string()
]);
assert_eq!(memory_paths("/work", AgentProfileKind::Gpt56), vec![
"/work/AGENTS.md".to_string(),
"/work/.codex/instructions.md".to_string()
]);
assert_eq!(memory_paths("/work", AgentProfileKind::Kimi), vec![
"/work/AGENTS.md".to_string()
]);
}
#[tokio::test]
async fn memory_text_dedupes_and_skips_missing_files() {
let dir = tempfile::tempdir().unwrap();
@ -102,7 +63,6 @@ mod tests {
let text = load_memory_text(
&sandbox,
sandbox.working_directory(),
AgentProfileKind::Anthropic,
&CancellationToken::new(),
)
@ -110,14 +70,17 @@ mod tests {
.unwrap();
assert_eq!(text.as_deref(), Some("shared"));
let none = load_memory_text(
let gemini = load_memory_text(
&sandbox,
sandbox.working_directory(),
AgentProfileKind::Gemini,
&CancellationToken::new(),
)
.await
.unwrap();
assert_eq!(none.as_deref(), Some("shared"));
assert_eq!(
gemini.as_deref(),
Some("shared"),
"AGENTS.md is every harness's"
);
}
}

View file

@ -41,8 +41,8 @@ use pebble_coding_agent::subagents::SubagentOptions;
use pebble_coding_agent::tools::{RegisteredTool, ToolEnvProvider};
use pebble_coding_agent::{
CodingAgent, CodingAgentBuilder, CodingAgentControlHandle, CodingAgentExport,
CodingAgentOptions, CodingInput, InterruptReason, ShutdownReason, SteeringLease,
SteeringMessage, SteeringOutcome,
CodingAgentOptions, CodingInput, InterruptReason, MemoryDiscovery, ShutdownReason,
SkillDiscovery, SteeringLease, SteeringMessage, SteeringOutcome,
};
use tokio_util::sync::CancellationToken;
@ -58,7 +58,6 @@ use super::controls::{
use super::fabro_tools::register_fabro_run_tools;
use super::fallback::{self, FallbackPlan, LlmRoute};
use super::routing::{self, ProviderContext};
use crate::agent_memory;
use crate::context::WorkflowContext;
use crate::context::keys::Fidelity;
use crate::error::Error;
@ -591,33 +590,31 @@ impl PebbleBackend {
build_llm_client(&self.catalog, Arc::clone(&self.source)).await
}
fn skill_dirs(&self, sandbox: &RunSandbox) -> Vec<String> {
if let Some(dirs) = &self.skill_dirs {
return dirs.clone();
/// Where a stage's skills come from: the directories the backend was
/// given, else fabro's convention — the user's skills directory, then
/// `.fabro/skills` and `skills` under the repository root — which pebble
/// resolves and searches.
fn skill_options(&self, options: CodingAgentOptions) -> CodingAgentOptions {
match &self.skill_dirs {
Some(dirs) => options.with_skill_dirs(dirs.clone()),
None => options.with_skill_discovery(
SkillDiscovery::new()
.search(Home::from_env().skills_dir().to_string_lossy().into_owned())
.search_under_git_root(".fabro/skills")
.search_under_git_root("skills"),
),
}
let root = sandbox.working_directory().trim_end_matches('/');
let mut dirs = vec![Home::from_env().skills_dir().to_string_lossy().into_owned()];
dirs.push(format!("{root}/.fabro/skills"));
dirs.push(format!("{root}/skills"));
dirs
}
fn agent_options(
&self,
node: &Node,
profile_kind: AgentProfileKind,
controls: EffectiveRequestControls,
sandbox: &RunSandbox,
) -> CodingAgentOptions {
CodingAgentOptions::default()
fn agent_options(&self, node: &Node, controls: EffectiveRequestControls) -> CodingAgentOptions {
// The profile's own instruction files, from the repository root down
// to the working directory: pebble knows the files and does the walk.
let options = CodingAgentOptions::default()
.with_reasoning_effort(controls.reasoning_effort)
.with_speed(controls.speed)
.with_max_tokens(node_max_output_tokens(node).map(i64::from))
.with_memory_files(agent_memory::memory_paths(
sandbox.working_directory(),
profile_kind,
))
.with_skill_dirs(self.skill_dirs(sandbox))
.with_memory_discovery(MemoryDiscovery::from_git_root());
self.skill_options(options)
.with_recorded_permission_level(PermissionLevel::Full)
.with_context_compaction(true)
.with_compaction_threshold_percent(COMPACTION_THRESHOLD_PERCENT)
@ -649,12 +646,7 @@ impl PebbleBackend {
.tools(self.stage_tools())
.mcp_servers(pebble_servers(&self.mcp_servers))
.permission_level(PermissionLevel::Full)
.options(self.agent_options(
node,
provider.profile_kind,
route.controls,
bindings.sandbox,
))
.options(self.agent_options(node, route.controls))
.fallback_routes(plan.pebble_routes(max_tokens))
.event_sink(Arc::new(WorkflowEventSink {
emitter: Arc::clone(bindings.emitter),
@ -1244,17 +1236,23 @@ impl CodergenBackend for PebbleBackend {
.with_reported_cost(live.total_cost);
live.release_lease();
let mut export = reuse_key.as_ref().map(|_| live.agent.export());
if let Err(error) = live.agent.shutdown(ShutdownReason::Completed).await {
tracing::debug!(error = %error, "agent session did not shut down cleanly");
}
if let (Some(key), Some(export)) = (reuse_key, export.as_mut()) {
// The close is in the log now; the successor numbers past it.
export.advance_event_cursor(live.agent.committed_event_seq());
self.store_thread(key, CachedThread {
export: export.clone(),
fallback_plan: fallback_plan.clone(),
});
match reuse_key {
// The thread's successor continues from an export whose cursor is
// already past this session's close.
Some(key) => match live.agent.export_for_reuse(ShutdownReason::Completed).await {
Ok(export) => self.store_thread(key, CachedThread {
export,
fallback_plan: fallback_plan.clone(),
}),
Err(error) => {
tracing::debug!(error = %error, "agent session did not shut down cleanly");
}
},
None => {
if let Err(error) = live.agent.shutdown(ShutdownReason::Completed).await {
tracing::debug!(error = %error, "agent session did not shut down cleanly");
}
}
}
Ok(CodergenResult::Text {

View file

@ -67,7 +67,6 @@ impl Handler for PromptHandler {
// 1b. Discover project docs for system prompt when project_memory is enabled
let system_prompt = if node.project_memory() {
let working_dir = services.run.sandbox.working_directory();
let profile_kind = routing::resolve_node_provider_context(
services.run.catalog.as_ref(),
&services.run.provider_id,
@ -77,7 +76,6 @@ impl Handler for PromptHandler {
.profile_kind;
agent_memory::load_memory_text(
&services.run.sandbox,
working_dir,
profile_kind,
&services.run.cancel_token(),
)