Skip to main content

construct/agent/operator/
mod.rs

1//! Operator MCP server injection.
2//!
3//! Every non-internal agent in Construct gets the Operator orchestration MCP
4//! server wired in automatically.  This module defines:
5//!
6//! - The canonical `McpServerConfig` for the Operator stdio server.
7//! - The system-prompt text that teaches the lead agent how to use operator
8//!   tools (`create_agent`, `wait_for_agent`, etc.).
9//! - `inject_operator()` -- called during agent/config construction to splice
10//!   both the server config and the prompt into whatever `Config` is being
11//!   assembled.
12//!
13//! # Multi-Provider Support
14//!
15//! The operator prompt is split into two layers:
16//!
17//! 1. **Core layer** ([`core::OPERATOR_CORE_PROMPT`]) — universal orchestration
18//!    philosophy that works with any LLM (planning, governance, rules).
19//! 2. **Tool layer** ([`providers`]) — provider-specific tool-calling examples
20//!    adapted for each LLM family (Claude MCP, OpenAI JSON, etc.).
21//!
22//! Provider detection is automatic via [`providers::Provider::detect`] using
23//! the model name string.
24
25pub mod core;
26pub mod providers;
27
28use crate::config::{Config, McpServerConfig, McpTransport, OperatorConfig};
29use directories::UserDirs;
30use std::collections::HashMap;
31
32// -- Constants ---------------------------------------------------------------
33
34/// Name used as the MCP server prefix (tools appear as `construct-operator__<tool>`).
35pub const OPERATOR_SERVER_NAME: &str = "construct-operator";
36
37/// Default path to the Operator MCP runner script (relative to `$HOME`).
38pub const DEFAULT_OPERATOR_MCP_PATH_SUFFIX: &str = ".construct/operator_mcp/run_operator_mcp.py";
39
40// -- Prompt builder ----------------------------------------------------------
41
42/// Build the complete operator prompt for a given model.
43///
44/// Assembles the universal core layer with the provider-specific tool layer
45/// based on automatic provider detection from `model_name`.
46pub fn build_operator_prompt(model_name: &str) -> String {
47    let provider = providers::Provider::detect(model_name);
48    let mut prompt =
49        String::with_capacity(core::OPERATOR_CORE_PROMPT.len() + provider.tool_layer().len() + 8);
50    prompt.push_str(core::OPERATOR_CORE_PROMPT);
51    prompt.push_str("\n\n");
52    prompt.push_str(provider.tool_layer());
53    prompt
54}
55
56/// Backward-compatible constant — use [`build_operator_prompt`] instead.
57pub const OPERATOR_PROMPT: &str = core::OPERATOR_CORE_PROMPT;
58
59// -- MCP server config -------------------------------------------------------
60
61/// Resolve the absolute path to `run_operator_mcp.py`.
62///
63/// Priority:
64/// 1. `operator.mcp_path` from config if non-empty.
65/// 2. `~/.construct/operator_mcp/run_operator_mcp.py` (the default install location).
66pub fn resolve_operator_mcp_path(cfg: &OperatorConfig) -> String {
67    let configured = cfg.mcp_path.trim();
68    if !configured.is_empty() {
69        return expand_tilde(configured);
70    }
71    // Fall back to the conventional install location.
72    let home = UserDirs::new()
73        .map(|u| u.home_dir().to_string_lossy().into_owned())
74        .unwrap_or_else(|| "~".to_string());
75    format!("{home}/{DEFAULT_OPERATOR_MCP_PATH_SUFFIX}")
76}
77
78/// Build the `McpServerConfig` for the Operator stdio server.
79pub fn operator_mcp_server_config(cfg: &OperatorConfig) -> McpServerConfig {
80    let script_path = resolve_operator_mcp_path(cfg);
81    let mut env: HashMap<String, String> = HashMap::new();
82    env.insert(
83        "CONSTRUCT_AGENT_ROOT".to_string(),
84        expand_tilde("~/.construct"),
85    );
86    // Forward the Kumiho service token so the operator can query the Agent Pool.
87    if let Ok(token) = std::env::var("KUMIHO_SERVICE_TOKEN") {
88        if !token.trim().is_empty() {
89            env.insert("KUMIHO_AUTH_TOKEN".to_string(), token);
90        }
91    }
92    // Enable Kumiho SDK auto-configure (uses cached credentials from ~/.kumiho/).
93    env.insert("KUMIHO_AUTO_CONFIGURE".to_string(), "1".to_string());
94    // Forward gateway URL + token so the operator can query cost/audit APIs.
95    if let Ok(url) = std::env::var("CONSTRUCT_GATEWAY_URL") {
96        if !url.trim().is_empty() {
97            env.insert("CONSTRUCT_GATEWAY_URL".to_string(), url);
98        }
99    }
100    if let Ok(token) = std::env::var("CONSTRUCT_GATEWAY_TOKEN") {
101        if !token.trim().is_empty() {
102            env.insert("CONSTRUCT_GATEWAY_TOKEN".to_string(), token);
103        }
104    }
105    McpServerConfig {
106        name: OPERATOR_SERVER_NAME.to_string(),
107        transport: McpTransport::Stdio,
108        command: crate::sidecars::python::default_python_command().to_string(),
109        args: vec![script_path],
110        env,
111        url: None,
112        headers: HashMap::new(),
113        tool_timeout_secs: None,
114    }
115}
116
117// -- Injection ---------------------------------------------------------------
118
119/// Inject the Operator MCP server into `config`.
120///
121/// For non-internal agents this:
122/// 1. Ensures `config.mcp.enabled = true`.
123/// 2. Prepends the Operator server to `config.mcp.servers` (if not already present).
124///
125/// The operator system-prompt text is handled separately: call
126/// [`append_operator_prompt`] on the assembled `system_prompt` string in the
127/// agent run loop (after `append_kumiho_bootstrap`).
128///
129/// Internal agents (is_internal = true) are left untouched.
130///
131/// The function is intentionally idempotent: a second call for the same config
132/// will not duplicate the server because it checks for existing entries by server
133/// name.
134pub fn inject_operator(mut config: Config, is_internal: bool) -> Config {
135    if is_internal {
136        return config;
137    }
138    if !config.operator.enabled {
139        return config;
140    }
141
142    // Enable MCP and prepend the Operator server.
143    config.mcp.enabled = true;
144
145    let already_registered = config
146        .mcp
147        .servers
148        .iter()
149        .any(|s| s.name == OPERATOR_SERVER_NAME);
150
151    if !already_registered {
152        let operator_cfg = config.operator.clone();
153        let mut server = operator_mcp_server_config(&operator_cfg);
154        // Pass the Kumiho API URL so the operator can query the Agent Pool.
155        if !config.kumiho.api_url.is_empty() {
156            server
157                .env
158                .insert("KUMIHO_API_URL".to_string(), config.kumiho.api_url.clone());
159        }
160        // Pass project names so operator tools use the configured projects.
161        server.env.insert(
162            "KUMIHO_MEMORY_PROJECT".to_string(),
163            config.kumiho.memory_project.clone(),
164        );
165        server.env.insert(
166            "KUMIHO_HARNESS_PROJECT".to_string(),
167            config.kumiho.harness_project.clone(),
168        );
169        // Pass the gateway URL so the operator can query cost/audit APIs.
170        // Use 127.0.0.1 instead of 0.0.0.0 for the operator — 0.0.0.0 is a
171        // listen address, not a connect address, and some systems don't route it
172        // to loopback correctly.
173        let gw_host = if config.gateway.host == "0.0.0.0" {
174            "127.0.0.1"
175        } else {
176            &config.gateway.host
177        };
178        let gw_port = config.gateway.port;
179        let gw_url = format!("http://{gw_host}:{gw_port}");
180        server
181            .env
182            .insert("CONSTRUCT_GATEWAY_URL".to_string(), gw_url);
183        // Forward the first paired token (if any) for API auth.
184        if let Some(token) = config.gateway.paired_tokens.first() {
185            if !token.is_empty() {
186                server
187                    .env
188                    .insert("CONSTRUCT_GATEWAY_TOKEN".to_string(), token.clone());
189            }
190        }
191        // Prepend so Operator tools appear early in deferred tool listings.
192        config.mcp.servers.insert(0, server);
193    }
194
195    config
196}
197
198/// Append the **full** Operator prompt to `system_prompt`.
199///
200/// Used for CLI and Dashboard agent sessions where the orchestration
201/// instructions should be present from the first turn.
202///
203/// Uses provider detection on `model_name` to select the appropriate tool
204/// layer.  Project names (`CognitiveMemory`, `Construct`) are substituted
205/// from `config.kumiho.memory_project` / `config.kumiho.harness_project`.
206///
207/// Call this right after `append_kumiho_bootstrap` in the agent run loop.
208pub fn append_operator_prompt(
209    system_prompt: &mut String,
210    config: &Config,
211    is_internal: bool,
212    model_name: &str,
213) {
214    if is_internal || !config.operator.enabled {
215        return;
216    }
217    if system_prompt.contains("OPERATOR MODE (Construct)")
218        || system_prompt.contains("OPERATOR (Construct)")
219    {
220        return; // already injected
221    }
222    let raw = build_operator_prompt(model_name);
223    let prompt = crate::agent::kumiho::substitute_project_names(&raw, config);
224    system_prompt.push_str("\n\n---\n\n");
225    system_prompt.push_str(&prompt);
226}
227
228/// Compact operator reference for channel agents (~200 tokens).
229///
230/// Tells the agent it has operator tools available without dumping the
231/// full orchestration philosophy.  The agent can request the full prompt
232/// via the `load_skill` tool on the first turn that needs orchestration,
233/// following OpenClaw's one-shot pattern.
234const OPERATOR_CHANNEL_PROMPT: &str = "\
235OPERATOR (Construct) — You have access to construct-operator MCP tools \
236for multi-agent orchestration. Available tools: create_agent, \
237wait_for_agent, send_agent_prompt, get_agent_activity, list_agents, \
238save_agent_template, search_agent_pool, create_team, spawn_team, \
239save_plan, compact_conversation, store_compaction.
240
241Agent types: 'claude' (reasoning, review) or 'codex' (fast coding). \
242Model tiering: opus for deep work, sonnet for balanced, haiku for cheap. \
243Always set cwd when creating agents. Use wait_for_agent to get results.
244
245For complex orchestration patterns, use load_skill to retrieve \
246detailed instructions on demand.";
247
248/// Append the **compact** operator prompt for channel agents.
249///
250/// Channels (Discord, Slack, etc.) get a lightweight reference (~200 tokens)
251/// instead of the full ~3,500 token prompt.  The agent still has full
252/// operator MCP access and can load detailed instructions on demand.
253pub fn append_operator_channel_prompt(
254    system_prompt: &mut String,
255    config: &Config,
256    is_internal: bool,
257    _model_name: &str,
258) {
259    if is_internal || !config.operator.enabled {
260        return;
261    }
262    if system_prompt.contains("OPERATOR MODE (Construct)")
263        || system_prompt.contains("OPERATOR (Construct)")
264    {
265        return;
266    }
267    let prompt = crate::agent::kumiho::substitute_project_names(OPERATOR_CHANNEL_PROMPT, config);
268    system_prompt.push_str("\n\n---\n\n");
269    system_prompt.push_str(&prompt);
270}
271
272// -- Helpers -----------------------------------------------------------------
273
274/// Expand a leading `~` to the current user's home directory.
275fn expand_tilde(path: &str) -> String {
276    let expanded = shellexpand::tilde(path);
277    let expanded_str = expanded.as_ref();
278    if expanded_str.starts_with('~') {
279        if let Some(user_dirs) = UserDirs::new() {
280            let home = user_dirs.home_dir();
281            if let Some(rest) = expanded_str.strip_prefix('~') {
282                return format!(
283                    "{}{}{}",
284                    home.display(),
285                    if rest.starts_with('/') { "" } else { "/" },
286                    rest.trim_start_matches('/')
287                );
288            }
289        }
290    }
291    expanded_str.to_string()
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn inject_operator_adds_server() {
300        let cfg = Config::default();
301        assert!(
302            !cfg.mcp
303                .servers
304                .iter()
305                .any(|s| s.name == OPERATOR_SERVER_NAME)
306        );
307
308        let injected = inject_operator(cfg, false);
309        assert!(injected.mcp.enabled);
310        assert!(
311            injected
312                .mcp
313                .servers
314                .iter()
315                .any(|s| s.name == OPERATOR_SERVER_NAME)
316        );
317    }
318
319    #[test]
320    fn append_operator_prompt_adds_text() {
321        let cfg = Config::default();
322        let mut prompt = "## Identity\n\nYou are Construct.".to_string();
323        append_operator_prompt(&mut prompt, &cfg, false, "claude-sonnet-4-6");
324        assert!(prompt.contains("OPERATOR MODE (Construct)"));
325    }
326
327    #[test]
328    fn append_operator_prompt_is_idempotent() {
329        let cfg = Config::default();
330        let mut prompt = String::new();
331        append_operator_prompt(&mut prompt, &cfg, false, "claude-sonnet-4-6");
332        let after_first = prompt.len();
333        append_operator_prompt(&mut prompt, &cfg, false, "claude-sonnet-4-6");
334        assert_eq!(prompt.len(), after_first);
335    }
336
337    #[test]
338    fn inject_operator_skips_internal_agents() {
339        let cfg = Config::default();
340        let original_servers = cfg.mcp.servers.len();
341        let unchanged = inject_operator(cfg, true);
342        assert_eq!(unchanged.mcp.servers.len(), original_servers);
343    }
344
345    #[test]
346    fn inject_operator_is_idempotent() {
347        let cfg = Config::default();
348        let once = inject_operator(cfg, false);
349        let count_after_once = once
350            .mcp
351            .servers
352            .iter()
353            .filter(|s| s.name == OPERATOR_SERVER_NAME)
354            .count();
355        let twice = inject_operator(once, false);
356        let count_after_twice = twice
357            .mcp
358            .servers
359            .iter()
360            .filter(|s| s.name == OPERATOR_SERVER_NAME)
361            .count();
362        assert_eq!(count_after_once, count_after_twice);
363    }
364
365    #[test]
366    fn inject_operator_respects_disabled_flag() {
367        let mut cfg = Config::default();
368        cfg.operator.enabled = false;
369        let unchanged = inject_operator(cfg, false);
370        assert!(
371            !unchanged
372                .mcp
373                .servers
374                .iter()
375                .any(|s| s.name == OPERATOR_SERVER_NAME)
376        );
377    }
378
379    #[test]
380    fn build_prompt_includes_core_and_tool_layer() {
381        let claude_prompt = build_operator_prompt("claude-opus-4-6");
382        assert!(claude_prompt.contains("OPERATOR MODE (Construct)"));
383        assert!(claude_prompt.contains("=== TOOL USAGE ==="));
384
385        let openai_prompt = build_operator_prompt("gpt-5.4");
386        assert!(openai_prompt.contains("OPERATOR MODE (Construct)"));
387        assert!(openai_prompt.contains("=== TOOL USAGE ==="));
388    }
389
390    #[test]
391    fn different_models_get_different_tool_layers() {
392        let claude_prompt = build_operator_prompt("claude-opus-4-6");
393        let openai_prompt = build_operator_prompt("gpt-5.4");
394        // Both share the core but differ in tool layer
395        assert_ne!(claude_prompt, openai_prompt);
396        // Claude layer is shorter (MCP-native, less verbose)
397        assert!(claude_prompt.len() < openai_prompt.len());
398    }
399}