Skip to main content

team_core/
render.rs

1//! Render a loaded compose into on-disk artifacts.
2//!
3//! Outputs under `<root>/state/`:
4//! - `envs/<project>-<agent>.env`      — env vars for the agent wrapper.
5//! - `mcp/<project>-<agent>.json`      — MCP stdio config for the runtime.
6//!
7//! `systemd` / `launchd` unit rendering lives behind a feature flag when
8//! those back-ends are enabled via `supervisor.type`.
9
10use std::path::{Path, PathBuf};
11
12use crate::compose::{AgentHandle, Compose};
13
14/// Absolute path to the rendered env file for a given agent.
15pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
16    root.join("state/envs")
17        .join(format!("{project}-{agent}.env"))
18}
19
20/// Absolute path to the rendered MCP config for a given agent.
21pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
22    root.join("state/mcp")
23        .join(format!("{project}-{agent}.json"))
24}
25
26/// Rendered env + MCP content for a single agent.
27pub fn render_agent(
28    compose: &Compose,
29    handle: AgentHandle<'_>,
30    team_mcp_bin: &str,
31) -> (String, String) {
32    let env = render_env(compose, handle);
33    let mcp = render_mcp(compose, handle, team_mcp_bin);
34    (env, mcp)
35}
36
37fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
38    let project = compose
39        .projects
40        .iter()
41        .find(|p| p.project.id == h.project)
42        .expect("agent belongs to a loaded project");
43    let mailbox = compose.root.join(&compose.global.broker.path);
44    let mcp = mcp_path(&compose.root, h.project, h.agent);
45    let prompt = h
46        .spec
47        .role_prompt
48        .as_ref()
49        .map(|p| compose.root.join(p))
50        .map(|p| p.display().to_string())
51        .unwrap_or_default();
52
53    let mut s = String::new();
54    s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
55    s.push_str(&format!("PROJECT_ID={}\n", h.project));
56    s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
57    if let Some(m) = &h.spec.model {
58        s.push_str(&format!("MODEL={m}\n"));
59    }
60    if let Some(pm) = &h.spec.permission_mode {
61        s.push_str(&format!("PERMISSION_MODE={pm}\n"));
62    }
63    // T-048: per-agent reasoning effort flows through to the runtime
64    // via the wrapper. Workspace-level `.env` `EFFORT=` still wins for
65    // operators not yet on the YAML form (back-compat).
66    if let Some(effort) = h.spec.effort {
67        s.push_str(&format!("EFFORT={}\n", effort.as_str()));
68    }
69    s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
70    s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
71    s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
72    s.push_str(&format!(
73        "CLAUDE_PROJECT_DIR={}\n",
74        project.project.cwd.display()
75    ));
76    s.push_str(&format!(
77        "TMUX_SESSION={}{}-{}\n",
78        compose.global.supervisor.tmux_prefix, h.project, h.agent
79    ));
80    s
81}
82
83fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
84    let mailbox = compose.root.join(&compose.global.broker.path);
85    let v = serde_json::json!({
86        "mcpServers": {
87            "team": {
88                "command": team_mcp_bin,
89                "args": [
90                    "--agent-id", format!("{}:{}", h.project, h.agent),
91                    "--mailbox", mailbox.display().to_string(),
92                ],
93                "env": {}
94            }
95        }
96    });
97    serde_json::to_string_pretty(&v).expect("json")
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103    use crate::compose::*;
104    use std::collections::BTreeMap;
105    use std::path::PathBuf;
106
107    fn fixture() -> Compose {
108        let mut managers = BTreeMap::new();
109        managers.insert(
110            "mgr".into(),
111            Agent {
112                runtime: "claude-code".into(),
113                model: Some("claude-opus-4-7".into()),
114                role_prompt: Some(PathBuf::from("roles/mgr.md")),
115                permission_mode: Some("auto".into()),
116                telegram_inbox: true,
117                reports_to_user: true,
118                autonomy: "low_risk_only".into(),
119                can_dm: vec![],
120                can_broadcast: vec![],
121                reports_to: None,
122                on_rate_limit: None,
123                effort: None,
124            },
125        );
126        Compose {
127            root: PathBuf::from("/teamctl"),
128            global: Global {
129                version: 2,
130                broker: Broker {
131                    r#type: "sqlite".into(),
132                    path: PathBuf::from("state/mailbox.db"),
133                },
134                supervisor: SupervisorCfg {
135                    r#type: "tmux".into(),
136                    tmux_prefix: "a-".into(),
137                    drain_timeout_secs: 10,
138                },
139                budget: Default::default(),
140                hitl: Default::default(),
141                rate_limits: Default::default(),
142                interfaces: vec![],
143                projects: vec![],
144            },
145            projects: vec![Project {
146                version: 2,
147                project: ProjectMeta {
148                    id: "hello".into(),
149                    name: "Hello".into(),
150                    cwd: PathBuf::from("/teamctl/examples/hello-team"),
151                },
152                channels: vec![],
153                managers,
154                workers: Default::default(),
155            }],
156        }
157    }
158
159    #[test]
160    fn env_contains_agent_id_and_mailbox() {
161        let c = fixture();
162        let h = c.agents().next().unwrap();
163        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
164        assert!(env.contains("AGENT_ID=hello:mgr"));
165        assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
166        assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
167    }
168
169    #[test]
170    fn env_omits_effort_when_unset() {
171        let c = fixture();
172        let h = c.agents().next().unwrap();
173        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
174        assert!(!env.contains("EFFORT="), "env was: {env}");
175    }
176
177    #[test]
178    fn env_emits_effort_when_set() {
179        let mut c = fixture();
180        c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
181        let h = c.agents().next().unwrap();
182        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
183        assert!(env.contains("EFFORT=max\n"), "env was: {env}");
184    }
185
186    #[test]
187    fn mcp_json_parses_back() {
188        let c = fixture();
189        let h = c.agents().next().unwrap();
190        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
191        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
192        assert_eq!(
193            v["mcpServers"]["team"]["command"],
194            "/usr/local/bin/team-mcp"
195        );
196        assert_eq!(
197            v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
198            "hello:mgr"
199        );
200    }
201}