diff --git a/Cargo.lock b/Cargo.lock index e6ee4196b..d12233be0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index a778e9b0f..f9b9dab84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/lib/apps/fabro-cli/src/commands/exec.rs b/lib/apps/fabro-cli/src/commands/exec.rs index cffda78ca..a63319c4a 100644 --- a/lib/apps/fabro-cli/src/commands/exec.rs +++ b/lib/apps/fabro-cli/src/commands/exec.rs @@ -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 = Arc::clone(&sandbox) as Arc; let mut builder = CodingAgent::builder(client, environment) diff --git a/lib/apps/fabro-server/src/server/handler/sessions.rs b/lib/apps/fabro-server/src/server/handler/sessions.rs index ba85d0e42..20f19abbd 100644 --- a/lib/apps/fabro-server/src/server/handler/sessions.rs +++ b/lib/apps/fabro-server/src/server/handler/sessions.rs @@ -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, diff --git a/lib/components/fabro-workflow/src/agent_memory.rs b/lib/components/fabro-workflow/src/agent_memory.rs index 2fa9cb4e5..6d54c0692 100644 --- a/lib/components/fabro-workflow/src/agent_memory.rs +++ b/lib/components/fabro-workflow/src/agent_memory.rs @@ -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 { - 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, 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" + ); } } diff --git a/lib/components/fabro-workflow/src/handler/llm/pebble.rs b/lib/components/fabro-workflow/src/handler/llm/pebble.rs index 7c9f7366f..e089bb42d 100644 --- a/lib/components/fabro-workflow/src/handler/llm/pebble.rs +++ b/lib/components/fabro-workflow/src/handler/llm/pebble.rs @@ -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 { - 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 { diff --git a/lib/components/fabro-workflow/src/handler/prompt.rs b/lib/components/fabro-workflow/src/handler/prompt.rs index 3174c76d0..85dd74c45 100644 --- a/lib/components/fabro-workflow/src/handler/prompt.rs +++ b/lib/components/fabro-workflow/src/handler/prompt.rs @@ -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(), )