diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 337f9604a..e5680eaab 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -301,6 +301,14 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate manifest / GetToolSpec facade 与 snapshot wrapper 注入。该收口不改变工具执行路径, 也不声明 `ToolUseContext`、collapsed unlock state、`GetToolSpecTool` Tool impl、 snapshot runtime 或 concrete tools 已迁出 core。 +- HR1 后续收口进一步把 `ToolUseContext` 的 workspace service accessor、runtime artifact + lookup、path policy enforcement、tool pipeline/description/preflight context + materialization、tool-call cancellation/post-call hook wrapper 和 Deep Review light checkpoint 绑定集中到 + core-owned `tools/tool_context_runtime.rs`;`framework.rs` 只保留 context shape、 + portable facts projection 与 `Tool` trait。该调整仍不迁移 `ToolUseContext` + 本体、runtime service handles 或 concrete tool behavior,并通过 remote workspace + containment、runtime URI scope、path policy、task/description/preflight context materialization + 与 cancellation hook 回归测试保护现有工具语义。 - 已完成的 MCP runtime/dynamic tools、remote-connect tracker/wire/pure policy、 semantic baseline、product-domain port/facade 与 tool contract/helper 外移不得重复规划; 如果后续发现这些已完成项存在实现错误,应在对应 H 阶段记录问题、风险和修复方案, diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index ec177bd14..791488d27 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -2142,7 +2142,7 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 29. 已完成:H1 tool catalog runtime facade slice。`bitfun-agent-tools::ToolCatalogRuntime` 接管 provider-backed visible-tools、prompt-visible manifest 与 readonly enabled catalog 查询入口;core 继续持有 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter 和 product facade,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 30. 已完成:H1 GetToolSpec Tool adapter facade slice。`bitfun-agent-tools::GetToolSpecRuntime::call_results` 接管单次执行结果到 `Vec` 的通用适配形状,core `product_runtime.rs` 暴露 product `resolve_product_get_tool_spec_results`,`GetToolSpecTool::call_impl` 只保留 product facade 委托和 `BitFunError` 映射;不迁移 `ToolUseContext`、runtime manifest assembly、unlock state owner、assistant rendering 语义或 concrete tools。 31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `product_runtime.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 -32. HR1 当前闭环状态:工具 runtime 的 provider-neutral contract、host path normalization / runtime artifact URI / remote POSIX path pure contract、allowed-list / collapsed-tool execution gate policy、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛;core 内部 product runtime adapter 已统一到 `product_runtime.rs`。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 +32. HR1 当前闭环状态:工具 runtime 的 provider-neutral contract、host path normalization / runtime artifact URI / remote POSIX path pure contract、allowed-list / collapsed-tool execution gate policy、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛;core 内部 product runtime adapter 已统一到 `product_runtime.rs`。本轮进一步把 `ToolUseContext` 上的 workspace service accessor、runtime artifact lookup、path policy enforcement、tool pipeline/description/preflight context materialization、tool-call cancellation/post-call hook wrapper 和 Deep Review light checkpoint 绑定集中到 `tool_context_runtime.rs`,作为 core-owned runtime binding owner,并补齐 remote workspace containment、runtime URI scope、path policy、task/description/preflight context materialization 与 cancellation hook 回归测试;`framework.rs` 只保留 context shape、portable facts projection 和 `Tool` trait。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 33. H4 已完成:facade / boundary finalization。`scripts/check-core-boundaries.mjs` 的 regular check 和 self-test 已覆盖 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与既有 duplicate-path required rule;root / core / services-integrations 文档与当前 H1-H3 代码状态一致,不声明 remote-SSH runtime、agent registry/scheduler、default feature 或构建收益已完成。 34. H5 已启动并完成当前闭环:第一步建立 `bitfun-core --no-default-features` 编译闭环, 证明 `ssh-remote` 关闭时不再编译 russh-backed runtime,并通过 disabled surface diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 5714319d0..0b655a90c 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -494,6 +494,36 @@ const forbiddenContentRules = [ regex: /\bpub enum ToolWorkspaceKind\b/, message: 'core tool framework must not redefine ToolWorkspaceKind; use bitfun-agent-tools', }, + { + regex: /\bget_global_coordinator\b/, + message: + 'core tool framework must not own runtime checkpoint coordination; keep it in tool_context_runtime', + }, + { + regex: /\bGitService\b/, + message: + 'core tool framework must not own git-backed checkpoint runtime; keep it in tool_context_runtime', + }, + { + regex: /\bget_workspace_runtime_service_arc\b/, + message: + 'core tool framework must not own workspace runtime lookup; keep it in tool_context_runtime', + }, + { + regex: /\bremote_workspace_runtime_root\b/, + message: + 'core tool framework must not own remote runtime-root lookup; keep it in tool_context_runtime', + }, + { + regex: /\bget_path_manager_arc\b/, + message: + 'core tool framework must not own host runtime-root lookup; keep it in tool_context_runtime', + }, + { + regex: /\bpost_call_hooks::record_successful_tool_call\b/, + message: + 'core tool framework must not own post-call runtime hooks; keep them in tool_context_runtime', + }, ], }, { @@ -2567,6 +2597,77 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/agentic/tools/tool_context_runtime.rs', + reason: + 'core must keep ToolUseContext runtime/service bindings centralized while ToolUseContext and concrete tools remain core-owned', + patterns: [ + { + regex: /\bimpl ToolUseContext\b/, + message: 'missing ToolUseContext runtime binding owner impl', + }, + { + regex: /\brecord_light_checkpoint\b/, + message: 'missing Deep Review checkpoint binding', + }, + { + regex: /\bcall_with_tool_runtime_hooks\b/, + message: 'missing tool-call cancellation/post-call hook binding', + }, + { + regex: /\bbuild_tool_use_context_for_task\b/, + message: 'missing tool pipeline context materialization binding', + }, + { + regex: /\bbuild_tool_description_context\b/, + message: 'missing tool manifest description context materialization binding', + }, + { + regex: /\bbuild_write_preflight_context\b/, + message: 'missing write preflight context materialization binding', + }, + { + regex: /\bensure_current_workspace_runtime\b/, + message: 'missing workspace runtime ensure binding', + }, + { + regex: /\bresolve_tool_path\b/, + message: 'missing tool path resolution binding', + }, + { + regex: /\benforce_path_operation\b/, + message: 'missing runtime path policy binding', + }, + { + regex: /\bworkspace_path_resolution_rejects_absolute_paths_outside_remote_workspace\b/, + message: 'missing remote workspace containment regression', + }, + { + regex: /\bruntime_uri_resolution_rejects_different_workspace_scope\b/, + message: 'missing runtime artifact scope regression', + }, + { + regex: /\bpath_policy_allows_only_configured_local_roots\b/, + message: 'missing path policy enforcement regression', + }, + { + regex: /\btool_call_runtime_hook_returns_cancelled_before_impl_completes\b/, + message: 'missing tool-call cancellation regression', + }, + { + regex: /\btool_task_context_materialization_preserves_runtime_fields\b/, + message: 'missing tool task context materialization regression', + }, + { + regex: /\btool_description_context_preserves_manifest_custom_data_shape\b/, + message: 'missing tool description context regression', + }, + { + regex: /\bwrite_preflight_context_preserves_minimal_runtime_fields\b/, + message: 'missing write preflight context regression', + }, + ], + }, { path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', reason: @@ -4392,6 +4493,12 @@ function runManifestParserSelfTest() { 'ToolRenderOptions', 'ToolPathBackend', 'ToolPathResolution', + 'get_global_coordinator', + 'GitService', + 'get_workspace_runtime_service_arc', + 'remote_workspace_runtime_root', + 'get_path_manager_arc', + 'post_call_hooks::record_successful_tool_call', ]; const coreToolFrameworkRuleText = coreToolFrameworkRule.patterns .map((pattern) => pattern.regex.source) @@ -4843,6 +4950,27 @@ function runManifestParserSelfTest() { 'unlocked_collapsed_tools', ], }, + { + path: 'src/crates/core/src/agentic/tools/tool_context_runtime.rs', + contracts: [ + 'impl ToolUseContext', + 'record_light_checkpoint', + 'call_with_tool_runtime_hooks', + 'build_tool_use_context_for_task', + 'build_tool_description_context', + 'build_write_preflight_context', + 'ensure_current_workspace_runtime', + 'resolve_tool_path', + 'enforce_path_operation', + 'workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace', + 'runtime_uri_resolution_rejects_different_workspace_scope', + 'path_policy_allows_only_configured_local_roots', + 'tool_call_runtime_hook_returns_cancelled_before_impl_completes', + 'tool_task_context_materialization_preserves_runtime_fields', + 'tool_description_context_preserves_manifest_custom_data_shape', + 'write_preflight_context_preserves_minimal_runtime_fields', + ], + }, { path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', contracts: ['validate_collapsed_tool_usage', 'unlocked_collapsed_tools', 'GetToolSpec'], diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 6e39737f7..9b97984c6 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -51,6 +51,12 @@ SessionManager → Session → DialogTurn → ModelRound reviewed port/provider plan and equivalence tests exist. `ToolContextFacts` / `PortableToolContextProvider` are only portable projections; they must not carry runtime handles, workspace services, or cancellation tokens. +- Keep `ToolUseContext` runtime/service bindings centralized in + `src/agentic/tools/tool_context_runtime.rs`. `framework.rs` should define the + context shape, portable facts projection, and tool trait, not own workspace + runtime lookup, path enforcement, pipeline/description/preflight context + materialization, cancellation wrapping, post-call hooks, or checkpoint + collection. - Host path normalization, runtime artifact URI parsing/building, and remote POSIX path containment are portable `bitfun-agent-tools` contracts. Core keeps compatibility wrappers for `BitFunError`, workspace runtime-root diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index d344d5706..b5152a506 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -5,23 +5,23 @@ use super::round_executor::RoundExecutor; use super::types::{ExecutionContext, ExecutionResult, RoundContext, RoundResult}; use crate::agentic::agents::{ - get_agent_registry, PromptBuilder, PromptBuilderContext, RemoteExecutionHints, + PromptBuilder, PromptBuilderContext, RemoteExecutionHints, get_agent_registry, }; use crate::agentic::context_profile::{ContextProfilePolicy, ModelCapabilityProfile}; use crate::agentic::core::{ - render_system_reminder, Message, MessageContent, MessageHelper, MessageRole, - MessageSemanticKind, RequestReasoningTokenPolicy, Session, + Message, MessageContent, MessageHelper, MessageRole, MessageSemanticKind, + RequestReasoningTokenPolicy, Session, render_system_reminder, }; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::execution::types::FinishReason; use crate::agentic::image_analysis::{ - build_multimodal_message_with_images, process_image_contexts_for_provider, ImageContextData, - ImageLimits, + ImageContextData, ImageLimits, build_multimodal_message_with_images, + process_image_contexts_for_provider, }; use crate::agentic::round_preempt::RoundInjectionKind; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; use crate::agentic::tools::{ - resolve_tool_manifest, ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions, + ResolvedToolManifest, SubagentParentInfo, resolve_tool_manifest, tool_context_runtime, }; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; @@ -34,7 +34,7 @@ use crate::util::token_counter::TokenCounter; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; -use bitfun_agent_tools::{collect_loaded_collapsed_tool_names, GetToolSpecLoadObservation}; +use bitfun_agent_tools::{GetToolSpecLoadObservation, collect_loaded_collapsed_tool_names}; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; @@ -2510,27 +2510,13 @@ impl ExecutionEngine { primary_supports_image_understanding: bool, context_vars: &HashMap, ) -> ResolvedToolManifest { - let mut tool_opts_custom = HashMap::new(); - tool_opts_custom.insert( - "primary_model_supports_image_understanding".to_string(), - serde_json::Value::Bool(primary_supports_image_understanding), + let description_context = tool_context_runtime::build_tool_description_context( + agent_type, + workspace, + workspace_services, + primary_supports_image_understanding, + context_vars, ); - for (key, value) in context_vars { - tool_opts_custom.insert(key.clone(), serde_json::Value::String(value.clone())); - } - let description_context = crate::agentic::tools::framework::ToolUseContext { - tool_call_id: None, - agent_type: Some(agent_type.to_string()), - session_id: None, - dialog_turn_id: None, - workspace: workspace.cloned(), - unlocked_collapsed_tools: Vec::new(), - custom_data: tool_opts_custom, - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: workspace_services.cloned(), - }; resolve_tool_manifest(allowed_tools, exposure_overrides, &description_context).await } diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 73897cd3d..87494aa88 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -4,8 +4,10 @@ use super::stream_processor::{StreamProcessOptions, StreamProcessor, StreamResult}; use super::types::{FinishReason, RoundContext, RoundResult}; +use crate::agentic::MessageContent; use crate::agentic::core::{Message, ToolCall}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; +use crate::agentic::tools::ToolPathOperation; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; use crate::agentic::tools::implementations::file_write_tool::{ @@ -13,18 +15,16 @@ use crate::agentic::tools::implementations::file_write_tool::{ }; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; use crate::agentic::tools::registry::get_global_tool_registry; -use crate::agentic::tools::ToolPathOperation; -use crate::agentic::MessageContent; +use crate::agentic::tools::tool_context_runtime; use crate::infrastructure::ai::AIClient; -use crate::service::config::types::WriteToolMode; use crate::service::config::GlobalConfigManager; +use crate::service::config::types::WriteToolMode; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use dashmap::DashMap; use log::{debug, error, info, warn}; -use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -999,10 +999,10 @@ impl RoundExecutor { Ok(None) => break, Err(_) => { return Err(BitFunError::Timeout(format!( - "Write content generation timed out for {} after {} seconds without stream progress", - file_path, - watchdog_timeout.as_secs() - ))); + "Write content generation timed out for {} after {} seconds without stream progress", + file_path, + watchdog_timeout.as_secs() + ))); } }; @@ -1185,19 +1185,15 @@ impl RoundExecutor { } fn build_write_preflight_context(context: &RoundContext) -> ToolUseContext { - ToolUseContext { - tool_call_id: None, - agent_type: Some(context.agent_type.clone()), - 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(), - custom_data: HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), - workspace_services: context.workspace_services.clone(), - } + tool_context_runtime::build_write_preflight_context( + &context.agent_type, + &context.session_id, + &context.dialog_turn_id, + context.workspace.clone(), + context.unlocked_collapsed_tools.clone(), + context.runtime_tool_restrictions.clone(), + context.workspace_services.clone(), + ) } /// Emit event @@ -1624,12 +1620,12 @@ fn detect_placeholder_patterns(content: &str) -> Option<&'static str> { #[cfg(test)] mod tests { - use super::{extract_bitfun_contents, RoundExecutor, StreamProcessor}; + use super::{RoundExecutor, StreamProcessor, extract_bitfun_contents}; + use crate::agentic::WorkspaceBinding; use crate::agentic::core::ToolCall; use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::execution::types::RoundContext; use crate::agentic::tools::ToolRuntimeRestrictions; - use crate::agentic::WorkspaceBinding; use dashmap::DashMap; use std::collections::HashMap; use std::path::PathBuf; @@ -1713,10 +1709,12 @@ mod tests { let _ = std::fs::remove_dir_all(&root); - assert!(error - .as_deref() - .unwrap_or_default() - .contains("already exists")); + assert!( + error + .as_deref() + .unwrap_or_default() + .contains("already exists") + ); } #[tokio::test] diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index 22a5d9a9b..d4611f1cd 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -1,21 +1,7 @@ //! Tool framework - Tool interface definition and execution context use crate::agentic::WorkspaceBinding; -use crate::agentic::coordination::get_global_coordinator; -use crate::agentic::session::EvidenceLedgerCheckpoint; -use crate::agentic::tools::post_call_hooks; -use crate::agentic::tools::restrictions::{ - ToolPathOperation, ToolRuntimeRestrictions, is_local_path_within_root, - is_remote_posix_path_within_root, -}; -use crate::agentic::tools::workspace_paths::{ - build_bitfun_runtime_uri, is_bitfun_runtime_uri, normalize_runtime_relative_path, - parse_bitfun_runtime_uri, -}; +use crate::agentic::tools::restrictions::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; -use crate::infrastructure::get_path_manager_arc; -use crate::service::git::{GitDiffParams, GitService}; -use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; -use crate::service::{WorkspaceRuntimeContext, get_workspace_runtime_service_arc}; use crate::util::errors::BitFunResult; use async_trait::async_trait; pub use bitfun_agent_tools::{ @@ -23,11 +9,9 @@ pub use bitfun_agent_tools::{ ToolExposure, ToolPathBackend, ToolPathResolution, ToolRenderOptions, ToolResult, ToolWorkspaceKind, ValidationResult, }; -use log::warn; use serde_json::Value; -use sha2::{Digest, Sha256}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use tokio_util::sync::CancellationToken; /// Tool use context @@ -88,173 +72,6 @@ impl ToolUseContext { } } - pub fn ws_fs(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceFileSystem> { - self.workspace_services.as_ref().map(|s| s.fs.as_ref()) - } - - pub fn ws_shell(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceShell> { - self.workspace_services.as_ref().map(|s| s.shell.as_ref()) - } - - pub async fn record_light_checkpoint( - &self, - tool_name: &str, - target: &str, - touched_files: Vec, - ) { - let Some(session_id) = self.session_id.as_deref() else { - return; - }; - let Some(turn_id) = self.dialog_turn_id.as_deref() else { - return; - }; - let Some(coordinator) = get_global_coordinator() else { - return; - }; - - let checkpoint = self.build_light_checkpoint(touched_files).await; - coordinator - .get_session_manager() - .record_checkpoint_created(session_id, turn_id, tool_name, target, checkpoint); - } - - async fn build_light_checkpoint(&self, touched_files: Vec) -> EvidenceLedgerCheckpoint { - let mut checkpoint = EvidenceLedgerCheckpoint { - current_branch: None, - dirty_state_summary: "workspace_unavailable".to_string(), - touched_files, - diff_hash: None, - }; - - if self.is_remote() { - checkpoint.dirty_state_summary = - "remote_workspace_git_metadata_unavailable".to_string(); - return checkpoint; - } - - let Some(workspace_root) = self.workspace_root() else { - return checkpoint; - }; - - match GitService::get_status(workspace_root).await { - Ok(status) => { - checkpoint.current_branch = Some(status.current_branch); - checkpoint.dirty_state_summary = format!( - "staged={}, unstaged={}, untracked={}", - status.staged.len(), - status.unstaged.len(), - status.untracked.len() - ); - } - Err(error) => { - checkpoint.dirty_state_summary = format!("git_status_unavailable: {}", error); - } - } - - checkpoint.diff_hash = self - .checkpoint_diff_hash(workspace_root, &checkpoint.touched_files) - .await; - checkpoint - } - - async fn checkpoint_diff_hash( - &self, - workspace_root: &Path, - touched_files: &[String], - ) -> Option { - let files = touched_files - .iter() - .filter_map(|file| git_relative_path(workspace_root, file)) - .collect::>(); - - if files.is_empty() { - return None; - } - - let mut diff = String::new(); - for staged in [false, true] { - let params = GitDiffParams { - files: Some(files.clone()), - staged: Some(staged), - ..Default::default() - }; - match GitService::get_diff(workspace_root, ¶ms).await { - Ok(part) => diff.push_str(&part), - Err(error) => { - warn!( - "Failed to collect checkpoint diff hash: staged={}, error={}", - staged, error - ); - return None; - } - } - } - - if diff.is_empty() { - return None; - } - - Some(hex::encode(Sha256::digest(diff.as_bytes()))) - } - - pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { - self.runtime_tool_restrictions - .ensure_tool_allowed(tool_name) - .map_err(Into::into) - } - - pub fn enforce_path_operation( - &self, - operation: ToolPathOperation, - resolution: &ToolPathResolution, - ) -> BitFunResult<()> { - let allowed_roots = self - .runtime_tool_restrictions - .path_policy - .roots_for(operation); - if allowed_roots.is_empty() { - return Ok(()); - } - - let mut resolved_roots = Vec::with_capacity(allowed_roots.len()); - for root in allowed_roots { - resolved_roots.push(self.resolve_tool_path(root)?); - } - - let mut is_allowed = false; - for root in &resolved_roots { - if root.backend != resolution.backend { - continue; - } - - let matches_root = match resolution.backend { - ToolPathBackend::Local => is_local_path_within_root( - Path::new(&resolution.resolved_path), - Path::new(&root.resolved_path), - )?, - ToolPathBackend::RemoteWorkspace => { - is_remote_posix_path_within_root(&resolution.resolved_path, &root.resolved_path) - } - }; - - if matches_root { - is_allowed = true; - break; - } - } - - if is_allowed { - return Ok(()); - } - - Err(crate::util::errors::BitFunError::validation(format!( - "Path '{}' is not allowed for {}. Allowed roots: {}", - resolution.logical_path, - operation.verb(), - allowed_roots.join(", ") - ))) - } - /// Whether the session primary model accepts image inputs (from tool-definition / pipeline context). /// Defaults to **true** when unset (e.g. API listings without model metadata). pub fn primary_model_supports_image_understanding(&self) -> bool { @@ -263,200 +80,6 @@ impl ToolUseContext { .and_then(|v| v.as_bool()) .unwrap_or(true) } - - /// Resolve a user or model-supplied path for file/shell tools. Uses POSIX semantics when the - /// workspace is remote SSH so Windows-hosted clients still resolve `/home/...` correctly. - pub fn resolve_workspace_tool_path(&self, path: &str) -> BitFunResult { - let workspace_root_owned = self - .workspace - .as_ref() - .map(|w| w.root_path_string()) - .ok_or_else(|| { - crate::util::errors::BitFunError::tool(format!( - "A workspace path is required to resolve tool path: {}", - path - )) - })?; - let resolved_path = crate::agentic::tools::workspace_paths::resolve_workspace_tool_path( - path, - Some(workspace_root_owned.as_str()), - self.is_remote(), - )?; - - // Remote SSH workspaces stay contained to the opened project tree. Local desktop - // sessions may use any host path the OS user can access (Bash already has the same - // reach); optional `path_policy` roots still apply via `enforce_path_operation`. - if self.is_remote() - && !is_remote_posix_path_within_root(&resolved_path, &workspace_root_owned) - { - return Err(crate::util::errors::BitFunError::tool(format!( - "Path '{}' resolves outside current workspace '{}': {}", - path, workspace_root_owned, resolved_path - ))); - } - - Ok(resolved_path) - } - - pub fn current_workspace_runtime_root(&self) -> BitFunResult { - let workspace = self.workspace.as_ref().ok_or_else(|| { - crate::util::errors::BitFunError::tool( - "A workspace is required to resolve runtime artifacts".to_string(), - ) - })?; - - if workspace.is_remote() { - let identity = &workspace.session_identity; - Ok(remote_workspace_runtime_root( - &identity.hostname, - identity.logical_workspace_path(), - )) - } else { - Ok(get_path_manager_arc().project_runtime_root(workspace.root_path())) - } - } - - pub fn current_workspace_scope(&self) -> Option { - self.workspace - .as_ref() - .and_then(|workspace| workspace.workspace_id.clone()) - } - - pub async fn ensure_current_workspace_runtime(&self) -> BitFunResult { - let workspace = self.workspace.as_ref().ok_or_else(|| { - crate::util::errors::BitFunError::tool( - "A workspace is required to ensure runtime artifacts".to_string(), - ) - })?; - - let runtime_service = get_workspace_runtime_service_arc(); - Ok(runtime_service - .ensure_runtime_for_workspace_binding(workspace) - .await? - .context) - } - - pub fn should_emit_runtime_uri(&self) -> bool { - self.is_remote() - } - - pub fn build_runtime_uri(&self, relative_path: &str) -> BitFunResult { - let scope = self - .current_workspace_scope() - .unwrap_or_else(|| "current".to_string()); - build_bitfun_runtime_uri(&scope, &normalize_runtime_relative_path(relative_path)?) - } - - pub fn build_runtime_artifact_reference(&self, relative_path: &str) -> BitFunResult { - let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; - if self.should_emit_runtime_uri() { - return self.build_runtime_uri(&normalized_relative_path); - } - - let mut resolved_path = self.current_workspace_runtime_root()?; - for segment in normalized_relative_path.split('/') { - resolved_path.push(segment); - } - - Ok(resolved_path.to_string_lossy().to_string()) - } - - pub fn build_session_runtime_artifact_reference( - &self, - session_id: &str, - relative_path: &str, - ) -> BitFunResult { - let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; - self.build_runtime_artifact_reference(&format!( - "sessions/{}/{}", - session_id, normalized_relative_path - )) - } - - pub fn current_workspace_session_dir(&self, session_id: &str) -> BitFunResult { - Ok(self - .current_workspace_runtime_root()? - .join("sessions") - .join(session_id)) - } - - pub fn current_workspace_session_tool_results_dir( - &self, - session_id: &str, - ) -> BitFunResult { - Ok(self - .current_workspace_session_dir(session_id)? - .join("tool-results")) - } - - pub fn current_workspace_session_tool_result_path( - &self, - session_id: &str, - file_name: &str, - ) -> BitFunResult { - Ok(self - .current_workspace_session_tool_results_dir(session_id)? - .join(file_name)) - } - - pub fn resolve_tool_path(&self, path: &str) -> BitFunResult { - if is_bitfun_runtime_uri(path) { - let parsed = parse_bitfun_runtime_uri(path)?; - let workspace_scope = self.current_workspace_scope(); - let scope_matches = parsed.workspace_scope == "current" - || workspace_scope.as_deref() == Some(parsed.workspace_scope.as_str()); - if !scope_matches { - return Err(crate::util::errors::BitFunError::tool(format!( - "Runtime URI scope '{}' does not match the current workspace", - parsed.workspace_scope - ))); - } - - let runtime_root = self.current_workspace_runtime_root()?; - let mut resolved_path = runtime_root.clone(); - for segment in parsed.relative_path.split('/') { - resolved_path.push(segment); - } - - let effective_scope = workspace_scope.unwrap_or_else(|| parsed.workspace_scope.clone()); - let logical_path = build_bitfun_runtime_uri(&effective_scope, &parsed.relative_path)?; - - return Ok(ToolPathResolution { - requested_path: path.to_string(), - logical_path, - resolved_path: resolved_path.to_string_lossy().to_string(), - backend: ToolPathBackend::Local, - runtime_scope: Some(effective_scope), - runtime_root: Some(runtime_root), - }); - } - - let resolved_path = self.resolve_workspace_tool_path(path)?; - Ok(ToolPathResolution { - requested_path: path.to_string(), - logical_path: resolved_path.clone(), - resolved_path, - backend: if self.is_remote() { - ToolPathBackend::RemoteWorkspace - } else { - ToolPathBackend::Local - }, - runtime_scope: None, - runtime_root: None, - }) - } - - /// Whether `path` is absolute for the active workspace (POSIX `/` for remote SSH). - pub fn workspace_path_is_effectively_absolute(&self, path: &str) -> bool { - if is_bitfun_runtime_uri(path) { - return true; - } - if self.is_remote() { - crate::agentic::tools::workspace_paths::posix_style_path_is_absolute(path) - } else { - Path::new(path).is_absolute() - } - } } impl PortableToolContextProvider for ToolUseContext { @@ -466,7 +89,7 @@ impl PortableToolContextProvider for ToolUseContext { } #[cfg(test)] -mod path_resolution_tests { +mod context_facts_tests { use super::ToolUseContext; use crate::agentic::WorkspaceBinding; use crate::agentic::tools::{ @@ -492,22 +115,6 @@ mod path_resolution_tests { } } - fn context_without_workspace() -> ToolUseContext { - ToolUseContext { - tool_call_id: None, - agent_type: None, - session_id: None, - dialog_turn_id: None, - workspace: None, - unlocked_collapsed_tools: Vec::new(), - custom_data: HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, - } - } - #[test] fn tool_context_facts_preserve_portable_fields_without_runtime_handles() { let context = ToolUseContext { @@ -654,89 +261,6 @@ mod path_resolution_tests { assert_eq!(facts.workspace_kind, Some(ToolWorkspaceKind::Local)); assert_eq!(facts.workspace_root.as_deref(), Some("/repo/project")); } - - #[test] - fn workspace_path_resolution_allows_absolute_paths_outside_local_workspace() { - let context = local_context("/repo/project"); - - let resolved = context - .resolve_workspace_tool_path("/tmp/pr_body.md") - .expect("local sessions may resolve paths outside the workspace root"); - - assert_eq!(PathBuf::from(resolved), PathBuf::from("/tmp/pr_body.md")); - } - - #[test] - fn workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace() { - let session_identity = - workspace_session_identity("/home/wsp/projects/test", Some("conn-1"), Some("ssh.dev")) - .expect("remote identity"); - let context = ToolUseContext { - tool_call_id: None, - agent_type: None, - session_id: None, - dialog_turn_id: None, - workspace: Some(WorkspaceBinding::new_remote( - None, - PathBuf::from("/home/wsp/projects/test"), - "conn-1".to_string(), - "Dev SSH".to_string(), - session_identity, - )), - unlocked_collapsed_tools: Vec::new(), - custom_data: HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, - }; - - let err = context - .resolve_workspace_tool_path("/tmp/pr_body.md") - .expect_err("remote sessions must stay within the workspace root"); - - assert!(err.to_string().contains("outside current workspace")); - } - - #[test] - fn workspace_path_resolution_rejects_root_without_workspace() { - let context = context_without_workspace(); - - let err = context - .resolve_workspace_tool_path("/") - .expect_err("workspace tools must not scan the host root without a workspace"); - - assert!(err.to_string().contains("workspace path is required")); - } - - #[test] - fn workspace_path_resolution_allows_paths_inside_local_workspace() { - let context = local_context("/repo/project"); - - let resolved = context - .resolve_workspace_tool_path("/repo/project/src/main.rs") - .expect("absolute paths inside the workspace remain valid"); - - assert_eq!( - PathBuf::from(resolved), - PathBuf::from("/repo/project/src/main.rs") - ); - } -} - -fn git_relative_path(workspace_root: &Path, path: &str) -> Option { - if is_bitfun_runtime_uri(path) { - return None; - } - - let path = Path::new(path); - let relative = if path.is_absolute() { - path.strip_prefix(workspace_root).ok()? - } else { - path - }; - - Some(relative.to_string_lossy().replace('\\', "/")) } /// Tool trait @@ -896,23 +420,13 @@ pub trait Tool: Send + Sync { /// execution to [`call_impl`], so most tools should override `call_impl` /// instead of overriding this method directly. async fn call(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { - let result = if let Some(cancellation_token) = context.cancellation_token.as_ref() { - tokio::select! { - result = self.call_impl(input, context) => { - result - } - - _ = cancellation_token.cancelled() => { - Err(crate::util::errors::BitFunError::Cancelled("Tool execution cancelled".to_string())) - } - } - } else { - self.call_impl(input, context).await - }; - if result.is_ok() { - post_call_hooks::record_successful_tool_call(self.name(), input, context); - } - result + crate::agentic::tools::tool_context_runtime::call_with_tool_runtime_hooks( + self.name(), + input, + context, + self.call_impl(input, context), + ) + .await } } diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index 28b25117d..39cbe93a3 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod product_runtime; pub mod registry; pub mod restrictions; pub(crate) mod tool_adapter; +pub(crate) mod tool_context_runtime; pub mod user_input_manager; pub mod workspace_paths; pub use bitfun_agent_tools::input_validator; @@ -26,7 +27,7 @@ pub use framework::{ pub use image_context::{ImageContextData, ImageContextProvider, ImageContextProviderRef}; pub use input_validator::InputValidator; pub use manifest_resolver::{ - resolve_tool_manifest, resolve_visible_tools, ResolvedToolManifest, ResolvedVisibleTools, + ResolvedToolManifest, ResolvedVisibleTools, resolve_tool_manifest, resolve_visible_tools, }; pub use pipeline::*; pub use registry::{ diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 719b5e3be..b7fba2e81 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -6,11 +6,11 @@ use super::state_manager::ToolStateManager; use super::types::*; use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; -use crate::agentic::deep_review::tool_context; use crate::agentic::events::types::ToolEventData; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ToolResult as FrameworkToolResult, ToolUseContext}; use crate::agentic::tools::registry::ToolRegistry; +use crate::agentic::tools::tool_context_runtime; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_tools::{ @@ -19,7 +19,7 @@ use bitfun_agent_tools::{ use dashmap::DashMap; use futures::future::join_all; use log::{debug, error, info, warn}; -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; use std::sync::Arc; use std::time::{Instant, SystemTime}; use tokio::sync::{RwLock as TokioRwLock, oneshot}; @@ -1259,77 +1259,11 @@ impl ToolPipeline { task: &ToolTask, cancellation_token: CancellationToken, ) -> ToolUseContext { - ToolUseContext { - tool_call_id: Some(task.tool_call.tool_id.clone()), - agent_type: Some(task.context.agent_type.clone()), - session_id: Some(task.context.session_id.clone()), - dialog_turn_id: Some(task.context.dialog_turn_id.clone()), - workspace: task.context.workspace.clone(), - unlocked_collapsed_tools: task.context.unlocked_collapsed_tools.clone(), - custom_data: { - let mut map = HashMap::new(); - - if let Some(turn_index) = task.context.context_vars.get("turn_index") { - if let Ok(n) = turn_index.parse::() { - map.insert("turn_index".to_string(), serde_json::json!(n)); - } - } - - if let Some(provider) = task.context.context_vars.get("primary_model_provider") { - if !provider.is_empty() { - map.insert( - "primary_model_provider".to_string(), - serde_json::json!(provider), - ); - } - } - if let Some(supports_images) = task - .context - .context_vars - .get("primary_model_supports_image_understanding") - { - if let Ok(flag) = supports_images.parse::() { - map.insert( - "primary_model_supports_image_understanding".to_string(), - serde_json::json!(flag), - ); - } - } - if let Some(write_tool_mode) = task.context.context_vars.get("write_tool_mode") { - if !write_tool_mode.is_empty() { - map.insert( - "write_tool_mode".to_string(), - serde_json::json!(write_tool_mode), - ); - } - } - if let Some(acp_transport) = task.context.context_vars.get("acp_transport") { - if let Ok(flag) = acp_transport.parse::() { - map.insert("acp_transport".to_string(), serde_json::json!(flag)); - } - } - let deep_review_parent_context = - task.context - .subagent_parent_info - .as_ref() - .map(|parent_info| tool_context::DeepReviewToolParentContext { - tool_call_id: parent_info.tool_call_id.as_str(), - session_id: parent_info.session_id.as_str(), - dialog_turn_id: parent_info.dialog_turn_id.as_str(), - }); - tool_context::append_tool_use_context_data( - &task.context.context_vars, - deep_review_parent_context, - &mut map, - ); - - map - }, - computer_use_host: self.computer_use_host.clone(), - cancellation_token: Some(cancellation_token), - runtime_tool_restrictions: task.context.runtime_tool_restrictions.clone(), - workspace_services: task.context.workspace_services.clone(), - } + tool_context_runtime::build_tool_use_context_for_task( + task, + self.computer_use_host.clone(), + cancellation_token, + ) } /// Handle streaming results diff --git a/src/crates/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/core/src/agentic/tools/tool_context_runtime.rs new file mode 100644 index 000000000..89512ef77 --- /dev/null +++ b/src/crates/core/src/agentic/tools/tool_context_runtime.rs @@ -0,0 +1,1044 @@ +//! Core-owned runtime bindings for `ToolUseContext`. +//! +//! This module intentionally keeps service handles, workspace runtime lookup, +//! path enforcement, cancellation/post-call hooks, and checkpoint recording in +//! core. The portable facts projection stays in `framework.rs` and +//! `bitfun-agent-tools`. + +use crate::agentic::WorkspaceBinding; +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::deep_review::tool_context; +use crate::agentic::session::EvidenceLedgerCheckpoint; +use crate::agentic::tools::ToolRuntimeRestrictions; +use crate::agentic::tools::computer_use_host::ComputerUseHostRef; +use crate::agentic::tools::framework::{ + ToolPathBackend, ToolPathResolution, ToolResult, ToolUseContext, +}; +use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolTask}; +use crate::agentic::tools::post_call_hooks; +use crate::agentic::tools::restrictions::{ + ToolPathOperation, is_local_path_within_root, is_remote_posix_path_within_root, +}; +use crate::agentic::tools::workspace_paths::{ + build_bitfun_runtime_uri, is_bitfun_runtime_uri, normalize_runtime_relative_path, + parse_bitfun_runtime_uri, +}; +use crate::agentic::workspace::WorkspaceServices; +use crate::infrastructure::get_path_manager_arc; +use crate::service::git::{GitDiffParams, GitService}; +use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; +use crate::service::{WorkspaceRuntimeContext, get_workspace_runtime_service_arc}; +use crate::util::errors::{BitFunError, BitFunResult}; +use log::warn; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use tokio_util::sync::CancellationToken; + +pub(crate) async fn call_with_tool_runtime_hooks( + tool_name: &str, + input: &Value, + context: &ToolUseContext, + call_impl: impl Future>>, +) -> BitFunResult> { + let result = if let Some(cancellation_token) = context.cancellation_token.as_ref() { + tokio::select! { + result = call_impl => { + result + } + + _ = cancellation_token.cancelled() => { + Err(BitFunError::Cancelled("Tool execution cancelled".to_string())) + } + } + } else { + call_impl.await + }; + + if result.is_ok() { + post_call_hooks::record_successful_tool_call(tool_name, input, context); + } + + result +} + +pub(crate) fn build_tool_use_context_for_task( + task: &ToolTask, + computer_use_host: Option, + cancellation_token: CancellationToken, +) -> ToolUseContext { + ToolUseContext { + tool_call_id: Some(task.tool_call.tool_id.clone()), + agent_type: Some(task.context.agent_type.clone()), + session_id: Some(task.context.session_id.clone()), + dialog_turn_id: Some(task.context.dialog_turn_id.clone()), + workspace: task.context.workspace.clone(), + unlocked_collapsed_tools: task.context.unlocked_collapsed_tools.clone(), + custom_data: build_tool_context_custom_data(&task.context), + computer_use_host, + cancellation_token: Some(cancellation_token), + runtime_tool_restrictions: task.context.runtime_tool_restrictions.clone(), + workspace_services: task.context.workspace_services.clone(), + } +} + +pub(crate) fn build_tool_description_context( + agent_type: &str, + workspace: Option<&WorkspaceBinding>, + workspace_services: Option<&WorkspaceServices>, + primary_supports_image_understanding: bool, + context_vars: &HashMap, +) -> ToolUseContext { + let mut custom_data = HashMap::new(); + custom_data.insert( + "primary_model_supports_image_understanding".to_string(), + Value::Bool(primary_supports_image_understanding), + ); + for (key, value) in context_vars { + custom_data.insert(key.clone(), Value::String(value.clone())); + } + + ToolUseContext { + tool_call_id: None, + agent_type: Some(agent_type.to_string()), + session_id: None, + dialog_turn_id: None, + workspace: workspace.cloned(), + unlocked_collapsed_tools: Vec::new(), + custom_data, + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: workspace_services.cloned(), + } +} + +pub(crate) fn build_write_preflight_context( + agent_type: &str, + session_id: &str, + dialog_turn_id: &str, + workspace: Option, + unlocked_collapsed_tools: Vec, + runtime_tool_restrictions: ToolRuntimeRestrictions, + workspace_services: Option, +) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: Some(agent_type.to_string()), + session_id: Some(session_id.to_string()), + dialog_turn_id: Some(dialog_turn_id.to_string()), + workspace, + unlocked_collapsed_tools, + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions, + workspace_services, + } +} + +fn build_tool_context_custom_data(context: &ToolExecutionContext) -> HashMap { + let mut map = HashMap::new(); + + if let Some(turn_index) = context.context_vars.get("turn_index") { + if let Ok(n) = turn_index.parse::() { + map.insert("turn_index".to_string(), serde_json::json!(n)); + } + } + + if let Some(provider) = context.context_vars.get("primary_model_provider") { + if !provider.is_empty() { + map.insert( + "primary_model_provider".to_string(), + serde_json::json!(provider), + ); + } + } + if let Some(supports_images) = context + .context_vars + .get("primary_model_supports_image_understanding") + { + if let Ok(flag) = supports_images.parse::() { + map.insert( + "primary_model_supports_image_understanding".to_string(), + serde_json::json!(flag), + ); + } + } + if let Some(write_tool_mode) = context.context_vars.get("write_tool_mode") { + if !write_tool_mode.is_empty() { + map.insert( + "write_tool_mode".to_string(), + serde_json::json!(write_tool_mode), + ); + } + } + if let Some(acp_transport) = context.context_vars.get("acp_transport") { + if let Ok(flag) = acp_transport.parse::() { + map.insert("acp_transport".to_string(), serde_json::json!(flag)); + } + } + + let deep_review_parent_context = context.subagent_parent_info.as_ref().map(|parent_info| { + tool_context::DeepReviewToolParentContext { + tool_call_id: parent_info.tool_call_id.as_str(), + session_id: parent_info.session_id.as_str(), + dialog_turn_id: parent_info.dialog_turn_id.as_str(), + } + }); + tool_context::append_tool_use_context_data( + &context.context_vars, + deep_review_parent_context, + &mut map, + ); + + map +} + +impl ToolUseContext { + pub fn ws_fs(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceFileSystem> { + self.workspace_services.as_ref().map(|s| s.fs.as_ref()) + } + + pub fn ws_shell(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceShell> { + self.workspace_services.as_ref().map(|s| s.shell.as_ref()) + } + + pub async fn record_light_checkpoint( + &self, + tool_name: &str, + target: &str, + touched_files: Vec, + ) { + let Some(session_id) = self.session_id.as_deref() else { + return; + }; + let Some(turn_id) = self.dialog_turn_id.as_deref() else { + return; + }; + let Some(coordinator) = get_global_coordinator() else { + return; + }; + + let checkpoint = self.build_light_checkpoint(touched_files).await; + coordinator + .get_session_manager() + .record_checkpoint_created(session_id, turn_id, tool_name, target, checkpoint); + } + + async fn build_light_checkpoint(&self, touched_files: Vec) -> EvidenceLedgerCheckpoint { + let mut checkpoint = EvidenceLedgerCheckpoint { + current_branch: None, + dirty_state_summary: "workspace_unavailable".to_string(), + touched_files, + diff_hash: None, + }; + + if self.is_remote() { + checkpoint.dirty_state_summary = + "remote_workspace_git_metadata_unavailable".to_string(); + return checkpoint; + } + + let Some(workspace_root) = self.workspace_root() else { + return checkpoint; + }; + + match GitService::get_status(workspace_root).await { + Ok(status) => { + checkpoint.current_branch = Some(status.current_branch); + checkpoint.dirty_state_summary = format!( + "staged={}, unstaged={}, untracked={}", + status.staged.len(), + status.unstaged.len(), + status.untracked.len() + ); + } + Err(error) => { + checkpoint.dirty_state_summary = format!("git_status_unavailable: {}", error); + } + } + + checkpoint.diff_hash = self + .checkpoint_diff_hash(workspace_root, &checkpoint.touched_files) + .await; + checkpoint + } + + async fn checkpoint_diff_hash( + &self, + workspace_root: &Path, + touched_files: &[String], + ) -> Option { + let files = touched_files + .iter() + .filter_map(|file| git_relative_path(workspace_root, file)) + .collect::>(); + + if files.is_empty() { + return None; + } + + let mut diff = String::new(); + for staged in [false, true] { + let params = GitDiffParams { + files: Some(files.clone()), + staged: Some(staged), + ..Default::default() + }; + match GitService::get_diff(workspace_root, ¶ms).await { + Ok(part) => diff.push_str(&part), + Err(error) => { + warn!( + "Failed to collect checkpoint diff hash: staged={}, error={}", + staged, error + ); + return None; + } + } + } + + if diff.is_empty() { + return None; + } + + Some(hex::encode(Sha256::digest(diff.as_bytes()))) + } + + pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { + self.runtime_tool_restrictions + .ensure_tool_allowed(tool_name) + .map_err(Into::into) + } + + pub fn enforce_path_operation( + &self, + operation: ToolPathOperation, + resolution: &ToolPathResolution, + ) -> BitFunResult<()> { + let allowed_roots = self + .runtime_tool_restrictions + .path_policy + .roots_for(operation); + if allowed_roots.is_empty() { + return Ok(()); + } + + let mut resolved_roots = Vec::with_capacity(allowed_roots.len()); + for root in allowed_roots { + resolved_roots.push(self.resolve_tool_path(root)?); + } + + let mut is_allowed = false; + for root in &resolved_roots { + if root.backend != resolution.backend { + continue; + } + + let matches_root = match resolution.backend { + ToolPathBackend::Local => is_local_path_within_root( + Path::new(&resolution.resolved_path), + Path::new(&root.resolved_path), + )?, + ToolPathBackend::RemoteWorkspace => { + is_remote_posix_path_within_root(&resolution.resolved_path, &root.resolved_path) + } + }; + + if matches_root { + is_allowed = true; + break; + } + } + + if is_allowed { + return Ok(()); + } + + Err(BitFunError::validation(format!( + "Path '{}' is not allowed for {}. Allowed roots: {}", + resolution.logical_path, + operation.verb(), + allowed_roots.join(", ") + ))) + } + + /// Resolve a user or model-supplied path for file/shell tools. Uses POSIX semantics when the + /// workspace is remote SSH so Windows-hosted clients still resolve `/home/...` correctly. + pub fn resolve_workspace_tool_path(&self, path: &str) -> BitFunResult { + let workspace_root_owned = self + .workspace + .as_ref() + .map(|w| w.root_path_string()) + .ok_or_else(|| { + BitFunError::tool(format!( + "A workspace path is required to resolve tool path: {}", + path + )) + })?; + let resolved_path = crate::agentic::tools::workspace_paths::resolve_workspace_tool_path( + path, + Some(workspace_root_owned.as_str()), + self.is_remote(), + )?; + + // Remote SSH workspaces stay contained to the opened project tree. Local desktop + // sessions may use any host path the OS user can access (Bash already has the same + // reach); optional `path_policy` roots still apply via `enforce_path_operation`. + if self.is_remote() + && !is_remote_posix_path_within_root(&resolved_path, &workspace_root_owned) + { + return Err(BitFunError::tool(format!( + "Path '{}' resolves outside current workspace '{}': {}", + path, workspace_root_owned, resolved_path + ))); + } + + Ok(resolved_path) + } + + pub fn current_workspace_runtime_root(&self) -> BitFunResult { + let workspace = self.workspace.as_ref().ok_or_else(|| { + BitFunError::tool("A workspace is required to resolve runtime artifacts".to_string()) + })?; + + if workspace.is_remote() { + let identity = &workspace.session_identity; + Ok(remote_workspace_runtime_root( + &identity.hostname, + identity.logical_workspace_path(), + )) + } else { + Ok(get_path_manager_arc().project_runtime_root(workspace.root_path())) + } + } + + pub fn current_workspace_scope(&self) -> Option { + self.workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.clone()) + } + + pub async fn ensure_current_workspace_runtime(&self) -> BitFunResult { + let workspace = self.workspace.as_ref().ok_or_else(|| { + BitFunError::tool("A workspace is required to ensure runtime artifacts".to_string()) + })?; + + let runtime_service = get_workspace_runtime_service_arc(); + Ok(runtime_service + .ensure_runtime_for_workspace_binding(workspace) + .await? + .context) + } + + pub fn should_emit_runtime_uri(&self) -> bool { + self.is_remote() + } + + pub fn build_runtime_uri(&self, relative_path: &str) -> BitFunResult { + let scope = self + .current_workspace_scope() + .unwrap_or_else(|| "current".to_string()); + build_bitfun_runtime_uri(&scope, &normalize_runtime_relative_path(relative_path)?) + } + + pub fn build_runtime_artifact_reference(&self, relative_path: &str) -> BitFunResult { + let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; + if self.should_emit_runtime_uri() { + return self.build_runtime_uri(&normalized_relative_path); + } + + let mut resolved_path = self.current_workspace_runtime_root()?; + for segment in normalized_relative_path.split('/') { + resolved_path.push(segment); + } + + Ok(resolved_path.to_string_lossy().to_string()) + } + + pub fn build_session_runtime_artifact_reference( + &self, + session_id: &str, + relative_path: &str, + ) -> BitFunResult { + let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; + self.build_runtime_artifact_reference(&format!( + "sessions/{}/{}", + session_id, normalized_relative_path + )) + } + + pub fn current_workspace_session_dir(&self, session_id: &str) -> BitFunResult { + Ok(self + .current_workspace_runtime_root()? + .join("sessions") + .join(session_id)) + } + + pub fn current_workspace_session_tool_results_dir( + &self, + session_id: &str, + ) -> BitFunResult { + Ok(self + .current_workspace_session_dir(session_id)? + .join("tool-results")) + } + + pub fn current_workspace_session_tool_result_path( + &self, + session_id: &str, + file_name: &str, + ) -> BitFunResult { + Ok(self + .current_workspace_session_tool_results_dir(session_id)? + .join(file_name)) + } + + pub fn resolve_tool_path(&self, path: &str) -> BitFunResult { + if is_bitfun_runtime_uri(path) { + let parsed = parse_bitfun_runtime_uri(path)?; + let workspace_scope = self.current_workspace_scope(); + let scope_matches = parsed.workspace_scope == "current" + || workspace_scope.as_deref() == Some(parsed.workspace_scope.as_str()); + if !scope_matches { + return Err(BitFunError::tool(format!( + "Runtime URI scope '{}' does not match the current workspace", + parsed.workspace_scope + ))); + } + + let runtime_root = self.current_workspace_runtime_root()?; + let mut resolved_path = runtime_root.clone(); + for segment in parsed.relative_path.split('/') { + resolved_path.push(segment); + } + + let effective_scope = workspace_scope.unwrap_or_else(|| parsed.workspace_scope.clone()); + let logical_path = build_bitfun_runtime_uri(&effective_scope, &parsed.relative_path)?; + + return Ok(ToolPathResolution { + requested_path: path.to_string(), + logical_path, + resolved_path: resolved_path.to_string_lossy().to_string(), + backend: ToolPathBackend::Local, + runtime_scope: Some(effective_scope), + runtime_root: Some(runtime_root), + }); + } + + let resolved_path = self.resolve_workspace_tool_path(path)?; + Ok(ToolPathResolution { + requested_path: path.to_string(), + logical_path: resolved_path.clone(), + resolved_path, + backend: if self.is_remote() { + ToolPathBackend::RemoteWorkspace + } else { + ToolPathBackend::Local + }, + runtime_scope: None, + runtime_root: None, + }) + } + + /// Whether `path` is absolute for the active workspace (POSIX `/` for remote SSH). + pub fn workspace_path_is_effectively_absolute(&self, path: &str) -> bool { + if is_bitfun_runtime_uri(path) { + return true; + } + if self.is_remote() { + crate::agentic::tools::workspace_paths::posix_style_path_is_absolute(path) + } else { + Path::new(path).is_absolute() + } + } +} + +fn git_relative_path(workspace_root: &Path, path: &str) -> Option { + if is_bitfun_runtime_uri(path) { + return None; + } + + let path = Path::new(path); + let relative = if path.is_absolute() { + path.strip_prefix(workspace_root).ok()? + } else { + path + }; + + Some(relative.to_string_lossy().replace('\\', "/")) +} + +#[cfg(test)] +mod path_resolution_tests { + use crate::agentic::WorkspaceBinding; + use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::tools::{ToolPathOperation, ToolPathPolicy, ToolRuntimeRestrictions}; + use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use std::collections::HashMap; + use std::path::PathBuf; + + fn local_context(root: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, PathBuf::from(root))), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + fn remote_context(root: &str, workspace_id: Option) -> ToolUseContext { + let session_identity = workspace_session_identity(root, Some("conn-1"), Some("ssh.dev")) + .expect("remote identity"); + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new_remote( + workspace_id, + PathBuf::from(root), + "conn-1".to_string(), + "Dev SSH".to_string(), + session_identity, + )), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + fn context_with_restrictions( + root: &str, + runtime_tool_restrictions: ToolRuntimeRestrictions, + ) -> ToolUseContext { + ToolUseContext { + runtime_tool_restrictions, + ..local_context(root) + } + } + + fn context_without_workspace() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + #[test] + fn workspace_path_resolution_allows_absolute_paths_outside_local_workspace() { + let context = local_context("/repo/project"); + + let resolved = context + .resolve_workspace_tool_path("/tmp/pr_body.md") + .expect("local sessions may resolve paths outside the workspace root"); + + assert_eq!(PathBuf::from(resolved), PathBuf::from("/tmp/pr_body.md")); + } + + #[test] + fn workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace() { + let context = remote_context("/home/wsp/projects/test", None); + + let err = context + .resolve_workspace_tool_path("/tmp/pr_body.md") + .expect_err("remote sessions must stay within the workspace root"); + + assert!(err.to_string().contains("outside current workspace")); + } + + #[test] + fn workspace_path_resolution_rejects_root_without_workspace() { + let context = context_without_workspace(); + + let err = context + .resolve_workspace_tool_path("/") + .expect_err("workspace tools must not scan the host root without a workspace"); + + assert!(err.to_string().contains("workspace path is required")); + } + + #[test] + fn workspace_path_resolution_allows_paths_inside_local_workspace() { + let context = local_context("/repo/project"); + + let resolved = context + .resolve_workspace_tool_path("/repo/project/src/main.rs") + .expect("absolute paths inside the workspace remain valid"); + + assert_eq!( + PathBuf::from(resolved), + PathBuf::from("/repo/project/src/main.rs") + ); + } + + #[test] + fn remote_runtime_artifact_reference_uses_runtime_uri_scope() { + let context = remote_context("/home/wsp/projects/test", Some("workspace-123".to_string())); + + let reference = context + .build_runtime_artifact_reference(r"plans\demo.plan.md") + .expect("remote runtime artifacts should use URI references"); + + assert_eq!( + reference, + "bitfun://runtime/workspace-123/plans/demo.plan.md" + ); + } + + #[test] + fn runtime_uri_resolution_rejects_different_workspace_scope() { + let context = remote_context("/home/wsp/projects/test", Some("workspace-123".to_string())); + + let err = context + .resolve_tool_path("bitfun://runtime/workspace-456/plans/demo.plan.md") + .expect_err("runtime artifact scopes must match the active workspace"); + + assert!( + err.to_string() + .contains("does not match the current workspace") + ); + } + + #[test] + fn workspace_absolute_detection_uses_remote_posix_semantics() { + let context = remote_context("/home/wsp/projects/test", None); + + assert!( + context.workspace_path_is_effectively_absolute("/home/wsp/projects/test/src/lib.rs") + ); + assert!(!context.workspace_path_is_effectively_absolute("src/lib.rs")); + } + + #[test] + fn path_policy_allows_only_configured_local_roots() { + let temp_root = std::env::temp_dir().join(format!( + "bitfun-tool-context-policy-{}", + uuid::Uuid::new_v4() + )); + let allowed_root = temp_root.join("allowed"); + std::fs::create_dir_all(&allowed_root).expect("create allowed root"); + let context = context_with_restrictions( + temp_root.to_string_lossy().as_ref(), + ToolRuntimeRestrictions { + path_policy: ToolPathPolicy { + write_roots: vec![allowed_root.to_string_lossy().to_string()], + ..Default::default() + }, + ..Default::default() + }, + ); + + let allowed = context + .resolve_tool_path(&allowed_root.join("file.txt").to_string_lossy()) + .expect("allowed path should resolve"); + context + .enforce_path_operation(ToolPathOperation::Write, &allowed) + .expect("path within configured root should be allowed"); + + let blocked = context + .resolve_tool_path(&temp_root.join("blocked/file.txt").to_string_lossy()) + .expect("blocked path should still resolve before policy enforcement"); + let err = context + .enforce_path_operation(ToolPathOperation::Write, &blocked) + .expect_err("path outside configured root should be blocked"); + + assert!(err.to_string().contains("is not allowed for write")); + + let _ = std::fs::remove_dir_all(&temp_root); + } +} + +#[cfg(test)] +mod call_runtime_tests { + use super::call_with_tool_runtime_hooks; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::tools::framework::{ToolResult, ToolUseContext}; + use crate::util::errors::{BitFunError, BitFunResult}; + use serde_json::json; + use std::collections::HashMap; + use tokio::time::{Duration, sleep}; + use tokio_util::sync::CancellationToken; + + fn context_with_cancellation(cancellation_token: CancellationToken) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: Some(cancellation_token), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + #[tokio::test] + async fn tool_call_runtime_hook_returns_cancelled_before_impl_completes() { + let cancellation_token = CancellationToken::new(); + cancellation_token.cancel(); + let context = context_with_cancellation(cancellation_token); + + let result = call_with_tool_runtime_hooks("Read", &json!({}), &context, async { + sleep(Duration::from_secs(30)).await; + Ok(vec![ToolResult::ok(json!({ "unexpected": true }), None)]) + }) + .await; + + assert!( + matches!(result, Err(BitFunError::Cancelled(message)) if message == "Tool execution cancelled") + ); + } + + #[tokio::test] + async fn tool_call_runtime_hook_preserves_success_result_without_cancellation() { + let context = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + }; + + let result: BitFunResult> = + call_with_tool_runtime_hooks("Read", &json!({}), &context, async { + Ok(vec![ToolResult::ok( + json!({ "ok": true }), + Some("ok".to_string()), + )]) + }) + .await; + + let result = result.expect("tool result should pass through"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].content()["ok"], true); + } +} + +#[cfg(test)] +mod context_builder_tests { + use super::{build_tool_description_context, build_write_preflight_context}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use serde_json::json; + use std::collections::{BTreeSet, HashMap}; + + #[test] + fn tool_description_context_preserves_manifest_custom_data_shape() { + let mut context_vars = HashMap::new(); + context_vars.insert("write_tool_mode".to_string(), "inline_content".to_string()); + context_vars.insert( + "primary_model_supports_image_understanding".to_string(), + "false".to_string(), + ); + + let context = build_tool_description_context("coding", None, None, true, &context_vars); + + assert_eq!(context.agent_type.as_deref(), Some("coding")); + assert!(context.tool_call_id.is_none()); + 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.cancellation_token.is_none()); + assert!(context.workspace_services.is_none()); + assert!(context.runtime_tool_restrictions.is_tool_allowed("Write")); + assert_eq!( + context.custom_data["primary_model_supports_image_understanding"], + json!("false") + ); + assert_eq!( + context.custom_data["write_tool_mode"], + json!("inline_content") + ); + } + + #[test] + fn write_preflight_context_preserves_minimal_runtime_fields() { + let restrictions = ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::from(["Write".to_string()]), + denied_tool_names: BTreeSet::from(["Delete".to_string()]), + path_policy: Default::default(), + }; + + let context = build_write_preflight_context( + "coding", + "session-1", + "turn-1", + None, + vec!["Write".to_string()], + restrictions, + None, + ); + + assert_eq!(context.agent_type.as_deref(), Some("coding")); + 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!["Write"]); + assert!(context.tool_call_id.is_none()); + assert!(context.custom_data.is_empty()); + assert!(context.cancellation_token.is_none()); + assert!(context.workspace_services.is_none()); + assert!(context.runtime_tool_restrictions.is_tool_allowed("Write")); + assert!(!context.runtime_tool_restrictions.is_tool_allowed("Delete")); + } +} + +#[cfg(test)] +mod task_context_tests { + use super::build_tool_use_context_for_task; + use crate::agentic::core::ToolCall; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::tools::pipeline::{ + SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolTask, + }; + use serde_json::json; + use std::collections::{BTreeSet, HashMap}; + use tokio_util::sync::CancellationToken; + + fn task_with_context_vars() -> ToolTask { + let mut context_vars = HashMap::new(); + context_vars.insert("turn_index".to_string(), "7".to_string()); + context_vars.insert("primary_model_provider".to_string(), "openai".to_string()); + context_vars.insert( + "primary_model_supports_image_understanding".to_string(), + "true".to_string(), + ); + context_vars.insert("write_tool_mode".to_string(), "inline_content".to_string()); + context_vars.insert("acp_transport".to_string(), "true".to_string()); + context_vars.insert( + "deep_review_run_manifest".to_string(), + r#"{"run_id":"run-1"}"#.to_string(), + ); + context_vars.insert( + "deep_review_subagent_role".to_string(), + "reviewer".to_string(), + ); + context_vars.insert( + "deep_review_subagent_type".to_string(), + "ReviewSecurity".to_string(), + ); + + ToolTask::new( + ToolCall { + tool_id: "tool_context_1".to_string(), + tool_name: "WebFetch".to_string(), + arguments: json!({ "url": "https://example.com" }), + raw_arguments: None, + is_error: false, + recovered_from_truncation: false, + }, + ToolExecutionContext { + session_id: "session_1".to_string(), + dialog_turn_id: "turn_1".to_string(), + round_id: "round_1".to_string(), + agent_type: "agent".to_string(), + workspace: None, + context_vars, + subagent_parent_info: Some(SubagentParentInfo { + tool_call_id: "parent_tool".to_string(), + session_id: "parent_session".to_string(), + dialog_turn_id: "parent_turn".to_string(), + }), + collapsed_tools: vec!["WebFetch".to_string()], + unlocked_collapsed_tools: vec!["WebFetch".to_string()], + allowed_tools: vec!["WebFetch".to_string()], + runtime_tool_restrictions: ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::from(["WebFetch".to_string()]), + denied_tool_names: BTreeSet::from(["Bash".to_string()]), + path_policy: Default::default(), + }, + steering_interrupt: None, + workspace_services: None, + }, + ToolExecutionOptions::default(), + ) + } + + #[test] + fn tool_task_context_materialization_preserves_runtime_fields() { + let task = task_with_context_vars(); + + let context = build_tool_use_context_for_task(&task, None, CancellationToken::new()); + + assert_eq!(context.tool_call_id.as_deref(), Some("tool_context_1")); + 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!(context.cancellation_token.is_some()); + assert!( + context + .runtime_tool_restrictions + .is_tool_allowed("WebFetch") + ); + assert!(!context.runtime_tool_restrictions.is_tool_allowed("Bash")); + assert_eq!(context.custom_data["turn_index"], json!(7)); + assert_eq!( + context.custom_data["primary_model_provider"], + json!("openai") + ); + assert_eq!( + context.custom_data["primary_model_supports_image_understanding"], + json!(true) + ); + assert_eq!( + context.custom_data["write_tool_mode"], + json!("inline_content") + ); + assert_eq!(context.custom_data["acp_transport"], json!(true)); + assert_eq!( + context.custom_data["deep_review_run_manifest"], + json!({ "run_id": "run-1" }) + ); + assert_eq!( + context.custom_data["deep_review_parent_tool_call_id"], + json!("parent_tool") + ); + assert_eq!( + context.custom_data["deep_review_parent_session_id"], + json!("parent_session") + ); + assert_eq!( + context.custom_data["deep_review_parent_dialog_turn_id"], + json!("parent_turn") + ); + + let facts = context.to_tool_context_facts(); + let value = serde_json::to_value(&facts).expect("serialize context facts"); + assert_eq!(value["toolCallId"], "tool_context_1"); + assert_eq!(value["sessionId"], "session_1"); + assert!(value.get("unlockedCollapsedTools").is_none()); + assert!(value.get("customData").is_none()); + assert!(value.get("cancellationToken").is_none()); + } +}