use super::{
ToolCapability, ToolResult, ToolResultDisplay, ToolRuntime,
args::{
AstGrepArgs, FindArgs, GrepArgs, HashEditArgs, ListFilesArgs, ReadArgs, SubagentsArgs,
ViewImageArgs,
},
exa::WebArgs,
};
use crate::{mcp::ContentBlock, output::ToolDispatchContext, output::redact_sensitive_text};
use serde_json::{Value, json};
use std::path::PathBuf;
pub(crate) const MAX_MCP_TOOL_TEXT_BYTES: usize = 65_536;
pub(crate) const MAX_MCP_STRUCTURED_JSON_BYTES: usize = 16_384;
const MCP_TOOL_PREFIX: &str = "mcp__";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TouchedPathKind {
Read,
ViewImage,
HashEdit,
Write,
Grep,
Find,
ListFiles,
AstGrep,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TouchedPath {
pub(crate) canonical: PathBuf,
pub(crate) kind: TouchedPathKind,
pub(crate) success: bool,
pub(crate) inside_root: bool,
pub(crate) is_dir: bool,
pub(crate) self_authored_agents_md: bool,
}
#[derive(Debug)]
pub(super) struct FilesystemOutcome {
pub(super) result: ToolResult,
pub(super) paths: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct ToolDispatchOutcome {
pub(crate) result: ToolResult,
pub(crate) touched_paths: Vec<TouchedPath>,
pub(crate) changed_paths: Vec<PathBuf>,
}
impl ToolDispatchOutcome {
fn result_only(result: ToolResult) -> Self {
Self {
result,
touched_paths: Vec::new(),
changed_paths: Vec::new(),
}
}
}
impl ToolRuntime {
#[cfg(test)]
pub fn dispatch(&self, name: &str, arguments: Value) -> ToolResult {
self.dispatch_with_context(name, arguments, ToolDispatchContext::new(None, None))
}
pub(crate) fn dispatch_with_context(
&self,
name: &str,
arguments: Value,
context: ToolDispatchContext,
) -> ToolResult {
self.dispatch_with_context_outcome(name, arguments, context)
.result
}
pub(crate) fn dispatch_with_context_outcome(
&self,
name: &str,
arguments: Value,
context: ToolDispatchContext,
) -> ToolDispatchOutcome {
match self.dispatch_inner(name, arguments, context) {
Ok(outcome) => outcome,
Err(error) => ToolDispatchOutcome::result_only(ToolResult {
tool_name: name.to_string(),
success: false,
content: error.to_string(),
metadata: json!({}),
display: ToolResultDisplay::default(),
}),
}
}
fn dispatch_inner(
&self,
name: &str,
arguments: Value,
context: ToolDispatchContext,
) -> anyhow::Result<ToolDispatchOutcome> {
context.cancellation.check()?;
if self.inspection && !super::ceiling::inspection_allows(name) {
anyhow::bail!(
"Scope limitation: inspection task cannot invoke '{name}'. Return partial findings or request a separately authorized execution task; do not retry through another tool."
);
}
let explicit_path = explicit_path_for_dispatch(name, &arguments);
if name.starts_with(MCP_TOOL_PREFIX) {
if self.is_tool_disabled(name) {
anyhow::bail!("tool disabled for this session: {name}");
}
return self
.dispatch_mcp(name, arguments, &context)
.map(ToolDispatchOutcome::result_only);
}
let tool = ToolCapability::from_dispatch_name(name)
.ok_or_else(|| anyhow::anyhow!("unknown tool '{name}'"))?;
if self.is_tool_disabled(tool.canonical_name()) {
anyhow::bail!("tool disabled for this session: {name}");
}
let result = match tool {
ToolCapability::Read => self.read(
serde_json::from_value::<ReadArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::ViewImage => self.view_image(
serde_json::from_value::<ViewImageArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::Bash => {
self.bash(serde_json::from_value(arguments)?, &context.cancellation)
}
ToolCapability::HashEdit => {
let outcome = self.hash_edit_outcome(
serde_json::from_value::<HashEditArgs>(arguments)?.validate()?,
&context.cancellation,
);
return Ok(self.filesystem_dispatch_outcome(outcome, TouchedPathKind::HashEdit));
}
ToolCapability::Write => {
let outcome = self.write_file_outcome(
serde_json::from_value(arguments)?,
&context.cancellation,
)?;
return Ok(self.filesystem_dispatch_outcome(outcome, TouchedPathKind::Write));
}
ToolCapability::Grep => self.grep(
serde_json::from_value::<GrepArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::Find => self.find(
serde_json::from_value::<FindArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::ListFiles => {
self.list_files(serde_json::from_value::<ListFilesArgs>(arguments)?.validate()?)
}
ToolCapability::Subagents => {
let tool_args: SubagentsArgs = serde_json::from_value(arguments)?;
let runtime_args = crate::subagents::SubagentsArgs::try_from(tool_args)?;
let arguments = serde_json::to_value(runtime_args)?;
self.subagents
.as_ref()
.map(|runner| runner(arguments, context))
.ok_or_else(|| anyhow::anyhow!("subagents runtime is not configured"))
}
ToolCapability::Web => {
self.web_for_context(serde_json::from_value::<WebArgs>(arguments)?, &context)
}
ToolCapability::AstGrep => self.ast_grep(
serde_json::from_value::<AstGrepArgs>(arguments)?.validate()?,
&context.cancellation,
),
ToolCapability::MagiControl => {
let object = arguments
.as_object()
.ok_or_else(|| anyhow::anyhow!("magi_control arguments must be an object"))?;
let action = object
.get("action")
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow::anyhow!("magi_control action must be `compact` or `context_usage`")
})?;
if object.keys().any(|key| key != "action" && key != "context") {
anyhow::bail!("magi_control accepts only `action` and optional `context`");
}
match action {
"compact" => {
if !self.magi_control_compact {
anyhow::bail!("magi_control action `compact` is disabled");
}
if let Some(context) = object.get("context") {
let context = context.as_str().ok_or_else(|| {
anyhow::anyhow!("magi_control context must be a nonblank string")
})?;
if context.trim().is_empty() {
anyhow::bail!("magi_control context must be a nonblank string");
}
if context.chars().count()
> super::capability::MAGI_CONTROL_CONTEXT_MAX_CHARS
{
anyhow::bail!(
"magi_control context exceeds maximum length of {} characters",
super::capability::MAGI_CONTROL_CONTEXT_MAX_CHARS
);
}
}
Ok(ToolResult {
tool_name: crate::tools::contract::tool_name::MAGI_CONTROL.to_string(),
success: true,
content: "compaction requested; execution will continue automatically after compaction".to_string(),
metadata: json!({"action": "compact"}),
display: ToolResultDisplay::default(),
})
}
"context_usage" => {
let usage = context.context_window_usage.ok_or_else(|| {
anyhow::anyhow!("current context-window usage is unavailable")
})?;
let percentage = if usage.max_tokens == 0 {
0
} else {
usize::try_from(
(usage.current_tokens as u128).saturating_mul(100)
/ usage.max_tokens as u128,
)
.unwrap_or(usize::MAX)
};
Ok(ToolResult {
tool_name: crate::tools::contract::tool_name::MAGI_CONTROL.to_string(),
success: true,
content: format!(
"context window: {percentage}% used ({}/{}) tokens",
usage.current_tokens, usage.max_tokens
),
metadata: json!({
"action": "context_usage",
"percentage": percentage,
"current_tokens": usage.current_tokens,
"max_tokens": usage.max_tokens,
}),
display: ToolResultDisplay::default(),
})
}
_ => anyhow::bail!("magi_control action must be `compact` or `context_usage`"),
}
}
}?;
let touched_paths =
self.touched_paths_for_dispatch(name, explicit_path.as_deref(), &result);
Ok(ToolDispatchOutcome {
result,
touched_paths,
changed_paths: Vec::new(),
})
}
fn touched_paths_for_dispatch(
&self,
name: &str,
explicit_path: Option<&str>,
result: &ToolResult,
) -> Vec<TouchedPath> {
let Some(tool) = ToolCapability::from_dispatch_name(name) else {
return Vec::new();
};
match tool {
ToolCapability::Read => self.touched_read_paths(result),
ToolCapability::ViewImage if result.success => result
.metadata
.get("path")
.and_then(Value::as_str)
.and_then(|path| {
self.touched_existing_path(path, TouchedPathKind::ViewImage, false)
})
.into_iter()
.collect(),
ToolCapability::Grep if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::Grep, false))
.into_iter()
.collect(),
ToolCapability::Find if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::Find, false))
.into_iter()
.collect(),
ToolCapability::ListFiles if result.success => explicit_path
.and_then(|path| {
self.touched_existing_path(path, TouchedPathKind::ListFiles, false)
})
.into_iter()
.collect(),
ToolCapability::AstGrep if result.success => explicit_path
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::AstGrep, false))
.into_iter()
.collect(),
_ => Vec::new(),
}
}
fn filesystem_dispatch_outcome(
&self,
outcome: FilesystemOutcome,
kind: TouchedPathKind,
) -> ToolDispatchOutcome {
let touched_paths = outcome
.paths
.iter()
.filter_map(|path| self.touched_existing_path(path, kind, true))
.collect();
ToolDispatchOutcome {
result: outcome.result,
touched_paths,
changed_paths: outcome.paths,
}
}
fn touched_read_paths(&self, result: &ToolResult) -> Vec<TouchedPath> {
if let Some(results) = result.metadata.get("results").and_then(Value::as_array) {
return results
.iter()
.filter(|item| item.get("success").and_then(Value::as_bool) == Some(true))
.filter_map(|item| item.get("path").and_then(Value::as_str))
.filter_map(|path| self.touched_existing_path(path, TouchedPathKind::Read, false))
.collect();
}
if result.success {
return result
.metadata
.get("path")
.and_then(Value::as_str)
.and_then(|path| self.touched_existing_path(path, TouchedPathKind::Read, false))
.into_iter()
.collect();
}
Vec::new()
}
fn touched_existing_path(
&self,
path: impl AsRef<std::path::Path>,
kind: TouchedPathKind,
self_authored_on_agents: bool,
) -> Option<TouchedPath> {
let path_buf = path.as_ref().to_path_buf();
let resolved = if path_buf.is_absolute() {
path_buf
} else {
self.cwd.join(path_buf)
};
let canonical = resolved.canonicalize().ok()?;
let inside_root = canonical.starts_with(&self.cwd_canonical);
let is_dir = canonical.is_dir();
let self_authored_agents_md = self_authored_on_agents
&& canonical
.file_name()
.is_some_and(|name| name == "AGENTS.md");
Some(TouchedPath {
canonical,
kind,
success: true,
inside_root,
is_dir,
self_authored_agents_md,
})
}
fn dispatch_mcp(
&self,
name: &str,
arguments: Value,
context: &ToolDispatchContext,
) -> anyhow::Result<ToolResult> {
context.cancellation.check()?;
let resolved_call = {
let manager = self
.mcp
.as_ref()
.ok_or_else(|| anyhow::anyhow!("MCP tool runtime is not configured"))?
.lock()
.map_err(|_| anyhow::anyhow!("MCP tool runtime lock poisoned"))?;
manager.resolve_tool_call(name)
};
let result = resolved_call.and_then(|resolved_call| {
resolved_call.call_tool_cancellable(Some(arguments), &context.cancellation)
});
Ok(match result {
Ok(result) => mcp_call_result_to_tool_result(name, result),
Err(error) => ToolResult {
tool_name: name.to_string(),
success: false,
content: bounded_text(&error.to_string(), MAX_MCP_TOOL_TEXT_BYTES),
metadata: json!({"mcp": true}),
display: ToolResultDisplay::default(),
},
})
}
}
fn explicit_path_for_dispatch(name: &str, arguments: &Value) -> Option<String> {
let tool = ToolCapability::from_dispatch_name(name)?;
match tool {
ToolCapability::Grep
| ToolCapability::Find
| ToolCapability::ListFiles
| ToolCapability::AstGrep => explicit_argument_path(arguments).map(str::to_owned),
_ => None,
}
}
fn explicit_argument_path(arguments: &Value) -> Option<&str> {
arguments
.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|path| !path.is_empty())
}
fn mcp_call_result_to_tool_result(name: &str, result: crate::mcp::CallToolResult) -> ToolResult {
let content = sanitize_mcp_result_content(&result);
ToolResult {
tool_name: name.to_string(),
success: !result.is_error.unwrap_or(false),
content,
metadata: json!({"mcp": true, "is_error": result.is_error.unwrap_or(false),
"structured_result": sanitize_mcp_structured_result(&result)}),
display: ToolResultDisplay::default(),
}
}
fn sanitize_mcp_structured_result(result: &crate::mcp::CallToolResult) -> Value {
let mut remaining = MAX_MCP_TOOL_TEXT_BYTES;
let mut blocks = Vec::new();
let mut complete = true;
for block in &result.content {
let single = crate::mcp::CallToolResult {
content: vec![block.clone()],
structured_content: None,
is_error: None,
};
let text = sanitize_mcp_result_content(&single);
if text.len().saturating_add(1) > remaining {
complete = false;
break;
}
remaining -= text.len() + 1;
let protected_block = match block {
ContentBlock::Text { text: original } => {
complete &= redact_sensitive_text(original).len() <= MAX_MCP_TOOL_TEXT_BYTES;
json!({"type":"text","text":text})
}
ContentBlock::Image { mime_type, .. } => {
complete = false;
json!({"type":"image","mimeType":mime_type,"redacted":true})
}
ContentBlock::Audio { mime_type, .. } => {
complete = false;
json!({"type":"audio","mimeType":mime_type,"redacted":true})
}
ContentBlock::Resource { resource } => {
complete = false;
json!({"type":"resource","mimeType":resource.mime_type,"redacted":true})
}
};
blocks.push(protected_block);
}
let structured = result.structured_content.as_ref().and_then(|value| {
let text = redact_sensitive_text(&value.to_string());
if text.len() > MAX_MCP_STRUCTURED_JSON_BYTES || text.len().saturating_add(20) > remaining {
complete = false;
None
} else {
let parsed = serde_json::from_str::<Value>(&text).ok();
complete &= parsed.is_some();
parsed
}
});
json!({"content":blocks,"structuredContent":structured,"complete":complete})
}
fn sanitize_mcp_result_content(result: &crate::mcp::CallToolResult) -> String {
let mut output = String::new();
for block in &result.content {
if !output.is_empty() {
output.push('\n');
}
match block {
ContentBlock::Text { text } => {
output.push_str(&bounded_text(
&redact_sensitive_text(text),
MAX_MCP_TOOL_TEXT_BYTES,
));
}
ContentBlock::Image { mime_type, .. } => {
output.push_str(&format!("[mcp content block redacted: image {mime_type}]"))
}
ContentBlock::Audio { mime_type, .. } => {
output.push_str(&format!("[mcp content block redacted: audio {mime_type}]"))
}
ContentBlock::Resource { resource } => {
let mime = resource.mime_type.as_deref().unwrap_or("unknown");
output.push_str(&format!("[mcp content block redacted: resource {mime}]"));
}
}
}
if let Some(structured) = &result.structured_content {
if !output.is_empty() {
output.push('\n');
}
let structured = redact_sensitive_text(&structured.to_string());
output.push_str("structuredContent: ");
output.push_str(&bounded_text(&structured, MAX_MCP_STRUCTURED_JSON_BYTES));
}
bounded_text(&output, MAX_MCP_TOOL_TEXT_BYTES)
}
fn bounded_text(text: &str, max_bytes: usize) -> String {
if text.len() <= max_bytes {
return text.to_string();
}
let mut end = max_bytes;
while !text.is_char_boundary(end) {
end -= 1;
}
format!(
"{}\n[truncated: MCP output exceeded {max_bytes} bytes]",
&text[..end]
)
}