From 66e313c4d2f854a057dda6c0335a105f30f67a5a Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Mon, 6 Apr 2026 20:37:41 +0530 Subject: [PATCH] feat(agent): add cwd override support for sub-agent execution --- crates/forge_app/src/agent_executor.rs | 10 +++++++++- crates/forge_app/src/app.rs | 13 +++++++++++-- crates/forge_app/src/command_generator.rs | 4 ++++ crates/forge_app/src/hooks/doom_loop.rs | 1 + crates/forge_app/src/services.rs | 8 ++++++++ crates/forge_app/src/tool_registry.rs | 4 +++- crates/forge_domain/src/conversation.rs | 6 ++++++ crates/forge_domain/src/tools/catalog.rs | 6 ++++++ crates/forge_main/src/conversation_selector.rs | 1 + crates/forge_main/src/info.rs | 3 +++ crates/forge_services/src/discovery.rs | 6 +++++- 11 files changed, 57 insertions(+), 5 deletions(-) diff --git a/crates/forge_app/src/agent_executor.rs b/crates/forge_app/src/agent_executor.rs index 77325db6ef..edc88c4bb2 100644 --- a/crates/forge_app/src/agent_executor.rs +++ b/crates/forge_app/src/agent_executor.rs @@ -41,6 +41,7 @@ impl AgentExecutor { &self, agent_id: AgentId, task: String, + cwd_override: Option, ctx: &ToolCallContext, ) -> anyhow::Result { ctx.send_tool_input( @@ -55,9 +56,16 @@ impl AgentExecutor { // Create a new conversation for agent execution // Create context with agent initiator since it's spawned by a parent agent let context = forge_domain::Context::default().initiator("agent".to_string()); - let conversation = Conversation::generate() + let mut conversation = Conversation::generate() .title(task.clone()) .context(context.clone()); + + // Set CWD override on the conversation so ForgeApp::chat uses it + // for environment, file listing, and extensions + if let Some(cwd) = cwd_override { + conversation = conversation.cwd(cwd); + } + self.services .conversation_service() .upsert_conversation(conversation.clone()) diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index 169a002f03..a69a31d895 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -70,9 +70,18 @@ impl ForgeApp { // Discover files using the discovery service let forge_config = services.get_config(); - let environment = services.get_environment(); + let cwd_override = conversation.cwd.clone(); + let mut environment = services.get_environment(); - let files = services.list_current_directory().await?; + // Apply CWD override from conversation (set by sub-agent invocations) + if let Some(ref cwd) = cwd_override { + environment.cwd = cwd.clone(); + } + + let files = match cwd_override { + Some(ref cwd) => services.list_directory_at(cwd.as_path()).await?, + None => services.list_current_directory().await?, + }; let custom_instructions = services.get_custom_instructions().await; diff --git a/crates/forge_app/src/command_generator.rs b/crates/forge_app/src/command_generator.rs index 837db4d7c0..6cf65dde52 100644 --- a/crates/forge_app/src/command_generator.rs +++ b/crates/forge_app/src/command_generator.rs @@ -193,6 +193,10 @@ mod tests { Ok(files) } + + async fn list_directory_at(&self, _path: &std::path::Path) -> Result> { + self.list_current_directory().await + } } #[async_trait::async_trait] diff --git a/crates/forge_app/src/hooks/doom_loop.rs b/crates/forge_app/src/hooks/doom_loop.rs index 3515b74e7b..bcd4786ca2 100644 --- a/crates/forge_app/src/hooks/doom_loop.rs +++ b/crates/forge_app/src/hooks/doom_loop.rs @@ -286,6 +286,7 @@ mod tests { context: Some(context), metrics: Default::default(), metadata: forge_domain::MetaData::new(chrono::Utc::now()), + cwd: None, } } diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 9cf7a12c89..9b4514bdb9 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -349,6 +349,10 @@ pub trait FileDiscoveryService: Send + Sync { /// Lists all entries (files and directories) in the current directory /// Returns a sorted vector of File entries with directories first async fn list_current_directory(&self) -> anyhow::Result>; + + /// Lists all entries (files and directories) at the specified path + /// Returns a sorted vector of File entries with directories first + async fn list_directory_at(&self, path: &std::path::Path) -> anyhow::Result>; } #[async_trait::async_trait] @@ -747,6 +751,10 @@ impl FileDiscoveryService for I { async fn list_current_directory(&self) -> anyhow::Result> { self.file_discovery_service().list_current_directory().await } + + async fn list_directory_at(&self, path: &std::path::Path) -> anyhow::Result> { + self.file_discovery_service().list_directory_at(path).await + } } #[async_trait::async_trait] diff --git a/crates/forge_app/src/tool_registry.rs b/crates/forge_app/src/tool_registry.rs index 9c84fa6254..8f1c303a3e 100644 --- a/crates/forge_app/src/tool_registry.rs +++ b/crates/forge_app/src/tool_registry.rs @@ -140,11 +140,13 @@ impl ToolRegistry { } else if self.agent_executor.contains_tool(&input.name).await? { // Handle agent delegation tool calls let agent_input = AgentInput::try_from(&input)?; + let cwd_override = agent_input.cwd.map(std::path::PathBuf::from); let executor = self.agent_executor.clone(); // NOTE: Agents should not timeout let outputs = join_all(agent_input.tasks.into_iter().map(|task| { - executor.execute(AgentId::new(input.name.as_str()), task, context) + let cwd = cwd_override.clone(); + executor.execute(AgentId::new(input.name.as_str()), task, cwd, context) })) .await .into_iter() diff --git a/crates/forge_domain/src/conversation.rs b/crates/forge_domain/src/conversation.rs index 94f0300f15..e308cee88c 100644 --- a/crates/forge_domain/src/conversation.rs +++ b/crates/forge_domain/src/conversation.rs @@ -46,6 +46,11 @@ pub struct Conversation { pub context: Option, pub metrics: Metrics, pub metadata: MetaData, + /// Optional working directory override for sub-agent conversations. + /// When set, the agent uses this path as its CWD instead of the parent + /// process's CWD for system information (file listing, extensions, env.cwd). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, } #[derive(Debug, Setters, Serialize, Deserialize, Clone)] @@ -71,6 +76,7 @@ impl Conversation { metadata: MetaData::new(created_at), title: None, context: None, + cwd: None, } } /// Creates a new conversation with a new conversation ID. diff --git a/crates/forge_domain/src/tools/catalog.rs b/crates/forge_domain/src/tools/catalog.rs index d17aefa00b..96e71fd9a0 100644 --- a/crates/forge_domain/src/tools/catalog.rs +++ b/crates/forge_domain/src/tools/catalog.rs @@ -67,6 +67,12 @@ pub struct AgentInput { /// requirements to enable the agent to understand and execute the work /// accurately. pub tasks: Vec, + /// Optional working directory override for the agent. When provided, the + /// agent will treat this path as its current working directory instead of + /// the parent process's CWD. This affects the system information shown to + /// the agent (file listing, extensions, env.cwd). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, } fn default_true() -> bool { diff --git a/crates/forge_main/src/conversation_selector.rs b/crates/forge_main/src/conversation_selector.rs index d0b3374142..3d87b9496e 100644 --- a/crates/forge_main/src/conversation_selector.rs +++ b/crates/forge_main/src/conversation_selector.rs @@ -138,6 +138,7 @@ mod tests { context: None, metrics: Metrics::default().started_at(now), metadata: MetaData { created_at: now, updated_at: Some(now) }, + cwd: None, } } diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index b0815a8799..860b8ff37c 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -979,6 +979,7 @@ mod tests { context: None, metrics, metadata: forge_domain::MetaData::new(Utc::now()), + cwd: None, }; let actual = super::Info::from(&fixture); @@ -1006,6 +1007,7 @@ mod tests { context: None, metrics, metadata: forge_domain::MetaData::new(Utc::now()), + cwd: None, }; let actual = super::Info::from(&fixture); @@ -1051,6 +1053,7 @@ mod tests { context: Some(context), metrics, metadata: forge_domain::MetaData::new(Utc::now()), + cwd: None, }; let actual = super::Info::from(&fixture); diff --git a/crates/forge_services/src/discovery.rs b/crates/forge_services/src/discovery.rs index 2871b57455..7d4c2e24cc 100644 --- a/crates/forge_services/src/discovery.rs +++ b/crates/forge_services/src/discovery.rs @@ -36,7 +36,11 @@ impl Fil async fn list_current_directory(&self) -> Result> { let env = self.service.get_environment(); - let entries = self.service.list_directory_entries(&env.cwd).await?; + self.list_directory_at(&env.cwd).await + } + + async fn list_directory_at(&self, path: &std::path::Path) -> Result> { + let entries = self.service.list_directory_entries(path).await?; let mut files: Vec = entries .into_iter()