microclaw_engine/tools/
consult_specialist.rs1use 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
9pub 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 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 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}