construct/agent/operator/
mod.rs1pub mod core;
26pub mod providers;
27
28use crate::config::{Config, McpServerConfig, McpTransport, OperatorConfig};
29use directories::UserDirs;
30use std::collections::HashMap;
31
32pub const OPERATOR_SERVER_NAME: &str = "construct-operator";
36
37pub const DEFAULT_OPERATOR_MCP_PATH_SUFFIX: &str = ".construct/operator_mcp/run_operator_mcp.py";
39
40pub 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
56pub const OPERATOR_PROMPT: &str = core::OPERATOR_CORE_PROMPT;
58
59pub 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 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
78pub 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 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 env.insert("KUMIHO_AUTO_CONFIGURE".to_string(), "1".to_string());
94 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
117pub 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 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 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 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 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 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 config.mcp.servers.insert(0, server);
193 }
194
195 config
196}
197
198pub 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; }
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
228const 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
248pub 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
272fn 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 assert_ne!(claude_prompt, openai_prompt);
396 assert!(claude_prompt.len() < openai_prompt.len());
398 }
399}