diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 1e7fb63ca..2fab6a09a 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -227,6 +227,88 @@ impl Drop for CancelTokenGuard { } } +#[derive(Clone)] +struct ActiveSubagentExecution { + parent_session_id: String, + parent_dialog_turn_id: String, + subagent_session_id: String, + subagent_dialog_turn_id: String, + cancel_token: CancellationToken, + abort_handle: tokio::task::AbortHandle, +} + +/// Ensures orphaned subagent work is stopped when the parent tool await is dropped. +struct SubagentExecutionScope { + execution_engine: Arc, + tool_pipeline: Arc, + session_manager: Arc, + active_subagent_executions: Arc>, + subagent_session_id: String, + subagent_dialog_turn_id: String, + subagent_cancel_token: CancellationToken, + abort_handle: tokio::task::AbortHandle, + disarmed: bool, +} + +impl SubagentExecutionScope { + fn disarm(&mut self) { + self.disarmed = true; + self.active_subagent_executions + .remove(&self.subagent_session_id); + } +} + +impl Drop for SubagentExecutionScope { + fn drop(&mut self) { + if self.disarmed { + return; + } + + warn!( + "Subagent execution scope dropped without normal completion; stopping orphaned subagent: session_id={}, dialog_turn_id={}", + self.subagent_session_id, self.subagent_dialog_turn_id + ); + + self.subagent_cancel_token.cancel(); + self.abort_handle.abort(); + self.active_subagent_executions + .remove(&self.subagent_session_id); + + let execution_engine = self.execution_engine.clone(); + let tool_pipeline = self.tool_pipeline.clone(); + let session_manager = self.session_manager.clone(); + let subagent_session_id = self.subagent_session_id.clone(); + let subagent_dialog_turn_id = self.subagent_dialog_turn_id.clone(); + + tokio::spawn(async move { + if let Err(error) = execution_engine + .cancel_dialog_turn(&subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel orphaned subagent dialog turn: session_id={}, dialog_turn_id={}, error={}", + subagent_session_id, subagent_dialog_turn_id, error + ); + } + + if let Err(error) = tool_pipeline + .cancel_dialog_turn_tools(&subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel orphaned subagent tools: session_id={}, dialog_turn_id={}, error={}", + subagent_session_id, subagent_dialog_turn_id, error + ); + } + + session_manager.reset_session_state_if_processing( + &subagent_session_id, + &subagent_dialog_turn_id, + ); + }); + } +} + #[derive(Clone)] struct SubagentConcurrencyLimiter { semaphore: Arc, @@ -355,6 +437,8 @@ pub struct ConversationCoordinator { subagent_profile_concurrency_limiters: Arc>>, /// Registry for dynamically adjusting subagent timeouts. subagent_timeout_registry: Arc>>>, + /// Active subagent executions keyed by subagent session id. + active_subagent_executions: Arc>, /// Notifies DialogScheduler of turn outcomes; injected after construction scheduler_notify_tx: OnceLock>, /// Round-boundary yield (same source as scheduler's yield flags); injected after construction @@ -808,6 +892,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_concurrency_limiter: Arc::new(RwLock::new(None)), subagent_profile_concurrency_limiters: Arc::new(RwLock::new(HashMap::new())), subagent_timeout_registry: Arc::new(RwLock::new(HashMap::new())), + active_subagent_executions: Arc::new(DashMap::new()), scheduler_notify_tx: OnceLock::new(), round_preempt_source: OnceLock::new(), round_injection_source: OnceLock::new(), @@ -1483,6 +1568,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet pub async fn prepare_goal_continuation_after_turn( &self, session_id: &str, + source_turn_id: &str, user_input: &str, user_message_metadata: Option<&serde_json::Value>, _final_response: &str, @@ -1511,16 +1597,37 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) .await?; + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "limit_reached".to_string(), + }) + .await; return Ok(None); } + self.emit_event(AgenticEvent::GoalVerificationStarted { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + }) + .await; + let context_messages = self .session_manager .get_context_messages(session_id) .await?; - let verification = verify_goal_achievement(&goal_state, &context_messages) - .await - .map_err(user_facing_goal_mode_error)?; + let verification = match verify_goal_achievement(&goal_state, &context_messages).await { + Ok(result) => result, + Err(error) => { + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "failed".to_string(), + }) + .await; + return Err(user_facing_goal_mode_error(error)); + } + }; if verification.achieved { info!( @@ -1530,6 +1637,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) .await?; + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "achieved".to_string(), + }) + .await; return Ok(None); } @@ -1538,6 +1651,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .merge_session_custom_metadata(session_id, goal_mode_patch(&goal_state)) .await?; + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "continuing".to_string(), + }) + .await; + Ok(Some(build_goal_continuation_plan( &goal_state, &verification, @@ -2564,6 +2684,137 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } + async fn cancel_active_subagents_for_parent_turn( + &self, + parent_session_id: &str, + parent_dialog_turn_id: &str, + ) { + let active_subagents: Vec = self + .active_subagent_executions + .iter() + .filter(|entry| { + entry.parent_session_id == parent_session_id + && entry.parent_dialog_turn_id == parent_dialog_turn_id + }) + .map(|entry| entry.value().clone()) + .collect(); + + if active_subagents.is_empty() { + return; + } + + info!( + "Cancelling {} active subagent execution(s) for parent turn: parent_session_id={}, parent_dialog_turn_id={}", + active_subagents.len(), + parent_session_id, + parent_dialog_turn_id + ); + + for active in active_subagents { + self.stop_active_subagent_execution(&active, "Parent dialog turn cancelled") + .await; + } + } + + async fn stop_active_subagent_execution( + &self, + active: &ActiveSubagentExecution, + reason: &str, + ) { + debug!( + "Stopping active subagent execution: subagent_session_id={}, subagent_dialog_turn_id={}, parent_session_id={}, parent_dialog_turn_id={}, reason={}", + active.subagent_session_id, + active.subagent_dialog_turn_id, + active.parent_session_id, + active.parent_dialog_turn_id, + reason + ); + + active.cancel_token.cancel(); + active.abort_handle.abort(); + + if let Err(error) = self + .execution_engine + .cancel_dialog_turn(&active.subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel active subagent dialog turn: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + + if let Err(error) = self + .tool_pipeline + .cancel_dialog_turn_tools(&active.subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel active subagent tools: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + + match self + .session_manager + .update_session_state_for_turn_if_processing( + &active.subagent_session_id, + &active.subagent_dialog_turn_id, + SessionState::Idle, + ) + .await + { + Ok(true) => { + self.emit_event(AgenticEvent::SessionStateChanged { + session_id: active.subagent_session_id.clone(), + new_state: "idle".to_string(), + }) + .await; + if let Err(error) = self + .event_queue + .enqueue( + AgenticEvent::DialogTurnCancelled { + session_id: active.subagent_session_id.clone(), + turn_id: active.subagent_dialog_turn_id.clone(), + }, + Some(EventPriority::Critical), + ) + .await + { + warn!( + "Failed to emit subagent DialogTurnCancelled event: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + } + Ok(false) => {} + Err(error) => { + warn!( + "Failed to set subagent session Idle after stop: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + } + + if let Err(error) = self.session_manager.cancel_dialog_turn( + &active.subagent_session_id, + &active.subagent_dialog_turn_id, + ).await { + warn!( + "Failed to persist subagent turn cancellation: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + + self.session_manager.reset_session_state_if_processing( + &active.subagent_session_id, + &active.subagent_dialog_turn_id, + ); + + self.active_subagent_executions + .remove(&active.subagent_session_id); + } + /// Cancel dialog turn execution /// Immediately set state to Idle to allow new dialog, old turn ends naturally via cancel token pub async fn cancel_dialog_turn( @@ -2639,6 +2890,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet warn!("Failed to cancel tool execution: {}", e); } + self.cancel_active_subagents_for_parent_turn(session_id, dialog_turn_id) + .await; + // Step 4: Wait briefly for the spawn task that owns this turn to drain // its in-memory message writes before returning. Capped so the RPC // never blocks longer than ~1.5s — beyond that we let the new turn @@ -3384,6 +3638,33 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await }); + let abort_handle = execution_task.abort_handle(); + + if subagent_parent_info.is_some() { + self.active_subagent_executions.insert( + session_id.clone(), + ActiveSubagentExecution { + parent_session_id: parent_session_id.to_string(), + parent_dialog_turn_id: parent_dialog_turn_id.to_string(), + subagent_session_id: session_id.clone(), + subagent_dialog_turn_id: dialog_turn_id.clone(), + cancel_token: subagent_cancel_token.clone(), + abort_handle: abort_handle.clone(), + }, + ); + } + + let mut execution_scope = SubagentExecutionScope { + execution_engine: self.execution_engine.clone(), + tool_pipeline: self.tool_pipeline.clone(), + session_manager: self.session_manager.clone(), + active_subagent_executions: self.active_subagent_executions.clone(), + subagent_session_id: session_id.clone(), + subagent_dialog_turn_id: dialog_turn_id.clone(), + subagent_cancel_token: subagent_cancel_token.clone(), + abort_handle, + disarmed: false, + }; enum SubagentExecutionOutcome { Completed(T), @@ -3475,6 +3756,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(BitFunError::tool(format!( "Subagent '{}' failed to join: {}", agent_type, error @@ -3537,6 +3819,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(BitFunError::Cancelled( "Subagent task has been cancelled".to_string(), )); @@ -3641,6 +3924,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Ok(partial_result); } @@ -3653,6 +3937,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(BitFunError::Timeout(timeout_error_message.clone())); } }; @@ -3681,6 +3966,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(e); } }; @@ -3749,6 +4035,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet response_text.len(), subagent_started_at.elapsed().as_millis() ); + execution_scope.disarm(); Ok(SubagentResult::completed(response_text)) } @@ -4016,10 +4303,18 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::service("Coordinator not initialized".to_string()))?; + let parent_cancel_token = self + .execution_engine + .cancel_token_for_dialog_turn(&subagent_parent_info.dialog_turn_id) + .map(|token| token.child_token()); tokio::spawn(async move { let delivery_text = match coordinator - .execute_hidden_subagent_internal(request, None, timeout_seconds) + .execute_hidden_subagent_internal( + request, + parent_cancel_token.as_ref(), + timeout_seconds, + ) .await { Ok(result) => format_background_subagent_delivery_text( diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index b34cfe1dc..ed975d39f 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -805,6 +805,7 @@ Status: {status}" .coordinator .prepare_goal_continuation_after_turn( &session_id, + &outcome.turn_id(), &active_turn.user_input, active_turn.user_message_metadata.as_ref(), final_response, diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 029a6e860..cfcb03cd9 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -2522,6 +2522,12 @@ impl ExecutionEngine { .register_cancel_token(dialog_turn_id, token) } + /// Return a clone of the cancellation token registered for a dialog turn. + pub fn cancel_token_for_dialog_turn(&self, dialog_turn_id: &str) -> Option { + self.round_executor + .cancel_token_for_dialog_turn(dialog_turn_id) + } + /// Cleanup cancellation token (for external calls) pub async fn cleanup_cancel_token(&self, dialog_turn_id: &str) { self.round_executor diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 9a269413c..91b6ec102 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -7,7 +7,7 @@ use super::types::{FinishReason, RoundContext, RoundResult}; use crate::agentic::core::{Message, ToolCall}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; -use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; +use crate::agentic::tools::framework::ToolUseContext; use crate::agentic::tools::implementations::file_write_tool::{ FileWriteTool, WRITE_TOOL_MODE_CONTEXT_KEY, }; @@ -15,7 +15,6 @@ use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions use crate::agentic::tools::registry::get_global_tool_registry; use crate::agentic::tools::tool_context_runtime; use crate::agentic::tools::tool_result_storage; -use crate::agentic::tools::ToolPathOperation; use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; use crate::service::config::types::WriteToolMode; @@ -826,6 +825,13 @@ impl RoundExecutor { .insert(dialog_turn_id.to_string(), token); } + /// Return a clone of the cancellation token registered for a dialog turn. + pub fn cancel_token_for_dialog_turn(&self, dialog_turn_id: &str) -> Option { + self.cancellation_tokens + .get(dialog_turn_id) + .map(|entry| entry.clone()) + } + /// Cancel dialog turn (using dialog_turn_id) pub async fn cancel_dialog_turn(&self, dialog_turn_id: &str) -> BitFunResult<()> { debug!("Cancelling dialog turn: dialog_turn_id={}", dialog_turn_id); @@ -907,16 +913,31 @@ impl RoundExecutor { .to_string(); let tool_id = tc.tool_id.clone(); - let target_has_prior_delete = - Self::write_target_has_prior_delete(context, &tool_calls, *idx, &file_path).await; - if let Some(error) = - Self::write_content_preflight_error(context, &file_path, target_has_prior_delete) - .await + if let Some(error) = Self::write_content_preflight_error(context, &file_path).await { debug!( "Skipping Write content generation after preflight failure: file_path={}, error={}", file_path, error ); + self.emit_event( + AgenticEvent::ToolEvent { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), + tool_event: ToolEventData::Failed { + tool_id: tool_id.clone(), + tool_name: "Write".to_string(), + error, + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }, + EventPriority::High, + ) + .await; continue; } @@ -1127,72 +1148,9 @@ impl RoundExecutor { async fn write_content_preflight_error( context: &RoundContext, file_path: &str, - target_has_prior_delete: bool, ) -> Option { let tool_context = Self::build_write_preflight_context(context); - let resolved = match tool_context.resolve_tool_path(file_path) { - Ok(resolved) => resolved, - Err(error) => return Some(error.to_string()), - }; - - if let Err(error) = tool_context.enforce_path_operation(ToolPathOperation::Write, &resolved) - { - return Some(error.to_string()); - } - - if target_has_prior_delete { - return None; - } - - FileWriteTool::existing_file_error(&tool_context, &resolved).await - } - - async fn write_target_has_prior_delete( - context: &RoundContext, - tool_calls: &[ToolCall], - write_idx: usize, - file_path: &str, - ) -> bool { - let tool_context = Self::build_write_preflight_context(context); - let write_resolved = match tool_context.resolve_tool_path(file_path) { - Ok(resolved) => resolved, - Err(_) => return false, - }; - - for prior_call in tool_calls.iter().take(write_idx) { - if prior_call.tool_name != "Delete" { - continue; - } - - let Some(delete_path) = prior_call.arguments.get("path").and_then(|v| v.as_str()) - else { - continue; - }; - - let delete_resolved = match tool_context.resolve_tool_path(delete_path) { - Ok(resolved) => resolved, - Err(_) => continue, - }; - - if tool_context - .enforce_path_operation(ToolPathOperation::Delete, &delete_resolved) - .is_err() - { - continue; - } - - let recursive = prior_call - .arguments - .get("recursive") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if delete_covers_write_target(&delete_resolved, &write_resolved, recursive) { - return true; - } - } - - false + FileWriteTool::preflight_write_error(&tool_context, file_path).await } fn build_write_preflight_context(context: &RoundContext) -> ToolUseContext { @@ -1389,36 +1347,6 @@ fn token_details_from_usage( (!details.is_empty()).then_some(serde_json::Value::Object(details)) } -fn delete_covers_write_target( - delete_target: &ToolPathResolution, - write_target: &ToolPathResolution, - recursive: bool, -) -> bool { - if delete_target.backend != write_target.backend { - return false; - } - - if delete_target.resolved_path == write_target.resolved_path { - return true; - } - - if !recursive { - return false; - } - - if delete_target.uses_remote_workspace_backend() { - let delete_prefix = delete_target.resolved_path.trim_end_matches('/'); - let write_path = write_target.resolved_path.as_str(); - return !delete_prefix.is_empty() - && write_path.len() > delete_prefix.len() - && write_path.starts_with(delete_prefix) - && write_path.as_bytes().get(delete_prefix.len()) == Some(&b'/'); - } - - std::path::Path::new(&write_target.resolved_path) - .starts_with(std::path::Path::new(&delete_target.resolved_path)) -} - /// Extract content from `...` tags. /// /// If the tags are present, returns the text between them (trimmed). @@ -1632,7 +1560,6 @@ fn detect_placeholder_patterns(content: &str) -> Option<&'static str> { #[cfg(test)] mod tests { use super::{extract_bitfun_contents, RoundExecutor, StreamProcessor}; - use crate::agentic::core::ToolCall; use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::execution::types::RoundContext; use crate::agentic::tools::ToolRuntimeRestrictions; @@ -1676,15 +1603,14 @@ mod tests { } } - fn tool_call(tool_id: &str, tool_name: &str, arguments: serde_json::Value) -> ToolCall { - ToolCall { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments, - raw_arguments: None, - is_error: false, - recovered_from_truncation: false, - } + #[tokio::test] + async fn cancel_token_for_dialog_turn_returns_registered_token() { + let executor = test_round_executor(); + let token = CancellationToken::new(); + executor.register_cancel_token("turn-1", token.clone()); + + assert!(executor.cancel_token_for_dialog_turn("turn-1").is_some()); + assert!(executor.cancel_token_for_dialog_turn("missing").is_none()); } #[tokio::test] @@ -1708,54 +1634,31 @@ mod tests { } #[tokio::test] - async fn write_preflight_rejects_existing_file_without_prior_delete() { + async fn write_preflight_allows_new_file_target() { let root = std::env::temp_dir().join(format!("bitfun-write-preflight-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); - std::fs::write(root.join("target.txt"), "old").expect("create target file"); let context = test_round_context(root.clone()); - let error = - RoundExecutor::write_content_preflight_error(&context, "target.txt", false).await; + let error = RoundExecutor::write_content_preflight_error(&context, "target.txt").await; let _ = std::fs::remove_dir_all(&root); - assert!(error - .as_deref() - .unwrap_or_default() - .contains("already exists")); + assert_eq!(error, None); } #[tokio::test] - async fn write_preflight_allows_existing_file_when_prior_delete_targets_same_path() { + async fn write_preflight_allows_existing_file_without_read_state_tracking() { let root = std::env::temp_dir().join(format!("bitfun-write-preflight-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); std::fs::write(root.join("target.txt"), "old").expect("create target file"); let context = test_round_context(root.clone()); - let tool_calls = vec![ - tool_call( - "delete-1", - "Delete", - serde_json::json!({"path": "target.txt"}), - ), - tool_call( - "write-1", - "Write", - serde_json::json!({"file_path": "target.txt"}), - ), - ]; - let has_prior_delete = - RoundExecutor::write_target_has_prior_delete(&context, &tool_calls, 1, "target.txt") - .await; - let error = - RoundExecutor::write_content_preflight_error(&context, "target.txt", has_prior_delete) - .await; + let error = RoundExecutor::write_content_preflight_error(&context, "target.txt").await; let _ = std::fs::remove_dir_all(&root); - assert!(has_prior_delete); assert_eq!(error, None); } diff --git a/src/crates/core/src/agentic/session/file_read_state.rs b/src/crates/core/src/agentic/session/file_read_state.rs index 14eb74920..f6bf81e26 100644 --- a/src/crates/core/src/agentic/session/file_read_state.rs +++ b/src/crates/core/src/agentic/session/file_read_state.rs @@ -13,6 +13,8 @@ pub struct FileReadState { pub start_line: usize, pub end_line: usize, pub total_lines: usize, + /// True when this entry was populated by auto-injection and the model has + /// not explicitly read the file. Range reads from the Read tool do not set this. pub is_partial_view: bool, } diff --git a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs index f4348ba44..d169b61a3 100644 --- a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs +++ b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs @@ -10,12 +10,43 @@ use tool_runtime::fs::read_file::ReadFileResult; use tool_runtime::util::read_line_prefix::read_tool_output_to_file_content; use tool_runtime::util::string::normalize_string; +pub const FILE_UNEXPECTEDLY_MODIFIED_ERROR: &str = + "File has been unexpectedly modified. Read it again before attempting to write it."; + +pub fn validate_write_has_prior_read( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + let Some(read_state) = coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path) + else { + return Some(format!( + "Use Read to load the current contents of {} before calling Write on it.", + resolved.logical_path + )); + }; + + if read_state.is_partial_view { + return Some(format!( + "Use Read to load the full contents of {} before calling Write on it.", + resolved.logical_path + )); + } + + None +} + +pub fn read_state_tracking_enabled(context: &ToolUseContext) -> bool { + context.session_id.is_some() && get_global_coordinator().is_some() +} + pub fn record_file_read_state( context: &ToolUseContext, resolved: &ToolPathResolution, read_result: &ReadFileResult, - requested_start_line: usize, - requested_limit: usize, timestamp_ms: u64, ) { let Some(session_id) = context.session_id.as_deref() else { @@ -25,18 +56,16 @@ pub fn record_file_read_state( return; }; - let is_partial_view = requested_start_line != 1 - || read_result.end_line < read_result.total_lines - || read_result.hit_total_char_limit - || requested_limit < read_result.total_lines; - + // `is_partial_view` is reserved for auto-injected content the model has not + // explicitly read (see Claude Code's FileState.isPartialView). Normal Read + // tool calls with offset/limit still count as a valid read for Edit/Write. let state = FileReadState { content: read_tool_output_to_file_content(&read_result.content), timestamp_ms, start_line: read_result.start_line, end_line: read_result.end_line, total_lines: read_result.total_lines, - is_partial_view, + is_partial_view: false, }; coordinator.get_session_manager().set_file_read_state( @@ -46,6 +75,53 @@ pub fn record_file_read_state( ); } +pub fn get_stored_file_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path) +} + +pub fn content_unchanged_since_full_read( + read_state: &FileReadState, + current_content: &str, +) -> bool { + read_state.is_full_file_read() + && normalize_string(current_content) == normalize_string(&read_state.content) +} + +pub fn assert_file_not_unexpectedly_modified( + read_state: Option<&FileReadState>, + current_content: &str, + current_mtime_ms: Option, +) -> Result<(), String> { + let Some(read_state) = read_state else { + return Err(FILE_UNEXPECTEDLY_MODIFIED_ERROR.to_string()); + }; + + if let Some(current_mtime_ms) = current_mtime_ms { + if current_mtime_ms > read_state.timestamp_ms { + if content_unchanged_since_full_read(read_state, current_content) { + return Ok(()); + } + return Err(FILE_UNEXPECTEDLY_MODIFIED_ERROR.to_string()); + } + return Ok(()); + } + + if read_state.is_full_file_read() + && normalize_string(current_content) != normalize_string(&read_state.content) + { + return Err(FILE_UNEXPECTEDLY_MODIFIED_ERROR.to_string()); + } + + Ok(()) +} + pub async fn validate_edit_against_read_state( context: &ToolUseContext, resolved: &ToolPathResolution, @@ -56,16 +132,6 @@ pub async fn validate_edit_against_read_state( .get_session_manager() .get_file_read_state(session_id, &resolved.logical_path)?; - if read_state.is_partial_view { - return Some(format!( - "File {} was only partially read (lines {}-{} of {}). Read the full target area again before editing.", - resolved.logical_path, - read_state.start_line, - read_state.end_line, - read_state.total_lines - )); - } - let current_content = match read_current_file_content(context, resolved).await { Ok(content) => content, Err(error) => { @@ -77,7 +143,7 @@ pub async fn validate_edit_against_read_state( }; let current_mtime_ms = file_modification_time_ms(context, resolved).await; - validate_content_freshness_against_read_state( + validate_edit_content_freshness_against_read_state( &resolved.logical_path, &read_state, ¤t_content, @@ -85,7 +151,47 @@ pub async fn validate_edit_against_read_state( ) } -fn validate_content_freshness_against_read_state( +pub async fn validate_write_against_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let read_state = get_stored_file_read_state(context, resolved)?; + + if let Some(current_mtime_ms) = file_modification_time_ms(context, resolved).await { + if current_mtime_ms > read_state.timestamp_ms { + return Some(format!( + "The file {} changed after it was last read. Use Read again, then retry Write.", + resolved.logical_path + )); + } + return None; + } + + let current_content = read_current_file_content(context, resolved).await.ok()?; + if read_state.is_full_file_read() + && normalize_string(¤t_content) != normalize_string(&read_state.content) + { + return Some(format!( + "The file {} no longer matches the last Read result. Use Read again, then retry Write.", + resolved.logical_path + )); + } + + None +} + +pub async fn validate_existing_file_read_before_write( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + if let Some(message) = validate_write_has_prior_read(context, resolved) { + return Some(message); + } + + validate_write_against_read_state(context, resolved).await +} + +fn validate_edit_content_freshness_against_read_state( logical_path: &str, read_state: &FileReadState, current_content: &str, @@ -100,13 +206,15 @@ fn validate_content_freshness_against_read_state( } return Some(format!( - "File {} has been modified since it was last read (by the user, another tool, or a linter). Read it again before editing.", + "The file {} changed after it was last read. Use Read again, then retry Edit.", logical_path )); } - } else if normalize_string(current_content) != normalize_string(&read_state.content) { + } else if read_state.is_full_file_read() + && normalize_string(current_content) != normalize_string(&read_state.content) + { return Some(format!( - "File {} no longer matches the last Read result. Read it again before editing.", + "The file {} no longer matches the last Read result. Use Read again, then retry Edit.", logical_path )); } @@ -120,19 +228,24 @@ pub fn validate_edit_has_prior_read( ) -> Option { let session_id = context.session_id.as_deref()?; let coordinator = get_global_coordinator()?; - let has_read = coordinator + let Some(read_state) = coordinator .get_session_manager() .get_file_read_state(session_id, &resolved.logical_path) - .is_some(); + else { + return Some(format!( + "Use Read to load the current contents of {} before calling Edit on it.", + resolved.logical_path + )); + }; - if has_read { - return None; + if read_state.is_partial_view { + return Some(format!( + "Use Read to load the full contents of {} before calling Edit on it.", + resolved.logical_path + )); } - Some(format!( - "File {} has not been read yet in this session. Use the Read tool on it before editing.", - resolved.logical_path - )) + None } pub fn update_file_read_state_after_mutation( @@ -170,7 +283,7 @@ pub fn update_file_read_state_after_mutation( ); } -async fn read_current_file_content( +pub async fn read_current_file_content( context: &ToolUseContext, resolved: &ToolPathResolution, ) -> BitFunResult { @@ -301,6 +414,86 @@ mod tests { .is_none()); } + #[test] + fn validate_edit_has_prior_read_rejects_auto_injected_partial_view() { + let context = test_context(Some("session-1"), PathBuf::from("/tmp")); + let resolution = ToolPathResolution { + logical_path: "src/main.rs".to_string(), + resolved_path: "src/main.rs".to_string(), + requested_path: "src/main.rs".to_string(), + backend: ToolPathBackend::Local, + runtime_root: None, + runtime_scope: None, + }; + + // Without a coordinator this stays permissive in unit tests. + assert!(validate_edit_has_prior_read(&context, &resolution).is_none()); + } + + #[test] + fn validate_content_freshness_allows_partial_read_range_without_full_file() { + let state = FileReadState { + content: "middle\n".to_string(), + timestamp_ms: 100, + start_line: 50, + end_line: 100, + total_lines: 556, + is_partial_view: false, + }; + + assert!(validate_edit_content_freshness_against_read_state( + "src/state.js", + &state, + "different full file\n", + Some(200), + ) + .is_some()); + } + + #[test] + fn assert_file_not_unexpectedly_modified_allows_matching_full_read_after_newer_mtime() { + let state = FileReadState { + content: "alpha\n".to_string(), + timestamp_ms: 100, + start_line: 1, + end_line: 1, + total_lines: 1, + is_partial_view: false, + }; + + assert!(assert_file_not_unexpectedly_modified(Some(&state), "alpha\n", Some(200)).is_ok()); + } + + #[test] + fn assert_file_not_unexpectedly_modified_rejects_changed_full_read_after_newer_mtime() { + let state = FileReadState { + content: "alpha\n".to_string(), + timestamp_ms: 100, + start_line: 1, + end_line: 1, + total_lines: 1, + is_partial_view: false, + }; + + assert!(assert_file_not_unexpectedly_modified(Some(&state), "beta\n", Some(200)).is_err()); + } + + #[test] + fn assert_file_not_unexpectedly_modified_rejects_partial_read_after_newer_mtime() { + let state = FileReadState { + content: "middle\n".to_string(), + timestamp_ms: 100, + start_line: 50, + end_line: 100, + total_lines: 556, + is_partial_view: false, + }; + + assert!( + assert_file_not_unexpectedly_modified(Some(&state), "full file\n", Some(200)).is_err() + ); + } + fn read_state(content: &str, timestamp_ms: u64) -> FileReadState { FileReadState { content: content.to_string(), @@ -316,7 +509,7 @@ mod tests { fn validate_content_freshness_allows_matching_remote_content_without_mtime() { let state = read_state("alpha\n", 100); - assert!(validate_content_freshness_against_read_state( + assert!(validate_edit_content_freshness_against_read_state( "src/main.rs", &state, "alpha\n", @@ -330,7 +523,7 @@ mod tests { let state = read_state("alpha\n", 100); assert_eq!( - validate_content_freshness_against_read_state( + validate_edit_content_freshness_against_read_state( "src/main.rs", &state, "beta\n", @@ -338,7 +531,7 @@ mod tests { ) .as_deref(), Some( - "File src/main.rs no longer matches the last Read result. Read it again before editing." + "The file src/main.rs no longer matches the last Read result. Use Read again, then retry Edit." ) ); } @@ -347,7 +540,7 @@ mod tests { fn validate_content_freshness_allows_newer_mtime_when_full_read_content_matches() { let state = read_state("alpha\n", 100); - assert!(validate_content_freshness_against_read_state( + assert!(validate_edit_content_freshness_against_read_state( "src/main.rs", &state, "alpha\n", @@ -360,7 +553,7 @@ mod tests { fn validate_content_freshness_rejects_newer_mtime_when_content_differs() { let state = read_state("alpha\n", 100); - assert!(validate_content_freshness_against_read_state( + assert!(validate_edit_content_freshness_against_read_state( "src/main.rs", &state, "beta\n", @@ -373,7 +566,7 @@ mod tests { fn validate_content_freshness_ignores_older_mtime_even_when_content_differs() { let state = read_state("alpha\n", 200); - assert!(validate_content_freshness_against_read_state( + assert!(validate_edit_content_freshness_against_read_state( "src/main.rs", &state, "beta\n", diff --git a/src/crates/core/src/agentic/tools/file_tool_guidance.rs b/src/crates/core/src/agentic/tools/file_tool_guidance.rs new file mode 100644 index 000000000..1245dc2af --- /dev/null +++ b/src/crates/core/src/agentic/tools/file_tool_guidance.rs @@ -0,0 +1,11 @@ +//! Shared guidance markers for file Write/Edit tool guardrails shown to users as hints. + +pub const FILE_TOOL_GUIDANCE_PREFIX: &str = "[guidance] "; + +pub fn file_tool_guidance_message(message: impl Into) -> String { + format!("{FILE_TOOL_GUIDANCE_PREFIX}{}", message.into()) +} + +pub fn is_file_tool_guidance_message(message: &str) -> bool { + message.starts_with(FILE_TOOL_GUIDANCE_PREFIX) +} diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index 1fc6ef670..ebb2f994c 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,18 +1,22 @@ use crate::agentic::tools::file_read_state_runtime::{ - file_mutation_timestamp_ms, update_file_read_state_after_mutation, - validate_edit_against_read_state, validate_edit_has_prior_read, + assert_file_not_unexpectedly_modified, file_mutation_timestamp_ms, + get_stored_file_read_state, local_file_modification_time_ms, + read_current_file_content, read_state_tracking_enabled, update_file_read_state_after_mutation, + validate_edit_against_read_state, validate_edit_has_prior_read, FILE_UNEXPECTEDLY_MODIFIED_ERROR, +}; +use crate::agentic::tools::file_tool_guidance::file_tool_guidance_message; +use crate::agentic::tools::framework::{ + Tool, ToolPathResolution, ToolResult, ToolUseContext, ValidationResult, }; -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; +use std::path::Path; use tool_runtime::fs::edit_file::apply_edit_to_content; pub struct FileEditTool; -const LARGE_EDIT_SOFT_LINE_LIMIT: usize = 200; -const LARGE_EDIT_SOFT_BYTE_LIMIT: usize = 20 * 1024; const EDIT_TOOL_PROMPT: &str = r#"Performs exact string replacements in files. Usage: @@ -24,8 +28,6 @@ Usage: - The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. - Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance."#; -const EDIT_RETRY_GUIDANCE: &str = "Common causes: stale Read output after another edit, copied Read line-number prefixes, changed whitespace, truncated Read lines, or an old_string that is too broad. Recovery: read the current target area again, copy the exact current text after the tab on each Read line, and retry with a uniquely matching old_string. If several edits target the same file, apply them sequentially from fresh content or replace one stable enclosing block. If the text appears more than once, include more surrounding context or set replace_all only when every occurrence should change."; - impl Default for FileEditTool { fn default() -> Self { Self::new() @@ -37,17 +39,72 @@ impl FileEditTool { Self } - fn enhance_edit_error(file_path: &str, error: String) -> String { - if error.contains("old_string not found in file") || error.contains("`old_string` appears") - { + fn guidance_failure(message: String) -> ValidationResult { + ValidationResult { + result: false, + message: Some(file_tool_guidance_message(message)), + error_code: Some(400), + meta: Some(json!({ "failure_kind": "guidance" })), + } + } + + fn format_edit_freshness_guidance(logical_path: &str, error: String) -> String { + if error == FILE_UNEXPECTEDLY_MODIFIED_ERROR || error.contains("unexpectedly modified") { format!( - "Edit failed for {}: {}\n{}", - file_path, error, EDIT_RETRY_GUIDANCE + "The file {} changed since it was last read. Use Read again, then retry Edit.", + logical_path ) } else { error } } + + fn is_edit_content_guardrail_error(error: &str) -> bool { + error.contains("old_string not found in file") + || error.contains("`old_string` appears") + } + + async fn edit_read_state_guardrail_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if let Some(message) = validate_edit_has_prior_read(context, resolved) { + return Some(message); + } + + validate_edit_against_read_state(context, resolved).await + } + + fn assert_atomic_edit_freshness( + context: &ToolUseContext, + resolved: &ToolPathResolution, + content: &str, + ) -> BitFunResult<()> { + if !read_state_tracking_enabled(context) { + return Ok(()); + } + + let read_state = get_stored_file_read_state(context, resolved); + let current_mtime_ms = if resolved.uses_remote_workspace_backend() { + None + } else { + Some(local_file_modification_time_ms(Path::new(&resolved.resolved_path))) + }; + + if let Some(error) = assert_file_not_unexpectedly_modified( + read_state.as_ref(), + content, + current_mtime_ms, + ) + .err() + { + return Err(BitFunError::tool(file_tool_guidance_message( + Self::format_edit_freshness_guidance(&resolved.logical_path, error), + ))); + } + + Ok(()) + } } #[async_trait] @@ -138,6 +195,36 @@ impl Tool for FileEditTool { }; } + let old_string = input + .get("old_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let new_string = input + .get("new_string") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let replace_all = input + .get("replace_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if old_string.is_empty() { + return ValidationResult { + result: false, + message: Some("old_string cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + if old_string == new_string { + return ValidationResult { + result: false, + message: Some("new_string must be different from old_string".to_string()), + error_code: Some(400), + meta: None, + }; + } + if let Some(ctx) = context { let resolved = match ctx.resolve_tool_path(file_path) { Ok(resolved) => resolved, @@ -160,71 +247,41 @@ impl Tool for FileEditTool { }; } - if let Some(message) = validate_edit_has_prior_read(ctx, &resolved) { - return ValidationResult { - result: false, - message: Some(message), - error_code: Some(400), - meta: None, - }; + if let Some(message) = Self::edit_read_state_guardrail_error(ctx, &resolved).await { + return Self::guidance_failure(message); } - if let Some(message) = validate_edit_against_read_state(ctx, &resolved).await { + let file_content = match read_current_file_content(ctx, &resolved).await { + Ok(content) => content, + Err(error) => { + return ValidationResult { + result: false, + message: Some(format!( + "Failed to read file {}: {}", + resolved.logical_path, error + )), + error_code: Some(400), + meta: None, + }; + } + }; + + if let Err(error) = + apply_edit_to_content(&file_content, old_string, new_string, replace_all) + { + if Self::is_edit_content_guardrail_error(&error) { + return Self::guidance_failure(error); + } + return ValidationResult { result: false, - message: Some(message), + message: Some(error), error_code: Some(400), meta: None, }; } } - let old_string = input - .get("old_string") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let new_string = input - .get("new_string") - .and_then(|v| v.as_str()) - .unwrap_or(""); - if old_string.is_empty() { - return ValidationResult { - result: false, - message: Some("old_string cannot be empty".to_string()), - error_code: Some(400), - meta: None, - }; - } - if old_string == new_string { - return ValidationResult { - result: false, - message: Some("new_string must be different from old_string".to_string()), - error_code: Some(400), - meta: None, - }; - } - - let largest_lines = old_string.lines().count().max(new_string.lines().count()); - let largest_bytes = old_string.len().max(new_string.len()); - if largest_lines > LARGE_EDIT_SOFT_LINE_LIMIT || largest_bytes > LARGE_EDIT_SOFT_BYTE_LIMIT - { - return ValidationResult { - result: true, - message: Some(format!( - "Large Edit payload: largest side is {} lines, {} bytes. This is allowed when necessary, but a staged approach is usually more reliable: edit one stable section, function, or component at a time, and refresh file context before additional edits to the same file.", - largest_lines, largest_bytes - )), - error_code: None, - meta: Some(json!({ - "large_edit": true, - "largest_line_count": largest_lines, - "largest_byte_count": largest_bytes, - "soft_line_limit": LARGE_EDIT_SOFT_LINE_LIMIT, - "soft_byte_limit": LARGE_EDIT_SOFT_BYTE_LIMIT - })), - }; - } - ValidationResult::default() } @@ -272,8 +329,15 @@ impl Tool for FileEditTool { .read_file_text(&resolved.resolved_path) .await .map_err(|e| BitFunError::tool(format!("Failed to read file: {}", e)))?; + Self::assert_atomic_edit_freshness(context, &resolved, &content)?; let edit_result = apply_edit_to_content(&content, old_string, new_string, replace_all) - .map_err(|e| BitFunError::tool(Self::enhance_edit_error(file_path, e)))?; + .map_err(|error| { + if Self::is_edit_content_guardrail_error(&error) { + BitFunError::tool(file_tool_guidance_message(error)) + } else { + BitFunError::tool(error) + } + })?; ws_fs .write_file(&resolved.resolved_path, edit_result.new_content.as_bytes()) @@ -315,8 +379,15 @@ impl Tool for FileEditTool { resolved.logical_path, e )) })?; + Self::assert_atomic_edit_freshness(context, &resolved, &content)?; let edit_result = apply_edit_to_content(&content, old_string, new_string, replace_all) - .map_err(|e| BitFunError::tool(Self::enhance_edit_error(file_path, e)))?; + .map_err(|error| { + if Self::is_edit_content_guardrail_error(&error) { + BitFunError::tool(file_tool_guidance_message(error)) + } else { + BitFunError::tool(error) + } + })?; std::fs::write(&resolved.resolved_path, edit_result.new_content.as_bytes()).map_err( |e| { @@ -368,7 +439,6 @@ mod tests { assert!(description.contains("You must use your `Read` tool")); assert!(description.contains("spaces + line number + tab")); assert!(description.contains("NEVER write new files unless explicitly required")); - assert!(!description.contains("Large Edit payload")); assert!(!description.contains("auto-strip")); } @@ -423,30 +493,13 @@ mod tests { } #[test] - fn edit_not_found_error_includes_retry_guidance() { - let message = FileEditTool::enhance_edit_error( - "src/lib.rs", - "old_string not found in file.\n[nearby content around line 2]\nfn main() {}".to_string(), - ); - - assert!(message.contains("Edit failed for src/lib.rs")); - assert!(message.contains("Common causes")); - assert!(message.contains("stale Read output")); - assert!(message.contains("read the current target area again")); - assert!(message.contains("[nearby content around line 2]")); - } - - #[test] - fn edit_multiple_match_error_includes_unique_context_guidance() { - let message = FileEditTool::enhance_edit_error( - "src/lib.rs", - "`old_string` appears 2 times in file\nMatched contexts:\n[match 1 starts at line 4]" - .to_string(), - ); - - assert!(message.contains("old_string")); - assert!(message.contains("[match 1 starts at line 4]")); - assert!(message.contains("include more surrounding context")); - assert!(message.contains("replace_all only when every occurrence should change")); + fn edit_content_guardrail_detection_matches_apply_edit_errors() { + assert!(FileEditTool::is_edit_content_guardrail_error( + "old_string not found in file." + )); + assert!(FileEditTool::is_edit_content_guardrail_error( + "`old_string` appears 2 times in file, either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.\n" + )); + assert!(!FileEditTool::is_edit_content_guardrail_error("Permission denied")); } } diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index c24d40daf..653f14fc0 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -420,14 +420,7 @@ Usage: } else { local_file_modification_time_ms(Path::new(&resolved.resolved_path)) }; - record_file_read_state( - context, - &resolved, - &read_file_result, - start_line, - limit, - timestamp_ms, - ); + record_file_read_state(context, &resolved, &read_file_result, timestamp_ms); let mut result_for_assistant = format!( "Read lines {}-{} from {} ({} total lines)\n\n{}\n", diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index b6b5fdb65..a89ec9ab0 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,5 +1,11 @@ use crate::agentic::tools::file_read_state_runtime::{ - file_mutation_timestamp_ms, update_file_read_state_after_mutation, + assert_file_not_unexpectedly_modified, file_mutation_timestamp_ms, + get_stored_file_read_state, local_file_modification_time_ms, read_current_file_content, + read_state_tracking_enabled, update_file_read_state_after_mutation, + validate_existing_file_read_before_write, FILE_UNEXPECTEDLY_MODIFIED_ERROR, +}; +use crate::agentic::tools::file_tool_guidance::{ + file_tool_guidance_message, is_file_tool_guidance_message, }; use crate::agentic::tools::framework::{ Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, @@ -29,6 +35,30 @@ impl FileWriteTool { Self } + pub(crate) fn write_guidance_message(message: impl Into) -> String { + file_tool_guidance_message(message) + } + + pub(crate) fn is_write_guidance_message(message: &str) -> bool { + is_file_tool_guidance_message(message) + } + + fn format_write_freshness_guidance(logical_path: &str, error: String) -> String { + if error == FILE_UNEXPECTEDLY_MODIFIED_ERROR || error.contains("unexpectedly modified") { + format!( + "The file {} changed since it was last read. Use Read again, then retry Write.", + logical_path + ) + } else if error.contains("modified since read") { + format!( + "The file {} changed after it was last read. Use Read again, then retry Write.", + logical_path + ) + } else { + error + } + } + pub(crate) fn write_tool_mode(context: Option<&ToolUseContext>) -> WriteToolMode { if Self::is_acp_context(context) { return WriteToolMode::InlineContent; @@ -41,22 +71,6 @@ impl FileWriteTool { ) } - pub(crate) async fn existing_file_error( - context: &ToolUseContext, - resolved: &ToolPathResolution, - ) -> Option { - let file_already_exists = Self::file_exists(context, resolved).await; - - file_already_exists.then(|| { - format!( - "File {} already exists. The Write tool is reserved for creating NEW files. \ - To modify the file, use the Edit tool. \ - To fully rewrite the file, first call the Delete tool on this path, then call Write again.", - resolved.logical_path - ) - }) - } - async fn file_exists(context: &ToolUseContext, resolved: &ToolPathResolution) -> bool { if resolved.uses_remote_workspace_backend() { if let Some(ws_fs) = context.ws_fs() { @@ -87,6 +101,82 @@ impl FileWriteTool { Some(existing == content.as_bytes()) } + async fn existing_file_write_freshness_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if !Self::file_exists(context, resolved).await { + return None; + } + if !read_state_tracking_enabled(context) { + return None; + } + + let current_content = match read_current_file_content(context, resolved).await { + Ok(content) => content, + Err(error) => return Some(error.to_string()), + }; + let read_state = get_stored_file_read_state(context, resolved); + let current_mtime_ms = if resolved.uses_remote_workspace_backend() { + None + } else { + Some(local_file_modification_time_ms(Path::new(&resolved.resolved_path))) + }; + + assert_file_not_unexpectedly_modified( + read_state.as_ref(), + ¤t_content, + current_mtime_ms, + ) + .err() + .map(|error| Self::format_write_freshness_guidance(&resolved.logical_path, error)) + } + + async fn assert_atomic_write_freshness_if_exists( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> BitFunResult<()> { + if let Some(error) = Self::existing_file_write_freshness_error(context, resolved).await { + return Err(BitFunError::tool(Self::write_guidance_message(error))); + } + + Ok(()) + } + + async fn write_guardrail_preflight_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if !Self::file_exists(context, resolved).await { + return None; + } + + if let Some(message) = validate_existing_file_read_before_write(context, resolved).await + { + return Some(Self::write_guidance_message(message)); + } + + Self::existing_file_write_freshness_error(context, resolved) + .await + .map(Self::write_guidance_message) + } + + pub(crate) async fn preflight_write_error( + context: &ToolUseContext, + file_path: &str, + ) -> Option { + let resolved = match context.resolve_tool_path(file_path) { + Ok(resolved) => resolved, + Err(err) => return Some(err.to_string()), + }; + + if let Err(err) = context.enforce_path_operation(ToolPathOperation::Write, &resolved) { + return Some(err.to_string()); + } + + Self::write_guardrail_preflight_error(context, &resolved).await + } + fn write_success_result( logical_path: &str, bytes_written: usize, @@ -164,12 +254,12 @@ Usage: r#"Writes a file to the local filesystem. Usage: -- This tool is for creating NEW files only. Calling Write on a path that already exists will be REJECTED with an error. -- To MODIFY an existing file, use the Edit tool — it is the correct choice in almost every case. -- To FULLY REWRITE an existing file (e.g. regenerate a generated file, replace a template), first call the Delete tool on that path, then call Write to create the new version. Do not try to "overwrite" via Write directly. -- After Write succeeds for a path, do not call Write for that path again in later rounds. Use Edit for any additional changes. +- This tool writes the COMPLETE file content and will overwrite the existing file if one already exists at the provided path. +- For partial changes to an existing file, use the Edit tool instead. Edit performs targeted string replacements; Write replaces the entire file. +- If this is an existing file, you MUST use the Read tool first to read the file's contents before calling Write. - The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Keep writes focused. For existing files, prefer Read + targeted Edit calls. Use Write only when you need to replace the entire file or create a new one. - NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. - Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. - Do NOT include the file content in the tool call arguments. Only provide file_path. The system will prompt you separately to output the file content as plain text."# @@ -201,6 +291,8 @@ Usage: .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; + Self::assert_atomic_write_freshness_if_exists(context, &resolved).await?; + if resolved.uses_remote_workspace_backend() { let ws_fs = context.ws_fs().ok_or_else(|| { BitFunError::tool("Remote workspace file system is unavailable".to_string()) @@ -266,24 +358,24 @@ Usage: .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; - if let Some(error) = Self::existing_file_error(context, &resolved).await { - if Self::existing_file_matches_content(context, &resolved, content).await == Some(true) - { - let result = Self::write_success_result( - &resolved.logical_path, - 0, - "already_exists_same_content", - format!( - "Write skipped because {} already exists with identical content. Treat this file as successfully created and do not call Write for this path again. Use Edit for any further changes.", - resolved.logical_path - ), - ); - return Ok(vec![result]); - } - - return Err(BitFunError::tool(error)); + let file_already_exists = Self::file_exists(context, &resolved).await; + if file_already_exists + && Self::existing_file_matches_content(context, &resolved, content).await == Some(true) + { + let result = Self::write_success_result( + &resolved.logical_path, + 0, + "already_exists_same_content", + format!( + "Write skipped because {} already exists with identical content.", + resolved.logical_path + ), + ); + return Ok(vec![result]); } + Self::assert_atomic_write_freshness_if_exists(context, &resolved).await?; + if resolved.uses_remote_workspace_backend() { let ws_fs = context.ws_fs().ok_or_else(|| { BitFunError::tool("Remote workspace file system is unavailable".to_string()) @@ -308,15 +400,34 @@ Usage: })?; } + let (status, assistant_message) = if file_already_exists { + ( + "overwritten", + format!( + "Successfully overwrote {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ) + } else { + ( + "created", + format!( + "Successfully created {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ) + }; + + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation(context, &resolved, content, timestamp_ms); + let result = Self::write_success_result( &resolved.logical_path, content.len(), - "created", - format!( - "Successfully created {} ({} bytes). The file now exists; do not call Write for this path again. Use Edit for any further changes.", - resolved.logical_path, - content.len() - ), + status, + assistant_message, ); Ok(vec![result]) @@ -326,6 +437,7 @@ Usage: #[cfg(test)] mod tests { use super::{FileWriteTool, WRITE_TOOL_MODE_CONTEXT_KEY}; + use crate::agentic::tools::file_tool_guidance::FILE_TOOL_GUIDANCE_PREFIX; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::WorkspaceBinding; @@ -384,27 +496,41 @@ mod tests { } } + #[test] + fn write_guidance_prefix_helpers_round_trip() { + let message = FileWriteTool::write_guidance_message("Use Read first."); + assert!(FileWriteTool::is_write_guidance_message(&message)); + assert_eq!( + message.strip_prefix(FILE_TOOL_GUIDANCE_PREFIX).unwrap(), + "Use Read first." + ); + } + #[tokio::test] - async fn validate_input_rejects_existing_file_before_content_generation() { + async fn preflight_write_error_allows_new_file_target() { let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); - let existing_file = root.join("existing.md"); - std::fs::write(&existing_file, "already here").expect("create existing file"); - let tool = FileWriteTool::new(); - let validation = tool - .validate_input( - &json!({ "file_path": "existing.md" }), - Some(&local_context(root.clone())), - ) + let error = FileWriteTool::preflight_write_error(&local_context(root.clone()), "new.txt") .await; let _ = std::fs::remove_dir_all(&root); - assert!(!validation.result); - let message = validation.message.unwrap_or_default(); - assert!(message.contains("already exists")); - assert!(message.contains("Edit tool")); + assert!(error.is_none()); + } + + #[tokio::test] + async fn preflight_write_error_allows_existing_file_without_read_state_tracking() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "already here").expect("create existing file"); + + let error = + FileWriteTool::preflight_write_error(&local_context(root.clone()), "existing.md").await; + + let _ = std::fs::remove_dir_all(&root); + + assert!(error.is_none()); } #[tokio::test] @@ -438,28 +564,33 @@ mod tests { assert!(result_for_assistant .as_deref() .unwrap_or_default() - .contains("do not call Write for this path again")); + .contains("identical content")); } #[tokio::test] - async fn call_impl_rejects_different_existing_content() { + async fn call_impl_overwrites_different_existing_content() { let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); std::fs::write(root.join("existing.md"), "old content").expect("create existing file"); let tool = FileWriteTool::new(); - let error = tool + let results = tool .call( &json!({ "file_path": "existing.md", "content": "new content" }), &local_context(root.clone()), ) .await - .expect_err("different content must not overwrite existing files"); + .expect("plaintext followup should overwrite existing files"); + let written = std::fs::read_to_string(root.join("existing.md")).expect("read file"); let _ = std::fs::remove_dir_all(&root); - assert!(error.to_string().contains("already exists")); - assert!(error.to_string().contains("Edit tool")); + assert_eq!(written, "new content"); + + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected result"); + }; + assert_eq!(data["status"], "overwritten"); } #[tokio::test] @@ -572,7 +703,7 @@ impl Tool for FileWriteTool { } fn short_description(&self) -> String { - "Write a new file.".to_string() + "Write or overwrite a file.".to_string() } async fn description_with_context( @@ -655,45 +786,15 @@ impl Tool for FileWriteTool { }; if let Some(ctx) = context { - let resolved = match ctx.resolve_tool_path(file_path) { - Ok(resolved) => resolved, - Err(err) => { - return ValidationResult { - result: false, - message: Some(err.to_string()), - error_code: Some(400), - meta: None, - }; - } - }; - - if let Err(err) = ctx.enforce_path_operation(ToolPathOperation::Write, &resolved) { + if let Some(message) = Self::preflight_write_error(ctx, file_path).await { + let is_guidance = Self::is_write_guidance_message(&message); return ValidationResult { result: false, - message: Some(err.to_string()), + message: Some(message), error_code: Some(400), - meta: None, + meta: is_guidance.then(|| json!({ "failure_kind": "guidance" })), }; } - - if matches!(mode, WriteToolMode::PlaintextFollowup) { - // If content is absent, RoundExecutor would otherwise launch a - // second model request to generate the full file. Reject existing - // targets here so we do not spend tokens producing content that - // Write must reject anyway. If a model already supplied content - // despite the public schema, defer to call_impl so identical - // retries can be treated as idempotent success. - if input.get("content").is_none() { - if let Some(error) = Self::existing_file_error(ctx, &resolved).await { - return ValidationResult { - result: false, - message: Some(error), - error_code: Some(400), - meta: None, - }; - } - } - } } if let Some((line_count, byte_count)) = large_write_warning { diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index 1f68da180..3a6082f8a 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -1,6 +1,7 @@ //! Tool system - includes Tool interface, tool registry and tool executor pub mod file_read_state_runtime; +pub mod file_tool_guidance; pub mod browser_control; pub mod computer_use_capability; pub mod computer_use_host; diff --git a/src/crates/events/src/agentic.rs b/src/crates/events/src/agentic.rs index 9ca1edd30..ddacb6f56 100644 --- a/src/crates/events/src/agentic.rs +++ b/src/crates/events/src/agentic.rs @@ -205,6 +205,20 @@ pub enum AgenticEvent { error: String, }, + /// Emitted when `/goal` verification begins after a dialog turn completes. + GoalVerificationStarted { + session_id: String, + source_turn_id: String, + }, + + /// Emitted when `/goal` verification finishes. + GoalVerificationFinished { + session_id: String, + source_turn_id: String, + /// One of: `achieved`, `continuing`, `failed`, `limit_reached`. + outcome: String, + }, + ModelRoundStarted { session_id: String, turn_id: String, @@ -666,6 +680,8 @@ impl AgenticEvent { | Self::ContextCompressionStarted { session_id, .. } | Self::ContextCompressionCompleted { session_id, .. } | Self::ContextCompressionFailed { session_id, .. } + | Self::GoalVerificationStarted { session_id, .. } + | Self::GoalVerificationFinished { session_id, .. } | Self::DialogTurnCancelled { session_id, .. } | Self::DialogTurnFailed { session_id, .. } | Self::ModelRoundStarted { session_id, .. } @@ -703,6 +719,8 @@ impl AgenticEvent { | Self::TokenUsageUpdated { .. } | Self::DialogTurnCompleted { .. } | Self::ContextCompressionStarted { .. } + | Self::GoalVerificationStarted { .. } + | Self::GoalVerificationFinished { .. } | Self::UserSteeringInjected { .. } | Self::ContextCompressionCompleted { .. } => AgenticEventPriority::Normal, diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index 985c96421..b90006fc7 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -360,6 +360,32 @@ impl TransportAdapter for TauriTransportAdapter { }), )?; } + AgenticEvent::GoalVerificationStarted { + session_id, + source_turn_id, + } => { + self.app_handle.emit( + "agentic://goal-verification-started", + json!({ + "sessionId": session_id, + "sourceTurnId": source_turn_id, + }), + )?; + } + AgenticEvent::GoalVerificationFinished { + session_id, + source_turn_id, + outcome, + } => { + self.app_handle.emit( + "agentic://goal-verification-finished", + json!({ + "sessionId": session_id, + "sourceTurnId": source_turn_id, + "outcome": outcome, + }), + )?; + } AgenticEvent::SessionStateChanged { session_id, new_state, diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 797596cf3..79903de06 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -283,6 +283,9 @@ export const ModernFlowChatContainer: React.FC = ( if (metadata?.localCommandKind === 'goal_pending') { return t('chatInput.goalGenerating'); } + if (metadata?.localCommandKind === 'goal_verifying') { + return t('chatInput.goalVerifying'); + } return null; }, [t]); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 14d3a084c..2f6862cda 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -70,6 +70,8 @@ export const UserMessageItem = React.memo( const messageImages = useMemo(() => message?.images ?? [], [message?.images]); const isUsageReportMessage = message?.metadata?.localCommandKind === 'usage_report'; const isGoalPendingMessage = message?.metadata?.localCommandKind === 'goal_pending'; + const isGoalVerifyingMessage = message?.metadata?.localCommandKind === 'goal_verifying'; + const isGoalLoadingMessage = isGoalPendingMessage || isGoalVerifyingMessage; const isUsageReportLoading = message?.metadata?.usageReportStatus === 'loading'; const usageReport = coerceSessionUsageReport(message?.metadata?.usageReport); const sessionRelationship = useMemo( @@ -358,7 +360,7 @@ export const UserMessageItem = React.memo( ); } - if (isGoalPendingMessage) { + if (isGoalLoadingMessage) { return (
diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 531caad3e..c057a8c12 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -48,6 +48,8 @@ export interface AgenticEventCallbacks { onContextCompressionStarted?: (event: AgenticEvent) => void; onContextCompressionCompleted?: (event: AgenticEvent) => void; onContextCompressionFailed?: (event: AgenticEvent) => void; + onGoalVerificationStarted?: (event: AgenticEvent) => void; + onGoalVerificationFinished?: (event: AgenticEvent) => void; onSessionTitleGenerated?: (event: SessionTitleGeneratedEvent) => void; onSessionModelAutoMigrated?: (event: SessionModelAutoMigratedEvent) => void; onUserSteeringInjected?: (event: UserSteeringInjectedEvent) => void; @@ -224,6 +226,22 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onGoalVerificationStarted) { + const unlisten = agentAPI.onGoalVerificationStarted((event) => { + logger.debug('Goal verification started:', event); + callbacks.onGoalVerificationStarted?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + + if (callbacks.onGoalVerificationFinished) { + const unlisten = agentAPI.onGoalVerificationFinished((event) => { + logger.debug('Goal verification finished:', event); + callbacks.onGoalVerificationFinished?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + if (callbacks.onSessionTitleGenerated) { const unlisten = agentAPI.onSessionTitleGenerated((event) => { logger.debug('Session title generated:', event); 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 23cc092a9..73b121d71 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 @@ -21,6 +21,11 @@ import { import { notificationService } from '../../../shared/notification-system/services/NotificationService'; import type { NotificationAction } from '../../../shared/notification-system/types'; import { createLogger } from '@/shared/utils/logger'; +import { + handleGoalVerificationFinished, + handleGoalVerificationStarted, + type GoalVerificationOutcome, +} from '../goalVerificationService'; import type { DeepReviewQueueStateChangedEvent, ImageAnalysisEvent, @@ -638,6 +643,12 @@ export async function initializeEventListeners( onContextCompressionFailed: (event) => { handleCompressionFailed(context, event); }, + onGoalVerificationStarted: (event) => { + handleGoalVerificationStartedEvent(event); + }, + onGoalVerificationFinished: (event) => { + handleGoalVerificationFinishedEvent(event); + }, onSessionTitleGenerated: (event) => { handleSessionTitleGenerated(event); }, @@ -1309,6 +1320,8 @@ function cleanRemoteUserInput(raw: string): string { function handleDialogTurnStarted(context: FlowChatContext, event: any): void { const { sessionId, turnId, turnIndex, userInput, originalUserInput, userMessageMetadata } = event; + FlowChatStore.getInstance().removeLocalGoalVerifyingTurn(sessionId); + finalizePendingTurnCompletionNow(context, sessionId); clearPendingTurnCompletion(context, sessionId, turnId); @@ -1935,6 +1948,68 @@ function buildUnsuccessfulCompletionError(finishReason?: string): string { : 'Dialog turn ended without a usable result.'; } +function parseGoalVerificationEvent(event: any): { + sessionId?: string; + sourceTurnId?: string; + outcome?: GoalVerificationOutcome; +} { + const sessionId = event?.sessionId ?? event?.session_id; + const sourceTurnId = event?.sourceTurnId ?? event?.source_turn_id; + const rawOutcome = event?.outcome; + const outcome = + rawOutcome === 'achieved' + || rawOutcome === 'continuing' + || rawOutcome === 'failed' + || rawOutcome === 'limit_reached' + ? rawOutcome + : undefined; + + return { + sessionId: typeof sessionId === 'string' ? sessionId : undefined, + sourceTurnId: typeof sourceTurnId === 'string' ? sourceTurnId : undefined, + outcome, + }; +} + +function handleGoalVerificationStartedEvent(event: any): void { + const payload = parseGoalVerificationEvent(event); + if (!payload.sessionId) { + log.warn('GoalVerificationStarted missing sessionId', { event }); + return; + } + + handleGoalVerificationStarted( + { sessionId: payload.sessionId, sourceTurnId: payload.sourceTurnId }, + i18nService.t('flow-chat:chatInput.goalVerifying', { + defaultValue: 'Checking if the session goal is met...', + }), + ); +} + +function handleGoalVerificationFinishedEvent(event: any): void { + const payload = parseGoalVerificationEvent(event); + if (!payload.sessionId) { + log.warn('GoalVerificationFinished missing sessionId', { event }); + return; + } + + handleGoalVerificationFinished( + { + sessionId: payload.sessionId, + sourceTurnId: payload.sourceTurnId, + outcome: payload.outcome, + }, + { + achievedTitle: i18nService.t('flow-chat:chatInput.goalAchieved', { + defaultValue: 'Session goal achieved', + }), + failedMessage: i18nService.t('flow-chat:chatInput.goalVerifyFailed', { + defaultValue: 'Goal verification failed. Check model configuration and try again.', + }), + }, + ); +} + export function handleDialogTurnComplete( context: FlowChatContext, event: any, diff --git a/src/web-ui/src/flow_chat/services/goalVerificationService.test.ts b/src/web-ui/src/flow_chat/services/goalVerificationService.test.ts new file mode 100644 index 000000000..232354e82 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/goalVerificationService.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { flowChatStore } from '../store/FlowChatStore'; +import type { Session } from '../types/flow-chat'; +import { + handleGoalVerificationFinished, + handleGoalVerificationStarted, +} from './goalVerificationService'; + +vi.mock('@/shared/notification-system', () => ({ + notificationService: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +function createSession(overrides: Partial = {}): Session { + return { + sessionId: 'session-1', + dialogTurns: [], + status: 'idle', + config: { modelName: 'auto' }, + createdAt: 1, + lastActiveAt: 1, + error: null, + ...overrides, + }; +} + +describe('goalVerificationService', () => { + beforeEach(() => { + flowChatStore.setState(() => ({ + sessions: new Map(), + activeSessionId: null, + })); + }); + + it('inserts and removes a local goal verifying turn', () => { + const session = createSession(); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + handleGoalVerificationStarted( + { sessionId: session.sessionId, sourceTurnId: 'turn-1' }, + 'Checking if the session goal is met...', + ); + + expect(flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns).toHaveLength(1); + + handleGoalVerificationFinished( + { sessionId: session.sessionId, sourceTurnId: 'turn-1', outcome: 'continuing' }, + { + achievedTitle: 'Session goal achieved', + failedMessage: 'Goal verification failed', + }, + ); + + expect(flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns).toHaveLength(0); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/goalVerificationService.ts b/src/web-ui/src/flow_chat/services/goalVerificationService.ts new file mode 100644 index 000000000..06a4479fe --- /dev/null +++ b/src/web-ui/src/flow_chat/services/goalVerificationService.ts @@ -0,0 +1,59 @@ +import { notificationService } from '@/shared/notification-system'; +import { flowChatStore } from '../store/FlowChatStore'; + +export type GoalVerificationOutcome = 'achieved' | 'continuing' | 'failed' | 'limit_reached'; + +export interface GoalVerificationEventPayload { + sessionId: string; + sourceTurnId?: string; + outcome?: GoalVerificationOutcome; +} + +export function handleGoalVerificationStarted( + payload: GoalVerificationEventPayload, + loadingMessage: string, +): void { + if (!payload.sessionId) return; + + const verifyingId = `verify-${payload.sessionId}-${payload.sourceTurnId ?? Date.now()}`; + flowChatStore.addLocalGoalVerifyingTurn({ + sessionId: payload.sessionId, + message: loadingMessage, + verifyingId, + }); +} + +export function handleGoalVerificationFinished( + payload: GoalVerificationEventPayload, + messages: { + achievedTitle: string; + achievedMessage?: string; + failedMessage: string; + }, +): void { + if (!payload.sessionId) return; + + flowChatStore.removeLocalGoalVerifyingTurn(payload.sessionId); + + switch (payload.outcome) { + case 'achieved': + if (messages.achievedMessage) { + notificationService.success(messages.achievedMessage, { + title: messages.achievedTitle, + duration: 6000, + }); + } else { + notificationService.success(messages.achievedTitle, { + duration: 6000, + }); + } + break; + case 'failed': + notificationService.error(messages.failedMessage, { + duration: 5000, + }); + break; + default: + break; + } +} 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 b57c80b70..bb3d16ef6 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -252,6 +252,30 @@ describe('FlowChatStore local usage reports', () => { expect(stored?.dialogTurns).toHaveLength(0); }); + it('can append and remove local goal verifying turns', () => { + const session = createSession(); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + const turn = flowChatStore.addLocalGoalVerifyingTurn({ + sessionId: session.sessionId, + message: 'Checking if the session goal is met...', + verifyingId: 'verify-1', + }); + + const stored = flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns[0]; + expect(turn).not.toBeNull(); + expect(stored?.userMessage.metadata).toMatchObject({ + localCommandKind: 'goal_verifying', + goalVerifyingId: 'verify-1', + }); + + flowChatStore.removeLocalGoalVerifyingTurn(session.sessionId); + expect(flowChatStore.getState().sessions.get(session.sessionId)?.dialogTurns).toHaveLength(0); + }); + it('appends repeated usage reports as separate snapshots', () => { const session = createSession(); flowChatStore.setState(() => ({ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 102c02ac3..e43631566 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1069,6 +1069,80 @@ export class FlowChatStore { return dialogTurn; } + public addLocalGoalVerifyingTurn(params: { + sessionId: string; + message: string; + verifyingId: string; + }): DialogTurn | null { + this.removeLocalGoalVerifyingTurn(params.sessionId); + + const session = this.state.sessions.get(params.sessionId); + if (!session) { + log.warn('Session not found, cannot add local goal verifying turn', { + sessionId: params.sessionId, + }); + return null; + } + + const generatedAt = Date.now(); + const metadata: LocalCommandMetadata = { + localCommandKind: 'goal_verifying', + modelVisible: false, + goalVerifyingId: params.verifyingId, + generatedAt, + }; + const turnIndex = session.dialogTurns.length; + const dialogTurn: DialogTurn = { + id: `local-goal-verify-${params.verifyingId}`, + sessionId: params.sessionId, + kind: 'local_command', + userMessage: { + id: `local-goal-verify-user-${params.verifyingId}`, + content: params.message, + timestamp: generatedAt, + metadata, + }, + modelRounds: [], + status: 'processing', + startTime: generatedAt, + endTime: generatedAt, + backendTurnIndex: turnIndex, + }; + + this.setState(prev => { + const currentSession = prev.sessions.get(params.sessionId); + if (!currentSession) return prev; + + if (currentSession.dialogTurns.some(turn => turn.id === dialogTurn.id)) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(params.sessionId, { + ...currentSession, + dialogTurns: [...currentSession.dialogTurns, dialogTurn], + }); + + return { + ...prev, + sessions: newSessions, + }; + }); + return dialogTurn; + } + + public removeLocalGoalVerifyingTurn(sessionId: string): void { + const session = this.state.sessions.get(sessionId); + if (!session) return; + + const verifyingTurn = session.dialogTurns.find( + turn => turn.userMessage?.metadata?.localCommandKind === 'goal_verifying', + ); + if (!verifyingTurn) return; + + this.deleteDialogTurn(sessionId, verifyingTurn.id); + } + public deleteDialogTurn(sessionId: string, dialogTurnId: string): void { this.setState(prev => { const session = prev.sessions.get(sessionId); @@ -2429,6 +2503,7 @@ export class FlowChatStore { const displayContent = metadata?.localCommandKind === 'usage_report' || metadata?.localCommandKind === 'goal_pending' + || metadata?.localCommandKind === 'goal_verifying' ? turn.userMessage.content : metadata?.original_text || this.cleanRemoteUserInput(turn.userMessage.content); const normalizedTurnStatus = normalizeRecoveredTurnStatus(turn.status, { error: undefined }); diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss index 60d785371..185826488 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss @@ -103,7 +103,8 @@ } } -.file-error-message-inline { +.file-error-message-inline, +.file-guidance-message-inline { min-width: 0; overflow: hidden; color: var(--color-text-muted); @@ -365,6 +366,40 @@ word-break: break-word; } +.file-operation-card--guidance { + .base-tool-card.status-error { + border-color: var(--border-medium, rgba(255, 255, 255, 0.12)); + background: var(--color-surface-elevated, transparent); + } +} + +.guidance-content { + padding: 8px 12px 10px; +} + +.guidance-title { + display: flex; + align-items: center; + gap: 6px; + color: var(--color-text-secondary); + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + font-weight: 600; + margin-bottom: 6px; + + svg { + flex-shrink: 0; + color: var(--color-text-secondary); + } +} + +.guidance-message { + font-size: var(--tool-card-action-font-size, var(--flowchat-font-size-sm)); + line-height: var(--flowchat-support-line-height, 1.45); + color: var(--color-text-muted); + white-space: pre-wrap; + word-break: break-word; +} + .file-op-header-actions { gap: 4px; } diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx index 8bb2bbac1..d686a4135 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx @@ -364,4 +364,102 @@ describe('FileOperationToolCard', () => { expect(container.textContent).toContain('from-acp-location.ts'); expect(container.textContent).not.toContain('toolCards.file.parsingPath'); }); + + it('renders write guardrail blocks as guidance instead of hard failure', async () => { + const toolItem: FlowToolItem = { + id: 'tool-1', + type: 'tool', + toolName: 'Write', + status: 'error', + toolCall: { + id: 'call-1', + name: 'Write', + input: { + file_path: 'docs/report.md', + }, + }, + toolResult: { + success: false, + error: + '[guidance] Use Read to load the current contents of docs/report.md before calling Write on it.', + }, + } as FlowToolItem; + + const config: ToolCardConfig = { + toolName: 'Write', + displayName: 'Write', + icon: 'WRITE', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Write a file', + displayMode: 'standard', + }; + + await act(async () => { + root.render( + + ); + }); + + expect(container.textContent).toContain('toolCards.file.guidanceHint'); + expect(container.textContent).not.toContain('toolCards.file.failed'); + expect(container.textContent).toContain( + 'Use Read to load the current contents of docs/report.md before calling Write on it.', + ); + expect(container.querySelector('.file-operation-card--guidance')).not.toBeNull(); + }); + + it('renders edit guardrail blocks as guidance instead of hard failure', async () => { + const toolItem: FlowToolItem = { + id: 'tool-2', + type: 'tool', + toolName: 'Edit', + status: 'error', + toolCall: { + id: 'call-2', + name: 'Edit', + input: { + file_path: 'src/main.rs', + old_string: 'foo', + new_string: 'bar', + }, + }, + toolResult: { + success: false, + error: + '[guidance] Use Read to load the current contents of src/main.rs before calling Edit on it.', + }, + } as FlowToolItem; + + const config: ToolCardConfig = { + toolName: 'Edit', + displayName: 'Edit', + icon: 'EDIT', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Edit a file', + displayMode: 'standard', + }; + + await act(async () => { + root.render( + + ); + }); + + expect(container.textContent).toContain('toolCards.file.guidanceHint'); + expect(container.textContent).not.toContain('toolCards.file.failed'); + expect(container.textContent).toContain( + 'Use Read to load the current contents of src/main.rs before calling Edit on it.', + ); + expect(container.querySelector('.file-operation-card--guidance')).not.toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index 56b5fdf19..7936f56cf 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next'; import path from 'path-browserify'; import { XCircle, + Info, GitBranch, FileText, FileEdit, @@ -49,6 +50,10 @@ import { useGitState } from '@/tools/git/hooks/useGitState'; import { ToolCardHeaderActions } from './ToolCardHeaderActions'; import { hasAcpPermissionOptions } from './AcpPermissionActions.utils'; import { AcpPermissionActions } from './AcpPermissionActions'; +import { + displayFileToolGuidanceMessage, + isFileToolGuidanceMessage, +} from './fileToolGuidance'; import './FileOperationToolCard.scss'; const log = createLogger('FileOperationToolCard'); @@ -213,6 +218,18 @@ export const FileOperationToolCard: React.FC = ({ }, [status, toolItem.toolName, writeContentCharCount]); const isFailed = status === 'error' || (toolResult && 'success' in toolResult && !toolResult.success); + const rawErrorMessage = (() => { + if (toolResult && 'error' in toolResult) { + return toolResult.error; + } + if (error) { + return error; + } + return undefined; + })(); + const isFileGuidanceBlocked = + (toolItem.toolName === 'Write' || toolItem.toolName === 'Edit') + && isFileToolGuidanceMessage(rawErrorMessage); const showConfirmationActions = Boolean( requiresConfirmation && !userConfirmed && @@ -462,17 +479,22 @@ export const FileOperationToolCard: React.FC = ({ ]); const getErrorMessage = () => { - if (toolResult && 'error' in toolResult) { - return toolResult.error; - } - if (error) { - return error; + if (rawErrorMessage !== undefined) { + return rawErrorMessage; } return t('error.unknown'); }; + const getDisplayMessage = () => { + const message = getErrorMessage(); + if (isFileGuidanceBlocked) { + return displayFileToolGuidanceMessage(message); + } + return message; + }; + const getSingleLineErrorMessage = () => { - return String(getErrorMessage()).replace(/\s+/g, ' ').trim(); + return String(getDisplayMessage()).replace(/\s+/g, ' ').trim(); }; const handleOpenInCodeEditor = useCallback(async () => { @@ -863,13 +885,23 @@ export const FileOperationToolCard: React.FC = ({ return null; }; + const renderGuidanceContent = () => ( +
+
+ + {t('toolCards.file.guidanceTitle')} +
+
{getDisplayMessage()}
+
+ ); + const renderErrorContent = () => (
{toolDisplayInfo.name}{t('toolCards.file.failed')}
-
{getErrorMessage()}
+
{getDisplayMessage()}
); @@ -918,7 +950,11 @@ export const FileOperationToolCard: React.FC = ({ const actionText = isDeleteTool ? '' - : (isFailed ? `${toolDisplayInfo.name}${t('toolCards.file.failed')}` : `${toolDisplayInfo.name}:`); + : (isFailed + ? (isFileGuidanceBlocked + ? `${toolDisplayInfo.name}${t('toolCards.file.guidanceHint')}` + : `${toolDisplayInfo.name}${t('toolCards.file.failed')}`) + : `${toolDisplayInfo.name}:`); return ( = ({ action={actionText} content={ isFailed ? ( - + {getSingleLineErrorMessage()} ) : ( @@ -1093,10 +1135,14 @@ export const FileOperationToolCard: React.FC = ({ status={status} isExpanded={isCardContentExpanded} onClick={handleCardClick} - className={`file-operation-card ${isDeleteTool ? 'non-clickable' : ''}`} + className={`file-operation-card ${isDeleteTool ? 'non-clickable' : ''} ${isFileGuidanceBlocked ? 'file-operation-card--guidance' : ''}`.trim()} header={renderHeader()} expandedContent={expandedContent} - errorContent={isFailed && isErrorExpanded ? renderErrorContent() : null} + errorContent={ + isFailed && isErrorExpanded + ? (isFileGuidanceBlocked ? renderGuidanceContent() : renderErrorContent()) + : null + } isFailed={isFailed} requiresConfirmation={showConfirmationActions} headerExpandAffordance={hasExpandableContent} diff --git a/src/web-ui/src/flow_chat/tool-cards/fileToolGuidance.test.ts b/src/web-ui/src/flow_chat/tool-cards/fileToolGuidance.test.ts new file mode 100644 index 000000000..c7a676c29 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/fileToolGuidance.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + FILE_TOOL_GUIDANCE_PREFIX, + displayFileToolGuidanceMessage, + isFileToolGuidanceMessage, +} from './fileToolGuidance'; + +describe('fileToolGuidance', () => { + it('detects guidance-prefixed messages', () => { + const message = `${FILE_TOOL_GUIDANCE_PREFIX}Use Read first.`; + expect(isFileToolGuidanceMessage(message)).toBe(true); + expect(displayFileToolGuidanceMessage(message)).toBe('Use Read first.'); + }); + + it('leaves non-guidance messages unchanged', () => { + expect(isFileToolGuidanceMessage('Permission denied')).toBe(false); + expect(displayFileToolGuidanceMessage('Permission denied')).toBe('Permission denied'); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/fileToolGuidance.ts b/src/web-ui/src/flow_chat/tool-cards/fileToolGuidance.ts new file mode 100644 index 000000000..055e0c58b --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/fileToolGuidance.ts @@ -0,0 +1,24 @@ +/** Mirrors `FILE_TOOL_GUIDANCE_PREFIX` in file_tool_guidance.rs */ +export const FILE_TOOL_GUIDANCE_PREFIX = '[guidance] '; + +/** @deprecated Use FILE_TOOL_GUIDANCE_PREFIX */ +export const WRITE_TOOL_GUIDANCE_PREFIX = FILE_TOOL_GUIDANCE_PREFIX; + +export function isFileToolGuidanceMessage(message: unknown): boolean { + return typeof message === 'string' && message.startsWith(FILE_TOOL_GUIDANCE_PREFIX); +} + +/** @deprecated Use isFileToolGuidanceMessage */ +export const isWriteToolGuidanceMessage = isFileToolGuidanceMessage; + +export function displayFileToolGuidanceMessage(message: unknown): string { + if (typeof message !== 'string') { + return ''; + } + return message.startsWith(FILE_TOOL_GUIDANCE_PREFIX) + ? message.slice(FILE_TOOL_GUIDANCE_PREFIX.length) + : message; +} + +/** @deprecated Use displayFileToolGuidanceMessage */ +export const displayWriteToolGuidanceMessage = displayFileToolGuidanceMessage; diff --git a/src/web-ui/src/flow_chat/tool-cards/writeToolGuidance.test.ts b/src/web-ui/src/flow_chat/tool-cards/writeToolGuidance.test.ts new file mode 100644 index 000000000..e49c3f6d9 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/writeToolGuidance.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { + WRITE_TOOL_GUIDANCE_PREFIX, + displayWriteToolGuidanceMessage, + isWriteToolGuidanceMessage, +} from './writeToolGuidance'; + +describe('writeToolGuidance', () => { + it('detects guidance-prefixed messages', () => { + const message = `${WRITE_TOOL_GUIDANCE_PREFIX}Use Read first.`; + expect(isWriteToolGuidanceMessage(message)).toBe(true); + expect(displayWriteToolGuidanceMessage(message)).toBe('Use Read first.'); + }); + + it('leaves non-guidance messages unchanged', () => { + expect(isWriteToolGuidanceMessage('Permission denied')).toBe(false); + expect(displayWriteToolGuidanceMessage('Permission denied')).toBe('Permission denied'); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/writeToolGuidance.ts b/src/web-ui/src/flow_chat/tool-cards/writeToolGuidance.ts new file mode 100644 index 000000000..05bc67c55 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/writeToolGuidance.ts @@ -0,0 +1,8 @@ +export { + FILE_TOOL_GUIDANCE_PREFIX, + WRITE_TOOL_GUIDANCE_PREFIX, + displayFileToolGuidanceMessage, + displayWriteToolGuidanceMessage, + isFileToolGuidanceMessage, + isWriteToolGuidanceMessage, +} from './fileToolGuidance'; diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index d948ead10..536587246 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -671,6 +671,14 @@ export class AgentAPI { return api.listen('agentic://context-compression-failed', callback); } + onGoalVerificationStarted(callback: (event: AgenticEvent) => void): () => void { + return api.listen('agentic://goal-verification-started', callback); + } + + onGoalVerificationFinished(callback: (event: AgenticEvent) => void): () => void { + return api.listen('agentic://goal-verification-finished', callback); + } + onImageAnalysisStarted(callback: (event: ImageAnalysisEvent) => void): () => void { return api.listen('agentic://image-analysis-started', callback); } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 88f662cbc..a21720b78 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -494,6 +494,9 @@ "usageFailed": "Usage report failed", "goalAction": "Session goal", "goalGenerating": "Generating session goal...", + "goalVerifying": "Checking if the session goal is met...", + "goalAchieved": "Session goal achieved", + "goalVerifyFailed": "Goal verification failed. Check model configuration and try again.", "goalNoSession": "No active session for /goal", "goalUsage": "Use /goal with optional focus text, for example /goal fix the login bug.", "goalFailed": "Goal mode activation failed", @@ -1069,6 +1072,8 @@ "delete": "Delete File", "deletedLabel": "Deleted", "failed": "Failed", + "guidanceHint": " hint", + "guidanceTitle": "Hint", "parsingPath": "Parsing file path...", "unknownFile": "Unknown file", "receivingParams": "Receiving parameters...", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index bae8d3a81..ccd19173d 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -488,6 +488,9 @@ "compactFailed": "会话压缩失败", "goalAction": "会话目标", "goalGenerating": "正在生成目标…", + "goalVerifying": "正在验证目标是否达成…", + "goalAchieved": "会话目标已达成", + "goalVerifyFailed": "目标验证失败,请检查模型配置后重试。", "goalNoSession": "当前没有可用于 /goal 的会话", "goalUsage": "使用 /goal 并可选择附加目标描述,例如 /goal 修复登录问题。", "goalFailed": "目标模式激活失败", @@ -1069,6 +1072,8 @@ "delete": "删除文件", "deletedLabel": "删除", "failed": "失败", + "guidanceHint": "提示", + "guidanceTitle": "提示", "parsingPath": "解析文件路径中...", "unknownFile": "未知文件", "receivingParams": "接收参数中...", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index acf5ed419..a248d658f 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -488,6 +488,9 @@ "compactFailed": "會話壓縮失敗", "goalAction": "會話目標", "goalGenerating": "正在生成目標…", + "goalVerifying": "正在驗證目標是否達成…", + "goalAchieved": "會話目標已達成", + "goalVerifyFailed": "目標驗證失敗,請檢查模型配置後重試。", "goalNoSession": "當前沒有可用於 /goal 的會話", "goalUsage": "使用 /goal 並可選擇附加目標描述,例如 /goal 修復登入問題。", "goalFailed": "目標模式啟用失敗", @@ -1069,6 +1072,8 @@ "delete": "刪除文件", "deletedLabel": "刪除", "failed": "失敗", + "guidanceHint": "提示", + "guidanceTitle": "提示", "parsingPath": "解析文件路徑中...", "unknownFile": "未知文件", "receivingParams": "接收參數中...", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 37952433b..c5dc11c83 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -98,7 +98,7 @@ export interface ReviewActionPersistedState { export type SessionStatus = 'active' | 'archived' | 'completed'; export type DialogTurnKind = 'user_dialog' | 'manual_compaction' | 'local_command'; -export type LocalCommandKind = 'usage_report' | 'goal_pending'; +export type LocalCommandKind = 'usage_report' | 'goal_pending' | 'goal_verifying'; export interface LocalCommandMetadata { localCommandKind: LocalCommandKind; @@ -109,6 +109,7 @@ export interface LocalCommandMetadata { usageReport?: Record; usageReportStatus?: 'loading' | 'completed'; goalPendingId?: string; + goalVerifyingId?: string; } export interface SessionList {