Skip to main content

microclaw_engine/tools/
consult_specialist.rs

1use async_trait::async_trait;
2use serde_json::json;
3
4use super::{schema_object, Tool, ToolResult};
5use crate::config::Config;
6use crate::tools::specialists::{resolve_specialist, specialist_catalog, specialist_names};
7use microclaw_core::llm_types::{Message, MessageContent, ResponseContentBlock, ToolDefinition};
8
9/// Lets one agent get a quick, inline second opinion from a different specialist
10/// without spawning a full async run. It's a single, bounded LLM call with the
11/// chosen specialist's persona — no tools, no recursion — so a researcher can ask
12/// the writer to polish a paragraph, or the coder can ask the mathematician to
13/// sanity-check a formula, and weave the answer straight back into their work.
14pub struct ConsultSpecialistTool {
15    config: Config,
16}
17
18impl ConsultSpecialistTool {
19    pub fn new(config: &Config) -> Self {
20        Self {
21            config: config.clone(),
22        }
23    }
24}
25
26#[async_trait]
27impl Tool for ConsultSpecialistTool {
28    fn name(&self) -> &str {
29        "consult_specialist"
30    }
31
32    fn definition(&self) -> ToolDefinition {
33        ToolDefinition {
34            name: "consult_specialist".into(),
35            description: format!(
36                "Get a quick, inline expert opinion from a different specialist when a sub-problem is outside your expertise (e.g. ask the writer to polish a draft, the mathematician to check a formula). One focused round, no tools — give them everything they need in `question`. Returns their answer for you to use. Specialists: {}.",
37                specialist_catalog()
38            ),
39            input_schema: schema_object(
40                json!({
41                    "specialist": {
42                        "type": "string",
43                        "enum": specialist_names(),
44                        "description": "Which specialist to consult."
45                    },
46                    "question": {
47                        "type": "string",
48                        "description": "The focused question plus all context they need (they have no tools and can't see your conversation)."
49                    }
50                }),
51                &["specialist", "question"],
52            ),
53        }
54    }
55
56    async fn execute(&self, input: serde_json::Value) -> ToolResult {
57        let specialist_name = input.get("specialist").and_then(|v| v.as_str());
58        let profile = resolve_specialist(specialist_name);
59        let question = input
60            .get("question")
61            .and_then(|v| v.as_str())
62            .unwrap_or("")
63            .trim()
64            .to_string();
65        if question.is_empty() {
66            return ToolResult::error("Missing required parameter: question".into());
67        }
68
69        let system_prompt = format!(
70            "{persona}\n\nYou are being consulted by a colleague for a focused expert opinion. \
71You have NO tools and cannot see their conversation — work only from what they give you. \
72Answer concisely and directly with your expert take; if their question lacks something you'd \
73need, say what's missing.",
74            persona = profile.persona
75        );
76        let messages = vec![Message {
77            role: "user".into(),
78            content: MessageContent::Text(question),
79        }];
80
81        let llm = crate::llm::create_provider(&self.config);
82        // Single bounded round, no tools → no recursion, no chat side effects.
83        let response = match llm.send_message(&system_prompt, messages, None).await {
84            Ok(r) => r,
85            Err(e) => return ToolResult::error(format!("consult_specialist LLM error: {e}")),
86        };
87        let answer = response
88            .content
89            .iter()
90            .filter_map(|block| match block {
91                ResponseContentBlock::Text { text } => Some(text.as_str()),
92                _ => None,
93            })
94            .collect::<Vec<_>>()
95            .join("");
96        let answer = answer.trim();
97        if answer.is_empty() {
98            return ToolResult::error(
99                "consult_specialist: the specialist returned no answer".into(),
100            );
101        }
102
103        ToolResult::success(json!({ "specialist": profile.name, "answer": answer }).to_string())
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[tokio::test]
112    async fn missing_question_errors_before_any_llm_call() {
113        let tool = ConsultSpecialistTool::new(&Config::test_defaults());
114        // No `question` → returns early, never reaches the provider.
115        let res = tool.execute(json!({ "specialist": "writer" })).await;
116        assert!(res.is_error);
117        assert!(res.content.contains("question"));
118    }
119
120    #[test]
121    fn definition_lists_specialists() {
122        let tool = ConsultSpecialistTool::new(&Config::test_defaults());
123        assert_eq!(tool.name(), "consult_specialist");
124        let def = tool.definition();
125        assert!(def.description.contains("writer"));
126    }
127}