@@ -10,10 +10,7 @@ use std::collections::{
1010 HashSet ,
1111} ;
1212use std:: ffi:: OsStr ;
13- use std:: io:: {
14- self ,
15- Write ,
16- } ;
13+ use std:: io:: Write ;
1714use std:: path:: {
1815 Path ,
1916 PathBuf ,
@@ -37,7 +34,6 @@ use serde::{
3734 Serialize ,
3835} ;
3936use thiserror:: Error ;
40- use tokio:: fs:: ReadDir ;
4137use tracing:: {
4238 error,
4339 info,
@@ -212,6 +208,38 @@ impl Default for Agent {
212208}
213209
214210impl Agent {
211+ /// Calculate the path-based identifier for this agent
212+ /// Returns the relative path from agent directory to file (without .json extension)
213+ /// Example: "team/assistant" for file at agents/team/assistant.json
214+ pub fn path_identifier ( & self , os : & Os ) -> Option < String > {
215+ let full_path = self . path . as_ref ( ) ?;
216+
217+ // Extract just the filename without extension for fallback
218+ let file_stem = full_path. file_stem ( ) ?. to_str ( ) ?;
219+
220+ // Try to get the actual directory paths using the proper functions
221+ // Check local workspace directory first
222+ if let Ok ( local_dir) = directories:: chat_local_agent_dir ( os) {
223+ if let Ok ( rel_path) = full_path. strip_prefix ( & local_dir) {
224+ if let Some ( path_str) = rel_path. with_extension ( "" ) . to_str ( ) {
225+ return Some ( path_str. to_string ( ) ) ;
226+ }
227+ }
228+ }
229+
230+ // Check global directory
231+ if let Ok ( global_dir) = directories:: chat_global_agent_path ( os) {
232+ if let Ok ( rel_path) = full_path. strip_prefix ( & global_dir) {
233+ if let Some ( path_str) = rel_path. with_extension ( "" ) . to_str ( ) {
234+ return Some ( path_str. to_string ( ) ) ;
235+ }
236+ }
237+ }
238+
239+ // Fallback to just filename
240+ Some ( file_stem. to_string ( ) )
241+ }
242+
215243 /// This function mutates the agent to a state that is writable.
216244 /// Practically this means reverting some fields back to their original values as they were
217245 /// written in the config.
@@ -339,39 +367,23 @@ impl Agent {
339367 }
340368 }
341369
342- /// Retrieves an agent by name. It does so via first seeking the given agent under local dir,
343- /// and falling back to global dir if it does not exist in local.
370+ /// Retrieves an agent by name or path identifier. It does so via first seeking the given agent
371+ /// under local dir, and falling back to global dir if it does not exist in local.
372+ /// Supports both JSON name field lookup and path-based lookup (e.g., "team/assistant").
373+ /// Load all agents first and filter by both JSON name and path identifier
344374 pub async fn get_agent_by_name ( os : & Os , agent_name : & str ) -> eyre:: Result < ( Agent , PathBuf ) > {
345- let config_path: Result < PathBuf , PathBuf > = ' config: {
346- // local first, and then fall back to looking at global
347- let local_config_dir = directories:: chat_local_agent_dir ( os) ?. join ( format ! ( "{agent_name}.json" ) ) ;
348- if os. fs . exists ( & local_config_dir) {
349- break ' config Ok ( local_config_dir) ;
350- }
375+ let mut stderr = std:: io:: stderr ( ) ;
376+ let ( agents, _) = Agents :: load ( & mut os. clone ( ) , None , true , & mut stderr, true ) . await ;
351377
352- let global_config_dir = directories:: chat_global_agent_path ( os) ?. join ( format ! ( "{agent_name}.json" ) ) ;
353- if os. fs . exists ( & global_config_dir) {
354- break ' config Ok ( global_config_dir) ;
378+ for ( _, agent) in agents. agents {
379+ if agent. name == agent_name || agent. path_identifier ( os) . as_deref ( ) == Some ( agent_name) {
380+ if let Some ( path) = agent. path . clone ( ) {
381+ return Ok ( ( agent, path) ) ;
382+ }
355383 }
356-
357- Err ( global_config_dir)
358- } ;
359-
360- match config_path {
361- Ok ( config_path) => {
362- let content = os. fs . read ( & config_path) . await ?;
363- let mut agent = serde_json:: from_slice :: < Agent > ( & content) ?;
364- let legacy_mcp_config = if agent. use_legacy_mcp_json {
365- load_legacy_mcp_config ( os) . await . unwrap_or ( None )
366- } else {
367- None
368- } ;
369- let mut stderr = std:: io:: stderr ( ) ;
370- agent. thaw ( & config_path, legacy_mcp_config. as_ref ( ) , & mut stderr) ?;
371- Ok ( ( agent, config_path) )
372- } ,
373- _ => bail ! ( "Agent {agent_name} does not exist" ) ,
374384 }
385+
386+ bail ! ( "Agent {agent_name} does not exist" )
375387 }
376388
377389 pub async fn load (
@@ -476,14 +488,22 @@ impl Agents {
476488 self . agents . get_mut ( & self . active_idx )
477489 }
478490
479- pub fn switch ( & mut self , name : & str ) -> eyre:: Result < & Agent > {
480- if !self . agents . contains_key ( name) {
491+ pub fn switch ( & mut self , name : & str , os : & Os ) -> eyre:: Result < & Agent > {
492+ // Find agent by either JSON name or path identifier
493+ let matching_key = self
494+ . agents
495+ . iter ( )
496+ . find ( |( _, agent) | agent. name . as_str ( ) == name || agent. path_identifier ( os) . as_deref ( ) == Some ( name) )
497+ . map ( |( key, _) | key. clone ( ) ) ;
498+
499+ if let Some ( key) = matching_key {
500+ self . active_idx = key;
501+ self . agents
502+ . get ( & self . active_idx )
503+ . ok_or ( eyre:: eyre!( "No agent with name {name} found" ) )
504+ } else {
481505 eyre:: bail!( "No agent with name {name} found" ) ;
482506 }
483- self . active_idx = name. to_string ( ) ;
484- self . agents
485- . get ( name)
486- . ok_or ( eyre:: eyre!( "No agent with name {name} found" ) )
487507 }
488508
489509 /// This function does a number of things in the following order:
@@ -558,12 +578,9 @@ impl Agents {
558578 let Ok ( path) = directories:: chat_local_agent_dir ( os) else {
559579 break ' local Vec :: < Agent > :: new ( ) ;
560580 } ;
561- let Ok ( files) = os. fs . read_dir ( path) . await else {
562- break ' local Vec :: < Agent > :: new ( ) ;
563- } ;
564581
565582 let mut agents = Vec :: < Agent > :: new ( ) ;
566- let results = load_agents_from_entries ( files , os, & mut global_mcp_config, mcp_enabled, output) . await ;
583+ let results = load_agents_from_directory ( & path , os, & mut global_mcp_config, mcp_enabled, output) . await ;
567584 for result in results {
568585 match result {
569586 Ok ( agent) => agents. push ( agent) ,
@@ -588,20 +605,9 @@ impl Agents {
588605 let Ok ( path) = directories:: chat_global_agent_path ( os) else {
589606 break ' global Vec :: < Agent > :: new ( ) ;
590607 } ;
591- let files = match os. fs . read_dir ( & path) . await {
592- Ok ( files) => files,
593- Err ( e) => {
594- if matches ! ( e. kind( ) , io:: ErrorKind :: NotFound ) {
595- if let Err ( e) = os. fs . create_dir_all ( & path) . await {
596- error ! ( "Error creating global agent dir: {:?}" , e) ;
597- }
598- }
599- break ' global Vec :: < Agent > :: new ( ) ;
600- } ,
601- } ;
602608
603609 let mut agents = Vec :: < Agent > :: new ( ) ;
604- let results = load_agents_from_entries ( files , os, & mut global_mcp_config, mcp_enabled, output) . await ;
610+ let results = load_agents_from_directory ( & path , os, & mut global_mcp_config, mcp_enabled, output) . await ;
605611 for result in results {
606612 match result {
607613 Ok ( agent) => agents. push ( agent) ,
@@ -704,8 +710,14 @@ impl Agents {
704710 // 3. If the above is missing or invalid, assume the in-memory default
705711 let active_idx = ' active_idx: {
706712 if let Some ( name) = agent_name {
707- if all_agents. iter ( ) . any ( |a| a. name . as_str ( ) == name) {
708- break ' active_idx name. to_string ( ) ;
713+ // Dual lookup: try both JSON name field and path identifier
714+ if let Some ( matching_agent) = all_agents. iter ( ) . find ( |agent| {
715+ // Current behavior: match against JSON name field
716+ agent. name . as_str ( ) == name ||
717+ // New behavior: match against file path identifier
718+ agent. path_identifier ( os) . as_deref ( ) == Some ( name)
719+ } ) {
720+ break ' active_idx matching_agent. name . clone ( ) ;
709721 }
710722 let _ = queue ! (
711723 output,
@@ -880,24 +892,41 @@ pub struct AgentsLoadMetadata {
880892 pub launched_agent : String ,
881893}
882894
883- async fn load_agents_from_entries (
884- mut files : ReadDir ,
895+ async fn load_agents_from_directory (
896+ dir_path : & Path ,
885897 os : & Os ,
886898 global_mcp_config : & mut Option < McpServerConfig > ,
887899 mcp_enabled : bool ,
888900 output : & mut impl Write ,
889901) -> Vec < Result < Agent , AgentConfigError > > {
890902 let mut res = Vec :: < Result < Agent , AgentConfigError > > :: new ( ) ;
891903
892- while let Ok ( Some ( file) ) = files. next_entry ( ) . await {
893- let file_path = & file. path ( ) ;
894- if file_path
895- . extension ( )
896- . and_then ( OsStr :: to_str)
897- . is_some_and ( |s| s == "json" )
898- {
899- res. push ( Agent :: load ( os, file_path, global_mcp_config, mcp_enabled, output) . await ) ;
900- }
904+ // Check if directory exists before trying to walk it
905+ if !os. fs . exists ( dir_path) {
906+ // Directory doesn't exist - return empty list (this is normal)
907+ return res;
908+ }
909+
910+ // Collect file paths in a blocking task to avoid blocking the async runtime
911+ let dir_path = dir_path. to_path_buf ( ) ;
912+ let file_paths = tokio:: task:: spawn_blocking ( move || {
913+ walkdir:: WalkDir :: new ( & dir_path)
914+ . follow_links ( false )
915+ . into_iter ( )
916+ . filter_map ( |e| e. ok ( ) )
917+ . filter ( |entry| {
918+ let path = entry. path ( ) ;
919+ path. is_file ( ) && path. extension ( ) . and_then ( OsStr :: to_str) . is_some_and ( |s| s == "json" )
920+ } )
921+ . map ( |entry| entry. path ( ) . to_path_buf ( ) )
922+ . collect :: < Vec < _ > > ( )
923+ } )
924+ . await
925+ . unwrap_or_default ( ) ;
926+
927+ // Load agents asynchronously
928+ for file_path in file_paths {
929+ res. push ( Agent :: load ( os, & file_path, global_mcp_config, mcp_enabled, output) . await ) ;
901930 }
902931
903932 res
@@ -1070,9 +1099,10 @@ mod tests {
10701099 ) ;
10711100 }
10721101
1073- #[ test]
1074- fn test_switch ( ) {
1102+ #[ tokio :: test]
1103+ async fn test_switch ( ) {
10751104 let mut collection = Agents :: default ( ) ;
1105+ let os = crate :: os:: Os :: new ( ) . await . unwrap ( ) ;
10761106
10771107 let default_agent = Agent :: default ( ) ;
10781108 let dev_agent = Agent {
@@ -1086,12 +1116,12 @@ mod tests {
10861116 collection. active_idx = "default" . to_string ( ) ;
10871117
10881118 // Test successful switch
1089- let result = collection. switch ( "dev" ) ;
1119+ let result = collection. switch ( "dev" , & os ) ;
10901120 assert ! ( result. is_ok( ) ) ;
10911121 assert_eq ! ( result. unwrap( ) . name, "dev" ) ;
10921122
10931123 // Test switch to non-existent agent
1094- let result = collection. switch ( "nonexistent" ) ;
1124+ let result = collection. switch ( "nonexistent" , & os ) ;
10951125 assert ! ( result. is_err( ) ) ;
10961126 assert_eq ! ( result. unwrap_err( ) . to_string( ) , "No agent with name nonexistent found" ) ;
10971127 }
@@ -1579,4 +1609,94 @@ mod tests {
15791609 let result = agent. resolve_prompt ( ) ;
15801610 assert ! ( result. is_err( ) ) ;
15811611 }
1612+
1613+ #[ tokio:: test]
1614+ async fn test_path_identifier ( ) {
1615+ use std:: path:: PathBuf ;
1616+
1617+ // Create a mock Os for testing
1618+ let os = crate :: os:: Os :: new ( ) . await . unwrap ( ) ;
1619+
1620+ // Get the actual OS paths for testing
1621+ let local_dir = directories:: chat_local_agent_dir ( & os) . unwrap ( ) ;
1622+ let global_dir = directories:: chat_global_agent_path ( & os) . unwrap ( ) ;
1623+
1624+ // Test workspace agent path using actual OS paths
1625+ let mut agent = Agent :: default ( ) ;
1626+ agent. path = Some ( local_dir. join ( "team/assistant.json" ) ) ;
1627+ assert_eq ! ( agent. path_identifier( & os) , Some ( "team/assistant" . to_string( ) ) ) ;
1628+
1629+ // Test global agent path using actual OS paths
1630+ agent. path = Some ( global_dir. join ( "org/specialist.json" ) ) ;
1631+ assert_eq ! ( agent. path_identifier( & os) , Some ( "org/specialist" . to_string( ) ) ) ;
1632+
1633+ // Test nested path using actual OS paths
1634+ agent. path = Some ( global_dir. join ( "company/team/expert.json" ) ) ;
1635+ assert_eq ! ( agent. path_identifier( & os) , Some ( "company/team/expert" . to_string( ) ) ) ;
1636+
1637+ // Test simple filename (fallback) - path that doesn't match agent directories
1638+ agent. path = Some ( PathBuf :: from ( "/some/other/path/simple.json" ) ) ;
1639+ assert_eq ! ( agent. path_identifier( & os) , Some ( "simple" . to_string( ) ) ) ;
1640+
1641+ // Test no path
1642+ agent. path = None ;
1643+ assert_eq ! ( agent. path_identifier( & os) , None ) ;
1644+
1645+ // Test cross-platform path normalization using actual OS paths
1646+ agent. path = Some ( global_dir. join ( "dev" ) . join ( "helper.json" ) ) ;
1647+ assert_eq ! ( agent. path_identifier( & os) , Some ( "dev/helper" . to_string( ) ) ) ;
1648+ }
1649+
1650+ #[ tokio:: test]
1651+ async fn test_switch_with_path_identifier ( ) {
1652+ let mut collection = Agents :: default ( ) ;
1653+ let os = crate :: os:: Os :: new ( ) . await . unwrap ( ) ;
1654+
1655+ // Get the actual OS paths for testing
1656+ let global_dir = directories:: chat_global_agent_path ( & os) . unwrap ( ) ;
1657+
1658+ // Create agents with different paths using actual OS paths
1659+ let mut agent1 = Agent {
1660+ name : "helper" . to_string ( ) ,
1661+ ..Default :: default ( )
1662+ } ;
1663+ agent1. path = Some ( global_dir. join ( "dev/helper.json" ) ) ;
1664+
1665+ let mut agent2 = Agent {
1666+ name : "assistant" . to_string ( ) ,
1667+ ..Default :: default ( )
1668+ } ;
1669+ agent2. path = Some ( global_dir. join ( "team/assistant.json" ) ) ;
1670+
1671+ collection. agents . insert ( "helper" . to_string ( ) , agent1) ;
1672+ collection. agents . insert ( "assistant" . to_string ( ) , agent2) ;
1673+ collection. active_idx = "helper" . to_string ( ) ;
1674+
1675+ // Test switch by JSON name (existing behavior)
1676+ let result = collection. switch ( "assistant" , & os) ;
1677+ assert ! ( result. is_ok( ) ) ;
1678+ assert_eq ! ( result. unwrap( ) . name, "assistant" ) ;
1679+
1680+ // Test switch by path identifier (new behavior)
1681+ let result = collection. switch ( "dev/helper" , & os) ;
1682+ assert ! ( result. is_ok( ) ) ;
1683+ assert_eq ! ( result. unwrap( ) . name, "helper" ) ;
1684+
1685+ // Test switch by nested path identifier
1686+ let result = collection. switch ( "team/assistant" , & os) ;
1687+ assert ! ( result. is_ok( ) ) ;
1688+ assert_eq ! ( result. unwrap( ) . name, "assistant" ) ;
1689+
1690+ // Test switch to non-existent agent (both name and path)
1691+ let result = collection. switch ( "nonexistent" , & os) ;
1692+ assert ! ( result. is_err( ) ) ;
1693+ assert_eq ! ( result. unwrap_err( ) . to_string( ) , "No agent with name nonexistent found" ) ;
1694+
1695+ let result = collection. switch ( "nonexistent/path" , & os) ;
1696+ assert ! ( result. is_err( ) ) ;
1697+ assert_eq ! (
1698+ result. unwrap_err( ) . to_string( ) ,
1699+ "No agent with name nonexistent/path found"
1700+ ) ;
1701+ }
15821702}
0 commit comments