Skip to main content

telos_agent/orchestration/team/
mod.rs

1//! Team collaboration — persistent multi-agent project coordination.
2//!
3//! A Team is a named group of agents that share a task list and communicate
4//! via a mailbox. Teams persist on disk at `~/.telos/teams/{name}/config.json`.
5//!
6//! Key concepts:
7//! - **Team** = Project = TaskList (1:1 mapping to shared task directories)
8//! - **Lead** — the agent that created the team, coordinates work
9//! - **Members** — agents spawned to help with specific tasks
10//! - **Mailbox** — async message passing between team members
11//!
12//! # Lifecycle
13//!
14//! 1. Lead calls TeamCreate → team file written, shared task list initialized
15//! 2. Lead creates tasks via TaskCreate/TodoWrite
16//! 3. Lead spawns teammates via SubagentTool with team context
17//! 4. Teammates claim tasks, work, and SendUserMessage to coordinate
18//! 5. TeamDelete when work is complete (fails if members still active)
19
20use serde::{Deserialize, Serialize};
21use std::path::PathBuf;
22
23/// On-disk team configuration.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TeamConfig {
26    /// Human-readable team name.
27    pub name: String,
28    /// Optional description.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub description: Option<String>,
31    /// When the team was created (unix timestamp seconds).
32    pub created_at: u64,
33    /// The lead agent's ID (deterministic: `team-lead@{name}`).
34    pub lead_agent_id: String,
35    /// The lead agent's session ID.
36    pub lead_session_id: String,
37    /// Known team members.
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub members: Vec<TeamMember>,
40}
41
42/// A team member record.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct TeamMember {
45    /// Agent ID.
46    pub agent_id: String,
47    /// Display name.
48    pub name: String,
49    /// Agent type (e.g. "explore", "general-purpose").
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub agent_type: Option<String>,
52    /// When this member joined.
53    pub joined_at: u64,
54    /// Whether this member is still active.
55    #[serde(default = "default_true")]
56    pub is_active: bool,
57}
58
59fn default_true() -> bool {
60    true
61}
62
63/// Team root directory under the user's home.
64pub fn teams_root() -> Option<PathBuf> {
65    std::env::var("HOME")
66        .ok()
67        .map(std::path::PathBuf::from)
68        .map(|home| home.join(".telos").join("teams"))
69}
70
71/// Path to a team's config file.
72pub fn team_config_path(team_name: &str) -> Option<PathBuf> {
73    teams_root().map(|root| root.join(team_name).join("config.json"))
74}
75
76/// Path to a team's shared task directory.
77pub fn team_tasks_dir(team_name: &str) -> Option<PathBuf> {
78    std::env::var("HOME")
79        .ok()
80        .map(std::path::PathBuf::from)
81        .map(|home| home.join(".telos").join("tasks").join(sanitize_name(team_name)))
82}
83
84/// Sanitize a team name for use in filesystem paths.
85fn sanitize_name(name: &str) -> String {
86    name.chars()
87        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
88        .collect()
89}
90
91/// Deterministic lead agent ID.
92pub fn lead_agent_id(team_name: &str) -> String {
93    format!("team-lead@{}", team_name)
94}
95
96/// Load a team config from disk.
97pub fn load_team_config(team_name: &str) -> Result<TeamConfig, crate::error::AgentError> {
98    let path = team_config_path(team_name).ok_or_else(|| {
99        crate::error::AgentError::Config("cannot determine home directory".into())
100    })?;
101
102    let content = std::fs::read_to_string(&path).map_err(|e| {
103        crate::error::AgentError::Config(format!(
104            "team '{}' not found at {}: {}",
105            team_name,
106            path.display(),
107            e
108        ))
109    })?;
110
111    serde_json::from_str(&content)
112        .map_err(|e| crate::error::AgentError::Config(format!("invalid team config: {e}")))
113}
114
115/// Save a team config to disk.
116pub fn save_team_config(config: &TeamConfig) -> Result<(), crate::error::AgentError> {
117    let path = team_config_path(&config.name).ok_or_else(|| {
118        crate::error::AgentError::Config("cannot determine home directory".into())
119    })?;
120
121    if let Some(parent) = path.parent() {
122        std::fs::create_dir_all(parent).map_err(|e| {
123            crate::error::AgentError::Config(format!("cannot create team directory: {e}"))
124        })?;
125    }
126
127    let json = serde_json::to_string_pretty(config).map_err(|e| {
128        crate::error::AgentError::Config(format!("cannot serialize team config: {e}"))
129    })?;
130
131    std::fs::write(&path, json).map_err(|e| {
132        crate::error::AgentError::Config(format!(
133            "cannot write team config to {}: {}",
134            path.display(),
135            e
136        ))
137    })?;
138
139    Ok(())
140}
141
142/// Delete a team from disk (config + tasks directory).
143pub fn cleanup_team(team_name: &str) -> Result<(), crate::error::AgentError> {
144    // Remove config directory.
145    if let Some(config_path) = team_config_path(team_name)
146        && let Some(parent) = config_path.parent()
147        && parent.exists()
148    {
149        let _ = std::fs::remove_dir_all(parent);
150    }
151
152    if let Some(tasks_dir) = team_tasks_dir(team_name)
153        && tasks_dir.exists()
154    {
155        let _ = std::fs::remove_dir_all(&tasks_dir);
156    }
157
158    Ok(())
159}
160
161/// Check if a team has any active (non-lead) members, which would prevent deletion.
162pub fn has_active_members(config: &TeamConfig) -> bool {
163    config.members.iter().any(|m| m.is_active && m.agent_id != config.lead_agent_id)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn lead_agent_id_is_deterministic() {
172        assert_eq!(lead_agent_id("my-project"), "team-lead@my-project");
173        // Same input always gives same output.
174        assert_eq!(lead_agent_id("my-project"), lead_agent_id("my-project"));
175    }
176
177    #[test]
178    fn sanitize_name_replaces_special_chars() {
179        assert_eq!(sanitize_name("hello world!"), "hello_world_");
180        assert_eq!(sanitize_name("foo/bar"), "foo_bar");
181        assert_eq!(sanitize_name("abc-123"), "abc-123");
182    }
183
184    #[test]
185    fn has_active_members_detects_lead_ignored() {
186        let config = TeamConfig {
187            name: "test".into(),
188            description: None,
189            created_at: 0,
190            lead_agent_id: "team-lead@test".into(),
191            lead_session_id: "s1".into(),
192            members: vec![TeamMember {
193                agent_id: "team-lead@test".into(),
194                name: "lead".into(),
195                agent_type: None,
196                joined_at: 0,
197                is_active: true,
198            }],
199        };
200        assert!(!has_active_members(&config));
201
202        let config_with_active = TeamConfig {
203            members: vec![
204                TeamMember {
205                    agent_id: "team-lead@test".into(),
206                    name: "lead".into(),
207                    agent_type: None,
208                    joined_at: 0,
209                    is_active: true,
210                },
211                TeamMember {
212                    agent_id: "worker@test".into(),
213                    name: "worker".into(),
214                    agent_type: None,
215                    joined_at: 0,
216                    is_active: true,
217                },
218            ],
219            ..config
220        };
221        assert!(has_active_members(&config_with_active));
222    }
223}