use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use super::microagent::SubtaskOutput;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ToolSchema {
pub name: String,
pub description: String,
#[serde(default)]
pub parameters: HashMap<String, String>,
#[serde(default)]
pub required: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<ToolCategory>,
}
impl From<brainwires_core::Tool> for ToolSchema {
fn from(tool: brainwires_core::Tool) -> Self {
let mut schema = Self::new(&tool.name, &tool.description);
if let Some(props) = &tool.input_schema.properties {
for (name, value) in props {
let desc = value
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("No description")
.to_string();
schema.parameters.insert(name.clone(), desc);
}
}
if let Some(required) = &tool.input_schema.required {
schema.required = required.clone();
}
schema
}
}
impl ToolSchema {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters: HashMap::new(),
required: Vec::new(),
category: None,
}
}
pub fn with_param(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
self.parameters.insert(name.into(), description.into());
self
}
pub fn with_required_param(
mut self,
name: impl Into<String>,
description: impl Into<String>,
) -> Self {
let name = name.into();
self.parameters.insert(name.clone(), description.into());
self.required.push(name);
self
}
pub fn with_category(mut self, category: ToolCategory) -> Self {
self.category = Some(category);
self
}
pub fn to_prompt_format(&self) -> String {
let mut result = format!("- **{}**: {}\n", self.name, self.description);
if !self.parameters.is_empty() {
result.push_str(" Parameters:\n");
for (name, desc) in &self.parameters {
let required = if self.required.contains(name) {
" (required)"
} else {
""
};
result.push_str(&format!(" - {}{}: {}\n", name, required, desc));
}
}
result
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ToolIntent {
pub tool_name: String,
#[serde(default)]
pub arguments: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
}
impl ToolIntent {
pub fn new(tool_name: impl Into<String>, arguments: serde_json::Value) -> Self {
Self {
tool_name: tool_name.into(),
arguments,
rationale: None,
}
}
pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
self.rationale = Some(rationale.into());
self
}
pub fn matches_category(&self, category: &ToolCategory) -> bool {
category.contains_tool(&self.tool_name)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SubtaskOutputWithIntent {
pub output: SubtaskOutput,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_intent: Option<ToolIntent>,
#[serde(default)]
pub awaiting_tool_result: bool,
}
impl SubtaskOutputWithIntent {
pub fn from_output(output: SubtaskOutput) -> Self {
Self {
output,
tool_intent: None,
awaiting_tool_result: false,
}
}
pub fn with_tool_intent(output: SubtaskOutput, intent: ToolIntent) -> Self {
Self {
output,
tool_intent: Some(intent),
awaiting_tool_result: true,
}
}
pub fn has_tool_intent(&self) -> bool {
self.tool_intent.is_some()
}
pub fn mark_tool_complete(mut self) -> Self {
self.awaiting_tool_result = false;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ToolCategory {
FileRead,
FileWrite,
Search,
SemanticSearch,
Bash,
Git,
Web,
AgentPool,
TaskManager,
Mcp,
Custom(String),
}
impl ToolCategory {
pub fn contains_tool(&self, tool_name: &str) -> bool {
match self {
ToolCategory::FileRead => {
matches!(tool_name, "read_file" | "file_read" | "get_file_contents")
}
ToolCategory::FileWrite => matches!(
tool_name,
"write_file" | "edit_file" | "delete_file" | "create_directory" | "file_write"
),
ToolCategory::Search => matches!(
tool_name,
"search_files" | "grep" | "find_files" | "glob" | "file_search"
),
ToolCategory::SemanticSearch => matches!(
tool_name,
"semantic_search" | "query_codebase" | "rag_search"
),
ToolCategory::Bash => matches!(
tool_name,
"bash" | "execute_command" | "shell" | "run_command"
),
ToolCategory::Git => matches!(
tool_name,
"git" | "git_status" | "git_diff" | "git_commit" | "git_log"
),
ToolCategory::Web => matches!(
tool_name,
"web_search" | "fetch_url" | "browse" | "http_request"
),
ToolCategory::AgentPool => {
matches!(tool_name, "spawn_agent" | "agent_pool" | "create_agent")
}
ToolCategory::TaskManager => {
matches!(tool_name, "create_task" | "update_task" | "task_manager")
}
ToolCategory::Mcp => tool_name.starts_with("mcp_") || tool_name.starts_with("mcp__"),
ToolCategory::Custom(prefix) => tool_name.starts_with(prefix),
}
}
pub fn read_only_categories() -> HashSet<ToolCategory> {
HashSet::from([
ToolCategory::FileRead,
ToolCategory::Search,
ToolCategory::SemanticSearch,
])
}
pub fn side_effect_categories() -> HashSet<ToolCategory> {
HashSet::from([
ToolCategory::FileWrite,
ToolCategory::Bash,
ToolCategory::Git,
ToolCategory::Web,
ToolCategory::AgentPool,
ToolCategory::TaskManager,
])
}
}
#[derive(Clone, Debug)]
pub enum IntentParseResult {
NoIntent(SubtaskOutput),
WithIntent(SubtaskOutputWithIntent),
ParseError(String),
}
pub fn parse_tool_intent(subtask_id: &str, response_text: &str) -> IntentParseResult {
if let Some(intent) = extract_tool_intent_json(response_text) {
match serde_json::from_value::<ToolIntent>(intent.clone()) {
Ok(tool_intent) => {
let output_text = remove_json_block(response_text);
let output = SubtaskOutput::new(
subtask_id,
serde_json::json!({
"text": output_text.trim(),
"awaiting_tool": true,
}),
);
IntentParseResult::WithIntent(SubtaskOutputWithIntent::with_tool_intent(
output,
tool_intent,
))
}
Err(e) => IntentParseResult::ParseError(format!("Failed to parse tool intent: {}", e)),
}
} else {
let output = SubtaskOutput::new(subtask_id, serde_json::json!({ "text": response_text }));
IntentParseResult::NoIntent(output)
}
}
fn extract_tool_intent_json(text: &str) -> Option<serde_json::Value> {
if let Some(json_block) = extract_json_code_block(text)
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&json_block)
{
if value.get("tool_intent").is_some() {
return value.get("tool_intent").cloned();
}
if value.get("tool_name").is_some() {
return Some(value);
}
}
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('{')
&& trimmed.ends_with('}')
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed)
{
if value.get("tool_intent").is_some() {
return value.get("tool_intent").cloned();
}
if value.get("tool_name").is_some() {
return Some(value);
}
}
}
None
}
fn extract_json_code_block(text: &str) -> Option<String> {
let start_markers = ["```json", "```JSON"];
let end_marker = "```";
for start in start_markers {
if let Some(start_idx) = text.find(start) {
let content_start = start_idx + start.len();
if let Some(end_idx) = text[content_start..].find(end_marker) {
return Some(
text[content_start..content_start + end_idx]
.trim()
.to_string(),
);
}
}
}
None
}
fn remove_json_block(text: &str) -> String {
let start_markers = ["```json", "```JSON"];
let end_marker = "```";
let mut result = text.to_string();
for start in start_markers {
if let Some(start_idx) = result.find(start) {
let content_start = start_idx + start.len();
if let Some(end_idx) = result[content_start..].find(end_marker) {
let block_end = content_start + end_idx + end_marker.len();
result = format!("{}{}", &result[..start_idx], &result[block_end..]);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_intent_creation() {
let intent = ToolIntent::new("read_file", serde_json::json!({"path": "/test.txt"}))
.with_rationale("Need to read configuration");
assert_eq!(intent.tool_name, "read_file");
assert_eq!(
intent.rationale,
Some("Need to read configuration".to_string())
);
}
#[test]
fn test_tool_category_matching() {
assert!(ToolCategory::FileRead.contains_tool("read_file"));
assert!(ToolCategory::FileWrite.contains_tool("write_file"));
assert!(ToolCategory::Search.contains_tool("grep"));
assert!(ToolCategory::Mcp.contains_tool("mcp__brainwires-rag__query"));
assert!(!ToolCategory::FileRead.contains_tool("bash"));
}
#[test]
fn test_parse_tool_intent_with_json_block() {
let response = r#"I need to read a file first.
```json
{
"tool_name": "read_file",
"arguments": {"path": "/test.txt"},
"rationale": "Check contents"
}
```
"#;
match parse_tool_intent("task-1", response) {
IntentParseResult::WithIntent(output) => {
assert!(output.has_tool_intent());
let intent = output.tool_intent.unwrap();
assert_eq!(intent.tool_name, "read_file");
}
_ => panic!("Expected WithIntent result"),
}
}
#[test]
fn test_parse_no_intent() {
let response = "This is just a regular response without any tool calls.";
match parse_tool_intent("task-1", response) {
IntentParseResult::NoIntent(output) => {
assert_eq!(output.subtask_id, "task-1");
}
_ => panic!("Expected NoIntent result"),
}
}
#[test]
fn test_read_only_categories() {
let read_only = ToolCategory::read_only_categories();
assert!(read_only.contains(&ToolCategory::FileRead));
assert!(read_only.contains(&ToolCategory::Search));
assert!(!read_only.contains(&ToolCategory::FileWrite));
assert!(!read_only.contains(&ToolCategory::Bash));
}
}