diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index b58626d113..b2149277af 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -2900,17 +2900,17 @@ export const forbiddenContentRules = [ { regex: /\bGetToolSpecLoadObservation\b/, message: - 'execution engine must not own collapsed-tool unlock observation details; use product_runtime unlock state owner', + 'execution engine must not own deferred-tool loaded-spec observation details; use product_runtime loaded-spec state owner', }, { - regex: /\bcollect_loaded_collapsed_tool_names\b/, + regex: /\bcollect_loaded_deferred_tool_specs\b/, message: - 'execution engine must not call generic collapsed-tool collector directly; use product_runtime unlock state owner', + 'execution engine must not call generic deferred-tool collector directly; use product_runtime loaded-spec state owner', }, { - regex: /\bfn\s+collect_unlocked_collapsed_tools\b/, + regex: /\bfn\s+collect_loaded_deferred_tool_specs\b/, message: - 'execution engine must not own collapsed-tool unlock collection; use product_runtime unlock state owner', + 'execution engine must not own deferred-tool loaded-spec collection; use product_runtime loaded-spec state owner', }, ], }, @@ -4229,7 +4229,7 @@ export const forbiddenContentUnderRules = [ { path: 'src/crates/execution/tool-contracts/src', reason: - 'agent-tools may own pure tool manifest contracts, but not product manifest runtime or GetToolSpec execution without an approved provider migration', + 'agent-tools may own pure tool manifest and deferred-tool state contracts, but not product manifest runtime or concrete GetToolSpec execution', patterns: [ { regex: /\bGetToolSpecTool\b/, @@ -4239,10 +4239,6 @@ export const forbiddenContentUnderRules = [ regex: /\bmanifest_resolver\b/, message: 'tool manifest resolution stays in core product tool runtime', }, - { - regex: /\bunlocked_collapsed_tools\b/, - message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', - }, { regex: /\bToolUseContext\b/, message: 'ToolUseContext stays in core until a portable context port is reviewed', @@ -4267,12 +4263,12 @@ export const forbiddenContentUnderRules = [ message: 'tool manifest resolution stays in core product tool runtime', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'collapsed-tool unlock state stays in core ToolUseContext/runtime', + regex: /\bloaded_deferred_tool_specs\b/, + message: 'deferred-tool loaded-spec state stays in core ToolUseContext/runtime', }, { regex: /\bToolExposure\b/, - message: 'expanded/collapsed exposure policy stays in core until provider migration', + message: 'direct/deferred exposure policy stays in core until provider migration', }, ], }, @@ -4301,8 +4297,8 @@ export const forbiddenContentUnderRules = [ message: 'runtime-restriction admission must stay behind validate_tool_execution_admission', }, { - regex: /\bvalidate_collapsed_tool_usage\s*\(/, - message: 'collapsed-tool admission must stay behind validate_tool_execution_admission', + regex: /\bvalidate_deferred_tool_usage\s*\(/, + message: 'deferred-tool admission must stay behind validate_tool_execution_admission', }, ], }, diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 06f02bcaae..e42df03230 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -5312,10 +5312,6 @@ export const requiredContentRules = [ regex: /\bpub fn build_tool_manifest_policy_tools\b/, message: 'missing registry snapshot to manifest policy input helper', }, - { - regex: /\bpub fn build_collapsed_tool_stub_definition\b/, - message: 'missing collapsed-tool prompt stub contract', - }, { regex: /\bpub enum PromptVisibleToolManifestItem\b/, message: 'missing prompt-visible manifest item contract', @@ -5373,16 +5369,16 @@ export const requiredContentRules = [ message: 'missing pure GetToolSpec prompt description contract', }, { - regex: /\bpub struct GetToolSpecCollapsedToolSummary\b/, - message: 'missing pure GetToolSpec collapsed catalog summary', + regex: /\bpub struct GetToolSpecDeferredToolSummary\b/, + message: 'missing pure GetToolSpec deferred catalog summary', }, { regex: /\bpub struct GetToolSpecDetail\b/, message: 'missing pure GetToolSpec detail contract', }, { - regex: /\bpub fn summarize_get_tool_spec_collapsed_tools\b/, - message: 'missing pure GetToolSpec collapsed summary helper', + regex: /\bpub fn summarize_get_tool_spec_deferred_tools\b/, + message: 'missing pure GetToolSpec deferred summary helper', }, { regex: /\bpub async fn resolve_get_tool_spec_detail\b/, @@ -5461,12 +5457,12 @@ export const requiredContentRules = [ message: 'missing pure GetToolSpec load observation contract', }, { - regex: /\bpub fn collect_loaded_collapsed_tool_names\b/, - message: 'missing pure collapsed-tool load collection contract', + regex: /\bpub fn collect_loaded_deferred_tool_specs\b/, + message: 'missing pure deferred-tool load collection contract', }, { - regex: /\bpub enum CollapsedToolUsageError\b/, - message: 'missing collapsed-tool execution gate error contract', + regex: /\bpub enum DeferredToolUsageError\b/, + message: 'missing deferred-tool execution gate error contract', }, { regex: /\bpub enum ToolExecutionAccessError\b/, @@ -5477,8 +5473,8 @@ export const requiredContentRules = [ message: 'missing tool execution allowed-list gate policy', }, { - regex: /\bpub fn validate_collapsed_tool_usage\b/, - message: 'missing collapsed-tool execution gate policy', + regex: /\bpub fn validate_deferred_tool_usage\b/, + message: 'missing deferred-tool execution gate policy', }, { regex: /\bpub fn is_tool_path_allowed_by_resolved_roots\b/, @@ -5553,12 +5549,12 @@ export const requiredContentRules = [ message: 'missing generic static-provider plan-to-registry assembly helper', }, { - regex: /\bpub fn is_tool_collapsed\b/, - message: 'missing generic collapsed-tool registry query', + regex: /\bpub fn is_tool_deferred\b/, + message: 'missing generic deferred-tool registry query', }, { - regex: /\bpub fn get_collapsed_tool_names\b/, - message: 'missing generic collapsed-tool registry catalog query', + regex: /\bpub fn get_deferred_tool_names\b/, + message: 'missing generic deferred-tool registry catalog query', }, { regex: /\bpub async fn resolve_readonly_enabled_tools\b/, @@ -7086,8 +7082,8 @@ export const requiredContentRules = [ message: 'missing product tool runtime owner delegation', }, { - regex: /\bget_collapsed_tool_names\b/, - message: 'missing collapsed-tool catalog owner', + regex: /\bget_deferred_tool_names\b/, + message: 'missing deferred-tool catalog owner', }, { regex: /\bresolve_product_readonly_enabled_tools\b/, @@ -7095,11 +7091,11 @@ export const requiredContentRules = [ }, { regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, - message: 'missing collapsed-tool manifest migration baseline', + message: 'missing deferred-tool manifest migration baseline', }, { - regex: /\binner\.is_tool_collapsed\b/, - message: 'missing collapsed exposure lookup delegation', + regex: /\binner\.is_tool_deferred\b/, + message: 'missing deferred exposure lookup delegation', }, ], }, @@ -7254,8 +7250,8 @@ export const requiredContentRules = [ message: 'missing product GetToolSpec Tool-result vector facade', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing product runtime collapsed-tool unlock state source', + regex: /\bloaded_deferred_tool_specs\b/, + message: 'missing product runtime deferred-tool loaded-spec state source', }, { regex: /\bproduct_catalog_provider_default_get_tool_spec_catalog_matches_registry\b/, @@ -7496,8 +7492,8 @@ export const requiredContentRules = [ message: 'missing core product manifest facade delegation', }, { - regex: /\bcollapsed_tool_names\b/, - message: 'missing collapsed-tool name tracking', + regex: /\bdeferred_tool_names\b/, + message: 'missing deferred-tool name tracking', }, { regex: /\bmanifest_resolver_facade_preserves_product_owner_output\b/, @@ -7523,8 +7519,8 @@ export const requiredContentRules = [ message: 'missing core GetToolSpec execution error mapping boundary', }, { - regex: /\bbuild_collapsed_tools_context_section\b/, - message: 'missing core collapsed-tool request-context section renderer', + regex: /\bbuild_deferred_tools_context_section\b/, + message: 'missing core deferred-tool request-context section renderer', }, { regex: /\bproduct_get_tool_spec_runtime\b/, @@ -7593,8 +7589,8 @@ export const requiredContentRules = [ message: 'missing cancellation token runtime-only facts guard', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool unlock state', + regex: /\bloaded_deferred_tool_specs\b/, + message: 'missing deferred-tool loaded-spec state', }, { regex: /\bimpl ToolUseContext\b/, @@ -7697,15 +7693,15 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', reason: - 'core must continue carrying collapsed-tool unlock state while delegating provider-neutral execution gate policy to agent-tools', + 'core must continue carrying deferred-tool loaded-spec state while delegating provider-neutral execution gate policy to agent-tools', patterns: [ { regex: /\bvalidate_tool_execution_admission\b/, message: 'missing provider-neutral tool execution admission gate delegation', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool unlock state propagation', + regex: /\bloaded_deferred_tool_specs\b/, + message: 'missing deferred-tool loaded-spec state propagation', }, { regex: /\bpipeline_preserves_core_owned_tool_context_without_portable_runtime_leak\b/, @@ -7740,19 +7736,19 @@ export const requiredContentRules = [ { path: 'src/crates/assembly/core/src/agentic/execution/execution_engine.rs', reason: - 'core execution must pass collapsed-tool unlock state through product runtime owner and keep DeepResearch post-turn hooks', + 'core execution must pass deferred-tool loaded-spec state through product runtime owner and keep DeepResearch post-turn hooks', patterns: [ { - regex: /\bcollect_product_unlocked_collapsed_tools\b/, - message: 'missing product runtime collapsed-tool unlock state handoff', + regex: /\bcollect_product_loaded_deferred_tool_specs\b/, + message: 'missing product runtime deferred-tool loaded-spec state handoff', }, { - regex: /\bunlocked_collapsed_tools\b/, - message: 'missing collapsed-tool unlock propagation into round context', + regex: /\bloaded_deferred_tool_specs\b/, + message: 'missing deferred-tool loaded-spec propagation into round context', }, { - regex: /\bcollapsed_tool_names\b/, - message: 'missing manifest collapsed-tool handoff', + regex: /\bdeferred_tool_names\b/, + message: 'missing manifest deferred-tool handoff', }, { regex: /\bGetToolSpec\b/, @@ -7769,25 +7765,25 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/unlock_state.rs', + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs', reason: - 'product runtime owns collapsed-tool unlock observation adaptation while preserving generic agent-tools policy', + 'product runtime owns deferred-tool loaded-spec observation adaptation while preserving generic agent-tools policy', patterns: [ { - regex: /\bcollect_product_unlocked_collapsed_tools\b/, - message: 'missing product runtime collapsed-tool unlock collector', + regex: /\bcollect_product_loaded_deferred_tool_specs\b/, + message: 'missing product runtime deferred-tool loaded-spec collector', }, { regex: /\bGetToolSpecLoadObservation\b/, message: 'missing GetToolSpec load observation adapter', }, { - regex: /\bcollect_loaded_collapsed_tool_names\b/, - message: 'missing generic collapsed-tool load collector delegation', + regex: /\bcollect_loaded_deferred_tool_specs\b/, + message: 'missing generic deferred-tool load collector delegation', }, { - regex: /\bproduct_unlock_state_dedupes_and_filters_runtime_unlocks\b/, - message: 'missing collapsed-tool unlock filtering regression', + regex: /\bproduct_loaded_spec_state_dedupes_and_filters_results\b/, + message: 'missing deferred-tool loaded-spec filtering regression', }, ], }, diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index f2214f4efb..97237421af 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1180,7 +1180,6 @@ export function runManifestParserSelfTest({ const agentToolsRuntimeForbiddenContracts = [ 'GetToolSpecTool', 'manifest_resolver', - 'unlocked_collapsed_tools', 'ToolUseContext', ]; const agentToolsManifestRuleText = agentToolsManifestRule.patterns @@ -1204,7 +1203,7 @@ export function runManifestParserSelfTest({ 'GetToolSpecTool', 'GET_TOOL_SPEC_TOOL_NAME', 'manifest_resolver', - 'unlocked_collapsed_tools', + 'loaded_deferred_tool_specs', 'ToolExposure', ]; for (const contract of toolPacksManifestContracts) { @@ -2608,7 +2607,6 @@ export function runManifestParserSelfTest({ 'resolve_tool_manifest_policy', 'default_exposure', 'build_tool_manifest_policy_tools', - 'build_collapsed_tool_stub_definition', 'PromptVisibleToolManifestItem', 'build_prompt_visible_tool_manifest_definitions', 'ContextualToolManifestItem', @@ -2623,9 +2621,9 @@ export function runManifestParserSelfTest({ 'build_get_tool_spec_catalog_description_from_provider', 'resolve_get_tool_spec_detail_from_provider', 'build_get_tool_spec_description', - 'GetToolSpecCollapsedToolSummary', + 'GetToolSpecDeferredToolSummary', 'GetToolSpecDetail', - 'summarize_get_tool_spec_collapsed_tools', + 'summarize_get_tool_spec_deferred_tools', 'resolve_get_tool_spec_detail', 'build_get_tool_spec_catalog_description', 'get_tool_spec_input_schema', @@ -2645,14 +2643,14 @@ export function runManifestParserSelfTest({ 'GetToolSpecRuntime', 'call_results', 'GetToolSpecLoadObservation', - 'collect_loaded_collapsed_tool_names', - 'CollapsedToolUsageError', + 'collect_loaded_deferred_tool_specs', + 'DeferredToolUsageError', 'ToolExecutionAccessError', 'validate_tool_allowed_by_list', - 'validate_collapsed_tool_usage', + 'validate_deferred_tool_usage', 'sort_tool_manifest_definitions', - 'is_tool_collapsed', - 'get_collapsed_tool_names', + 'is_tool_deferred', + 'get_deferred_tool_names', ], }, { @@ -3025,7 +3023,7 @@ export function runManifestParserSelfTest({ 'from_inner', 'ProductToolDecoratorRef', 'ProductToolRuntime', - 'get_collapsed_tool_names', + 'get_deferred_tool_names', 'resolve_product_readonly_enabled_tools', ], }, @@ -3065,7 +3063,7 @@ export function runManifestParserSelfTest({ 'resolve_product_tool_manifest', 'resolve_product_readonly_enabled_tools', 'resolve_product_get_tool_spec_results', - 'unlocked_collapsed_tools', + 'loaded_deferred_tool_specs', 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', 'product_resolved_manifest_owner_matches_legacy_shape', 'GetToolSpec requires agent type context', @@ -3191,14 +3189,14 @@ export function runManifestParserSelfTest({ 'GET_TOOL_SPEC_TOOL_NAME', 'resolve_product_resolved_visible_tools', 'resolve_product_resolved_tool_manifest', - 'collapsed_tool_names', + 'deferred_tool_names', ], }, { path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs', contracts: [ 'GetToolSpecTool', - 'build_collapsed_tools_context_section', + 'build_deferred_tools_context_section', 'product_get_tool_spec_runtime', 'with_runtime', 'resolve_product_get_tool_spec_results', @@ -3225,7 +3223,7 @@ export function runManifestParserSelfTest({ 'tool_context_facts_omit_runtime_owner_fields_even_when_context_is_populated', 'customData', 'cancellationToken', - 'unlocked_collapsed_tools', + 'loaded_deferred_tool_specs', 'impl ToolUseContext', 'record_light_checkpoint', 'build_runtime_light_checkpoint', @@ -3250,7 +3248,7 @@ export function runManifestParserSelfTest({ path: 'src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs', contracts: [ 'validate_tool_execution_admission', - 'unlocked_collapsed_tools', + 'loaded_deferred_tool_specs', 'GetToolSpec', 'render_tool_result_for_assistant', 'build_tool_execution_error_presentation', @@ -3261,21 +3259,21 @@ export function runManifestParserSelfTest({ { path: 'src/crates/assembly/core/src/agentic/execution/execution_engine.rs', contracts: [ - 'collect_product_unlocked_collapsed_tools', - 'unlocked_collapsed_tools', - 'collapsed_tool_names', + 'collect_product_loaded_deferred_tool_specs', + 'loaded_deferred_tool_specs', + 'deferred_tool_names', 'GetToolSpec', 'should_post_process_research_report', 'bitfun_services_integrations::deep_research::run_for_session_workspace', ], }, { - path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/unlock_state.rs', + path: 'src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs', contracts: [ - 'collect_product_unlocked_collapsed_tools', + 'collect_product_loaded_deferred_tool_specs', 'GetToolSpecLoadObservation', - 'collect_loaded_collapsed_tool_names', - 'product_unlock_state_dedupes_and_filters_runtime_unlocks', + 'collect_loaded_deferred_tool_specs', + 'product_loaded_spec_state_dedupes_and_filters_results', ], }, { diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 1519c18f5a..60a8644df4 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -17,6 +17,7 @@ bitfun-acp = { path = "../../crates/interfaces/acp" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } bitfun-runtime-services = { path = "../../crates/execution/runtime-services" } +bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } # CLI framework clap = { workspace = true } diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 67346f3c8d..485c75021d 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; /// This module only maintains transient state needed for TUI rendering. use std::time::SystemTime; +use bitfun_agent_tools::effective_tool_invocation; use bitfun_core::agentic::core::message::{ Message as CoreMessage, MessageContent, MessageRole as CoreMessageRole, }; @@ -174,11 +175,13 @@ impl ChatMessage { // Add tool call blocks for tc in tool_calls { + let (tool_name, parameters) = + effective_tool_invocation(&tc.tool_name, &tc.arguments); flow_items.push(FlowItem::Tool { tool_state: ToolDisplayState { tool_id: tc.tool_id.clone(), - tool_name: tc.tool_name.clone(), - parameters: tc.arguments.clone(), + tool_name: tool_name.to_string(), + parameters: parameters.clone(), status: ToolDisplayStatus::Success, // Historical messages are completed result: None, progress_message: None, @@ -200,6 +203,7 @@ impl ChatMessage { MessageContent::ToolResult { tool_id, tool_name, + effective_tool_name, result, is_error, .. @@ -208,7 +212,10 @@ impl ChatMessage { flow_items.push(FlowItem::Tool { tool_state: ToolDisplayState { tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + tool_name: effective_tool_name + .as_deref() + .unwrap_or(tool_name) + .to_string(), parameters: serde_json::Value::Null, status: if *is_error { ToolDisplayStatus::Failed @@ -465,15 +472,15 @@ impl ChatState { /// Existing tools are updated in-place via tool_index for O(1) lookup. pub(crate) fn handle_tool_event(&mut self, tool_event: &ToolEventData) { match tool_event { - ToolEventData::EarlyDetected { tool_id, tool_name } => { + ToolEventData::EarlyDetected { identity } => { self.insert_or_update_tool( - tool_id, + &identity.tool_id, |_existing| { // Should not exist yet, but handle gracefully }, || ToolDisplayState { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + tool_id: identity.tool_id.clone(), + tool_name: identity.effective_name().to_string(), parameters: serde_json::Value::Null, status: ToolDisplayStatus::EarlyDetected, result: None, @@ -487,9 +494,9 @@ impl ChatState { } ToolEventData::ParamsPartial { - tool_id, params, .. + identity, params, .. } => { - self.update_tool(tool_id, |tool| { + self.update_tool(&identity.tool_id, |tool| { // Only update status if not yet in an advanced execution state. // Due to priority queue ordering, ParamsPartial (Normal priority) may // arrive after Started (High priority), which would incorrectly @@ -503,9 +510,9 @@ impl ChatState { } ToolEventData::Queued { - tool_id, position, .. + identity, position, .. } => { - self.update_tool(tool_id, |tool| { + self.update_tool(&identity.tool_id, |tool| { if !tool.status.is_execution_phase() { tool.status = ToolDisplayStatus::Queued; } @@ -515,11 +522,11 @@ impl ChatState { } ToolEventData::Waiting { - tool_id, + identity, dependencies, .. } => { - self.update_tool(tool_id, |tool| { + self.update_tool(&identity.tool_id, |tool| { if !tool.status.is_execution_phase() { tool.status = ToolDisplayStatus::Waiting; } @@ -529,23 +536,27 @@ impl ChatState { } ToolEventData::Started { - tool_id, - tool_name, + identity, params, timeout_seconds: _, } => { - let params_for_update = params.clone(); - let params_for_create = params.clone(); - let tool_name_clone = tool_name.clone(); + let (tool_name, effective_params) = + effective_tool_invocation(&identity.tool_name, params); + debug_assert_eq!(identity.effective_name(), tool_name); + let params_for_update = effective_params.clone(); + let params_for_create = effective_params.clone(); + let tool_name_for_update = tool_name.to_string(); + let tool_name_for_create = tool_name.to_string(); self.insert_or_update_tool( - tool_id, + &identity.tool_id, |tool| { tool.status = ToolDisplayStatus::Running; + tool.tool_name = tool_name_for_update; tool.parameters = params_for_update; }, || ToolDisplayState { - tool_id: tool_id.clone(), - tool_name: tool_name_clone, + tool_id: identity.tool_id.clone(), + tool_name: tool_name_for_create, parameters: params_for_create, status: ToolDisplayStatus::Running, result: None, @@ -559,7 +570,9 @@ impl ChatState { // Auto-create question prompt for AskUserQuestion tool if tool_name == "AskUserQuestion" { - if let Some(prompt) = QuestionPrompt::from_params(tool_id.clone(), params) { + if let Some(prompt) = + QuestionPrompt::from_params(identity.tool_id.clone(), effective_params) + { self.question_prompt = Some(prompt); } } @@ -568,20 +581,20 @@ impl ChatState { } ToolEventData::Progress { - tool_id, message, .. + identity, message, .. } => { - self.update_tool(tool_id, |tool| { + self.update_tool(&identity.tool_id, |tool| { tool.progress_message = Some(message.clone()); }); self.rebuild_streaming_message(); } ToolEventData::Streaming { - tool_id, + identity, chunks_received, .. } => { - self.update_tool(tool_id, |tool| { + self.update_tool(&identity.tool_id, |tool| { tool.status = ToolDisplayStatus::Streaming; tool.progress_message = Some(format!("Received {} chunks", chunks_received)); }); @@ -589,50 +602,51 @@ impl ChatState { } ToolEventData::ConfirmationNeeded { - tool_id, - tool_name, - params, - .. + identity, params, .. } => { - self.update_tool(tool_id, |tool| { + let (tool_name, effective_params) = + effective_tool_invocation(&identity.tool_name, params); + debug_assert_eq!(identity.effective_name(), tool_name); + self.update_tool(&identity.tool_id, |tool| { tool.status = ToolDisplayStatus::ConfirmationNeeded; + tool.tool_name = tool_name.to_string(); + tool.parameters = effective_params.clone(); tool.progress_message = Some("Waiting for user confirmation".to_string()); }); // Auto-create permission prompt for user interaction self.permission_prompt = Some(PermissionPrompt::new( - tool_id.clone(), - tool_name.clone(), - params.clone(), + identity.tool_id.clone(), + tool_name.to_string(), + effective_params.clone(), )); self.rebuild_streaming_message(); } - ToolEventData::Confirmed { tool_id, .. } => { - self.update_tool(tool_id, |tool| { + ToolEventData::Confirmed { identity } => { + self.update_tool(&identity.tool_id, |tool| { tool.status = ToolDisplayStatus::Confirmed; }); // Clear permission prompt if it matches this tool - if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { self.permission_prompt = None; } self.rebuild_streaming_message(); } - ToolEventData::Rejected { tool_id, .. } => { - self.update_tool(tool_id, |tool| { + ToolEventData::Rejected { identity } => { + self.update_tool(&identity.tool_id, |tool| { tool.status = ToolDisplayStatus::Rejected; tool.result = Some("User rejected execution".to_string()); }); // Clear permission prompt if it matches this tool - if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + if self.permission_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { self.permission_prompt = None; } self.rebuild_streaming_message(); } ToolEventData::Completed { - tool_id, - tool_name, + identity, result, result_for_assistant, duration_ms, @@ -644,8 +658,9 @@ impl ChatState { .unwrap_or_else(|| extract_fallback_summary(result)); let metadata = result.clone(); let dur = *duration_ms; - self.update_tool(tool_id, |tool| { - let is_hmos_failed = tool_name == "HmosCompilation" + self.update_tool(&identity.tool_id, |tool| { + tool.tool_name = identity.effective_name().to_string(); + let is_hmos_failed = identity.effective_name() == "HmosCompilation" && result.get("success").and_then(|v| v.as_bool()) == Some(false); tool.status = if is_hmos_failed { ToolDisplayStatus::Failed @@ -657,35 +672,39 @@ impl ChatState { tool.duration_ms = Some(dur); }); // Clear question prompt if this tool completed - if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { self.question_prompt = None; } self.rebuild_streaming_message(); } - ToolEventData::Failed { tool_id, error, .. } => { + ToolEventData::Failed { + identity, error, .. + } => { let err = error.clone(); - self.update_tool(tool_id, |tool| { + self.update_tool(&identity.tool_id, |tool| { + tool.tool_name = identity.effective_name().to_string(); tool.status = ToolDisplayStatus::Failed; tool.result = Some(err); }); // Clear question prompt if this tool failed - if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { self.question_prompt = None; } self.rebuild_streaming_message(); } ToolEventData::Cancelled { - tool_id, reason, .. + identity, reason, .. } => { let rsn = reason.clone(); - self.update_tool(tool_id, |tool| { + self.update_tool(&identity.tool_id, |tool| { + tool.tool_name = identity.effective_name().to_string(); tool.status = ToolDisplayStatus::Cancelled; tool.result = Some(rsn); }); // Clear question prompt if this tool was cancelled - if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(tool_id) { + if self.question_prompt.as_ref().map(|p| &p.tool_id) == Some(&identity.tool_id) { self.question_prompt = None; } self.rebuild_streaming_message(); @@ -710,45 +729,50 @@ impl ChatState { match event { AgenticEvent::ToolEvent { tool_event, .. } => match tool_event { ToolEventData::Started { - tool_name, params, .. + identity, params, .. } => { - let title = extract_tool_title(tool_name, params); + let (tool_name, effective_params) = + effective_tool_invocation(&identity.tool_name, params); + debug_assert_eq!(identity.effective_name(), tool_name); + let title = extract_tool_title(tool_name, effective_params); self.update_tool(parent_tool_id, |tool| { let progress = tool .subagent_progress .get_or_insert_with(SubagentProgress::default); progress.tool_count += 1; - progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_name = Some(tool_name.to_string()); progress.current_tool_title = title; }); self.rebuild_streaming_message(); } ToolEventData::Completed { - tool_name, + identity, result_for_assistant, result: _, .. } => { + let tool_name = identity.effective_name(); let summary = result_for_assistant .clone() - .unwrap_or_else(|| tool_name.clone()); + .unwrap_or_else(|| tool_name.to_string()); self.update_tool(parent_tool_id, |tool| { let progress = tool .subagent_progress .get_or_insert_with(SubagentProgress::default); - progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_name = Some(tool_name.to_string()); progress.current_tool_title = Some(summary); }); self.rebuild_streaming_message(); } ToolEventData::Failed { - tool_name, error, .. + identity, error, .. } => { + let tool_name = identity.effective_name(); self.update_tool(parent_tool_id, |tool| { let progress = tool .subagent_progress .get_or_insert_with(SubagentProgress::default); - progress.current_tool_name = Some(tool_name.clone()); + progress.current_tool_name = Some(tool_name.to_string()); progress.current_tool_title = Some(format!("Error: {}", truncate_string(error, 60))); }); @@ -1064,3 +1088,99 @@ fn truncate_string(s: &str, max_len: usize) -> String { format!("{}...", truncated) } } + +#[cfg(test)] +mod tests { + use super::{ChatState, FlowItem}; + use bitfun_core::agentic::core::message::{Message, ToolCall}; + use bitfun_events::{ToolEventData, ToolEventIdentity}; + use serde_json::json; + + fn deferred_input() -> serde_json::Value { + json!({ + "tool_name": "CreatePlan", + "args": { + "title": "Deferred tool plan", + "steps": ["Inspect", "Implement"] + } + }) + } + + fn assert_create_plan_item(item: &FlowItem) { + let FlowItem::Tool { tool_state } = item else { + panic!("expected tool item"); + }; + assert_eq!(tool_state.tool_name, "CreatePlan"); + assert_eq!( + tool_state.parameters, + json!({ + "title": "Deferred tool plan", + "steps": ["Inspect", "Implement"] + }) + ); + } + + #[test] + fn deferred_started_event_replaces_early_wire_display_with_effective_view() { + let mut state = ChatState::new( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + ); + state.handle_turn_started("turn-1", "Create a plan"); + state.handle_tool_event(&ToolEventData::EarlyDetected { + identity: ToolEventIdentity::direct( + "tool-1", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + ), + }); + state.handle_tool_event(&ToolEventData::Started { + identity: ToolEventIdentity::resolved( + "tool-1", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + "CreatePlan", + ), + params: deferred_input(), + timeout_seconds: None, + }); + + assert_create_plan_item(&state.current_flow_items[0]); + } + + #[test] + fn deferred_history_projects_effective_view_without_mutating_wire_message() { + let wire_input = deferred_input(); + let messages = vec![Message::assistant_with_tools( + String::new(), + vec![ToolCall { + tool_id: "tool-1".to_string(), + tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), + arguments: wire_input.clone(), + raw_arguments: None, + is_error: false, + recovered_from_truncation: false, + }], + )]; + + let state = ChatState::from_core_messages( + "session-1".to_string(), + "Session".to_string(), + "agentic".to_string(), + None, + &messages, + ); + + assert_create_plan_item(&state.messages[0].flow_items[0]); + let bitfun_core::agentic::core::message::MessageContent::Mixed { tool_calls, .. } = + &messages[0].content + else { + panic!("expected mixed message"); + }; + assert_eq!( + tool_calls[0].tool_name, + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME + ); + assert_eq!(tool_calls[0].arguments, wire_input); + } +} diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 796f9fcb7b..d402aa9f50 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -795,12 +795,16 @@ impl ChatMode { continue; } if let ToolEventData::ConfirmationNeeded { - tool_id, tool_name, .. + identity, .. } = tool_event { - if self.runtime.approval_controller().is_allowed(tool_name) { + if self + .runtime + .approval_controller() + .is_allowed(identity.effective_name()) + { let agent = self.agent.clone(); - let tool_id = tool_id.clone(); + let tool_id = identity.tool_id.clone(); match tokio::task::block_in_place(|| { rt_handle.block_on(agent.confirm_tool(&tool_id, None)) }) { diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 9035f5fdd3..97521129b7 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -11,7 +11,8 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use bitfun_events::AgenticEvent; +use bitfun_agent_tools::effective_tool_invocation; +use bitfun_events::{AgenticEvent, ToolEventIdentity}; use tokio::time::{sleep, Instant}; use crate::agent::{core_adapter::CoreAgentAdapter, Agent}; @@ -22,6 +23,15 @@ use crate::runtime::CliRuntimeContext; const TOOL_START_INPUT_PREVIEW_CHARS: usize = 4_000; const INTERRUPT_EVENT_DRAIN_TIMEOUT: Duration = Duration::from_secs(1); +fn effective_event_invocation<'a>( + identity: &'a ToolEventIdentity, + params: &'a serde_json::Value, +) -> (&'a str, &'a serde_json::Value) { + let (derived_name, effective_input) = effective_tool_invocation(&identity.tool_name, params); + debug_assert_eq!(identity.effective_name(), derived_name); + (derived_name, effective_input) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] pub(crate) enum ExecOutputFormat { Text, @@ -698,27 +708,27 @@ impl ExecMode { use bitfun_events::ToolEventData; match tool_event { ToolEventData::Started { - tool_name, - tool_id, - params, - .. + identity, params, .. } => { + let (tool_name, input) = + effective_event_invocation(identity, params); self.print_text(|| { let started_at = chrono::Utc::now().to_rfc3339(); - let input_preview = Self::tool_input_preview(params); + let input_preview = Self::tool_input_preview(input); eprintln!(" [subagent] {}", tool_name); eprintln!(" Started at: {}", started_at); - eprintln!(" Tool ID: {}", tool_id); + eprintln!(" Tool ID: {}", identity.tool_id); eprintln!(" CWD: {}", self.workspace_display()); eprintln!(" Input: {}", input_preview); }); } ToolEventData::Completed { - tool_name, + identity, result_for_assistant, result, .. } => { + let tool_name = identity.effective_name(); let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); @@ -730,8 +740,9 @@ impl ExecMode { }); } ToolEventData::Failed { - tool_name, error, .. + identity, error, .. } => { + let tool_name = identity.effective_name(); self.print_text(|| { eprintln!(" [subagent] {} failed: {}", tool_name, error) }); @@ -801,8 +812,10 @@ impl ExecMode { use bitfun_events::ToolEventData; match tool_event { ToolEventData::ConfirmationNeeded { - tool_id, tool_name, .. + identity, .. } => { + let tool_id = &identity.tool_id; + let tool_name = identity.effective_name(); if self.approval_mode.rejects_confirmation() { let mut message = format!( "Permission rejected for {tool_name}; rerun with --auto to approve tool requests" @@ -883,24 +896,24 @@ impl ExecMode { } } ToolEventData::Started { - tool_name, - tool_id, - params, - .. + identity, params, .. } => { - self.print_tool_start_details(tool_name, tool_id, params); + let (tool_name, input) = + effective_event_invocation(identity, params); + self.print_tool_start_details(tool_name, &identity.tool_id, input); total_tool_calls += 1; } ToolEventData::Progress { message, .. } => { self.print_text(|| eprintln!(" In progress: {}", message)); } ToolEventData::Completed { - tool_name, + identity, result_for_assistant, result, duration_ms, .. } => { + let tool_name = identity.effective_name(); let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); @@ -912,8 +925,9 @@ impl ExecMode { }); } ToolEventData::Failed { - tool_name, error, .. + identity, error, .. } => { + let tool_name = identity.effective_name(); self.print_text(|| eprintln!(" [x] {}: {}", tool_name, error)); } _ => {} @@ -1289,11 +1303,13 @@ mod patch_tests { use std::process::Command; use super::{ - completed_turn_failure, event_belongs_to_exec_turn, event_turn_id, - serialize_stream_envelope, write_patch_to_path, ExecApprovalMode, ExecJsonResult, ExecMode, - ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, + completed_turn_failure, effective_event_invocation, event_belongs_to_exec_turn, + event_turn_id, serialize_stream_envelope, write_patch_to_path, ExecApprovalMode, + ExecJsonResult, ExecMode, ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, + }; + use bitfun_events::{ + AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, ToolEventIdentity, }; - use bitfun_events::{AgenticEvent, AgenticEventEnvelope, AgenticEventPriority}; use serde_json::json; #[test] @@ -1678,4 +1694,23 @@ mod patch_tests { "turn-current" )); } + + #[test] + fn deferred_exec_event_projects_effective_name_and_input() { + let identity = ToolEventIdentity::resolved( + "tool-1", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + "CreatePlan", + ); + let wire_input = json!({ + "tool_name": "CreatePlan", + "args": { "title": "Ship deferred tools" } + }); + + let (tool_name, input) = effective_event_invocation(&identity, &wire_input); + + assert_eq!(tool_name, "CreatePlan"); + assert_eq!(input, &json!({ "title": "Ship deferred tools" })); + assert_eq!(wire_input["tool_name"], "CreatePlan"); + } } diff --git a/src/apps/cli/src/peer_host/fanout.rs b/src/apps/cli/src/peer_host/fanout.rs index a6e9a161a1..a57433b791 100644 --- a/src/apps/cli/src/peer_host/fanout.rs +++ b/src/apps/cli/src/peer_host/fanout.rs @@ -5,6 +5,7 @@ use std::sync::OnceLock; use bitfun_core::service::remote_connect::encryption::encrypt_to_base64; use bitfun_core::service::remote_connect::remote_server::RemoteCommand; +use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::{project_agentic_frontend_event, AgenticEvent, ToolEventData}; use tokio::sync::{broadcast, mpsc}; @@ -240,14 +241,15 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res turn_id, tool_event: ToolEventData::Started { - tool_id, - tool_name, + identity, params, .. }, .. } = &event { + let (tool_name, params) = effective_tool_invocation(&identity.tool_name, params); + debug_assert_eq!(identity.effective_name(), tool_name); if tool_name == "Task" && params .get("run_in_background") @@ -256,7 +258,7 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res { state.turns.record_background_task_call( &PeerTurnKey::new(session_id, turn_id), - tool_id.clone(), + identity.tool_id.clone(), )?; } else if tool_name == "Task" && params.get("action").and_then(serde_json::Value::as_str) == Some("cancel") @@ -266,7 +268,7 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res { state.turns.record_background_task_cancellation( &PeerTurnKey::new(session_id, turn_id), - tool_id.clone(), + identity.tool_id.clone(), target_session_id.to_string(), )?; } @@ -282,12 +284,11 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res { let terminal_task_call = match tool_event { ToolEventData::Completed { - tool_id, - tool_name, + identity, result, .. - } if tool_name == "Task" => Some(( - tool_id.as_str(), + } if identity.effective_name() == "Task" => Some(( + identity.tool_id.as_str(), result .get("background_task_id") .and_then(serde_json::Value::as_str), @@ -296,11 +297,11 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res .and_then(serde_json::Value::as_u64), )), ToolEventData::Failed { - tool_id, tool_name, .. + identity, .. } | ToolEventData::Cancelled { - tool_id, tool_name, .. - } if tool_name == "Task" => Some((tool_id.as_str(), None, None)), + identity, .. + } if identity.effective_name() == "Task" => Some((identity.tool_id.as_str(), None, None)), _ => None, }; if let Some((tool_id, background_task_id, cancelled_background_tasks)) = terminal_task_call @@ -317,13 +318,13 @@ async fn handle_agentic_event(state: &PeerHostState, event: AgenticEvent) -> Res if let AgenticEvent::ToolEvent { session_id, turn_id, - tool_event: ToolEventData::ConfirmationNeeded { tool_id, .. }, + tool_event: ToolEventData::ConfirmationNeeded { identity, .. }, .. } = &event { state .turns - .record_confirmation(&PeerTurnKey::new(session_id, turn_id), tool_id.clone())?; + .record_confirmation(&PeerTurnKey::new(session_id, turn_id), identity.tool_id.clone())?; } let Some(projected) = project_agentic_frontend_event(event) else { diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs b/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs index a8b8750c68..133cb34698 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs +++ b/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs @@ -34,9 +34,9 @@ async fn openai_fixture_keeps_collecting_tool_args_across_usage_chunks() { event, AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, + tool_event: ToolEventData::EarlyDetected { identity }, .. - } if round_id == "round_fixture" && tool_id == "call_1" && tool_name == "tool_a" + } if round_id == "round_fixture" && identity.tool_id == "call_1" && identity.tool_name == "tool_a" ) }); assert!(early_detected, "expected early tool detection event"); @@ -122,11 +122,11 @@ async fn openai_fixture_keeps_malformed_tool_arguments_invalid() { .filter_map(|event| match event { AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, .. }, + tool_event: ToolEventData::EarlyDetected { identity }, .. } => { assert_eq!(round_id, "round_fixture"); - Some(tool_id.as_str()) + Some(identity.tool_id.as_str()) } _ => None, }) @@ -255,9 +255,9 @@ async fn openai_fixture_reattaches_id_only_prelude_to_following_payload_chunk() event, AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, + tool_event: ToolEventData::EarlyDetected { identity }, .. - } if round_id == "round_fixture" && tool_id == "call_1" && tool_name == "tool_a" + } if round_id == "round_fixture" && identity.tool_id == "call_1" && identity.tool_name == "tool_a" ) }); assert!( @@ -398,11 +398,11 @@ async fn openai_fixture_filters_orphan_id_only_block_when_it_shares_chunk_with_f .filter_map(|event| match event { AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, .. }, + tool_event: ToolEventData::EarlyDetected { identity }, .. } => { assert_eq!(round_id, "round_fixture"); - Some(tool_id.as_str()) + Some(identity.tool_id.as_str()) } _ => None, }) @@ -417,12 +417,12 @@ async fn openai_fixture_filters_orphan_id_only_block_when_it_shares_chunk_with_f round_id, tool_event: ToolEventData::ParamsPartial { - tool_id, params, .. + identity, params, .. }, .. } => { assert_eq!(round_id, "round_fixture"); - Some((tool_id.as_str(), params.as_str())) + Some((identity.tool_id.as_str(), params.as_str())) } _ => None, }) @@ -471,11 +471,11 @@ async fn openai_fixture_routes_interleaved_tool_args_by_index() { .filter_map(|event| match event { AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, .. }, + tool_event: ToolEventData::EarlyDetected { identity }, .. } => { assert_eq!(round_id, "round_fixture"); - Some(tool_id.as_str()) + Some(identity.tool_id.as_str()) } _ => None, }) @@ -490,12 +490,12 @@ async fn openai_fixture_routes_interleaved_tool_args_by_index() { round_id, tool_event: ToolEventData::ParamsPartial { - tool_id, params, .. + identity, params, .. }, .. } => { assert_eq!(round_id, "round_fixture"); - Some((tool_id.as_str(), params.as_str())) + Some((identity.tool_id.as_str(), params.as_str())) } _ => None, }) @@ -532,9 +532,9 @@ async fn openai_fixture_accepts_tool_call_without_type_field() { event, AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, + tool_event: ToolEventData::EarlyDetected { identity }, .. - } if round_id == "round_fixture" && tool_id == "call_abc123" && tool_name == "test_tool" + } if round_id == "round_fixture" && identity.tool_id == "call_abc123" && identity.tool_name == "test_tool" ) }); assert!( @@ -573,11 +573,11 @@ async fn openai_fixture_ignores_trailing_empty_tool_args_finish_chunk() { .filter_map(|event| match event { AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, .. }, + tool_event: ToolEventData::EarlyDetected { identity }, .. } => { assert_eq!(round_id, "round_fixture"); - Some(tool_id.as_str()) + Some(identity.tool_id.as_str()) } _ => None, }) diff --git a/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs b/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs index e7206c1a54..80b96afc88 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs +++ b/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs @@ -96,12 +96,12 @@ async fn replays_structurally_empty_openai_reasoning_content_with_tool_call() { round_id, tool_event: ToolEventData::ParamsPartial { - tool_id, params, .. + identity, params, .. }, .. } => { assert_eq!(round_id, "round_fixture"); - Some((tool_id.as_str(), params.as_str())) + Some((identity.tool_id.as_str(), params.as_str())) } _ => None, }) @@ -185,9 +185,9 @@ async fn replays_structurally_empty_anthropic_thinking_with_signature_and_tool_u event, AgenticEvent::ToolEvent { round_id, - tool_event: ToolEventData::EarlyDetected { tool_id, tool_name }, + tool_event: ToolEventData::EarlyDetected { identity }, .. - } if round_id == "round_fixture" && tool_id == "toolu_ds_1" && tool_name == "lookup_status" + } if round_id == "round_fixture" && identity.tool_id == "toolu_ds_1" && identity.tool_name == "lookup_status" ) }); assert!( diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/hidden/code_review.rs b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/code_review.rs index 31681c9a02..9660771c17 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/hidden/code_review.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/code_review.rs @@ -12,7 +12,7 @@ pub struct CodeReviewAgent { impl CodeReviewAgent { pub fn new() -> Self { let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); - tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Direct); Self { default_tools: vec![ @@ -91,7 +91,7 @@ mod tests { assert!(tools.contains(&"GetFileDiff".to_string())); assert_eq!( agent.tool_exposure_overrides().get("GetFileDiff"), - Some(&ToolExposure::Expanded), + Some(&ToolExposure::Direct), ); assert!(tools.contains(&"submit_code_review".to_string())); assert!(agent.description().contains("one isolated instance")); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/hidden/deep_review.rs b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/deep_review.rs index e421a7040e..cad6b13608 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/hidden/deep_review.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/hidden/deep_review.rs @@ -16,7 +16,7 @@ impl Default for DeepReviewAgent { impl DeepReviewAgent { pub fn new() -> Self { let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); - tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Direct); Self { default_tools: vec![ @@ -89,7 +89,7 @@ mod tests { assert!(!tools.contains(&"Task".to_string())); assert_eq!( agent.tool_exposure_overrides().get("GetFileDiff"), - Some(&ToolExposure::Expanded), + Some(&ToolExposure::Direct), ); assert!(tools.contains(&"submit_code_review".to_string())); assert!(!tools.contains(&"AskUserQuestion".to_string())); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/agentic.rs index cde2c4c8ff..a8ed1251c6 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/agentic.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/agentic.rs @@ -111,8 +111,8 @@ mod tests { let mode = AgenticMode::new(); let overrides = mode.tool_exposure_overrides(); - assert_eq!(overrides.get("WebSearch"), Some(&ToolExposure::Expanded)); - assert_eq!(overrides.get("WebFetch"), Some(&ToolExposure::Expanded)); + assert_eq!(overrides.get("WebSearch"), Some(&ToolExposure::Direct)); + assert_eq!(overrides.get("WebFetch"), Some(&ToolExposure::Direct)); assert!(mode.default_tools().contains(&"WebSearch".to_string())); assert!(mode.default_tools().contains(&"WebFetch".to_string())); } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 1da4d2edb4..d09e1c841b 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -22,8 +22,8 @@ impl CoworkMode { // Cowork is the office/research mode; web research is baseline there, // so keep WebSearch/WebFetch expanded (same as DeepResearch). let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); - tool_exposure_overrides.insert("WebSearch".to_string(), ToolExposure::Expanded); - tool_exposure_overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("WebSearch".to_string(), ToolExposure::Direct); + tool_exposure_overrides.insert("WebFetch".to_string(), ToolExposure::Direct); Self { tool_exposure_overrides, default_tools: vec![ diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs index ef432383af..eba385af18 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -16,8 +16,8 @@ impl Default for DeepResearchMode { impl DeepResearchMode { pub fn new() -> Self { let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); - tool_exposure_overrides.insert("WebSearch".to_string(), ToolExposure::Expanded); - tool_exposure_overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("WebSearch".to_string(), ToolExposure::Direct); + tool_exposure_overrides.insert("WebFetch".to_string(), ToolExposure::Direct); Self { default_tools: vec![ "Task".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs index f1124202b6..8e90ecc666 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_fixer.rs @@ -16,8 +16,8 @@ impl Default for ReviewFixerAgent { impl ReviewFixerAgent { pub fn new() -> Self { let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); - tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Expanded); - tool_exposure_overrides.insert("Git".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("GetFileDiff".to_string(), ToolExposure::Direct); + tool_exposure_overrides.insert("Git".to_string(), ToolExposure::Direct); Self { default_tools: vec![ "Read".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs index d946e1f75e..e15c2b91ba 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/review/review_specialists.rs @@ -9,7 +9,7 @@ use crate::define_readonly_subagent_with_overrides; fn reviewer_tool_exposure_overrides() -> AgentToolPolicyOverrides { let mut overrides = AgentToolPolicyOverrides::default(); - overrides.insert("GetFileDiff".to_string(), ToolExposure::Expanded); + overrides.insert("GetFileDiff".to_string(), ToolExposure::Direct); overrides } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/computer_use.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/computer_use.rs index ea7a4b354b..57a919ba30 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/computer_use.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/computer_use.rs @@ -20,8 +20,8 @@ impl Default for ComputerUseMode { impl ComputerUseMode { pub fn new() -> Self { let mut tool_exposure_overrides = AgentToolPolicyOverrides::default(); - tool_exposure_overrides.insert("ControlHub".to_string(), ToolExposure::Expanded); - tool_exposure_overrides.insert("ComputerUse".to_string(), ToolExposure::Expanded); + tool_exposure_overrides.insert("ControlHub".to_string(), ToolExposure::Direct); + tool_exposure_overrides.insert("ComputerUse".to_string(), ToolExposure::Direct); Self { default_tools: vec![ "AskUserQuestion".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 4c3ca32edf..6f6cba3e6b 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -67,8 +67,8 @@ pub fn shared_coding_mode_tool_exposure_overrides() -> AgentToolPolicyOverrides // WebSearch/WebFetch expanded so models do not need a GetToolSpec // unlock round-trip when switching between those modes. let mut overrides = AgentToolPolicyOverrides::default(); - overrides.insert("WebSearch".to_string(), ToolExposure::Expanded); - overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); + overrides.insert("WebSearch".to_string(), ToolExposure::Direct); + overrides.insert("WebFetch".to_string(), ToolExposure::Direct); overrides } diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index 770c387338..4778c9ef59 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -312,10 +312,10 @@ impl PromptBuilder { .render_agent_listing_reminder() } - pub fn build_collapsed_tool_listing_reminder(&self) -> Option { + pub fn build_deferred_tool_listing_reminder(&self) -> Option { self.context .tool_listing_sections - .render_collapsed_tool_listing_reminder() + .render_deferred_tool_listing_reminder() } pub async fn build_user_context_reminder(&self, policy: &UserContextPolicy) -> Option { @@ -391,7 +391,7 @@ impl PromptBuilder { user_context_policy: &UserContextPolicy, ) -> PrependedPromptReminders { PrependedPromptReminders { - collapsed_tool_listing: self.build_collapsed_tool_listing_reminder(), + deferred_tool_listing: self.build_deferred_tool_listing_reminder(), skill_listing: self.build_skill_listing_reminder(), agent_listing: self.build_agent_listing_reminder(), runtime_context: self.build_runtime_context_reminder().await, @@ -617,8 +617,8 @@ mod tests { let tool_sections = ToolListingSections { skill_listing: Some("\n- pdf\n".to_string()), agent_listing: Some("\n- Explore\n".to_string()), - collapsed_tool_listing: Some( - "\n- WebFetch\n".to_string(), + deferred_tool_listing: Some( + "\n- WebFetch\n".to_string(), ), }; let context = PromptBuilderContext::new(r"workspace\root", None, None) @@ -640,9 +640,9 @@ mod tests { let agent_listing = reminders .agent_listing .expect("agent listing reminder should build"); - let collapsed_tool_listing = reminders - .collapsed_tool_listing - .expect("collapsed tool listing reminder should build"); + let deferred_tool_listing = reminders + .deferred_tool_listing + .expect("deferred tool listing reminder should build"); let user_context = reminders.user_context.expect("user context should build"); let runtime_context = reminders .runtime_context @@ -655,9 +655,10 @@ mod tests { assert!(!skill_listing.contains("# Agent Listing")); assert!(agent_listing.contains("# Agent Listing")); assert!(agent_listing.contains("")); - assert!(!agent_listing.contains("# Collapsed Tool Listing")); - assert!(collapsed_tool_listing.contains("# Collapsed Tool Listing")); - assert!(collapsed_tool_listing.contains("")); + assert!(!agent_listing.contains("# Tool Calling Guide")); + assert!(deferred_tool_listing.contains("# Tool Calling Guide")); + assert!(deferred_tool_listing.contains("## Deferred Tool Listing")); + assert!(deferred_tool_listing.contains("")); assert!(user_context.contains("# User Context")); assert!(user_context.contains("As you answer the user's questions")); assert!(user_context.contains("Current Working Directory: workspace/root")); @@ -671,7 +672,7 @@ mod tests { assert_eq!( ordered_reminders, vec![ - collapsed_tool_listing.as_str(), + deferred_tool_listing.as_str(), skill_listing.as_str(), agent_listing.as_str(), runtime_context.as_str(), @@ -689,7 +690,7 @@ mod tests { assert_eq!(reminders.skill_listing, None); assert_eq!(reminders.agent_listing, None); - assert_eq!(reminders.collapsed_tool_listing, None); + assert_eq!(reminders.deferred_tool_listing, None); assert_eq!(reminders.user_context, None); assert_eq!(reminders.runtime_context, None); } diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md b/src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md index 51e9df7ac0..81dbcf055a 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/plan_mode_first_entry_reminder.md @@ -35,7 +35,7 @@ Use `AskUserQuestion` whenever missing information would materially change the p # Plan Creation And Updates -Note: The `CreatePlan` tool is collapsed by default. Before your first calling `CreatePlan`, call `GetToolSpec(tool_name="CreatePlan")` to read its full usage instructions and input schema. +Note: Follow the `CreatePlan` definition currently visible in the tool list. If it appears in the Deferred Tool Listing, load its schema with `GetToolSpec(tool_name="CreatePlan")` before using it, then execute it through `CallDeferredTool` with `tool_name` set to `CreatePlan` and its arguments inside `args`. Otherwise, call `CreatePlan` directly with arguments matching its visible schema. 1. When research is complete, create the implementation plan with `CreatePlan` tool. Do NOT make any file changes or run any tools that modify the system state in any way. 2. After `CreatePlan` succeeds, stop further research for that turn and briefly tell the user the plan is ready. Your response for that turn must include the clickable plan link returned by the tool. diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index 58760b9e81..7d9e940087 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -38,6 +38,8 @@ pub enum MessageContent { ToolResult { tool_id: String, tool_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + effective_tool_name: Option, result: serde_json::Value, result_for_assistant: Option, is_error: bool, @@ -346,6 +348,7 @@ impl From for AIMessage { MessageContent::ToolResult { tool_id, tool_name, + effective_tool_name: _, result, result_for_assistant, is_error, @@ -484,6 +487,7 @@ impl Message { content: MessageContent::ToolResult { tool_id: result.tool_id.clone(), tool_name: result.tool_name.clone(), + effective_tool_name: result.effective_tool_name.clone(), result: result.result.clone(), result_for_assistant: result.result_for_assistant.clone(), is_error: result.is_error, @@ -667,6 +671,7 @@ impl Display for MessageContent { MessageContent::ToolResult { tool_id, tool_name, + effective_tool_name: _, result, result_for_assistant, is_error, @@ -762,7 +767,11 @@ impl From for ToolCall { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolResult { pub tool_id: String, + /// Provider-facing tool name. Deferred calls retain the gateway name. pub tool_name: String, + /// Runtime target for internal persistence, classification, and UI projection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_tool_name: Option, pub result: serde_json::Value, /// Result text specifically for passing to AI assistant (if None, then use result) pub result_for_assistant: Option, diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 615e10a906..3f6f26c308 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -30,7 +30,7 @@ use crate::agentic::session::{ use crate::agentic::skill_agent_snapshot::build_skill_agent_tool_listing_sections_from_snapshot; use crate::agentic::tools::implementations::{SkillTool, TaskTool}; use crate::agentic::tools::product_runtime::{ - collect_product_unlocked_collapsed_tools, GetToolSpecTool, + collect_product_loaded_deferred_tool_specs, GetToolSpecTool, }; use crate::agentic::tools::{ resolve_tool_manifest, tool_context_runtime, ResolvedToolManifest, ToolRuntimeRestrictions, @@ -949,9 +949,9 @@ impl ExecutionEngine { } else { None }, - collapsed_tool_listing: if has_tool_definition("GetToolSpec") { - GetToolSpecTool::build_collapsed_tools_context_section( - &manifest.collapsed_tool_summaries, + deferred_tool_listing: if has_tool_definition("GetToolSpec") { + GetToolSpecTool::build_deferred_tools_context_section( + &manifest.deferred_tool_summaries, ) } else { None @@ -1077,7 +1077,7 @@ impl ExecutionEngine { let runtime_context = prompt_builder.build_runtime_context_reminder().await; PrependedPromptReminders { - collapsed_tool_listing: prompt_builder.build_collapsed_tool_listing_reminder(), + deferred_tool_listing: prompt_builder.build_deferred_tool_listing_reminder(), skill_listing: baseline_tool_sections .as_ref() .and_then(|sections| sections.render_skill_listing_reminder()), @@ -1183,7 +1183,7 @@ impl ExecutionEngine { prepended_prompt_reminders: &PrependedPromptReminders, ) { debug!( - "Turn prompt scaffold resolved: session_id={}, turn_id={}, stage={}, system_prompt_len={} bytes, skill_listing_len={}, agent_listing_len={}, collapsed_tool_listing_len={}, user_context_len={}, runtime_context_len={}", + "Turn prompt scaffold resolved: session_id={}, turn_id={}, stage={}, system_prompt_len={} bytes, skill_listing_len={}, agent_listing_len={}, deferred_tool_listing_len={}, user_context_len={}, runtime_context_len={}", session_id, turn_id, stage, @@ -1199,7 +1199,7 @@ impl ExecutionEngine { .map(|text| text.len()) .unwrap_or(0), prepended_prompt_reminders - .collapsed_tool_listing + .deferred_tool_listing .as_ref() .map(|text| text.len()) .unwrap_or(0), @@ -1377,8 +1377,8 @@ impl ExecutionEngine { workspace: input.context.workspace.clone(), model_exchange_trace_dir, available_tools: finalize_tool_names, - collapsed_tools: Vec::new(), - unlocked_collapsed_tools: Vec::new(), + deferred_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), model_name: input.ai_client.config.model.clone(), primary_model_facts: input.primary_model_facts.clone(), agent_type: input.agent_type, @@ -1865,8 +1865,8 @@ impl ExecutionEngine { }) .unwrap_or_default(); // Snapshot prompt-visible tool definitions once for this turn. Do not - // re-resolve or rewrite them after GetToolSpec unlocks a collapsed tool: - // the unlocked detail travels in tool results, while mutating the tool + // re-resolve or rewrite them after GetToolSpec loads a deferred tool spec: + // the loaded detail travels in tool results, while mutating the tool // definitions would change the request prefix and trigger provider // prefix/KV cache misses on subsequent rounds. let tool_definitions = tool_manifest.map(|manifest| manifest.tool_definitions); @@ -2646,7 +2646,20 @@ impl ExecutionEngine { .get("enable_tools") .and_then(|v| v.parse::().ok()) .unwrap_or(true); - let tool_manifest_context_vars = context.context.clone(); + let deferred_tool_loading_enabled = match get_global_config_service().await { + Ok(service) => service + .get_config::(Some("ai.enable_deferred_tool_loading")) + .await + .unwrap_or(true), + Err(_) => true, + }; + let mut execution_context_vars = context.context.clone(); + execution_context_vars.insert( + "enable_deferred_tool_loading".to_string(), + deferred_tool_loading_enabled.to_string(), + ); + execution_context_vars.insert("turn_index".to_string(), context.turn_index.to_string()); + let tool_manifest_context_vars = execution_context_vars.clone(); let tool_description_context = tool_context_runtime::build_tool_description_context( &agent_type, @@ -2673,9 +2686,9 @@ impl ExecutionEngine { } else { None }; - let collapsed_tools = tool_manifest + let deferred_tools = tool_manifest .as_ref() - .map(|manifest| manifest.collapsed_tool_names.clone()) + .map(|manifest| manifest.deferred_tool_names.clone()) .unwrap_or_default(); let tool_listing_sections = if let Some(manifest) = tool_manifest.as_ref() { Self::build_tool_listing_sections(manifest, &tool_description_context).await @@ -2708,7 +2721,7 @@ impl ExecutionEngine { }; let final_tool_names = Self::finalize_tool_names(tool_definitions.as_deref()); debug!( - "Primary model and tool manifest resolved: session_id={}, turn_id={}, resolved_primary_model_id={}, primary_model_api_format={}, primary_model_supports_image_inputs={}, final_tool_count={}, final_tool_names={:?}, collapsed_tool_names={:?}", + "Primary model and tool manifest resolved: session_id={}, turn_id={}, resolved_primary_model_id={}, primary_model_api_format={}, primary_model_supports_image_inputs={}, final_tool_count={}, final_tool_names={:?}, deferred_tool_names={:?}", context.session_id, context.dialog_turn_id, primary_model_facts.model_id, @@ -2716,7 +2729,7 @@ impl ExecutionEngine { primary_model_facts.supports_image_inputs, final_tool_names.len(), final_tool_names, - collapsed_tools, + deferred_tools, ); // 4. Resolve the prompt scaffold used by model requests in this turn. @@ -2787,9 +2800,6 @@ impl ExecutionEngine { let compression_trigger_budget = Self::compression_trigger_budget(context_window, ai_client.config.max_tokens); - let mut execution_context_vars = context.context.clone(); - execution_context_vars.insert("turn_index".to_string(), context.turn_index.to_string()); - // If the primary model is text-only, do not send image payloads to the provider. // Instead, keep a text-only placeholder (including `image_id`). if !primary_supports_image_understanding { @@ -3094,8 +3104,8 @@ impl ExecutionEngine { if context.skip_tool_confirmation { round_context_vars.insert("skip_tool_confirmation".to_string(), "true".to_string()); } - let unlocked_collapsed_tools = - collect_product_unlocked_collapsed_tools(&messages, &collapsed_tools); + let loaded_deferred_tool_specs = + collect_product_loaded_deferred_tool_specs(&messages, &deferred_tools); let model_exchange_trace_dir = self .session_manager @@ -3111,8 +3121,8 @@ impl ExecutionEngine { workspace: context.workspace.clone(), model_exchange_trace_dir, available_tools: available_tools.clone(), - collapsed_tools: collapsed_tools.clone(), - unlocked_collapsed_tools, + deferred_tools: deferred_tools.clone(), + loaded_deferred_tool_specs, model_name: ai_client.config.model.clone(), primary_model_facts: primary_model_facts.clone(), agent_type: agent_type.clone(), @@ -4347,6 +4357,7 @@ mod tests { let results = vec![Message::tool_result(ToolResult { tool_id: "tool-1".to_string(), tool_name: "PollStatus".to_string(), + effective_tool_name: None, result: json!({ "status": "pending", "success": true }), result_for_assistant: Some("The job is still pending.".to_string()), is_error: false, @@ -4373,6 +4384,7 @@ mod tests { let results = vec![Message::tool_result(ToolResult { tool_id: "tool-1".to_string(), tool_name: "Read".to_string(), + effective_tool_name: None, result: json!({ "success": false, "error": "not found" }), result_for_assistant: Some("File not found.".to_string()), is_error: true, @@ -4522,6 +4534,7 @@ mod tests { Message::tool_result(ToolResult { tool_id: format!("{}-tool", tool_name), tool_name: tool_name.to_string(), + effective_tool_name: None, result: json!({ "success": success, "exit_code": exit_code, diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 4bded3b12f..636d8517ef 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -16,7 +16,7 @@ use crate::agentic::tools::pipeline::{ SubagentBatchExecutionPolicy as PipelineSubagentBatchExecutionPolicy, ToolExecutionContext, ToolExecutionOptions, ToolPipeline, }; -use crate::agentic::tools::registry::get_global_tool_registry; +use crate::agentic::tools::registry::{get_global_tool_registry, ToolRegistry}; use crate::agentic::tools::tool_context_runtime; use crate::agentic::tools::tool_result_storage; use crate::agentic::MessageContent; @@ -32,6 +32,7 @@ use bitfun_agent_runtime::tool_confirmation::{ ToolConfirmationPolicyGateFacts, }; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; +use bitfun_agent_tools::ResolvedToolInvocation; use bitfun_ai_adapters::{ ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, ModelExchangeTraceConfig, }; @@ -40,6 +41,21 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; +fn tool_call_needs_permission(registry: &ToolRegistry, tool_call: &ToolCall) -> bool { + let invocation = ResolvedToolInvocation::from_wire_call( + tool_call.tool_name.clone(), + tool_call.arguments.clone(), + ) + .unwrap_or_else(|_| { + ResolvedToolInvocation::direct(tool_call.tool_name.clone(), tool_call.arguments.clone()) + }); + + registry + .get_tool(&invocation.effective_tool_name) + .map(|tool| tool.needs_permissions(Some(&invocation.effective_arguments))) + .unwrap_or(false) +} + /// Round executor pub struct RoundExecutor { stream_processor: Arc, @@ -726,8 +742,8 @@ impl RoundExecutor { context_vars: context.context_vars.clone(), subagent_parent_info, delegation_policy: context.delegation_policy, - collapsed_tools: context.collapsed_tools.clone(), - unlocked_collapsed_tools: context.unlocked_collapsed_tools.clone(), + deferred_tools: context.deferred_tools.clone(), + loaded_deferred_tool_specs: context.loaded_deferred_tool_specs.clone(), allowed_tools, runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), steering_interrupt: context.steering_interrupt.clone(), @@ -801,12 +817,10 @@ impl RoundExecutor { let registry = get_global_tool_registry(); let tool_registry = registry.read().await; - stream_result.tool_calls.iter().any(|tool_call| { - tool_registry - .get_tool(&tool_call.tool_name) - .map(|tool| tool.needs_permissions(Some(&tool_call.arguments))) - .unwrap_or(false) - }) + stream_result + .tool_calls + .iter() + .any(|tool_call| tool_call_needs_permission(&tool_registry, tool_call)) }; let needs_confirm = resolve_tool_confirmation_policy_gate(ToolConfirmationPolicyGateFacts { @@ -854,9 +868,11 @@ impl RoundExecutor { .map(|tc| crate::agentic::tools::pipeline::ToolExecutionResult { tool_id: tc.tool_id.clone(), tool_name: tc.tool_name.clone(), + effective_tool_name: tc.tool_name.clone(), result: crate::agentic::core::ToolResult { tool_id: tc.tool_id.clone(), tool_name: tc.tool_name.clone(), + effective_tool_name: None, result: serde_json::json!({ "error": e.to_string(), "message": format!("Tool pipeline execution failed: {}", e) @@ -873,7 +889,15 @@ impl RoundExecutor { }; // Convert to ToolResult, then enforce the aggregate budget for this model round. - let tool_results = execution_results.into_iter().map(|r| r.result).collect(); + let tool_results = execution_results + .into_iter() + .map(|mut execution_result| { + execution_result.result.effective_tool_name = (execution_result.tool_name + != execution_result.effective_tool_name) + .then_some(execution_result.effective_tool_name); + execution_result.result + }) + .collect(); tool_result_storage::apply_round_tool_result_budget(tool_results, &storage_context) .await } else { @@ -1062,8 +1086,10 @@ impl RoundExecutor { attempt_id: None, attempt_index: None, tool_event: ToolEventData::Failed { - tool_id: tool_call.tool_id.clone(), - tool_name: tool_call.tool_name.clone(), + identity: bitfun_events::ToolEventIdentity::direct( + tool_call.tool_id.clone(), + tool_call.tool_name.clone(), + ), error: format!("Tool arguments stream interrupted: {}", error), duration_ms: None, queue_wait_ms: None, @@ -1328,11 +1354,12 @@ fn token_details_from_usage( #[cfg(test)] mod tests { - use super::{RoundExecutor, StreamProcessor}; + use super::{tool_call_needs_permission, RoundExecutor, StreamProcessor}; use crate::agentic::core::ToolCall; use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::execution::stream_processor::StreamResult; use crate::agentic::execution::types::RoundContext; + use crate::agentic::tools::registry::create_tool_registry; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; @@ -1354,6 +1381,24 @@ mod tests { } } + #[test] + fn deferred_permission_gate_uses_effective_target() { + let registry = create_tool_registry(); + let call = ToolCall { + tool_id: "tool-1".to_string(), + tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), + arguments: json!({ + "tool_name": "Write", + "args": { "payload": "+++ file.txt\ncontent" } + }), + raw_arguments: None, + is_error: false, + recovered_from_truncation: false, + }; + + assert!(tool_call_needs_permission(®istry, &call)); + } + fn test_round_context() -> RoundContext { RoundContext { session_id: "session-1".to_string(), @@ -1365,8 +1410,8 @@ mod tests { workspace: None, model_exchange_trace_dir: None, available_tools: Vec::new(), - collapsed_tools: Vec::new(), - unlocked_collapsed_tools: Vec::new(), + deferred_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), model_name: "model-1".to_string(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::new( "model-1", "model-1", "openai", true, diff --git a/src/crates/assembly/core/src/agentic/execution/types.rs b/src/crates/assembly/core/src/agentic/execution/types.rs index 8ca096f083..fb41e589c0 100644 --- a/src/crates/assembly/core/src/agentic/execution/types.rs +++ b/src/crates/assembly/core/src/agentic/execution/types.rs @@ -7,6 +7,7 @@ use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; pub use bitfun_agent_runtime::events::FinishReason; +use bitfun_agent_tools::LoadedDeferredToolSpec; use bitfun_runtime_ports::{DelegationPolicy, RemoteExecPort, TerminalPort}; use serde_json::Value; use std::collections::HashMap; @@ -56,8 +57,8 @@ pub struct RoundContext { pub workspace: Option, pub model_exchange_trace_dir: Option, pub available_tools: Vec, - pub collapsed_tools: Vec, - pub unlocked_collapsed_tools: Vec, + pub deferred_tools: Vec, + pub loaded_deferred_tool_specs: Vec, pub model_name: String, pub primary_model_facts: PrimaryModelFacts, pub agent_type: String, diff --git a/src/crates/assembly/core/src/agentic/insights/collector.rs b/src/crates/assembly/core/src/agentic/insights/collector.rs index 50c1ff2e63..699400aca5 100644 --- a/src/crates/assembly/core/src/agentic/insights/collector.rs +++ b/src/crates/assembly/core/src/agentic/insights/collector.rs @@ -3,8 +3,9 @@ use crate::agentic::insights::session_paths::collect_effective_session_storage_r use crate::agentic::insights::types::*; use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::get_path_manager_arc; -use crate::service::session::{DialogTurnData, TurnStatus}; +use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; use crate::util::errors::BitFunResult; +use bitfun_agent_tools::ResolvedToolInvocation; use chrono::{DateTime, Utc}; use log::{debug, warn}; use std::collections::{HashMap, HashSet}; @@ -18,6 +19,19 @@ const TAIL_RESERVE_CHARS: usize = 4000; /// from both active duration and response time calculations. const ACTIVITY_GAP_THRESHOLD_SECS: u64 = 30 * 60; +fn effective_tool_call_name(tool_call: &ToolCall) -> String { + ResolvedToolInvocation::from_wire_call(tool_call.tool_name.clone(), tool_call.arguments.clone()) + .map(|invocation| invocation.effective_tool_name) + .unwrap_or_else(|_| tool_call.tool_name.clone()) +} + +fn effective_tool_result_name<'a>( + wire_tool_name: &'a str, + effective_tool_name: &'a Option, +) -> &'a str { + effective_tool_name.as_deref().unwrap_or(wire_tool_name) +} + pub struct InsightsCollector; impl InsightsCollector { @@ -184,20 +198,25 @@ impl InsightsCollector { all_parts.push(format!("[Assistant]: {}", truncated)); } for tc in tool_calls { - if !tool_names.contains(&tc.tool_name) { - tool_names.push(tc.tool_name.clone()); + let tool_name = effective_tool_call_name(tc); + if !tool_names.contains(&tool_name) { + tool_names.push(tool_name.clone()); } - all_parts.push(format!("[Tool: {}]", tc.tool_name)); + all_parts.push(format!("[Tool: {}]", tool_name)); } } MessageContent::ToolResult { tool_name, + effective_tool_name, is_error, .. } => { if *is_error { has_errors = true; - all_parts.push(format!("[Tool Error: {}]", tool_name)); + all_parts.push(format!( + "[Tool Error: {}]", + effective_tool_result_name(tool_name, effective_tool_name) + )); } } MessageContent::Multimodal { text, .. } => { @@ -260,18 +279,22 @@ impl InsightsCollector { match &msg.content { MessageContent::Mixed { tool_calls, .. } => { for tc in tool_calls { - *base_stats - .tool_usage - .entry(tc.tool_name.clone()) - .or_insert(0) += 1; + let tool_name = effective_tool_call_name(tc); + *base_stats.tool_usage.entry(tool_name).or_insert(0) += 1; } } MessageContent::ToolResult { tool_name, + effective_tool_name, is_error: true, .. } => { - *base_stats.tool_errors.entry(tool_name.clone()).or_insert(0) += 1; + *base_stats + .tool_errors + .entry( + effective_tool_result_name(tool_name, effective_tool_name).to_string(), + ) + .or_insert(0) += 1; } _ => {} } @@ -472,9 +495,12 @@ fn rebuild_messages_from_turns(turns: &[DialogTurnData]) -> Vec { for ti in &round.tool_items { if let Some(result_data) = &ti.tool_result { + let effective_tool_name = ti.effective_name(); let mut msg = Message::tool_result(ToolResult { tool_id: ti.tool_call.id.clone(), tool_name: ti.tool_name.clone(), + effective_tool_name: (effective_tool_name != ti.tool_name) + .then(|| effective_tool_name.to_string()), result: result_data.result.clone(), result_for_assistant: None, is_error: !result_data.success, @@ -659,7 +685,7 @@ fn accumulate_code_stats_from_turns(base_stats: &mut BaseStats, turns: &[DialogT continue; } - match ti.tool_name.as_str() { + match ti.effective_name() { "Edit" => { let result = &result_data.result; @@ -705,7 +731,7 @@ fn accumulate_code_stats_from_turns(base_stats: &mut BaseStats, turns: &[DialogT { base_stats.total_lines_added += lines_written as usize; } else if let Some(content) = - ti.tool_call.input.get("content").and_then(|v| v.as_str()) + ti.effective_input().get("content").and_then(|v| v.as_str()) { base_stats.total_lines_added += content.lines().count().max(1); } diff --git a/src/crates/assembly/core/src/agentic/memories/external_context.rs b/src/crates/assembly/core/src/agentic/memories/external_context.rs index 343bce3b9e..f1d6d6f6a1 100644 --- a/src/crates/assembly/core/src/agentic/memories/external_context.rs +++ b/src/crates/assembly/core/src/agentic/memories/external_context.rs @@ -1,11 +1,11 @@ -use crate::service::session::DialogTurnData; +use crate::service::session::{DialogTurnData, ToolItemIdentityExt}; pub(crate) fn dialog_turn_uses_external_context(turn: &DialogTurnData) -> bool { turn.model_rounds.iter().any(|round| { round .tool_items .iter() - .any(|item| is_external_context_tool_name(&item.tool_name)) + .any(|item| is_external_context_tool_name(item.effective_name())) }) } diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index f3dbbd8ea1..2655154e0c 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -4,7 +4,7 @@ use crate::agentic::session::transcript_render::{ }; use crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME; use crate::service::config::types::MemoryExternalContextPolicy; -use crate::service::session::{DialogTurnData, ToolItemData}; +use crate::service::session::{DialogTurnData, ToolItemData, ToolItemIdentityExt}; use crate::util::errors::{BitFunError, BitFunResult}; use regex::Regex; use serde::Serialize; @@ -120,8 +120,8 @@ fn collect_memory_transcript_items( id: tool_call_id(tool), kind: "function", function: MemoryTranscriptToolFunction { - name: tool.tool_name.clone(), - arguments: serialize_tool_arguments(&tool.tool_call.input), + name: tool.effective_name().to_string(), + arguments: serialize_tool_arguments(tool.effective_input()), }, }) .collect::>(); @@ -140,7 +140,7 @@ fn collect_memory_transcript_items( if let Some(result) = tool.tool_result.as_ref() { messages.push(MemoryTranscriptMessage::Tool { role: "tool", - name: tool.tool_name.clone(), + name: tool.effective_name().to_string(), tool_call_id: tool_call_id(tool), content: truncate_middle_tokens( &memory_tool_result_content( @@ -179,11 +179,11 @@ fn memory_tool_result_content( let Some(result) = tool.tool_result.as_ref() else { return String::new(); }; - if tool.tool_name == GET_TOOL_SPEC_TOOL_NAME { + if tool.effective_name() == GET_TOOL_SPEC_TOOL_NAME { return "[cleared]".to_string(); } if external_context_policy == MemoryExternalContextPolicy::ClearToolResults - && is_external_context_tool_name(&tool.tool_name) + && is_external_context_tool_name(tool.effective_name()) { return "[external tool result cleared]".to_string(); } @@ -474,7 +474,7 @@ mod tests { #[test] fn memory_transcript_clears_get_tool_spec_results() { - let mut turn = base_turn("load a collapsed tool"); + let mut turn = base_turn("load a deferred tool"); let mut round = base_round(); round.tool_items.push(ToolItemData { id: "tool_1".to_string(), @@ -542,10 +542,13 @@ mod tests { let mut round = base_round(); round.tool_items.push(ToolItemData { id: "tool_1".to_string(), - tool_name: "WebFetch".to_string(), + tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), tool_call: ToolCallData { id: "call_1".to_string(), - input: json!({ "url": "https://example.test/preferences" }), + input: json!({ + "tool_name": "WebFetch", + "args": { "url": "https://example.test/preferences" } + }), }, tool_result: Some(ToolResultData { result: json!({ @@ -588,6 +591,7 @@ mod tests { .unwrap(); assert!(transcript.contains("\"function\":{\"name\":\"WebFetch\"")); + assert!(!transcript.contains(bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME)); assert!(transcript.contains("https://example.test/preferences")); assert!(transcript.contains("\"content\":\"[external tool result cleared]\"")); assert!( diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 18911de87b..3da1413a15 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -3470,6 +3470,7 @@ mod tests { Message::tool_result(ToolResult { tool_id: "tool-1".to_string(), tool_name: "Bash".to_string(), + effective_tool_name: None, result: serde_json::json!({ "output": "x".repeat(40) }), result_for_assistant: Some("assistant summary".to_string()), is_error: false, diff --git a/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs b/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs index 608912dd1e..a4bfb48279 100644 --- a/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs +++ b/src/crates/assembly/core/src/agentic/session/compression/fallback/tests.rs @@ -39,6 +39,7 @@ fn clears_tool_results_from_compressed_history() { let tool_result = Message::tool_result(ToolResult { tool_id: "tool_1".to_string(), tool_name: "Read".to_string(), + effective_tool_name: None, result: json!({"content": "ignored"}), result_for_assistant: Some("Read succeeded with file preview".to_string()), is_error: false, @@ -225,6 +226,7 @@ fn renders_contract_facts_even_when_tool_results_are_cleared() { vec![vec![Message::tool_result(ToolResult { tool_id: "tool_1".to_string(), tool_name: "Read".to_string(), + effective_tool_name: None, result: json!({"content": "large output omitted"}), result_for_assistant: Some("large output omitted".to_string()), is_error: false, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index fe63a4e46e..c09b570232 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -5845,7 +5845,8 @@ impl SessionManager { mod tests { use super::{CoreSessionStorePort, SessionManager, SessionManagerConfig}; use crate::agentic::core::{ - Message, MessageContent, MessageRole, ProcessingPhase, Session, SessionConfig, SessionState, + Message, MessageContent, MessageRole, ProcessingPhase, Session, SessionConfig, + SessionState, ToolCall, ToolResult, }; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::{ @@ -5894,6 +5895,67 @@ mod tests { } } + #[test] + fn persisted_round_preserves_deferred_wire_call_and_effective_identity() { + let assistant = Message::assistant_with_tools( + String::new(), + vec![ToolCall { + tool_id: "tool-1".to_string(), + tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), + arguments: json!({ + "tool_name": "WebFetch", + "args": { "url": "https://example.test" } + }), + raw_arguments: None, + is_error: false, + recovered_from_truncation: false, + }], + ) + .with_turn_id("turn-1".to_string()) + .with_round_id("round-1".to_string()); + let result = Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(), + effective_tool_name: Some("WebFetch".to_string()), + result: json!({ "content": "external content" }), + result_for_assistant: Some("external content".to_string()), + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }) + .with_turn_id("turn-1".to_string()) + .with_round_id("round-1".to_string()); + + let persisted_messages: Vec = serde_json::from_value( + serde_json::to_value(vec![assistant, result]).expect("serialize messages"), + ) + .expect("deserialize messages"); + let provider_result: crate::util::types::Message = (&persisted_messages[1]).into(); + assert_eq!( + provider_result.name.as_deref(), + Some(bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME) + ); + + let rounds = + SessionManager::build_model_rounds_from_messages(&persisted_messages, "turn-1", 1); + + assert_eq!(rounds.len(), 1); + assert_eq!(rounds[0].tool_items.len(), 1); + let tool = &rounds[0].tool_items[0]; + assert_eq!(tool.tool_name, bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME); + assert_eq!( + tool.tool_call.input, + json!({ + "tool_name": "WebFetch", + "args": { "url": "https://example.test" } + }) + ); + let (effective_name, effective_input) = + crate::service::session::effective_tool_identity(tool); + assert_eq!(effective_name, "WebFetch"); + assert_eq!(effective_input, &json!({ "url": "https://example.test" })); + } + impl Drop for TestWorkspace { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.path); diff --git a/src/crates/assembly/core/src/agentic/session/transcript_render.rs b/src/crates/assembly/core/src/agentic/session/transcript_render.rs index 1eeab08970..c16d0892e3 100644 --- a/src/crates/assembly/core/src/agentic/session/transcript_render.rs +++ b/src/crates/assembly/core/src/agentic/session/transcript_render.rs @@ -1,7 +1,7 @@ use crate::agentic::core::strip_prompt_markup; use crate::service::session::{ DialogTurnData, ModelRoundData, SessionTranscriptExportOptions, SessionTranscriptIndexEntry, - ToolItemData, TranscriptLineRange, + ToolItemData, ToolItemIdentityExt, TranscriptLineRange, }; use crate::util::errors::{BitFunError, BitFunResult}; use serde::Serialize; @@ -53,11 +53,11 @@ pub(crate) fn transcript_value_string(value: &serde_json::Value) -> String { } fn transcript_tool_input(item: &ToolItemData, tool_inputs: bool) -> Option { - if !tool_inputs || item.tool_call.input.is_null() { + if !tool_inputs || item.effective_input().is_null() { return None; } - Some(transcript_value_string(&item.tool_call.input)) + Some(transcript_value_string(item.effective_input())) } fn transcript_tool_result(item: &ToolItemData) -> Option { @@ -156,7 +156,7 @@ fn transcript_round_blocks( }) .filter(|item| !item.is_subagent_item.unwrap_or(false)) .map(|item| TranscriptToolBlock { - tool_name: item.tool_name.clone(), + tool_name: item.effective_name().to_string(), tool_input: transcript_tool_input(item, options.tool_inputs), result: transcript_tool_result(item), }) diff --git a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs index 0742723e5b..f3e9b5afc9 100644 --- a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs +++ b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs @@ -115,10 +115,8 @@ fn build_tool_listing_sections( agent_listing: has_tool("Task") .then(|| render_full_agent_listing_body(&snapshot.subagents)) .filter(|body| !body.is_empty()), - collapsed_tool_listing: if has_tool("GetToolSpec") { - GetToolSpecTool::build_collapsed_tools_context_section( - &manifest.collapsed_tool_summaries, - ) + deferred_tool_listing: if has_tool("GetToolSpec") { + GetToolSpecTool::build_deferred_tools_context_section(&manifest.deferred_tool_summaries) } else { None }, diff --git a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index ce1b2d6ff7..dd8180e42a 100644 --- a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -1,49 +1,52 @@ -## Current Tool Default Exposure / Collapse States and Agent Overrides +## Current Tool Default Exposure States and Agent Overrides Notes: -- "Default state" comes from `Tool::default_exposure()`. Tools that do not implement this method default to `Expanded`. +- "Default state" comes from `Tool::default_exposure()`. Tools that do not implement this method default to `Direct`. - "Overriding agents" only lists built-in agents that explicitly define `tool_exposure_overrides()` in the current code. - Custom subagents do not currently support independent exposure overrides and inherit the default behavior. +- Dynamically registered MCP tools default to `Deferred`; they are discovered at runtime and are not enumerated in the built-in table below. +- Global `ai.enable_deferred_tool_loading=false` overrides all allowed tools to `Direct` and removes `GetToolSpec` and `CallDeferredTool` from the model-visible manifest. **Tool Exposure Table** | Tool | Default State | Overridden By | Override State | |---|---|---|---| -| `LS` | Expanded | None | - | -| `Read` | Expanded | None | - | -| `Glob` | Expanded | None | - | -| `Grep` | Expanded | None | - | -| `Write` | Expanded | None | - | -| `Edit` | Expanded | None | - | -| `Delete` | Expanded | None | - | -| `ExecCommand` | Expanded | None | - | -| `WriteStdin` | Expanded | None | - | -| `ExecControl` | Expanded | None | - | -| `GetTime` | Expanded | None | - | -| `Task` | Expanded | None | - | -| `Skill` | Expanded | None | - | -| `AskUserQuestion` | Expanded | None | - | -| `TodoWrite` | Expanded | None | - | -| `CodeReview` | Expanded | None | - | -| `GetToolSpec` | Expanded | None | - | -| `CreatePlan` | Collapsed | None | - | -| `GetFileDiff` | Collapsed | `ReviewFixer`, `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, `ReviewJudge` | Expanded | -| `SessionControl` | Collapsed | None | - | -| `SessionMessage` | Collapsed | None | - | -| `SessionHistory` | Collapsed | None | - | -| `Cron` | Collapsed | None | - | -| `WebSearch` | Collapsed | `DeepResearch` | Expanded | -| `WebFetch` | Collapsed | `DeepResearch` | Expanded | -| `ListMCPResources` | Collapsed | None | - | -| `ReadMCPResource` | Collapsed | None | - | -| `ListMCPPrompts` | Collapsed | None | - | -| `GetMCPPrompt` | Collapsed | None | - | -| `GenerativeUI` | Collapsed | None | - | -| `Git` | Collapsed | `ReviewFixer`, `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, `ReviewJudge` | Expanded | -| `InitMiniApp` | Collapsed | None | - | -| `ControlHub` | Collapsed | `ComputerUse` | Expanded | -| `ComputerUse` | Collapsed | `ComputerUse` | Expanded | -| `Playbook` | Collapsed | None | - | +| `LS` | Direct | None | - | +| `Read` | Direct | None | - | +| `Glob` | Direct | None | - | +| `Grep` | Direct | None | - | +| `Write` | Direct | None | - | +| `Edit` | Direct | None | - | +| `Delete` | Direct | None | - | +| `ExecCommand` | Direct | None | - | +| `WriteStdin` | Direct | None | - | +| `ExecControl` | Direct | None | - | +| `GetTime` | Direct | None | - | +| `Task` | Direct | None | - | +| `Skill` | Direct | None | - | +| `AskUserQuestion` | Direct | None | - | +| `TodoWrite` | Direct | None | - | +| `CodeReview` | Direct | None | - | +| `GetToolSpec` | Direct | None | - | +| `CallDeferredTool` | Direct | None | - | +| `CreatePlan` | Deferred | None | - | +| `GetFileDiff` | Deferred | `ReviewFixer`, `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, `ReviewJudge` | Direct | +| `SessionControl` | Deferred | None | - | +| `SessionMessage` | Deferred | None | - | +| `SessionHistory` | Deferred | None | - | +| `Cron` | Deferred | None | - | +| `WebSearch` | Deferred | `DeepResearch` | Direct | +| `WebFetch` | Deferred | `DeepResearch` | Direct | +| `ListMCPResources` | Deferred | None | - | +| `ReadMCPResource` | Deferred | None | - | +| `ListMCPPrompts` | Deferred | None | - | +| `GetMCPPrompt` | Deferred | None | - | +| `GenerativeUI` | Deferred | None | - | +| `Git` | Deferred | `ReviewFixer`, `ReviewBusinessLogic`, `ReviewPerformance`, `ReviewSecurity`, `ReviewArchitecture`, `ReviewFrontend`, `ReviewJudge` | Direct | +| `InitMiniApp` | Deferred | None | - | +| `ControlHub` | Deferred | `ComputerUse` | Direct | +| `ComputerUse` | Deferred | `ComputerUse` | Direct | +| `Playbook` | Deferred | None | - | **Agents With Override Policies** diff --git a/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs b/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs index aa7394ada6..b158c974a7 100644 --- a/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/file_read_state_runtime.rs @@ -267,7 +267,7 @@ mod tests { session_id: session_id.map(str::to_string), dialog_turn_id: Some("turn-1".to_string()), workspace: Some(WorkspaceBinding::new(None, root)), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/framework.rs b/src/crates/assembly/core/src/agentic/tools/framework.rs index d58d8d6489..81ca8de38b 100644 --- a/src/crates/assembly/core/src/agentic/tools/framework.rs +++ b/src/crates/assembly/core/src/agentic/tools/framework.rs @@ -37,7 +37,7 @@ pub trait Tool: Send + Sync { /// This is tool-owned metadata: registries and agent manifests may use it /// as the baseline before applying any higher-level overrides. fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } /// Input mode definition - using JSON Schema diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs index d153b40b76..690113d39c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs @@ -173,7 +173,7 @@ impl Tool for AnalyzeImageTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs index 3d11a5a34c..816136de93 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -274,7 +274,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data, computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs index 69be58e989..eb5f1333e1 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/bash_tool.rs @@ -289,7 +289,7 @@ Usage notes: - The output may include the command echo and/or the shell prompt prefix (for example, a printed `PS` or `$` prompt line). Do not treat these as part of the command's actual result. - Avoid interactive commands that may block waiting for user input or open a pager/editor. Prefer non-interactive variants and explicit flags. For example, use `git --no-pager diff` instead of `git diff`, and avoid commands that prompt for confirmation unless the User explicitly asks for them. - - Prefer specialized tools for workspace file operations: Glob for file discovery, Grep for content search, Read for reading, Edit for modifying, Write for creating, and Delete for deletion. Prefer the Git tool (after loading it with GetToolSpec when collapsed) for Git subcommands such as status, diff, log, add, commit, branch, checkout, pull, and push. Use Bash for commands that genuinely need a shell, such as build/test/package CLIs, process control, scripts, and environment checks. Never use shell output only to communicate with the user. + - Prefer specialized tools for workspace file operations: Glob for file discovery, Grep for content search, Read for reading, Edit for modifying, Write for creating, and Delete for deletion. Prefer the Git tool for Git subcommands such as status, diff, log, add, commit, branch, checkout, pull, and push. When Git appears in the Deferred Tool Listing, load its schema with GetToolSpec and execute it through CallDeferredTool; otherwise call Git directly. Use Bash for commands that genuinely need a shell, such as build/test/package CLIs, process control, scripts, and environment checks. Never use shell output only to communicate with the user. - When issuing multiple commands: - If the commands are independent and can run in parallel, make multiple tool calls in a single message. For Git inspection, prefer parallel Git tool calls such as `{{"operation":"status"}}` and `{{"operation":"diff","args":"--stat"}}` instead of Bash. - If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m "message" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs index 5b68ec3543..4095540a15 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs @@ -831,7 +831,7 @@ mod tests { Some(format!("workspace_{session_id}")), std::env::temp_dir().join(format!("bitfun-canvas-tool-test-{}", uuid_short())), )), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs index b6411904d1..71f6765d92 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs @@ -758,7 +758,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index b231fa3f4a..6f1adcc265 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -973,7 +973,7 @@ impl Tool for ComputerUseTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } async fn description_with_context( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index 1752eafff5..c47c7af36d 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -1878,7 +1878,7 @@ impl Tool for ControlHubTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } async fn description_with_context( @@ -2131,7 +2131,7 @@ mod control_hub_tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: std::collections::HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs index e05ae3427b..6080ae03d4 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/create_plan_tool.rs @@ -90,7 +90,7 @@ Additional guidelines: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { @@ -311,10 +311,10 @@ mod tests { use crate::agentic::tools::framework::{Tool, ToolExposure}; #[test] - fn create_plan_is_collapsed_and_plan_mode_specific() { + fn create_plan_is_deferred_and_plan_mode_specific() { let tool = CreatePlanTool::new(); - assert_eq!(tool.default_exposure(), ToolExposure::Collapsed); + assert_eq!(tool.default_exposure(), ToolExposure::Deferred); assert_eq!( tool.short_description(), "Create and store a concise implementation plan; only for Plan mode." diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 9ef685e336..664bb16758 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -664,7 +664,7 @@ Patch schema for "update": } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { @@ -1239,7 +1239,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -1267,7 +1267,7 @@ mod tests { "Dev SSH".to_string(), session_identity, )), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs index 063d1f4c55..01128144de 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs @@ -1024,7 +1024,7 @@ mod tests { "Remote Host".to_string(), session_identity, )), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -1052,7 +1052,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs index 2ef469008d..908a0576c8 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs @@ -581,7 +581,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: Some(WorkspaceBinding::new(None, root)), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs index b12dbd90f5..a3dca29d0e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -230,7 +230,7 @@ Input rules: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs index 582ef5d885..2f9fe75944 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -1264,7 +1264,7 @@ Usage: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs index 65c7f489c3..cc454bf249 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs @@ -123,7 +123,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs index a228c999a5..c9e838bd44 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/git_tool.rs @@ -893,7 +893,7 @@ impl Tool for GitTool { This tool provides a safe and convenient way to execute Git commands. It supports common Git operations like status, diff, log, add, commit, branch, checkout, pull, push, and more. -If this tool was collapsed earlier in the conversation, only call it after `GetToolSpec` has returned this definition. A failed direct call that says "Tool 'Git' is collapsed" means the next tool call should be `GetToolSpec` with `{"tool_name":"Git"}`; after that, retry `Git` with the schema below. +If this definition was returned by `GetToolSpec`, execute it through `CallDeferredTool` with `tool_name` set to `Git` and put the arguments matching the schema below inside `args`. If Git is directly exposed in the available tool list, call it directly instead. ## Supported Operations @@ -1001,11 +1001,11 @@ When creating commits, use this format for the commit message: } fn short_description(&self) -> String { - "Inspect and operate on the Git repository; load with GetToolSpec before first use when collapsed.".to_string() + "Inspect and operate on the Git repository; load with GetToolSpec before deferred execution.".to_string() } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs index 647c84fc5a..3d8cc0e50b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs @@ -573,7 +573,7 @@ mod tests { "Dev SSH".to_string(), session_identity, )), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs index 56d07829de..e10423128f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs @@ -240,7 +240,7 @@ impl Tool for ListMCPResourcesTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { @@ -358,7 +358,7 @@ impl Tool for ReadMCPResourceTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { @@ -480,7 +480,7 @@ impl Tool for ListMCPPromptsTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { @@ -598,7 +598,7 @@ impl Tool for GetMCPPromptTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs index 3d3cf2280b..5ad051fe44 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -226,6 +226,6 @@ mod tests { #[test] fn init_miniapp_stays_expanded_for_assistant_creation() { let tool = InitMiniAppTool::new(); - assert_eq!(tool.default_exposure(), ToolExposure::Expanded); + assert_eq!(tool.default_exposure(), ToolExposure::Direct); } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs index 8d846660cc..73f60f2f2b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs @@ -263,7 +263,7 @@ Use this tool when you recognize a common task pattern — it saves planning tim } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs index f291595c6f..0d8056de2b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -270,7 +270,7 @@ When returning pull request results to the user, include the provider web URL so } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index af4df4a7cf..be53cad988 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -276,7 +276,7 @@ Arguments: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { @@ -616,7 +616,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index b0339ec773..67295625b6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -106,7 +106,7 @@ Examples: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index d53db6d2b3..550926116e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -298,7 +298,7 @@ Allowed agent types when creating a session: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { @@ -723,7 +723,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index ecc28a8d6b..76521463e3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -407,7 +407,7 @@ Use the remote project skill. session_id: None, dialog_turn_id: None, workspace: Some(workspace), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: Default::default(), computer_use_host: None, @@ -449,7 +449,7 @@ Use the remote project skill. session_id: None, dialog_turn_id: None, workspace: Some(workspace), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: Default::default(), computer_use_host: None, @@ -497,7 +497,7 @@ Use the remote project skill. session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: Default::default(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index 6a4114cdb0..c49ab9c942 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -1042,7 +1042,7 @@ mod target_context_tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index ef7b82a651..d1cff6e353 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -75,7 +75,7 @@ fn test_tool_context(agent_type: &str) -> ToolUseContext { session_id: Some("session-1".to_string()), dialog_turn_id: Some("turn-1".to_string()), workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -768,7 +768,7 @@ async fn call_impl_rejects_nested_subagent_delegation() { session_id: Some("session-1".to_string()), dialog_turn_id: Some("turn-1".to_string()), workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::from([ ( @@ -913,7 +913,7 @@ async fn description_with_context_filters_restricted_subagents_by_parent_agent() session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -949,7 +949,7 @@ async fn prompt_stability_description_with_context_renders_available_agents_in_s session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs index c0e9d17311..89369d2fb0 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/terminal_control_tool.rs @@ -46,7 +46,7 @@ The terminal_session_id is returned inside ... ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs index 3695e02830..788b6df524 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs @@ -227,7 +227,7 @@ impl Tool for ViewImageTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } fn input_schema(&self) -> Value { @@ -522,7 +522,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts, custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs index f21a958737..7dc2a108e7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs @@ -57,7 +57,7 @@ Example usage: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs index f5cc434f55..45f88d9971 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs @@ -41,7 +41,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: std::collections::HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs index 70f1ce5694..2adc454521 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs @@ -126,7 +126,7 @@ Advanced features: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Deferred } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs b/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs index 7d1a28f8e4..12c8b43f78 100644 --- a/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs +++ b/src/crates/assembly/core/src/agentic/tools/manifest_resolver.rs @@ -43,7 +43,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -71,7 +71,7 @@ mod tests { .await; assert_eq!(facade.allowed_tool_names, owner.allowed_tool_names); - assert_eq!(facade.collapsed_tool_names, owner.collapsed_tool_names); + assert_eq!(facade.deferred_tool_names, owner.deferred_tool_names); assert_eq!( facade .tool_definitions @@ -109,24 +109,24 @@ mod tests { assert_eq!( facade - .expanded_tools + .direct_tools .iter() .map(|tool| tool.name().to_string()) .collect::>(), owner - .expanded_tools + .direct_tools .iter() .map(|tool| tool.name().to_string()) .collect::>() ); assert_eq!( facade - .collapsed_tools + .deferred_tools .iter() .map(|tool| tool.name().to_string()) .collect::>(), owner - .collapsed_tools + .deferred_tools .iter() .map(|tool| tool.name().to_string()) .collect::>() diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index 82f86e0661..ad1e00e266 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -97,9 +97,11 @@ impl ToolStateManager { if let Some(mut task) = self.tasks.get_mut(tool_id) { debug!( "Updated tool arguments: tool_id={}, old_args={:?}, new_args={:?}", - tool_id, task.tool_call.arguments, new_arguments + tool_id, + task.effective_arguments(), + new_arguments ); - task.tool_call.arguments = new_arguments; + task.update_effective_arguments(new_arguments); } } @@ -152,7 +154,7 @@ impl ToolStateManager { dependencies: dependencies.clone(), }, ToolExecutionState::Running { .. } => ToolStateEventKind::Running { - params: task.tool_call.arguments.clone(), + params: task.invocation.wire_arguments.clone(), timeout_seconds: task.options.timeout_secs, }, ToolExecutionState::Streaming { @@ -160,13 +162,16 @@ impl ToolStateManager { } => ToolStateEventKind::Streaming { chunks_received: *chunks_received, }, - ToolExecutionState::AwaitingConfirmation { params, timeout_at } => { + ToolExecutionState::AwaitingConfirmation { + params: _, + timeout_at, + } => { let confirmation_timeout_secs = task .options .confirmation_timeout_secs .filter(|seconds| *seconds > 0); ToolStateEventKind::AwaitingConfirmation { - params: params.clone(), + params: task.invocation.wire_arguments.clone(), timeout_at: confirmation_timeout_secs.map(|_| { timeout_at .duration_since(std::time::SystemTime::UNIX_EPOCH) @@ -232,8 +237,11 @@ impl ToolStateManager { }, }; let tool_event = tool_state_event_data(ToolStateEventFacts { - tool_id: task.tool_call.tool_id.clone(), - tool_name: task.tool_call.tool_name.clone(), + identity: bitfun_events::ToolEventIdentity::resolved( + task.tool_call.tool_id.clone(), + task.invocation.wire_tool_name.clone(), + task.effective_tool_name().to_string(), + ), state, }); @@ -278,6 +286,22 @@ mod tests { use std::time::Duration; use tokio::time::timeout; + #[derive(Default)] + struct CapturingEventSink { + events: tokio::sync::Mutex>, + } + + #[async_trait::async_trait] + impl StreamEventSink for CapturingEventSink { + async fn enqueue( + &self, + event: AgenticEvent, + _priority: Option, + ) { + self.events.lock().await.push(event); + } + } + struct BlockingEventSink { started: tokio::sync::Notify, release: tokio::sync::Notify, @@ -317,8 +341,8 @@ mod tests { context_vars: HashMap::new(), subagent_parent_info: None, delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), - collapsed_tools: Vec::new(), - unlocked_collapsed_tools: Vec::new(), + deferred_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), allowed_tools: Vec::new(), runtime_tool_restrictions: Default::default(), steering_interrupt: None, @@ -371,6 +395,53 @@ mod tests { .expect("state update should finish after event queue is released") .expect("state update task should not panic"); } + + #[tokio::test] + async fn deferred_started_event_keeps_wire_input_and_effective_name() { + let wire_arguments = serde_json::json!({ + "tool_name": "CreatePlan", + "args": { "name": "Plan" } + }); + let mut task = test_task("tool-1"); + task.tool_call.tool_name = bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME.to_string(); + task.tool_call.arguments = wire_arguments.clone(); + task.invocation = bitfun_agent_tools::ResolvedToolInvocation::from_wire_call( + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + wire_arguments.clone(), + ) + .expect("valid deferred invocation"); + + let sink = Arc::new(CapturingEventSink::default()); + let manager = ToolStateManager::new(sink.clone()); + let tool_id = manager.create_task(task).await; + manager + .update_state( + &tool_id, + ToolExecutionState::Running { + started_at: std::time::SystemTime::now(), + progress: None, + }, + ) + .await; + + let events = sink.events.lock().await; + let AgenticEvent::ToolEvent { + tool_event: + bitfun_events::ToolEventData::Started { + identity, params, .. + }, + .. + } = &events[0] + else { + panic!("expected started event"); + }; + assert_eq!( + identity.tool_name, + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME + ); + assert_eq!(identity.effective_name(), "CreatePlan"); + assert_eq!(params, &wire_arguments); + } } /// Tool statistics diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index f8266102a7..51fcc99df5 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -27,7 +27,7 @@ use bitfun_agent_tools::{ build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, render_tool_result_for_assistant, truncate_raw_tool_arguments_preview, truncate_tool_arguments_preview, - validate_tool_execution_admission, ToolExecutionAdmissionRejection, + validate_tool_execution_admission, ResolvedToolInvocation, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, GET_TOOL_SPEC_TOOL_NAME, USER_STEERING_INTERRUPTED_MESSAGE, }; use bitfun_runtime_ports::RoundInjectionToolPreemption; @@ -44,13 +44,21 @@ use tool_runtime::pipeline::{ ToolCancellationTokenStore, ToolExecutionErrorClass, ToolRetryAttemptFacts, }; +fn persisted_effective_tool_name( + wire_tool_name: &str, + effective_tool_name: &str, +) -> Option { + (wire_tool_name != effective_tool_name).then(|| effective_tool_name.to_string()) +} + /// Convert framework::ToolResult to core::ToolResult /// /// Ensure always has result_for_assistant, avoid tool message content being empty fn convert_tool_result( framework_result: FrameworkToolResult, tool_id: &str, - tool_name: &str, + wire_tool_name: &str, + effective_tool_name: &str, ) -> ModelToolResult { match framework_result { FrameworkToolResult::Result { @@ -63,11 +71,15 @@ fn convert_tool_result( // "completed successfully" can hide fields the model needs for the // next decision. let assistant_text = result_for_assistant - .or_else(|| Some(render_tool_result_for_assistant(tool_name, &data))); + .or_else(|| Some(render_tool_result_for_assistant(effective_tool_name, &data))); ModelToolResult { tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), + tool_name: wire_tool_name.to_string(), + effective_tool_name: persisted_effective_tool_name( + wire_tool_name, + effective_tool_name, + ), result: data, result_for_assistant: assistant_text, is_error: false, @@ -76,11 +88,18 @@ fn convert_tool_result( } } FrameworkToolResult::Progress { content, .. } => { - let assistant_text = Some(render_tool_result_for_assistant(tool_name, &content)); + let assistant_text = Some(render_tool_result_for_assistant( + effective_tool_name, + &content, + )); ModelToolResult { tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), + tool_name: wire_tool_name.to_string(), + effective_tool_name: persisted_effective_tool_name( + wire_tool_name, + effective_tool_name, + ), result: content, result_for_assistant: assistant_text, is_error: false, @@ -89,11 +108,15 @@ fn convert_tool_result( } } FrameworkToolResult::StreamChunk { data, .. } => { - let assistant_text = Some(render_tool_result_for_assistant(tool_name, &data)); + let assistant_text = Some(render_tool_result_for_assistant(effective_tool_name, &data)); ModelToolResult { tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), + tool_name: wire_tool_name.to_string(), + effective_tool_name: persisted_effective_tool_name( + wire_tool_name, + effective_tool_name, + ), result: data, result_for_assistant: assistant_text, is_error: false, @@ -104,6 +127,44 @@ fn convert_tool_result( } } +fn resolve_pipeline_invocation( + tool_call: &ToolCall, + context: &ToolExecutionContext, +) -> (ResolvedToolInvocation, Option) { + let invocation = match ResolvedToolInvocation::from_wire_call( + tool_call.tool_name.clone(), + tool_call.arguments.clone(), + ) { + Ok(invocation) => invocation, + Err(error) => { + return ( + ResolvedToolInvocation::direct( + tool_call.tool_name.clone(), + tool_call.arguments.clone(), + ), + Some(error.to_string()), + ); + } + }; + + if invocation.is_deferred() + && !context + .deferred_tools + .iter() + .any(|tool_name| tool_name == &invocation.effective_tool_name) + { + let effective_tool_name = invocation.effective_tool_name.clone(); + return ( + invocation, + Some(format!( + "Tool '{effective_tool_name}' is not an available deferred tool in the current context" + )), + ); + } + + (invocation, None) +} + /// Convert core::ToolResult to framework::ToolResult fn convert_to_framework_result(model_result: &ModelToolResult) -> FrameworkToolResult { FrameworkToolResult::Result { @@ -134,38 +195,53 @@ fn build_error_execution_result( task: Option, error: &BitFunError, ) -> ToolExecutionResult { - let (tool_id, tool_name, execution_time_ms, provided_arguments) = if let Some(task) = task { - let preview = task - .tool_call - .raw_arguments - .as_deref() - .map(truncate_raw_tool_arguments_preview) - .unwrap_or_else(|| truncate_tool_arguments_preview(&task.tool_call.arguments)); - ( - task.tool_call.tool_id, - task.tool_call.tool_name, - elapsed_ms_since(task.created_at), - Some(preview), - ) - } else { - warn!("Task not found in state manager: {}", task_id); - (task_id.to_string(), "unknown".to_string(), 0, None) - }; + let (tool_id, wire_tool_name, effective_tool_name, execution_time_ms, provided_arguments) = + if let Some(task) = task { + let preview = if task.invocation.is_deferred() { + truncate_tool_arguments_preview(task.effective_arguments()) + } else { + task.tool_call + .raw_arguments + .as_deref() + .map(truncate_raw_tool_arguments_preview) + .unwrap_or_else(|| truncate_tool_arguments_preview(task.effective_arguments())) + }; + ( + task.tool_call.tool_id, + task.tool_call.tool_name, + task.invocation.effective_tool_name, + elapsed_ms_since(task.created_at), + Some(preview), + ) + } else { + warn!("Task not found in state manager: {}", task_id); + ( + task_id.to_string(), + "unknown".to_string(), + "unknown".to_string(), + 0, + None, + ) + }; let error_message = error.to_string(); let category = classify_tool_error(error); let presentation = build_tool_execution_error_presentation( - &tool_name, + &effective_tool_name, category, &error_message, provided_arguments, ); + let persisted_effective_tool_name = + persisted_effective_tool_name(&wire_tool_name, &effective_tool_name); ToolExecutionResult { tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + tool_name: wire_tool_name.clone(), + effective_tool_name, result: ModelToolResult { tool_id, - tool_name, + tool_name: wire_tool_name, + effective_tool_name: persisted_effective_tool_name, result: presentation.result_json, result_for_assistant: Some(presentation.result_for_assistant), is_error: true, @@ -180,10 +256,12 @@ fn build_user_steering_interrupted_result( task_id: &str, task: Option, ) -> ToolExecutionResult { - let (tool_id, tool_name, execution_time_ms) = if let Some(task) = task { + let (tool_id, wire_tool_name, effective_tool_name, execution_time_ms) = if let Some(task) = task + { ( task.tool_call.tool_id, task.tool_call.tool_name, + task.invocation.effective_tool_name, elapsed_ms_since(task.created_at), ) } else { @@ -191,17 +269,26 @@ fn build_user_steering_interrupted_result( "Task not found while building steering-interrupted result: {}", task_id ); - (task_id.to_string(), "unknown".to_string(), 0) + ( + task_id.to_string(), + "unknown".to_string(), + "unknown".to_string(), + 0, + ) }; - let presentation = build_user_steering_interrupted_presentation(&tool_name); + let presentation = build_user_steering_interrupted_presentation(&effective_tool_name); + let persisted_effective_tool_name = + persisted_effective_tool_name(&wire_tool_name, &effective_tool_name); ToolExecutionResult { tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + tool_name: wire_tool_name.clone(), + effective_tool_name, result: ModelToolResult { tool_id, - tool_name, + tool_name: wire_tool_name, + effective_tool_name: persisted_effective_tool_name, result: presentation.result_json, result_for_assistant: Some(presentation.result_for_assistant), is_error: true, @@ -217,10 +304,12 @@ fn build_user_rejected_tool_result( task: Option, instruction: Option<&str>, ) -> ToolExecutionResult { - let (tool_id, tool_name, execution_time_ms) = if let Some(task) = task { + let (tool_id, wire_tool_name, effective_tool_name, execution_time_ms) = if let Some(task) = task + { ( task.tool_call.tool_id, task.tool_call.tool_name, + task.invocation.effective_tool_name, elapsed_ms_since(task.created_at), ) } else { @@ -228,18 +317,27 @@ fn build_user_rejected_tool_result( "Task not found while building user-rejected result: {}", task_id ); - (task_id.to_string(), "unknown".to_string(), 0) + ( + task_id.to_string(), + "unknown".to_string(), + "unknown".to_string(), + 0, + ) }; let presentation = - build_user_rejected_tool_presentation_with_instruction(&tool_name, instruction); + build_user_rejected_tool_presentation_with_instruction(&effective_tool_name, instruction); + let persisted_effective_tool_name = + persisted_effective_tool_name(&wire_tool_name, &effective_tool_name); ToolExecutionResult { tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + tool_name: wire_tool_name.clone(), + effective_tool_name, result: ModelToolResult { tool_id, - tool_name, + tool_name: wire_tool_name, + effective_tool_name: persisted_effective_tool_name, result: presentation.result_json, result_for_assistant: Some(presentation.result_for_assistant), is_error: false, @@ -280,7 +378,7 @@ fn map_tool_execution_admission_rejection(error: ToolExecutionAdmissionRejection ToolExecutionAdmissionRejection::AllowedList(error) => { BitFunError::Validation(error.to_string()) } - ToolExecutionAdmissionRejection::Collapsed(error) => { + ToolExecutionAdmissionRejection::Deferred(error) => { BitFunError::Validation(error.to_string()) } } @@ -445,28 +543,41 @@ impl ToolPipeline { } info!("Executing tools: count={}", tool_calls.len()); - let tool_names: Vec = tool_calls + let resolved_tool_calls = tool_calls .iter() - .map(|tool_call| tool_call.tool_name.clone()) - .collect(); + .map(|tool_call| { + let (invocation, resolution_error) = + resolve_pipeline_invocation(tool_call, &context); + (tool_call.clone(), invocation, resolution_error) + }) + .collect::>(); + let tool_names = resolved_tool_calls + .iter() + .map(|(_, invocation, _)| invocation.effective_tool_name.clone()) + .collect::>(); - let subagent_call_count = tool_calls + let subagent_call_count = resolved_tool_calls .iter() - .filter(|tool_call| tool_call.tool_name == SUBAGENT_LAUNCH_TOOL_NAME) + .filter(|(_, invocation, _)| { + invocation.effective_tool_name == SUBAGENT_LAUNCH_TOOL_NAME + }) .count(); // Determine concurrency safety for each tool call let concurrency_flags: Vec = { let registry = self.tool_registry.read().await; - tool_calls + resolved_tool_calls .iter() - .map(|tc| { + .map(|(_, invocation, resolution_error)| { + if resolution_error.is_some() { + return false; + } let tool_is_concurrency_safe = registry - .get_tool(&tc.tool_name) - .map(|tool| tool.is_concurrency_safe(Some(&tc.arguments))) + .get_tool(&invocation.effective_tool_name) + .map(|tool| tool.is_concurrency_safe(Some(&invocation.effective_arguments))) .unwrap_or(false); tool_call_concurrency_safe_for_batch( - &tc.tool_name, + &invocation.effective_tool_name, tool_is_concurrency_safe, subagent_call_count, options.subagent_batch_execution_policy, @@ -477,9 +588,15 @@ impl ToolPipeline { let concurrency_safe_count = concurrency_flags.iter().filter(|&&flag| flag).count(); // Create tasks for all tool calls - let mut task_ids = Vec::with_capacity(tool_calls.len()); - for tool_call in tool_calls { - let task = ToolTask::new(tool_call, context.clone(), options.clone()); + let mut task_ids = Vec::with_capacity(resolved_tool_calls.len()); + for (tool_call, invocation, resolution_error) in resolved_tool_calls { + let task = ToolTask::new_resolved( + tool_call, + invocation, + resolution_error, + context.clone(), + options.clone(), + ); let tool_id = self.state_manager.create_task(task).await; task_ids.push(tool_id); } @@ -633,26 +750,29 @@ impl ToolPipeline { .get_task(&tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - let tool_name = task.tool_call.tool_name.clone(); - let tool_args = task.tool_call.arguments.clone(); + let wire_tool_name = task.tool_call.tool_name.clone(); + let tool_name = task.invocation.effective_tool_name.clone(); + let tool_args = task.invocation.effective_arguments.clone(); let tool_is_error = task.tool_call.is_error; let recovered_from_truncation = task.tool_call.recovered_from_truncation; let queue_wait_ms = elapsed_ms_since(task.created_at); let mut confirmation_wait_ms = 0; debug!( - "Tool task details: tool_name={}, tool_id={}, queue_wait_ms={}", - tool_name, tool_id, queue_wait_ms + "Tool task details: tool_name={}, wire_tool_name={}, tool_id={}, queue_wait_ms={}", + tool_name, wire_tool_name, tool_id, queue_wait_ms ); - let invalid_call_error = if tool_name.is_empty() || tool_is_error { + let invalid_call_error = if let Some(error) = task.invocation_resolution_error.clone() { + Some(error) + } else if wire_tool_name.is_empty() || tool_is_error { let raw_arguments_preview = task .tool_call .raw_arguments .as_deref() .map(truncate_raw_tool_arguments_preview); Some(build_invalid_tool_call_error_message( - &tool_name, + &wire_tool_name, tool_is_error, recovered_from_truncation, raw_arguments_preview, @@ -699,16 +819,28 @@ impl ToolPipeline { // Repetition alone is not execution failure: polling and status checks // may legitimately reuse identical arguments. The execution engine // evaluates repeated patterns only after observing actual tool results. - if let Err(err) = validate_tool_execution_admission(ToolExecutionAdmissionRequest { - tool_name: &tool_name, - allowed_tools: &task.context.allowed_tools, - runtime_tool_restrictions: &task.context.runtime_tool_restrictions, - collapsed_tools: &task.context.collapsed_tools, - loaded_collapsed_tools: &task.context.unlocked_collapsed_tools, - get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, - }) { + let (admission, tool) = { + let registry = self.tool_registry.read().await; + let admission = validate_tool_execution_admission(ToolExecutionAdmissionRequest { + tool_name: &tool_name, + allowed_tools: &task.context.allowed_tools, + runtime_tool_restrictions: &task.context.runtime_tool_restrictions, + invocation_is_deferred: task.invocation.is_deferred(), + deferred_tools: &task.context.deferred_tools, + loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, + current_catalog_generation: registry.current_snapshot_generation(), + get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, + }); + (admission, registry.get_tool(&tool_name)) + }; + + if let Err(err) = admission { let error_msg = err.to_string(); - warn!("Tool execution admission rejected: {}", error_msg); + if task.invocation.is_deferred() { + warn!("Deferred tool gateway admission rejected: {}", error_msg); + } else { + warn!("Tool execution admission rejected: {}", error_msg); + } self.state_manager .update_state( @@ -728,19 +860,11 @@ impl ToolPipeline { return Err(map_tool_execution_admission_rejection(err)); } - let tool = { - let registry = self.tool_registry.read().await; - registry - .get_tool(&task.tool_call.tool_name) - .ok_or_else(|| { - let error_msg = format!( - "Tool '{}' is not registered or enabled.", - task.tool_call.tool_name, - ); - error!("{}", error_msg); - BitFunError::tool(error_msg) - })? - }; + let tool = tool.ok_or_else(|| { + let error_msg = format!("Tool '{}' is not registered or enabled.", tool_name); + error!("{}", error_msg); + BitFunError::tool(error_msg) + })?; let cancellation_token = CancellationToken::new(); let tool_context = self.build_tool_use_context(&task, cancellation_token.clone()); @@ -898,10 +1022,15 @@ impl ToolPipeline { let presentation = build_tool_confirmation_timeout_presentation(&tool_name); return Ok(ToolExecutionResult { tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + tool_name: wire_tool_name.clone(), + effective_tool_name: tool_name.clone(), result: ModelToolResult { tool_id, - tool_name, + effective_tool_name: persisted_effective_tool_name( + &wire_tool_name, + &tool_name, + ), + tool_name: wire_tool_name, result: presentation.result_json, result_for_assistant: Some(presentation.result_for_assistant), is_error: false, @@ -974,11 +1103,13 @@ impl ToolPipeline { match result { Ok(tool_result) => { let duration_ms = elapsed_ms_u64(start_time); - let mut tool_result = tool_result_storage::maybe_persist_large_tool_result( - tool_result, - &tool_context, - ) - .await; + let mut tool_result = + tool_result_storage::maybe_persist_large_tool_result_for_tool( + tool_result, + &tool_name, + &tool_context, + ) + .await; tool_result.duration_ms = Some(duration_ms); // The tool call succeeded with arguments that we patched @@ -1022,7 +1153,8 @@ impl ToolPipeline { Ok(ToolExecutionResult { tool_id, - tool_name, + tool_name: wire_tool_name, + effective_tool_name: tool_name, result: tool_result, execution_time_ms: duration_ms, }) @@ -1095,10 +1227,15 @@ impl ToolPipeline { return Ok(ToolExecutionResult { tool_id: timed_out_tool_id.clone(), - tool_name: timed_out_tool_name.clone(), + tool_name: wire_tool_name.clone(), + effective_tool_name: timed_out_tool_name.clone(), result: ModelToolResult { tool_id: timed_out_tool_id, - tool_name: timed_out_tool_name, + effective_tool_name: persisted_effective_tool_name( + &wire_tool_name, + &timed_out_tool_name, + ), + tool_name: wire_tool_name, result: presentation.result_json, result_for_assistant: Some(presentation.result_for_assistant), is_error: false, @@ -1206,7 +1343,7 @@ impl ToolPipeline { let tool_context = self.build_tool_use_context(task, cancellation_token); - let execution_future = tool.call(&task.tool_call.arguments, &tool_context); + let execution_future = tool.call(task.effective_arguments(), &tool_context); let pipeline_timeout_secs = if tool.manages_own_execution_timeout() { None @@ -1222,7 +1359,7 @@ impl ToolPipeline { .map_err(|_| { BitFunError::Timeout(format!( "Tool execution timeout: {}", - task.tool_call.tool_name + task.effective_tool_name() )) })?; result? @@ -1237,11 +1374,18 @@ impl ToolPipeline { tool_results .into_iter() .last() - .map(|r| convert_tool_result(r, &task.tool_call.tool_id, &task.tool_call.tool_name)) + .map(|r| { + convert_tool_result( + r, + &task.tool_call.tool_id, + &task.tool_call.tool_name, + task.effective_tool_name(), + ) + }) .ok_or_else(|| { BitFunError::Tool(format!( "Tool did not return result: {}", - task.tool_call.tool_name + task.effective_tool_name() )) }) } @@ -1288,8 +1432,11 @@ impl ToolPipeline { // Send StreamChunk event let _event_data = ToolEventData::StreamChunk { - tool_id: task.tool_call.tool_id.clone(), - tool_name: task.tool_call.tool_name.clone(), + identity: bitfun_events::ToolEventIdentity::resolved( + task.tool_call.tool_id.clone(), + task.invocation.wire_tool_name.clone(), + task.effective_tool_name().to_string(), + ), data: data.clone(), }; } @@ -1484,16 +1631,24 @@ mod tests { use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::ToolRuntimeRestrictions; use async_trait::async_trait; + use bitfun_agent_tools::{LoadedDeferredToolSpec, CALL_DEFERRED_TOOL_NAME}; use bitfun_runtime_ports::{ RoundInjection, RoundInjectionExecutionPolicy, RoundInjectionKind, RoundInjectionTarget, RoundInjectionToolPreemption, }; use serde_json::json; use std::collections::HashMap; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use std::time::SystemTime; use tokio::time::{sleep, Duration}; + fn loaded_spec(tool_name: &str, catalog_generation: u64) -> LoadedDeferredToolSpec { + LoadedDeferredToolSpec { + tool_name: tool_name.to_string(), + catalog_generation, + } + } + #[test] fn recovered_write_without_separator_is_rejected_as_potentially_truncated_path() { assert!(recovered_write_has_potentially_truncated_marked_path( @@ -1538,6 +1693,75 @@ mod tests { needs_permissions: bool, } + struct CapturingTestTool { + name: String, + received_arguments: Arc>>, + } + + #[async_trait] + impl Tool for CapturingTestTool { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> BitFunResult { + Ok("capturing test tool".to_string()) + } + + fn short_description(&self) -> String { + "capturing test tool".to_string() + } + + fn is_readonly(&self) -> bool { + true + } + + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["city"], + "properties": { + "city": { "type": "string" } + } + }) + } + + async fn validate_input( + &self, + input: &serde_json::Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let valid = input + .get("city") + .and_then(serde_json::Value::as_str) + .is_some() + && input.as_object().is_some_and(|object| object.len() == 1); + ValidationResult { + result: valid, + message: (!valid).then(|| "city must be the only target argument".to_string()), + error_code: (!valid).then_some(400), + meta: None, + } + } + + async fn call_impl( + &self, + input: &serde_json::Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + *self + .received_arguments + .lock() + .expect("capturing tool argument lock") = Some(input.clone()); + Ok(vec![ToolResult::Result { + data: json!({ "received": input }), + result_for_assistant: None, + image_attachments: None, + }]) + } + } + #[async_trait] impl Tool for StaticTestTool { fn name(&self) -> &str { @@ -1627,8 +1851,8 @@ mod tests { context_vars: HashMap::new(), subagent_parent_info: None, delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), - collapsed_tools: Vec::new(), - unlocked_collapsed_tools: Vec::new(), + deferred_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), allowed_tools: Vec::new(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), steering_interrupt: None, @@ -1682,6 +1906,274 @@ mod tests { })); } + async fn register_capturing_test_tool( + pipeline: &ToolPipeline, + name: &str, + received_arguments: Arc>>, + ) { + pipeline + .tool_registry + .write() + .await + .register_tool(Arc::new(CapturingTestTool { + name: name.to_string(), + received_arguments, + })); + } + + async fn current_registry_generation(pipeline: &ToolPipeline) -> u64 { + pipeline + .tool_registry + .read() + .await + .current_snapshot_generation() + } + + #[tokio::test] + async fn deferred_gateway_executes_effective_target_and_preserves_wire_identity() { + let pipeline = test_tool_pipeline(); + let received_arguments = Arc::new(Mutex::new(None)); + register_capturing_test_tool(&pipeline, "get_weather", Arc::clone(&received_arguments)) + .await; + + let mut context = test_tool_execution_context(); + context.allowed_tools = vec![ + CALL_DEFERRED_TOOL_NAME.to_string(), + "get_weather".to_string(), + ]; + context.deferred_tools = vec!["get_weather".to_string()]; + context.loaded_deferred_tool_specs = vec![loaded_spec( + "get_weather", + current_registry_generation(&pipeline).await, + )]; + + let mut call = test_tool_call("deferred_1", CALL_DEFERRED_TOOL_NAME); + call.arguments = json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }); + + let results = pipeline + .execute_tools( + vec![call], + context, + ToolExecutionOptions { + confirm_before_run: false, + ..ToolExecutionOptions::default() + }, + ) + .await + .expect("deferred tool execution"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(results[0].effective_tool_name, "get_weather"); + assert_eq!(results[0].result.tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(results[0].result.result["received"]["city"], "Shanghai"); + assert_eq!( + *received_arguments + .lock() + .expect("capturing tool argument lock"), + Some(json!({ "city": "Shanghai" })) + ); + + let task = pipeline + .state_manager + .get_task("deferred_1") + .expect("deferred tool task"); + assert_eq!(task.tool_call.tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(task.effective_tool_name(), "get_weather"); + assert_eq!(task.effective_arguments(), &json!({ "city": "Shanghai" })); + } + + #[tokio::test] + async fn deferred_gateway_rejects_registry_refresh_before_execution() { + let pipeline = test_tool_pipeline(); + let old_received_arguments = Arc::new(Mutex::new(None)); + register_capturing_test_tool( + &pipeline, + "get_weather", + Arc::clone(&old_received_arguments), + ) + .await; + let loaded_generation = current_registry_generation(&pipeline).await; + + let new_received_arguments = Arc::new(Mutex::new(None)); + register_capturing_test_tool( + &pipeline, + "get_weather", + Arc::clone(&new_received_arguments), + ) + .await; + + let mut context = test_tool_execution_context(); + context.allowed_tools = vec![ + CALL_DEFERRED_TOOL_NAME.to_string(), + "get_weather".to_string(), + ]; + context.deferred_tools = vec!["get_weather".to_string()]; + context.loaded_deferred_tool_specs = vec![loaded_spec("get_weather", loaded_generation)]; + + let mut call = test_tool_call("deferred_stale", CALL_DEFERRED_TOOL_NAME); + call.arguments = json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }); + + let results = pipeline + .execute_tools(vec![call], context, ToolExecutionOptions::default()) + .await + .expect("stale deferred call should become a per-tool error result"); + + assert_eq!(results.len(), 1); + assert!(results[0].result.is_error); + assert_eq!( + results[0].result.effective_tool_name.as_deref(), + Some("get_weather") + ); + assert!(results[0] + .result + .result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("is stale")); + assert_eq!( + *old_received_arguments + .lock() + .expect("old capturing tool argument lock"), + None + ); + assert_eq!( + *new_received_arguments + .lock() + .expect("new capturing tool argument lock"), + None + ); + } + + #[tokio::test] + async fn deferred_gateway_requires_loaded_get_tool_spec_result() { + let pipeline = test_tool_pipeline(); + register_capturing_test_tool(&pipeline, "get_weather", Arc::new(Mutex::new(None))).await; + + let mut context = test_tool_execution_context(); + context.allowed_tools = vec![ + CALL_DEFERRED_TOOL_NAME.to_string(), + "get_weather".to_string(), + ]; + context.deferred_tools = vec!["get_weather".to_string()]; + + let mut call = test_tool_call("deferred_locked", CALL_DEFERRED_TOOL_NAME); + call.arguments = json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }); + + let results = pipeline + .execute_tools(vec![call], context, ToolExecutionOptions::default()) + .await + .expect("pipeline should return a per-tool error result"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(results[0].effective_tool_name, "get_weather"); + assert!(results[0].result.is_error); + assert!(results[0] + .result + .result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("Call GetToolSpec first")); + } + + #[tokio::test] + async fn deferred_gateway_does_not_dispatch_direct_tools() { + let pipeline = test_tool_pipeline(); + let received_arguments = Arc::new(Mutex::new(None)); + register_capturing_test_tool(&pipeline, "get_weather", Arc::clone(&received_arguments)) + .await; + + let mut context = test_tool_execution_context(); + context.allowed_tools = vec![ + CALL_DEFERRED_TOOL_NAME.to_string(), + "get_weather".to_string(), + ]; + + let mut call = test_tool_call("deferred_direct", CALL_DEFERRED_TOOL_NAME); + call.arguments = json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }); + + let results = pipeline + .execute_tools(vec![call], context, ToolExecutionOptions::default()) + .await + .expect("pipeline should return a per-tool error result"); + + assert_eq!(results.len(), 1); + assert!(results[0].result.is_error); + assert!(results[0] + .result + .result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("not an available deferred tool")); + assert_eq!( + *received_arguments + .lock() + .expect("capturing tool argument lock"), + None + ); + } + + #[tokio::test] + async fn deferred_gateway_uses_effective_target_permission_policy() { + let pipeline = test_tool_pipeline(); + register_permissioned_static_test_tool( + &pipeline, + "write_weather", + json!({ "ok": true }), + 0, + ) + .await; + + let mut context = test_tool_execution_context(); + context.allowed_tools = vec![ + CALL_DEFERRED_TOOL_NAME.to_string(), + "write_weather".to_string(), + ]; + context.deferred_tools = vec!["write_weather".to_string()]; + context.loaded_deferred_tool_specs = vec![loaded_spec( + "write_weather", + current_registry_generation(&pipeline).await, + )]; + + let mut call = test_tool_call("deferred_permission", CALL_DEFERRED_TOOL_NAME); + call.arguments = json!({ + "tool_name": "write_weather", + "args": { "city": "Shanghai" } + }); + + let results = pipeline + .execute_tools( + vec![call], + context, + ToolExecutionOptions { + confirmation_timeout_secs: Some(0), + ..ToolExecutionOptions::default() + }, + ) + .await + .expect("permission timeout should be returned as a tool result"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(results[0].effective_tool_name, "write_weather"); + assert_eq!(results[0].result.tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(results[0].result.result["category"], "confirmation_timeout"); + assert_eq!(results[0].result.result["tool_name"], "write_weather"); + } + fn test_round_injection( kind: RoundInjectionKind, tool_preemption: RoundInjectionToolPreemption, @@ -1946,10 +2438,10 @@ mod tests { } #[tokio::test] - async fn pipeline_admission_collapsed_tool_rejection_updates_failed_state_before_validation() { + async fn pipeline_admission_deferred_tool_rejection_updates_failed_state_before_validation() { let pipeline = test_tool_pipeline(); let mut context = test_tool_execution_context(); - context.collapsed_tools = vec!["WebFetch".to_string()]; + context.deferred_tools = vec!["WebFetch".to_string()]; let results = pipeline .execute_tools( @@ -1965,7 +2457,7 @@ mod tests { assert_failed_task_contains( &pipeline, "tool_1", - "Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}", + "Tool 'WebFetch' is deferred and cannot be called directly", ); } @@ -2104,6 +2596,7 @@ mod tests { }, "tool_1", "Bash", + "Bash", ); let assistant_text = result.result_for_assistant.unwrap_or_default(); @@ -2142,8 +2635,8 @@ mod tests { task.context .context_vars .insert("acp_transport".to_string(), "true".to_string()); - task.context.collapsed_tools = vec!["WebFetch".to_string()]; - task.context.unlocked_collapsed_tools = vec!["WebFetch".to_string()]; + task.context.deferred_tools = vec!["WebFetch".to_string()]; + task.context.loaded_deferred_tool_specs = vec![loaded_spec("WebFetch", 0)]; task.context.runtime_tool_restrictions = ToolRuntimeRestrictions { allowed_tool_names: ["WebFetch"].into_iter().map(str::to_string).collect(), denied_tool_names: ["Bash"].into_iter().map(str::to_string).collect(), @@ -2157,7 +2650,10 @@ mod tests { assert_eq!(context.agent_type.as_deref(), Some("agent")); assert_eq!(context.session_id.as_deref(), Some("session_1")); assert_eq!(context.dialog_turn_id.as_deref(), Some("turn_1")); - assert_eq!(context.unlocked_collapsed_tools, vec!["WebFetch"]); + assert_eq!( + context.loaded_deferred_tool_specs, + vec![loaded_spec("WebFetch", 0)] + ); assert!(context.cancellation_token().is_some()); assert!(context .runtime_tool_restrictions @@ -2181,19 +2677,21 @@ mod tests { } #[test] - fn collapsed_tool_requires_tool_catalog_unlock() { + fn deferred_tool_requires_loaded_catalog_spec() { let mut task = test_tool_task("tool_1", "WebFetch"); - task.context.collapsed_tools = vec!["WebFetch".to_string()]; + task.context.deferred_tools = vec!["WebFetch".to_string()]; let err = validate_tool_execution_admission(ToolExecutionAdmissionRequest { tool_name: &task.tool_call.tool_name, allowed_tools: &task.context.allowed_tools, runtime_tool_restrictions: &task.context.runtime_tool_restrictions, - collapsed_tools: &task.context.collapsed_tools, - loaded_collapsed_tools: &task.context.unlocked_collapsed_tools, + invocation_is_deferred: true, + deferred_tools: &task.context.deferred_tools, + loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, + current_catalog_generation: 0, get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, }) - .expect_err("collapsed tool should require GetToolSpec unlock"); + .expect_err("deferred tool should require a loaded GetToolSpec result"); assert!(err .to_string() @@ -2201,17 +2699,19 @@ mod tests { } #[test] - fn tool_catalog_rejects_reloading_already_unlocked_tool() { + fn tool_catalog_rejects_reloading_already_loaded_tool() { let mut task = test_tool_task("tool_1", "GetToolSpec"); task.tool_call.arguments = json!({ "tool_name": "WebFetch" }); - task.context.unlocked_collapsed_tools = vec!["WebFetch".to_string()]; + task.context.loaded_deferred_tool_specs = vec![loaded_spec("WebFetch", 0)]; let result = validate_tool_execution_admission(ToolExecutionAdmissionRequest { tool_name: &task.tool_call.tool_name, allowed_tools: &task.context.allowed_tools, runtime_tool_restrictions: &task.context.runtime_tool_restrictions, - collapsed_tools: &task.context.collapsed_tools, - loaded_collapsed_tools: &task.context.unlocked_collapsed_tools, + invocation_is_deferred: false, + deferred_tools: &task.context.deferred_tools, + loaded_deferred_tool_specs: &task.context.loaded_deferred_tool_specs, + current_catalog_generation: 0, get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, }); diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs index d5faa23102..d805794e56 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs @@ -6,6 +6,7 @@ use crate::agentic::round_preempt::DialogRoundInjectionInterrupt; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; +use bitfun_agent_tools::ResolvedToolInvocation; use bitfun_runtime_ports::{DelegationPolicy, RemoteExecPort, TerminalPort}; use std::collections::HashMap; use std::sync::Arc; @@ -70,8 +71,8 @@ pub struct ToolExecutionContext { pub context_vars: HashMap, pub subagent_parent_info: Option, pub(crate) delegation_policy: DelegationPolicy, - pub collapsed_tools: Vec, - pub unlocked_collapsed_tools: Vec, + pub deferred_tools: Vec, + pub loaded_deferred_tool_specs: Vec, /// Allowed tools list (whitelist) /// If empty, allow all registered tools /// If not empty, only allow tools in the list to be executed @@ -89,6 +90,8 @@ pub struct ToolExecutionContext { #[derive(Debug, Clone)] pub struct ToolTask { pub tool_call: ToolCall, + pub invocation: ResolvedToolInvocation, + pub invocation_resolution_error: Option, pub context: ToolExecutionContext, pub options: ToolExecutionOptions, pub state: ToolExecutionState, @@ -102,9 +105,25 @@ impl ToolTask { tool_call: ToolCall, context: ToolExecutionContext, options: ToolExecutionOptions, + ) -> Self { + let invocation = ResolvedToolInvocation::direct( + tool_call.tool_name.clone(), + tool_call.arguments.clone(), + ); + Self::new_resolved(tool_call, invocation, None, context, options) + } + + pub fn new_resolved( + tool_call: ToolCall, + invocation: ResolvedToolInvocation, + invocation_resolution_error: Option, + context: ToolExecutionContext, + options: ToolExecutionOptions, ) -> Self { Self { tool_call, + invocation, + invocation_resolution_error, context, options, state: ToolExecutionState::Queued { position: 0 }, @@ -113,13 +132,30 @@ impl ToolTask { completed_at: None, } } + + pub fn effective_tool_name(&self) -> &str { + &self.invocation.effective_tool_name + } + + pub fn effective_arguments(&self) -> &serde_json::Value { + &self.invocation.effective_arguments + } + + pub fn update_effective_arguments(&mut self, arguments: serde_json::Value) { + self.invocation + .replace_effective_arguments(arguments.clone()); + self.tool_call.arguments = self.invocation.wire_arguments.clone(); + } } /// Tool execution result wrapper #[derive(Debug, Clone)] pub struct ToolExecutionResult { pub tool_id: String, + /// Provider-facing tool name. For deferred calls this remains CallDeferredTool. pub tool_name: String, + /// Runtime target used for validation, permissions, hooks, and execution. + pub effective_tool_name: String, pub result: crate::agentic::core::ToolResult, pub execution_time_ms: u64, } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index daf2502433..53faae70fe 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -5,11 +5,12 @@ //! decoration. Concrete tools and `ToolUseContext` stay in core so this owner //! remains an equivalent structural boundary rather than a behavior migration. +mod call_deferred_tool; mod catalog; mod get_tool_spec_tool; +mod loaded_spec_state; mod materialization; mod snapshot; -mod unlock_state; use crate::agentic::tools::registry::{ProductToolDecoratorRef, ToolRegistry}; use bitfun_agent_tools::SnapshotToolDecorator; @@ -20,6 +21,7 @@ use materialization::create_product_tool_registry_from_plan; use snapshot::ProductSnapshotToolWrapper; use std::sync::Arc; +pub use call_deferred_tool::CallDeferredTool; pub(crate) use catalog::{ product_get_tool_spec_runtime, resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, resolve_product_resolved_tool_manifest, @@ -27,7 +29,7 @@ pub(crate) use catalog::{ }; pub use catalog::{ResolvedToolManifest, ResolvedVisibleTools}; pub use get_tool_spec_tool::GetToolSpecTool; -pub(crate) use unlock_state::collect_product_unlocked_collapsed_tools; +pub(crate) use loaded_spec_state::collect_product_loaded_deferred_tool_specs; #[derive(Clone)] pub(crate) struct ProductToolRuntime { @@ -101,9 +103,9 @@ mod tests { "product tool runtime owner must preserve legacy registry output" ); assert_eq!( - owner_registry.get_collapsed_tool_names(), - compatibility_registry.get_collapsed_tool_names(), - "product tool runtime owner must preserve collapsed-tool exposure" + owner_registry.get_deferred_tool_names(), + compatibility_registry.get_deferred_tool_names(), + "product tool runtime owner must preserve deferred-tool exposure" ); } @@ -133,8 +135,8 @@ mod tests { compatibility_registry.get_tool_names() ); assert_eq!( - owner_registry.get_collapsed_tool_names(), - compatibility_registry.get_collapsed_tool_names() + owner_registry.get_deferred_tool_names(), + compatibility_registry.get_deferred_tool_names() ); } @@ -155,8 +157,8 @@ mod tests { "{profile} must not expose product-full tools" ); assert!( - registry.get_collapsed_tool_names().is_empty(), - "{profile} must not expose collapsed product-full tools" + registry.get_deferred_tool_names().is_empty(), + "{profile} must not expose deferred product-full tools" ); } } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs new file mode 100644 index 0000000000..fcfb940020 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs @@ -0,0 +1,86 @@ +//! Product Tool Runtime owned CallDeferredTool gateway definition. + +use crate::agentic::tools::framework::{Tool, ToolRenderOptions, ToolResult, ValidationResult}; +use crate::agentic::tools::tool_context_runtime::ToolUseContext; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_agent_tools::{ + call_deferred_tool_description, call_deferred_tool_input_schema, + call_deferred_tool_short_description, parse_call_deferred_tool_input, CALL_DEFERRED_TOOL_NAME, +}; +use serde_json::Value; + +pub struct CallDeferredTool; + +impl CallDeferredTool { + pub fn new() -> Self { + Self + } +} + +impl Default for CallDeferredTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for CallDeferredTool { + fn name(&self) -> &str { + CALL_DEFERRED_TOOL_NAME + } + + async fn description(&self) -> BitFunResult { + Ok(call_deferred_tool_description()) + } + + fn short_description(&self) -> String { + call_deferred_tool_short_description() + } + + fn input_schema(&self) -> Value { + call_deferred_tool_input_schema() + } + + fn is_readonly(&self) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let target = input + .get("tool_name") + .and_then(Value::as_str) + .unwrap_or("?"); + format!("Calling deferred tool '{}'.", target) + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match parse_call_deferred_tool_input(input) { + Ok(_) => ValidationResult::default(), + Err(error) => ValidationResult { + result: false, + message: Some(error.to_string()), + error_code: Some(400), + meta: None, + }, + } + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Err(BitFunError::Validation( + "CallDeferredTool must be resolved by the tool pipeline".to_string(), + )) + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index 94b3e200b8..b0e33e78be 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -8,25 +8,28 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolDefinition; use bitfun_agent_tools::{ ContextualToolManifest, ContextualVisibleTools, GetToolSpecCatalogProvider, - GetToolSpecCollapsedToolSummary, GetToolSpecExecutionError, GetToolSpecRuntime, + GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolManifestDefinition, - GET_TOOL_SPEC_TOOL_NAME, + CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; use serde_json::Value; use std::sync::Arc; +const DEFERRED_TOOL_LOADING_CONTEXT_KEY: &str = "enable_deferred_tool_loading"; + #[derive(Debug, Clone)] pub struct ResolvedToolManifest { pub allowed_tool_names: Vec, pub tool_definitions: Vec, - pub collapsed_tool_names: Vec, - pub collapsed_tool_summaries: Vec, + pub deferred_tool_names: Vec, + pub deferred_tool_summaries: Vec, + pub catalog_generation: u64, } #[derive(Clone)] pub struct ResolvedVisibleTools { - pub expanded_tools: Vec>, - pub collapsed_tools: Vec>, + pub direct_tools: Vec>, + pub deferred_tools: Vec>, } fn to_core_tool_definition(definition: ToolManifestDefinition) -> ToolDefinition { @@ -40,20 +43,23 @@ fn to_core_tool_definition(definition: ToolManifestDefinition) -> ToolDefinition impl From> for ResolvedVisibleTools { fn from(value: ContextualVisibleTools) -> Self { Self { - expanded_tools: value.expanded_tools, - collapsed_tools: value.collapsed_tools, + direct_tools: value.direct_tools, + deferred_tools: value.deferred_tools, } } } impl From> for ResolvedToolManifest { fn from(value: ContextualToolManifest) -> Self { - let collapsed_tool_summaries = value - .collapsed_tools + let deferred_tool_summaries = value + .deferred_tools .iter() - .map(|tool| GetToolSpecCollapsedToolSummary { + .map(|tool| GetToolSpecDeferredToolSummary { name: tool.name().to_string(), - short_description: tool.short_description(), + short_description: match tool.dynamic_tool_info() { + Some(info) if info.mcp.is_some() => None, + _ => Some(tool.short_description()), + }, }) .collect(); @@ -64,8 +70,9 @@ impl From> for ResolvedToolManifest { .into_iter() .map(to_core_tool_definition) .collect(), - collapsed_tool_names: value.collapsed_tool_names, - collapsed_tool_summaries, + deferred_tool_names: value.deferred_tool_names, + deferred_tool_summaries, + catalog_generation: 0, } } } @@ -90,16 +97,16 @@ impl ToolCatalogSnapshotProvider for ProductToolCatalogProvider { #[async_trait::async_trait] impl GetToolSpecCatalogProvider for ProductToolCatalogProvider { - async fn collapsed_tools_for_get_tool_spec( + async fn deferred_tools_for_get_tool_spec( &self, context: Option<&ToolUseContext>, ) -> Result, String> { match context { Some(context) => self - .contextual_collapsed_tools(context) + .contextual_deferred_tools(context) .await .map_err(|error| error.to_string()), - None => Ok(self.default_collapsed_tools().await), + None => Ok(self.default_deferred_tools().await), } } @@ -112,23 +119,66 @@ impl GetToolSpecCatalogProvider for ProductToolCatalog .contextual_available_tools(context) .await .map_err(|error| error.to_string()), - None => Ok(self.default_collapsed_tools().await), + None => Ok(self.default_deferred_tools().await), } } + + async fn catalog_generation(&self) -> u64 { + let registry = get_global_tool_registry(); + let generation = registry.read().await.current_snapshot_generation(); + generation + } } impl ProductToolCatalogProvider { - async fn default_collapsed_tools(&self) -> Vec { + fn deferred_tool_loading_enabled(context: &ToolUseContext) -> bool { + context + .custom_data + .get(DEFERRED_TOOL_LOADING_CONTEXT_KEY) + .and_then(|value| { + value + .as_bool() + .or_else(|| value.as_str().and_then(|value| value.parse::().ok())) + }) + .unwrap_or(true) + } + + fn resolve_manifest_inputs( + allowed_tools: &[String], + exposure_overrides: &AgentToolPolicyOverrides, + context: &ToolUseContext, + ) -> (Vec, AgentToolPolicyOverrides) { + if Self::deferred_tool_loading_enabled(context) { + return (allowed_tools.to_vec(), exposure_overrides.clone()); + } + + let allowed_tools = allowed_tools + .iter() + .filter(|tool_name| { + tool_name.as_str() != GET_TOOL_SPEC_TOOL_NAME + && tool_name.as_str() != CALL_DEFERRED_TOOL_NAME + }) + .cloned() + .collect::>(); + let exposure_overrides = allowed_tools + .iter() + .map(|tool_name| (tool_name.clone(), ToolExposure::Direct)) + .collect(); + + (allowed_tools, exposure_overrides) + } + + async fn default_deferred_tools(&self) -> Vec { let registry = get_global_tool_registry(); let registry = registry.read().await; registry .get_all_tools() .into_iter() - .filter(|tool| tool.default_exposure() == ToolExposure::Collapsed) + .filter(|tool| tool.default_exposure() == ToolExposure::Deferred) .collect() } - async fn contextual_collapsed_tools( + async fn contextual_deferred_tools( &self, context: &ToolUseContext, ) -> BitFunResult> { @@ -140,10 +190,15 @@ impl ProductToolCatalogProvider { let policy = agent_registry .get_agent_tool_policy(agent_type, workspace_root) .await; + let (allowed_tools, exposure_overrides) = Self::resolve_manifest_inputs( + &policy.allowed_tools, + &policy.exposure_overrides, + context, + ); let visible_tools = product_tool_catalog_runtime(self) - .visible_tools(&policy.allowed_tools, &policy.exposure_overrides, context) + .visible_tools(&allowed_tools, &exposure_overrides, context) .await; - Ok(visible_tools.collapsed_tools) + Ok(visible_tools.deferred_tools) } async fn contextual_available_tools( @@ -158,11 +213,16 @@ impl ProductToolCatalogProvider { let policy = agent_registry .get_agent_tool_policy(agent_type, workspace_root) .await; + let (allowed_tools, exposure_overrides) = Self::resolve_manifest_inputs( + &policy.allowed_tools, + &policy.exposure_overrides, + context, + ); let visible_tools = product_tool_catalog_runtime(self) - .visible_tools(&policy.allowed_tools, &policy.exposure_overrides, context) + .visible_tools(&allowed_tools, &exposure_overrides, context) .await; - let mut tools = visible_tools.expanded_tools; - tools.extend(visible_tools.collapsed_tools); + let mut tools = visible_tools.direct_tools; + tools.extend(visible_tools.deferred_tools); Ok(tools) } } @@ -185,8 +245,13 @@ pub(crate) async fn resolve_product_visible_tools( context: &ToolUseContext, ) -> ContextualVisibleTools { let provider = ProductToolCatalogProvider; + let (allowed_tools, exposure_overrides) = ProductToolCatalogProvider::resolve_manifest_inputs( + allowed_tools, + exposure_overrides, + context, + ); product_tool_catalog_runtime(&provider) - .visible_tools(allowed_tools, exposure_overrides, context) + .visible_tools(&allowed_tools, &exposure_overrides, context) .await } @@ -196,8 +261,13 @@ pub(crate) async fn resolve_product_tool_manifest( context: &ToolUseContext, ) -> ContextualToolManifest { let provider = ProductToolCatalogProvider; + let (allowed_tools, exposure_overrides) = ProductToolCatalogProvider::resolve_manifest_inputs( + allowed_tools, + exposure_overrides, + context, + ); product_tool_catalog_runtime(&provider) - .tool_manifest(allowed_tools, exposure_overrides, context) + .tool_manifest(&allowed_tools, &exposure_overrides, context) .await } @@ -216,9 +286,13 @@ pub(crate) async fn resolve_product_resolved_tool_manifest( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ResolvedToolManifest { - resolve_product_tool_manifest(allowed_tools, exposure_overrides, context) - .await - .into() + let mut manifest: ResolvedToolManifest = + resolve_product_tool_manifest(allowed_tools, exposure_overrides, context) + .await + .into(); + let provider = ProductToolCatalogProvider; + manifest.catalog_generation = provider.catalog_generation().await; + manifest } pub(crate) async fn resolve_product_readonly_enabled_tools() -> Vec { @@ -235,7 +309,7 @@ pub(crate) async fn resolve_product_get_tool_spec_results( ) -> Result, GetToolSpecExecutionError> { let provider = ProductToolCatalogProvider; GetToolSpecRuntime::new(&provider, get_tool_spec_tool_name) - .call_results(input, &context.unlocked_collapsed_tools, context) + .call_results(input, &context.loaded_deferred_tool_specs, context) .await } @@ -245,17 +319,73 @@ mod tests { resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, resolve_product_resolved_tool_manifest, resolve_product_resolved_visible_tools, resolve_product_tool_manifest, ProductToolCatalogProvider, + DEFERRED_TOOL_LOADING_CONTEXT_KEY, }; use crate::agentic::agents::AgentToolPolicyOverrides; - use crate::agentic::tools::framework::{ToolExposure, ToolResult}; + use crate::agentic::tools::framework::{ + DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolExposure, ToolResult, + }; use crate::agentic::tools::registry::create_tool_registry; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::agentic::tools::ToolRuntimeRestrictions; use bitfun_agent_tools::{ - GetToolSpecCatalogProvider, ToolCatalogSnapshotProvider, GET_TOOL_SPEC_TOOL_NAME, + GetToolSpecCatalogProvider, ToolCatalogSnapshotProvider, CALL_DEFERRED_TOOL_NAME, + GET_TOOL_SPEC_TOOL_NAME, }; - use serde_json::json; + use serde_json::{json, Value}; use std::collections::HashMap; + use std::sync::Arc; + + struct DeferredMcpCatalogTool; + + #[async_trait::async_trait] + impl Tool for DeferredMcpCatalogTool { + fn name(&self) -> &str { + "mcp__github__search_repos" + } + + async fn description(&self) -> crate::util::errors::BitFunResult { + Ok("Search GitHub repositories".to_string()) + } + + fn short_description(&self) -> String { + "Search repositories through GitHub MCP".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Deferred + } + + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string" } + } + }) + } + + fn dynamic_tool_info(&self) -> Option { + Some(DynamicToolInfo { + provider_id: "github".to_string(), + provider_kind: Some("mcp".to_string()), + mcp: Some(DynamicMcpToolInfo { + server_id: "github".to_string(), + server_name: "GitHub".to_string(), + tool_name: "search_repos".to_string(), + }), + }) + } + + async fn call_impl( + &self, + _input: &serde_json::Value, + _context: &ToolUseContext, + ) -> crate::util::errors::BitFunResult> { + Ok(Vec::new()) + } + } fn tool_context(agent_type: Option<&str>) -> ToolUseContext { ToolUseContext { @@ -264,7 +394,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -306,18 +436,18 @@ mod tests { async fn product_catalog_provider_default_get_tool_spec_catalog_matches_registry() { let provider = ProductToolCatalogProvider; - let collapsed_names = provider - .collapsed_tools_for_get_tool_spec(None) + let deferred_names = provider + .deferred_tools_for_get_tool_spec(None) .await - .expect("default collapsed catalog") + .expect("default deferred catalog") .into_iter() .map(|tool| tool.name().to_string()) .collect::>(); - let expected_builtin_collapsed_names = create_tool_registry().get_collapsed_tool_names(); + let expected_builtin_deferred_names = create_tool_registry().get_deferred_tool_names(); assert!( - collapsed_names.starts_with(&expected_builtin_collapsed_names), - "GetToolSpec default catalog must preserve collapsed registry order" + deferred_names.starts_with(&expected_builtin_deferred_names), + "GetToolSpec default catalog must preserve deferred registry order" ); } @@ -326,7 +456,7 @@ mod tests { let provider = ProductToolCatalogProvider; let result = provider - .collapsed_tools_for_get_tool_spec(Some(&context_without_agent_type())) + .deferred_tools_for_get_tool_spec(Some(&context_without_agent_type())) .await; let error = match result { Ok(_) => { @@ -352,14 +482,14 @@ mod tests { ) .await; - assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); + assert_eq!(manifest.deferred_tool_names, vec!["WebFetch".to_string()]); assert_eq!( manifest .tool_definitions .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["Read", "WebFetch", "GetToolSpec"], + vec!["Read", "GetToolSpec", "CallDeferredTool"], "product manifest facade must preserve prompt-visible definition order" ); } @@ -380,22 +510,174 @@ mod tests { vec![ "Read".to_string(), "WebFetch".to_string(), - GET_TOOL_SPEC_TOOL_NAME.to_string() + GET_TOOL_SPEC_TOOL_NAME.to_string(), + "CallDeferredTool".to_string() ] ); - assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); - assert_eq!(manifest.collapsed_tool_summaries.len(), 1); - assert_eq!(manifest.collapsed_tool_summaries[0].name, "WebFetch"); + assert_eq!(manifest.deferred_tool_names, vec!["WebFetch".to_string()]); + assert_eq!(manifest.deferred_tool_summaries.len(), 1); + assert_eq!(manifest.deferred_tool_summaries[0].name, "WebFetch"); + assert!(manifest.deferred_tool_summaries[0] + .short_description + .as_deref() + .is_some_and(|description| !description.is_empty())); assert_eq!( manifest .tool_definitions .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["Read", "WebFetch", "GetToolSpec"] + vec!["Read", "GetToolSpec", "CallDeferredTool"] + ); + let gateway = manifest + .tool_definitions + .iter() + .find(|tool| tool.name == "CallDeferredTool") + .expect("deferred execution gateway definition"); + assert_eq!(gateway.parameters["required"], json!(["tool_name", "args"])); + } + + #[tokio::test] + async fn deferred_mcp_tool_omits_schema_from_manifest_but_keeps_get_tool_spec_detail() { + let registry = create_tool_registry(); + let tool_snapshot = vec![ + registry + .get_tool(GET_TOOL_SPEC_TOOL_NAME) + .expect("GetToolSpec gateway"), + registry + .get_tool("CallDeferredTool") + .expect("CallDeferredTool gateway"), + Arc::new(DeferredMcpCatalogTool) as Arc, + ]; + let context = tool_context(Some("agentic")); + let manifest = bitfun_agent_tools::resolve_contextual_tool_manifest( + &tool_snapshot, + &["mcp__github__search_repos".to_string()], + &AgentToolPolicyOverrides::default(), + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await; + + assert_eq!( + manifest.deferred_tool_names, + vec!["mcp__github__search_repos".to_string()] + ); + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|definition| definition.name.as_str()) + .collect::>(), + vec![GET_TOOL_SPEC_TOOL_NAME, "CallDeferredTool"] + ); + + let detail = bitfun_agent_tools::resolve_get_tool_spec_detail( + &manifest.deferred_tools, + "mcp__github__search_repos", + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .expect("MCP detail remains available through GetToolSpec"); + assert_eq!(detail.description, "Search GitHub repositories"); + assert_eq!(detail.input_schema["required"], json!(["query"])); + + let resolved: super::ResolvedToolManifest = manifest.into(); + assert_eq!(resolved.deferred_tool_summaries.len(), 1); + assert_eq!( + resolved.deferred_tool_summaries[0].name, + "mcp__github__search_repos" + ); + assert_eq!( + resolved.deferred_tool_summaries[0].short_description, None, + "MCP descriptions must not re-enter the deferred listing" + ); + assert_eq!( + crate::agentic::tools::product_runtime::GetToolSpecTool::build_deferred_tools_context_section( + &resolved.deferred_tool_summaries, + ) + .as_deref(), + Some("\n- mcp__github__search_repos\n") ); } + #[tokio::test] + async fn disabled_deferred_tool_loading_exposes_builtin_and_mcp_tools_directly() { + let registry = create_tool_registry(); + let tool_snapshot = vec![ + registry.get_tool("Read").expect("Read tool"), + registry + .get_tool(GET_TOOL_SPEC_TOOL_NAME) + .expect("GetToolSpec gateway"), + registry + .get_tool(CALL_DEFERRED_TOOL_NAME) + .expect("CallDeferredTool gateway"), + registry.get_tool("WebFetch").expect("WebFetch tool"), + Arc::new(DeferredMcpCatalogTool) as Arc, + ]; + let allowed_tools = vec![ + "Read".to_string(), + "WebFetch".to_string(), + GET_TOOL_SPEC_TOOL_NAME.to_string(), + CALL_DEFERRED_TOOL_NAME.to_string(), + "mcp__github__search_repos".to_string(), + ]; + let mut context = tool_context(Some("agentic")); + context.custom_data.insert( + DEFERRED_TOOL_LOADING_CONTEXT_KEY.to_string(), + Value::String("false".to_string()), + ); + + let (allowed_tools, exposure_overrides) = + ProductToolCatalogProvider::resolve_manifest_inputs( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &context, + ); + let manifest = bitfun_agent_tools::resolve_contextual_tool_manifest( + &tool_snapshot, + &allowed_tools, + &exposure_overrides, + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await; + + assert_eq!( + allowed_tools, + vec![ + "Read".to_string(), + "WebFetch".to_string(), + "mcp__github__search_repos".to_string(), + ] + ); + assert!(manifest.deferred_tool_names.is_empty()); + assert!(manifest.deferred_tools.is_empty()); + for tool_name in ["Read", "WebFetch", "mcp__github__search_repos"] { + assert!( + manifest + .tool_definitions + .iter() + .any(|definition| definition.name == tool_name), + "{tool_name} must be directly exposed when deferred loading is disabled" + ); + } + assert!( + manifest.tool_definitions.iter().all(|definition| { + definition.name != GET_TOOL_SPEC_TOOL_NAME + && definition.name != CALL_DEFERRED_TOOL_NAME + }), + "internal deferred gateways must be hidden when deferred loading is disabled" + ); + let mcp_tool = manifest + .tool_definitions + .iter() + .find(|definition| definition.name == "mcp__github__search_repos") + .expect("MCP tool must be in the direct manifest"); + assert_eq!(mcp_tool.parameters["required"], json!(["query"])); + } + #[tokio::test] async fn product_resolved_visible_tools_owner_matches_registry_visibility() { let visible = resolve_product_resolved_visible_tools( @@ -407,15 +689,19 @@ mod tests { assert_eq!( visible - .expanded_tools + .direct_tools .iter() .map(|tool| tool.name().to_string()) .collect::>(), - vec!["Read".to_string(), GET_TOOL_SPEC_TOOL_NAME.to_string()] + vec![ + "Read".to_string(), + GET_TOOL_SPEC_TOOL_NAME.to_string(), + "CallDeferredTool".to_string(), + ] ); assert_eq!( visible - .collapsed_tools + .deferred_tools .iter() .map(|tool| tool.name().to_string()) .collect::>(), @@ -440,10 +726,11 @@ mod tests { assert_eq!(data["tool_name"], "WebFetch"); assert_eq!(data["input_schema"]["type"], "object"); + assert!(data["catalog_generation"].as_u64().is_some()); } #[tokio::test] - async fn product_get_tool_spec_returns_assistant_hint_for_expanded_webfetch_in_agentic_mode() { + async fn product_get_tool_spec_returns_assistant_hint_for_direct_webfetch_in_agentic_mode() { let results = resolve_product_get_tool_spec_results( &json!({ "tool_name": "WebFetch" }), &tool_context(Some("agentic")), @@ -541,7 +828,7 @@ mod tests { } #[tokio::test] - async fn product_manifest_omits_get_tool_spec_without_collapsed_tools() { + async fn product_manifest_omits_get_tool_spec_without_deferred_tools() { let allowed_tools = vec!["Read".to_string(), "Grep".to_string()]; let manifest = resolve_product_resolved_tool_manifest( @@ -551,7 +838,7 @@ mod tests { ) .await; - assert!(manifest.collapsed_tool_names.is_empty()); + assert!(manifest.deferred_tool_names.is_empty()); assert_eq!(manifest.allowed_tool_names, allowed_tools); assert!(!manifest .tool_definitions @@ -578,7 +865,7 @@ mod tests { } #[tokio::test] - async fn product_manifest_snapshot_preserves_collapsed_tool_discovery_contract() { + async fn product_manifest_snapshot_preserves_deferred_tool_discovery_contract() { let allowed_tools = vec![ "TodoWrite".to_string(), "WebFetch".to_string(), @@ -601,13 +888,14 @@ mod tests { "Read".to_string(), "WebSearch".to_string(), GET_TOOL_SPEC_TOOL_NAME.to_string(), + "CallDeferredTool".to_string(), ], "GetToolSpec should be appended without reordering the allowed-list contract" ); assert_eq!( - manifest.collapsed_tool_names, + manifest.deferred_tool_names, vec!["WebSearch".to_string(), "WebFetch".to_string()], - "collapsed tools should follow registry snapshot order" + "deferred tools should follow registry snapshot order" ); assert_eq!( manifest @@ -615,13 +903,13 @@ mod tests { .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["Read", "WebFetch", "WebSearch", "TodoWrite", "GetToolSpec"], + vec!["Read", "TodoWrite", "GetToolSpec", "CallDeferredTool",], "prompt-visible manifest order must stay stable before owner migration" ); } #[tokio::test] - async fn product_manifest_guard_preserves_get_tool_spec_unlock_surface() { + async fn product_manifest_guard_preserves_deferred_gateway_surface() { let allowed_tools = vec![ "Read".to_string(), "WebFetch".to_string(), @@ -644,17 +932,18 @@ mod tests { "GetFileDiff".to_string(), "Git".to_string(), GET_TOOL_SPEC_TOOL_NAME.to_string(), + "CallDeferredTool".to_string(), ], "GetToolSpec insertion must preserve the runtime allowed-list contract" ); assert_eq!( - manifest.collapsed_tool_names, + manifest.deferred_tool_names, vec![ "GetFileDiff".to_string(), "WebFetch".to_string(), "Git".to_string() ], - "collapsed unlock list must follow product registry snapshot order" + "deferred loaded-spec list must follow product registry snapshot order" ); assert_eq!( manifest @@ -662,25 +951,18 @@ mod tests { .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["Read", "WebFetch", "GetToolSpec", "GetFileDiff", "Git"], + vec!["Read", "GetToolSpec", "CallDeferredTool"], "prompt-visible definitions must keep the current discovery insertion and policy order stable" ); for tool_name in ["GetFileDiff", "WebFetch", "Git"] { - let stub = manifest - .tool_definitions - .iter() - .find(|tool| tool.name == tool_name) - .unwrap_or_else(|| panic!("{tool_name} stub should exist")); assert!( - stub.description.contains(&format!( - "THIS IS A COLLAPSED TOOL. Before first use, call GetToolSpec({{\"tool_name\":\"{tool_name}\"}}) to load its schema." - )), - "collapsed stub must point to the explicit GetToolSpec unlock flow" + !manifest + .tool_definitions + .iter() + .any(|tool| tool.name == tool_name), + "deferred target {tool_name} must not enter the provider manifest" ); - assert_eq!(stub.parameters["type"], json!("object")); - assert_eq!(stub.parameters["additionalProperties"], json!(true)); - assert_eq!(stub.parameters["properties"], json!({})); } } @@ -695,16 +977,23 @@ mod tests { ) .await; - assert_eq!(manifest.allowed_tool_names, allowed_tools); - assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); + assert_eq!( + manifest.allowed_tool_names, + vec![ + GET_TOOL_SPEC_TOOL_NAME.to_string(), + "WebFetch".to_string(), + "CallDeferredTool".to_string(), + ] + ); + assert_eq!(manifest.deferred_tool_names, vec!["WebFetch".to_string()]); assert_eq!( manifest .tool_definitions .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["WebFetch", "GetToolSpec", "GetToolSpec"], - "core runtime currently mirrors the pure policy contract when GetToolSpec is already allowed" + vec!["GetToolSpec", "CallDeferredTool"], + "explicit GetToolSpec policy must still expose each deferred gateway once" ); } @@ -712,7 +1001,7 @@ mod tests { async fn product_manifest_expands_tool_when_agent_override_requests_it() { let allowed_tools = vec!["Read".to_string(), "WebFetch".to_string()]; let mut overrides = AgentToolPolicyOverrides::default(); - overrides.insert("WebFetch".to_string(), ToolExposure::Expanded); + overrides.insert("WebFetch".to_string(), ToolExposure::Direct); let manifest = resolve_product_resolved_tool_manifest( &allowed_tools, @@ -721,7 +1010,7 @@ mod tests { ) .await; - assert!(manifest.collapsed_tool_names.is_empty()); + assert!(manifest.deferred_tool_names.is_empty()); assert!(manifest .tool_definitions .iter() diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs index e3cd64978c..7cba456f9c 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs @@ -10,7 +10,7 @@ use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_agent_tools::{ build_get_tool_spec_catalog_description, build_get_tool_spec_description, - GetToolSpecCollapsedToolSummary, GetToolSpecExecutionError, GET_TOOL_SPEC_TOOL_NAME, + GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, GET_TOOL_SPEC_TOOL_NAME, }; use serde_json::Value; @@ -21,10 +21,10 @@ impl GetToolSpecTool { Self } - pub(crate) fn build_collapsed_tools_context_section( - collapsed_tools: &[GetToolSpecCollapsedToolSummary], + pub(crate) fn build_deferred_tools_context_section( + deferred_tools: &[GetToolSpecDeferredToolSummary], ) -> Option { - build_get_tool_spec_catalog_description(collapsed_tools) + build_get_tool_spec_catalog_description(deferred_tools) } } @@ -116,30 +116,35 @@ mod tests { use std::collections::HashMap; #[test] - fn collapsed_tools_context_lists_names_without_short_descriptions() { + fn deferred_tools_context_lists_names_without_short_descriptions() { let tool_name = format!("CatalogDescriptionTestTool_{}", uuid::Uuid::new_v4()); - let description = GetToolSpecTool::build_collapsed_tools_context_section(&[ - bitfun_agent_tools::GetToolSpecCollapsedToolSummary { + let description = GetToolSpecTool::build_deferred_tools_context_section(&[ + bitfun_agent_tools::GetToolSpecDeferredToolSummary { name: tool_name.clone(), - short_description: "Concise catalog entry.".to_string(), + short_description: None, }, ]) - .expect("collapsed tools section"); + .expect("deferred tools section"); assert!(description.contains(&format!("- {}", tool_name))); assert!(!description.contains("Concise catalog entry.")); } #[tokio::test] - async fn reloading_already_unlocked_tool_returns_assistant_hint() { + async fn reloading_already_loaded_tool_returns_assistant_hint() { let tool = GetToolSpecTool::new(); + let registry = crate::agentic::tools::registry::get_global_tool_registry(); + let catalog_generation = registry.read().await.current_snapshot_generation(); let context = ToolUseContext { tool_call_id: None, agent_type: None, session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: vec!["WebFetch".to_string()], + loaded_deferred_tool_specs: vec![bitfun_agent_tools::LoadedDeferredToolSpec { + tool_name: "WebFetch".to_string(), + catalog_generation, + }], primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/unlock_state.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs similarity index 62% rename from src/crates/assembly/core/src/agentic/tools/product_runtime/unlock_state.rs rename to src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs index c01fd7c705..e5a2463ed9 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/unlock_state.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs @@ -1,46 +1,48 @@ -//! Product tool collapsed-unlock state owner. +//! Product deferred-tool loaded-spec state owner. use crate::agentic::core::{Message, MessageContent}; -use bitfun_agent_tools::{collect_loaded_collapsed_tool_names, GetToolSpecLoadObservation}; +use bitfun_agent_tools::{ + collect_loaded_deferred_tool_specs, GetToolSpecLoadObservation, LoadedDeferredToolSpec, +}; #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct ProductCollapsedUnlockState { - unlocked_tools: Vec, +pub(crate) struct ProductLoadedDeferredToolSpecs { + loaded_specs: Vec, } -impl ProductCollapsedUnlockState { - pub(crate) fn from_messages(messages: &[Message], collapsed_tools: &[String]) -> Self { +impl ProductLoadedDeferredToolSpecs { + pub(crate) fn from_messages(messages: &[Message], deferred_tools: &[String]) -> Self { let observations = messages .iter() .filter_map(get_tool_spec_load_observation) .collect::>(); Self { - unlocked_tools: collect_loaded_collapsed_tool_names( + loaded_specs: collect_loaded_deferred_tool_specs( &observations, - collapsed_tools, + deferred_tools, crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME, ), } } #[cfg(test)] - fn is_unlocked(&self, tool_name: &str) -> bool { - self.unlocked_tools + fn is_loaded(&self, tool_name: &str) -> bool { + self.loaded_specs .iter() - .any(|unlocked_tool| unlocked_tool == tool_name) + .any(|spec| spec.tool_name == tool_name) } - pub(crate) fn into_unlocked_tools(self) -> Vec { - self.unlocked_tools + pub(crate) fn into_loaded_specs(self) -> Vec { + self.loaded_specs } } -pub(crate) fn collect_product_unlocked_collapsed_tools( +pub(crate) fn collect_product_loaded_deferred_tool_specs( messages: &[Message], - collapsed_tools: &[String], -) -> Vec { - ProductCollapsedUnlockState::from_messages(messages, collapsed_tools).into_unlocked_tools() + deferred_tools: &[String], +) -> Vec { + ProductLoadedDeferredToolSpecs::from_messages(messages, deferred_tools).into_loaded_specs() } fn get_tool_spec_load_observation(message: &Message) -> Option> { @@ -57,23 +59,33 @@ fn get_tool_spec_load_observation(message: &Message) -> Option bitfun_agent_tools::LoadedDeferredToolSpec { + bitfun_agent_tools::LoadedDeferredToolSpec { + tool_name: tool_name.to_string(), + catalog_generation: 42, + } + } + #[test] - fn product_unlock_state_collects_visible_get_tool_spec_results() { + fn product_loaded_spec_state_collects_visible_get_tool_spec_results() { let visible_get_tool_spec_result = Message::tool_result(ToolResult { tool_id: "tool-1".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ "tool_name": "WebFetch", + "catalog_generation": 42, }), result_for_assistant: None, is_error: false, @@ -83,8 +95,10 @@ mod tests { let hidden_get_tool_spec_result = Message::tool_result(ToolResult { tool_id: "tool-2".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ "tool_name": "Read", + "catalog_generation": 42, }), result_for_assistant: None, is_error: false, @@ -94,8 +108,10 @@ mod tests { let failed_get_tool_spec_result = Message::tool_result(ToolResult { tool_id: "tool-3".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ "tool_name": "GetFileDiff", + "catalog_generation": 42, }), result_for_assistant: None, is_error: true, @@ -103,7 +119,7 @@ mod tests { image_attachments: None, }); - let unlocked = collect_product_unlocked_collapsed_tools( + let loaded_specs = collect_product_loaded_deferred_tool_specs( &[ visible_get_tool_spec_result, hidden_get_tool_spec_result, @@ -112,19 +128,21 @@ mod tests { &["WebFetch".to_string(), "GetFileDiff".to_string()], ); - assert_eq!(unlocked, vec!["WebFetch".to_string()]); + assert_eq!(loaded_specs, vec![loaded_spec("WebFetch")]); } #[test] - fn product_unlock_state_dedupes_and_filters_runtime_unlocks() { - let unlocked = collect_product_unlocked_collapsed_tools( + fn product_loaded_spec_state_dedupes_and_filters_results() { + let loaded_specs = collect_product_loaded_deferred_tool_specs( &[ Message::tool_result(ToolResult { tool_id: "tool-1".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ - "tool_name": "WebFetch", - }), + "tool_name": "WebFetch", + "catalog_generation": 42, + }), result_for_assistant: None, is_error: false, duration_ms: Some(1), @@ -133,9 +151,11 @@ mod tests { Message::tool_result(ToolResult { tool_id: "tool-2".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ - "tool_name": "WebFetch", - }), + "tool_name": "WebFetch", + "catalog_generation": 42, + }), result_for_assistant: None, is_error: false, duration_ms: Some(1), @@ -144,9 +164,11 @@ mod tests { Message::tool_result(ToolResult { tool_id: "tool-3".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ - "tool_name": "Git", - }), + "tool_name": "Git", + "catalog_generation": 42, + }), result_for_assistant: None, is_error: false, duration_ms: Some(1), @@ -155,9 +177,11 @@ mod tests { Message::tool_result(ToolResult { tool_id: "tool-4".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ - "tool_name": "Read", - }), + "tool_name": "Read", + "catalog_generation": 42, + }), result_for_assistant: None, is_error: false, duration_ms: Some(1), @@ -166,9 +190,11 @@ mod tests { Message::tool_result(ToolResult { tool_id: "tool-5".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ - "tool_name": "GetFileDiff", - }), + "tool_name": "GetFileDiff", + "catalog_generation": 42, + }), result_for_assistant: None, is_error: true, duration_ms: Some(1), @@ -177,6 +203,7 @@ mod tests { Message::tool_result(ToolResult { tool_id: "tool-6".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ "tool_name": 42, }), @@ -188,9 +215,11 @@ mod tests { Message::tool_result(ToolResult { tool_id: "tool-7".to_string(), tool_name: "Read".to_string(), + effective_tool_name: None, result: json!({ - "tool_name": "GetFileDiff", - }), + "tool_name": "GetFileDiff", + "catalog_generation": 42, + }), result_for_assistant: None, is_error: false, duration_ms: Some(1), @@ -204,17 +233,22 @@ mod tests { ], ); - assert_eq!(unlocked, vec!["Git".to_string(), "WebFetch".to_string()]); + assert_eq!( + loaded_specs, + vec![loaded_spec("Git"), loaded_spec("WebFetch")] + ); } #[test] - fn product_collapsed_unlock_state_preserves_message_derived_lifecycle() { - let state = ProductCollapsedUnlockState::from_messages( + fn product_deferred_loaded_spec_state_preserves_message_derived_lifecycle() { + let state = ProductLoadedDeferredToolSpecs::from_messages( &[Message::tool_result(ToolResult { tool_id: "tool-1".to_string(), tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, result: json!({ "tool_name": "Git", + "catalog_generation": 42, }), result_for_assistant: None, is_error: false, @@ -224,8 +258,8 @@ mod tests { &["Git".to_string(), "WebFetch".to_string()], ); - assert!(state.is_unlocked("Git")); - assert!(!state.is_unlocked("WebFetch")); - assert_eq!(state.into_unlocked_tools(), vec!["Git".to_string()]); + assert!(state.is_loaded("Git")); + assert!(!state.is_loaded("WebFetch")); + assert_eq!(state.into_loaded_specs(), vec![loaded_spec("Git")]); } } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index f1e92fc35c..77474acefb 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -2,6 +2,7 @@ use crate::agentic::tools::framework::Tool; use crate::agentic::tools::implementations::*; +use crate::agentic::tools::product_runtime::CallDeferredTool; use crate::agentic::tools::registry::ProductToolDecoratorRef; use bitfun_agent_tools::{ StaticToolProviderFactory, ToolRegistry as AgentToolRegistry, ToolRuntimeAssembly, @@ -47,6 +48,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "CreatePlan" => Some(Arc::new(CreatePlanTool::new())), "submit_code_review" => Some(Arc::new(CodeReviewTool::new())), "GetToolSpec" => Some(Arc::new(GetToolSpecTool::new())), + "CallDeferredTool" => Some(Arc::new(CallDeferredTool::new())), "GetFileDiff" => Some(Arc::new(GetFileDiffTool::new())), "SessionControl" => Some(Arc::new(SessionControlTool::new())), "SessionMessage" => Some(Arc::new(SessionMessageTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 2350aacb2e..79a538d9e7 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -134,12 +134,16 @@ impl ToolRegistry { self.inner.get_dynamic_tool_info(name) } - pub fn is_tool_collapsed(&self, name: &str) -> bool { - self.inner.is_tool_collapsed(name) + pub fn is_tool_deferred(&self, name: &str) -> bool { + self.inner.is_tool_deferred(name) } - pub fn get_collapsed_tool_names(&self) -> Vec { - self.inner.get_collapsed_tool_names() + pub fn get_deferred_tool_names(&self) -> Vec { + self.inner.get_deferred_tool_names() + } + + pub fn current_snapshot_generation(&self) -> u64 { + self.inner.current_snapshot_generation() } /// Get all tool names @@ -238,6 +242,7 @@ mod tests { struct DynamicMetadataTool { name: String, dynamic_info: Option, + exposure: crate::agentic::tools::framework::ToolExposure, } #[async_trait] @@ -258,6 +263,10 @@ mod tests { json!({ "type": "object" }) } + fn default_exposure(&self) -> crate::agentic::tools::framework::ToolExposure { + self.exposure + } + fn dynamic_provider_id(&self) -> Option<&str> { self.dynamic_info .as_ref() @@ -298,6 +307,7 @@ mod tests { provider_kind: None, mcp: None, }), + exposure: crate::agentic::tools::framework::ToolExposure::Direct, }) } @@ -319,6 +329,7 @@ mod tests { tool_name: tool_name.to_string(), }), }), + exposure: crate::agentic::tools::framework::ToolExposure::Deferred, }) } @@ -416,6 +427,7 @@ mod tests { "CreatePlan", "submit_code_review", "GetToolSpec", + "CallDeferredTool", "GetFileDiff", "CreateCanvas", "ReadCanvas", @@ -508,9 +520,9 @@ mod tests { "runtime assembly must preserve legacy create_tool_registry output" ); assert_eq!( - assembled_registry.get_collapsed_tool_names(), - compatibility_registry.get_collapsed_tool_names(), - "runtime assembly must preserve product collapsed-tool catalog" + assembled_registry.get_deferred_tool_names(), + compatibility_registry.get_deferred_tool_names(), + "runtime assembly must preserve product deferred-tool catalog" ); for tool_name in ["Write", "Edit", "Delete"] { @@ -541,9 +553,9 @@ mod tests { "product tool runtime owner must preserve legacy registry output" ); assert_eq!( - owner_registry.get_collapsed_tool_names(), - compatibility_registry.get_collapsed_tool_names(), - "product tool runtime owner must preserve collapsed-tool exposure" + owner_registry.get_deferred_tool_names(), + compatibility_registry.get_deferred_tool_names(), + "product tool runtime owner must preserve deferred-tool exposure" ); } @@ -559,9 +571,9 @@ mod tests { "custom decorator assembly must keep provider tool order stable" ); assert_eq!( - registry.get_collapsed_tool_names(), - compatibility_registry.get_collapsed_tool_names(), - "custom decorator assembly must keep collapsed exposure stable" + registry.get_deferred_tool_names(), + compatibility_registry.get_deferred_tool_names(), + "custom decorator assembly must keep deferred exposure stable" ); for tool_name in ["Write", "GetToolSpec", "WebFetch"] { @@ -577,23 +589,23 @@ mod tests { } #[test] - fn registry_marks_collapsed_tools_for_get_tool_spec() { + fn registry_marks_deferred_tools_for_get_tool_spec() { let registry = create_tool_registry(); - assert!(registry.is_tool_collapsed("WebFetch")); - assert!(registry.is_tool_collapsed("GetFileDiff")); - assert!(!registry.is_tool_collapsed("GetToolSpec")); - assert!(registry.is_tool_collapsed("Git")); - assert!(registry.is_tool_collapsed("ReviewPlatform")); - assert!(!registry.is_tool_collapsed("InitMiniApp")); + assert!(registry.is_tool_deferred("WebFetch")); + assert!(registry.is_tool_deferred("GetFileDiff")); + assert!(!registry.is_tool_deferred("GetToolSpec")); + assert!(registry.is_tool_deferred("Git")); + assert!(registry.is_tool_deferred("ReviewPlatform")); + assert!(!registry.is_tool_deferred("InitMiniApp")); } #[test] - fn registry_preserves_collapsed_tool_manifest_for_owner_migration() { + fn registry_preserves_deferred_tool_manifest_for_owner_migration() { let registry = create_tool_registry(); assert_eq!( - registry.get_collapsed_tool_names(), + registry.get_deferred_tool_names(), vec![ "CreatePlan", "GetFileDiff", @@ -614,7 +626,7 @@ mod tests { "ComputerUse", "Playbook", ], - "collapsed tool manifest must stay stable before moving registry or manifest ownership" + "deferred tool manifest must stay stable before moving registry or manifest ownership" ); } @@ -785,6 +797,7 @@ mod tests { .expect("mcp descriptor"); assert_eq!(descriptor.provider_id.as_deref(), Some("github-server-id")); + assert!(registry.is_tool_deferred("mcp__github__search_repos")); assert_eq!( registry .get_dynamic_tool_info("mcp__github__search_repos") @@ -795,6 +808,50 @@ mod tests { "search_repos" ); } + + #[test] + fn mcp_catalog_refresh_advances_generation_and_invalidates_loaded_specs() { + let mut registry = ToolRegistry::new(); + registry.register_tool(mcp_dynamic_tool( + "mcp__github__search_repos", + None, + "github-server-id", + "GitHub", + "search_repos", + )); + let loaded_generation = registry.current_snapshot_generation(); + + registry.unregister_mcp_server_tools("github-server-id"); + let removed_generation = registry.current_snapshot_generation(); + assert!(removed_generation > loaded_generation); + assert!(registry.get_tool("mcp__github__search_repos").is_none()); + + registry.register_tool(mcp_dynamic_tool( + "mcp__github__search_repos", + None, + "github-server-id", + "GitHub", + "search_repos", + )); + let refreshed_generation = registry.current_snapshot_generation(); + + assert!(refreshed_generation > removed_generation); + let error = bitfun_agent_tools::validate_deferred_tool_usage( + "mcp__github__search_repos", + true, + &["mcp__github__search_repos".to_string()], + &[bitfun_agent_tools::LoadedDeferredToolSpec { + tool_name: "mcp__github__search_repos".to_string(), + catalog_generation: loaded_generation, + }], + refreshed_generation, + bitfun_agent_tools::GET_TOOL_SPEC_TOOL_NAME, + ) + .expect_err("refresh must invalidate the previously loaded MCP spec"); + + assert!(error.to_string().contains("loaded spec for deferred tool")); + assert!(error.to_string().contains("is stale")); + } #[test] fn registry_exposes_controlhub_and_computer_use() { let registry = create_tool_registry(); diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index ffe37ddbab..7c993eb69c 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -36,7 +36,9 @@ use bitfun_agent_runtime::checkpoint::{ LightCheckpointWorkspaceFacts, }; use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; -use bitfun_agent_tools::{PortableToolContextProvider, ToolContextFacts, ToolWorkspaceKind}; +use bitfun_agent_tools::{ + LoadedDeferredToolSpec, PortableToolContextProvider, ToolContextFacts, ToolWorkspaceKind, +}; #[cfg(feature = "canvas-runtime")] use bitfun_product_domains::canvas::CanvasStoragePort; use bitfun_runtime_ports::{DelegationPolicy, RemoteExecPort, TerminalPort, ToolRuntimeHandles}; @@ -63,7 +65,7 @@ pub struct ToolUseContext { pub session_id: Option, pub dialog_turn_id: Option, pub workspace: Option, - pub unlocked_collapsed_tools: Vec, + pub loaded_deferred_tool_specs: Vec, pub primary_model_facts: PrimaryModelFacts, /// Extended context data passed from execution layer to tools. pub custom_data: HashMap, @@ -164,7 +166,7 @@ impl ToolUseContext { session_id: None, dialog_turn_id: None, workspace, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -245,7 +247,7 @@ pub(crate) fn build_tool_use_context_for_execution_context( session_id: Some(context.session_id.clone()), dialog_turn_id: Some(context.dialog_turn_id.clone()), workspace: context.workspace.clone(), - unlocked_collapsed_tools: context.unlocked_collapsed_tools.clone(), + loaded_deferred_tool_specs: context.loaded_deferred_tool_specs.clone(), primary_model_facts: context.primary_model_facts.clone(), custom_data: build_tool_context_custom_data(context), computer_use_host, @@ -278,7 +280,7 @@ pub(crate) fn build_tool_description_context( session_id: None, dialog_turn_id: None, workspace: workspace.cloned(), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts, custom_data, computer_use_host: None, @@ -716,10 +718,18 @@ mod context_facts_tests { }; use crate::agentic::WorkspaceBinding; use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use bitfun_agent_tools::LoadedDeferredToolSpec; use std::collections::{BTreeSet, HashMap}; use std::path::PathBuf; use tool_runtime::context::PrimaryModelFacts; + fn loaded_spec(tool_name: &str) -> LoadedDeferredToolSpec { + LoadedDeferredToolSpec { + tool_name: tool_name.to_string(), + catalog_generation: 0, + } + } + fn local_context(root: &str) -> ToolUseContext { ToolUseContext { tool_call_id: None, @@ -727,7 +737,7 @@ mod context_facts_tests { session_id: None, dialog_turn_id: None, workspace: Some(WorkspaceBinding::new(None, PathBuf::from(root))), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -744,7 +754,7 @@ mod context_facts_tests { session_id: Some("session-1".to_string()), dialog_turn_id: Some("turn-1".to_string()), workspace: Some(WorkspaceBinding::new(None, PathBuf::from("/repo/project"))), - unlocked_collapsed_tools: vec!["WebFetch".to_string()], + loaded_deferred_tool_specs: vec![loaded_spec("WebFetch")], primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -789,7 +799,7 @@ mod context_facts_tests { session_id: Some("session-runtime".to_string()), dialog_turn_id: Some("turn-runtime".to_string()), workspace: Some(WorkspaceBinding::new(None, PathBuf::from("/repo/runtime"))), - unlocked_collapsed_tools: vec!["WebFetch".to_string(), "Git".to_string()], + loaded_deferred_tool_specs: vec![loaded_spec("WebFetch"), loaded_spec("Git")], primary_model_facts: PrimaryModelFacts::default(), custom_data, computer_use_host: None, @@ -851,7 +861,7 @@ mod context_facts_tests { "Dev SSH".to_string(), session_identity, )), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -904,7 +914,7 @@ mod path_resolution_tests { session_id: None, dialog_turn_id: None, workspace: Some(WorkspaceBinding::new(None, PathBuf::from(root))), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -928,7 +938,7 @@ mod path_resolution_tests { "Dev SSH".to_string(), session_identity, )), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -954,7 +964,7 @@ mod path_resolution_tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -1233,7 +1243,7 @@ mod call_runtime_tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -1270,7 +1280,7 @@ mod call_runtime_tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, @@ -1311,7 +1321,7 @@ mod call_runtime_tests { session_id: Some("subagent-session".to_string()), dialog_turn_id: Some("subagent-turn".to_string()), workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: PrimaryModelFacts::default(), custom_data, computer_use_host: None, @@ -1368,7 +1378,7 @@ mod context_builder_tests { assert!(context.session_id.is_none()); assert!(context.dialog_turn_id.is_none()); assert!(context.workspace.is_none()); - assert!(context.unlocked_collapsed_tools.is_empty()); + assert!(context.loaded_deferred_tool_specs.is_empty()); assert!(context.cancellation_token().is_none()); assert!(context.workspace_services().is_none()); assert!(context.runtime_tool_restrictions.is_tool_allowed("Write")); @@ -1393,12 +1403,20 @@ mod task_context_tests { SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolTask, }; use crate::agentic::tools::ToolRuntimeRestrictions; + use bitfun_agent_tools::LoadedDeferredToolSpec; use bitfun_runtime_ports::DelegationPolicy; use serde_json::json; use std::collections::{BTreeSet, HashMap}; use tokio_util::sync::CancellationToken; use tool_runtime::context::PrimaryModelFacts; + fn loaded_spec(tool_name: &str) -> LoadedDeferredToolSpec { + LoadedDeferredToolSpec { + tool_name: tool_name.to_string(), + catalog_generation: 0, + } + } + fn task_with_context_vars() -> ToolTask { let mut context_vars = HashMap::new(); context_vars.insert("turn_index".to_string(), "7".to_string()); @@ -1446,8 +1464,8 @@ mod task_context_tests { dialog_turn_id: "parent_turn".to_string(), }), delegation_policy: DelegationPolicy::top_level().spawn_child(), - collapsed_tools: vec!["WebFetch".to_string()], - unlocked_collapsed_tools: vec!["WebFetch".to_string()], + deferred_tools: vec!["WebFetch".to_string()], + loaded_deferred_tool_specs: vec![loaded_spec("WebFetch")], allowed_tools: vec!["WebFetch".to_string()], runtime_tool_restrictions: ToolRuntimeRestrictions { allowed_tool_names: BTreeSet::from(["WebFetch".to_string()]), @@ -1474,7 +1492,10 @@ mod task_context_tests { assert_eq!(context.agent_type.as_deref(), Some("agent")); assert_eq!(context.session_id.as_deref(), Some("session_1")); assert_eq!(context.dialog_turn_id.as_deref(), Some("turn_1")); - assert_eq!(context.unlocked_collapsed_tools, vec!["WebFetch"]); + assert_eq!( + context.loaded_deferred_tool_specs, + vec![loaded_spec("WebFetch")] + ); assert!(context.cancellation_token().is_some()); assert!(context .runtime_tool_restrictions diff --git a/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs b/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs index 82cfdd6e92..90ebd5a89f 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_result_storage.rs @@ -26,18 +26,38 @@ const READ_TOOL_NAME: &str = "Read"; const BASH_TOOL_NAME: &str = "Bash"; const SHELL_MAX_TOOL_RESULT_CHARS: usize = 30_000; +fn effective_tool_name(result: &ToolResult) -> &str { + result + .effective_tool_name + .as_deref() + .unwrap_or(&result.tool_name) +} + +#[cfg(test)] pub(crate) async fn maybe_persist_large_tool_result( + result: ToolResult, + context: &ToolUseContext, +) -> ToolResult { + let effective_tool_name = effective_tool_name(&result).to_string(); + maybe_persist_large_tool_result_for_tool(result, &effective_tool_name, context).await +} + +pub(crate) async fn maybe_persist_large_tool_result_for_tool( mut result: ToolResult, + effective_tool_name: &str, context: &ToolUseContext, ) -> ToolResult { let policy = ToolResultStoragePolicy::default(); - if should_skip_tool_result(&result) || visible_content_is_compacted(&result) { + if should_skip_tool_result(&result, effective_tool_name) + || visible_content_is_compacted(&result) + { return result; } - let per_tool_limit = effective_per_tool_limit(&result.tool_name, policy); + let per_tool_limit = effective_per_tool_limit(effective_tool_name, policy); let visible_chars = result_visible_content(&result).chars().count(); - let content_override = content_override_if_oversized(&result, per_tool_limit); + let content_override = + content_override_if_oversized(&result, effective_tool_name, per_tool_limit); if visible_chars <= per_tool_limit && content_override.is_none() && !json_result_is_oversized(&result, per_tool_limit) @@ -53,7 +73,7 @@ pub(crate) async fn maybe_persist_large_tool_result( Err(error) => { warn!( "Failed to persist oversized tool result: tool_name={}, tool_id={}, error={}", - result.tool_name, result.tool_id, error + effective_tool_name, result.tool_id, error ); result } @@ -99,7 +119,7 @@ pub(crate) async fn apply_round_tool_result_budget( Err(error) => { warn!( "Failed to persist round-budget tool result: tool_name={}, tool_id={}, error={}", - result.tool_name, result.tool_id, error + effective_tool_name(result), result.tool_id, error ); } } @@ -115,8 +135,8 @@ pub(crate) async fn apply_round_tool_result_budget( results } -fn should_skip_tool_result(result: &ToolResult) -> bool { - result.tool_name == GET_TOOL_SPEC_TOOL_NAME +fn should_skip_tool_result(result: &ToolResult, effective_tool_name: &str) -> bool { + effective_tool_name == GET_TOOL_SPEC_TOOL_NAME || result .image_attachments .as_ref() @@ -127,7 +147,7 @@ fn collect_round_budget_candidates(results: &[ToolResult]) -> Vec } } -fn content_override_if_oversized(result: &ToolResult, limit: usize) -> Option { - if result.tool_name != BASH_TOOL_NAME { +fn content_override_if_oversized( + result: &ToolResult, + effective_tool_name: &str, + limit: usize, +) -> Option { + if effective_tool_name != BASH_TOOL_NAME { return None; } @@ -362,7 +386,7 @@ mod tests { session_id: Some("session_1".to_string()), dialog_turn_id: Some("turn_1".to_string()), workspace: Some(WorkspaceBinding::new(None, root)), - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data, computer_use_host: None, @@ -383,6 +407,7 @@ mod tests { ToolResult { tool_id: tool_id.to_string(), tool_name: tool_name.to_string(), + effective_tool_name: None, result: json!({ "content": text }), result_for_assistant: Some(text), is_error: false, @@ -395,6 +420,7 @@ mod tests { ToolResult { tool_id: tool_id.to_string(), tool_name: "Bash".to_string(), + effective_tool_name: None, result: json!({ "success": false, "output": output, @@ -498,6 +524,37 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[tokio::test] + async fn deferred_mcp_large_result_uses_effective_policy_and_keeps_wire_identity() { + let root = temp_workspace("deferred-mcp"); + let context = test_context(root.clone()); + let result = tool_result( + "mcp_call_1", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + "x".repeat(DEFAULT_MAX_TOOL_RESULT_CHARS + 1), + ); + + let processed = + maybe_persist_large_tool_result_for_tool(result, "mcp__github__search_repos", &context) + .await; + + assert_eq!( + processed.tool_name, + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME + ); + assert!(processed + .result_for_assistant + .as_deref() + .unwrap_or_default() + .starts_with(PERSISTED_OUTPUT_TAG)); + let output_path = context + .current_workspace_session_tool_result_path("session_1", "mcp_call_1.txt") + .expect("deferred MCP tool result path"); + assert!(output_path.exists()); + + let _ = std::fs::remove_dir_all(root); + } + #[tokio::test] async fn bash_full_output_persists_even_when_assistant_text_is_already_truncated() { let root = temp_workspace("bash"); diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 2abab38623..bbc6fd82a5 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -540,6 +540,10 @@ pub struct AIConfig { #[serde(default = "default_skip_tool_confirmation")] pub skip_tool_confirmation: bool, + /// Whether tools with deferred exposure load their schemas on demand. + #[serde(default = "default_enable_deferred_tool_loading")] + pub enable_deferred_tool_loading: bool, + /// Debug-mode configuration (log path, language templates, etc.). #[serde(default)] pub debug_mode_config: DebugModeConfig, @@ -762,6 +766,10 @@ fn default_skip_tool_confirmation() -> bool { true } +fn default_enable_deferred_tool_loading() -> bool { + true +} + fn default_subagent_max_concurrency() -> usize { 5 } @@ -1513,6 +1521,7 @@ impl Default for AIConfig { tool_execution_timeout_secs: default_tool_execution_timeout(), tool_confirmation_timeout_secs: default_tool_confirmation_timeout(), skip_tool_confirmation: true, + enable_deferred_tool_loading: default_enable_deferred_tool_loading(), debug_mode_config: DebugModeConfig::default(), computer_use_enabled: false, browser_control_preferred_browser: String::new(), @@ -2072,6 +2081,7 @@ mod tests { assert_eq!(config.stream_idle_timeout_secs, Some(600)); assert_eq!(config.stream_ttft_timeout_secs, Some(600)); + assert!(config.enable_deferred_tool_loading); assert_eq!(config.subagent_max_concurrency, 5); assert_eq!( config.subagent_batch_execution_policy, diff --git a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs index 7c5fa42185..d480926606 100644 --- a/src/crates/assembly/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/assembly/core/src/service/mcp/adapter/tool.rs @@ -3,7 +3,8 @@ //! Wraps MCP tools as implementations of BitFun's `Tool` trait. use crate::agentic::tools::framework::{ - DynamicToolInfo, Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, + DynamicToolInfo, Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, + ValidationResult, }; use crate::service::mcp::protocol::{MCPTool, MCPToolResult}; use crate::service::mcp::server::MCPConnection; @@ -22,6 +23,8 @@ use log::{debug, error, info, warn}; use serde_json::Value; use std::sync::Arc; +const MCP_TOOL_DEFAULT_EXPOSURE: ToolExposure = ToolExposure::Deferred; + /// MCP tool wrapper that adapts an MCP tool to BitFun's `Tool`. struct MCPToolWrapper { mcp_tool: MCPTool, @@ -76,6 +79,10 @@ impl Tool for MCPToolWrapper { ) } + fn default_exposure(&self) -> ToolExposure { + MCP_TOOL_DEFAULT_EXPOSURE + } + fn input_schema(&self) -> Value { self.mcp_tool.input_schema.clone() } @@ -274,3 +281,13 @@ impl Default for MCPToolAdapter { Self::new() } } + +#[cfg(test)] +mod tests { + use super::{ToolExposure, MCP_TOOL_DEFAULT_EXPOSURE}; + + #[test] + fn mcp_tool_wrapper_defaults_to_deferred_exposure() { + assert_eq!(MCP_TOOL_DEFAULT_EXPOSURE, ToolExposure::Deferred); + } +} diff --git a/src/crates/assembly/core/src/service/session/mod.rs b/src/crates/assembly/core/src/service/session/mod.rs index 8f8db40f07..91df694688 100644 --- a/src/crates/assembly/core/src/service/session/mod.rs +++ b/src/crates/assembly/core/src/service/session/mod.rs @@ -2,3 +2,22 @@ pub use bitfun_services_core::session::types; pub use bitfun_services_core::session::*; + +pub fn effective_tool_identity(item: &ToolItemData) -> (&str, &serde_json::Value) { + bitfun_agent_tools::effective_tool_invocation(&item.tool_name, &item.tool_call.input) +} + +pub trait ToolItemIdentityExt { + fn effective_name(&self) -> &str; + fn effective_input(&self) -> &serde_json::Value; +} + +impl ToolItemIdentityExt for ToolItemData { + fn effective_name(&self) -> &str { + effective_tool_identity(self).0 + } + + fn effective_input(&self) -> &serde_json::Value { + effective_tool_identity(self).1 + } +} diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index 02e93ce510..b1fe869811 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -1,7 +1,7 @@ use crate::agentic::persistence::PersistenceManager; use crate::service::session::{ collect_hidden_subagent_cascade, DialogTurnData, DialogTurnKind, ModelRoundData, - SessionMetadata, ToolItemData, TurnStatus, + SessionMetadata, ToolItemData, ToolItemIdentityExt, TurnStatus, }; use crate::service::session_usage::classifier::classify_tool_usage; use crate::service::session_usage::redaction::{ @@ -836,12 +836,14 @@ fn build_tool_breakdown(turns: &[DialogTurnData]) -> Vec { for turn in turns { for tool in iter_turn_tools(turn) { - let label = redact_usage_label(&tool.tool_name, 80); + let tool_name = tool.effective_name(); + let tool_input = tool.effective_input(); + let label = redact_usage_label(tool_name, 80); let row = by_tool .entry(label.value.clone()) .or_insert_with(|| UsageToolBreakdown { tool_name: label.value.clone(), - category: classify_tool_usage(&tool.tool_name, Some(&tool.tool_call.input)), + category: classify_tool_usage(tool_name, Some(tool_input)), call_count: 0, success_count: 0, error_count: 0, @@ -1003,7 +1005,7 @@ fn build_file_breakdown_from_tool_inputs( for turn in turns { for tool in iter_turn_tools(turn) { - if !is_file_modification_tool(&tool.tool_name) { + if !is_file_modification_tool(tool.effective_name()) { continue; } @@ -1075,7 +1077,7 @@ fn build_compression_breakdown(turns: &[DialogTurnData]) -> UsageCompressionBrea .filter(|turn| turn.kind == DialogTurnKind::ManualCompaction) .count() as u64; let automatic_compaction_count = iter_tools(turns) - .filter(|tool| tool.tool_name.to_lowercase().contains("compaction")) + .filter(|tool| tool.effective_name().to_lowercase().contains("compaction")) .count() as u64; UsageCompressionBreakdown { @@ -1119,7 +1121,7 @@ fn build_error_breakdown(turns: &[DialogTurnData]) -> UsageErrorBreakdown { .as_ref() .is_some_and(|result| !result.success) }) { - let label = redact_usage_label(&tool.tool_name, 80); + let label = redact_usage_label(tool.effective_name(), 80); let row = tool_error_counts .entry(label.value.clone()) .or_insert_with(|| UsageErrorExample { @@ -1212,7 +1214,7 @@ fn build_slowest_spans( } for tool in iter_turn_tools(turn) { - let label = redact_usage_label(&tool.tool_name, 80); + let label = redact_usage_label(tool.effective_name(), 80); if let Some(duration_ms) = tool_duration_ms(tool) { spans.push(UsageSlowSpan { label: label.value, @@ -1321,7 +1323,7 @@ fn tool_duration_ms(tool: &ToolItemData) -> Option { } fn tool_input_summary(tool: &ToolItemData) -> Option { - let input = tool.tool_call.input.as_object()?; + let input = tool.effective_input().as_object()?; let command = input .get("command") .and_then(|value| value.as_str()) @@ -1348,7 +1350,7 @@ fn tool_input_summary(tool: &ToolItemData) -> Option { } fn tool_timeout_seconds(tool: &ToolItemData) -> Option { - let input = tool.tool_call.input.as_object()?; + let input = tool.effective_input().as_object()?; input .get("timeout_seconds") .and_then(|value| value.as_u64()) @@ -1474,7 +1476,7 @@ fn is_file_modification_tool(tool_name: &str) -> bool { } fn extract_file_path(tool: &ToolItemData) -> Option { - let input = tool.tool_call.input.as_object()?; + let input = tool.effective_input().as_object()?; ["file_path", "path", "filePath", "target_file", "filename"] .into_iter() .find_map(|key| input.get(key).and_then(|value| value.as_str())) @@ -1888,6 +1890,30 @@ mod tests { assert_eq!(report.files.files[0].operation_count, 1); } + #[test] + fn report_classifies_deferred_calls_by_effective_identity_and_nested_args() { + let request = test_request(None); + let tool = test_tool_item_with_input( + "tool-deferred", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + Some(true), + 120, + serde_json::json!({ + "tool_name": "write_file", + "args": { "path": "D:/workspace/bitfun/src/main.rs" } + }), + ); + let turn = test_turn_with_tools("turn-1", 0, DialogTurnKind::UserDialog, vec![tool]); + + let report = + build_session_usage_report_from_turns(request, &[turn], &[], 1_778_347_200_000); + + assert_eq!(report.tools.len(), 1); + assert_eq!(report.tools[0].tool_name, "write_file"); + assert_eq!(report.files.files.len(), 1); + assert_eq!(report.files.files[0].path_label, "src/main.rs"); + } + #[test] fn report_uses_persisted_model_span_facts_without_token_records() { let request = test_request(None); diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index fab44b29e9..347b08ceca 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -48,7 +48,7 @@ use crate::agentic::workspace::WorkspaceBinding; use crate::service::remote_connect::remote_server::RemoteExecutionDispatcher; use crate::service::config::types::{AIConfig, GlobalConfig, ModelCapability, ReasoningMode}; -use crate::service::session::{DialogTurnData, TurnStatus}; +use crate::service::session::{DialogTurnData, ToolItemIdentityExt, TurnStatus}; fn current_workspace_path() -> Option { crate::service::workspace::get_global_workspace_service() @@ -264,10 +264,10 @@ fn remote_chat_history_turn_from_core_turn(turn: &DialogTurnData) -> RemoteChatH .iter() .map(|item| RemoteChatHistoryToolItem { id: item.id.clone(), - name: item.tool_name.clone(), + name: item.effective_name().to_string(), call: RemoteChatHistoryToolCall { id: item.tool_call.id.clone(), - input: item.tool_call.input.clone(), + input: item.effective_input().clone(), }, has_result: item.tool_result.is_some(), status: item.status.clone(), diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 8f7597ff6c..788d411199 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -336,69 +336,111 @@ pub enum AgenticEvent { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ToolEventIdentity { + pub tool_id: String, + /// Provider-facing name. Deferred calls remain `CallDeferredTool`. + pub tool_name: String, + /// Runtime target when it differs from the provider-facing name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_tool_name: Option, +} + +impl ToolEventIdentity { + pub fn direct(tool_id: impl Into, tool_name: impl Into) -> Self { + Self { + tool_id: tool_id.into(), + tool_name: tool_name.into(), + effective_tool_name: None, + } + } + + pub fn resolved( + tool_id: impl Into, + tool_name: impl Into, + effective_tool_name: impl Into, + ) -> Self { + let tool_name = tool_name.into(); + let effective_tool_name = effective_tool_name.into(); + Self { + tool_id: tool_id.into(), + effective_tool_name: (tool_name != effective_tool_name).then_some(effective_tool_name), + tool_name, + } + } + + pub fn effective_name(&self) -> &str { + self.effective_tool_name + .as_deref() + .unwrap_or(&self.tool_name) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "event_type")] pub enum ToolEventData { EarlyDetected { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, }, ParamsPartial { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, params: String, }, Queued { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, position: usize, }, Waiting { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, dependencies: Vec, }, Started { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, + /// Complete provider-facing input. Effective input is derived by consumers. params: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] timeout_seconds: Option, }, Progress { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, message: String, percentage: f32, }, Streaming { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, chunks_received: usize, }, StreamChunk { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, data: serde_json::Value, }, ConfirmationNeeded { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, + /// Complete provider-facing input. Effective input is derived by consumers. params: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] timeout_at: Option, }, Confirmed { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, }, Rejected { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, }, Completed { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, result: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] result_for_assistant: Option, @@ -413,8 +455,8 @@ pub enum ToolEventData { execution_ms: Option, }, Failed { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, error: String, #[serde(default, skip_serializing_if = "Option::is_none")] duration_ms: Option, @@ -428,8 +470,8 @@ pub enum ToolEventData { execution_ms: Option, }, Cancelled { - tool_id: String, - tool_name: String, + #[serde(flatten)] + identity: ToolEventIdentity, reason: String, #[serde(default, skip_serializing_if = "Option::is_none")] duration_ms: Option, @@ -553,6 +595,37 @@ impl AgenticEvent { } impl ToolEventData { + pub fn identity(&self) -> &ToolEventIdentity { + match self { + Self::EarlyDetected { identity } + | Self::ParamsPartial { identity, .. } + | Self::Queued { identity, .. } + | Self::Waiting { identity, .. } + | Self::Started { identity, .. } + | Self::Progress { identity, .. } + | Self::Streaming { identity, .. } + | Self::StreamChunk { identity, .. } + | Self::ConfirmationNeeded { identity, .. } + | Self::Confirmed { identity } + | Self::Rejected { identity } + | Self::Completed { identity, .. } + | Self::Failed { identity, .. } + | Self::Cancelled { identity, .. } => identity, + } + } + + pub fn tool_id(&self) -> &str { + &self.identity().tool_id + } + + pub fn wire_tool_name(&self) -> &str { + &self.identity().tool_name + } + + pub fn effective_tool_name(&self) -> &str { + self.identity().effective_name() + } + /// Get the default priority for a specific tool event variant. pub fn default_priority(&self) -> AgenticEventPriority { match self { @@ -651,8 +724,7 @@ mod tests { #[test] fn completed_tool_reports_total_and_execution_duration() { let event = ToolEventData::Completed { - tool_id: "tool-1".to_string(), - tool_name: "write_file".to_string(), + identity: ToolEventIdentity::direct("tool-1", "write_file"), result: serde_json::json!({ "ok": true }), result_for_assistant: None, duration_ms: 120, @@ -668,11 +740,34 @@ mod tests { assert_eq!(json["execution_ms"], 90); } + #[test] + fn deferred_started_event_preserves_wire_invocation_and_effective_name() { + let params = serde_json::json!({ + "tool_name": "CreatePlan", + "args": { "name": "Plan" } + }); + let event = ToolEventData::Started { + identity: ToolEventIdentity::resolved("tool-1", "CallDeferredTool", "CreatePlan"), + params: params.clone(), + timeout_seconds: None, + }; + + let json = serde_json::to_value(&event).expect("serialize deferred event"); + assert_eq!(json["tool_id"], "tool-1"); + assert_eq!(json["tool_name"], "CallDeferredTool"); + assert_eq!(json["effective_tool_name"], "CreatePlan"); + assert_eq!(json["params"], params); + + let decoded: ToolEventData = + serde_json::from_value(json).expect("deserialize deferred event"); + assert_eq!(decoded.wire_tool_name(), "CallDeferredTool"); + assert_eq!(decoded.effective_tool_name(), "CreatePlan"); + } + #[test] fn failed_tool_reports_best_effort_total_duration() { let event = ToolEventData::Failed { - tool_id: "tool-1".to_string(), - tool_name: "write_file".to_string(), + identity: ToolEventIdentity::direct("tool-1", "write_file"), error: "failed".to_string(), duration_ms: Some(120), queue_wait_ms: Some(10), @@ -690,8 +785,7 @@ mod tests { #[test] fn cancelled_tool_reports_best_effort_total_duration() { let event = ToolEventData::Cancelled { - tool_id: "tool-1".to_string(), - tool_name: "write_file".to_string(), + identity: ToolEventIdentity::direct("tool-1", "write_file"), reason: "cancelled".to_string(), duration_ms: Some(120), queue_wait_ms: Some(10), diff --git a/src/crates/contracts/events/src/lib.rs b/src/crates/contracts/events/src/lib.rs index 80efb64911..54abd9b23c 100644 --- a/src/crates/contracts/events/src/lib.rs +++ b/src/crates/contracts/events/src/lib.rs @@ -14,6 +14,7 @@ pub mod types; pub use agentic::{ AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, DeepReviewQueueReason, DeepReviewQueueState, DeepReviewQueueStatus, SubagentParentInfo, ToolEventData, + ToolEventIdentity, }; pub use agentic_projection_manifest::{ agentic_event_projection_manifest_entry, is_legacy_websocket_agentic_event_type, diff --git a/src/crates/execution/agent-runtime/src/prompt.rs b/src/crates/execution/agent-runtime/src/prompt.rs index dfabb4bf2c..f2e5715f24 100644 --- a/src/crates/execution/agent-runtime/src/prompt.rs +++ b/src/crates/execution/agent-runtime/src/prompt.rs @@ -8,11 +8,16 @@ If the user names a skill (with `[$SkillName]` or plain text) OR the task clearl Below is the list of skills that can be used with the Skill tool. Each entry includes a name and description"#; const AGENT_LISTING_TITLE: &str = "# Agent Listing"; const AGENT_LISTING_GUIDANCE: &str = "Available subagent types for the Task tool:"; -const COLLAPSED_TOOL_LISTING_TITLE: &str = "# Collapsed Tool Listing"; -const COLLAPSED_TOOL_LISTING_GUIDANCE: &str = r#"The folling tools are intentionally collapsed. Their listed descriptions are short summaries rather than full usage instructions. -Before calling a collapsed tool, call `GetToolSpec` with its exact tool name to read its full schema. -After reading the returned spec, call the real tool directly by its own name. -If a tool spec is already available in the current conversation, do not call `GetToolSpec` for it again."#; +const TOOL_CALLING_GUIDANCE_TITLE: &str = "# Tool Calling Guide"; +const TOOL_CALLING_GUIDANCE: &str = r#"You can access two types of tools: +- Direct tools: tools in the available tool list include their full definitions. Call them directly using their listed names and input schemas. +- Deferred tools: call them through `CallDeferredTool`. + Before the first call for a deferred tool whose full spec is not already available in the current conversation, call `GetToolSpec` with its exact name. + Once its spec is available, call `CallDeferredTool` directly with that tool name and its arguments inside `args`. + Do not call `GetToolSpec` again unless the system reports that the spec is stale or unavailable. + +## Deferred Tool Listing +Each entry has the form `tool_name[: optional short description]`."#; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PromptEnvironmentFacts<'a> { @@ -548,14 +553,14 @@ impl UserContextSection { pub struct ToolListingSections { pub skill_listing: Option, pub agent_listing: Option, - pub collapsed_tool_listing: Option, + pub deferred_tool_listing: Option, } impl ToolListingSections { pub fn is_empty(&self) -> bool { self.skill_listing.is_none() && self.agent_listing.is_none() - && self.collapsed_tool_listing.is_none() + && self.deferred_tool_listing.is_none() } pub fn render_skill_listing_reminder(&self) -> Option { @@ -578,14 +583,14 @@ impl ToolListingSections { }) } - pub fn render_collapsed_tool_listing_reminder(&self) -> Option { - self.collapsed_tool_listing + pub fn render_deferred_tool_listing_reminder(&self) -> Option { + self.deferred_tool_listing .as_deref() - .map(|collapsed_tool_listing| { + .map(|deferred_tool_listing| { Self::render_section( - COLLAPSED_TOOL_LISTING_TITLE, - collapsed_tool_listing, - Some(COLLAPSED_TOOL_LISTING_GUIDANCE), + TOOL_CALLING_GUIDANCE_TITLE, + deferred_tool_listing, + Some(TOOL_CALLING_GUIDANCE), ) }) } @@ -600,7 +605,7 @@ impl ToolListingSections { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PrependedPromptReminders { - pub collapsed_tool_listing: Option, + pub deferred_tool_listing: Option, pub skill_listing: Option, pub agent_listing: Option, pub runtime_context: Option, @@ -610,8 +615,8 @@ pub struct PrependedPromptReminders { impl PrependedPromptReminders { pub fn ordered_reminders(&self) -> Vec<&str> { let mut reminders = Vec::new(); - if let Some(collapsed_tool_listing) = self.collapsed_tool_listing.as_deref() { - reminders.push(collapsed_tool_listing); + if let Some(deferred_tool_listing) = self.deferred_tool_listing.as_deref() { + reminders.push(deferred_tool_listing); } if let Some(skill_listing) = self.skill_listing.as_deref() { reminders.push(skill_listing); diff --git a/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs b/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs index d129fb996b..cce388bdd2 100644 --- a/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs +++ b/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs @@ -256,7 +256,7 @@ pub fn build_skill_agent_tool_listing_sections_from_snapshot( agent_listing: (!snapshot.subagents.is_empty()) .then(|| render_full_agent_listing_body(&snapshot.subagents)) .filter(|body| !body.is_empty()), - collapsed_tool_listing: None, + deferred_tool_listing: None, } } diff --git a/src/crates/execution/agent-runtime/tests/prompt_contracts.rs b/src/crates/execution/agent-runtime/tests/prompt_contracts.rs index 4d8ea580f4..236a5ca591 100644 --- a/src/crates/execution/agent-runtime/tests/prompt_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/prompt_contracts.rs @@ -42,7 +42,7 @@ fn tool_listing_sections_render_only_present_sections() { let sections = ToolListingSections { skill_listing: Some("skill-a\nskill-b".to_string()), agent_listing: None, - collapsed_tool_listing: Some("Search: summary".to_string()), + deferred_tool_listing: Some("Search: summary".to_string()), }; assert!(!sections.is_empty()); @@ -51,16 +51,29 @@ fn tool_listing_sections_render_only_present_sections() { .expect("skill listing should render") .starts_with("# Skill Listing\nA skill is a set of instructions")); assert!(sections.render_agent_listing_reminder().is_none()); - assert!(sections - .render_collapsed_tool_listing_reminder() - .expect("collapsed tool listing should render") - .starts_with("# Collapsed Tool Listing\n")); + let deferred_tool_listing = sections + .render_deferred_tool_listing_reminder() + .expect("deferred tool listing should render"); + assert!(deferred_tool_listing.starts_with("# Tool Calling Guide\n")); + assert!(deferred_tool_listing.contains("Direct tools: tools in the available tool list")); + assert!(deferred_tool_listing.contains("Deferred tools: call them through `CallDeferredTool`")); + assert!(deferred_tool_listing.contains( + "Before the first call for a deferred tool whose full spec is not already available" + )); + assert!(deferred_tool_listing + .contains("Once its spec is available, call `CallDeferredTool` directly")); + assert!(deferred_tool_listing + .contains("unless the system reports that the spec is stale or unavailable")); + assert!(deferred_tool_listing.contains("tool_name[: optional short description]")); + assert!(deferred_tool_listing.contains( + "## Deferred Tool Listing\nEach entry has the form `tool_name[: optional short description]`.\n\nSearch: summary" + )); } #[test] fn prepended_prompt_reminders_keep_runtime_injection_order() { let reminders = PrependedPromptReminders { - collapsed_tool_listing: Some("collapsed-tools".to_string()), + deferred_tool_listing: Some("deferred-tools".to_string()), skill_listing: Some("skills".to_string()), agent_listing: Some("agents".to_string()), runtime_context: Some("runtime-context".to_string()), @@ -70,7 +83,7 @@ fn prepended_prompt_reminders_keep_runtime_injection_order() { assert_eq!( reminders.ordered_reminders(), vec![ - "collapsed-tools", + "deferred-tools", "skills", "agents", "runtime-context", diff --git a/src/crates/execution/agent-stream/src/lib.rs b/src/crates/execution/agent-stream/src/lib.rs index b4497a2178..d471d2f97f 100644 --- a/src/crates/execution/agent-stream/src/lib.rs +++ b/src/crates/execution/agent-stream/src/lib.rs @@ -602,10 +602,11 @@ impl StreamProcessor { tool_call.tool_id ); + let identity = + bitfun_events::ToolEventIdentity::direct(tool_call.tool_id, tool_call.tool_name); let tool_event = if is_user_cancellation { ToolEventData::Cancelled { - tool_id: tool_call.tool_id, - tool_name: tool_call.tool_name, + identity, reason: reason.clone(), duration_ms: None, queue_wait_ms: None, @@ -615,8 +616,7 @@ impl StreamProcessor { } } else { ToolEventData::Failed { - tool_id: tool_call.tool_id, - tool_name: tool_call.tool_name, + identity, error: reason.clone(), duration_ms: None, queue_wait_ms: None, @@ -716,8 +716,10 @@ impl StreamProcessor { attempt_id: Some(ctx.attempt_id.clone()), attempt_index: Some(ctx.attempt_index), tool_event: ToolEventData::EarlyDetected { - tool_id: early_detected.tool_id, - tool_name: early_detected.tool_name, + identity: bitfun_events::ToolEventIdentity::direct( + early_detected.tool_id, + early_detected.tool_name, + ), }, }, None, @@ -738,8 +740,10 @@ impl StreamProcessor { attempt_id: Some(ctx.attempt_id.clone()), attempt_index: Some(ctx.attempt_index), tool_event: ToolEventData::ParamsPartial { - tool_id: params_partial.tool_id, - tool_name: params_partial.tool_name, + identity: bitfun_events::ToolEventIdentity::direct( + params_partial.tool_id, + params_partial.tool_name, + ), params: params_partial.params_chunk, }, }, @@ -1236,9 +1240,9 @@ mod tests { assert!(matches!( &events[0], AgenticEvent::ToolEvent { - tool_event: ToolEventData::Cancelled { tool_id, .. }, + tool_event: ToolEventData::Cancelled { identity, .. }, .. - } if tool_id == "tool_1" + } if identity.tool_id == "tool_1" )); assert!(matches!( &events[1], diff --git a/src/crates/execution/tool-contracts/src/deferred_tool.rs b/src/crates/execution/tool-contracts/src/deferred_tool.rs new file mode 100644 index 0000000000..4bebf640e0 --- /dev/null +++ b/src/crates/execution/tool-contracts/src/deferred_tool.rs @@ -0,0 +1,192 @@ +use serde_json::Value; +use std::fmt; + +pub const CALL_DEFERRED_TOOL_NAME: &str = "CallDeferredTool"; + +#[derive(Debug, Clone, PartialEq)] +pub struct CallDeferredToolInput { + pub tool_name: String, + pub args: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CallDeferredToolInputError { + InputMustBeObject, + MissingToolName, + EmptyToolName, + MissingArgs, + ArgsMustBeObject, + UnexpectedField(String), +} + +impl fmt::Display for CallDeferredToolInputError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InputMustBeObject => { + write!(formatter, "CallDeferredTool input must be an object") + } + Self::MissingToolName => write!(formatter, "tool_name is required"), + Self::EmptyToolName => write!(formatter, "tool_name cannot be empty"), + Self::MissingArgs => write!(formatter, "args is required"), + Self::ArgsMustBeObject => write!(formatter, "args must be an object"), + Self::UnexpectedField(field) => { + write!(formatter, "unexpected CallDeferredTool field: {field}") + } + } + } +} + +impl std::error::Error for CallDeferredToolInputError {} + +pub fn call_deferred_tool_input_schema() -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "required": ["tool_name", "args"], + "properties": { + "tool_name": { + "type": "string", + "description": "Exact deferred tool name previously loaded with GetToolSpec." + }, + "args": { + "type": "object", + "additionalProperties": true, + "description": "Arguments matching the schema returned by GetToolSpec." + } + } + }) +} + +pub fn call_deferred_tool_short_description() -> String { + "Call a deferred tool whose full schema was loaded with GetToolSpec.".to_string() +} + +pub fn call_deferred_tool_description() -> String { + r#"Call a deferred tool after reading its full schema with GetToolSpec. + +Pass the exact deferred tool name in tool_name and put only that tool's arguments inside args. +The order is important. ALWAYS output tool_name first, then args."# + .to_string() +} + +pub fn parse_call_deferred_tool_input( + input: &Value, +) -> Result { + let (tool_name, args) = parse_call_deferred_tool_input_ref(input)?; + + Ok(CallDeferredToolInput { + tool_name: tool_name.to_string(), + args: args.clone(), + }) +} + +fn parse_call_deferred_tool_input_ref( + input: &Value, +) -> Result<(&str, &Value), CallDeferredToolInputError> { + let object = input + .as_object() + .ok_or(CallDeferredToolInputError::InputMustBeObject)?; + + if let Some(field) = object + .keys() + .find(|field| field.as_str() != "tool_name" && field.as_str() != "args") + { + return Err(CallDeferredToolInputError::UnexpectedField(field.clone())); + } + + let tool_name = object + .get("tool_name") + .and_then(Value::as_str) + .ok_or(CallDeferredToolInputError::MissingToolName)?; + if tool_name.trim().is_empty() { + return Err(CallDeferredToolInputError::EmptyToolName); + } + + let args = object + .get("args") + .ok_or(CallDeferredToolInputError::MissingArgs)?; + if !args.is_object() { + return Err(CallDeferredToolInputError::ArgsMustBeObject); + } + + Ok((tool_name, args)) +} + +/// Project a provider-facing invocation to the runtime tool identity without +/// allocating or duplicating persisted arguments. Invalid gateway payloads +/// fall back to their wire identity so historical data remains renderable. +pub fn effective_tool_invocation<'a>( + wire_tool_name: &'a str, + wire_arguments: &'a Value, +) -> (&'a str, &'a Value) { + if wire_tool_name != CALL_DEFERRED_TOOL_NAME { + return (wire_tool_name, wire_arguments); + } + + parse_call_deferred_tool_input_ref(wire_arguments).unwrap_or((wire_tool_name, wire_arguments)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolInvocationKind { + Direct, + Deferred { gateway_tool_name: String }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedToolInvocation { + pub wire_tool_name: String, + pub wire_arguments: Value, + pub effective_tool_name: String, + pub effective_arguments: Value, + pub kind: ToolInvocationKind, +} + +impl ResolvedToolInvocation { + pub fn direct(tool_name: impl Into, arguments: Value) -> Self { + let tool_name = tool_name.into(); + Self { + wire_tool_name: tool_name.clone(), + wire_arguments: arguments.clone(), + effective_tool_name: tool_name, + effective_arguments: arguments, + kind: ToolInvocationKind::Direct, + } + } + + pub fn from_wire_call( + tool_name: impl Into, + arguments: Value, + ) -> Result { + let tool_name = tool_name.into(); + if tool_name != CALL_DEFERRED_TOOL_NAME { + return Ok(Self::direct(tool_name, arguments)); + } + + let parsed = parse_call_deferred_tool_input(&arguments)?; + Ok(Self { + wire_tool_name: tool_name.clone(), + wire_arguments: arguments, + effective_tool_name: parsed.tool_name, + effective_arguments: parsed.args, + kind: ToolInvocationKind::Deferred { + gateway_tool_name: tool_name, + }, + }) + } + + pub fn is_deferred(&self) -> bool { + matches!(self.kind, ToolInvocationKind::Deferred { .. }) + } + + pub fn replace_effective_arguments(&mut self, arguments: Value) { + self.effective_arguments = arguments.clone(); + match self.kind { + ToolInvocationKind::Direct => self.wire_arguments = arguments, + ToolInvocationKind::Deferred { .. } => { + if let Some(object) = self.wire_arguments.as_object_mut() { + object.insert("args".to_string(), arguments); + } + } + } + } +} diff --git a/src/crates/execution/tool-contracts/src/execution_gate.rs b/src/crates/execution/tool-contracts/src/execution_gate.rs index 14e092a4ed..9a197745df 100644 --- a/src/crates/execution/tool-contracts/src/execution_gate.rs +++ b/src/crates/execution/tool-contracts/src/execution_gate.rs @@ -1,6 +1,7 @@ use crate::{ - validate_collapsed_tool_usage, validate_tool_allowed_by_list, CollapsedToolUsageError, - ToolExecutionAccessError, ToolRestrictionError, ToolRuntimeRestrictions, + validate_deferred_tool_usage, validate_tool_allowed_by_list, DeferredToolUsageError, + LoadedDeferredToolSpec, ToolExecutionAccessError, ToolRestrictionError, + ToolRuntimeRestrictions, }; use std::fmt; @@ -9,8 +10,10 @@ pub struct ToolExecutionAdmissionRequest<'a> { pub tool_name: &'a str, pub allowed_tools: &'a [String], pub runtime_tool_restrictions: &'a ToolRuntimeRestrictions, - pub collapsed_tools: &'a [String], - pub loaded_collapsed_tools: &'a [String], + pub invocation_is_deferred: bool, + pub deferred_tools: &'a [String], + pub loaded_deferred_tool_specs: &'a [LoadedDeferredToolSpec], + pub current_catalog_generation: u64, pub get_tool_spec_tool_name: &'a str, } @@ -18,7 +21,7 @@ pub struct ToolExecutionAdmissionRequest<'a> { pub enum ToolExecutionAdmissionRejection { AllowedList(ToolExecutionAccessError), RuntimeRestriction(ToolRestrictionError), - Collapsed(CollapsedToolUsageError), + Deferred(DeferredToolUsageError), } impl fmt::Display for ToolExecutionAdmissionRejection { @@ -26,7 +29,7 @@ impl fmt::Display for ToolExecutionAdmissionRejection { match self { Self::AllowedList(error) => write!(formatter, "{error}"), Self::RuntimeRestriction(error) => write!(formatter, "{error}"), - Self::Collapsed(error) => write!(formatter, "{error}"), + Self::Deferred(error) => write!(formatter, "{error}"), } } } @@ -42,11 +45,13 @@ pub fn validate_tool_execution_admission( .runtime_tool_restrictions .ensure_tool_allowed(request.tool_name) .map_err(ToolExecutionAdmissionRejection::RuntimeRestriction)?; - validate_collapsed_tool_usage( + validate_deferred_tool_usage( request.tool_name, - request.collapsed_tools, - request.loaded_collapsed_tools, + request.invocation_is_deferred, + request.deferred_tools, + request.loaded_deferred_tool_specs, + request.current_catalog_generation, request.get_tool_spec_tool_name, ) - .map_err(ToolExecutionAdmissionRejection::Collapsed) + .map_err(ToolExecutionAdmissionRejection::Deferred) } diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index 1fa7c6bd01..356bca7f4b 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -2,7 +2,8 @@ use crate::tool_snapshot::{ materialize_tool_snapshot, MaterializedToolSnapshot, ToolProviderIdentity, }; use crate::{ - DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, + DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, + ToolDecorator, CALL_DEFERRED_TOOL_NAME, }; use async_trait::async_trait; use bitfun_core_types::ToolImageAttachment; @@ -69,14 +70,24 @@ pub trait PortableToolContextProvider: Send + Sync { pub const GET_TOOL_SPEC_TOOL_NAME: &str = "GetToolSpec"; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum CollapsedToolUsageError { +pub enum DeferredToolUsageError { RequiresGetToolSpec { tool_name: String, get_tool_spec_tool_name: String, }, + RequiresGateway { + tool_name: String, + gateway_tool_name: String, + }, + StaleSpec { + tool_name: String, + loaded_generation: u64, + current_generation: u64, + get_tool_spec_tool_name: String, + }, } -impl fmt::Display for CollapsedToolUsageError { +impl fmt::Display for DeferredToolUsageError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::RequiresGetToolSpec { @@ -84,13 +95,29 @@ impl fmt::Display for CollapsedToolUsageError { get_tool_spec_tool_name, } => write!( formatter, - "Tool '{tool_name}' is collapsed. Call {get_tool_spec_tool_name} first with {{\"tool_name\":\"{tool_name}\"}} to read its full usage instructions and input schema, then try again." + "Tool '{tool_name}' is deferred. Call {get_tool_spec_tool_name} first with {{\"tool_name\":\"{tool_name}\"}} to read its full usage instructions and input schema, then call it through CallDeferredTool." + ), + Self::RequiresGateway { + tool_name, + gateway_tool_name, + } => write!( + formatter, + "Tool '{tool_name}' is deferred and cannot be called directly. Use {gateway_tool_name} with {{\"tool_name\":\"{tool_name}\",\"args\":{{...}}}}." + ), + Self::StaleSpec { + tool_name, + loaded_generation, + current_generation, + get_tool_spec_tool_name, + } => write!( + formatter, + "The loaded spec for deferred tool '{tool_name}' is stale (loaded catalog generation {loaded_generation}, current generation {current_generation}). Call {get_tool_spec_tool_name} again before using CallDeferredTool." ), } } } -impl std::error::Error for CollapsedToolUsageError {} +impl std::error::Error for DeferredToolUsageError {} #[derive(Debug, Clone, PartialEq, Eq)] pub enum ToolExecutionAccessError { @@ -130,31 +157,48 @@ pub fn validate_tool_allowed_by_list( }) } -pub fn validate_collapsed_tool_usage( +pub fn validate_deferred_tool_usage( tool_name: &str, - collapsed_tools: &[String], - loaded_collapsed_tools: &[String], + invocation_is_deferred: bool, + deferred_tools: &[String], + loaded_deferred_tool_specs: &[LoadedDeferredToolSpec], + current_catalog_generation: u64, get_tool_spec_tool_name: &str, -) -> Result<(), CollapsedToolUsageError> { +) -> Result<(), DeferredToolUsageError> { if tool_name == get_tool_spec_tool_name { return Ok(()); } - if !collapsed_tools + if !deferred_tools .iter() - .any(|collapsed_tool| collapsed_tool == tool_name) + .any(|deferred_tool| deferred_tool == tool_name) { return Ok(()); } - if loaded_collapsed_tools + if !invocation_is_deferred { + return Err(DeferredToolUsageError::RequiresGateway { + tool_name: tool_name.to_string(), + gateway_tool_name: CALL_DEFERRED_TOOL_NAME.to_string(), + }); + } + + if let Some(loaded) = loaded_deferred_tool_specs .iter() - .any(|loaded_tool| loaded_tool == tool_name) + .find(|loaded| loaded.tool_name == tool_name) { - return Ok(()); + if loaded.catalog_generation == current_catalog_generation { + return Ok(()); + } + return Err(DeferredToolUsageError::StaleSpec { + tool_name: tool_name.to_string(), + loaded_generation: loaded.catalog_generation, + current_generation: current_catalog_generation, + get_tool_spec_tool_name: get_tool_spec_tool_name.to_string(), + }); } - Err(CollapsedToolUsageError::RequiresGetToolSpec { + Err(DeferredToolUsageError::RequiresGetToolSpec { tool_name: tool_name.to_string(), get_tool_spec_tool_name: get_tool_spec_tool_name.to_string(), }) @@ -162,8 +206,8 @@ pub fn validate_collapsed_tool_usage( #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ToolExposure { - Expanded, - Collapsed, + Direct, + Deferred, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -185,11 +229,7 @@ impl ToolManifestDefinition { #[derive(Debug, Clone, PartialEq)] pub enum PromptVisibleToolManifestItem { - Expanded(ToolManifestDefinition), - Collapsed { - name: String, - short_description: String, - }, + Direct(ToolManifestDefinition), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -202,24 +242,24 @@ pub struct ToolManifestPolicyTool { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolManifestPolicyResolution { pub allowed_tool_names: Vec, - pub expanded_tool_names: Vec, - pub collapsed_tool_names: Vec, + pub direct_tool_names: Vec, + pub deferred_tool_names: Vec, } #[derive(Clone)] pub struct ContextualVisibleTools { pub allowed_tool_names: Vec, - pub expanded_tools: Vec>, - pub collapsed_tool_names: Vec, - pub collapsed_tools: Vec>, + pub direct_tools: Vec>, + pub deferred_tool_names: Vec, + pub deferred_tools: Vec>, } #[derive(Clone)] pub struct ContextualToolManifest { pub allowed_tool_names: Vec, - pub expanded_tools: Vec>, - pub collapsed_tool_names: Vec, - pub collapsed_tools: Vec>, + pub direct_tools: Vec>, + pub deferred_tool_names: Vec, + pub deferred_tools: Vec>, pub tool_definitions: Vec, } @@ -234,8 +274,8 @@ pub fn resolve_tool_manifest_policy( .map(String::as_str) .collect::>(); let mut allowed_tool_names = allowed_tools.to_vec(); - let mut expanded_tool_names = Vec::new(); - let mut collapsed_tool_names = Vec::new(); + let mut direct_tool_names = Vec::new(); + let mut deferred_tool_names = Vec::new(); for tool in tool_snapshot { if !tool.available || !allowed_set.contains(tool.name.as_str()) { @@ -247,30 +287,28 @@ pub fn resolve_tool_manifest_policy( .copied() .unwrap_or(tool.default_exposure); match exposure { - ToolExposure::Expanded => expanded_tool_names.push(tool.name.clone()), - ToolExposure::Collapsed => collapsed_tool_names.push(tool.name.clone()), + ToolExposure::Direct => direct_tool_names.push(tool.name.clone()), + ToolExposure::Deferred => deferred_tool_names.push(tool.name.clone()), } } - if !collapsed_tool_names.is_empty() { - if !allowed_tool_names - .iter() - .any(|name| name == get_tool_spec_tool_name) - { - allowed_tool_names.push(get_tool_spec_tool_name.to_string()); - } - if tool_snapshot - .iter() - .any(|tool| tool.name == get_tool_spec_tool_name) - { - expanded_tool_names.push(get_tool_spec_tool_name.to_string()); + if !deferred_tool_names.is_empty() { + for gateway_name in [get_tool_spec_tool_name, CALL_DEFERRED_TOOL_NAME] { + if !allowed_tool_names.iter().any(|name| name == gateway_name) { + allowed_tool_names.push(gateway_name.to_string()); + } + if tool_snapshot.iter().any(|tool| tool.name == gateway_name) + && !direct_tool_names.iter().any(|name| name == gateway_name) + { + direct_tool_names.push(gateway_name.to_string()); + } } } ToolManifestPolicyResolution { allowed_tool_names, - expanded_tool_names, - collapsed_tool_names, + direct_tool_names, + deferred_tool_names, } } @@ -306,38 +344,10 @@ fn tools_by_name( .collect() } -pub fn build_collapsed_tool_stub_definition( - tool_name: &str, - short_description: &str, -) -> ToolManifestDefinition { - // Keep the prompt-visible stub stable for the life of the conversation. - // GetToolSpec returns the full schema out-of-band; replacing this stub with - // a different tool definition mid-session changes the request prefix and - // causes provider-side prefix/KV cache misses on later rounds. - // We still need a stub definition in the request because some providers - // constrain model tool calls to the exact tool list attached to that - // request. Without a prompt-visible stub entry, the model may be unable to - // call the collapsed tool at all, even after GetToolSpec has described it. - ToolManifestDefinition::new( - tool_name, - format!( - "THIS IS A COLLAPSED TOOL. Before first use, call GetToolSpec({{\"tool_name\":\"{}\"}}) to load its schema. After that, you can call {} directly. Any direct call before loading will fail validation.\nSummary: {}", - tool_name, - tool_name, - short_description, - ), - serde_json::json!({ - "type": "object", - "additionalProperties": true, - "properties": {} - }), - ) -} - #[derive(Debug, Clone, PartialEq, Eq)] -pub struct GetToolSpecCollapsedToolSummary { +pub struct GetToolSpecDeferredToolSummary { pub name: String, - pub short_description: String, + pub short_description: Option, } #[derive(Debug, Clone, PartialEq)] @@ -345,6 +355,7 @@ pub struct GetToolSpecDetail { pub tool_name: String, pub description: String, pub input_schema: Value, + pub catalog_generation: u64, } impl GetToolSpecDetail { @@ -353,6 +364,7 @@ impl GetToolSpecDetail { "tool_name": self.tool_name.clone(), "description": self.description.clone(), "input_schema": self.input_schema.clone(), + "catalog_generation": self.catalog_generation, }) } } @@ -365,39 +377,44 @@ pub fn get_tool_spec_input_schema() -> Value { "properties": { "tool_name": { "type": "string", - "description": "Exact collapsed tool name to load, using the tool's canonical casing from the catalog (for example, \"Git\"). Do not pass a command such as \"git status\" or an operation such as \"status\" here." + "description": "Exact deferred tool name to load, using the tool's canonical casing from the catalog (for example, \"Git\"). Do not pass a command such as \"git status\" or an operation such as \"status\" here." } } }) } pub fn get_tool_spec_short_description() -> String { - "Discover collapsed tools and read their detailed definitions.".to_string() + "Discover deferred tools and read their detailed definitions.".to_string() } pub fn build_get_tool_spec_description() -> String { - r#"Read full schema before first calling a collapsed tool. + r#"Read the full schema before first calling a deferred tool through CallDeferredTool. Do not call GetToolSpec again for a tool whose definition is already loaded in the current conversation."# .to_string() } pub fn build_get_tool_spec_catalog_description( - collapsed_tools: &[GetToolSpecCollapsedToolSummary], + deferred_tools: &[GetToolSpecDeferredToolSummary], ) -> Option { - if collapsed_tools.is_empty() { + if deferred_tools.is_empty() { return None; } - let collapsed_tools_list = collapsed_tools + let deferred_tools_list = deferred_tools .iter() - .map(|tool| format!("- {}", tool.name)) + .map(|tool| match tool.short_description.as_deref() { + Some(description) if !description.trim().is_empty() => { + format!("- {}: {}", tool.name, description) + } + _ => format!("- {}", tool.name), + }) .collect::>() .join("\n"); Some(format!( - "\n{}\n", - collapsed_tools_list + "\n{}\n", + deferred_tools_list )) } @@ -445,7 +462,7 @@ pub fn validate_get_tool_spec_input(input: &Value) -> ValidationResult { pub fn build_get_tool_spec_duplicate_load_hint(tool_name: &str) -> String { format!( - "Tool '{}' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use '{}' directly.", + "Tool '{}' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use CallDeferredTool with tool_name '{}' and put the tool arguments inside args.", tool_name, tool_name ) } @@ -479,17 +496,17 @@ pub fn build_get_tool_spec_already_available_result(tool_name: &str) -> ToolResu } } -pub fn build_get_tool_spec_unavailable_collapsed_hint(tool_name: &str) -> String { +pub fn build_get_tool_spec_unavailable_deferred_hint(tool_name: &str) -> String { format!("'{}' is not available in the current context", tool_name) } -pub fn build_get_tool_spec_unavailable_collapsed_result(tool_name: &str) -> ToolResult { +pub fn build_get_tool_spec_unavailable_deferred_result(tool_name: &str) -> ToolResult { ToolResult::Result { data: serde_json::json!({ "tool_name": tool_name, - "available_collapsed_tool": false + "available_deferred_tool": false }), - result_for_assistant: Some(build_get_tool_spec_unavailable_collapsed_hint(tool_name)), + result_for_assistant: Some(build_get_tool_spec_unavailable_deferred_hint(tool_name)), image_attachments: None, } } @@ -519,14 +536,14 @@ pub enum GetToolSpecExecutionPlan<'a> { pub fn resolve_get_tool_spec_execution_plan<'a>( input: &'a Value, - loaded_collapsed_tools: &[String], + loaded_deferred_tool_names: &[String], ) -> Result, GetToolSpecExecutionError> { let tool_name = input .get("tool_name") .and_then(|value| value.as_str()) .ok_or(GetToolSpecExecutionError::MissingToolName)?; - if loaded_collapsed_tools + if loaded_deferred_tool_names .iter() .any(|loaded| loaded == tool_name) { @@ -542,16 +559,23 @@ pub fn resolve_get_tool_spec_execution_plan<'a>( pub struct GetToolSpecLoadObservation<'a> { pub tool_name: &'a str, pub loaded_tool_name: Option<&'a str>, + pub catalog_generation: Option, pub is_error: bool, } -pub fn collect_loaded_collapsed_tool_names( +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoadedDeferredToolSpec { + pub tool_name: String, + pub catalog_generation: u64, +} + +pub fn collect_loaded_deferred_tool_specs( observations: &[GetToolSpecLoadObservation<'_>], - collapsed_tool_names: &[String], + deferred_tool_names: &[String], get_tool_spec_tool_name: &str, -) -> Vec { - let collapsed_set: HashSet<&str> = collapsed_tool_names.iter().map(String::as_str).collect(); - let mut loaded = BTreeSet::new(); +) -> Vec { + let deferred_set: HashSet<&str> = deferred_tool_names.iter().map(String::as_str).collect(); + let mut loaded = BTreeMap::new(); for observation in observations { if observation.is_error || observation.tool_name != get_tool_spec_tool_name { @@ -562,19 +586,32 @@ pub fn collect_loaded_collapsed_tool_names( continue; }; - if collapsed_set.contains(tool_name) { - loaded.insert(tool_name.to_string()); + if deferred_set.contains(tool_name) { + if let Some(catalog_generation) = observation.catalog_generation { + loaded.insert( + tool_name.to_string(), + LoadedDeferredToolSpec { + tool_name: tool_name.to_string(), + catalog_generation, + }, + ); + } } } - loaded.into_iter().collect() + loaded.into_values().collect() } -pub fn build_get_tool_spec_assistant_detail(description: &str, input_schema: &Value) -> String { +pub fn build_get_tool_spec_assistant_detail( + tool_name: &str, + description: &str, + input_schema: &Value, +) -> String { format!( - "\n{}\n\n\n{}\n", + "\n{}\n\n\n{}\n\n\nCallDeferredTool({{\"tool_name\":\"{}\",\"args\":{{...}}}})\n", escape_get_tool_spec_xml_text(description), - escape_get_tool_spec_xml_text(&input_schema.to_string()) + escape_get_tool_spec_xml_text(&input_schema.to_string()), + escape_get_tool_spec_xml_text(tool_name), ) } @@ -582,6 +619,7 @@ pub fn build_get_tool_spec_detail_result(detail: &GetToolSpecDetail) -> ToolResu ToolResult::Result { data: detail.to_value(), result_for_assistant: Some(build_get_tool_spec_assistant_detail( + &detail.tool_name, &detail.description, &detail.input_schema, )), @@ -627,11 +665,7 @@ pub fn build_prompt_visible_tool_manifest_definitions( let mut definitions = items .iter() .map(|item| match item { - PromptVisibleToolManifestItem::Expanded(definition) => definition.clone(), - PromptVisibleToolManifestItem::Collapsed { - name, - short_description, - } => build_collapsed_tool_stub_definition(name, short_description), + PromptVisibleToolManifestItem::Direct(definition) => definition.clone(), }) .collect::>(); sort_tool_manifest_definitions(&mut definitions); @@ -651,7 +685,7 @@ pub trait ToolRegistryItem: Send + Sync { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } fn is_readonly(&self) -> bool { @@ -744,27 +778,34 @@ pub trait GetToolSpecCatalogProvider: Send + Sync where Context: Sync, { - async fn collapsed_tools_for_get_tool_spec( + async fn deferred_tools_for_get_tool_spec( &self, context: Option<&Context>, ) -> Result>, String>; + async fn catalog_generation(&self) -> u64 { + 0 + } + async fn available_tools_for_get_tool_spec( &self, context: Option<&Context>, ) -> Result>, String> { - self.collapsed_tools_for_get_tool_spec(context).await + self.deferred_tools_for_get_tool_spec(context).await } } -pub fn summarize_get_tool_spec_collapsed_tools( - collapsed_tools: &[ToolRef], -) -> Vec { - collapsed_tools +pub fn summarize_get_tool_spec_deferred_tools( + deferred_tools: &[ToolRef], +) -> Vec { + deferred_tools .iter() - .map(|tool| GetToolSpecCollapsedToolSummary { + .map(|tool| GetToolSpecDeferredToolSummary { name: tool.name().to_string(), - short_description: tool.short_description(), + short_description: match tool.dynamic_tool_info() { + Some(info) if info.mcp.is_some() => None, + _ => Some(tool.short_description()), + }, }) .collect() } @@ -778,8 +819,8 @@ where Context: Sync, Provider: GetToolSpecCatalogProvider + ?Sized, { - let collapsed_tools = provider.collapsed_tools_for_get_tool_spec(context).await?; - let summaries = summarize_get_tool_spec_collapsed_tools(&collapsed_tools); + let deferred_tools = provider.deferred_tools_for_get_tool_spec(context).await?; + let summaries = summarize_get_tool_spec_deferred_tools(&deferred_tools); Ok(build_get_tool_spec_catalog_description(&summaries)) } @@ -864,7 +905,7 @@ where } pub async fn resolve_get_tool_spec_detail( - collapsed_tools: &[ToolRef], + deferred_tools: &[ToolRef], tool_name: &str, context: &Context, get_tool_spec_tool_name: &str, @@ -873,7 +914,7 @@ where Tool: ContextualToolManifestItem + ?Sized, Context: Sync, { - let tool = collapsed_tools + let tool = deferred_tools .iter() .find(|tool| tool.name() == tool_name) .ok_or_else(|| format!("'{tool_name}' is not available in the current context"))?; @@ -892,6 +933,7 @@ where tool_name: tool_name.to_string(), description, input_schema, + catalog_generation: 0, }) } @@ -906,22 +948,16 @@ where Context: Sync, Provider: GetToolSpecCatalogProvider + ?Sized, { - let collapsed_tools = provider - .collapsed_tools_for_get_tool_spec(Some(context)) + let deferred_tools = provider + .deferred_tools_for_get_tool_spec(Some(context)) .await?; - resolve_get_tool_spec_detail( - &collapsed_tools, - tool_name, - context, - get_tool_spec_tool_name, - ) - .await + resolve_get_tool_spec_detail(&deferred_tools, tool_name, context, get_tool_spec_tool_name).await } pub async fn resolve_get_tool_spec_execution_result_from_provider( provider: &Provider, input: &Value, - loaded_collapsed_tools: &[String], + loaded_deferred_tool_specs: &[LoadedDeferredToolSpec], context: &Context, get_tool_spec_tool_name: &str, ) -> Result @@ -930,23 +966,33 @@ where Context: Sync, Provider: GetToolSpecCatalogProvider + ?Sized, { - match resolve_get_tool_spec_execution_plan(input, loaded_collapsed_tools)? { + let current_generation = provider.catalog_generation().await; + let loaded_names = loaded_deferred_tool_specs + .iter() + .filter(|spec| spec.catalog_generation == current_generation) + .map(|spec| spec.tool_name.clone()) + .collect::>(); + match resolve_get_tool_spec_execution_plan(input, &loaded_names)? { GetToolSpecExecutionPlan::DuplicateLoad(result) => Ok(result), GetToolSpecExecutionPlan::LoadDetail { tool_name } => { - let collapsed_tools = provider - .collapsed_tools_for_get_tool_spec(Some(context)) + let deferred_tools = provider + .deferred_tools_for_get_tool_spec(Some(context)) .await .map_err(GetToolSpecExecutionError::Detail)?; - if collapsed_tools.iter().any(|tool| tool.name() == tool_name) { + if deferred_tools.iter().any(|tool| tool.name() == tool_name) { let detail = resolve_get_tool_spec_detail( - &collapsed_tools, + &deferred_tools, tool_name, context, get_tool_spec_tool_name, ) .await .map_err(GetToolSpecExecutionError::Detail)?; + let detail = GetToolSpecDetail { + catalog_generation: current_generation, + ..detail + }; return Ok(build_get_tool_spec_detail_result(&detail)); } @@ -958,7 +1004,7 @@ where return Ok(build_get_tool_spec_already_available_result(tool_name)); } - Ok(build_get_tool_spec_unavailable_collapsed_result(tool_name)) + Ok(build_get_tool_spec_unavailable_deferred_result(tool_name)) } } } @@ -1020,13 +1066,13 @@ where pub async fn execute( &self, input: &Value, - loaded_collapsed_tools: &[String], + loaded_deferred_tool_specs: &[LoadedDeferredToolSpec], context: &Context, ) -> Result { resolve_get_tool_spec_execution_result_from_provider( self.provider, input, - loaded_collapsed_tools, + loaded_deferred_tool_specs, context, self.tool_name, ) @@ -1036,10 +1082,10 @@ where pub async fn call_results( &self, input: &Value, - loaded_collapsed_tools: &[String], + loaded_deferred_tool_specs: &[LoadedDeferredToolSpec], context: &Context, ) -> Result, GetToolSpecExecutionError> { - self.execute(input, loaded_collapsed_tools, context) + self.execute(input, loaded_deferred_tool_specs, context) .await .map(|result| vec![result]) } @@ -1116,14 +1162,14 @@ where exposure_overrides, get_tool_spec_tool_name, ); - let expanded_tools = tools_by_name(tool_snapshot, &policy.expanded_tool_names); - let collapsed_tools = tools_by_name(tool_snapshot, &policy.collapsed_tool_names); + let direct_tools = tools_by_name(tool_snapshot, &policy.direct_tool_names); + let deferred_tools = tools_by_name(tool_snapshot, &policy.deferred_tool_names); ContextualVisibleTools { allowed_tool_names: policy.allowed_tool_names, - expanded_tools, - collapsed_tool_names: policy.collapsed_tool_names, - collapsed_tools, + direct_tools, + deferred_tool_names: policy.deferred_tool_names, + deferred_tools, } } @@ -1147,39 +1193,30 @@ where ) .await; - let mut manifest_items = Vec::with_capacity( - visible_tools.expanded_tools.len() + visible_tools.collapsed_tools.len(), - ); - for tool in &visible_tools.expanded_tools { + let mut manifest_items = Vec::with_capacity(visible_tools.direct_tools.len()); + for tool in &visible_tools.direct_tools { let description = tool .description_with_context(context) .await .unwrap_or_else(|_| format!("Tool: {}", tool.name())); let parameters = tool.input_schema_for_model_with_context(context).await; - manifest_items.push(PromptVisibleToolManifestItem::Expanded( + manifest_items.push(PromptVisibleToolManifestItem::Direct( ToolManifestDefinition::new(tool.name().to_string(), description, parameters), )); } - for tool in &visible_tools.collapsed_tools { - manifest_items.push(PromptVisibleToolManifestItem::Collapsed { - name: tool.name().to_string(), - short_description: tool.short_description(), - }); - } - // This prompt-visible tool-definition list is part of the request prefix. - // Once a turn starts, enrich collapsed tools through GetToolSpec results + // Once a turn starts, enrich deferred tools through GetToolSpec results // instead of mutating this list, or later rounds will lose prefix-cache // reuse even if the actual tool set is unchanged. let tool_definitions = build_prompt_visible_tool_manifest_definitions(&manifest_items); ContextualToolManifest { allowed_tool_names: visible_tools.allowed_tool_names, - expanded_tools: visible_tools.expanded_tools, - collapsed_tool_names: visible_tools.collapsed_tool_names, - collapsed_tools: visible_tools.collapsed_tools, + direct_tools: visible_tools.direct_tools, + deferred_tool_names: visible_tools.deferred_tool_names, + deferred_tools: visible_tools.deferred_tools, tool_definitions, } } @@ -1526,16 +1563,16 @@ impl ToolRegistry { .map(|metadata| metadata.info.clone()) } - pub fn is_tool_collapsed(&self, name: &str) -> bool { + pub fn is_tool_deferred(&self, name: &str) -> bool { self.tools .get(name) - .is_some_and(|tool| tool.default_exposure() == ToolExposure::Collapsed) + .is_some_and(|tool| tool.default_exposure() == ToolExposure::Deferred) } - pub fn get_collapsed_tool_names(&self) -> Vec { + pub fn get_deferred_tool_names(&self) -> Vec { self.tools .iter() - .filter(|(_, tool)| tool.default_exposure() == ToolExposure::Collapsed) + .filter(|(_, tool)| tool.default_exposure() == ToolExposure::Deferred) .map(|(name, _)| name.clone()) .collect() } @@ -2505,22 +2542,22 @@ mod tests { } #[test] - fn get_tool_spec_catalog_description_lists_names_only() { + fn get_tool_spec_catalog_description_keeps_builtin_summaries_optional() { let description = build_get_tool_spec_catalog_description(&[ - GetToolSpecCollapsedToolSummary { + GetToolSpecDeferredToolSummary { name: "Git".to_string(), - short_description: "Inspect repository state.".to_string(), + short_description: Some("Inspect repository state.".to_string()), }, - GetToolSpecCollapsedToolSummary { + GetToolSpecDeferredToolSummary { name: "WebFetch".to_string(), - short_description: "Fetch a URL.".to_string(), + short_description: None, }, ]) .expect("catalog description"); assert!(description.contains("- Git")); assert!(description.contains("- WebFetch")); - assert!(!description.contains("Inspect repository state.")); + assert!(description.contains("- Git: Inspect repository state.")); assert!(!description.contains("Fetch a URL.")); } diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index 7e4b343695..874d5622b0 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -5,6 +5,7 @@ pub mod acp_tool_bridge; pub mod computer_use; +pub mod deferred_tool; pub mod element_token; pub mod execution_gate; pub mod file_guidance; @@ -29,6 +30,12 @@ pub use bitfun_core_types::ToolImageAttachment; pub use bitfun_runtime_ports::{ DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, }; +pub use deferred_tool::{ + call_deferred_tool_description, call_deferred_tool_input_schema, + call_deferred_tool_short_description, effective_tool_invocation, + parse_call_deferred_tool_input, CallDeferredToolInput, CallDeferredToolInputError, + ResolvedToolInvocation, ToolInvocationKind, CALL_DEFERRED_TOOL_NAME, +}; pub use execution_gate::{ validate_tool_execution_admission, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, @@ -42,17 +49,16 @@ pub use file_read_freshness::{ }; pub use framework::{ build_bitfun_current_session_uri, build_bitfun_runtime_uri, - build_collapsed_tool_stub_definition, build_get_tool_spec_assistant_detail, - build_get_tool_spec_catalog_description, build_get_tool_spec_catalog_description_from_provider, - build_get_tool_spec_description, build_get_tool_spec_detail_result, - build_get_tool_spec_duplicate_load_hint, build_get_tool_spec_duplicate_load_result, - build_prompt_visible_tool_manifest_definitions, build_tool_manifest_policy_tools, - build_tool_path_policy_denial_message, build_tool_runtime_artifact_reference, - build_tool_session_runtime_artifact_reference, collect_loaded_collapsed_tool_names, - get_tool_spec_input_schema, get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, - get_tool_spec_needs_permissions, get_tool_spec_short_description, - is_bitfun_current_session_uri, is_bitfun_runtime_uri, is_bitfun_tool_uri, - is_miniapp_headless_agent_run, is_remote_posix_path_within_root, + build_get_tool_spec_assistant_detail, build_get_tool_spec_catalog_description, + build_get_tool_spec_catalog_description_from_provider, build_get_tool_spec_description, + build_get_tool_spec_detail_result, build_get_tool_spec_duplicate_load_hint, + build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, + build_tool_manifest_policy_tools, build_tool_path_policy_denial_message, + build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, + collect_loaded_deferred_tool_specs, get_tool_spec_input_schema, + get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, + get_tool_spec_short_description, is_bitfun_current_session_uri, is_bitfun_runtime_uri, + is_bitfun_tool_uri, is_miniapp_headless_agent_run, is_remote_posix_path_within_root, is_tool_path_allowed_by_resolved_roots, materialize_static_tool_provider_groups, miniapp_headless_agent_tool_restrictions, normalize_absolute_posix_path, normalize_host_path, normalize_runtime_relative_path, parse_bitfun_current_session_uri, parse_bitfun_runtime_uri, @@ -65,23 +71,24 @@ pub use framework::{ resolve_host_path_with_workspace, resolve_readonly_enabled_tools, resolve_tool_manifest_policy, resolve_tool_path_with_context, resolve_tool_path_with_context_roots, resolve_workspace_tool_path, sort_tool_manifest_definitions, - summarize_get_tool_spec_collapsed_tools, tool_manifest_sort_rank, + summarize_get_tool_spec_deferred_tools, tool_manifest_sort_rank, tool_path_is_effectively_absolute, tool_restrictions_for_delegation_policy, - validate_collapsed_tool_usage, validate_get_tool_spec_input, validate_tool_allowed_by_list, - CollapsedToolUsageError, ContextualToolManifest, ContextualToolManifestItem, - ContextualVisibleTools, DynamicMcpToolInfo, DynamicToolInfo, GetToolSpecCatalogProvider, - GetToolSpecCollapsedToolSummary, GetToolSpecDetail, GetToolSpecExecutionError, + validate_deferred_tool_usage, validate_get_tool_spec_input, validate_tool_allowed_by_list, + ContextualToolManifest, ContextualToolManifestItem, ContextualVisibleTools, + DeferredToolUsageError, DynamicMcpToolInfo, DynamicToolInfo, GetToolSpecCatalogProvider, + GetToolSpecDeferredToolSummary, GetToolSpecDetail, GetToolSpecExecutionError, GetToolSpecExecutionPlan, GetToolSpecLoadObservation, GetToolSpecRuntime, - ParsedBitFunCurrentSessionUri, ParsedBitFunRuntimeUri, PortableToolContextProvider, - PromptVisibleToolManifestItem, SnapshotToolDecorator, SnapshotToolWrapper, - SnapshotToolWrapperRef, StaticToolMaterializationError, StaticToolProvider, - StaticToolProviderFactory, StaticToolProviderGroup, StaticToolProviderPlan, ToolCatalogRuntime, - ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef, ToolExecutionAccessError, - ToolExposure, ToolManifestDefinition, ToolManifestPolicyResolution, ToolManifestPolicyTool, - ToolPathBackend, ToolPathContractError, ToolPathOperation, ToolPathPolicy, ToolPathResolution, - ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, ToolRestrictionError, ToolResult, - ToolRuntimeAssembly, ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult, - BITFUN_CURRENT_SESSION_URI_PREFIX, BITFUN_RUNTIME_URI_PREFIX, GET_TOOL_SPEC_TOOL_NAME, + LoadedDeferredToolSpec, ParsedBitFunCurrentSessionUri, ParsedBitFunRuntimeUri, + PortableToolContextProvider, PromptVisibleToolManifestItem, SnapshotToolDecorator, + SnapshotToolWrapper, SnapshotToolWrapperRef, StaticToolMaterializationError, + StaticToolProvider, StaticToolProviderFactory, StaticToolProviderGroup, StaticToolProviderPlan, + ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef, + ToolExecutionAccessError, ToolExposure, ToolManifestDefinition, ToolManifestPolicyResolution, + ToolManifestPolicyTool, ToolPathBackend, ToolPathContractError, ToolPathOperation, + ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, + ToolRestrictionError, ToolResult, ToolRuntimeAssembly, ToolRuntimeRestrictions, + ToolWorkspaceKind, ValidationResult, BITFUN_CURRENT_SESSION_URI_PREFIX, + BITFUN_RUNTIME_URI_PREFIX, GET_TOOL_SPEC_TOOL_NAME, }; pub use input_validator::InputValidator; pub use mcp_tool_bridge::{ diff --git a/src/crates/execution/tool-contracts/tests/tool_contracts.rs b/src/crates/execution/tool-contracts/tests/tool_contracts.rs index 396506c6f7..81f9bf6a76 100644 --- a/src/crates/execution/tool-contracts/tests/tool_contracts.rs +++ b/src/crates/execution/tool-contracts/tests/tool_contracts.rs @@ -7,34 +7,35 @@ use bitfun_agent_tools::{ AcpExternalAgentToolDefinitionInput, ACP_TOOL_PREFIX, ACP_TOOL_SUFFIX, }; use bitfun_agent_tools::{ - build_bitfun_runtime_uri, build_collapsed_tool_stub_definition, - build_get_tool_spec_assistant_detail, build_get_tool_spec_detail_result, - build_get_tool_spec_duplicate_load_hint, build_get_tool_spec_duplicate_load_result, - build_prompt_visible_tool_manifest_definitions, build_tool_execution_timeout_presentation, - build_tool_path_policy_denial_message, build_tool_runtime_artifact_reference, - build_tool_session_runtime_artifact_reference, collect_loaded_collapsed_tool_names, - get_tool_spec_input_schema, get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, - get_tool_spec_needs_permissions, get_tool_spec_short_description, is_bitfun_runtime_uri, - is_remote_posix_path_within_root, is_tool_path_allowed_by_resolved_roots, normalize_host_path, - normalize_runtime_relative_path, parse_bitfun_current_session_uri, parse_bitfun_runtime_uri, - posix_resolve_path_with_workspace, posix_style_path_is_absolute, - render_get_tool_spec_tool_use_message, resolve_contextual_tool_manifest, - resolve_contextual_tool_manifest_from_provider, resolve_get_tool_spec_detail, - resolve_get_tool_spec_detail_from_provider, + build_bitfun_runtime_uri, build_get_tool_spec_assistant_detail, + build_get_tool_spec_detail_result, build_get_tool_spec_duplicate_load_hint, + build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, + build_tool_execution_timeout_presentation, build_tool_path_policy_denial_message, + build_tool_runtime_artifact_reference, build_tool_session_runtime_artifact_reference, + call_deferred_tool_description, call_deferred_tool_input_schema, + collect_loaded_deferred_tool_specs, effective_tool_invocation, get_tool_spec_input_schema, + get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, + get_tool_spec_short_description, is_bitfun_runtime_uri, is_remote_posix_path_within_root, + is_tool_path_allowed_by_resolved_roots, normalize_host_path, normalize_runtime_relative_path, + parse_bitfun_current_session_uri, parse_bitfun_runtime_uri, posix_resolve_path_with_workspace, + posix_style_path_is_absolute, render_get_tool_spec_tool_use_message, + resolve_contextual_tool_manifest, resolve_contextual_tool_manifest_from_provider, + resolve_get_tool_spec_detail, resolve_get_tool_spec_detail_from_provider, resolve_get_tool_spec_execution_result_from_provider, resolve_host_path_with_workspace, resolve_readonly_enabled_tools, resolve_tool_manifest_policy, resolve_tool_path_with_context, resolve_tool_path_with_context_roots, resolve_workspace_tool_path, - sort_tool_manifest_definitions, summarize_get_tool_spec_collapsed_tools, - tool_path_is_effectively_absolute, validate_collapsed_tool_usage, validate_get_tool_spec_input, + sort_tool_manifest_definitions, summarize_get_tool_spec_deferred_tools, + tool_path_is_effectively_absolute, validate_deferred_tool_usage, validate_get_tool_spec_input, validate_mcp_tool_bridge_input, validate_tool_allowed_by_list, - validate_tool_execution_admission, DynamicMcpToolInfo, DynamicToolInfo, - GetToolSpecCollapsedToolSummary, GetToolSpecExecutionError, GetToolSpecExecutionPlan, - GetToolSpecLoadObservation, GetToolSpecRuntime, InputValidator, McpToolBridgeBehaviorHints, - McpToolBridgeDefinitionInput, PromptVisibleToolManifestItem, ToolContextFacts, + validate_tool_execution_admission, CallDeferredToolInputError, DynamicMcpToolInfo, + DynamicToolInfo, GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, + GetToolSpecExecutionPlan, GetToolSpecLoadObservation, GetToolSpecRuntime, InputValidator, + LoadedDeferredToolSpec, McpToolBridgeBehaviorHints, McpToolBridgeDefinitionInput, + PromptVisibleToolManifestItem, ResolvedToolInvocation, ToolContextFacts, ToolExecutionAdmissionRejection, ToolExecutionAdmissionRequest, ToolExposure, ToolImageAttachment, ToolManifestDefinition, ToolManifestPolicyTool, ToolPathBackend, ToolPathOperation, ToolPathResolution, ToolRenderOptions, ToolResult, ToolRuntimeRestrictions, - ToolWorkspaceKind, ValidationResult, GET_TOOL_SPEC_TOOL_NAME, + ToolWorkspaceKind, ValidationResult, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; use bitfun_agent_tools::{ build_invalid_tool_call_error_message, build_tool_call_truncation_recovery_notice, @@ -90,6 +91,100 @@ impl StaticToolProviderPlan for TestProviderPlan { } } +#[test] +fn call_deferred_tool_contract_uses_nested_object_arguments() { + let schema = call_deferred_tool_input_schema(); + + assert!(call_deferred_tool_description() + .contains("The order is important. ALWAYS output tool_name first, then args.")); + assert_eq!(schema["additionalProperties"], false); + assert_eq!(schema["required"], json!(["tool_name", "args"])); + assert_eq!(schema["properties"]["args"]["type"], "object"); + assert_eq!(schema["properties"]["args"]["additionalProperties"], true); + + let invocation = ResolvedToolInvocation::from_wire_call( + CALL_DEFERRED_TOOL_NAME, + json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }), + ) + .expect("valid deferred tool invocation"); + + assert!(invocation.is_deferred()); + assert_eq!(invocation.wire_tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(invocation.effective_tool_name, "get_weather"); + assert_eq!( + invocation.effective_arguments, + json!({ "city": "Shanghai" }) + ); +} + +#[test] +fn effective_tool_invocation_borrows_deferred_identity_from_wire_call() { + let arguments = json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }); + + let (tool_name, effective_arguments) = + effective_tool_invocation(CALL_DEFERRED_TOOL_NAME, &arguments); + + assert_eq!(tool_name, "get_weather"); + assert_eq!(effective_arguments, &json!({ "city": "Shanghai" })); +} + +#[test] +fn call_deferred_tool_contract_rejects_flat_or_string_arguments() { + let flat = ResolvedToolInvocation::from_wire_call( + CALL_DEFERRED_TOOL_NAME, + json!({ + "tool_name": "get_weather", + "city": "Shanghai" + }), + ) + .expect_err("flat target arguments must be rejected"); + assert_eq!( + flat, + CallDeferredToolInputError::UnexpectedField("city".to_string()) + ); + + let encoded = ResolvedToolInvocation::from_wire_call( + CALL_DEFERRED_TOOL_NAME, + json!({ + "tool_name": "get_weather", + "args": "{\"city\":\"Shanghai\"}" + }), + ) + .expect_err("JSON-encoded string arguments must be rejected"); + assert_eq!(encoded, CallDeferredToolInputError::ArgsMustBeObject); +} + +#[test] +fn resolved_tool_invocation_updates_effective_arguments_without_losing_wire_identity() { + let mut invocation = ResolvedToolInvocation::from_wire_call( + CALL_DEFERRED_TOOL_NAME, + json!({ + "tool_name": "get_weather", + "args": { "city": "Shanghai" } + }), + ) + .expect("valid deferred tool invocation"); + + invocation.replace_effective_arguments(json!({ "city": "Beijing" })); + + assert_eq!(invocation.wire_tool_name, CALL_DEFERRED_TOOL_NAME); + assert_eq!(invocation.effective_tool_name, "get_weather"); + assert_eq!(invocation.effective_arguments, json!({ "city": "Beijing" })); + assert_eq!( + invocation.wire_arguments, + json!({ + "tool_name": "get_weather", + "args": { "city": "Beijing" } + }) + ); +} + #[test] fn mcp_tool_bridge_preserves_prompt_visible_name_and_descriptor_contract() { assert_eq!(MCP_TOOL_PREFIX, "mcp__"); @@ -1278,35 +1373,73 @@ fn runtime_artifact_reference_owner_preserves_session_prefix_and_rejects_escape( } #[test] -fn collapsed_tool_usage_gate_preserves_get_tool_spec_unlock_contract() { - let collapsed_tools = vec!["WebFetch".to_string()]; - let loaded_collapsed_tools = Vec::new(); +fn deferred_tool_usage_gate_preserves_get_tool_spec_unlock_contract() { + let deferred_tools = vec!["WebFetch".to_string()]; + let loaded_deferred_tool_specs = Vec::new(); - let err = validate_collapsed_tool_usage( + let err = validate_deferred_tool_usage( "WebFetch", - &collapsed_tools, - &loaded_collapsed_tools, + true, + &deferred_tools, + &loaded_deferred_tool_specs, + 0, GET_TOOL_SPEC_TOOL_NAME, ) - .expect_err("collapsed tool should require GetToolSpec unlock"); + .expect_err("deferred tool should require GetToolSpec unlock"); assert_eq!( err.to_string(), - "Tool 'WebFetch' is collapsed. Call GetToolSpec first with {\"tool_name\":\"WebFetch\"} to read its full usage instructions and input schema, then try again." + "Tool 'WebFetch' is deferred. Call GetToolSpec first with {\"tool_name\":\"WebFetch\"} to read its full usage instructions and input schema, then call it through CallDeferredTool." ); - let loaded_collapsed_tools = vec!["WebFetch".to_string()]; - validate_collapsed_tool_usage( + let loaded_deferred_tool_specs = vec![LoadedDeferredToolSpec { + tool_name: "WebFetch".to_string(), + catalog_generation: 0, + }]; + validate_deferred_tool_usage( + "WebFetch", + true, + &deferred_tools, + &loaded_deferred_tool_specs, + 0, + GET_TOOL_SPEC_TOOL_NAME, + ) + .expect("loaded deferred tool should be executable"); + + let stale = validate_deferred_tool_usage( + "WebFetch", + true, + &deferred_tools, + &[LoadedDeferredToolSpec { + tool_name: "WebFetch".to_string(), + catalog_generation: 41, + }], + 42, + GET_TOOL_SPEC_TOOL_NAME, + ) + .expect_err("stale deferred spec should require a reload"); + assert!(stale + .to_string() + .contains("loaded catalog generation 41, current generation 42")); + + let direct = validate_deferred_tool_usage( "WebFetch", - &collapsed_tools, - &loaded_collapsed_tools, + false, + &deferred_tools, + &loaded_deferred_tool_specs, + 0, GET_TOOL_SPEC_TOOL_NAME, ) - .expect("loaded collapsed tool should be executable"); + .expect_err("deferred tools must not be called directly"); + assert!(direct + .to_string() + .contains("deferred and cannot be called directly")); - validate_collapsed_tool_usage( + validate_deferred_tool_usage( GET_TOOL_SPEC_TOOL_NAME, - &collapsed_tools, + false, + &deferred_tools, &[], + 0, GET_TOOL_SPEC_TOOL_NAME, ) .expect("GetToolSpec itself is the unlock path"); @@ -1339,8 +1472,10 @@ fn tool_execution_admission_gate_preserves_pipeline_rejection_order() { tool_name: "WebFetch", allowed_tools: &["Read".to_string()], runtime_tool_restrictions: &restrictions, - collapsed_tools: &["WebFetch".to_string()], - loaded_collapsed_tools: &[], + invocation_is_deferred: true, + deferred_tools: &["WebFetch".to_string()], + loaded_deferred_tool_specs: &[], + current_catalog_generation: 0, get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, }; @@ -1360,8 +1495,10 @@ fn tool_execution_admission_gate_preserves_pipeline_rejection_order() { tool_name: "WebFetch", allowed_tools: &["WebFetch".to_string()], runtime_tool_restrictions: &restrictions, - collapsed_tools: &["WebFetch".to_string()], - loaded_collapsed_tools: &[], + invocation_is_deferred: true, + deferred_tools: &["WebFetch".to_string()], + loaded_deferred_tool_specs: &[], + current_catalog_generation: 0, get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, }; @@ -1381,15 +1518,17 @@ fn tool_execution_admission_gate_preserves_pipeline_rejection_order() { tool_name: "WebFetch", allowed_tools: &["WebFetch".to_string()], runtime_tool_restrictions: &ToolRuntimeRestrictions::default(), - collapsed_tools: &["WebFetch".to_string()], - loaded_collapsed_tools: &[], + invocation_is_deferred: true, + deferred_tools: &["WebFetch".to_string()], + loaded_deferred_tool_specs: &[], + current_catalog_generation: 0, get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, }; let err = validate_tool_execution_admission(request) - .expect_err("collapsed tool should require GetToolSpec after access gates pass"); + .expect_err("deferred tool should require GetToolSpec after access gates pass"); - assert!(matches!(err, ToolExecutionAdmissionRejection::Collapsed(_))); + assert!(matches!(err, ToolExecutionAdmissionRejection::Deferred(_))); assert!(err .to_string() .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}")); @@ -1477,13 +1616,13 @@ fn dynamic_tool_provider_contract_is_available_from_agent_tools_boundary() { #[test] fn tool_exposure_contract_keeps_lightweight_wire_shape() { - let collapsed = ToolExposure::Collapsed; + let collapsed = ToolExposure::Deferred; let value = serde_json::to_value(collapsed).expect("serialize exposure"); - assert_eq!(value, json!("Collapsed")); + assert_eq!(value, json!("Deferred")); assert_eq!( serde_json::from_value::(value).expect("deserialize exposure"), - ToolExposure::Collapsed + ToolExposure::Deferred ); } @@ -1517,27 +1656,27 @@ fn tool_manifest_policy_keeps_get_tool_spec_insertion_and_registry_order() { let tools = vec![ ToolManifestPolicyTool { name: "Read".to_string(), - default_exposure: ToolExposure::Expanded, + default_exposure: ToolExposure::Direct, available: true, }, ToolManifestPolicyTool { name: "WebSearch".to_string(), - default_exposure: ToolExposure::Collapsed, + default_exposure: ToolExposure::Deferred, available: true, }, ToolManifestPolicyTool { name: "WebFetch".to_string(), - default_exposure: ToolExposure::Collapsed, + default_exposure: ToolExposure::Deferred, available: true, }, ToolManifestPolicyTool { name: GET_TOOL_SPEC_TOOL_NAME.to_string(), - default_exposure: ToolExposure::Expanded, + default_exposure: ToolExposure::Direct, available: true, }, ToolManifestPolicyTool { name: "HiddenUnavailable".to_string(), - default_exposure: ToolExposure::Expanded, + default_exposure: ToolExposure::Direct, available: false, }, ]; @@ -1560,13 +1699,14 @@ fn tool_manifest_policy_keeps_get_tool_spec_insertion_and_registry_order() { "WebSearch", "HiddenUnavailable", GET_TOOL_SPEC_TOOL_NAME, + CALL_DEFERRED_TOOL_NAME, ] ); assert_eq!( - policy.expanded_tool_names, + policy.direct_tool_names, vec!["Read", GET_TOOL_SPEC_TOOL_NAME] ); - assert_eq!(policy.collapsed_tool_names, vec!["WebSearch", "WebFetch"]); + assert_eq!(policy.deferred_tool_names, vec!["WebSearch", "WebFetch"]); } #[test] @@ -1574,12 +1714,12 @@ fn tool_manifest_policy_preserves_explicit_get_tool_spec_duplicate_runtime_contr let tools = vec![ ToolManifestPolicyTool { name: GET_TOOL_SPEC_TOOL_NAME.to_string(), - default_exposure: ToolExposure::Expanded, + default_exposure: ToolExposure::Direct, available: true, }, ToolManifestPolicyTool { name: "WebFetch".to_string(), - default_exposure: ToolExposure::Collapsed, + default_exposure: ToolExposure::Deferred, available: true, }, ]; @@ -1591,75 +1731,61 @@ fn tool_manifest_policy_preserves_explicit_get_tool_spec_duplicate_runtime_contr assert_eq!( policy.allowed_tool_names, - vec![GET_TOOL_SPEC_TOOL_NAME, "WebFetch"] + vec![GET_TOOL_SPEC_TOOL_NAME, "WebFetch", CALL_DEFERRED_TOOL_NAME] ); assert_eq!( - policy.expanded_tool_names, - vec![GET_TOOL_SPEC_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME], - "core currently appends the runtime GetToolSpec entry whenever collapsed tools exist" + policy.direct_tool_names, + vec![GET_TOOL_SPEC_TOOL_NAME], + "explicit GetToolSpec must not be duplicated when deferred tools exist" ); - assert_eq!(policy.collapsed_tool_names, vec!["WebFetch"]); + assert_eq!(policy.deferred_tool_names, vec!["WebFetch"]); } #[test] fn get_tool_spec_load_collector_preserves_collapsed_runtime_contract() { - let collapsed_tools = vec!["WebFetch".to_string(), "GetFileDiff".to_string()]; + let deferred_tools = vec!["WebFetch".to_string(), "GetFileDiff".to_string()]; let observations = vec![ GetToolSpecLoadObservation { tool_name: GET_TOOL_SPEC_TOOL_NAME, loaded_tool_name: Some("WebFetch"), + catalog_generation: Some(42), is_error: false, }, GetToolSpecLoadObservation { tool_name: GET_TOOL_SPEC_TOOL_NAME, loaded_tool_name: Some("Read"), + catalog_generation: Some(42), is_error: false, }, GetToolSpecLoadObservation { tool_name: GET_TOOL_SPEC_TOOL_NAME, loaded_tool_name: Some("GetFileDiff"), + catalog_generation: Some(42), is_error: true, }, GetToolSpecLoadObservation { tool_name: "Read", loaded_tool_name: Some("WebFetch"), + catalog_generation: Some(42), is_error: false, }, GetToolSpecLoadObservation { tool_name: GET_TOOL_SPEC_TOOL_NAME, loaded_tool_name: Some("WebFetch"), + catalog_generation: Some(42), is_error: false, }, ]; - let loaded = collect_loaded_collapsed_tool_names( - &observations, - &collapsed_tools, - GET_TOOL_SPEC_TOOL_NAME, - ); - - assert_eq!(loaded, vec!["WebFetch".to_string()]); -} - -#[test] -fn collapsed_tool_stub_definition_preserves_prompt_visible_guardrail() { - let stub = build_collapsed_tool_stub_definition( - "WebFetch", - "Fetch a URL and return readable content.", - ); + let loaded = + collect_loaded_deferred_tool_specs(&observations, &deferred_tools, GET_TOOL_SPEC_TOOL_NAME); - assert_eq!(stub.name, "WebFetch"); - assert!(stub.description.contains("Fetch a URL")); - assert!(stub - .description - .contains("THIS IS A COLLAPSED TOOL. Before first use, call GetToolSpec({\"tool_name\":\"WebFetch\"}) to load its schema.")); assert_eq!( - stub.parameters, - json!({ - "type": "object", - "additionalProperties": true, - "properties": {} - }) + loaded, + vec![LoadedDeferredToolSpec { + tool_name: "WebFetch".to_string(), + catalog_generation: 42, + }] ); } @@ -1685,18 +1811,14 @@ fn tool_manifest_sorting_preserves_prompt_visible_order() { } #[test] -fn prompt_visible_manifest_builder_preserves_expanded_and_collapsed_contract() { +fn prompt_visible_manifest_builder_omits_deferred_tools_from_provider_manifest() { let definitions = build_prompt_visible_tool_manifest_definitions(&[ - PromptVisibleToolManifestItem::Collapsed { - name: "WebFetch".to_string(), - short_description: "Fetch readable web content.".to_string(), - }, - PromptVisibleToolManifestItem::Expanded(ToolManifestDefinition::new( + PromptVisibleToolManifestItem::Direct(ToolManifestDefinition::new( "Read", "Read files from the workspace.", json!({ "type": "object", "properties": { "path": { "type": "string" } } }), )), - PromptVisibleToolManifestItem::Expanded(ToolManifestDefinition::new( + PromptVisibleToolManifestItem::Direct(ToolManifestDefinition::new( "Bash", "Run shell commands.", json!({ "type": "object", "properties": { "command": { "type": "string" } } }), @@ -1708,16 +1830,13 @@ fn prompt_visible_manifest_builder_preserves_expanded_and_collapsed_contract() { .iter() .map(|definition| definition.name.as_str()) .collect::>(), - vec!["Bash", "Read", "WebFetch"] + vec!["Bash", "Read"] ); assert_eq!(definitions[0].description, "Run shell commands."); assert_eq!( definitions[0].parameters["properties"]["command"]["type"], json!("string") ); - assert!(definitions[2] - .description - .contains("THIS IS A COLLAPSED TOOL. Before first use, call GetToolSpec({\"tool_name\":\"WebFetch\"}) to load its schema.")); } #[test] @@ -1756,7 +1875,7 @@ fn get_tool_spec_contract_preserves_input_schema_and_validation() { fn get_tool_spec_contract_preserves_static_metadata_and_use_message() { assert_eq!( get_tool_spec_short_description(), - "Discover collapsed tools and read their detailed definitions." + "Discover deferred tools and read their detailed definitions." ); assert!(get_tool_spec_is_readonly()); assert!(get_tool_spec_is_concurrency_safe(Some(&json!({ @@ -1778,6 +1897,7 @@ fn get_tool_spec_contract_preserves_static_metadata_and_use_message() { #[test] fn get_tool_spec_contract_escapes_assistant_detail_for_xml_sections() { let detail = build_get_tool_spec_assistant_detail( + "Git", "Use & keep output valid.", &json!({ "type": "object", @@ -1791,6 +1911,7 @@ fn get_tool_spec_contract_escapes_assistant_detail_for_xml_sections() { assert!(detail.contains("\nUse <danger> & keep output valid.")); assert!(detail.contains("\"description\":\"Match <tag> & symbols\"")); + assert!(detail.contains("CallDeferredTool({\"tool_name\":\"Git\",\"args\":{...}})")); assert!(!detail.contains("Use & keep output valid.")); } @@ -1798,7 +1919,7 @@ fn get_tool_spec_contract_escapes_assistant_detail_for_xml_sections() { fn get_tool_spec_contract_preserves_duplicate_load_hint() { assert_eq!( build_get_tool_spec_duplicate_load_hint("WebFetch"), - "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly." + "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use CallDeferredTool with tool_name 'WebFetch' and put the tool arguments inside args." ); } @@ -1820,7 +1941,7 @@ fn get_tool_spec_contract_builds_duplicate_load_result() { assert_eq!( result_for_assistant.as_deref(), Some( - "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly." + "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use CallDeferredTool with tool_name 'WebFetch' and put the tool arguments inside args." ) ); assert_eq!(image_attachments, None); @@ -1840,6 +1961,7 @@ fn get_tool_spec_contract_builds_detail_result() { } } }), + catalog_generation: 42, }); let ToolResult::Result { @@ -2093,7 +2215,7 @@ impl ContextualToolManifestItem for ContextualManifestTool } fn registry_marker_tool(name: &str, provider_id: Option<&str>) -> Arc { - registry_marker_tool_with_exposure(name, provider_id, ToolExposure::Expanded) + registry_marker_tool_with_exposure(name, provider_id, ToolExposure::Direct) } fn registry_marker_tool_with_exposure( @@ -2160,7 +2282,7 @@ impl ToolCatalogSnapshotProvider for RegistryMarkerSnapshotP impl GetToolSpecCatalogProvider for ContextualManifestSnapshotProvider { - async fn collapsed_tools_for_get_tool_spec( + async fn deferred_tools_for_get_tool_spec( &self, context: Option<&ManifestTestContext>, ) -> Result>, String> { @@ -2168,7 +2290,7 @@ impl GetToolSpecCatalogProvider Some(context) => { let mut tools = Vec::new(); for tool in &self.tools { - if tool.default_exposure() == ToolExposure::Collapsed + if tool.default_exposure() == ToolExposure::Deferred && tool.is_available_in_context(context).await { tools.push(tool.clone()); @@ -2179,7 +2301,7 @@ impl GetToolSpecCatalogProvider None => self .tools .iter() - .filter(|tool| tool.default_exposure() == ToolExposure::Collapsed) + .filter(|tool| tool.default_exposure() == ToolExposure::Deferred) .cloned() .collect(), }; @@ -2212,7 +2334,7 @@ impl GetToolSpecCatalogProvider impl GetToolSpecCatalogProvider for ErroringGetToolSpecProvider { - async fn collapsed_tools_for_get_tool_spec( + async fn deferred_tools_for_get_tool_spec( &self, _context: Option<&ManifestTestContext>, ) -> Result>, String> { @@ -2416,7 +2538,7 @@ fn generic_tool_runtime_assembly_installs_static_providers_with_decorator() { vec![registry_marker_tool_with_exposure( "WebFetch", None, - ToolExposure::Collapsed, + ToolExposure::Deferred, )], ), ]; @@ -2433,9 +2555,9 @@ fn generic_tool_runtime_assembly_installs_static_providers_with_decorator() { "runtime assembly must preserve static provider order while applying the decorator" ); assert_eq!( - registry.get_collapsed_tool_names(), + registry.get_deferred_tool_names(), vec!["decorated_WebFetch".to_string()], - "runtime assembly must preserve collapsed exposure after decoration" + "runtime assembly must preserve deferred exposure after decoration" ); } @@ -2467,7 +2589,7 @@ fn generic_tool_runtime_assembly_materializes_plans_before_registry_install() { "assembly must own generic plan materialization plus registry installation" ); assert_eq!( - registry.get_collapsed_tool_names(), + registry.get_deferred_tool_names(), Vec::::new(), "decorator-based plan materialization must not change exposure" ); @@ -2480,18 +2602,18 @@ fn generic_tool_registry_preserves_exposure_catalog_contract() { registry.register_tool(registry_marker_tool_with_exposure( "WebFetch", None, - ToolExposure::Collapsed, + ToolExposure::Deferred, )); registry.register_tool(registry_marker_tool_with_exposure( "Git", None, - ToolExposure::Collapsed, + ToolExposure::Deferred, )); - assert!(!registry.is_tool_collapsed("Read")); - assert!(registry.is_tool_collapsed("WebFetch")); + assert!(!registry.is_tool_deferred("Read")); + assert!(registry.is_tool_deferred("WebFetch")); assert_eq!( - registry.get_collapsed_tool_names(), + registry.get_deferred_tool_names(), vec!["WebFetch".to_string(), "Git".to_string()] ); } @@ -2499,16 +2621,16 @@ fn generic_tool_registry_preserves_exposure_catalog_contract() { #[tokio::test] async fn generic_readonly_enabled_filter_preserves_registry_order() { let tools = vec![ - registry_marker_tool_with_access("Read", None, ToolExposure::Expanded, true, true), - registry_marker_tool_with_access("Write", None, ToolExposure::Expanded, false, true), + registry_marker_tool_with_access("Read", None, ToolExposure::Direct, true, true), + registry_marker_tool_with_access("Write", None, ToolExposure::Direct, false, true), registry_marker_tool_with_access( "DisabledReadonly", None, - ToolExposure::Expanded, + ToolExposure::Direct, true, false, ), - registry_marker_tool_with_access("WebFetch", None, ToolExposure::Collapsed, true, true), + registry_marker_tool_with_access("WebFetch", None, ToolExposure::Deferred, true, true), ]; let readonly_names = resolve_readonly_enabled_tools(&tools) @@ -2528,8 +2650,8 @@ async fn generic_readonly_enabled_filter_preserves_registry_order() { fn manifest_policy_tools_from_registry_snapshot_preserve_exposure_and_availability() { let tools = vec![ registry_marker_tool("Read", None), - registry_marker_tool_with_exposure("WebFetch", None, ToolExposure::Collapsed), - registry_marker_tool_with_exposure("Git", None, ToolExposure::Collapsed), + registry_marker_tool_with_exposure("WebFetch", None, ToolExposure::Deferred), + registry_marker_tool_with_exposure("Git", None, ToolExposure::Deferred), ]; let available_tool_names = ["Read".to_string(), "Git".to_string()] .into_iter() @@ -2543,17 +2665,17 @@ fn manifest_policy_tools_from_registry_snapshot_preserve_exposure_and_availabili vec![ ToolManifestPolicyTool { name: "Read".to_string(), - default_exposure: ToolExposure::Expanded, + default_exposure: ToolExposure::Direct, available: true, }, ToolManifestPolicyTool { name: "WebFetch".to_string(), - default_exposure: ToolExposure::Collapsed, + default_exposure: ToolExposure::Deferred, available: false, }, ToolManifestPolicyTool { name: "Git".to_string(), - default_exposure: ToolExposure::Collapsed, + default_exposure: ToolExposure::Deferred, available: true, }, ] @@ -2563,10 +2685,10 @@ fn manifest_policy_tools_from_registry_snapshot_preserve_exposure_and_availabili #[tokio::test] async fn contextual_manifest_resolver_preserves_runtime_visible_manifest_contract() { let tools = vec![ - contextual_manifest_tool("Read", ToolExposure::Expanded, None), - contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), - contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), - contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Expanded, None), + contextual_manifest_tool("Read", ToolExposure::Direct, None), + contextual_manifest_tool("WebFetch", ToolExposure::Deferred, None), + contextual_manifest_tool("Git", ToolExposure::Deferred, Some("other-agent")), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Direct, None), ]; let manifest = resolve_contextual_tool_manifest( @@ -2589,31 +2711,32 @@ async fn contextual_manifest_resolver_preserves_runtime_visible_manifest_contrac "WebFetch".to_string(), "Git".to_string(), GET_TOOL_SPEC_TOOL_NAME.to_string(), + CALL_DEFERRED_TOOL_NAME.to_string(), ], "GetToolSpec insertion must preserve the runtime allowed-list contract" ); assert_eq!( - manifest.collapsed_tool_names, + manifest.deferred_tool_names, vec!["WebFetch".to_string()], - "unavailable collapsed tools must not leak into the prompt-visible unlock catalog" + "unavailable deferred tools must not leak into the prompt-visible unlock catalog" ); assert_eq!( manifest - .expanded_tools + .direct_tools .iter() .map(|tool| tool.name.as_str()) .collect::>(), vec!["Read", GET_TOOL_SPEC_TOOL_NAME], - "expanded tool handles must follow the resolved runtime policy" + "direct tool handles must follow the resolved runtime policy" ); assert_eq!( manifest - .collapsed_tools + .deferred_tools .iter() .map(|tool| tool.name.as_str()) .collect::>(), vec!["WebFetch"], - "collapsed tool handles must follow the resolved runtime policy" + "deferred tool handles must follow the resolved runtime policy" ); assert_eq!( manifest @@ -2621,7 +2744,7 @@ async fn contextual_manifest_resolver_preserves_runtime_visible_manifest_contrac .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["Read", "WebFetch", GET_TOOL_SPEC_TOOL_NAME], + vec!["Read", GET_TOOL_SPEC_TOOL_NAME], "prompt-visible manifest ordering must stay stable when the owner moves" ); @@ -2633,26 +2756,20 @@ async fn contextual_manifest_resolver_preserves_runtime_visible_manifest_contrac assert_eq!(read.description, "Read description for agentic"); assert_eq!(read.parameters["properties"]["agent"]["const"], "agentic"); - let web_fetch = manifest + assert!(!manifest .tool_definitions .iter() - .find(|tool| tool.name == "WebFetch") - .expect("collapsed WebFetch stub"); - assert!(web_fetch - .description - .contains("THIS IS A COLLAPSED TOOL. Before first use, call GetToolSpec({\"tool_name\":\"WebFetch\"}) to load its schema.")); - assert_eq!(web_fetch.parameters["additionalProperties"], true); - assert_eq!(web_fetch.parameters["properties"], json!({})); + .any(|tool| tool.name == "WebFetch")); } #[tokio::test] async fn contextual_manifest_resolver_accepts_snapshot_provider_boundary() { let provider = ContextualManifestSnapshotProvider { tools: vec![ - contextual_manifest_tool("Read", ToolExposure::Expanded, None), - contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), - contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), - contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Expanded, None), + contextual_manifest_tool("Read", ToolExposure::Direct, None), + contextual_manifest_tool("WebFetch", ToolExposure::Deferred, None), + contextual_manifest_tool("Git", ToolExposure::Deferred, Some("other-agent")), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Direct, None), ], }; @@ -2676,6 +2793,7 @@ async fn contextual_manifest_resolver_accepts_snapshot_provider_boundary() { "WebFetch".to_string(), "Git".to_string(), GET_TOOL_SPEC_TOOL_NAME.to_string(), + CALL_DEFERRED_TOOL_NAME.to_string(), ], "provider-backed resolution must preserve allowed-list semantics" ); @@ -2685,11 +2803,11 @@ async fn contextual_manifest_resolver_accepts_snapshot_provider_boundary() { .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["Read", "WebFetch", GET_TOOL_SPEC_TOOL_NAME], + vec!["Read", GET_TOOL_SPEC_TOOL_NAME], "provider-backed resolution must preserve prompt-visible manifest ordering" ); assert_eq!( - manifest.collapsed_tool_names, + manifest.deferred_tool_names, vec!["WebFetch".to_string()], "provider-backed resolution must preserve context-aware availability filtering" ); @@ -2699,10 +2817,10 @@ async fn contextual_manifest_resolver_accepts_snapshot_provider_boundary() { async fn tool_catalog_runtime_facade_owns_manifest_and_readonly_paths() { let manifest_provider = ContextualManifestSnapshotProvider { tools: vec![ - contextual_manifest_tool("Read", ToolExposure::Expanded, None), - contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), - contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), - contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Expanded, None), + contextual_manifest_tool("Read", ToolExposure::Direct, None), + contextual_manifest_tool("WebFetch", ToolExposure::Deferred, None), + contextual_manifest_tool("Git", ToolExposure::Deferred, Some("other-agent")), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Direct, None), ], }; let runtime = ToolCatalogRuntime::::new( @@ -2728,20 +2846,21 @@ async fn tool_catalog_runtime_facade_owns_manifest_and_readonly_paths() { "WebFetch".to_string(), "Git".to_string(), GET_TOOL_SPEC_TOOL_NAME.to_string(), + CALL_DEFERRED_TOOL_NAME.to_string(), ], "runtime facade must preserve allowed-list insertion" ); assert_eq!( visible_tools - .expanded_tools + .direct_tools .iter() .map(|tool| tool.name.as_str()) .collect::>(), vec!["Read", GET_TOOL_SPEC_TOOL_NAME], - "runtime facade must preserve expanded handle order" + "runtime facade must preserve direct handle order" ); assert_eq!( - visible_tools.collapsed_tool_names, + visible_tools.deferred_tool_names, vec!["WebFetch".to_string()], "runtime facade must preserve context-aware collapsed filtering" ); @@ -2763,16 +2882,16 @@ async fn tool_catalog_runtime_facade_owns_manifest_and_readonly_paths() { .iter() .map(|tool| tool.name.as_str()) .collect::>(), - vec!["Read", "WebFetch", GET_TOOL_SPEC_TOOL_NAME], + vec!["Read", GET_TOOL_SPEC_TOOL_NAME], "runtime facade must preserve prompt-visible manifest order" ); let readonly_provider = RegistryMarkerSnapshotProvider { tools: vec![ - registry_marker_tool_with_access("Read", None, ToolExposure::Expanded, true, true), - registry_marker_tool_with_access("Write", None, ToolExposure::Expanded, false, true), - registry_marker_tool_with_access("Disabled", None, ToolExposure::Expanded, true, false), - registry_marker_tool_with_access("WebFetch", None, ToolExposure::Collapsed, true, true), + registry_marker_tool_with_access("Read", None, ToolExposure::Direct, true, true), + registry_marker_tool_with_access("Write", None, ToolExposure::Direct, false, true), + registry_marker_tool_with_access("Disabled", None, ToolExposure::Direct, true, false), + registry_marker_tool_with_access("WebFetch", None, ToolExposure::Deferred, true, true), ], }; let readonly_runtime = ToolCatalogRuntime::::new( @@ -2795,30 +2914,30 @@ async fn tool_catalog_runtime_facade_owns_manifest_and_readonly_paths() { #[tokio::test] async fn get_tool_spec_detail_resolver_preserves_contextual_detail_contract() { - let collapsed_tools = vec![ - contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), - contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Collapsed, None), + let deferred_tools = vec![ + contextual_manifest_tool("WebFetch", ToolExposure::Deferred, None), + contextual_manifest_tool(GET_TOOL_SPEC_TOOL_NAME, ToolExposure::Deferred, None), ]; let context = ManifestTestContext { agent: "agentic" }; - let summaries = summarize_get_tool_spec_collapsed_tools(&collapsed_tools); + let summaries = summarize_get_tool_spec_deferred_tools(&deferred_tools); assert_eq!( summaries, vec![ - GetToolSpecCollapsedToolSummary { + GetToolSpecDeferredToolSummary { name: "WebFetch".to_string(), - short_description: "WebFetch short description".to_string(), + short_description: Some("WebFetch short description".to_string()), }, - GetToolSpecCollapsedToolSummary { + GetToolSpecDeferredToolSummary { name: GET_TOOL_SPEC_TOOL_NAME.to_string(), - short_description: "GetToolSpec short description".to_string(), + short_description: Some("GetToolSpec short description".to_string()), }, ], - "catalog summaries must preserve collapsed tool order and short descriptions" + "catalog summaries must preserve deferred tool order and short descriptions" ); let detail = resolve_get_tool_spec_detail( - &collapsed_tools, + &deferred_tools, "WebFetch", &context, GET_TOOL_SPEC_TOOL_NAME, @@ -2844,19 +2963,20 @@ async fn get_tool_spec_detail_resolver_preserves_contextual_detail_contract() { "const": "agentic" } } - } + }, + "catalog_generation": 0 }), "detail JSON shape must stay compatible with GetToolSpec execution output" ); let missing = - resolve_get_tool_spec_detail(&collapsed_tools, "Git", &context, GET_TOOL_SPEC_TOOL_NAME) + resolve_get_tool_spec_detail(&deferred_tools, "Git", &context, GET_TOOL_SPEC_TOOL_NAME) .await .expect_err("missing tool should stay a validation-style error"); assert_eq!(missing, "'Git' is not available in the current context"); let self_inspection = resolve_get_tool_spec_detail( - &collapsed_tools, + &deferred_tools, GET_TOOL_SPEC_TOOL_NAME, &context, GET_TOOL_SPEC_TOOL_NAME, @@ -2870,9 +2990,9 @@ async fn get_tool_spec_detail_resolver_preserves_contextual_detail_contract() { async fn get_tool_spec_catalog_provider_preserves_runtime_catalog_contract() { let provider = ContextualManifestSnapshotProvider { tools: vec![ - contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), - contextual_manifest_tool("Git", ToolExposure::Collapsed, Some("other-agent")), - contextual_manifest_tool("Read", ToolExposure::Expanded, None), + contextual_manifest_tool("WebFetch", ToolExposure::Deferred, None), + contextual_manifest_tool("Git", ToolExposure::Deferred, Some("other-agent")), + contextual_manifest_tool("Read", ToolExposure::Direct, None), ], }; let context = ManifestTestContext { agent: "agentic" }; @@ -2897,7 +3017,10 @@ async fn get_tool_spec_provider_execution_returns_duplicate_result_without_detai let result = resolve_get_tool_spec_execution_result_from_provider( &ErroringGetToolSpecProvider, &input, - &["WebFetch".to_string()], + &[LoadedDeferredToolSpec { + tool_name: "WebFetch".to_string(), + catalog_generation: 0, + }], &context, GET_TOOL_SPEC_TOOL_NAME, ) @@ -2927,7 +3050,7 @@ async fn get_tool_spec_provider_execution_returns_detail_result_from_provider() let provider = ContextualManifestSnapshotProvider { tools: vec![contextual_manifest_tool( "WebFetch", - ToolExposure::Collapsed, + ToolExposure::Deferred, None, )], }; @@ -2970,8 +3093,8 @@ async fn get_tool_spec_provider_execution_returns_detail_result_from_provider() async fn get_tool_spec_provider_execution_returns_already_available_result_for_expanded_tool() { let provider = ContextualManifestSnapshotProvider { tools: vec![ - contextual_manifest_tool("WebFetch", ToolExposure::Collapsed, None), - contextual_manifest_tool("Read", ToolExposure::Expanded, None), + contextual_manifest_tool("WebFetch", ToolExposure::Deferred, None), + contextual_manifest_tool("Read", ToolExposure::Direct, None), ], }; let context = ManifestTestContext { agent: "agentic" }; @@ -3012,7 +3135,7 @@ async fn get_tool_spec_runtime_facade_owns_execution_path() { let provider = ContextualManifestSnapshotProvider { tools: vec![contextual_manifest_tool( "WebFetch", - ToolExposure::Collapsed, + ToolExposure::Deferred, None, )], }; @@ -3026,7 +3149,7 @@ async fn get_tool_spec_runtime_facade_owns_execution_path() { let result = runtime .execute(&input, &[], &context) .await - .expect("collapsed tool detail should resolve through runtime facade"); + .expect("deferred tool detail should resolve through runtime facade"); let ToolResult::Result { data, .. } = result else { panic!("expected normal tool result"); @@ -3044,7 +3167,7 @@ async fn get_tool_spec_runtime_facade_owns_tool_result_vector_adapter_shape() { let provider = ContextualManifestSnapshotProvider { tools: vec![contextual_manifest_tool( "WebFetch", - ToolExposure::Collapsed, + ToolExposure::Deferred, None, )], }; @@ -3082,7 +3205,10 @@ async fn get_tool_spec_runtime_facade_owns_tool_result_vector_adapter_shape() { let duplicate_results = duplicate_runtime .call_results( &json!({ "tool_name": "WebFetch" }), - &["WebFetch".to_string()], + &[LoadedDeferredToolSpec { + tool_name: "WebFetch".to_string(), + catalog_generation: 0, + }], &context, ) .await @@ -3101,7 +3227,7 @@ async fn get_tool_spec_runtime_facade_owns_tool_result_vector_adapter_shape() { assert_eq!( result_for_assistant.as_deref(), Some( - "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly." + "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use CallDeferredTool with tool_name 'WebFetch' and put the tool arguments inside args." ) ); assert!(image_attachments.is_none()); @@ -3141,7 +3267,7 @@ async fn get_tool_spec_provider_execution_returns_unavailable_result_for_unknown let provider = ContextualManifestSnapshotProvider { tools: vec![contextual_manifest_tool( "WebFetch", - ToolExposure::Collapsed, + ToolExposure::Deferred, None, )], }; @@ -3168,7 +3294,7 @@ async fn get_tool_spec_provider_execution_returns_unavailable_result_for_unknown }; assert_eq!(data["tool_name"], "Git"); - assert_eq!(data["available_collapsed_tool"], false); + assert_eq!(data["available_deferred_tool"], false); assert_eq!( result_for_assistant.as_deref(), Some("'Git' is not available in the current context") @@ -3234,14 +3360,14 @@ async fn generic_tool_registry_materializes_provider_effect_and_stale_call_contr registry.register_tool(registry_marker_tool_with_access( "external_search", Some("provider-a"), - ToolExposure::Collapsed, + ToolExposure::Deferred, true, true, )); registry.register_tool(registry_marker_tool_with_access( "write_file", None, - ToolExposure::Expanded, + ToolExposure::Direct, false, true, )); @@ -3266,7 +3392,7 @@ async fn generic_tool_registry_materializes_provider_effect_and_stale_call_contr .tool("external_search") .expect("external tool") .exposure, - ToolExposure::Collapsed + ToolExposure::Deferred ); assert!( snapshot diff --git a/src/crates/execution/tool-execution/src/pipeline.rs b/src/crates/execution/tool-execution/src/pipeline.rs index 69c84346f2..e74380fdd0 100644 --- a/src/crates/execution/tool-execution/src/pipeline.rs +++ b/src/crates/execution/tool-execution/src/pipeline.rs @@ -1,6 +1,6 @@ //! Provider-neutral tool pipeline planning helpers. -use bitfun_events::ToolEventData; +use bitfun_events::{ToolEventData, ToolEventIdentity}; use dashmap::DashMap; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -85,8 +85,7 @@ pub struct ToolStateCounts { #[derive(Debug, Clone)] pub struct ToolStateEventFacts { - pub tool_id: String, - pub tool_name: String, + pub identity: ToolEventIdentity, pub state: ToolStateEventKind, } @@ -276,41 +275,29 @@ pub fn sanitize_tool_result_for_event(result: &serde_json::Value) -> serde_json: } pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { - let ToolStateEventFacts { - tool_id, - tool_name, - state, - } = facts; + let ToolStateEventFacts { identity, state } = facts; match state { - ToolStateEventKind::Queued { position } => ToolEventData::Queued { - tool_id, - tool_name, - position, - }, + ToolStateEventKind::Queued { position } => ToolEventData::Queued { identity, position }, ToolStateEventKind::Waiting { dependencies } => ToolEventData::Waiting { - tool_id, - tool_name, + identity, dependencies, }, ToolStateEventKind::Running { params, timeout_seconds, } => ToolEventData::Started { - tool_id, - tool_name, + identity, params, timeout_seconds, }, ToolStateEventKind::Streaming { chunks_received } => ToolEventData::Streaming { - tool_id, - tool_name, + identity, chunks_received, }, ToolStateEventKind::AwaitingConfirmation { params, timeout_at } => { ToolEventData::ConfirmationNeeded { - tool_id, - tool_name, + identity, params, timeout_at, } @@ -324,8 +311,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { confirmation_wait_ms, execution_ms, } => ToolEventData::Completed { - tool_id, - tool_name, + identity, result: sanitize_tool_result_for_event(&result), result_for_assistant, duration_ms, @@ -342,8 +328,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { confirmation_wait_ms, execution_ms, } => ToolEventData::Failed { - tool_id, - tool_name, + identity, error, duration_ms, queue_wait_ms, @@ -351,7 +336,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { confirmation_wait_ms, execution_ms, }, - ToolStateEventKind::Rejected => ToolEventData::Rejected { tool_id, tool_name }, + ToolStateEventKind::Rejected => ToolEventData::Rejected { identity }, ToolStateEventKind::Cancelled { reason, duration_ms, @@ -360,8 +345,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { confirmation_wait_ms, execution_ms, } => ToolEventData::Cancelled { - tool_id, - tool_name, + identity, reason, duration_ms, queue_wait_ms, @@ -396,14 +380,13 @@ fn redact_data_url_in_json(value: &mut serde_json::Value) { mod tests { use super::ToolStateEventKind; use super::{sanitize_tool_result_for_event, tool_state_event_data, ToolStateEventFacts}; - use bitfun_events::ToolEventData; + use bitfun_events::{ToolEventData, ToolEventIdentity}; use serde_json::json; #[test] fn completed_event_redacts_data_urls_recursively() { let data = tool_state_event_data(ToolStateEventFacts { - tool_id: "tool-1".to_string(), - tool_name: "Screenshot".to_string(), + identity: ToolEventIdentity::direct("tool-1", "Screenshot"), state: ToolStateEventKind::Completed { result: json!({ "data_url": "data:image/png;base64,AAAA", @@ -430,17 +413,16 @@ mod tests { #[test] fn rejected_state_maps_to_rejected_event() { let data = tool_state_event_data(ToolStateEventFacts { - tool_id: "tool-1".to_string(), - tool_name: "ExecCommand".to_string(), + identity: ToolEventIdentity::direct("tool-1", "ExecCommand"), state: ToolStateEventKind::Rejected, }); - let ToolEventData::Rejected { tool_id, tool_name } = data else { + let ToolEventData::Rejected { identity } = data else { panic!("expected rejected event"); }; - assert_eq!(tool_id, "tool-1"); - assert_eq!(tool_name, "ExecCommand"); + assert_eq!(identity.tool_id, "tool-1"); + assert_eq!(identity.tool_name, "ExecCommand"); } #[test] diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 8b0608e381..09c0adfce0 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -150,6 +150,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "CreatePlan", "submit_code_review", "GetToolSpec", + "CallDeferredTool", "GetFileDiff", ], }, @@ -369,6 +370,7 @@ mod tests { "CreatePlan", "submit_code_review", "GetToolSpec", + "CallDeferredTool", "GetFileDiff", "CreateCanvas", "ReadCanvas", diff --git a/src/crates/interfaces/acp/src/client/stream.rs b/src/crates/interfaces/acp/src/client/stream.rs index b3b3cbf8ae..054573c087 100644 --- a/src/crates/interfaces/acp/src/client/stream.rs +++ b/src/crates/interfaces/acp/src/client/stream.rs @@ -246,8 +246,7 @@ fn acp_tool_call_events( ); let mut events = vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + identity: bitfun_events::ToolEventIdentity::direct(tool_id.clone(), tool_name.clone()), params, timeout_seconds: None, })]; @@ -255,8 +254,7 @@ fn acp_tool_call_events( match tool_call.status { ToolCallStatus::Completed => { events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { - tool_id, - tool_name, + identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), result: acp_tool_result_value( tool_call.raw_output, Some(tool_call.content), @@ -272,8 +270,7 @@ fn acp_tool_call_events( } ToolCallStatus::Failed => { events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { - tool_id, - tool_name, + identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), error: acp_tool_error_text(tool_call.raw_output, tool_call.content), duration_ms: None, queue_wait_ms: None, @@ -307,15 +304,16 @@ fn acp_tool_call_update_events( let mut events = Vec::new(); if let Some(raw_input) = snapshot.raw_input { events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + identity: bitfun_events::ToolEventIdentity::direct( + tool_id.clone(), + tool_name.clone(), + ), params: normalize_tool_params(&tool_name, raw_input), timeout_seconds: None, })); } events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { - tool_id, - tool_name, + identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), result: acp_tool_result_value( update.fields.raw_output, update.fields.content, @@ -334,15 +332,16 @@ fn acp_tool_call_update_events( let mut events = Vec::new(); if let Some(raw_input) = snapshot.raw_input { events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id: tool_id.clone(), - tool_name: tool_name.clone(), + identity: bitfun_events::ToolEventIdentity::direct( + tool_id.clone(), + tool_name.clone(), + ), params: normalize_tool_params(&tool_name, raw_input), timeout_seconds: None, })); } events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Failed { - tool_id, - tool_name, + identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), error: acp_tool_error_text( update.fields.raw_output, update.fields.content.unwrap_or_default(), @@ -365,8 +364,7 @@ fn acp_tool_call_update_events( }), ); vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id, - tool_name, + identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), params, timeout_seconds: None, })] @@ -376,8 +374,7 @@ fn acp_tool_call_update_events( .map(|params| { let params = normalize_tool_params(&tool_name, params); vec![AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id, - tool_name, + identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), params, timeout_seconds: None, })] @@ -441,8 +438,7 @@ mod tests { fn tool_event(id: &str) -> AcpClientStreamEvent { AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_id: id.to_string(), - tool_name: "Bash".to_string(), + identity: bitfun_events::ToolEventIdentity::direct(id, "Bash"), params: json!({ "command": "echo ok" }), timeout_seconds: None, }) @@ -694,9 +690,9 @@ mod tests { assert_eq!(second.len(), 2); match &second[0] { AcpClientStreamEvent::ToolEvent(ToolEventData::Started { - tool_name, params, .. + identity, params, .. }) => { - assert_eq!(tool_name, "Edit"); + assert_eq!(identity.effective_name(), "Edit"); assert_eq!(params["file_path"], "src/lib.rs"); assert_eq!(params["old_string"], "before"); assert_eq!(params["new_string"], "after"); diff --git a/src/crates/interfaces/acp/src/runtime/events.rs b/src/crates/interfaces/acp/src/runtime/events.rs index 7d27cdc5be..bab463a2f4 100644 --- a/src/crates/interfaces/acp/src/runtime/events.rs +++ b/src/crates/interfaces/acp/src/runtime/events.rs @@ -7,7 +7,7 @@ use agent_client_protocol::schema::{ ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, }; use agent_client_protocol::{Client, ConnectionTo, Result}; -use bitfun_core::service::session::ToolItemData; +use bitfun_core::service::session::{ToolItemData, ToolItemIdentityExt}; use bitfun_events::ToolEventData; pub(super) const PERMISSION_ALLOW_ONCE: &str = "allow_once"; @@ -61,8 +61,8 @@ pub(super) fn tool_event_updates( /// "in progress", leaving a stuck tool card in the client transcript. pub(super) fn tool_call_replay_updates(tool_item: &ToolItemData) -> Vec { let tool_id = tool_item.id.clone(); - let tool_name = tool_item.tool_name.as_str(); - let raw_input = sanitize_tool_input(tool_name, tool_item.tool_call.input.clone()); + let tool_name = tool_item.effective_name(); + let raw_input = sanitize_tool_input(tool_name, tool_item.effective_input().clone()); let initial = ToolCall::new(tool_id.clone(), tool_title(tool_name)) .kind(tool_kind(tool_name)) @@ -195,7 +195,7 @@ pub(super) fn permission_request( fn initial_tool_call(tool_event: &ToolEventData) -> ToolCall { let tool_id = tool_event.tool_id().to_string(); - let tool_name = tool_event.tool_name(); + let tool_name = tool_event.effective_tool_name(); ToolCall::new(tool_id, tool_title(tool_name)) .kind(tool_kind(tool_name)) .status(ToolCallStatus::Pending) @@ -205,24 +205,29 @@ fn initial_tool_call(tool_event: &ToolEventData) -> ToolCall { fn tool_call_update(tool_event: &ToolEventData) -> Option { let tool_id = tool_event.tool_id().to_string(); let fields = match tool_event { - ToolEventData::EarlyDetected { tool_name, .. } => ToolCallUpdateFields::new() - .title(tool_title(tool_name)) - .kind(tool_kind(tool_name)) + ToolEventData::EarlyDetected { identity } => ToolCallUpdateFields::new() + .title(tool_title(identity.effective_name())) + .kind(tool_kind(identity.effective_name())) .status(ToolCallStatus::Pending), ToolEventData::ParamsPartial { - tool_name, params, .. + identity, params, .. } => { let fields = ToolCallUpdateFields::new().status(ToolCallStatus::Pending); - if is_write_like_tool(tool_name) { - match serde_json::from_str::(params) { - Ok(value) => fields - .raw_input(sanitize_tool_input(tool_name, value.clone())) - .content(vec![text_content(write_input_status_text(&value))]), - Err(_) => fields.content(vec![text_content(format!( - "Writing file ({} bytes received so far).", - params.len() - ))]), + if let Ok(wire_input) = serde_json::from_str::(params) { + let (tool_name, effective_input) = + bitfun_agent_tools::effective_tool_invocation(&identity.tool_name, &wire_input); + if is_write_like_tool(tool_name) { + fields + .raw_input(sanitize_tool_input(tool_name, effective_input.clone())) + .content(vec![text_content(write_input_status_text(effective_input))]) + } else { + fields.content(vec![text_content(format!("Input: {}", effective_input))]) } + } else if is_write_like_tool(identity.effective_name()) { + fields.content(vec![text_content(format!( + "Writing file ({} bytes received so far).", + params.len() + ))]) } else { fields.content(vec![text_content(format!("Input: {}", params))]) } @@ -240,13 +245,17 @@ fn tool_call_update(tool_event: &ToolEventData) -> Option { dependencies.join(", ") ))]), ToolEventData::Started { - tool_name, params, .. - } => ToolCallUpdateFields::new() - .title(tool_title(tool_name)) - .kind(tool_kind(tool_name)) - .status(ToolCallStatus::InProgress) - .locations(tool_locations(params)) - .raw_input(sanitize_tool_input(tool_name, params.clone())), + identity, params, .. + } => { + let (tool_name, effective_input) = + bitfun_agent_tools::effective_tool_invocation(&identity.tool_name, params); + ToolCallUpdateFields::new() + .title(tool_title(tool_name)) + .kind(tool_kind(tool_name)) + .status(ToolCallStatus::InProgress) + .locations(tool_locations(effective_input)) + .raw_input(sanitize_tool_input(tool_name, effective_input.clone())) + } ToolEventData::Progress { message, percentage, @@ -269,13 +278,17 @@ fn tool_call_update(tool_event: &ToolEventData) -> Option { .status(ToolCallStatus::InProgress) .content(vec![text_content(value_to_display_text(data))]), ToolEventData::ConfirmationNeeded { - tool_name, params, .. - } => ToolCallUpdateFields::new() - .title(format!("Allow {}?", tool_name)) - .status(ToolCallStatus::Pending) - .locations(tool_locations(params)) - .raw_input(sanitize_tool_input(tool_name, params.clone())) - .content(vec![text_content("Waiting for permission.")]), + identity, params, .. + } => { + let (tool_name, effective_input) = + bitfun_agent_tools::effective_tool_invocation(&identity.tool_name, params); + ToolCallUpdateFields::new() + .title(format!("Allow {}?", tool_name)) + .status(ToolCallStatus::Pending) + .locations(tool_locations(effective_input)) + .raw_input(sanitize_tool_input(tool_name, effective_input.clone())) + .content(vec![text_content("Waiting for permission.")]) + } ToolEventData::Confirmed { .. } => ToolCallUpdateFields::new() .status(ToolCallStatus::InProgress) .content(vec![text_content("Permission granted.")]), @@ -283,12 +296,13 @@ fn tool_call_update(tool_event: &ToolEventData) -> Option { .status(ToolCallStatus::Failed) .content(vec![text_content("Permission rejected.")]), ToolEventData::Completed { - tool_name, + identity, result, result_for_assistant, duration_ms, .. } => { + let tool_name = identity.effective_name(); let raw_output = sanitize_tool_payload(tool_name, result.clone()); let display = result_for_assistant .clone() @@ -517,63 +531,22 @@ fn value_to_display_text(value: &serde_json::Value) -> String { } } -trait ToolEventExt { - fn tool_id(&self) -> &str; - fn tool_name(&self) -> &str; -} - -impl ToolEventExt for ToolEventData { - fn tool_id(&self) -> &str { - match self { - Self::EarlyDetected { tool_id, .. } - | Self::ParamsPartial { tool_id, .. } - | Self::Queued { tool_id, .. } - | Self::Waiting { tool_id, .. } - | Self::Started { tool_id, .. } - | Self::Progress { tool_id, .. } - | Self::Streaming { tool_id, .. } - | Self::StreamChunk { tool_id, .. } - | Self::ConfirmationNeeded { tool_id, .. } - | Self::Confirmed { tool_id, .. } - | Self::Rejected { tool_id, .. } - | Self::Completed { tool_id, .. } - | Self::Failed { tool_id, .. } - | Self::Cancelled { tool_id, .. } => tool_id, - } - } - - fn tool_name(&self) -> &str { - match self { - Self::EarlyDetected { tool_name, .. } - | Self::ParamsPartial { tool_name, .. } - | Self::Queued { tool_name, .. } - | Self::Waiting { tool_name, .. } - | Self::Started { tool_name, .. } - | Self::Progress { tool_name, .. } - | Self::Streaming { tool_name, .. } - | Self::StreamChunk { tool_name, .. } - | Self::ConfirmationNeeded { tool_name, .. } - | Self::Confirmed { tool_name, .. } - | Self::Rejected { tool_name, .. } - | Self::Completed { tool_name, .. } - | Self::Failed { tool_name, .. } - | Self::Cancelled { tool_name, .. } => tool_name, - } - } -} - #[cfg(test)] mod tests { use super::*; use agent_client_protocol::schema::ContentBlock; use bitfun_core::service::session::ToolCallData; + use bitfun_events::ToolEventIdentity; + + fn identity(tool_name: &str) -> ToolEventIdentity { + ToolEventIdentity::direct("tool-1", tool_name) + } #[test] fn early_detected_creates_tool_call_once() { let mut seen = HashSet::new(); let event = ToolEventData::EarlyDetected { - tool_id: "tool-1".to_string(), - tool_name: "Read".to_string(), + identity: identity("Read"), }; let first = tool_event_updates(&event, &mut seen); @@ -590,8 +563,7 @@ mod tests { fn completed_event_maps_to_completed_update_with_output() { let mut seen = HashSet::new(); let event = ToolEventData::Completed { - tool_id: "tool-1".to_string(), - tool_name: "Bash".to_string(), + identity: identity("Bash"), result: serde_json::json!({ "stdout": "ok" }), result_for_assistant: Some("done".to_string()), duration_ms: 42, @@ -617,8 +589,7 @@ mod tests { fn write_started_supports_combined_payload_input() { let mut seen = HashSet::new(); let event = ToolEventData::Started { - tool_id: "tool-1".to_string(), - tool_name: "Write".to_string(), + identity: identity("Write"), params: serde_json::json!({ "payload": "+++ src/lib.rs\nhello\n", }), @@ -642,12 +613,44 @@ mod tests { ); } + #[test] + fn deferred_write_started_projects_effective_identity_and_input() { + let mut seen = HashSet::new(); + let event = ToolEventData::Started { + identity: ToolEventIdentity::resolved( + "tool-1", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + "Write", + ), + params: serde_json::json!({ + "tool_name": "Write", + "args": { + "file_path": "src/lib.rs", + "content": "updated" + } + }), + timeout_seconds: None, + }; + + let updates = tool_event_updates(&event, &mut seen); + let SessionUpdate::ToolCallUpdate(update) = &updates[1] else { + panic!("expected tool call update"); + }; + assert_eq!(update.fields.title.as_deref(), Some("Run Write")); + assert_eq!( + update.fields.raw_input, + Some(serde_json::json!({ + "file_path": "src/lib.rs", + "content": "updated" + })) + ); + } + #[test] fn write_started_supports_path_only_empty_file_input() { let mut seen = HashSet::new(); let event = ToolEventData::Started { - tool_id: "tool-1".to_string(), - tool_name: "Write".to_string(), + identity: identity("Write"), params: serde_json::json!({ "payload": "+++ src/empty.rs", }), @@ -696,8 +699,7 @@ mod tests { let mut seen = HashSet::new(); let content = "x".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS + 10); let event = ToolEventData::Started { - tool_id: "tool-1".to_string(), - tool_name: "Write".to_string(), + identity: identity("Write"), params: serde_json::json!({ "file_path": "src/lib.rs", "content": content, @@ -744,8 +746,7 @@ mod tests { let mut seen = HashSet::new(); let content = "a".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS + 25); let event = ToolEventData::ParamsPartial { - tool_id: "tool-1".to_string(), - tool_name: "Write".to_string(), + identity: identity("Write"), params: serde_json::json!({ "file_path": "src/main.rs", "content": content, @@ -779,8 +780,7 @@ mod tests { fn write_started_sends_small_content_on_in_progress_update() { let mut seen = HashSet::new(); let event = ToolEventData::Started { - tool_id: "tool-1".to_string(), - tool_name: "Write".to_string(), + identity: identity("Write"), params: serde_json::json!({ "file_path": "tiny.txt", "content": "hello\n", @@ -821,8 +821,7 @@ mod tests { let old_string = "old".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS); let new_string = "new".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS); let event = ToolEventData::Started { - tool_id: "tool-1".to_string(), - tool_name: "Edit".to_string(), + identity: identity("Edit"), params: serde_json::json!({ "file_path": "src/lib.rs", "old_string": old_string, @@ -873,8 +872,7 @@ mod tests { let old_string = "old".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS); let new_string = "new".repeat(ACP_LARGE_TEXT_PREVIEW_CHARS); let event = ToolEventData::Completed { - tool_id: "tool-1".to_string(), - tool_name: "Edit".to_string(), + identity: identity("Edit"), result: serde_json::json!({ "file_path": "src/lib.rs", "old_string": old_string, @@ -988,6 +986,24 @@ mod tests { update } + #[test] + fn replay_projects_deferred_wire_call_to_effective_tool() { + let mut item = replay_tool_item("tool-1", "CallDeferredTool", Some("completed"), None); + item.tool_call.input = serde_json::json!({ + "tool_name": "WebFetch", + "args": { "url": "https://example.test" } + }); + let updates = tool_call_replay_updates(&item); + let SessionUpdate::ToolCall(tool_call) = &updates[0] else { + panic!("expected initial tool call"); + }; + assert_eq!(tool_call.title, tool_title("WebFetch")); + assert_eq!( + tool_call.raw_input, + Some(serde_json::json!({ "url": "https://example.test" })) + ); + } + #[test] fn replay_without_result_defaults_to_in_progress() { // No status, no interruption reason: the stored state is indeterminate, diff --git a/src/crates/interfaces/acp/src/runtime/prompt.rs b/src/crates/interfaces/acp/src/runtime/prompt.rs index 59062484c1..ac2912c671 100644 --- a/src/crates/interfaces/acp/src/runtime/prompt.rs +++ b/src/crates/interfaces/acp/src/runtime/prompt.rs @@ -162,19 +162,18 @@ async fn wait_for_prompt_completion( } if let bitfun_events::ToolEventData::ConfirmationNeeded { - tool_id, - tool_name, - params, - .. + identity, params, .. } = tool_event { + let (effective_tool_name, effective_params) = + bitfun_agent_tools::effective_tool_invocation(&identity.tool_name, ¶ms); handle_permission_request( runtime, connection, acp_session_id, - &tool_id, - &tool_name, - ¶ms, + &identity.tool_id, + effective_tool_name, + effective_params, ) .await?; } diff --git a/src/crates/services/services-core/tests/session_metadata_contracts.rs b/src/crates/services/services-core/tests/session_metadata_contracts.rs index 6184ec8ef0..fda19cc932 100644 --- a/src/crates/services/services-core/tests/session_metadata_contracts.rs +++ b/src/crates/services/services-core/tests/session_metadata_contracts.rs @@ -132,6 +132,22 @@ fn finished_turn( turn } +#[test] +fn deferred_tool_item_serializes_only_its_wire_invocation() { + let mut item = tool_item("deferred"); + item.tool_name = "CallDeferredTool".to_string(); + item.tool_call.input = serde_json::json!({ + "tool_name": "WebFetch", + "args": { "url": "https://example.test" } + }); + + let value = serde_json::to_value(&item).expect("serialize tool item"); + assert_eq!(value["toolName"], "CallDeferredTool"); + assert_eq!(value["toolCall"]["input"], item.tool_call.input); + assert!(value.get("effectiveToolName").is_none()); + assert!(value.get("effectiveToolInput").is_none()); +} + #[test] fn full_refresh_recomputes_metadata_counters_from_turns() { let mut metadata = metadata("session-1"); diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 68c5ca9eb9..4a8aece06b 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -133,6 +133,7 @@ remote-connect = [ "argon2", "async-trait", "base64", + "bitfun-agent-tools", "bitfun-services-core", "bitfun-runtime-ports", "chrono", diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 7bff877c82..764e53f8b0 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -2840,22 +2840,26 @@ impl RemoteSessionStateTracker { } } AE::ToolEvent { tool_event, .. } => { + let tool_id = tool_event.tool_id().to_string(); + let tool_name = tool_event.effective_tool_name().to_string(); + let effective_params = match tool_event { + bitfun_events::ToolEventData::Started { + identity, params, .. + } + | bitfun_events::ToolEventData::ConfirmationNeeded { + identity, params, .. + } => Some( + bitfun_agent_tools::effective_tool_invocation(&identity.tool_name, params) + .1 + .clone(), + ), + _ => None, + }; if let Ok(value) = serde_json::to_value(tool_event) { let event_type = value .get("event_type") .and_then(|value| value.as_str()) .unwrap_or(""); - let tool_id = value - .get("tool_id") - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string(); - let tool_name = value - .get("tool_name") - .and_then(|value| value.as_str()) - .unwrap_or("") - .to_string(); - let mut state = self.state.write().unwrap(); let allow_name_fallback = tool_id.is_empty() && !tool_name.is_empty(); let mut pending_tool_event: Option = None; @@ -2872,7 +2876,7 @@ impl RemoteSessionStateTracker { ); } "ConfirmationNeeded" => { - let params = value.get("params").cloned(); + let params = effective_params.clone(); let input_preview = params.as_ref().and_then(make_slim_tool_params); Self::upsert_active_tool( &mut state, @@ -2885,7 +2889,7 @@ impl RemoteSessionStateTracker { ); } "Started" => { - let params = value.get("params").cloned(); + let params = effective_params.clone(); let input_preview = params.as_ref().and_then(make_slim_tool_params); let tool_input = if tool_name == "AskUserQuestion" || tool_name == "Task" diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index d74b81046f..d45a2b8932 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -2264,9 +2264,15 @@ async fn remote_connect_tracker_broadcasts_tool_and_turn_events() { attempt_id: None, attempt_index: None, tool_event: ToolEventData::Started { - tool_id: "tool-1".to_string(), - tool_name: "AskUserQuestion".to_string(), - params: serde_json::json!({ "questions": [] }), + identity: bitfun_events::ToolEventIdentity::resolved( + "tool-1", + bitfun_agent_tools::CALL_DEFERRED_TOOL_NAME, + "AskUserQuestion", + ), + params: serde_json::json!({ + "tool_name": "AskUserQuestion", + "args": { "questions": [] } + }), timeout_seconds: None, }, }); @@ -2283,7 +2289,7 @@ async fn remote_connect_tracker_broadcasts_tool_and_turn_events() { } => { assert_eq!(tool_id, "tool-1"); assert_eq!(tool_name, "AskUserQuestion"); - assert!(params.is_some()); + assert_eq!(params, Some(serde_json::json!({ "questions": [] }))); } other => panic!("unexpected event: {other:?}"), } diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index 8b47940e62..2786a85e7b 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -71,6 +71,9 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record = ({ const toolItem = item as FlowToolItem; if (toolItem.toolCall) { - const toolName = toolItem.toolName || t('copyOutput.unknownTool'); + const effectiveItem = projectEffectiveToolItem(toolItem); + const toolName = effectiveItem.toolName || t('copyOutput.unknownTool'); let toolContent = t('copyOutput.toolCall', { name: toolName }) + '\n'; - if (toolItem.toolCall.input) { - const inputStr = typeof toolItem.toolCall.input === 'string' - ? toolItem.toolCall.input - : JSON.stringify(toolItem.toolCall.input, null, 2); + if (effectiveItem.toolCall.input) { + const inputStr = typeof effectiveItem.toolCall.input === 'string' + ? effectiveItem.toolCall.input + : JSON.stringify(effectiveItem.toolCall.input, null, 2); toolContent += `\n[Input]\n\`\`\`json\n${inputStr}\n\`\`\`\n`; } diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx new file mode 100644 index 0000000000..446652021d --- /dev/null +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowToolItem } from '../types/flow-chat'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('../tool-cards', async () => { + const ReactModule = await import('react'); + return { + getToolCardComponent: (toolName: string) => ({ toolItem }: { toolItem: FlowToolItem }) => + ReactModule.createElement('div', { + 'data-selected-card': toolName, + 'data-card-tool-name': toolItem.toolName, + }), + }; +}); + +vi.mock('../tool-cards/toolCardMetadata', () => ({ + getToolCardConfig: (toolName: string) => ({ + toolName, + displayName: toolName, + icon: 'TOOL', + requiresConfirmation: false, + resultDisplayType: 'summary', + description: toolName, + }), +})); + +vi.mock('./FlowToolCardErrorBoundary', () => ({ + FlowToolCardErrorBoundary: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('./ToolApprovalBar', () => ({ ToolApprovalBar: () => null })); + +import { FlowToolCard } from './FlowToolCard'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('FlowToolCard deferred identity', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('switches from the gateway card to the effective card when wire input completes', () => { + const base: FlowToolItem = { + id: 'tool-1', + type: 'tool', + toolName: 'CallDeferredTool', + toolCall: { id: 'tool-1', input: { tool_name: 'CreatePlan' } }, + status: 'streaming', + timestamp: 1, + }; + + act(() => root.render()); + expect(container.querySelector('[data-selected-card="CallDeferredTool"]')).not.toBeNull(); + + act(() => root.render( + , + )); + + expect(container.querySelector('[data-selected-card="CreatePlan"]')).not.toBeNull(); + expect(container.querySelector('[data-card-tool-name="CreatePlan"]')).not.toBeNull(); + expect(container.querySelector('[data-tool-name="CreatePlan"]')).not.toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index dfc2f41c0b..34a67687bd 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -12,6 +12,7 @@ import { FlowToolCardErrorBoundary } from './FlowToolCardErrorBoundary'; import { useTranslation } from 'react-i18next'; import { getToolInterruptionNote } from '../utils/toolInterruption'; import { ToolApprovalBar } from './ToolApprovalBar'; +import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; const log = createLogger('FlowToolCard'); @@ -40,28 +41,29 @@ export const FlowToolCard: React.FC = React.memo(({ displayContext = 'default', }) => { const { t } = useTranslation('flow-chat'); - const config = getToolCardConfig(toolItem.toolName); - const CardComponent = getToolCardComponent(toolItem.toolName); - const interruptionNote = getToolInterruptionNote(toolItem, t); - const cardHandlesInterruptionNote = toolItem.toolName === 'Task'; + const effectiveToolItem = projectEffectiveToolItem(toolItem); + const config = getToolCardConfig(effectiveToolItem.toolName); + const CardComponent = getToolCardComponent(effectiveToolItem.toolName); + const interruptionNote = getToolInterruptionNote(effectiveToolItem, t); + const cardHandlesInterruptionNote = effectiveToolItem.toolName === 'Task'; const toolCardTestId = - toolItem.toolName === 'Bash' + effectiveToolItem.toolName === 'Bash' ? 'chat-shell-tool-card' - : toolItem.toolName === 'WebFetch' + : effectiveToolItem.toolName === 'WebFetch' ? 'chat-browser-tool-card' : undefined; const handleConfirm = React.useCallback((updatedInput?: any, permissionOptionId?: string, approve?: boolean) => { log.debug('handleConfirm called', { toolId: toolItem.id, - toolName: toolItem.toolName, + toolName: effectiveToolItem.toolName, hasUpdatedInput: updatedInput !== undefined, updatedInputKeys: updatedInput ? Object.keys(updatedInput) : [], hasPermissionOption: Boolean(permissionOptionId), approve }); onConfirm?.(toolItem.id, updatedInput, permissionOptionId, approve); - }, [toolItem.id, toolItem.toolName, onConfirm]); + }, [effectiveToolItem.toolName, toolItem.id, onConfirm]); const handleReject = React.useCallback((options?: ToolRejectOptions) => { onReject?.(toolItem.id, options); @@ -75,16 +77,16 @@ export const FlowToolCard: React.FC = React.memo(({
= React.memo(({ /> @@ -115,6 +117,8 @@ export const FlowToolCard: React.FC = React.memo(({ return ( prevProps.toolItem.id === nextProps.toolItem.id && + prevProps.toolItem.toolName === nextProps.toolItem.toolName && + prevProps.toolItem.toolCall === nextProps.toolItem.toolCall && prevProps.sessionId === nextProps.sessionId && prevProps.toolItem.status === nextProps.toolItem.status && prevProps.toolItem.interruptionReason === nextProps.toolItem.interruptionReason && diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index b2dd53de1c..54505c93b3 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -19,6 +19,7 @@ import { isCollapsibleTool } from '../../tool-cards/toolCardMetadata'; import { useFlowChatContext } from './FlowChatContext'; import { FlowChatStore } from '../../store/FlowChatStore'; import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; +import { getEffectiveToolName, projectEffectiveToolItem } from '../../utils/toolInvocationIdentity'; import { ExportImageButton } from './ExportImageButton'; import { ForkSessionButton } from './ForkSessionButton'; import { @@ -449,7 +450,7 @@ export const ModelRoundItem = React.memo( )); case 'critical': { - const projectedSubagent = group.item.type === 'tool' && (group.item as FlowToolItem).toolName === 'Task' + const projectedSubagent = group.item.type === 'tool' && getEffectiveToolName(group.item as FlowToolItem) === 'Task' ? group.item as FlowToolItem : undefined; if (projectedSubagent) { @@ -519,13 +520,14 @@ export const ModelRoundItem = React.memo( } else if (item.type === 'thinking' && item.content?.trim()) { roundContent.push(`[Thinking]\n${item.content.trim()}`); } else if (item.type === 'tool' && item.toolCall) { - const toolName = item.toolName || t('copyOutput.unknownTool'); + const effectiveItem = projectEffectiveToolItem(item); + const toolName = effectiveItem.toolName || t('copyOutput.unknownTool'); let toolContent = t('modelRound.toolCallLabel', { name: toolName }) + '\n'; - if (item.toolCall.input) { - const inputStr = typeof item.toolCall.input === 'string' - ? item.toolCall.input - : JSON.stringify(item.toolCall.input, null, 2); + if (effectiveItem.toolCall.input) { + const inputStr = typeof effectiveItem.toolCall.input === 'string' + ? effectiveItem.toolCall.input + : JSON.stringify(effectiveItem.toolCall.input, null, 2); toolContent += `\n[Input]\n\`\`\`json\n${inputStr}\n\`\`\`\n`; } @@ -871,7 +873,7 @@ const FlowItemRenderer: React.FC = ({ case 'tool': { const toolItem = item as FlowToolItem; const isCompletedTool = toolItem.status === 'completed'; - const isCollapsible = isCollapsibleTool(toolItem.toolName); + const isCollapsible = isCollapsibleTool(getEffectiveToolName(toolItem)); const shouldAnimateCompletedExit = allowCompletedToolExit && isCollapsible && diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts index 4e62174b64..991db4154c 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts @@ -1,4 +1,5 @@ import type { FlowItem, FlowToolItem } from '../../types/flow-chat'; +import { getEffectiveToolName } from '../../utils/toolInvocationIdentity'; export const COMPLETED_TOOL_TRANSIENT_MS = 1000; @@ -88,7 +89,7 @@ export function buildModelRoundItemGroups({ flushPendingAsCritical(); } } else if (item.type === 'tool') { - const toolName = (item as FlowToolItem).toolName; + const toolName = getEffectiveToolName(item as FlowToolItem); const isExploreTool = isCollapsibleTool(toolName); if (isExploreTool) { diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts index bb1d41fc93..ab19dc4d85 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts @@ -10,6 +10,7 @@ import { createLogger } from '@/shared/utils/logger'; import { FlowChatStore } from '../../store/FlowChatStore'; import { i18nService } from '@/infrastructure/i18n'; import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview'; +import { projectEffectiveToolItem } from '../../utils/toolInvocationIdentity'; const log = createLogger('useFlowChatCopyDialog'); @@ -45,13 +46,14 @@ function extractDialogTurnContent(turnId: string): string { } else if (item.type === 'thinking' && item.content?.trim()) { roundContent.push(`[Thinking]\n${item.content.trim()}`); } else if (item.type === 'tool' && item.toolCall) { - const toolName = item.toolName || i18nService.t('flow-chat:copyOutput.unknownTool'); + const effectiveItem = projectEffectiveToolItem(item); + const toolName = effectiveItem.toolName || i18nService.t('flow-chat:copyOutput.unknownTool'); let toolContent = i18nService.t('flow-chat:modelRound.toolCallLabel', { name: toolName }) + '\n'; - if (item.toolCall.input) { - const inputStr = typeof item.toolCall.input === 'string' - ? item.toolCall.input - : JSON.stringify(item.toolCall.input, null, 2); + if (effectiveItem.toolCall.input) { + const inputStr = typeof effectiveItem.toolCall.input === 'string' + ? effectiveItem.toolCall.input + : JSON.stringify(effectiveItem.toolCall.input, null, 2); toolContent += `\n[Input]\n\`\`\`json\n${inputStr}\n\`\`\`\n`; } diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts index cb357fcb11..bdd84647e2 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts @@ -3,6 +3,10 @@ */ import { useCallback } from 'react'; +import { + effectiveToolInvocation, + replaceEffectiveToolInput, +} from '../../utils/toolInvocationIdentity'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { @@ -70,14 +74,20 @@ export function useFlowChatToolActions() { return; } - const finalInput = updatedInput || toolItem.toolCall?.input; + const effective = effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input); + const finalInput = updatedInput || effective.input; + const finalWireInput = replaceEffectiveToolInput( + toolItem.toolName, + toolItem.toolCall?.input, + finalInput, + ); flowChatStore.updateModelRoundItem(sessionId, turnId, toolId, { userConfirmed: approve, status: approve ? 'confirmed' : 'rejected', toolCall: { ...toolItem.toolCall, - input: finalInput, + input: finalWireInput, }, ...(approve ? {} : { requiresConfirmation: false, diff --git a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx index 5a3df2891f..193e7bfed0 100644 --- a/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx +++ b/src/web-ui/src/flow_chat/components/toolbar-mode/ToolbarMode.tsx @@ -25,8 +25,9 @@ import { import { useToolbarModeContext } from './ToolbarModeContext'; import { flowChatStore } from '../../store/FlowChatStore'; import { activateMainSession } from '../../services/sessionActivation'; -import { FlowChatState } from '../../types/flow-chat'; +import { FlowChatState, type FlowToolItem } from '../../types/flow-chat'; import { compareSessionsForDisplay } from '../../utils/sessionOrdering'; +import { projectEffectiveToolItem } from '../../utils/toolInvocationIdentity'; import { createLogger } from '@/shared/utils/logger'; import { isMacOSDesktopRuntime } from '@/infrastructure/runtime'; import { i18nService } from '@/infrastructure/i18n'; @@ -139,9 +140,10 @@ export const ToolbarMode: React.FC = () => { const item = lastRound.items[i]; if (item.type === 'tool' && 'toolName' in item) { - toolName = (item as any).toolName; - if ('input' in item && typeof (item as any).input === 'object') { - const input = (item as any).input; + const effectiveItem = projectEffectiveToolItem(item as FlowToolItem); + toolName = effectiveItem.toolName; + if (effectiveItem.toolCall?.input && typeof effectiveItem.toolCall.input === 'object') { + const input = effectiveItem.toolCall.input; content = input.path || input.command || input.query || input.content?.slice(0, 50) || t('toolCards.toolbar.executing'); } else { content = t('toolCards.toolbar.executing'); diff --git a/src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts b/src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts index b158fcbd11..e2033a2c92 100644 --- a/src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts +++ b/src/web-ui/src/flow_chat/hooks/useVisibleTaskInfo.ts @@ -11,6 +11,7 @@ import { useRef, useCallback, useState, useEffect } from 'react'; import type { VirtualItem } from '../store/modernFlowChatStore'; import type { FlowToolItem } from '../types/flow-chat'; +import { getEffectiveToolName, projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; const VIEWPORT_TOP_OFFSET_PX = 57; // Keep in sync with PINNED_TURN_VIEWPORT_OFFSET_PX. const TASK_TOOL_NAME = 'Task'; @@ -38,7 +39,7 @@ interface UseVisibleTaskInfoReturn { } function getTaskLabel(toolItem: FlowToolItem): string { - const input = toolItem.toolCall?.input; + const input = projectEffectiveToolItem(toolItem).toolCall?.input; if (!input) return ''; const desc = input.description || input.prompt || input.task || ''; return typeof desc === 'string' ? desc.trim() : ''; @@ -58,7 +59,7 @@ function findTaskVirtualItems(virtualItems: VirtualItem[]): Array<{ const round = vItem.data; for (const flowItem of round.items) { - if (flowItem.type === 'tool' && (flowItem as FlowToolItem).toolName === TASK_TOOL_NAME) { + if (flowItem.type === 'tool' && getEffectiveToolName(flowItem as FlowToolItem) === TASK_TOOL_NAME) { result.push({ index: i, itemId: flowItem.id, diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index d987fe6932..66b2d0c27a 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -255,7 +255,10 @@ export type ToolEventType = interface BaseToolEvent { event_type: T; tool_id: string; + /** Provider-facing name. Deferred calls remain CallDeferredTool. */ tool_name: string; + /** Runtime target when it differs from the provider-facing name. */ + effective_tool_name?: string; } export type EarlyDetectedToolEvent = BaseToolEvent<'EarlyDetected'>; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 8840619a1b..7af369f57b 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -23,6 +23,7 @@ import type { NotificationAction } from '../../../shared/notification-system/typ import { createLogger } from '@/shared/utils/logger'; import { handleThreadGoalUpdated } from '../threadGoalEventService'; import { resolveThreadGoalUserMessageDisplay } from '../../utils/threadGoalDisplay'; +import { effectiveToolInvocation, getEffectiveToolName } from '../../utils/toolInvocationIdentity'; import type { DeepReviewQueueStateChangedEvent, ImageAnalysisEvent, @@ -542,7 +543,7 @@ function findSubagentParentInfoByRound( const toolItem = item as FlowToolItem; if ( - toolItem.toolName?.toLowerCase() === 'task' && + getEffectiveToolName(toolItem).toLowerCase() === 'task' && toolItem.subagentSessionId === subagentSessionId && toolItem.subagentDialogTurnId === subagentDialogTurnId ) { @@ -2652,14 +2653,15 @@ function detectModifiedPlanFiles(dialogTurn: DialogTurn): string[] { for (const item of round.items) { if (item.type !== 'tool') continue; const toolItem = item as FlowToolItem; + const effective = effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input); - if (toolItem.toolName === 'CreatePlan' && toolItem.toolResult?.success) { + if (effective.toolName === 'CreatePlan' && toolItem.toolResult?.success) { const planPath = toolItem.toolResult.result?.plan_file_path; if (planPath) createPlanFiles.add(planPath); } - if (['Edit', 'Write'].includes(toolItem.toolName) && toolItem.toolResult?.success) { - const input = toolItem.toolCall?.input; + if (['Edit', 'Write'].includes(effective.toolName) && toolItem.toolResult?.success) { + const input = effective.input as any; const filePath = splitFilePathAndContent(input?.payload)?.filePath || input?.file_path || input?.target_file diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index 90b56f4e71..1ccf01864e 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -218,6 +218,61 @@ describe('PersistenceModule', () => { }); }); + it('persists only the original deferred wire invocation', () => { + const turn = createDialogTurn('completed'); + turn.modelRounds[0].items = [{ + id: 'tool-1', + type: 'tool', + toolName: 'CallDeferredTool', + toolCall: { + id: 'tool-1', + input: { + tool_name: 'WebFetch', + args: { url: 'https://example.test' }, + }, + }, + status: 'completed', + timestamp: 1001, + startTime: 1001, + }]; + + const persisted = convertDialogTurnToBackendFormat(turn, 0); + const [toolItem] = persisted.modelRounds[0].toolItems; + + expect(toolItem).toMatchObject({ + toolName: 'CallDeferredTool', + toolCall: { + id: 'tool-1', + input: { + tool_name: 'WebFetch', + args: { url: 'https://example.test' }, + }, + }, + }); + expect(toolItem).not.toHaveProperty('effectiveToolName'); + expect(toolItem).not.toHaveProperty('effectiveToolInput'); + }); + + it('refuses to overwrite persistence with a completed mixed deferred identity', () => { + const turn = createDialogTurn('completed'); + turn.modelRounds[0].items = [{ + id: 'tool-broken', + type: 'tool', + toolName: 'CallDeferredTool', + toolCall: { + id: 'tool-broken', + input: { name: 'Plan', overview: 'Overview', plan: '# Plan' }, + }, + status: 'completed', + timestamp: 1001, + startTime: 1001, + }]; + + expect(() => convertDialogTurnToBackendFormat(turn, 0)).toThrow( + 'Completed deferred tool is missing its wire invocation: tool-broken', + ); + }); + it('coalesces non-terminal immediate saves into a short latest-state window', async () => { const turn = createDialogTurn('processing'); const context = createContext(turn); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index a5bf194b2a..fbc5e11ca4 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -8,6 +8,10 @@ import type { FlowChatContext, DialogTurn } from './types'; import { buildSessionMetadata } from '../../utils/sessionMetadata'; import { settleInterruptedDialogTurn } from '../../utils/dialogTurnStability'; import { isRuntimeStatusItem } from './RuntimeStatusModule'; +import { + DEFERRED_TOOL_GATEWAY_NAME, + effectiveToolInvocation, +} from '../../utils/toolInvocationIdentity'; const log = createLogger('PersistenceModule'); const COALESCED_IMMEDIATE_SAVE_DELAY_MS = 500; @@ -422,6 +426,14 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd .filter(({ item }) => item.type === 'tool') .map(({ item, index }) => { const toolItem = item as any; + const effective = effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input); + if ( + toolItem.toolName === DEFERRED_TOOL_GATEWAY_NAME + && toolItem.status === 'completed' + && !effective.isDeferred + ) { + throw new Error(`Completed deferred tool is missing its wire invocation: ${item.id}`); + } return { id: item.id, toolName: toolItem.toolName || '', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts index 9967eea2cf..aefb44211c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { FlowChatStore } from '../../store/FlowChatStore'; import type { DialogTurn, FlowToolItem, ModelRound, Session } from '../../types/flow-chat'; import { processToolEvent, processToolParamsPartialInternal } from './ToolEventModule'; +import { convertDialogTurnToBackendFormat } from './PersistenceModule'; +import { projectEffectiveToolItem } from '../../utils/toolInvocationIdentity'; function resetStore(): void { FlowChatStore.getInstance().setState(() => ({ @@ -359,6 +361,150 @@ describe('processToolEvent late Started event behavior', () => { }); }); +describe('deferred tool wire identity', () => { + afterEach(() => { + resetStore(); + }); + + it('keeps one wire invocation through streaming and Started while selecting the effective card', () => { + const session = createSessionWithTool({ + id: 'placeholder', + type: 'tool', + toolName: 'Read', + timestamp: 1000, + status: 'completed', + toolCall: { id: 'placeholder', input: {} }, + }); + session.dialogTurns[0].modelRounds[0].items = []; + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([['session-1', session]]), + activeSessionId: 'session-1', + })); + + const context = makeToolContext(); + processToolEvent(context, 'session-1', 'turn-1', 'round-1', { + event_type: 'EarlyDetected', + tool_id: 'tool-deferred', + tool_name: 'CallDeferredTool', + }); + processToolParamsPartialInternal('session-1', 'turn-1', { + event_type: 'ParamsPartial', + tool_id: 'tool-deferred', + tool_name: 'CallDeferredTool', + params: JSON.stringify({ + tool_name: 'CreatePlan', + args: { name: 'Plan', overview: 'Overview', plan: '# Plan' }, + }), + }); + + const wireInput = { + tool_name: 'CreatePlan', + args: { name: 'Plan', overview: 'Overview', plan: '# Plan' }, + }; + processToolEvent(context, 'session-1', 'turn-1', 'round-1', { + event_type: 'Started', + tool_id: 'tool-deferred', + tool_name: 'CallDeferredTool', + effective_tool_name: 'CreatePlan', + params: wireInput, + }); + + const tool = FlowChatStore.getInstance() + .findToolItem('session-1', 'turn-1', 'tool-deferred') as FlowToolItem; + expect(tool).toMatchObject({ + toolName: 'CallDeferredTool', + toolCall: { id: 'tool-deferred', input: wireInput }, + status: 'running', + }); + + const effective = projectEffectiveToolItem(tool); + expect(effective).toMatchObject({ + toolName: 'CreatePlan', + toolCall: { input: wireInput.args }, + }); + + const turn = FlowChatStore.getInstance().getState().sessions + .get('session-1')!.dialogTurns[0]; + const persisted = convertDialogTurnToBackendFormat(turn, 0); + expect(persisted.modelRounds[0].toolItems[0]).toMatchObject({ + toolName: 'CallDeferredTool', + toolCall: { input: wireInput }, + }); + }); + + it('uses wire input for deferred confirmation and derives the Write view', () => { + const wireInput = { + tool_name: 'Write', + args: { file_path: 'README.md', content: 'updated' }, + }; + const tool: FlowToolItem = { + id: 'tool-write', + type: 'tool', + toolName: 'CallDeferredTool', + timestamp: 1001, + status: 'queued', + toolCall: { id: 'tool-write', input: wireInput }, + }; + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([['session-1', createSessionWithTool(tool)]]), + activeSessionId: 'session-1', + })); + + processToolEvent(makeToolContext(), 'session-1', 'turn-1', 'round-1', { + event_type: 'ConfirmationNeeded', + tool_id: 'tool-write', + tool_name: 'CallDeferredTool', + effective_tool_name: 'Write', + params: wireInput, + }); + + const updated = FlowChatStore.getInstance() + .findToolItem('session-1', 'turn-1', 'tool-write') as FlowToolItem; + expect(updated).toMatchObject({ + toolName: 'CallDeferredTool', + toolCall: { input: wireInput }, + status: 'pending_confirmation', + requiresConfirmation: true, + }); + expect(projectEffectiveToolItem(updated)).toMatchObject({ + toolName: 'Write', + toolCall: { input: wireInput.args }, + }); + }); + + it('keeps effective identity for Streaming even when no Started event arrives', () => { + const wireInput = { + tool_name: 'mcp__docs__search', + args: { query: 'identity' }, + }; + const tool: FlowToolItem = { + id: 'tool-streaming', + type: 'tool', + toolName: 'CallDeferredTool', + timestamp: 1001, + status: 'queued', + toolCall: { id: 'tool-streaming', input: wireInput }, + }; + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([['session-1', createSessionWithTool(tool)]]), + activeSessionId: 'session-1', + })); + + processToolEvent(makeToolContext(), 'session-1', 'turn-1', 'round-1', { + event_type: 'Streaming', + tool_id: 'tool-streaming', + tool_name: 'CallDeferredTool', + effective_tool_name: 'mcp__docs__search', + chunks_received: 1, + }); + + const updated = FlowChatStore.getInstance() + .findToolItem('session-1', 'turn-1', 'tool-streaming') as FlowToolItem; + expect(updated.status).toBe('streaming'); + expect(projectEffectiveToolItem(updated).toolName).toBe('mcp__docs__search'); + }); +}); + describe('processToolEvent rejected event behavior', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 5011d0e264..5a0859a73b 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -10,6 +10,7 @@ import type { FlowChatContext, FlowToolItem, ToolEventOptions, DialogTurn } from import { immediateSaveDialogTurn } from './PersistenceModule'; import { applyPendingAcpPermissionForTool } from './AcpPermissionToolCardModule'; import { normalizeParamsPartialFragment } from '../EventBatcher'; +import { effectiveToolInvocation } from '../../utils/toolInvocationIdentity'; import type { CancelledToolEvent, CompletedToolEvent, @@ -63,6 +64,8 @@ export function processToolEvent( return; } + reconcileToolEventWireIdentity(store, sessionId, turnId, toolEvent); + switch (toolEvent.event_type) { case 'EarlyDetected': { handleEarlyDetected(context, store, sessionId, turnId, roundId, dialogTurn, toolEvent, attemptId, attemptIndex, options); @@ -124,10 +127,56 @@ export function processToolEvent( handleProgress(store, sessionId, turnId, toolEvent); break; } + + case 'Streaming': { + updateToolItem(store, sessionId, turnId, toolEvent.tool_id, { + status: 'streaming', + isParamsStreaming: false, + }); + break; + } + + case 'Confirmed': { + updateToolItem(store, sessionId, turnId, toolEvent.tool_id, { + status: 'confirmed', + userConfirmed: true, + requiresConfirmation: false, + }); + break; + } + + case 'StreamChunk': { + break; + } default: break; } + +} + +function reconcileToolEventWireIdentity( + store: FlowChatStore, + sessionId: string, + turnId: string, + toolEvent: FlowToolEvent, +): void { + const existing = store.findToolItem(sessionId, turnId, toolEvent.tool_id); + if (!existing || existing.type !== 'tool') { + return; + } + + const updates: Partial = { toolName: toolEvent.tool_name }; + if (toolEvent.event_type === 'Started' || toolEvent.event_type === 'ConfirmationNeeded') { + updates.toolCall = { + input: toolEvent.params, + id: toolEvent.tool_id, + ...('timeout_seconds' in toolEvent && typeof toolEvent.timeout_seconds === 'number' + ? { timeout_seconds: toolEvent.timeout_seconds } + : {}), + }; + } + store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, updates); } function flushPendingBatchedEvents(context: FlowChatContext): void { @@ -198,8 +247,11 @@ function applyParamsPartial( if (existingItem && existingItem.type === 'tool') { const existingToolItem = existingItem as FlowToolItem; const prevBuffer = existingToolItem._paramsBuffer || ''; - const isWriteTool = isWriteLikeToolName(toolEvent.tool_name); - if (shouldIgnoreParamsPartial(existingToolItem.status, toolEvent.tool_name)) { + const currentEffectiveName = effectiveToolInvocation( + existingToolItem.toolName, + existingToolItem.toolCall?.input, + ).toolName; + if (shouldIgnoreParamsPartial(existingToolItem.status, currentEffectiveName)) { return; } @@ -215,30 +267,36 @@ function applyParamsPartial( } catch { } + const effective = effectiveToolInvocation(toolEvent.tool_name, parsedParams); + const effectiveToolName = toolEvent.effective_tool_name || effective.toolName; + const effectiveParams = effective.input && typeof effective.input === 'object' + ? effective.input as Record + : {}; + const isWriteTool = isWriteLikeToolName(effectiveToolName); + if (isWriteTool) { - const combinedParts = splitFilePathAndContent(parsedParams.payload); + const combinedParts = splitFilePathAndContent(effectiveParams.payload); if (combinedParts) { - parsedParams = { - ...parsedParams, + Object.assign(effectiveParams, { file_path: combinedParts.filePath, content: combinedParts.content, - }; + }); } else { - if (typeof parsedParams.payload === 'string') { - parsedParams = { ...parsedParams, content: parsedParams.payload }; + if (typeof effectiveParams.payload === 'string') { + effectiveParams.content = effectiveParams.payload; } const extractedPath = extractFilePathFromJsonBuffer(newBuffer); const hasPath = ['file_path', 'filePath', 'filepath', 'target_file', 'targetFile', 'path', 'filename'] - .some((key) => typeof parsedParams[key] === 'string' && parsedParams[key].length > 0); + .some((key) => typeof effectiveParams[key] === 'string' && effectiveParams[key].length > 0); if (extractedPath && !hasPath) { - parsedParams = { ...parsedParams, file_path: extractedPath }; + effectiveParams.file_path = extractedPath; } } } - const isEditTool = ['edit', 'search_replace', 'Edit'].includes(toolEvent.tool_name); - const hasContentField = parsedParams && ('content' in parsedParams || 'contents' in parsedParams); - const hasNewString = parsedParams && 'new_string' in parsedParams; + const isEditTool = ['edit', 'search_replace', 'Edit'].includes(effectiveToolName); + const hasContentField = 'content' in effectiveParams || 'contents' in effectiveParams; + const hasNewString = 'new_string' in effectiveParams; let status: 'streaming' | 'receiving' = 'streaming'; if ((isWriteTool && hasContentField) || (isEditTool && hasNewString)) { @@ -246,6 +304,7 @@ function applyParamsPartial( } updateToolItem(store, sessionId, turnId, toolEvent.tool_id, { + toolName: toolEvent.tool_name, toolCall: { input: parsedParams, id: toolEvent.tool_id @@ -254,7 +313,7 @@ function applyParamsPartial( _paramsBuffer: newBuffer, status, isParamsStreaming: true, - _contentSize: isWriteTool && hasContentField ? ((parsedParams.content || parsedParams.contents || '').length) : undefined + _contentSize: isWriteTool && hasContentField ? ((effectiveParams.content || effectiveParams.contents || '').length) : undefined }, silent); applyPendingTerminalSessionId(store, sessionId, turnId, toolEvent.tool_id, silent); applyPendingAcpPermissionForTool(store, toolEvent.tool_id); @@ -416,6 +475,7 @@ function handleStarted( if (existingItem) { store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, { + toolName: toolEvent.tool_name, toolCall: toolCallData, status: 'running', isParamsStreaming: false, @@ -469,7 +529,8 @@ function handleCompleted( options?: ToolEventOptions, onTodoWriteResult?: (sessionId: string, turnId: string, result: any) => void ): void { - if (!options?.isSubagent && toolEvent.tool_name === 'TodoWrite' && isTodoWriteSuccessResult(toolEvent.result)) { + const effectiveToolName = toolEvent.effective_tool_name || toolEvent.tool_name; + if (!options?.isSubagent && effectiveToolName === 'TodoWrite' && isTodoWriteSuccessResult(toolEvent.result)) { onTodoWriteResult?.(sessionId, turnId, toolEvent.result); } @@ -608,6 +669,11 @@ function handleConfirmationNeeded( toolEvent: ConfirmationNeededToolEvent ): void { store.updateModelRoundItem(sessionId, turnId, toolEvent.tool_id, { + toolName: toolEvent.tool_name, + toolCall: { + input: toolEvent.params, + id: toolEvent.tool_id, + }, requiresConfirmation: true, status: 'pending_confirmation', confirmationTimeoutAt: typeof toolEvent.timeout_at === 'number' ? toolEvent.timeout_at : undefined, diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 4f97979aec..5bd1ffbd3e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { flowChatStore } from './FlowChatStore'; import type { FlowChatState, Session } from '../types/flow-chat'; import { startupTrace } from '@/shared/utils/startupTrace'; +import { projectEffectiveToolItem } from '../utils/toolInvocationIdentity'; const apiMocks = vi.hoisted(() => ({ listSessions: vi.fn(), @@ -486,6 +487,61 @@ describe('FlowChatStore round attempts', () => { attemptIndex: 1, }); }); + + it('restores a persisted deferred call as its canonical wire invocation', () => { + const [restoredTurn] = (flowChatStore as any).convertToDialogTurns([{ + turnId: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'fetch docs', + timestamp: 1000, + metadata: {}, + }, + modelRounds: [{ + id: 'round-1', + index: 0, + status: 'completed', + timestamp: 1000, + textItems: [], + thinkingItems: [], + toolItems: [{ + id: 'tool-1', + toolName: 'CallDeferredTool', + toolCall: { + id: 'tool-1', + input: { + tool_name: 'WebFetch', + args: { url: 'https://example.test' }, + }, + }, + toolResult: { result: { content: 'docs' }, success: true }, + startTime: 1100, + endTime: 1200, + status: 'completed', + }], + }], + status: 'completed', + timestamp: 1000, + }]); + + const tool = restoredTurn.modelRounds[0].items[0]; + expect(tool).toMatchObject({ + type: 'tool', + toolName: 'CallDeferredTool', + toolCall: { + id: 'tool-1', + input: { + tool_name: 'WebFetch', + args: { url: 'https://example.test' }, + }, + }, + }); + expect(projectEffectiveToolItem(tool as any)).toMatchObject({ + toolName: 'WebFetch', + toolCall: { id: 'tool-1', input: { url: 'https://example.test' } }, + }); + }); }); describe('FlowChatStore local usage reports', () => { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 8952f54305..6298281a4e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -4458,41 +4458,41 @@ export class FlowChatStore { attemptIndex: text.attemptIndex, })), ...round.toolItems.map((tool: any) => ({ - id: tool.id, - type: 'tool' as const, - toolName: tool.toolName, - interruptionReason: normalizePersistedToolInterruptionReason( - tool.interruptionReason, - tool.status, - ), - toolCall: tool.toolCall, - toolResult: tool.toolResult, - aiIntent: tool.aiIntent, - requiresConfirmation: tool.requiresConfirmation, - userConfirmed: tool.userConfirmed, - acpPermission: tool.acpPermission, - startTime: tool.startTime, - confirmationTimeoutAt: tool.confirmationTimeoutAt, - endTime: tool.endTime, - durationMs: tool.durationMs, - queueWaitMs: tool.queueWaitMs, - preflightMs: tool.preflightMs, - confirmationWaitMs: tool.confirmationWaitMs, - executionMs: tool.executionMs, - timestamp: tool.startTime, - status: normalizeRecoveredToolStatus( - tool.status, - normalizedTurnStatus, - tool.toolResult, - ), - orderIndex: tool.orderIndex, - subagentSessionId: tool.subagentSessionId, - subagentDialogTurnId: tool.subagentDialogTurnId, - subagentModelId: tool.subagentModelId, - subagentModelDisplayName: tool.subagentModelDisplayName, - attemptId: tool.attemptId, - attemptIndex: tool.attemptIndex, - })), + id: tool.id, + type: 'tool' as const, + toolName: tool.toolName, + toolCall: tool.toolCall, + interruptionReason: normalizePersistedToolInterruptionReason( + tool.interruptionReason, + tool.status, + ), + toolResult: tool.toolResult, + aiIntent: tool.aiIntent, + requiresConfirmation: tool.requiresConfirmation, + userConfirmed: tool.userConfirmed, + acpPermission: tool.acpPermission, + startTime: tool.startTime, + confirmationTimeoutAt: tool.confirmationTimeoutAt, + endTime: tool.endTime, + durationMs: tool.durationMs, + queueWaitMs: tool.queueWaitMs, + preflightMs: tool.preflightMs, + confirmationWaitMs: tool.confirmationWaitMs, + executionMs: tool.executionMs, + timestamp: tool.startTime, + status: normalizeRecoveredToolStatus( + tool.status, + normalizedTurnStatus, + tool.toolResult, + ), + orderIndex: tool.orderIndex, + subagentSessionId: tool.subagentSessionId, + subagentDialogTurnId: tool.subagentDialogTurnId, + subagentModelId: tool.subagentModelId, + subagentModelDisplayName: tool.subagentModelDisplayName, + attemptId: tool.attemptId, + attemptIndex: tool.attemptIndex, + })), ...(round.thinkingItems || []).map((thinking: any) => ({ id: thinking.id, type: 'thinking' as const, diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index a31131369e..6d92b6b692 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -16,6 +16,7 @@ import { } from '../tool-cards/toolCardMetadata'; import { isCompletedToolInTransientWindow } from '../components/modern/modelRoundItemGrouping'; import { flowChatStore } from './FlowChatStore'; +import { getEffectiveToolName } from '../utils/toolInvocationIdentity'; import { getTurnCompletionNotice, type TurnCompletionNotice, @@ -174,7 +175,7 @@ function isExploreOnlyRound(round: ModelRound, nowMs: number): boolean { } const hasCollapsibleTool = round.items.some(item => - item.type === 'tool' && isCollapsibleTool((item as FlowToolItem).toolName) + item.type === 'tool' && isCollapsibleTool(getEffectiveToolName(item as FlowToolItem)) ); const hasAnyTool = round.items.some(item => item.type === 'tool'); @@ -184,7 +185,7 @@ function isExploreOnlyRound(round: ModelRound, nowMs: number): boolean { const allItemsCollapsible = round.items.every(item => { if (item.type === 'tool') { - return isCollapsibleTool((item as FlowToolItem).toolName); + return isCollapsibleTool(getEffectiveToolName(item as FlowToolItem)); } return item.type === 'text' || item.type === 'thinking'; }); @@ -264,7 +265,7 @@ function computeRoundStats(round: ModelRound): ExploreGroupStats { for (const item of round.items) { if (item.type === 'tool') { - const toolName = (item as FlowToolItem).toolName; + const toolName = getEffectiveToolName(item as FlowToolItem); if (READ_TOOL_NAMES.has(toolName)) readCount++; else if (SEARCH_TOOL_NAMES.has(toolName)) searchCount++; else if (COMMAND_TOOL_NAMES.has(toolName)) commandCount++; diff --git a/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx index a5c8b8731a..ac296eab62 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GetToolSpecCard.test.tsx @@ -28,7 +28,7 @@ const config: ToolCardConfig = { icon: 'SPEC', requiresConfirmation: false, resultDisplayType: 'detailed', - description: 'Read usage instructions and schema for a collapsed tool', + description: 'Read usage instructions and schema for a deferred tool', displayMode: 'compact', }; diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 154caaa125..7ed8009910 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -8,6 +8,7 @@ import type { FlowItem, FlowToolItem, ToolCardConfig } from '../types/flow-chat'; import { isMcpToolName, parseMcpToolName } from '@/infrastructure/mcp/toolName'; import { UI_EXCEPTION_ACCENTS } from '@/shared/theme/uiExceptionAccents'; +import { getEffectiveToolName } from '../utils/toolInvocationIdentity'; // Tool card config map - uses backend tool names export const TOOL_CARD_CONFIGS: Record = { @@ -154,7 +155,7 @@ export const TOOL_CARD_CONFIGS: Record = { icon: 'SPEC', requiresConfirmation: false, resultDisplayType: 'detailed', - description: 'Read usage instructions and schema for a collapsed tool', + description: 'Read usage instructions and schema for a deferred tool', displayMode: 'compact', primaryColor: UI_EXCEPTION_ACCENTS.tealAction }, @@ -474,7 +475,7 @@ export function isCollapsibleItem(item: FlowItem): boolean { // Tools: only explorer tools are collapsible. if (item.type === 'tool') { - return isCollapsibleTool((item as FlowToolItem).toolName); + return isCollapsibleTool(getEffectiveToolName(item as FlowToolItem)); } return false; @@ -498,7 +499,7 @@ export function isCollapsibleItemWithContext( // If followed by an explorer tool, collapse together. if (nextItem.type === 'tool') { - return isCollapsibleTool((nextItem as FlowToolItem).toolName); + return isCollapsibleTool(getEffectiveToolName(nextItem as FlowToolItem)); } // If followed by text or thinking, treat as collapsible for grouping. @@ -512,7 +513,7 @@ export function isCollapsibleItemWithContext( // Tools: only explorer tools are collapsible. if (item.type === 'tool') { - return isCollapsibleTool((item as FlowToolItem).toolName); + return isCollapsibleTool(getEffectiveToolName(item as FlowToolItem)); } return false; diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index ff8872f974..ecb83c9eda 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -51,6 +51,7 @@ export interface FlowThinkingItem extends FlowItem { export interface FlowToolItem extends FlowItem { type: 'tool'; + /** Provider-facing identity. Deferred calls remain `CallDeferredTool`. */ toolName: string; terminalSessionId?: string; interruptionReason?: 'app_restart' | 'retry_superseded'; diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index 9838071d1d..0bb1ba4162 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -9,6 +9,7 @@ import { findPendingAskUserQuestion, TRANSIENT_TURN_STATUSES, } from './askUserQuestionState'; +import { effectiveToolInvocation } from './toolInvocationIdentity'; export type AgentCompanionTaskState = | 'running' @@ -126,12 +127,12 @@ function latestAssistantSnippet(turn: DialogTurn | undefined): string | undefine } function extractAskUserQuestionText(tool: FlowToolItem): string | undefined { - const input = tool.toolCall?.input; + const input = effectiveToolInvocation(tool.toolName, tool.toolCall?.input).input; if (!input || typeof input !== 'object') { return undefined; } - const questions = input.questions; + const questions = (input as Record).questions; if (!Array.isArray(questions) || questions.length === 0) { return undefined; } diff --git a/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts b/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts index 518ff87d85..e9c9ea55d4 100644 --- a/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts +++ b/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts @@ -1,5 +1,6 @@ import { stateMachineManager } from '../state-machine/SessionStateMachineManager'; import type { DialogTurn, FlowToolItem, Session } from '../types/flow-chat'; +import { effectiveToolInvocation } from './toolInvocationIdentity'; export const TRANSIENT_TURN_STATUSES = new Set([ 'pending', @@ -37,16 +38,23 @@ export function findPendingAskUserQuestion( for (let itemIndex = round.items.length - 1; itemIndex >= 0; itemIndex -= 1) { const item = round.items[itemIndex]; if ( - item.type === 'tool' - && item.toolName === 'AskUserQuestion' - && !TERMINAL_TOOL_STATUSES.has(item.status) - && !item.isParamsStreaming + item.type !== 'tool' + || TERMINAL_TOOL_STATUSES.has(item.status) + || item.isParamsStreaming ) { - const input = item.toolCall?.input; - const questions = input && typeof input === 'object' ? input.questions : undefined; - if (Array.isArray(questions) && questions.length > 0) { - return item; - } + continue; + } + + const effective = effectiveToolInvocation(item.toolName, item.toolCall?.input); + if (effective.toolName !== 'AskUserQuestion') { + continue; + } + + const questions = effective.input && typeof effective.input === 'object' + ? (effective.input as Record).questions + : undefined; + if (Array.isArray(questions) && questions.length > 0) { + return item; } } } diff --git a/src/web-ui/src/flow_chat/utils/backgroundSubagentActivity.ts b/src/web-ui/src/flow_chat/utils/backgroundSubagentActivity.ts index 4b3de49cc9..7a612a028e 100644 --- a/src/web-ui/src/flow_chat/utils/backgroundSubagentActivity.ts +++ b/src/web-ui/src/flow_chat/utils/backgroundSubagentActivity.ts @@ -1,5 +1,6 @@ import { SessionExecutionState } from '../state-machine/types'; import type { DialogTurn, FlowChatState, FlowToolItem, Session } from '../types/flow-chat'; +import { getEffectiveToolName } from './toolInvocationIdentity'; export type BackgroundSubagentActivityStatus = 'processing' | 'finishing'; @@ -99,7 +100,7 @@ function collectBackgroundTaskToolsBySubagentId( const toolItem = item as BackgroundTaskTool; if ( - toolItem.toolName?.toLowerCase() === 'task' && + getEffectiveToolName(toolItem).toLowerCase() === 'task' && toolItem.subagentSessionId && isBackgroundTaskTool(toolItem) ) { @@ -132,7 +133,7 @@ function findBackgroundTaskToolForSubagent( const toolItem = item as BackgroundTaskTool; if ( - toolItem.toolName?.toLowerCase() === 'task' && + getEffectiveToolName(toolItem).toLowerCase() === 'task' && toolItem.subagentSessionId === subagentSessionId && isBackgroundTaskTool(toolItem) ) { diff --git a/src/web-ui/src/flow_chat/utils/deepReviewCapacityGuard.ts b/src/web-ui/src/flow_chat/utils/deepReviewCapacityGuard.ts index 8a8a8b0bad..ac04b010df 100644 --- a/src/web-ui/src/flow_chat/utils/deepReviewCapacityGuard.ts +++ b/src/web-ui/src/flow_chat/utils/deepReviewCapacityGuard.ts @@ -1,4 +1,5 @@ import type { FlowChatState, FlowToolItem, Session } from '../types/flow-chat'; +import { getEffectiveToolName } from './toolInvocationIdentity'; export const DEEP_REVIEW_SESSION_CONCURRENCY_WARNING_THRESHOLD = 2; @@ -23,7 +24,7 @@ function isActiveSubagentTask(item: unknown): item is FlowToolItem { const toolItem = item as FlowToolItem; if ( toolItem.type !== 'tool' || - toolItem.toolName !== 'Task' || + getEffectiveToolName(toolItem) !== 'Task' || !ACTIVE_TOOL_STATUSES.has(toolItem.status) ) { return false; diff --git a/src/web-ui/src/flow_chat/utils/deepReviewContinuation.ts b/src/web-ui/src/flow_chat/utils/deepReviewContinuation.ts index 0b05cf82aa..d8b7012e7c 100644 --- a/src/web-ui/src/flow_chat/utils/deepReviewContinuation.ts +++ b/src/web-ui/src/flow_chat/utils/deepReviewContinuation.ts @@ -4,6 +4,7 @@ import { normalizeAiErrorDetail, } from '@/shared/ai-errors/aiErrorPresenter'; import type { FlowToolItem, Session } from '../types/flow-chat'; +import { getEffectiveToolName } from './toolInvocationIdentity'; export type DeepReviewContinuationPhase = 'review_interrupted' | 'resume_blocked'; export type DeepReviewResultRecoveryReason = @@ -311,7 +312,7 @@ export function collectReviewerProgress(session: Session): DeepReviewReviewerPro for (const turn of session.dialogTurns) { for (const round of turn.modelRounds) { for (const item of round.items) { - if (item.type !== 'tool' || item.toolName !== 'Task') { + if (item.type !== 'tool' || getEffectiveToolName(item) !== 'Task') { continue; } const progress = getReviewerProgressFromTask(item); diff --git a/src/web-ui/src/flow_chat/utils/deepReviewExperience.ts b/src/web-ui/src/flow_chat/utils/deepReviewExperience.ts index df4c2510e2..da1e199562 100644 --- a/src/web-ui/src/flow_chat/utils/deepReviewExperience.ts +++ b/src/web-ui/src/flow_chat/utils/deepReviewExperience.ts @@ -11,6 +11,7 @@ import type { Session } from '../types/flow-chat'; import type { CodeReviewRemediationData } from './codeReviewRemediation'; import type { DeepReviewInterruption, DeepReviewReviewerProgress } from './deepReviewContinuation'; import { collectReviewerProgress } from './deepReviewContinuation'; +import { getEffectiveToolName } from './toolInvocationIdentity'; // --------------------------------------------------------------------------- // Reviewer progress @@ -203,7 +204,7 @@ export function extractPartialReviewData( for (const turn of session.dialogTurns) { for (const round of turn.modelRounds) { for (const item of round.items) { - if (item.type !== 'tool' || item.toolName !== 'Task') { + if (item.type !== 'tool' || getEffectiveToolName(item) !== 'Task') { continue; } const reviewer = String( diff --git a/src/web-ui/src/flow_chat/utils/modifiedFilePaths.ts b/src/web-ui/src/flow_chat/utils/modifiedFilePaths.ts index 89acc1e286..b2ced84930 100644 --- a/src/web-ui/src/flow_chat/utils/modifiedFilePaths.ts +++ b/src/web-ui/src/flow_chat/utils/modifiedFilePaths.ts @@ -1,6 +1,7 @@ import { splitFilePathAndContent } from '@/shared/utils/partialJsonParser'; import type { DialogTurn, FlowToolItem } from '../types/flow-chat'; +import { effectiveToolInvocation, getEffectiveToolName } from './toolInvocationIdentity'; const FILE_MUTATION_TOOLS = new Set([ 'write', @@ -59,21 +60,26 @@ export function collectModifiedFilePathsFromTurns( } const tool = item as FlowToolItem; + const effective = effectiveToolInvocation(tool.toolName, tool.toolCall?.input); if ( - !FILE_MUTATION_TOOLS.has(normalizeToolName(tool.toolName)) || + !FILE_MUTATION_TOOLS.has(normalizeToolName(effective.toolName)) || tool.status !== 'completed' || tool.toolResult?.success === false ) { continue; } - const input = tool.toolCall?.input; + const input = effective.input; if (!input || typeof input !== 'object') { continue; } - const combinedFilePath = splitFilePathAndContent(input.payload)?.filePath; - const filePath = combinedFilePath ?? input.file_path ?? input.filePath ?? input.path; + const inputRecord = input as Record; + const combinedFilePath = splitFilePathAndContent(inputRecord.payload)?.filePath; + const filePath = combinedFilePath + ?? inputRecord.file_path + ?? inputRecord.filePath + ?? inputRecord.path; if (typeof filePath === 'string' && filePath.trim()) { paths.add(workspaceRelativePath(filePath, workspacePath)); } @@ -95,7 +101,7 @@ export function hasOpaqueWorkspaceMutationRisk( continue; } const tool = item as FlowToolItem; - if (OPAQUE_WORKSPACE_TOOLS.has(normalizeToolName(tool.toolName))) { + if (OPAQUE_WORKSPACE_TOOLS.has(normalizeToolName(getEffectiveToolName(tool)))) { return true; } } diff --git a/src/web-ui/src/flow_chat/utils/reviewSessionSummary.ts b/src/web-ui/src/flow_chat/utils/reviewSessionSummary.ts index 17694d37ef..c9ad47b7c6 100644 --- a/src/web-ui/src/flow_chat/utils/reviewSessionSummary.ts +++ b/src/web-ui/src/flow_chat/utils/reviewSessionSummary.ts @@ -1,4 +1,5 @@ import type { FlowToolItem, Session } from '../types/flow-chat'; +import { getEffectiveToolName } from './toolInvocationIdentity'; export interface CodeReviewSummaryData { overall_assessment?: string; @@ -72,7 +73,7 @@ export function findLatestCodeReviewResultState(session?: Session | null): CodeR const item = items[i]; if (item.type === 'tool') { const toolItem = item as FlowToolItem; - if (toolItem.toolName === 'submit_code_review') { + if (getEffectiveToolName(toolItem) === 'submit_code_review') { const parsed = parseReviewResult(toolItem.toolResult?.result); if (parsed) { return { diff --git a/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.test.ts b/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.test.ts new file mode 100644 index 0000000000..5a5d425704 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { + effectiveToolInvocation, + projectEffectiveToolItem, + replaceEffectiveToolInput, +} from './toolInvocationIdentity'; + +describe('toolInvocationIdentity', () => { + it('derives an effective invocation without changing the wire input', () => { + const wireInput = { + tool_name: 'mcp__docs__search', + args: { query: 'identity' }, + }; + + expect(effectiveToolInvocation('CallDeferredTool', wireInput)).toEqual({ + toolName: 'mcp__docs__search', + input: wireInput.args, + isDeferred: true, + }); + expect(wireInput).toEqual({ + tool_name: 'mcp__docs__search', + args: { query: 'identity' }, + }); + }); + + it('falls back to the wire identity for malformed gateway input', () => { + const input = { path: 'README.md' }; + expect(effectiveToolInvocation('CallDeferredTool', input)).toEqual({ + toolName: 'CallDeferredTool', + input, + isDeferred: false, + }); + }); + + it('projects an effective card view while retaining the canonical item', () => { + const item = { + id: 'tool-1', + type: 'tool' as const, + toolName: 'CallDeferredTool', + toolCall: { + id: 'tool-1', + input: { + tool_name: 'Write', + args: { file_path: 'README.md', content: 'updated' }, + }, + }, + status: 'pending_confirmation' as const, + timestamp: 1, + }; + + const projected = projectEffectiveToolItem(item); + expect(projected.toolName).toBe('Write'); + expect(projected.toolCall.input).toEqual({ file_path: 'README.md', content: 'updated' }); + expect(item.toolName).toBe('CallDeferredTool'); + expect(item.toolCall.input).toHaveProperty('tool_name', 'Write'); + }); + + it('writes edited effective input back into deferred args', () => { + const wireInput = { + tool_name: 'Write', + args: { file_path: 'README.md', content: 'before' }, + }; + + expect(replaceEffectiveToolInput('CallDeferredTool', wireInput, { + file_path: 'README.md', + content: 'after', + })).toEqual({ + tool_name: 'Write', + args: { file_path: 'README.md', content: 'after' }, + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.ts b/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.ts new file mode 100644 index 0000000000..a39d2df0bb --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/toolInvocationIdentity.ts @@ -0,0 +1,79 @@ +import type { FlowToolItem } from '../types/flow-chat'; + +export const DEFERRED_TOOL_GATEWAY_NAME = 'CallDeferredTool'; + +export interface EffectiveToolInvocation { + toolName: string; + input: unknown; + isDeferred: boolean; +} + +export function effectiveToolInvocation( + wireToolName: string, + wireInput: unknown, +): EffectiveToolInvocation { + if ( + wireToolName !== DEFERRED_TOOL_GATEWAY_NAME + || wireInput === null + || typeof wireInput !== 'object' + || Array.isArray(wireInput) + ) { + return { toolName: wireToolName, input: wireInput, isDeferred: false }; + } + + const input = wireInput as Record; + const keys = Object.keys(input); + if ( + keys.some(key => key !== 'tool_name' && key !== 'args') + || typeof input.tool_name !== 'string' + || input.tool_name.trim().length === 0 + || !Object.prototype.hasOwnProperty.call(input, 'args') + || input.args === null + || typeof input.args !== 'object' + || Array.isArray(input.args) + ) { + return { toolName: wireToolName, input: wireInput, isDeferred: false }; + } + + return { + toolName: input.tool_name, + input: input.args, + isDeferred: true, + }; +} + +export function getEffectiveToolName(toolItem: Pick): string { + return effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input).toolName; +} + +export function projectEffectiveToolItem(toolItem: FlowToolItem): FlowToolItem { + const effective = effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input); + if (!effective.isDeferred) { + return toolItem; + } + + return { + ...toolItem, + toolName: effective.toolName, + toolCall: { + ...toolItem.toolCall, + input: effective.input, + }, + }; +} + +export function replaceEffectiveToolInput( + wireToolName: string, + wireInput: unknown, + effectiveInput: unknown, +): unknown { + const current = effectiveToolInvocation(wireToolName, wireInput); + if (!current.isDeferred || wireInput === null || typeof wireInput !== 'object') { + return effectiveInput; + } + + return { + ...(wireInput as Record), + args: effectiveInput, + }; +} diff --git a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx index ef3efdaead..5f6b560ef9 100644 --- a/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIFeaturesConfig.tsx @@ -50,11 +50,11 @@ const AIFeaturesConfig: React.FC = () => { const [ loadedSettings, allModels, - funcAgentModelsData + funcAgentModelsData, ] = await Promise.all([ aiExperienceConfigService.getSettingsAsync(), configManager.getConfig('ai.models') || [], - configManager.getConfig>('ai.func_agent_models') || {} + configManager.getConfig>('ai.func_agent_models') || {}, ]); setSettings(loadedSettings); @@ -215,6 +215,7 @@ const AIFeaturesConfig: React.FC = () => { ); })} + ); diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index a78499193e..94d2266e36 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -106,12 +106,14 @@ const SessionSettingsPanels: React.FC = ({ variant } const [models, setModels] = useState([]); const [funcAgentModels, setFuncAgentModels] = useState>({}); const [skipToolConfirmation, setSkipToolConfirmation] = useState(true); + const [enableDeferredToolLoading, setEnableDeferredToolLoading] = useState(true); const [subagentMaxConcurrency, setSubagentMaxConcurrency] = useState(DEFAULT_SUBAGENT_MAX_CONCURRENCY); const [executionTimeout, setExecutionTimeout] = useState(''); const [confirmationTimeout, setConfirmationTimeout] = useState(''); const [subagentBatchExecutionPolicy, setSubagentBatchExecutionPolicy] = useState(DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY); const [toolExecConfigLoading, setToolExecConfigLoading] = useState(false); + const [deferredToolLoadingConfigSaving, setDeferredToolLoadingConfigSaving] = useState(false); const [computerUseEnabled, setComputerUseEnabled] = useState(false); const [computerUseAccess, setComputerUseAccess] = useState(false); @@ -210,6 +212,7 @@ const SessionSettingsPanels: React.FC = ({ variant } allModels, funcAgentModelsData, skipConfirm, + deferredToolLoadingEnabled, loadedSubagentMaxConcurrency, execTimeout, confirmTimeout, @@ -223,6 +226,7 @@ const SessionSettingsPanels: React.FC = ({ variant } configManager.getConfig('ai.models') || [], configManager.getConfig>('ai.func_agent_models') || {}, configManager.getConfig('ai.skip_tool_confirmation'), + configManager.getConfig('ai.enable_deferred_tool_loading'), configManager.getConfig('ai.subagent_max_concurrency'), configManager.getConfig('ai.tool_execution_timeout_secs'), configManager.getConfig('ai.tool_confirmation_timeout_secs'), @@ -238,6 +242,7 @@ const SessionSettingsPanels: React.FC = ({ variant } setModels(allModels as AIModelConfig[]); setFuncAgentModels(funcAgentModelsData as Record); setSkipToolConfirmation(skipConfirm ?? true); + setEnableDeferredToolLoading(deferredToolLoadingEnabled ?? true); setSubagentMaxConcurrency(loadedSubagentMaxConcurrency != null ? loadedSubagentMaxConcurrency : DEFAULT_SUBAGENT_MAX_CONCURRENCY); @@ -479,6 +484,24 @@ const SessionSettingsPanels: React.FC = ({ variant } } }; + const handleDeferredToolLoadingChange = async (checked: boolean) => { + const previous = enableDeferredToolLoading; + setEnableDeferredToolLoading(checked); + setDeferredToolLoadingConfigSaving(true); + try { + await configManager.setConfig('ai.enable_deferred_tool_loading', checked); + notificationService.success(t('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save enable_deferred_tool_loading', error); + notificationService.error( + `${t('messages.saveFailed')}: ` + (error instanceof Error ? error.message : String(error)) + ); + setEnableDeferredToolLoading(previous); + } finally { + setDeferredToolLoadingConfigSaving(false); + } + }; + const handleSubagentBatchExecutionPolicyChange = async (value: string | number | (string | number)[]) => { const nextPolicy = normalizeSubagentBatchExecutionPolicy(Array.isArray(value) ? value[0] : value); const previousPolicy = subagentBatchExecutionPolicy; @@ -1188,6 +1211,26 @@ const SessionSettingsPanels: React.FC = ({ variant } + + +
+ handleDeferredToolLoadingChange(event.target.checked)} + disabled={deferredToolLoadingConfigSaving} + size="small" + /> +
+
+
+ {/* ── Computer use (desktop) ─────────────────────────────── */}