Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 28 additions & 32 deletions src/crates/core/src/agentic/tools/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ impl ToolRegistry {
);
}

self.tools.insert(name.clone(), tool);
self.register_tool(tool);
debug!("MCP tool registered: tool_name={}", name);
}

Expand Down Expand Up @@ -146,6 +146,9 @@ impl ToolRegistry {

/// Register a single tool
pub fn register_tool(&mut self, tool: Arc<dyn Tool>) {
// Snapshot-aware wrapping happens once at registration time so every
// subsequent lookup returns the same runtime implementation.
let tool = crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool);
let name = tool.name().to_string();
self.tools.insert(name, tool);
}
Expand Down Expand Up @@ -173,6 +176,7 @@ impl ToolRegistry {
#[cfg(test)]
mod tests {
use super::create_tool_registry;
use serde_json::json;

#[test]
fn registry_includes_webfetch_tool() {
Expand All @@ -185,27 +189,32 @@ mod tests {
let registry = create_tool_registry();
assert!(registry.get_tool("Cron").is_some());
}

#[test]
fn registry_wraps_file_modification_tools_for_snapshot_tracking() {
let registry = create_tool_registry();
let tool = registry
.get_tool("Write")
.expect("Write tool should be registered");

let assistant_text = tool.render_result_for_assistant(&json!({
"success": true,
"file_path": "E:/Projects/demo.txt"
}));

assert!(
assistant_text.contains("snapshot system"),
"expected snapshot wrapper text, got: {}",
assistant_text
);
}
}

/// Get all tools
/// If you need **always include** MCP tools, use [get_all_registered_tools]
/// Get all tools from the snapshot-aware global registry.
pub async fn get_all_tools() -> Vec<Arc<dyn Tool>> {
let registry = get_global_tool_registry();
let registry_lock = registry.read().await;
let all_tools = registry_lock.get_all_tools();
let wrapped_tools = crate::service::snapshot::get_snapshot_wrapped_tools();
let file_tool_names: std::collections::HashSet<String> = wrapped_tools
.iter()
.map(|tool| tool.name().to_string())
.collect();

let mut result = wrapped_tools;
for tool in all_tools {
if !file_tool_names.contains(tool.name()) {
result.push(tool);
}
}
result
registry_lock.get_all_tools()
}

/// Get readonly tools
Expand Down Expand Up @@ -243,22 +252,9 @@ pub fn get_global_tool_registry() -> Arc<TokioRwLock<ToolRegistry>> {
.clone()
}

/// Get all registered tools (**always include** dynamically registered MCP tools)
/// Backward-compatible alias for callers that expect MCP tools to be included.
pub async fn get_all_registered_tools() -> Vec<Arc<dyn Tool>> {
let registry = get_global_tool_registry();
let registry_lock = registry.read().await;
let all_tools = registry_lock.get_all_tools();
let wrapped_tools = crate::service::snapshot::get_snapshot_wrapped_tools();
let file_tool_names: std::collections::HashSet<String> =
wrapped_tools.iter().map(|t| t.name().to_string()).collect();

let mut result = wrapped_tools;
for tool in all_tools {
if !file_tool_names.contains(tool.name()) {
result.push(tool);
}
}
result
get_all_tools().await
}

/// Get all registered tool names
Expand Down
80 changes: 15 additions & 65 deletions src/crates/core/src/service/snapshot/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use crate::service::snapshot::types::{
use async_trait::async_trait;
use log::{debug, error, info, warn};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock as StdRwLock};
use tokio::sync::RwLock;
Expand All @@ -17,9 +17,6 @@ use tokio::sync::RwLock;
/// Manages all components of the snapshot system.
pub struct SnapshotManager {
snapshot_service: Arc<RwLock<SnapshotService>>,
original_tools: Vec<Arc<dyn Tool>>,
file_modification_tools: HashSet<String>,
initialized: bool,
}

impl SnapshotManager {
Expand All @@ -36,56 +33,7 @@ impl SnapshotManager {
let mut snapshot_service = SnapshotService::new(workspace_dir, config);
snapshot_service.initialize().await?;
let snapshot_service = Arc::new(RwLock::new(snapshot_service));

let original_tools = ToolRegistry::new().get_all_tools();

let file_modification_tools = [
"Write",
"Edit",
"Delete",
"write_file",
"edit_file",
"create_file",
"delete_file",
"rename_file",
"move_file",
]
.iter()
.map(|s| s.to_string())
.collect();

Ok(Self {
snapshot_service,
original_tools,
file_modification_tools,
initialized: true,
})
}

/// Returns whether the tool modifies files.
fn is_file_modification_tool(&self, tool_name: &str) -> bool {
self.file_modification_tools.contains(tool_name)
}

/// Returns wrapped tool list.
pub fn get_wrapped_tools(&self) -> Vec<Arc<dyn Tool>> {
if !self.initialized {
error!("Snapshot manager not initialized");
return vec![];
}

let mut wrapped_tools: Vec<Arc<dyn Tool>> = Vec::new();

for tool in &self.original_tools {
if self.is_file_modification_tool(tool.name()) {
let wrapped_tool: Arc<dyn Tool> = Arc::new(WrappedTool::new(tool.clone()));
wrapped_tools.push(wrapped_tool);
} else {
wrapped_tools.push(tool.clone());
}
}

wrapped_tools
Ok(Self { snapshot_service })
}

/// Records a file change.
Expand Down Expand Up @@ -340,18 +288,20 @@ fn snapshot_managers() -> &'static StdRwLock<HashMap<PathBuf, Arc<SnapshotManage
SNAPSHOT_MANAGERS.get_or_init(|| StdRwLock::new(HashMap::new()))
}

/// Ensures the registry always exposes the same tool implementation that will be
/// executed at runtime. File-modifying tools are wrapped once at registration time
/// so tool definitions, permission checks, and execution all share one source of truth.
pub fn wrap_tool_for_snapshot_tracking(tool: Arc<dyn Tool>) -> Arc<dyn Tool> {
if WrappedTool::is_file_modification_tool_name(tool.name()) {
Arc::new(WrappedTool::new(tool))
} else {
tool
}
}

/// Compatibility helper that returns a fresh snapshot-aware tool list.
pub fn get_snapshot_wrapped_tools() -> Vec<Arc<dyn Tool>> {
ToolRegistry::new()
.get_all_tools()
.into_iter()
.map(|tool| {
if WrappedTool::is_file_modification_tool_name(tool.name()) {
Arc::new(WrappedTool::new(tool)) as Arc<dyn Tool>
} else {
tool
}
})
.collect()
ToolRegistry::new().get_all_tools()
}

/// Wrapped tool
Expand Down
2 changes: 1 addition & 1 deletion src/crates/core/src/service/snapshot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub use events::{
pub use manager::{
ensure_snapshot_manager_for_workspace, get_or_create_snapshot_manager,
get_snapshot_manager_for_workspace, get_snapshot_wrapped_tools,
initialize_snapshot_manager_for_workspace, SnapshotManager,
initialize_snapshot_manager_for_workspace, wrap_tool_for_snapshot_tracking, SnapshotManager,
};
pub use service::{SnapshotService, SystemStats};
pub use snapshot_core::{FileChangeEntry, FileChangeQueue, SessionStats, SnapshotCore};
Expand Down
34 changes: 28 additions & 6 deletions src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ export const FileOperationToolCard: React.FC<FileOperationToolCardProps> = ({

const prevIsParamsStreamingRef = useRef(isParamsStreaming);
const userCollapsedRef = useRef(false);
const hasInitializedCompletionEffectRef = useRef(false);
const previousCompletionEndTimeRef = useRef<number | null>(toolItem.endTime ?? null);

useEffect(() => {
const prevIsParamsStreaming = prevIsParamsStreamingRef.current;
Expand Down Expand Up @@ -108,13 +110,33 @@ export const FileOperationToolCard: React.FC<FileOperationToolCardProps> = ({
const currentFile = files.find(f => f.filePath === currentFilePath);

useEffect(() => {
if (status === 'completed' && toolResult?.success && sessionId && currentFilePath) {
eventBus.emit(SNAPSHOT_EVENTS.FILE_OPERATION_COMPLETED, {
toolName: toolItem.toolName,
toolResult
}, sessionId, currentFilePath);
const completionEndTime = toolItem.endTime ?? null;
const isCompletedSuccess = status === 'completed' && Boolean(toolResult?.success);

if (!hasInitializedCompletionEffectRef.current) {
hasInitializedCompletionEffectRef.current = true;
previousCompletionEndTimeRef.current = completionEndTime;
return;
}

const shouldEmitCompletionEvent =
isCompletedSuccess &&
completionEndTime !== null &&
previousCompletionEndTimeRef.current !== completionEndTime &&
Boolean(sessionId) &&
Boolean(currentFilePath);

previousCompletionEndTimeRef.current = completionEndTime;

if (!shouldEmitCompletionEvent || !sessionId || !currentFilePath) {
return;
}
}, [status, toolResult, sessionId, currentFilePath, toolItem.toolName, eventBus]);

eventBus.emit(SNAPSHOT_EVENTS.FILE_OPERATION_COMPLETED, {
toolName: toolItem.toolName,
toolResult
}, sessionId, currentFilePath);
}, [status, toolResult, sessionId, currentFilePath, toolItem.toolName, toolItem.endTime, eventBus]);

const getToolDisplayInfo = () => {
const toolMap: Record<string, { icon: string; name: string }> = {
Expand Down
Loading