diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 2d89592a..095d5e3a 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -9,7 +9,9 @@ use tokio::sync::mpsc; use super::{Agent, AgentEvent, AgentResponse}; use crate::session::{ToolCall, ToolCallStatus}; -use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogTriggerSource}; +use bitfun_core::agentic::coordination::{ + ConversationCoordinator, DialogSubmissionPolicy, DialogTriggerSource, +}; use bitfun_core::agentic::core::SessionConfig; use bitfun_core::agentic::events::EventQueue; use bitfun_events::{AgenticEvent as CoreEvent, ToolEventData}; @@ -99,7 +101,6 @@ impl Agent for CoreAgentAdapter { tracing::info!("Processing message: {}", message); let _ = event_tx.send(AgentEvent::Thinking); - self.coordinator .start_dialog_turn( session_id.clone(), @@ -108,7 +109,7 @@ impl Agent for CoreAgentAdapter { None, self.agent_type.clone(), None, - DialogTriggerSource::Cli, + DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), ) .await?; diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 0bd6f839..b4dc125d 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -8,7 +8,8 @@ use tauri::{AppHandle, State}; use crate::api::app_state::AppState; use bitfun_core::agentic::coordination::{ - ConversationCoordinator, DialogScheduler, DialogTriggerSource, + AssistantBootstrapBlockReason, AssistantBootstrapEnsureOutcome, AssistantBootstrapSkipReason, + ConversationCoordinator, DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, }; use bitfun_core::agentic::core::*; use bitfun_core::agentic::image_analysis::ImageContextData; @@ -65,6 +66,23 @@ pub struct StartDialogTurnResponse { pub message: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnsureAssistantBootstrapRequest { + pub session_id: String, + pub workspace_path: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EnsureAssistantBootstrapResponse { + pub status: String, + pub reason: String, + pub session_id: String, + pub turn_id: Option, + pub detail: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetSessionRequest { @@ -229,7 +247,7 @@ pub async fn start_dialog_turn( turn_id, agent_type, workspace_path, - DialogTriggerSource::DesktopUi, + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi), ) .await .map_err(|e| format!("Failed to start dialog turn: {}", e))?; @@ -242,7 +260,8 @@ pub async fn start_dialog_turn( turn_id, agent_type, workspace_path, - DialogTriggerSource::DesktopUi, + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopUi), + None, ) .await .map_err(|e| format!("Failed to start dialog turn: {}", e))?; @@ -254,6 +273,19 @@ pub async fn start_dialog_turn( }) } +#[tauri::command] +pub async fn ensure_assistant_bootstrap( + coordinator: State<'_, Arc>, + request: EnsureAssistantBootstrapRequest, +) -> Result { + let outcome = coordinator + .ensure_assistant_bootstrap(request.session_id, request.workspace_path) + .await + .map_err(|e| format!("Failed to ensure assistant bootstrap: {}", e))?; + + Ok(assistant_bootstrap_outcome_to_response(outcome)) +} + fn is_blank_text(value: Option<&String>) -> bool { value.map(|s| s.trim().is_empty()).unwrap_or(true) } @@ -517,6 +549,57 @@ pub struct ModeInfoDTO { pub enabled: bool, } +fn assistant_bootstrap_outcome_to_response( + outcome: AssistantBootstrapEnsureOutcome, +) -> EnsureAssistantBootstrapResponse { + match outcome { + AssistantBootstrapEnsureOutcome::Started { + session_id, + turn_id, + } => EnsureAssistantBootstrapResponse { + status: "started".to_string(), + reason: "bootstrap_started".to_string(), + session_id, + turn_id: Some(turn_id), + detail: None, + }, + AssistantBootstrapEnsureOutcome::Skipped { session_id, reason } => { + EnsureAssistantBootstrapResponse { + status: "skipped".to_string(), + reason: assistant_bootstrap_skip_reason_to_str(reason).to_string(), + session_id, + turn_id: None, + detail: None, + } + } + AssistantBootstrapEnsureOutcome::Blocked { + session_id, + reason, + detail, + } => EnsureAssistantBootstrapResponse { + status: "blocked".to_string(), + reason: assistant_bootstrap_block_reason_to_str(reason).to_string(), + session_id, + turn_id: None, + detail: Some(detail), + }, + } +} + +fn assistant_bootstrap_skip_reason_to_str(reason: AssistantBootstrapSkipReason) -> &'static str { + match reason { + AssistantBootstrapSkipReason::BootstrapNotRequired => "bootstrap_not_required", + AssistantBootstrapSkipReason::SessionHasExistingTurns => "session_has_existing_turns", + AssistantBootstrapSkipReason::SessionNotIdle => "session_not_idle", + } +} + +fn assistant_bootstrap_block_reason_to_str(reason: AssistantBootstrapBlockReason) -> &'static str { + match reason { + AssistantBootstrapBlockReason::ModelUnavailable => "model_unavailable", + } +} + fn session_to_response(session: Session) -> SessionResponse { SessionResponse { session_id: session.session_id, diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 9cefbcf4..349849e0 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -1,7 +1,7 @@ //! Application state management -use bitfun_core::agentic::{agents, tools}; use bitfun_core::agentic::side_question::SideQuestionRuntime; +use bitfun_core::agentic::{agents, tools}; use bitfun_core::infrastructure::ai::{AIClient, AIClientFactory}; use bitfun_core::miniapp::{initialize_global_miniapp_manager, JsWorkerPool, MiniAppManager}; use bitfun_core::service::{ai_rules, config, filesystem, mcp, token_usage, workspace}; diff --git a/src/apps/desktop/src/api/image_analysis_api.rs b/src/apps/desktop/src/api/image_analysis_api.rs index 14662811..961df1fb 100644 --- a/src/apps/desktop/src/api/image_analysis_api.rs +++ b/src/apps/desktop/src/api/image_analysis_api.rs @@ -1,7 +1,9 @@ //! Image Analysis API use crate::api::app_state::AppState; -use bitfun_core::agentic::coordination::{DialogScheduler, DialogTriggerSource}; +use bitfun_core::agentic::coordination::{ + DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, +}; use bitfun_core::agentic::image_analysis::{ resolve_vision_model_from_ai_config, AnalyzeImagesRequest, ImageAnalysisResult, ImageAnalyzer, MessageEnhancer, SendEnhancedMessageRequest, @@ -97,7 +99,8 @@ pub async fn send_enhanced_message( Some(request.dialog_turn_id.clone()), request.agent_type.clone(), None, - DialogTriggerSource::DesktopApi, + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi), + None, ) .await .map_err(|e| format!("Failed to send enhanced message: {}", e))?; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6c6a9b5c..edc514a8 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -296,6 +296,7 @@ pub async fn run() { theme::show_main_window, api::agentic_api::create_session, api::agentic_api::start_dialog_turn, + api::agentic_api::ensure_assistant_bootstrap, api::agentic_api::cancel_dialog_turn, api::agentic_api::delete_session, api::agentic_api::restore_session, diff --git a/src/crates/core/src/agentic/agents/claw_mode.rs b/src/crates/core/src/agentic/agents/claw_mode.rs index 4b9900c8..1b05edac 100644 --- a/src/crates/core/src/agentic/agents/claw_mode.rs +++ b/src/crates/core/src/agentic/agents/claw_mode.rs @@ -26,6 +26,7 @@ impl ClawMode { "Git".to_string(), "TerminalControl".to_string(), "SessionControl".to_string(), + "SessionMessage".to_string(), ], } } diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md index 4c4bb017..9f4df204 100644 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md @@ -4,7 +4,7 @@ You are pair programming with a USER to solve their coding task. Each time the U Your main goal is to follow the USER's instructions at each message, denoted by the tag. -Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. +Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation. IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. @@ -84,7 +84,7 @@ The user will primarily request you perform software engineering tasks. This inc - Don't create helpers, utilities, or abstractions for one-time operations. Don't design for hypothetical future requirements. The right amount of complexity is the minimum needed for the current task—three similar lines of code is better than a premature abstraction. - Avoid backwards-compatibility hacks like renaming unused `_vars`, re-exporting types, adding `// removed` comments for removed code, etc. If something is unused, delete it completely. -- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. +- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. # Tool usage policy diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md b/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md index 831fad2a..60032a83 100644 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md +++ b/src/crates/core/src/agentic/agents/prompts/agentic_mode_gpt5.md @@ -4,7 +4,7 @@ You are pair programming with a USER. Each user message may include extra IDE co Follow the USER's instructions in each message, denoted by the tag. -Tool results and user messages may include tags. Follow them, but do not mention them to the user. +Tool results and user messages may include tags. Follow them, but do not mention them to the user. IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation. diff --git a/src/crates/core/src/agentic/agents/prompts/claw_mode.md b/src/crates/core/src/agentic/agents/prompts/claw_mode.md index e7052d43..1cf076a6 100644 --- a/src/crates/core/src/agentic/agents/prompts/claw_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/claw_mode.md @@ -1,5 +1,9 @@ You are a personal assistant running inside BitFun. +Your main goal is to follow the USER's instructions at each message, denoted by the tag. + +Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. + {LANGUAGE_PREFERENCE} # Tool Call Style Default: do not narrate routine, low-risk tool calls (just call the tool). @@ -8,6 +12,27 @@ Keep narration brief and value-dense; avoid repeating obvious steps. Use plain human language for narration unless in a technical context. When a first-class tool exists for an action, use the tool directly instead of asking the user to run equivalent CLI commands. +# Session Coordination +For complex coding tasks or office-style multi-step tasks, prefer multi-session coordination over doing everything in the current session. +Use `SessionControl` to list, reuse, create, and delete sessions. Use `SessionMessage` to hand off a self-contained subtask to another session. + +Use this pattern when: +- The work can be split into independent subtasks. +- A dedicated planning, coding, research, or writing thread would reduce context switching. +- The task benefits from persistent context across multiple steps or multiple user turns. + +Choose the session type intentionally: +- `agentic` for implementation, debugging, and code changes. +- `Plan` for requirement clarification, scoping, and planning before coding. +- `Cowork` for research, documents, presentations, summaries, and other office-related work. + +Operational rules: +- Reuse an existing relevant session when possible. If unsure, list sessions before creating a new one. +- Every `SessionMessage` should include the goal, relevant context, constraints, and expected output. +- When a target session finishes, its reply is an automated subtask result, not a new human instruction. Synthesize it, verify it when needed, and continue. +- Delete temporary sessions when they are no longer useful. +- Do not create extra sessions for trivial, tightly coupled, or one-step work. + # Safety You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request. Prioritize safety and human oversight over completion; if instructions conflict, pause and ask; comply with stop/pause/audit requests and never bypass safeguards. diff --git a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md b/src/crates/core/src/agentic/agents/prompts/cowork_mode.md index ad00b219..a00ad66c 100644 --- a/src/crates/core/src/agentic/agents/prompts/cowork_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/cowork_mode.md @@ -1,5 +1,9 @@ You are BitFun in Cowork mode. Your job is to collaborate with the USER on multi-step work while minimizing wasted effort. +Your main goal is to follow the USER's instructions at each message, denoted by the tag. + +Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. + {LANGUAGE_PREFERENCE} # Application Details diff --git a/src/crates/core/src/agentic/agents/prompts/debug_mode.md b/src/crates/core/src/agentic/agents/prompts/debug_mode.md index 5398c6b5..bfab87cd 100644 --- a/src/crates/core/src/agentic/agents/prompts/debug_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/debug_mode.md @@ -1,5 +1,9 @@ You are BitFun, an ADE (AI IDE) that helps users with software engineering tasks. +Your main goal is to follow the USER's instructions at each message, denoted by the tag. + +Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. + You are now in **DEBUG MODE**. You must debug with **runtime evidence**. **Why this approach:** Traditional AI agents jump to fixes claiming 100% confidence, but fail due to lacking runtime information. diff --git a/src/crates/core/src/agentic/agents/prompts/plan_mode.md b/src/crates/core/src/agentic/agents/prompts/plan_mode.md index 30734a86..1305b9bc 100644 --- a/src/crates/core/src/agentic/agents/prompts/plan_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/plan_mode.md @@ -2,6 +2,10 @@ You are a software architect and planning specialist for designing implementatio You MUST NOT make any edits (with the exception of the plan file you created), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received (for example, to make edits). +Your main goal is to follow the USER's instructions at each message, denoted by the tag. + +Tool results and user messages may include tags. These tags contain useful information and reminders. Please heed them, but don't mention them in your response to the user. + {LANGUAGE_PREFERENCE} # Plan Workflow diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 7b5e83e8..0c0983db 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -2,10 +2,11 @@ //! //! Top-level component that integrates all subsystems and provides a unified interface +use super::{scheduler::DialogSubmissionPolicy, turn_outcome::TurnOutcome}; use crate::agentic::agents::get_agent_registry; use crate::agentic::core::{ - Message, MessageContent, ProcessingPhase, Session, SessionConfig, SessionState, SessionSummary, - TurnStats, + has_prompt_markup, Message, MessageContent, ProcessingPhase, PromptEnvelope, Session, + SessionConfig, SessionState, SessionSummary, TurnStats, }; use crate::agentic::events::{ AgenticEvent, EventPriority, EventQueue, EventRouter, EventSubscriber, @@ -15,7 +16,7 @@ use crate::agentic::image_analysis::ImageContextData; use crate::agentic::session::SessionManager; use crate::agentic::tools::pipeline::{SubagentParentInfo, ToolPipeline}; use crate::agentic::WorkspaceBinding; -use crate::service::workspace::get_global_workspace_service; +use crate::service::bootstrap::is_workspace_bootstrap_pending; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error, info, warn}; use std::path::{Path, PathBuf}; @@ -33,21 +34,47 @@ pub struct SubagentResult { pub text: String, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DialogTriggerSource { DesktopUi, DesktopApi, + AgentSession, RemoteRelay, Bot, Cli, } -impl DialogTriggerSource { - fn skip_tool_confirmation(self) -> bool { - matches!(self, Self::RemoteRelay | Self::Bot) - } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssistantBootstrapSkipReason { + BootstrapNotRequired, + SessionHasExistingTurns, + SessionNotIdle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AssistantBootstrapBlockReason { + ModelUnavailable, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AssistantBootstrapEnsureOutcome { + Started { + session_id: String, + turn_id: String, + }, + Skipped { + session_id: String, + reason: AssistantBootstrapSkipReason, + }, + Blocked { + session_id: String, + reason: AssistantBootstrapBlockReason, + detail: String, + }, +} + +const ASSISTANT_BOOTSTRAP_AGENT_TYPE: &str = "Claw"; + /// Cancel token cleanup guard /// /// Automatically cleans up cancel tokens in ExecutionEngine when dropped @@ -67,17 +94,6 @@ impl Drop for CancelTokenGuard { } } -/// Outcome of a completed dialog turn, used to notify DialogScheduler -#[derive(Debug, Clone)] -pub enum TurnOutcome { - /// Turn completed normally - Completed, - /// Turn was cancelled by user - Cancelled, - /// Turn failed with an error - Failed, -} - /// Conversation coordinator pub struct ConversationCoordinator { session_manager: Arc, @@ -101,41 +117,39 @@ impl ConversationCoordinator { .map(|workspace_path| WorkspaceBinding::new(None, PathBuf::from(workspace_path))) } - async fn normalize_agent_type_for_workspace_path( - agent_type: &str, - workspace_path: &str, - ) -> String { - let normalized_agent_type = if agent_type.trim().is_empty() { + fn normalize_agent_type(agent_type: &str) -> String { + if agent_type.trim().is_empty() { "agentic".to_string() } else { agent_type.trim().to_string() - }; + } + } - let Some(workspace_service) = get_global_workspace_service() else { - return normalized_agent_type; - }; + fn ensure_user_message_metadata_object( + metadata: Option, + ) -> serde_json::Value { + match metadata { + Some(value) if value.is_object() => value, + Some(value) => serde_json::json!({ "raw_metadata": value }), + None => serde_json::json!({}), + } + } - let workspace_path_buf = PathBuf::from(workspace_path); - if workspace_service.is_assistant_workspace_path(&workspace_path_buf) - || workspace_service - .get_workspace_by_path(&workspace_path_buf) - .await - .map(|workspace| { - workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Assistant - }) - .unwrap_or(false) - { - if normalized_agent_type != "Claw" { - info!( - "Normalize agent type to Claw for assistant workspace: workspace_path={}, requested_agent_type={}", - workspace_path, - normalized_agent_type - ); - } - return "Claw".to_string(); + fn assistant_bootstrap_kickoff_query(is_chinese: bool) -> &'static str { + if is_chinese { + "请开始初始化" + } else { + "Please start bootstrap" } + } - normalized_agent_type + fn assistant_bootstrap_system_reminder(kickoff_query: &str, expected_reply_language: &str) -> String { + format!( + "This is an automatic bootstrap kickoff generated by the system because this assistant workspace still contains BOOTSTRAP.md. \ +Treat the user message `{kickoff_query}` only as a start signal, begin bootstrap immediately, and finish it in this session. \ +Use {expected_reply_language} for all user-facing replies during bootstrap unless the user later asks to switch languages. \ +Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complete." + ) } pub fn new( @@ -243,8 +257,7 @@ impl ConversationCoordinator { // Persist the workspace binding inside the session config so execution can // consistently restore the correct workspace regardless of the entry point. config.workspace_path = Some(workspace_path.clone()); - let agent_type = - Self::normalize_agent_type_for_workspace_path(&agent_type, &workspace_path).await; + let agent_type = Self::normalize_agent_type(&agent_type); let session = self .session_manager .create_session_with_id_and_creator( @@ -472,25 +485,136 @@ impl ConversationCoordinator { .ok_or_else(|| BitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; let system_reminder = current_agent.get_system_reminder(0).await?; - let mut wrapped_user_input = if agent_type == "agentic" { - // Only this mode uses user_query tag - format!("\n{}\n\n", user_input) - } else { + let mut wrapped_user_input = if has_prompt_markup(&user_input) { user_input + } else { + let mut envelope = PromptEnvelope::new(); + envelope.push_user_query(user_input); + envelope.render() }; if !system_reminder.is_empty() { - wrapped_user_input.push_str(&format!( - "\n{}\n", - system_reminder - )); + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(system_reminder); + if !wrapped_user_input.is_empty() { + wrapped_user_input.push('\n'); + } + wrapped_user_input.push_str(&envelope.render()); } Ok(wrapped_user_input) } + pub async fn ensure_assistant_bootstrap( + &self, + session_id: String, + workspace_path: String, + ) -> BitFunResult { + let workspace_root = PathBuf::from(&workspace_path); + if !is_workspace_bootstrap_pending(&workspace_root) { + return Ok(AssistantBootstrapEnsureOutcome::Skipped { + session_id, + reason: AssistantBootstrapSkipReason::BootstrapNotRequired, + }); + } + + let session = match self.session_manager.get_session(&session_id) { + Some(session) => session, + None => { + self.session_manager + .restore_session(&workspace_root, &session_id) + .await? + } + }; + + if self.session_manager.get_turn_count(&session_id) > 0 { + return Ok(AssistantBootstrapEnsureOutcome::Skipped { + session_id, + reason: AssistantBootstrapSkipReason::SessionHasExistingTurns, + }); + } + + if !matches!(session.state, SessionState::Idle) { + return Ok(AssistantBootstrapEnsureOutcome::Skipped { + session_id, + reason: AssistantBootstrapSkipReason::SessionNotIdle, + }); + } + + let is_chinese = Self::is_chinese_locale().await; + let kickoff_query = Self::assistant_bootstrap_kickoff_query(is_chinese); + let expected_reply_language = if is_chinese { "Chinese" } else { "English" }; + let workspace_binding = WorkspaceBinding::new(None, workspace_root.clone()); + let model_id = self + .execution_engine + .resolve_model_id_for_turn( + &session, + ASSISTANT_BOOTSTRAP_AGENT_TYPE, + Some(&workspace_binding), + kickoff_query, + 0, + ) + .await?; + + let ai_client_factory = + match crate::infrastructure::ai::get_global_ai_client_factory().await { + Ok(factory) => factory, + Err(error) => { + return Ok(AssistantBootstrapEnsureOutcome::Blocked { + session_id, + reason: AssistantBootstrapBlockReason::ModelUnavailable, + detail: format!("Failed to get AI client factory: {error}"), + }); + } + }; + + if let Err(error) = ai_client_factory.get_client_resolved(&model_id).await { + return Ok(AssistantBootstrapEnsureOutcome::Blocked { + session_id, + reason: AssistantBootstrapBlockReason::ModelUnavailable, + detail: format!("Failed to get AI client (model_id={model_id}): {error}"), + }); + } + + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(Self::assistant_bootstrap_system_reminder( + kickoff_query, + expected_reply_language, + )); + envelope.push_user_query(kickoff_query.to_string()); + + let turn_id = format!("assistant-bootstrap-{}", uuid::Uuid::new_v4()); + let metadata = serde_json::json!({ + "assistant_bootstrap": { + "trigger": "lazy_auto", + "system_generated": true, + "workspace_path": workspace_path, + } + }); + + self.start_dialog_turn_internal( + session_id.clone(), + envelope.render(), + Some(kickoff_query.to_string()), + None, + Some(turn_id.clone()), + ASSISTANT_BOOTSTRAP_AGENT_TYPE.to_string(), + Some(workspace_root.to_string_lossy().to_string()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::DesktopApi) + .with_skip_tool_confirmation(true), + Some(metadata), + true, + ) + .await?; + + Ok(AssistantBootstrapEnsureOutcome::Started { + session_id, + turn_id, + }) + } + /// Start a new dialog turn /// Note: Events are sent to frontend via EventLoop, no Stream returned. - /// Channel-specific interaction policy is decided here from `trigger_source` - /// so adapters only declare where the message came from. + /// Submission behavior is controlled by `submission_policy`, which provides + /// default per-source behavior while still allowing selective overrides. pub async fn start_dialog_turn( &self, session_id: String, @@ -499,7 +623,7 @@ impl ConversationCoordinator { turn_id: Option, agent_type: String, workspace_path: Option, - trigger_source: DialogTriggerSource, + submission_policy: DialogSubmissionPolicy, ) -> BitFunResult<()> { self.start_dialog_turn_internal( session_id, @@ -509,7 +633,9 @@ impl ConversationCoordinator { turn_id, agent_type, workspace_path, - trigger_source, + submission_policy, + None, + false, ) .await } @@ -523,7 +649,7 @@ impl ConversationCoordinator { turn_id: Option, agent_type: String, workspace_path: Option, - trigger_source: DialogTriggerSource, + submission_policy: DialogSubmissionPolicy, ) -> BitFunResult<()> { self.start_dialog_turn_internal( session_id, @@ -533,7 +659,9 @@ impl ConversationCoordinator { turn_id, agent_type, workspace_path, - trigger_source, + submission_policy, + None, + false, ) .await } @@ -676,7 +804,9 @@ impl ConversationCoordinator { turn_id: Option, agent_type: String, workspace_path: Option, - trigger_source: DialogTriggerSource, + submission_policy: DialogSubmissionPolicy, + extra_user_message_metadata: Option, + suppress_session_title_generation: bool, ) -> BitFunResult<()> { // Get latest session, restoring from persistence on demand so every entry // point can use the same start_dialog_turn flow. @@ -700,9 +830,6 @@ impl ConversationCoordinator { }; let requested_agent_type = agent_type.trim().to_string(); - let workspace_path_for_policy = workspace_path - .clone() - .or_else(|| session.config.workspace_path.clone()); let provisional_agent_type = if !requested_agent_type.is_empty() { requested_agent_type.clone() } else if !session.agent_type.is_empty() { @@ -710,15 +837,10 @@ impl ConversationCoordinator { } else { "agentic".to_string() }; - let effective_agent_type = if let Some(ref workspace_path) = workspace_path_for_policy { - Self::normalize_agent_type_for_workspace_path(&provisional_agent_type, workspace_path) - .await - } else { - provisional_agent_type - }; + let effective_agent_type = Self::normalize_agent_type(&provisional_agent_type); debug!( - "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}", + "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}, queue_priority={:?}, skip_tool_confirmation={}", session_id, turn_id.as_deref().unwrap_or(""), if requested_agent_type.is_empty() { @@ -732,7 +854,9 @@ impl ConversationCoordinator { session.agent_type.as_str() }, effective_agent_type, - trigger_source + submission_policy.trigger_source, + submission_policy.queue_priority, + submission_policy.skip_tool_confirmation ); if session.agent_type != effective_agent_type { @@ -863,41 +987,47 @@ impl ConversationCoordinator { let original_user_input = original_user_input.unwrap_or_else(|| user_input.clone()); + let mut user_message_metadata = extra_user_message_metadata; + // Build image metadata for workspace turn persistence (before image_contexts is consumed) // Also stores original_text so the UI can display the user's actual input // instead of the vision-enhanced text. - let user_message_metadata: Option = image_contexts - .as_ref() - .filter(|imgs| !imgs.is_empty()) - .map(|imgs| { - let image_meta: Vec = imgs - .iter() - .map(|img| { - let name = img - .metadata - .as_ref() - .and_then(|m| m.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("image.png"); - let mut meta = serde_json::json!({ - "id": &img.id, - "name": name, - "mime_type": &img.mime_type, - }); - if let Some(url) = &img.data_url { - meta["data_url"] = serde_json::json!(url); - } - if let Some(path) = &img.image_path { - meta["image_path"] = serde_json::json!(path); - } - meta - }) - .collect(); - serde_json::json!({ - "images": image_meta, - "original_text": &original_user_input, + if let Some(imgs) = image_contexts.as_ref().filter(|imgs| !imgs.is_empty()) { + let image_meta: Vec = imgs + .iter() + .map(|img| { + let name = img + .metadata + .as_ref() + .and_then(|m| m.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or("image.png"); + let mut meta = serde_json::json!({ + "id": &img.id, + "name": name, + "mime_type": &img.mime_type, + }); + if let Some(url) = &img.data_url { + meta["data_url"] = serde_json::json!(url); + } + if let Some(path) = &img.image_path { + meta["image_path"] = serde_json::json!(path); + } + meta }) - }); + .collect(); + + let mut metadata = + Self::ensure_user_message_metadata_object(user_message_metadata.take()); + if let Some(obj) = metadata.as_object_mut() { + obj.insert("images".to_string(), serde_json::json!(image_meta)); + obj.insert( + "original_text".to_string(), + serde_json::json!(original_user_input.clone()), + ); + } + user_message_metadata = Some(metadata); + } // Auto vision pre-analysis: when images are present, try to use the configured // vision model to pre-analyze them, then enhance the user message with text descriptions. @@ -922,6 +1052,18 @@ impl ConversationCoordinator { ) .await?; + if original_user_input != wrapped_user_input { + let mut metadata = + Self::ensure_user_message_metadata_object(user_message_metadata.take()); + if let Some(obj) = metadata.as_object_mut() { + obj.insert( + "original_text".to_string(), + serde_json::json!(original_user_input.clone()), + ); + } + user_message_metadata = Some(metadata); + } + // Start new dialog turn (sets state to Processing internally) let turn_index = self.session_manager.get_turn_count(&session_id); // Pass frontend turnId, generate if not provided @@ -938,13 +1080,12 @@ impl ConversationCoordinator { // Send dialog turn started event with original input and image metadata // so all frontends (desktop, mobile, bot) can display correctly. - let has_images = user_message_metadata.is_some(); self.emit_event(AgenticEvent::DialogTurnStarted { session_id: session_id.clone(), turn_id: turn_id.clone(), turn_index, user_input: wrapped_user_input.clone(), - original_user_input: if has_images { + original_user_input: if original_user_input != wrapped_user_input { Some(original_user_input.clone()) } else { None @@ -999,11 +1140,11 @@ impl ConversationCoordinator { workspace: session_workspace, context: context_vars, subagent_parent_info: None, - skip_tool_confirmation: trigger_source.skip_tool_confirmation(), + skip_tool_confirmation: submission_policy.skip_tool_confirmation, }; // Auto-generate session title on first message - if turn_index == 0 { + if turn_index == 0 && !suppress_session_title_generation { let sm = self.session_manager.clone(); let eq = self.event_queue.clone(); let sid = session_id.clone(); @@ -1075,6 +1216,11 @@ impl ConversationCoordinator { .await { Ok(execution_result) => { + let final_response = match &execution_result.final_message.content { + MessageContent::Text(text) => text.clone(), + MessageContent::Mixed { text, .. } => text.clone(), + _ => String::new(), + }; info!( "Dialog turn completed: session={}, turn={}, rounds={}", session_id_clone, turn_id_clone, execution_result.total_rounds @@ -1084,11 +1230,7 @@ impl ConversationCoordinator { .complete_dialog_turn( &session_id_clone, &turn_id_clone, - match &execution_result.final_message.content { - MessageContent::Text(text) => text.clone(), - MessageContent::Mixed { text, .. } => text.clone(), - _ => String::new(), - }, + final_response.clone(), TurnStats { total_rounds: execution_result.total_rounds, total_tools: 0, // TODO: get from execution_result @@ -1103,7 +1245,13 @@ impl ConversationCoordinator { .await; if let Some(tx) = &scheduler_notify_tx { - let _ = tx.try_send((session_id_clone.clone(), TurnOutcome::Completed)); + let _ = tx.try_send(( + session_id_clone.clone(), + TurnOutcome::Completed { + turn_id: turn_id_clone.clone(), + final_response, + }, + )); } Some(crate::service::session::TurnStatus::Completed) @@ -1154,12 +1302,18 @@ impl ConversationCoordinator { .await; if let Some(tx) = &scheduler_notify_tx { - let _ = tx.try_send((session_id_clone.clone(), TurnOutcome::Cancelled)); + let _ = tx.try_send(( + session_id_clone.clone(), + TurnOutcome::Cancelled { + turn_id: turn_id_clone.clone(), + }, + )); } Some(crate::service::session::TurnStatus::Cancelled) } else { - error!("Dialog turn execution failed: {}", e); + let error_text = e.to_string(); + error!("Dialog turn execution failed: {}", error_text); let recoverable = !matches!(&e, BitFunError::AIClient(_) | BitFunError::Timeout(_)); @@ -1169,7 +1323,7 @@ impl ConversationCoordinator { AgenticEvent::DialogTurnFailed { session_id: session_id_clone.clone(), turn_id: turn_id_clone.clone(), - error: e.to_string(), + error: error_text.clone(), subagent_parent_info: None, }, Some(EventPriority::Critical), @@ -1177,21 +1331,27 @@ impl ConversationCoordinator { .await; let _ = session_manager - .fail_dialog_turn(&session_id_clone, &turn_id_clone, e.to_string()) + .fail_dialog_turn(&session_id_clone, &turn_id_clone, error_text.clone()) .await; let _ = session_manager .update_session_state( &session_id_clone, SessionState::Error { - error: e.to_string(), + error: error_text.clone(), recoverable, }, ) .await; if let Some(tx) = &scheduler_notify_tx { - let _ = tx.try_send((session_id_clone.clone(), TurnOutcome::Failed)); + let _ = tx.try_send(( + session_id_clone.clone(), + TurnOutcome::Failed { + turn_id: turn_id_clone.clone(), + error: error_text, + }, + )); } Some(crate::service::session::TurnStatus::Error) diff --git a/src/crates/core/src/agentic/coordination/mod.rs b/src/crates/core/src/agentic/coordination/mod.rs index 44cc0406..b3faeff8 100644 --- a/src/crates/core/src/agentic/coordination/mod.rs +++ b/src/crates/core/src/agentic/coordination/mod.rs @@ -5,10 +5,12 @@ pub mod coordinator; pub mod scheduler; pub mod state_manager; +pub mod turn_outcome; pub use coordinator::*; pub use scheduler::*; pub use state_manager::*; +pub use turn_outcome::*; pub use coordinator::get_global_coordinator; pub use scheduler::get_global_scheduler; diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 25d034de..188544c7 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -5,35 +5,114 @@ //! //! Acts as the primary entry point for all user-facing message submissions, //! wrapping ConversationCoordinator with: -//! - Per-session FIFO queue (max 20 messages) -//! - 1-second debounce after session becomes idle (resets on each new incoming message) -//! - Automatic message merging when queue has multiple entries -//! - Queue cleared on cancel or error - -use super::coordinator::{ConversationCoordinator, DialogTriggerSource, TurnOutcome}; -use crate::agentic::core::SessionState; +//! - Per-session priority queue (max 20 messages) +//! - Higher-priority messages dispatched before lower-priority ones +//! - FIFO ordering within the same priority level +//! - Queue cleared on unrecoverable failure + +use super::coordinator::{ConversationCoordinator, DialogTriggerSource}; +use super::turn_outcome::{TurnOutcome, TurnOutcomeQueueAction, TurnOutcomeStatus}; +use crate::agentic::core::{PromptEnvelope, SessionState}; use crate::agentic::session::SessionManager; use dashmap::DashMap; use log::{debug, info, warn}; +use std::collections::VecDeque; use std::sync::Arc; use std::sync::OnceLock; use std::time::SystemTime; use tokio::sync::mpsc; -use tokio::task::AbortHandle; -use tokio::time::Duration; const MAX_QUEUE_DEPTH: usize = 20; -const DEBOUNCE_DELAY: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum DialogQueuePriority { + Low = 0, + Normal = 1, + High = 2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DialogSubmissionPolicy { + pub trigger_source: DialogTriggerSource, + pub queue_priority: DialogQueuePriority, + pub skip_tool_confirmation: bool, +} + +impl DialogSubmissionPolicy { + pub const fn new( + trigger_source: DialogTriggerSource, + queue_priority: DialogQueuePriority, + skip_tool_confirmation: bool, + ) -> Self { + Self { + trigger_source, + queue_priority, + skip_tool_confirmation, + } + } + + pub const fn for_source(trigger_source: DialogTriggerSource) -> Self { + let (queue_priority, skip_tool_confirmation) = match trigger_source { + DialogTriggerSource::AgentSession => (DialogQueuePriority::Low, true), + DialogTriggerSource::DesktopUi + | DialogTriggerSource::DesktopApi + | DialogTriggerSource::Cli => (DialogQueuePriority::Normal, false), + DialogTriggerSource::RemoteRelay | DialogTriggerSource::Bot => { + (DialogQueuePriority::Normal, true) + } + }; + Self::new(trigger_source, queue_priority, skip_tool_confirmation) + } + + pub const fn with_queue_priority(mut self, queue_priority: DialogQueuePriority) -> Self { + self.queue_priority = queue_priority; + self + } + + pub const fn with_skip_tool_confirmation(mut self, skip_tool_confirmation: bool) -> Self { + self.skip_tool_confirmation = skip_tool_confirmation; + self + } +} + +#[derive(Debug, Clone)] +pub struct AgentSessionReplyRoute { + pub source_session_id: String, + pub source_workspace_path: String, +} + +#[derive(Debug, Clone)] +struct ActiveTurn { + workspace_path: Option, + policy: DialogSubmissionPolicy, + reply_route: Option, +} + +impl ActiveTurn { + fn from_queued_turn(turn: &QueuedTurn) -> Self { + Self { + workspace_path: turn.workspace_path.clone(), + policy: turn.policy, + reply_route: turn.reply_route.clone(), + } + } + + fn is_agent_session_request(&self) -> bool { + self.policy.trigger_source == DialogTriggerSource::AgentSession + && self.reply_route.is_some() + } +} /// A message waiting to be dispatched to the coordinator -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct QueuedTurn { pub user_input: String, pub original_user_input: Option, pub turn_id: Option, pub agent_type: String, pub workspace_path: Option, - pub trigger_source: DialogTriggerSource, + pub policy: DialogSubmissionPolicy, + pub reply_route: Option, #[allow(dead_code)] pub enqueued_at: SystemTime, } @@ -46,10 +125,10 @@ pub struct QueuedTurn { pub struct DialogScheduler { coordinator: Arc, session_manager: Arc, - /// Per-session FIFO message queues - queues: Arc>>, - /// Per-session pending debounce task handles (present = debounce window active) - debounce_handles: Arc>, + /// Per-session priority message queues + queues: Arc>>, + /// Currently active turn metadata keyed by target session ID + active_turns: Arc>, /// Cloneable sender given to ConversationCoordinator for turn outcome notifications outcome_tx: mpsc::Sender<(String, TurnOutcome)>, } @@ -70,7 +149,7 @@ impl DialogScheduler { coordinator, session_manager, queues: Arc::new(DashMap::new()), - debounce_handles: Arc::new(DashMap::new()), + active_turns: Arc::new(DashMap::new()), outcome_tx, }); @@ -89,8 +168,8 @@ impl DialogScheduler { /// Submit a user message for a session. /// - /// - Session idle, no debounce window active → dispatched immediately. - /// - Session idle, debounce window active (collecting messages) → queued, timer reset. + /// - Session idle, queue empty → dispatched immediately. + /// - Session idle, queue non-empty → enqueued then highest-priority queued message dispatched. /// - Session processing → queued (up to MAX_QUEUE_DEPTH). /// - Session error → queue cleared, dispatched immediately. /// @@ -103,90 +182,49 @@ impl DialogScheduler { turn_id: Option, agent_type: String, workspace_path: Option, - trigger_source: DialogTriggerSource, + policy: DialogSubmissionPolicy, + reply_route: Option, ) -> Result<(), String> { + let queued_turn = QueuedTurn { + user_input, + original_user_input, + turn_id, + agent_type, + workspace_path, + policy, + reply_route, + enqueued_at: SystemTime::now(), + }; let state = self .session_manager .get_session(&session_id) .map(|s| s.state.clone()); match state { - None => self - .coordinator - .start_dialog_turn( - session_id, - user_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - ) - .await - .map_err(|e| e.to_string()), + None => self.start_turn(&session_id, &queued_turn).await, Some(SessionState::Error { .. }) => { - self.clear_queue_and_debounce(&session_id); - self.coordinator - .start_dialog_turn( - session_id, - user_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - ) - .await - .map_err(|e| e.to_string()) + self.clear_queue(&session_id); + self.start_turn(&session_id, &queued_turn).await } Some(SessionState::Idle) => { - let in_debounce = self.debounce_handles.contains_key(&session_id); let queue_non_empty = self .queues .get(&session_id) .map(|q| !q.is_empty()) .unwrap_or(false); - if in_debounce || queue_non_empty { - self.enqueue( - &session_id, - user_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - )?; - self.schedule_debounce(session_id); - Ok(()) + if queue_non_empty { + self.enqueue(&session_id, queued_turn)?; + self.dispatch_next_if_idle(&session_id).await } else { - self.coordinator - .start_dialog_turn( - session_id, - user_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - ) - .await - .map_err(|e| e.to_string()) + self.start_turn(&session_id, &queued_turn).await } } Some(SessionState::Processing { .. }) => { - self.enqueue( - &session_id, - user_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - )?; + self.enqueue(&session_id, queued_turn)?; Ok(()) } } @@ -199,16 +237,7 @@ impl DialogScheduler { // ── Private helpers ────────────────────────────────────────────────────── - fn enqueue( - &self, - session_id: &str, - user_input: String, - original_user_input: Option, - turn_id: Option, - agent_type: String, - workspace_path: Option, - trigger_source: DialogTriggerSource, - ) -> Result<(), String> { + fn enqueue(&self, session_id: &str, queued_turn: QueuedTurn) -> Result<(), String> { let queue_len = self.queues.get(session_id).map(|q| q.len()).unwrap_or(0); if queue_len >= MAX_QUEUE_DEPTH { @@ -225,28 +254,29 @@ impl DialogScheduler { self.queues .entry(session_id.to_string()) .or_default() - .push_back(QueuedTurn { - user_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - enqueued_at: SystemTime::now(), - }); + .push_back(queued_turn.clone()); + if let Some(mut queue) = self.queues.get_mut(session_id) { + if let Some(reordered_turn) = queue.pop_back() { + let insert_at = queue.iter().position(|existing| { + existing.policy.queue_priority < reordered_turn.policy.queue_priority + }); + if let Some(index) = insert_at { + queue.insert(index, reordered_turn); + } else { + queue.push_back(reordered_turn); + } + } + } let new_len = self.queues.get(session_id).map(|q| q.len()).unwrap_or(0); debug!( - "Message queued: session_id={}, queue_depth={}", - session_id, new_len + "Message queued: session_id={}, queue_depth={}, priority={:?}", + session_id, new_len, queued_turn.policy.queue_priority ); Ok(()) } - fn clear_queue_and_debounce(&self, session_id: &str) { - if let Some((_, handle)) = self.debounce_handles.remove(session_id) { - handle.abort(); - } + fn clear_queue(&self, session_id: &str) { if let Some(mut queue) = self.queues.get_mut(session_id) { let count = queue.len(); queue.clear(); @@ -259,175 +289,164 @@ impl DialogScheduler { } } - /// Start (or restart) the 1-second debounce timer for a session. - /// When the timer fires, all queued messages are merged and dispatched. - fn schedule_debounce(&self, session_id: String) { - // Cancel the existing timer (if any) - if let Some((_, old)) = self.debounce_handles.remove(&session_id) { - old.abort(); - } + fn dequeue_next(&self, session_id: &str) -> Option { + self.queues + .get_mut(session_id) + .and_then(|mut q| q.pop_front()) + } - let queues = Arc::clone(&self.queues); - let coordinator = Arc::clone(&self.coordinator); - let debounce_handles = Arc::clone(&self.debounce_handles); - let session_id_clone = session_id.clone(); + fn requeue_front(&self, session_id: &str, turn: QueuedTurn) { + self.queues + .entry(session_id.to_string()) + .or_default() + .push_front(turn); + } - let join_handle = tokio::spawn(async move { - tokio::time::sleep(DEBOUNCE_DELAY).await; + async fn start_turn(&self, session_id: &str, queued_turn: &QueuedTurn) -> Result<(), String> { + self.coordinator + .start_dialog_turn( + session_id.to_string(), + queued_turn.user_input.clone(), + queued_turn.original_user_input.clone(), + queued_turn.turn_id.clone(), + queued_turn.agent_type.clone(), + queued_turn.workspace_path.clone(), + queued_turn.policy, + ) + .await + .map_err(|e| e.to_string())?; + + self.active_turns.insert( + session_id.to_string(), + ActiveTurn::from_queued_turn(queued_turn), + ); + Ok(()) + } - // Remove our own handle - we are now executing - debounce_handles.remove(&session_id_clone); + async fn forward_agent_session_reply( + &self, + responder_session_id: &str, + active_turn: &ActiveTurn, + outcome: &TurnOutcome, + ) { + if !active_turn.is_agent_session_request() { + return; + } - // Drain all queued messages - let messages: Vec = { - let mut entry = queues.entry(session_id_clone.clone()).or_default(); - entry.drain(..).collect() - }; + let Some(reply_route) = active_turn.reply_route.as_ref() else { + return; + }; + + let responder_workspace = active_turn + .workspace_path + .as_deref() + .unwrap_or(""); + let reply_user_input = outcome.reply_text(); + let reply_message = + Self::format_agent_session_reply(responder_session_id, responder_workspace, outcome); + + if let Err(error) = self + .submit( + reply_route.source_session_id.clone(), + reply_message, + Some(reply_user_input), + None, + String::new(), + Some(reply_route.source_workspace_path.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + None, + ) + .await + { + warn!( + "Failed to forward agent-session reply: responder_session_id={}, source_session_id={}, error={}", + responder_session_id, reply_route.source_session_id, error + ); + } + } - if messages.is_empty() { - return; - } + fn format_agent_session_reply( + responder_session_id: &str, + responder_workspace: &str, + outcome: &TurnOutcome, + ) -> String { + let mut envelope = PromptEnvelope::new(); + let status = outcome.status(); + let reply_text = outcome.reply_text(); + envelope.push_system_reminder(format!( + "This message is an automated reply to a previous SessionMessage call, not a human user message.\n\ +From session: {responder_session_id}\n\ +From workspace: {responder_workspace}\n\ +Status: {status}" + )); + envelope.push_user_query(reply_text); + envelope.render() + } - info!( - "Dispatching {} queued message(s) after debounce: session_id={}", - messages.len(), - session_id_clone - ); + async fn dispatch_next_if_idle(&self, session_id: &str) -> Result<(), String> { + let state = self + .session_manager + .get_session(session_id) + .map(|s| s.state.clone()); + if matches!(state, Some(SessionState::Processing { .. })) { + return Ok(()); + } - let ( - merged_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - ) = merge_messages(messages); - - if let Err(e) = coordinator - .start_dialog_turn( - session_id_clone.clone(), - merged_input, - original_user_input, - turn_id, - agent_type, - workspace_path, - trigger_source, - ) - .await - { - warn!( - "Failed to dispatch queued messages: session_id={}, error={}", - session_id_clone, e - ); - } - }); + let Some(next_turn) = self.dequeue_next(session_id) else { + return Ok(()); + }; + + let remaining = self.queues.get(session_id).map(|q| q.len()).unwrap_or(0); + info!( + "Dispatching queued message: session_id={}, priority={:?}, remaining_queue_depth={}", + session_id, next_turn.policy.queue_priority, remaining + ); + + if let Err(err) = self.start_turn(session_id, &next_turn).await { + self.requeue_front(session_id, next_turn); + return Err(err); + } - // Store abort handle; drop the JoinHandle (task is detached but remains abortable) - self.debounce_handles - .insert(session_id, join_handle.abort_handle()); + Ok(()) } /// Background loop that receives turn outcome notifications from the coordinator. async fn run_outcome_handler(&self, mut outcome_rx: mpsc::Receiver<(String, TurnOutcome)>) { while let Some((session_id, outcome)) = outcome_rx.recv().await { - match outcome { - TurnOutcome::Completed => { - let has_queued = self - .queues - .get(&session_id) - .map(|q| !q.is_empty()) - .unwrap_or(false); - - if has_queued { + let active_turn = self.active_turns.remove(&session_id).map(|(_, turn)| turn); + if let Some(active_turn) = active_turn.as_ref() { + self.forward_agent_session_reply(&session_id, active_turn, &outcome) + .await; + } + + let status = outcome.status(); + match outcome.queue_action() { + TurnOutcomeQueueAction::DispatchNext => { + if status == TurnOutcomeStatus::Cancelled { debug!( - "Turn completed, queue non-empty, starting debounce: session_id={}", + "Turn cancelled, dispatching next queued message if present: session_id={}", session_id ); - self.schedule_debounce(session_id); + } + + if let Err(e) = self.dispatch_next_if_idle(&session_id).await { + warn!( + "Failed to dispatch next queued message after {}: session_id={}, error={}", + status, + session_id, + e + ); } } - TurnOutcome::Cancelled => { - debug!("Turn cancelled, clearing queue: session_id={}", session_id); - self.clear_queue_and_debounce(&session_id); - } - TurnOutcome::Failed => { - debug!("Turn failed, clearing queue: session_id={}", session_id); - self.clear_queue_and_debounce(&session_id); + TurnOutcomeQueueAction::ClearQueue => { + debug!("Turn {}, clearing queue: session_id={}", status, session_id); + self.clear_queue(&session_id); } } } } } -/// Merge multiple queued turns into a single user input string. -/// -/// Single message → returned as-is (no wrapping). -/// Multiple messages → formatted as: -/// ```text -/// [Queued messages while agent was busy] -/// -/// --- -/// Queued #1 -/// -/// -/// --- -/// Queued #2 -/// -/// ``` -fn merge_messages( - messages: Vec, -) -> ( - String, - Option, - Option, - String, - Option, - DialogTriggerSource, -) { - if messages.len() == 1 { - let m = messages.into_iter().next().unwrap(); - return ( - m.user_input, - m.original_user_input, - m.turn_id, - m.agent_type, - m.workspace_path, - m.trigger_source, - ); - } - - let agent_type = messages - .last() - .map(|m| m.agent_type.clone()) - .unwrap_or_else(|| "agentic".to_string()); - let workspace_path = messages.last().and_then(|m| m.workspace_path.clone()); - let trigger_source = messages - .last() - .map(|m| m.trigger_source) - .unwrap_or(DialogTriggerSource::DesktopUi); - let original_user_input = messages.last().and_then(|m| m.original_user_input.clone()); - - let entries: Vec = messages - .iter() - .enumerate() - .map(|(i, m)| format!("---\nQueued #{}\n{}", i + 1, m.user_input)) - .collect(); - - let merged = format!( - "[Queued messages while agent was busy]\n\n{}", - entries.join("\n\n") - ); - - ( - merged, - original_user_input, - None, - agent_type, - workspace_path, - trigger_source, - ) -} - // ── Global instance ────────────────────────────────────────────────────────── static GLOBAL_SCHEDULER: OnceLock> = OnceLock::new(); diff --git a/src/crates/core/src/agentic/coordination/turn_outcome.rs b/src/crates/core/src/agentic/coordination/turn_outcome.rs new file mode 100644 index 00000000..4fc19f46 --- /dev/null +++ b/src/crates/core/src/agentic/coordination/turn_outcome.rs @@ -0,0 +1,94 @@ +//! Turn outcome model shared across coordination components. + +use std::fmt; + +/// Outcome of a completed dialog turn, used to notify `DialogScheduler`. +#[derive(Debug, Clone)] +pub enum TurnOutcome { + /// Turn completed normally. + Completed { + turn_id: String, + final_response: String, + }, + /// Turn was cancelled by user. + Cancelled { turn_id: String }, + /// Turn failed with an error. + Failed { turn_id: String, error: String }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurnOutcomeQueueAction { + DispatchNext, + ClearQueue, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TurnOutcomeStatus { + Completed, + Cancelled, + Failed, +} + +impl TurnOutcomeStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Cancelled => "cancelled", + Self::Failed => "failed", + } + } +} + +impl fmt::Display for TurnOutcomeStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl TurnOutcome { + pub fn turn_id(&self) -> &str { + match self { + Self::Completed { turn_id, .. } + | Self::Cancelled { turn_id } + | Self::Failed { turn_id, .. } => turn_id, + } + } + + pub fn status(&self) -> TurnOutcomeStatus { + match self { + Self::Completed { .. } => TurnOutcomeStatus::Completed, + Self::Cancelled { .. } => TurnOutcomeStatus::Cancelled, + Self::Failed { .. } => TurnOutcomeStatus::Failed, + } + } + + pub fn status_str(&self) -> &'static str { + self.status().as_str() + } + + pub fn reply_text(&self) -> String { + match self { + Self::Completed { final_response, .. } => { + if final_response.trim().is_empty() { + "(no final text response)".to_string() + } else { + final_response.clone() + } + } + Self::Cancelled { .. } => { + "The target session cancelled this request before producing a final answer." + .to_string() + } + Self::Failed { error, .. } => { + format!("The target session failed to complete this request.\nError: {error}") + } + } + } + + pub fn queue_action(&self) -> TurnOutcomeQueueAction { + match self { + Self::Completed { .. } | Self::Cancelled { .. } => TurnOutcomeQueueAction::DispatchNext, + Self::Failed { .. } => TurnOutcomeQueueAction::ClearQueue, + } + } +} diff --git a/src/crates/core/src/agentic/core/message.rs b/src/crates/core/src/agentic/core/message.rs index 8f27f9c7..e74e0371 100644 --- a/src/crates/core/src/agentic/core/message.rs +++ b/src/crates/core/src/agentic/core/message.rs @@ -1,3 +1,4 @@ +use super::prompt_markup::is_system_reminder_only; use crate::agentic::image_analysis::ImageContextData; use crate::util::types::{Message as AIMessage, ToolCall as AIToolCall}; use crate::util::TokenCounter; @@ -57,6 +58,15 @@ pub struct MessageMetadata { /// Anthropic extended thinking signature (for passing back in multi-turn conversations) #[serde(skip_serializing_if = "Option::is_none")] pub thinking_signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub semantic_kind: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MessageSemanticKind { + ActualUserInput, + InternalReminder, } impl From for AIMessage { @@ -319,17 +329,20 @@ impl Message { } } - /// Check if message is a user message (role is user and not ) + /// Check if message should be treated as an actual user-turn boundary. pub fn is_actual_user_message(&self) -> bool { if self.role != MessageRole::User { return false; } + if let Some(semantic_kind) = self.metadata.semantic_kind { + return semantic_kind == MessageSemanticKind::ActualUserInput; + } let text = match &self.content { MessageContent::Text(text) => Some(text.as_str()), MessageContent::Multimodal { text, .. } => Some(text.as_str()), _ => None, }; - if text.is_some_and(|t| t.starts_with("")) { + if text.is_some_and(is_system_reminder_only) { return false; } true @@ -347,6 +360,11 @@ impl Message { self } + pub fn with_semantic_kind(mut self, semantic_kind: MessageSemanticKind) -> Self { + self.metadata.semantic_kind = Some(semantic_kind); + self + } + /// Set message's thinking_signature (for Anthropic extended thinking multi-turn conversations) pub fn with_thinking_signature(mut self, signature: Option) -> Self { self.metadata.thinking_signature = signature; diff --git a/src/crates/core/src/agentic/core/messages_helper.rs b/src/crates/core/src/agentic/core/messages_helper.rs index 1d4c5fc1..519c40fb 100644 --- a/src/crates/core/src/agentic/core/messages_helper.rs +++ b/src/crates/core/src/agentic/core/messages_helper.rs @@ -41,7 +41,7 @@ impl MessageHelper { } }) } else { - // Find the index of the last user message (role is user and not ) from back to front + // Find the last actual user-turn boundary from back to front. let last_user_message_index = messages.iter().rposition(|m| m.is_actual_user_message()); if let Some(last_user_message_index) = last_user_message_index { diff --git a/src/crates/core/src/agentic/core/mod.rs b/src/crates/core/src/agentic/core/mod.rs index 90524d06..d85ba603 100644 --- a/src/crates/core/src/agentic/core/mod.rs +++ b/src/crates/core/src/agentic/core/mod.rs @@ -6,12 +6,18 @@ pub mod dialog_turn; pub mod message; pub mod messages_helper; pub mod model_round; +pub mod prompt_markup; pub mod session; pub mod state; - pub use dialog_turn::{DialogTurn, DialogTurnState, TurnStats}; -pub use message::{Message, MessageContent, MessageRole, ToolCall, ToolResult}; +pub use message::{ + Message, MessageContent, MessageRole, MessageSemanticKind, ToolCall, ToolResult, +}; pub use messages_helper::MessageHelper; pub use model_round::ModelRound; +pub use prompt_markup::{ + has_prompt_markup, is_system_reminder_only, render_system_reminder, render_user_query, + strip_prompt_markup, PromptBlock, PromptBlockKind, PromptEnvelope, +}; pub use session::{CompressionState, Session, SessionConfig, SessionSummary}; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/core/src/agentic/core/prompt_markup.rs b/src/crates/core/src/agentic/core/prompt_markup.rs new file mode 100644 index 00000000..3747ed0e --- /dev/null +++ b/src/crates/core/src/agentic/core/prompt_markup.rs @@ -0,0 +1,133 @@ +use serde::{Deserialize, Serialize}; + +pub const USER_QUERY_TAG: &str = "user_query"; +pub const SYSTEM_REMINDER_TAG: &str = "system_reminder"; +const LEGACY_SYSTEM_REMINDER_TAG: &str = "system-reminder"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PromptBlockKind { + UserQuery, + SystemReminder, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PromptBlock { + pub kind: PromptBlockKind, + pub text: String, +} + +impl PromptBlock { + pub fn user_query(text: impl Into) -> Self { + Self { + kind: PromptBlockKind::UserQuery, + text: text.into(), + } + } + + pub fn system_reminder(text: impl Into) -> Self { + Self { + kind: PromptBlockKind::SystemReminder, + text: text.into(), + } + } + + pub fn render(&self) -> String { + match self.kind { + PromptBlockKind::UserQuery => wrap_tag(USER_QUERY_TAG, &self.text), + PromptBlockKind::SystemReminder => wrap_tag(SYSTEM_REMINDER_TAG, &self.text), + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PromptEnvelope { + pub blocks: Vec, +} + +impl PromptEnvelope { + pub fn new() -> Self { + Self::default() + } + + pub fn push_block(&mut self, block: PromptBlock) { + self.blocks.push(block); + } + + pub fn push_user_query(&mut self, text: impl Into) { + self.push_block(PromptBlock::user_query(text)); + } + + pub fn push_system_reminder(&mut self, text: impl Into) { + self.push_block(PromptBlock::system_reminder(text)); + } + + pub fn render(&self) -> String { + self.blocks + .iter() + .map(PromptBlock::render) + .collect::>() + .join("\n") + } +} + +pub fn render_user_query(text: &str) -> String { + PromptBlock::user_query(text).render() +} + +pub fn render_system_reminder(text: &str) -> String { + PromptBlock::system_reminder(text).render() +} + +pub fn has_prompt_markup(raw: &str) -> bool { + let trimmed = raw.trim_start(); + trimmed.starts_with(&opening_tag(USER_QUERY_TAG)) + || trimmed.starts_with(&opening_tag(SYSTEM_REMINDER_TAG)) + || trimmed.starts_with(&opening_tag(LEGACY_SYSTEM_REMINDER_TAG)) +} + +pub fn is_system_reminder_only(raw: &str) -> bool { + let trimmed = raw.trim(); + trimmed.starts_with(&opening_tag(SYSTEM_REMINDER_TAG)) + || trimmed.starts_with(&opening_tag(LEGACY_SYSTEM_REMINDER_TAG)) +} + +pub fn strip_prompt_markup(raw: &str) -> String { + let text = raw.trim(); + let inner = extract_tag_content(text, USER_QUERY_TAG) + .map(|content| content.trim().to_string()) + .unwrap_or_else(|| strip_after_first_system_reminder(text).trim().to_string()); + strip_after_first_system_reminder(&inner).trim().to_string() +} + +fn wrap_tag(tag: &str, text: &str) -> String { + format!("<{tag}>\n{text}\n") +} + +fn opening_tag(tag: &str) -> String { + format!("<{tag}>") +} + +fn closing_tag(tag: &str) -> String { + format!("") +} + +fn extract_tag_content<'a>(text: &'a str, tag: &str) -> Option<&'a str> { + let open = opening_tag(tag); + let close = closing_tag(tag); + let start = text.find(&open)?; + let after_open = &text[start + open.len()..]; + let end = after_open.find(&close)?; + Some(&after_open[..end]) +} + +fn strip_after_first_system_reminder(text: &str) -> &str { + let underscore = text.find(&opening_tag(SYSTEM_REMINDER_TAG)); + let legacy = text.find(&opening_tag(LEGACY_SYSTEM_REMINDER_TAG)); + match (underscore, legacy) { + (Some(a), Some(b)) => &text[..a.min(b)], + (Some(a), None) => &text[..a], + (None, Some(b)) => &text[..b], + (None, None) => text, + } +} diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 3e280365..5758cd99 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -5,7 +5,7 @@ use super::round_executor::RoundExecutor; use super::types::{ExecutionContext, ExecutionResult, RoundContext}; use crate::agentic::agents::get_agent_registry; -use crate::agentic::core::{Message, MessageContent, MessageHelper}; +use crate::agentic::core::{Message, MessageContent, MessageHelper, Session}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::image_analysis::{ build_multimodal_message_with_images, process_image_contexts_for_provider, ImageContextData, @@ -13,6 +13,7 @@ use crate::agentic::image_analysis::{ }; use crate::agentic::session::SessionManager; use crate::agentic::tools::{get_all_registered_tools, SubagentParentInfo}; +use crate::agentic::WorkspaceBinding; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; use crate::service::config::types::{ModelCapability, ModelCategory}; @@ -157,6 +158,84 @@ impl ExecutionEngine { turn_index == 0 && original_user_input.chars().count() <= 10 } + pub(crate) async fn resolve_model_id_for_turn( + &self, + session: &Session, + agent_type: &str, + workspace: Option<&WorkspaceBinding>, + original_user_input: &str, + turn_index: usize, + ) -> BitFunResult { + let agent_registry = get_agent_registry(); + let configured_model_id = agent_registry + .get_model_id_for_agent(agent_type, workspace.map(|binding| binding.root_path())) + .await + .map_err(|e| BitFunError::AIClient(format!("Failed to get model ID: {}", e)))?; + + let model_id = if configured_model_id == "auto" { + let config_service = get_global_config_service().await.map_err(|e| { + BitFunError::AIClient(format!( + "Failed to get config service for auto model resolution: {}", + e + )) + })?; + let ai_config: crate::service::config::types::AIConfig = config_service + .get_config(Some("ai")) + .await + .unwrap_or_default(); + + let locked_model_id = + Self::resolve_locked_auto_model_id(&ai_config, session.config.model_id.as_ref()); + let raw_locked_model_id = session.config.model_id.clone(); + + if let Some(locked_model_id) = locked_model_id { + locked_model_id + } else { + if let Some(raw_locked_model_id) = raw_locked_model_id.as_ref() { + let trimmed = raw_locked_model_id.trim(); + if !trimmed.is_empty() && trimmed != "auto" && trimmed != "default" { + warn!( + "Ignoring invalid locked auto model for session: session_id={}, model_id={}", + session.session_id, trimmed + ); + } + } + + let use_fast_model = + Self::should_use_fast_auto_model(turn_index, original_user_input); + let fallback_model = if use_fast_model { "fast" } else { "primary" }; + let resolved_model_id = ai_config.resolve_model_selection(fallback_model); + + if let Some(resolved_model_id) = resolved_model_id { + self.session_manager + .update_session_model_id(&session.session_id, &resolved_model_id) + .await?; + + info!( + "Auto model resolved: session_id={}, turn_index={}, user_input_chars={}, strategy={}, resolved_model_id={}", + session.session_id, + turn_index, + original_user_input.chars().count(), + fallback_model, + resolved_model_id + ); + + resolved_model_id + } else { + warn!( + "Auto model strategy unresolved, keeping symbolic selector: session_id={}, strategy={}", + session.session_id, fallback_model + ); + fallback_model.to_string() + } + } + } else { + configured_model_id + }; + + Ok(model_id) + } + async fn build_ai_messages_for_send( messages: &[Message], provider: &str, @@ -435,86 +514,23 @@ impl ExecutionEngine { })?; // 2. Get AI client - // Get model ID from AgentRegistry - let configured_model_id = agent_registry - .get_model_id_for_agent( + let original_user_input = context + .context + .get("original_user_input") + .cloned() + .unwrap_or_default(); + let model_id = self + .resolve_model_id_for_turn( + &session, &agent_type, - context - .workspace - .as_ref() - .map(|workspace| workspace.root_path()), + context.workspace.as_ref(), + &original_user_input, + context.turn_index, ) - .await - .map_err(|e| BitFunError::AIClient(format!("Failed to get model ID: {}", e)))?; - let model_id = if configured_model_id == "auto" { - let config_service = get_global_config_service().await.map_err(|e| { - BitFunError::AIClient(format!( - "Failed to get config service for auto model resolution: {}", - e - )) - })?; - let ai_config: crate::service::config::types::AIConfig = config_service - .get_config(Some("ai")) - .await - .unwrap_or_default(); - - let locked_model_id = - Self::resolve_locked_auto_model_id(&ai_config, session.config.model_id.as_ref()); - let raw_locked_model_id = session.config.model_id.clone(); - - if let Some(locked_model_id) = locked_model_id { - locked_model_id - } else { - if let Some(raw_locked_model_id) = raw_locked_model_id.as_ref() { - let trimmed = raw_locked_model_id.trim(); - if !trimmed.is_empty() && trimmed != "auto" && trimmed != "default" { - warn!( - "Ignoring invalid locked auto model for session: session_id={}, model_id={}", - context.session_id, trimmed - ); - } - } - - let original_user_input = context - .context - .get("original_user_input") - .cloned() - .unwrap_or_default(); - let use_fast_model = - Self::should_use_fast_auto_model(context.turn_index, &original_user_input); - let fallback_model = if use_fast_model { "fast" } else { "primary" }; - let resolved_model_id = ai_config.resolve_model_selection(fallback_model); - - if let Some(resolved_model_id) = resolved_model_id { - self.session_manager - .update_session_model_id(&context.session_id, &resolved_model_id) - .await?; - - info!( - "Auto model resolved: session_id={}, turn_index={}, user_input_chars={}, strategy={}, resolved_model_id={}", - context.session_id, - context.turn_index, - original_user_input.chars().count(), - fallback_model, - resolved_model_id - ); - - resolved_model_id - } else { - warn!( - "Auto model strategy unresolved, keeping symbolic selector: session_id={}, strategy={}", - context.session_id, fallback_model - ); - fallback_model.to_string() - } - } - } else { - configured_model_id.clone() - }; + .await?; info!( - "Agent using model: agent={}, configured_model_id={}, resolved_model_id={}", + "Agent using model: agent={}, resolved_model_id={}", current_agent.name(), - configured_model_id, model_id ); diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 0d70a807..34688c8a 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -4,7 +4,7 @@ use super::stream_processor::StreamProcessor; use super::types::{FinishReason, RoundContext, RoundResult}; -use crate::agentic::core::Message; +use crate::agentic::core::{render_system_reminder, Message, MessageSemanticKind}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::image_analysis::ImageContextData as ModelImageContextData; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; @@ -473,12 +473,13 @@ impl RoundExecutor { .collect(); if !injected_images.is_empty() { - let reminder_text = format!( - "\nAttached {} image(s) from view_image tool.\n", + let reminder_text = render_system_reminder(&format!( + "Attached {} image(s) from view_image tool.", injected_images.len() - ); + )); tool_result_messages.push( Message::user_multimodal(reminder_text, injected_images) + .with_semantic_kind(MessageSemanticKind::InternalReminder) .with_turn_id(dialog_turn_id.clone()) .with_round_id(round_id_clone.clone()), ); diff --git a/src/crates/core/src/agentic/session/compression_manager.rs b/src/crates/core/src/agentic/session/compression_manager.rs index dbc5a01c..82e31b8a 100644 --- a/src/crates/core/src/agentic/session/compression_manager.rs +++ b/src/crates/core/src/agentic/session/compression_manager.rs @@ -2,7 +2,9 @@ //! //! Responsible for managing session context compression -use crate::agentic::core::{Message, MessageHelper, MessageRole}; +use crate::agentic::core::{ + render_system_reminder, Message, MessageHelper, MessageRole, MessageSemanticKind, +}; use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::ai::{get_global_ai_client_factory, AIClient}; use crate::util::errors::{BitFunError, BitFunResult}; @@ -247,10 +249,13 @@ impl CompressionManager { .await?; trace!("Compression summary: {}", summary); - compressed_messages.push(Message::user(format!( - "\nPrevious conversation is summarized below:\n{}\n", - summary - ))); + compressed_messages.push( + Message::user(render_system_reminder(&format!( + "Previous conversation is summarized below:\n{}", + summary + ))) + .with_semantic_kind(MessageSemanticKind::InternalReminder), + ); } if !turns_to_keep.is_empty() { @@ -264,10 +269,13 @@ impl CompressionManager { } // Append last todo if let Some(last_todo) = last_todo { - compressed_messages.push(Message::user(format!( - "\nBelow is the most recent to-do list. Continue working on these tasks:\n{}\n", - last_todo - ))); + compressed_messages.push( + Message::user(render_system_reminder(&format!( + "Below is the most recent to-do list. Continue working on these tasks:\n{}", + last_todo + ))) + .with_semantic_kind(MessageSemanticKind::InternalReminder), + ); } } diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 6e50b47b..c27f4bc6 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -3,8 +3,8 @@ //! Responsible for session CRUD, lifecycle management, and resource association use crate::agentic::core::{ - CompressionState, DialogTurn, Message, ProcessingPhase, Session, SessionConfig, SessionState, - SessionSummary, TurnStats, + CompressionState, DialogTurn, Message, MessageSemanticKind, ProcessingPhase, Session, + SessionConfig, SessionState, SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; @@ -119,7 +119,11 @@ impl SessionManager { } else { Message::user(turn.user_message.content.clone()) }; - messages.push(user_message.with_turn_id(turn.turn_id.clone())); + messages.push( + user_message + .with_turn_id(turn.turn_id.clone()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput), + ); let assistant_text = turn .model_rounds @@ -681,9 +685,13 @@ impl SessionManager { // 2. Add user message to history and compression managers let user_message = if let Some(images) = image_contexts.as_ref().filter(|v| !v.is_empty()).cloned() { - Message::user_multimodal(user_input.clone(), images).with_turn_id(turn_id.clone()) + Message::user_multimodal(user_input.clone(), images) + .with_turn_id(turn_id.clone()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput) } else { - Message::user(user_input.clone()).with_turn_id(turn_id.clone()) + Message::user(user_input.clone()) + .with_turn_id(turn_id.clone()) + .with_semantic_kind(MessageSemanticKind::ActualUserInput) }; self.history_manager .add_message(session_id, user_message.clone()) diff --git a/src/crates/core/src/agentic/side_question.rs b/src/crates/core/src/agentic/side_question.rs index 449cbe15..6d87d373 100644 --- a/src/crates/core/src/agentic/side_question.rs +++ b/src/crates/core/src/agentic/side_question.rs @@ -165,8 +165,8 @@ impl SideQuestionService { let mut context_messages = session_manager.get_context_messages(session_id).await?; if context_messages.len() > max_context_messages { - context_messages = - context_messages.split_off(context_messages.len().saturating_sub(max_context_messages)); + context_messages = context_messages + .split_off(context_messages.len().saturating_sub(max_context_messages)); } Ok(context_messages) @@ -189,7 +189,9 @@ Rules:\n\ max_context_messages: Option, ) -> BitFunResult { if session_id.trim().is_empty() { - return Err(BitFunError::Validation("session_id is required".to_string())); + return Err(BitFunError::Validation( + "session_id is required".to_string(), + )); } if question.trim().is_empty() { return Err(BitFunError::Validation("question is required".to_string())); @@ -235,10 +237,14 @@ Rules:\n\ request: SideQuestionStreamRequest, ) -> BitFunResult> { if request.request_id.trim().is_empty() { - return Err(BitFunError::Validation("request_id is required".to_string())); + return Err(BitFunError::Validation( + "request_id is required".to_string(), + )); } if request.session_id.trim().is_empty() { - return Err(BitFunError::Validation("session_id is required".to_string())); + return Err(BitFunError::Validation( + "session_id is required".to_string(), + )); } if request.question.trim().is_empty() { return Err(BitFunError::Validation("question is required".to_string())); @@ -334,7 +340,10 @@ Rules:\n\ } if full_text.trim().is_empty() { - warn!("Side question stream completed with empty output: request_id={}", request_id); + warn!( + "Side question stream completed with empty output: request_id={}", + request_id + ); } let _ = tx.send(SideQuestionStreamEvent::Completed { diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 348dad69..5761d80d 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -19,6 +19,7 @@ pub mod ls_tool; pub mod mermaid_interactive_tool; pub mod miniapp_init_tool; pub mod session_control_tool; +pub mod session_message_tool; pub mod skill_tool; pub mod skills; pub mod task_tool; @@ -47,6 +48,7 @@ pub use ls_tool::LSTool; pub use mermaid_interactive_tool::MermaidInteractiveTool; pub use miniapp_init_tool::InitMiniAppTool; pub use session_control_tool::SessionControlTool; +pub use session_message_tool::SessionMessageTool; pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index 1f7f5e0a..954b956f 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -39,8 +39,22 @@ impl SessionControlTool { Ok(()) } - fn resolve_workspace(&self, workspace: &str, context: &ToolUseContext) -> BitFunResult { - let resolved = resolve_path_with_workspace(workspace, context.workspace_root())?; + fn resolve_workspace( + &self, + workspace: Option<&str>, + context: &ToolUseContext, + ) -> BitFunResult { + let resolved = match workspace.filter(|value| !value.trim().is_empty()) { + Some(workspace) => resolve_path_with_workspace(workspace, context.workspace_root())?, + None => context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| { + BitFunError::tool( + "SessionControl requires workspace input or a source workspace".to_string(), + ) + })?, + }; let path = Path::new(&resolved); if !path.exists() { return Err(BitFunError::tool(format!( @@ -112,12 +126,33 @@ enum SessionControlAction { List, } +#[derive(Debug, Clone, Deserialize)] +enum SessionControlAgentType { + #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] + Agentic, + #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] + Plan, + #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] + Cowork, +} + +impl SessionControlAgentType { + fn as_str(&self) -> &'static str { + match self { + Self::Agentic => "agentic", + Self::Plan => "Plan", + Self::Cowork => "Cowork", + } + } +} + #[derive(Debug, Clone, Deserialize)] struct SessionControlInput { action: SessionControlAction, - workspace: String, + workspace: Option, session_id: Option, session_name: Option, + agent_type: Option, } #[async_trait] @@ -128,12 +163,19 @@ impl Tool for SessionControlTool { async fn description(&self) -> BitFunResult { Ok( - r#"Manage persisted workspace-scoped agent conversation sessions. + r#"Manage persisted workspace-scoped agent sessions. Actions: -- "create": Create a new session. You may optionally provide session_name. +- "create": Create a new session. You may optionally provide session_name and agent_type. - "delete": Delete an existing session by session_id. -- "list": List all sessions."# +- "list": List all sessions. + +Optional inputs: +- "workspace": Workspace path. Can be absolute or relative to the current workspace. If omitted, uses your current workspace. +- "agent_type": Only used by create. Defaults to "agentic". + - "agentic": Coding-focused agent for implementation, debugging, and code changes. + - "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. + - "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc."# .to_string(), ) } @@ -158,9 +200,14 @@ Actions: "session_name": { "type": "string", "description": "Optional display name when creating a session." + }, + "agent_type": { + "type": "string", + "enum": ["agentic", "Plan", "Cowork"], + "description": "Optional agent type when creating a session. Defaults to agentic." } }, - "required": ["action", "workspace"], + "required": ["action"], "additionalProperties": false }) } @@ -190,10 +237,19 @@ Actions: } }; - if parsed.workspace.trim().is_empty() { + if parsed + .workspace + .as_deref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + && context.and_then(|value| value.workspace_root()).is_none() + { return ValidationResult { result: false, - message: Some("workspace is required".to_string()), + message: Some( + "SessionControl requires workspace input or a source workspace in tool context" + .to_string(), + ), error_code: Some(400), meta: None, }; @@ -224,6 +280,14 @@ Actions: } } SessionControlAction::Delete => { + if parsed.agent_type.is_some() { + return ValidationResult { + result: false, + message: Some("agent_type is only allowed for create".to_string()), + error_code: Some(400), + meta: None, + }; + } let Some(session_id) = parsed.session_id.as_deref() else { return ValidationResult { result: false, @@ -241,7 +305,16 @@ Actions: }; } } - SessionControlAction::List => {} + SessionControlAction::List => { + if parsed.agent_type.is_some() { + return ValidationResult { + result: false, + message: Some("agent_type is only allowed for create".to_string()), + error_code: Some(400), + meta: None, + }; + } + } } ValidationResult::default() @@ -255,7 +328,7 @@ Actions: let workspace = input .get("workspace") .and_then(|value| value.as_str()) - .unwrap_or("unknown"); + .unwrap_or("current workspace"); let session_id = input .get("session_id") .and_then(|value| value.as_str()) @@ -276,7 +349,7 @@ Actions: ) -> BitFunResult> { let params: SessionControlInput = serde_json::from_value(input.clone()) .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; - let workspace = self.resolve_workspace(¶ms.workspace, context)?; + let workspace = self.resolve_workspace(params.workspace.as_deref(), context)?; let workspace_path = Path::new(&workspace); let coordinator = get_global_coordinator() .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; @@ -288,7 +361,11 @@ Actions: .clone() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(Self::default_session_name); - let agent_type = "agentic".to_string(); + let agent_type = params + .agent_type + .as_ref() + .map(|agent_type| agent_type.as_str().to_string()) + .unwrap_or_else(|| "agentic".to_string()); let created_by = self.creator_session_marker(context)?; let session = coordinator @@ -306,9 +383,10 @@ Actions: .await?; let created_session_id = session.session_id.clone(); let created_session_name = session.session_name.clone(); + let created_agent_type = session.agent_type.clone(); let result_for_assistant = format!( - "Created session '{}' in workspace '{}'", - created_session_id, workspace + "Created session '{}' in workspace '{}' using agent type '{}'.", + created_session_id, workspace, created_agent_type ); Ok(vec![ToolResult::Result { @@ -319,6 +397,7 @@ Actions: "session": { "session_id": created_session_id, "session_name": created_session_name, + "agent_type": created_agent_type, } }), result_for_assistant: Some(result_for_assistant), diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs new file mode 100644 index 00000000..693adfc9 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -0,0 +1,396 @@ +use super::util::resolve_path_with_workspace; +use crate::agentic::coordination::{ + get_global_coordinator, get_global_scheduler, AgentSessionReplyRoute, DialogSubmissionPolicy, + DialogTriggerSource, +}; +use crate::agentic::core::PromptEnvelope; +use crate::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::path::Path; + +/// SessionMessage tool - send a message to another session via the dialog scheduler +pub struct SessionMessageTool; + +impl SessionMessageTool { + pub fn new() -> Self { + Self + } + + fn validate_session_id(session_id: &str) -> Result<(), String> { + if session_id.is_empty() { + return Err("session_id cannot be empty".to_string()); + } + if session_id == "." || session_id == ".." { + return Err("session_id cannot be '.' or '..'".to_string()); + } + if session_id.contains('/') || session_id.contains('\\') { + return Err("session_id cannot contain path separators".to_string()); + } + if !session_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err( + "session_id can only contain ASCII letters, numbers, '-' and '_'".to_string(), + ); + } + Ok(()) + } + + fn resolve_workspace( + &self, + workspace: Option<&str>, + context: &ToolUseContext, + ) -> BitFunResult { + let resolved = match workspace.filter(|value| !value.trim().is_empty()) { + Some(workspace) => resolve_path_with_workspace(workspace, context.workspace_root())?, + None => context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| { + BitFunError::tool( + "SessionMessage requires workspace input or a source workspace".to_string(), + ) + })?, + }; + let path = Path::new(&resolved); + if !path.exists() { + return Err(BitFunError::tool(format!( + "Workspace does not exist: {}", + resolved + ))); + } + if !path.is_dir() { + return Err(BitFunError::tool(format!( + "Workspace is not a directory: {}", + resolved + ))); + } + Ok(resolved) + } + + fn sender_session_id<'a>(&self, context: &'a ToolUseContext) -> BitFunResult<&'a str> { + context.session_id.as_deref().ok_or_else(|| { + BitFunError::tool("SessionMessage requires a source session".to_string()) + }) + } + + fn sender_workspace(&self, context: &ToolUseContext) -> BitFunResult { + context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()) + .ok_or_else(|| { + BitFunError::tool("SessionMessage requires a source workspace".to_string()) + }) + } + + fn format_forwarded_message(&self, message: &str) -> String { + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder( + "This request was sent by another agent, not human user. Do not use interactive tools for this request. In particular, do not call AskUserQuestion." + .to_string(), + ); + envelope.push_user_query(message.to_string()); + envelope.render() + } +} + +#[derive(Debug, Clone, Deserialize)] +enum SessionMessageAgentType { + #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] + Agentic, + #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] + Plan, + #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] + Cowork, +} + +impl SessionMessageAgentType { + fn as_str(&self) -> &'static str { + match self { + Self::Agentic => "agentic", + Self::Plan => "Plan", + Self::Cowork => "Cowork", + } + } + + fn from_str(value: &str) -> Option { + if value.eq_ignore_ascii_case("agentic") { + Some(Self::Agentic) + } else if value.eq_ignore_ascii_case("plan") { + Some(Self::Plan) + } else if value.eq_ignore_ascii_case("cowork") { + Some(Self::Cowork) + } else { + None + } + } + + fn is_coding_mode(&self) -> bool { + matches!(self, Self::Agentic | Self::Plan) + } +} + +#[derive(Debug, Clone, Deserialize)] +struct SessionMessageInput { + workspace: Option, + session_id: String, + message: String, + agent_type: Option, +} + +#[async_trait] +impl Tool for SessionMessageTool { + fn name(&self) -> &str { + "SessionMessage" + } + + async fn description(&self) -> BitFunResult { + Ok( + r#"Asynchronously send a message to another agent session. When the target session finishes, its result is automatically sent back to you as a follow-up message. + +You can optionally set agent_type to choose how the target session handles the request: +- "agentic": Coding-focused agent for implementation, debugging, and code changes. +- "Plan": Planning agent for clarifying requirements and producing an implementation plan before coding. +- "Cowork": Collaborative agent for office-style work such as research, documentation, presentations, etc. + +When overriding an existing session's agent_type, only switching between "agentic" and "Plan" is allowed. It will not switch coding sessions to or from "Cowork"."# + .to_string(), + ) + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "workspace": { + "type": "string", + "description": "Target workspace path. Can be absolute or relative to the current workspace. Defaults to the current workspace." + }, + "session_id": { + "type": "string", + "description": "Target session ID." + }, + "message": { + "type": "string", + "description": "Message to send to the target session." + }, + "agent_type": { + "type": "string", + "enum": ["agentic", "Plan", "Cowork"], + "description": "Optional target agent type. Defaults to the target session's current agent type." + } + }, + "required": ["session_id", "message"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + context: Option<&ToolUseContext>, + ) -> ValidationResult { + let parsed: SessionMessageInput = match serde_json::from_value(input.clone()) { + Ok(value) => value, + Err(err) => { + return ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", err)), + error_code: Some(400), + meta: None, + }; + } + }; + + if let Err(message) = Self::validate_session_id(&parsed.session_id) { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } + + if parsed.message.trim().is_empty() { + return ValidationResult { + result: false, + message: Some("message cannot be empty".to_string()), + error_code: Some(400), + meta: None, + }; + } + + let Some(context) = context else { + return ValidationResult::default(); + }; + + let Some(source_session_id) = context.session_id.as_deref() else { + return ValidationResult { + result: false, + message: Some( + "SessionMessage requires a source session in tool context".to_string(), + ), + error_code: Some(400), + meta: None, + }; + }; + + if source_session_id == parsed.session_id { + return ValidationResult { + result: false, + message: Some( + "SessionMessage cannot send a message to the same session".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + if parsed + .workspace + .as_deref() + .map(|value| value.trim().is_empty()) + .unwrap_or(true) + && context.workspace_root().is_none() + { + return ValidationResult { + result: false, + message: Some( + "SessionMessage requires workspace input or a source workspace in tool context" + .to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + + ValidationResult::default() + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let workspace = input + .get("workspace") + .and_then(|value| value.as_str()) + .unwrap_or("current workspace"); + let session_id = input + .get("session_id") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + + format!("Send message to session {} in {}", session_id, workspace) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let params: SessionMessageInput = serde_json::from_value(input.clone()) + .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; + let workspace = self.resolve_workspace(params.workspace.as_deref(), context)?; + let workspace_path = Path::new(&workspace); + let source_session_id = self.sender_session_id(context)?.to_string(); + let target_session_id = params.session_id.clone(); + + if source_session_id == target_session_id { + return Err(BitFunError::tool( + "SessionMessage cannot send a message to the same session".to_string(), + )); + } + + let source_workspace = self.sender_workspace(context)?; + + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let scheduler = get_global_scheduler() + .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; + + let existing_sessions = coordinator.list_sessions(workspace_path).await?; + let target_session = existing_sessions + .iter() + .find(|session| session.session_id == target_session_id.as_str()) + .ok_or_else(|| { + BitFunError::NotFound(format!( + "Session not found in workspace: {}", + target_session_id + )) + })?; + + let persisted_agent_type = target_session.agent_type.trim(); + let target_agent_type = if let Some(requested_agent_type) = params.agent_type.as_ref() { + let current_agent_type = if persisted_agent_type.is_empty() { + SessionMessageAgentType::Agentic + } else { + SessionMessageAgentType::from_str(persisted_agent_type).ok_or_else(|| { + BitFunError::tool(format!( + "SessionMessage agent_type override is only supported for sessions using 'agentic', 'Plan', or 'Cowork'. Current agent type is '{}'.", + persisted_agent_type + )) + })? + }; + + if requested_agent_type.as_str() != current_agent_type.as_str() + && !(requested_agent_type.is_coding_mode() && current_agent_type.is_coding_mode()) + { + return Err(BitFunError::tool(format!( + "SessionMessage only allows agent_type override between 'agentic' and 'Plan'. Cannot switch session '{}' from '{}' to '{}'.", + target_session_id, + current_agent_type.as_str(), + requested_agent_type.as_str() + ))); + } + + requested_agent_type.as_str().to_string() + } else if persisted_agent_type.is_empty() { + "agentic".to_string() + } else { + persisted_agent_type.to_string() + }; + + let forwarded_message = self.format_forwarded_message(¶ms.message); + + scheduler + .submit( + target_session_id.clone(), + forwarded_message, + Some(params.message.clone()), + None, + target_agent_type.clone(), + Some(workspace.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + Some(AgentSessionReplyRoute { + source_session_id, + source_workspace_path: source_workspace, + }), + ) + .await + .map_err(BitFunError::tool)?; + + Ok(vec![ToolResult::Result { + data: json!({ + "success": true, + "target_workspace": workspace.clone(), + "target_session_id": target_session_id.clone(), + "target_agent_type": target_agent_type.clone(), + }), + result_for_assistant: Some(format!( + "Message accepted for session '{}' in workspace '{}' using agent type '{}'.", + target_session_id, workspace, target_agent_type + )), + }]) + } +} diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 1c24ecc4..300d5ceb 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -91,6 +91,7 @@ impl ToolRegistry { self.register_tool(Arc::new(BashTool::new())); self.register_tool(Arc::new(TerminalControlTool::new())); self.register_tool(Arc::new(SessionControlTool::new())); + self.register_tool(Arc::new(SessionMessageTool::new())); // TodoWrite tool self.register_tool(Arc::new(TodoWriteTool::new())); diff --git a/src/crates/core/src/lib.rs b/src/crates/core/src/lib.rs index 62aa467e..c5c46249 100644 --- a/src/crates/core/src/lib.rs +++ b/src/crates/core/src/lib.rs @@ -8,7 +8,7 @@ pub mod infrastructure; // Infrastructure layer - AI clients, storage, logging, pub mod miniapp; pub mod service; // Service layer - Workspace, Config, FileSystem, Terminal, Git pub mod util; // Utility layer - General types, errors, helper functions // MiniApp - AI-generated instant apps (Zero-Dialect Runtime) - // Re-export debug_log from infrastructure for backward compatibility + // Re-export debug_log from infrastructure for backward compatibility pub use infrastructure::debug_log as debug; // Export main types diff --git a/src/crates/core/src/service/bootstrap/bootstrap.rs b/src/crates/core/src/service/bootstrap/bootstrap.rs index dd2c058d..576ed78e 100644 --- a/src/crates/core/src/service/bootstrap/bootstrap.rs +++ b/src/crates/core/src/service/bootstrap/bootstrap.rs @@ -59,6 +59,10 @@ pub(crate) async fn initialize_workspace_persona_files(workspace_root: &Path) -> Ok(()) } +pub(crate) fn is_workspace_bootstrap_pending(workspace_root: &Path) -> bool { + workspace_root.join(BOOTSTRAP_FILE_NAME).exists() +} + pub(crate) async fn ensure_workspace_persona_files_for_prompt( workspace_root: &Path, ) -> BitFunResult<()> { diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs index e29cced9..5a10bfc7 100644 --- a/src/crates/core/src/service/bootstrap/mod.rs +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -1,4 +1,7 @@ mod bootstrap; pub use bootstrap::reset_workspace_persona_files_to_default; -pub(crate) use bootstrap::{build_workspace_persona_prompt, initialize_workspace_persona_files}; +pub(crate) use bootstrap::{ + build_workspace_persona_prompt, initialize_workspace_persona_files, + is_workspace_bootstrap_pending, +}; diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index e33bac36..a5f2621b 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -1293,32 +1293,9 @@ async fn load_last_dialog_pair_from_turns( )) } -/// Strip XML wrapper tags injected by wrap_user_input before storing the message: -/// \n{content}\n\n... +/// Strip prompt markup injected before storing the message. fn strip_user_message_tags(raw: &str) -> String { - let text = raw.trim(); - - // Extract content inside ... if present. - let inner = if let Some(start) = text.find("") { - let after_open = &text[start + "".len()..]; - if let Some(end) = after_open.find("") { - after_open[..end].trim() - } else { - // Malformed — use everything after the opening tag. - after_open.trim() - } - } else { - text - }; - - // Drop any trailing block. - let result = if let Some(reminder_pos) = inner.find("") { - inner[..reminder_pos].trim() - } else { - inner.trim() - }; - - result.to_string() + crate::agentic::core::strip_prompt_markup(raw) } fn truncate_text(text: &str, max_chars: usize) -> String { @@ -1801,7 +1778,7 @@ pub async fn execute_forwarded_turn( interaction_handler: Option, _message_sender: Option, ) -> ForwardedTurnResult { - use crate::agentic::coordination::DialogTriggerSource; + use crate::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; use crate::service::remote_connect::remote_server::{ get_or_init_global_dispatcher, TrackerEvent, }; @@ -1818,8 +1795,8 @@ pub async fn execute_forwarded_turn( forward.content, Some(&forward.agent_type), forward.image_contexts, - DialogTriggerSource::Bot, - Some(forward.turn_id.clone()), + DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot), + Some(forward.turn_id), ) .await { diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 037681b2..4fe6ef43 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -693,23 +693,14 @@ async fn load_chat_messages_from_conversation_persistence( } fn strip_user_input_tags(content: &str) -> String { - let s = content.trim(); - if s.starts_with("") { - if let Some(end) = s.find("") { - let inner = s["".len()..end].trim(); - return inner.to_string(); - } - } - if let Some(pos) = s.find("") { - return s[..pos].trim().to_string(); - } + let s = crate::agentic::core::strip_prompt_markup(content); // Extract original question from enhancer-wrapped content if s.starts_with("User uploaded") { if let Some(pos) = s.find("User's question:\n") { return s[pos + "User's question:\n".len()..].trim().to_string(); } } - s.to_string() + s } fn resolve_agent_type(mobile_type: Option<&str>) -> &'static str { @@ -1446,7 +1437,7 @@ impl RemoteExecutionDispatcher { content: String, agent_type: Option<&str>, image_contexts: Vec, - trigger_source: crate::agentic::coordination::DialogTriggerSource, + submission_policy: crate::agentic::coordination::DialogSubmissionPolicy, turn_id: Option, ) -> std::result::Result<(String, String), String> { use crate::agentic::coordination::get_global_coordinator; @@ -1533,7 +1524,7 @@ impl RemoteExecutionDispatcher { Some(turn_id.clone()), resolved_agent_type, binding_workspace.clone(), - trigger_source, + submission_policy, ) .await .map_err(|e| e.to_string())?; @@ -1547,7 +1538,7 @@ impl RemoteExecutionDispatcher { Some(turn_id.clone()), resolved_agent_type, binding_workspace, - trigger_source, + submission_policy, ) .await .map_err(|e| e.to_string())?; @@ -2315,7 +2306,9 @@ impl RemoteServer { // ── Execution commands ────────────────────────────────────────── async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::{get_global_coordinator, DialogTriggerSource}; + use crate::agentic::coordination::{ + get_global_coordinator, DialogSubmissionPolicy, DialogTriggerSource, + }; let dispatcher = get_or_init_global_dispatcher(); @@ -2342,7 +2335,7 @@ impl RemoteServer { content.clone(), requested_agent_type.as_deref(), resolved_contexts, - DialogTriggerSource::RemoteRelay, + DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay), None, ) .await diff --git a/src/web-ui/src/app/hooks/useAssistantBootstrap.ts b/src/web-ui/src/app/hooks/useAssistantBootstrap.ts new file mode 100644 index 00000000..ba49eee3 --- /dev/null +++ b/src/web-ui/src/app/hooks/useAssistantBootstrap.ts @@ -0,0 +1,206 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { + agentAPI, + type EnsureAssistantBootstrapResponse, +} from '@/infrastructure/api/service-api/AgentAPI'; +import { useI18n } from '@/infrastructure/i18n'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; + +const log = createLogger('AssistantBootstrap'); + +interface BootstrapRequest { + workspacePath: string; + sessionId: string; +} + +interface ActiveBootstrapAttempt extends BootstrapRequest { + turnId: string; +} + +export function useAssistantBootstrap() { + const { t } = useI18n('notifications'); + const activeAttemptRef = useRef(null); + const pendingRequestRef = useRef(null); + const latestWorkspacePathRef = useRef(null); + const inFlightWorkspacePathRef = useRef(null); + const blockedNoticeShownRef = useRef>(new Set()); + const requestBootstrapRef = useRef<(request: BootstrapRequest) => void>(() => {}); + + const drainPendingRequest = useCallback(() => { + const pending = pendingRequestRef.current; + if (!pending) { + return; + } + + if (latestWorkspacePathRef.current !== pending.workspacePath) { + pendingRequestRef.current = null; + return; + } + + pendingRequestRef.current = null; + requestBootstrapRef.current(pending); + }, []); + + const clearActiveAttempt = useCallback( + (event: { sessionId?: string; turnId?: string }) => { + const activeAttempt = activeAttemptRef.current; + if (!activeAttempt) { + return; + } + + if (event.sessionId !== activeAttempt.sessionId || event.turnId !== activeAttempt.turnId) { + return; + } + + activeAttemptRef.current = null; + drainPendingRequest(); + }, + [drainPendingRequest] + ); + + useEffect(() => { + const unlistenCompleted = agentAPI.onDialogTurnCompleted(clearActiveAttempt); + const unlistenFailed = agentAPI.onDialogTurnFailed(clearActiveAttempt); + const unlistenCancelled = agentAPI.onDialogTurnCancelled(clearActiveAttempt); + + return () => { + unlistenCompleted(); + unlistenFailed(); + unlistenCancelled(); + }; + }, [clearActiveAttempt]); + + const handleEnsureResponse = useCallback( + (request: BootstrapRequest, response: EnsureAssistantBootstrapResponse): void => { + switch (response.status) { + case 'started': + if (!response.turnId) { + log.warn('Assistant bootstrap started without turnId', { request, response }); + return; + } + activeAttemptRef.current = { + ...request, + sessionId: response.sessionId, + turnId: response.turnId, + }; + blockedNoticeShownRef.current.delete(request.workspacePath); + log.info('Assistant bootstrap started', { + workspacePath: request.workspacePath, + sessionId: response.sessionId, + turnId: response.turnId, + }); + return; + case 'blocked': + if ( + response.reason === 'model_unavailable' && + !blockedNoticeShownRef.current.has(request.workspacePath) + ) { + blockedNoticeShownRef.current.add(request.workspacePath); + notificationService.info(t('info.assistantBootstrapWaitingForModelConfiguration'), { + duration: 5000, + }); + } + log.info('Assistant bootstrap blocked', { + workspacePath: request.workspacePath, + sessionId: request.sessionId, + reason: response.reason, + detail: response.detail, + }); + return; + case 'skipped': + if (response.reason === 'bootstrap_not_required') { + blockedNoticeShownRef.current.delete(request.workspacePath); + } + log.debug('Assistant bootstrap skipped', { + workspacePath: request.workspacePath, + sessionId: request.sessionId, + reason: response.reason, + }); + return; + default: + return; + } + }, + [t] + ); + + const requestBootstrap = useCallback( + async (request: BootstrapRequest): Promise => { + const activeAttempt = activeAttemptRef.current; + if (activeAttempt) { + if (activeAttempt.workspacePath === request.workspacePath) { + return; + } + pendingRequestRef.current = request; + return; + } + + const inFlightWorkspacePath = inFlightWorkspacePathRef.current; + if (inFlightWorkspacePath) { + if (inFlightWorkspacePath === request.workspacePath) { + return; + } + pendingRequestRef.current = request; + return; + } + + inFlightWorkspacePathRef.current = request.workspacePath; + + try { + const response = await agentAPI.ensureAssistantBootstrap({ + sessionId: request.sessionId, + workspacePath: request.workspacePath, + }); + handleEnsureResponse(request, response); + } catch (error) { + log.error('Failed to ensure assistant bootstrap', { + workspacePath: request.workspacePath, + sessionId: request.sessionId, + error, + }); + } finally { + if (inFlightWorkspacePathRef.current === request.workspacePath) { + inFlightWorkspacePathRef.current = null; + } + + if (!activeAttemptRef.current) { + drainPendingRequest(); + } + } + }, + [drainPendingRequest, handleEnsureResponse] + ); + + useEffect(() => { + requestBootstrapRef.current = (request: BootstrapRequest) => { + void requestBootstrap(request); + }; + }, [requestBootstrap]); + + const ensureForWorkspace = useCallback( + (workspace: WorkspaceInfo | null | undefined, sessionId?: string | null): void => { + latestWorkspacePathRef.current = workspace?.rootPath ?? null; + + if ( + !workspace || + workspace.workspaceKind !== WorkspaceKind.Assistant || + !sessionId + ) { + pendingRequestRef.current = null; + return; + } + + void requestBootstrap({ + workspacePath: workspace.rootPath, + sessionId, + }); + }, + [requestBootstrap] + ); + + return { + ensureForWorkspace, + }; +} diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index bfc1e2de..10701990 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -12,6 +12,7 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { open } from '@tauri-apps/plugin-dialog'; import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceContext'; import { useWindowControls } from '../hooks/useWindowControls'; +import { useAssistantBootstrap } from '../hooks/useAssistantBootstrap'; import { useApp } from '../hooks/useApp'; import { useSceneStore } from '../stores/sceneStore'; @@ -55,6 +56,7 @@ const AppLayout: React.FC = ({ className = '' }) => { const { currentWorkspace, hasWorkspace, openWorkspace, recentWorkspaces, loading } = useWorkspaceContext(); const { isToolbarMode } = useToolbarModeContext(); + const { ensureForWorkspace: ensureAssistantBootstrapForWorkspace } = useAssistantBootstrap(); const { handleMinimize, handleMaximize, handleClose, isMaximized } = useWindowControls({ isToolbarMode }); @@ -187,6 +189,11 @@ const AppLayout: React.FC = ({ className = '' }) => { sessionId = await flowChatManager.createChatSession({}, initialSessionMode); } + const activeSessionId = sessionId || flowChatStore.getState().activeSessionId; + if (currentWorkspace.workspaceKind === WorkspaceKind.Assistant && activeSessionId) { + ensureAssistantBootstrapForWorkspace(currentWorkspace, activeSessionId); + } + const pendingDescription = sessionStorage.getItem('pendingProjectDescription'); if (pendingDescription && pendingDescription.trim()) { sessionStorage.removeItem('pendingProjectDescription'); @@ -236,7 +243,13 @@ const AppLayout: React.FC = ({ className = '' }) => { }; initializeFlowChat(); - }, [currentWorkspace?.rootPath, t]); + }, [ + currentWorkspace?.id, + currentWorkspace?.rootPath, + currentWorkspace?.workspaceKind, + ensureAssistantBootstrapForWorkspace, + t, + ]); // Save in-progress conversations on window close React.useEffect(() => { 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 5ac81330..730e1549 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 @@ -409,21 +409,18 @@ function handleImageAnalysisCompleted(_context: FlowChatContext, event: ImageAna /** * Strip agent-internal XML wrapper tags from user input before displaying. - * Handles: ... and trailing ... + * Handles both normal and forwarded-agent envelopes. */ function cleanRemoteUserInput(raw: string): string { - let s = raw.trim(); - if (s.startsWith('')) { - const endIdx = s.indexOf(''); - if (endIdx !== -1) { - s = s.slice(''.length, endIdx).trim(); - } - } - const reminderIdx = s.indexOf(''); - if (reminderIdx !== -1) { - s = s.slice(0, reminderIdx).trim(); + const s = raw.trim(); + const userQueryMatch = s.match(/\s*([\s\S]*?)\s*<\/user_query>/); + if (userQueryMatch) { + return userQueryMatch[1].trim(); } - return s; + + return s + .replace(/[\s\S]*?<\/system(?:_|-)reminder>/g, '') + .trim(); } function handleDialogTurnStarted(context: FlowChatContext, event: any): void { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index c468d415..a0c0d2c4 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -13,6 +13,7 @@ import type { FlowChatContext, SessionConfig } from './types'; import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; const log = createLogger('SessionModule'); +const pendingSessionCreations = new Map>(); type SessionDisplayMode = 'code' | 'cowork' | 'claw'; @@ -117,6 +118,12 @@ export async function createChatSession( } const agentType = resolveAgentType(mode, workspace); const sessionMode = normalizeSessionDisplayMode(agentType, workspace); + const creationKey = workspacePath; + + const pendingCreation = pendingSessionCreations.get(creationKey); + if (pendingCreation) { + return pendingCreation; + } const sameModeCount = Array.from(context.flowChatStore.getState().sessions.values()).filter( @@ -131,31 +138,42 @@ export async function createChatSession( const maxContextTokens = await getModelMaxTokens(config.modelName); - const response = await agentAPI.createSession({ - sessionName, - agentType, - workspacePath, - config: { - modelName: config.modelName || 'auto', - enableTools: true, - safeMode: true, - autoCompact: true, - maxContextTokens: maxContextTokens, - enableContextCompression: true, - } - }); - - context.flowChatStore.createSession( - response.sessionId, - config, - undefined, - sessionName, - maxContextTokens, - agentType, - workspacePath - ); + const createPromise = (async () => { + const response = await agentAPI.createSession({ + sessionName, + agentType, + workspacePath, + config: { + modelName: config.modelName || 'auto', + enableTools: true, + safeMode: true, + autoCompact: true, + maxContextTokens: maxContextTokens, + enableContextCompression: true, + } + }); + + context.flowChatStore.createSession( + response.sessionId, + config, + undefined, + sessionName, + maxContextTokens, + agentType, + workspacePath + ); + + return response.sessionId; + })(); - return response.sessionId; + pendingSessionCreations.set(creationKey, createPromise); + try { + return await createPromise; + } finally { + if (pendingSessionCreations.get(creationKey) === createPromise) { + pendingSessionCreations.delete(creationKey); + } + } } catch (error) { log.error('Failed to create chat session', { config, error }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 99bca426..c7ca99eb 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1565,6 +1565,21 @@ export class FlowChatStore { } } + /** + * Strip agent-internal XML wrapper tags from persisted user inputs. + */ + private cleanRemoteUserInput(raw: string): string { + const s = raw.trim(); + const userQueryMatch = s.match(/\s*([\s\S]*?)\s*<\/user_query>/); + if (userQueryMatch) { + return userQueryMatch[1].trim(); + } + + return s + .replace(/[\s\S]*?<\/system(?:_|-)reminder>/g, '') + .trim(); + } + /** * Convert DialogTurnData to FlowChat DialogTurn format */ @@ -1583,7 +1598,8 @@ export class FlowChatStore { })) : undefined; - const displayContent = metadata?.original_text || turn.userMessage.content; + const displayContent = + metadata?.original_text || this.cleanRemoteUserInput(turn.userMessage.content); diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss index 938e1454..f52a9dcb 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.scss @@ -5,148 +5,196 @@ @use './_tool-card-common.scss'; -/* ========== Confirmation state pulse animation ========== */ @keyframes defaultToolConfirmationPulse { 0%, 100% { - box-shadow: - 0 0 0 1px rgba(245, 158, 11, 0.15), - 0 0 12px rgba(245, 158, 11, 0.2), - inset 0 0 8px rgba(245, 158, 11, 0.05); + box-shadow: + 0 0 0 1px rgba(245, 158, 11, 0.14), + 0 0 12px rgba(245, 158, 11, 0.16); } 50% { - box-shadow: - 0 0 0 2px rgba(245, 158, 11, 0.25), - 0 0 20px rgba(245, 158, 11, 0.35), - inset 0 0 12px rgba(245, 158, 11, 0.08); + box-shadow: + 0 0 0 2px rgba(245, 158, 11, 0.2), + 0 0 16px rgba(245, 158, 11, 0.22); } } -.flow-tool-card.default-tool-card { - &.requires-confirmation { - border-color: var(--color-warning, #f59e0b); - border-width: 1.5px; - box-shadow: - 0 0 0 1px rgba(245, 158, 11, 0.15), - 0 0 12px rgba(245, 158, 11, 0.2), - inset 0 0 8px rgba(245, 158, 11, 0.05); - animation: defaultToolConfirmationPulse 2s ease-in-out infinite; - - &:hover { - border-color: var(--color-warning, #f59e0b); - box-shadow: - 0 0 0 2px rgba(245, 158, 11, 0.25), - 0 0 20px rgba(245, 158, 11, 0.3), - 0 4px 20px rgba(0, 0, 0, 0.3), - inset 0 0 12px rgba(245, 158, 11, 0.08); - } +.default-tool-card { + .compact-tool-card { + min-height: 28px; + padding-right: 10px !important; + } + + .compact-card-action { + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-secondary); + font-weight: 600; } - .tool-input { - margin-top: 8px; - padding: 8px; - background: var(--tool-card-bg-secondary); - border-radius: 4px; + .compact-card-content { + color: var(--color-text-muted); } - .input-label { - font-size: 11px; + .compact-card-extra { + flex-shrink: 0; + min-width: 0; + } + + .compact-card-right-icon { + color: var(--color-text-muted); + } + + .default-tool-card__icon-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 18px; + padding: 0 6px; + border: 1px solid var(--border-subtle); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + color: var(--color-text-muted); + font-size: 10px; font-weight: 600; - color: var(--tool-card-text-secondary); - margin-bottom: 6px; + letter-spacing: 0.04em; text-transform: uppercase; - letter-spacing: 0.5px; - } - - .input-content { - pre { - margin: 0; - padding: 6px; - background: var(--tool-card-bg-primary); - border: 1px solid var(--tool-card-border); - border-radius: 3px; - color: var(--tool-card-text-primary); - font-family: var(--tool-card-font-mono); - font-size: 11px; - line-height: 1.4; - overflow-x: auto; - white-space: pre-wrap; - word-break: break-word; - } } - .input-parsing { - color: var(--tool-card-text-muted); + .default-tool-card__expanded { + display: flex; + flex-direction: column; + gap: 12px; + } + + .default-tool-card__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-subtle); + } + + .default-tool-card__meta-label { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); + color: var(--color-text-secondary); + font-size: 11px; + font-weight: 600; + font-family: var(--tool-card-font-mono); + } + + .default-tool-card__meta-description { + color: var(--color-text-muted); font-size: 12px; - font-style: italic; - padding: 6px; + line-height: 1.4; } - .tool-result { - margin-top: 8px; - padding: 8px; - background: var(--tool-card-bg-secondary); - border-radius: 4px; + .default-tool-card__section { + display: flex; + flex-direction: column; + gap: 6px; } - .result-label { + .default-tool-card__section-label { + color: var(--color-text-secondary); font-size: 11px; font-weight: 600; - color: var(--tool-card-text-secondary); - margin-bottom: 6px; text-transform: uppercase; - letter-spacing: 0.5px; - } - - .result-content { - pre { - margin: 0; - padding: 6px; - background: var(--tool-card-bg-primary); - border: 1px solid var(--tool-card-border); - border-radius: 3px; - color: var(--tool-card-text-primary); - font-family: var(--tool-card-font-mono); - font-size: 11px; - line-height: 1.4; - overflow-x: auto; - max-height: 300px; - overflow-y: auto; - white-space: pre-wrap; - word-break: break-word; - } + letter-spacing: 0.04em; } - .result-success { - border-left: 3px solid #22c55e; - padding-left: 8px; + .default-tool-card__code-block { + margin: 0; + padding: 10px 12px; + border: 1px solid var(--border-subtle); + border-radius: 8px; + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-family: var(--tool-card-font-mono); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + overflow: auto; + max-height: 320px; } - .result-error { - border-left: 3px solid #ef4444; - padding-left: 8px; + .default-tool-card__error-message { + padding: 10px 12px; + border: 1px solid rgba(239, 68, 68, 0.24); + border-radius: 8px; + background: rgba(239, 68, 68, 0.08); + color: var(--color-error); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + } - .error-message { - color: #ef4444; - font-weight: 500; - padding: 6px 0; - } + .default-tool-card__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; } - .expand-button { - margin-top: 8px; - width: 100%; - padding: 6px; - background: var(--tool-card-bg-primary); - border: 1px solid var(--tool-card-border); - border-radius: 4px; - color: var(--tool-card-text-secondary); - font-size: 11px; + .default-tool-card__button { + appearance: none; + border: 1px solid var(--border-base); + border-radius: 8px; + padding: 7px 12px; + font-size: 12px; + font-weight: 600; cursor: pointer; transition: all 0.2s ease; + } + + .default-tool-card__button:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .default-tool-card__button--confirm { + background: rgba(34, 197, 94, 0.12); + border-color: rgba(34, 197, 94, 0.26); + color: #86efac; + } + + .default-tool-card__button--confirm:hover:not(:disabled) { + background: rgba(34, 197, 94, 0.18); + border-color: rgba(34, 197, 94, 0.4); + } + + .default-tool-card__button--reject { + background: rgba(255, 255, 255, 0.03); + color: var(--color-text-secondary); + } + + .default-tool-card__button--reject:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); + color: var(--color-text-primary); + } + + &.requires-confirmation { + .compact-tool-card { + border-radius: 8px; + background: rgba(245, 158, 11, 0.05) !important; + box-shadow: + inset 0 0 0 1px rgba(245, 158, 11, 0.18), + 0 0 0 1px rgba(245, 158, 11, 0.1); + animation: defaultToolConfirmationPulse 2s ease-in-out infinite; + } - &:hover { - background: var(--tool-card-bg-hover); - color: var(--tool-card-text-primary); + .compact-card-status-icon, + .compact-card-right-icon { + color: var(--color-warning, #f59e0b); } } } diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx index 81a43e59..e4336353 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx @@ -3,10 +3,81 @@ * Used for tool types without specific customization */ -import React from 'react'; -import { Loader2, XCircle, Clock } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { Loader2, XCircle, Clock, Check, ChevronDown, ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; +import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import './DefaultToolCard.scss'; + +const MAX_PREVIEW_CHARS = 4000; + +function sanitizeToolInput(input: any): any { + if (input === null || input === undefined) return input; + if (Array.isArray(input)) return input; + if (typeof input !== 'object') return input; + + return Object.entries(input).reduce((acc, [key, value]) => { + if (!key.startsWith('_')) { + acc[key] = value; + } + return acc; + }, {} as Record); +} + +function hasVisibleValue(value: any): boolean { + if (value === null || value === undefined) return false; + if (typeof value === 'string') return value.trim().length > 0; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === 'object') return Object.keys(value).length > 0; + return true; +} + +function stringifyValue(value: any): string { + try { + if (typeof value === 'string') { + return value; + } + + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function truncatePreview(text: string, maxChars: number = MAX_PREVIEW_CHARS): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n...`; +} + +function getInlinePreview(value: any): string | null { + if (value === null || value === undefined) return null; + + if (typeof value === 'string') { + const normalized = value.replace(/\s+/g, ' ').trim(); + if (!normalized) return null; + return normalized.length > 72 ? `${normalized.slice(0, 72)}...` : normalized; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + if (Array.isArray(value)) { + return `Array(${value.length})`; + } + + if (typeof value === 'object') { + const entries = Object.entries(value).filter(([key]) => !key.startsWith('_')); + if (entries.length === 0) return null; + + const [firstKey, firstValue] = entries[0]; + const nestedPreview = getInlinePreview(firstValue); + return nestedPreview ? `${firstKey}: ${nestedPreview}` : `Object(${entries.length})`; + } + + return String(value); +} export const DefaultToolCard: React.FC = ({ toolItem, @@ -17,6 +88,28 @@ export const DefaultToolCard: React.FC = ({ }) => { const { t } = useTranslation('flow-chat'); const { toolCall, toolResult, status, requiresConfirmation, userConfirmed } = toolItem; + const [isExpanded, setIsExpanded] = useState(false); + + const filteredInput = useMemo(() => sanitizeToolInput(toolCall?.input), [toolCall?.input]); + const hasInput = useMemo(() => hasVisibleValue(filteredInput), [filteredInput]); + const hasResult = toolResult !== undefined && toolResult !== null && config.resultDisplayType !== 'hidden'; + const errorMessage = toolResult?.success === false ? toolResult.error || t('toolCards.default.failed') : null; + const hasError = Boolean(errorMessage); + const showConfirmationActions = requiresConfirmation && !userConfirmed && + status !== 'completed' && + status !== 'cancelled' && + status !== 'error'; + const canExpand = hasInput || hasResult || hasError || showConfirmationActions; + + const inputPreview = useMemo(() => { + if (!hasInput) return null; + return truncatePreview(stringifyValue(filteredInput)); + }, [filteredInput, hasInput]); + + const resultPreview = useMemo(() => { + if (!hasResult) return null; + return truncatePreview(stringifyValue(toolResult?.result)); + }, [hasResult, toolResult?.result]); const handleConfirm = () => { onConfirm?.(toolCall?.input); @@ -26,15 +119,25 @@ export const DefaultToolCard: React.FC = ({ onReject?.(); }; + const handleToggleExpand = () => { + if (!canExpand) return; + + const nextExpanded = !isExpanded; + setIsExpanded(nextExpanded); + + if (nextExpanded) { + onExpand?.(); + } + }; + const getStatusIcon = () => { switch (status) { case 'running': case 'streaming': return ; case 'completed': - return null; + return ; case 'cancelled': - return ; case 'error': return ; default: @@ -46,12 +149,12 @@ export const DefaultToolCard: React.FC = ({ if (requiresConfirmation && !userConfirmed) { return t('toolCards.default.waitingConfirm'); } - + const progressMessage = (toolItem as any)._progressMessage; if (progressMessage && (status === 'running' || status === 'streaming')) { return progressMessage; } - + switch (status) { case 'streaming': case 'running': @@ -67,94 +170,118 @@ export const DefaultToolCard: React.FC = ({ } }; - const showConfirmationHighlight = requiresConfirmation && !userConfirmed && - status !== 'completed' && - status !== 'cancelled' && + const getSummaryText = () => { + if (requiresConfirmation && !userConfirmed) { + const preview = getInlinePreview(filteredInput); + return preview + ? `${t('toolCards.default.waitingConfirm')} - ${preview}` + : t('toolCards.default.waitingConfirm'); + } + + const progressMessage = (toolItem as any)._progressMessage; + if (progressMessage && (status === 'running' || status === 'streaming')) { + return progressMessage; + } + + if (status === 'completed') { + const preview = getInlinePreview(toolResult?.result) || getInlinePreview(filteredInput); + return preview + ? `${t('toolCards.default.completed')} - ${preview}` + : t('toolCards.default.completed'); + } + + if (status === 'error') { + return errorMessage || t('toolCards.default.failed'); + } + + if (status === 'running' || status === 'streaming') { + const preview = getInlinePreview(filteredInput); + return preview + ? `${t('toolCards.default.executing')} - ${preview}` + : t('toolCards.default.executing'); + } + + if (status === 'pending' || status === 'preparing') { + const preview = getInlinePreview(filteredInput); + return preview + ? `${t('toolCards.default.preparing')} - ${preview}` + : t('toolCards.default.preparing'); + } + + return getStatusText(); + }; + + const showConfirmationHighlight = requiresConfirmation && !userConfirmed && + status !== 'completed' && + status !== 'cancelled' && status !== 'error'; return ( -
-
-
- {config.icon} -
-
{config.displayName}
-
{config.description}
-
-
-
- {getStatusIcon()} - {getStatusText()} -
-
- - {toolCall && ( -
-
{t('toolCards.common.inputParams')}:
-
- {(() => { - if (!toolCall.input) { - return
{t('toolCards.readFile.parsingParams')}
; - } - - const filteredInput = Object.keys(toolCall.input) - .filter(key => !key.startsWith('_')) - .reduce((obj, key) => { - obj[key] = toolCall.input[key]; - return obj; - }, {} as Record); - - if (Object.keys(filteredInput).length === 0) { - return
{t('toolCards.readFile.parsingParams')}
; - } - - return
{JSON.stringify(filteredInput, null, 2)}
; - })()} -
-
- )} - - {requiresConfirmation && !userConfirmed && status !== 'completed' && ( -
- - -
- )} - - {toolResult && config.resultDisplayType !== 'hidden' && ( -
-
{t('toolCards.common.executionResult')}:
-
- {toolResult.success ? ( -
-
{JSON.stringify(toolResult.result, null, 2)}
-
- ) : ( -
-
{toolResult.error || t('toolCards.default.failed')}
-
+ {config.icon} : undefined} + rightIcon={canExpand ? (isExpanded ? : ) : undefined} + /> + } + expandedContent={canExpand ? ( +
+
+ {config.toolName} + {config.description && ( + {config.description} )}
- {config.resultDisplayType === 'summary' && ( - + + {hasInput && ( +
+
{t('toolCards.common.inputParams')}
+
{inputPreview}
+
+ )} + + {showConfirmationActions && ( +
+ + +
+ )} + + {hasResult && ( +
+
{t('toolCards.common.executionResult')}
+ {toolResult?.success === false ? ( +
{errorMessage}
+ ) : ( +
{resultPreview}
+ )} +
)}
- )} -
+ ) : undefined} + /> ); }; diff --git a/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx new file mode 100644 index 00000000..ad6a840c --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx @@ -0,0 +1,236 @@ +import React, { useMemo, useState } from 'react'; +import { Check, Clock, Loader2, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { ToolCardProps } from '../types/flow-chat'; +import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; + +interface SessionSummary { + session_id?: string; + session_name?: string; + agent_type?: string; +} + +interface SessionControlInput { + action?: 'create' | 'delete' | 'list'; + workspace?: string; + session_id?: string; + session_name?: string; + agent_type?: string; +} + +interface SessionControlResult { + success?: boolean; + action?: 'create' | 'delete' | 'list'; + workspace?: string; + count?: number; + session_id?: string; + session?: SessionSummary; + sessions?: SessionSummary[]; +} + +function parseData(value: unknown): T | null { + if (!value) return null; + + try { + return typeof value === 'string' ? JSON.parse(value) as T : value as T; + } catch { + return null; + } +} + +export const SessionControlToolCard: React.FC = React.memo(({ + toolItem, +}) => { + const { t } = useTranslation('flow-chat'); + const { toolCall, toolResult, status } = toolItem; + const [isExpanded, setIsExpanded] = useState(false); + + const inputData = useMemo( + () => parseData(toolCall?.input) ?? {}, + [toolCall?.input] + ); + + const resultData = useMemo( + () => parseData(toolResult?.result), + [toolResult?.result] + ); + + const action = resultData?.action ?? inputData.action ?? 'list'; + const workspace = resultData?.workspace ?? inputData.workspace; + const session = resultData?.session; + const sessionId = session?.session_id ?? resultData?.session_id ?? inputData.session_id; + const sessionName = session?.session_name ?? inputData.session_name; + const agentType = session?.agent_type ?? inputData.agent_type; + const sessions = Array.isArray(resultData?.sessions) ? resultData.sessions : []; + const sessionCount = resultData?.count ?? sessions.length; + const hasDetails = Boolean(workspace || sessionId || sessionName || agentType || sessions.length || toolResult?.error); + + const getStatusIcon = () => { + switch (status) { + case 'running': + case 'streaming': + return ; + case 'completed': + return ; + case 'error': + case 'cancelled': + return ; + case 'pending': + case 'preparing': + default: + return ; + } + }; + + const getActionLabel = () => { + switch (action) { + case 'create': + return sessionName || t('toolCards.sessionControl.defaultSessionName'); + case 'delete': + return sessionId || t('toolCards.sessionControl.unknownSession'); + case 'list': + default: + return workspace || t('toolCards.sessionControl.currentWorkspace'); + } + }; + + const renderContent = () => { + const label = getActionLabel(); + + if (status === 'completed') { + switch (action) { + case 'create': + return <>{t('toolCards.sessionControl.createdSession', { session: label })}; + case 'delete': + return <>{t('toolCards.sessionControl.deletedSession', { session: label })}; + case 'list': + default: + return <>{t('toolCards.sessionControl.listedSessions', { count: sessionCount })}; + } + } + + if (status === 'running' || status === 'streaming') { + switch (action) { + case 'create': + return <>{t('toolCards.sessionControl.creatingSession', { session: label })}...; + case 'delete': + return <>{t('toolCards.sessionControl.deletingSession', { session: label })}...; + case 'list': + default: + return <>{t('toolCards.sessionControl.listingSessions')}...; + } + } + + if (status === 'error' || status === 'cancelled') { + return <>{t('toolCards.sessionControl.actionFailed')}; + } + + switch (action) { + case 'create': + return <>{t('toolCards.sessionControl.preparingCreate', { session: label })}; + case 'delete': + return <>{t('toolCards.sessionControl.preparingDelete', { session: label })}; + case 'list': + default: + return <>{t('toolCards.sessionControl.preparingList')}; + } + }; + + const expandedContent = hasDetails ? ( +
+ {workspace && ( +
+ {t('toolCards.sessionControl.workspace')}: + {workspace} +
+ )} + + {sessionId && ( +
+ {t('toolCards.sessionControl.sessionId')}: + {sessionId} +
+ )} + + {sessionName && ( +
+ {t('toolCards.sessionControl.sessionName')}: + {sessionName} +
+ )} + + {agentType && ( +
+ {t('toolCards.sessionControl.agentType')}: + {agentType} +
+ )} + + {action === 'list' && ( +
+ {t('toolCards.sessionControl.sessionCount')}: + {sessionCount} +
+ )} + + {action === 'list' && sessions.length > 0 && ( +
+ {sessions.map((item, index) => ( +
+ + {item.session_id || t('toolCards.sessionControl.unknownSession')} + + {item.session_name || t('toolCards.sessionControl.defaultSessionName')} + {item.agent_type || '-'} +
+ ))} +
+ )} + + {action === 'list' && sessions.length === 0 && status === 'completed' && ( +
+ {t('toolCards.sessionControl.noSessions')} +
+ )} + + {toolResult?.error && ( +
+ {toolResult.error} +
+ )} +
+ ) : null; + + return ( + { + if (hasDetails) { + setIsExpanded(prev => !prev); + } + }} + className="session-control-card" + clickable={hasDetails} + header={( + + )} + expandedContent={expandedContent} + /> + ); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx new file mode 100644 index 00000000..0b7ea871 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx @@ -0,0 +1,161 @@ +import React, { useMemo, useState } from 'react'; +import { Check, Clock, Loader2, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { ToolCardProps } from '../types/flow-chat'; +import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; + +interface SessionMessageInput { + workspace?: string; + session_id?: string; + message?: string; + agent_type?: string; +} + +interface SessionMessageResult { + success?: boolean; + target_workspace?: string; + target_session_id?: string; + target_agent_type?: string; +} + +function parseData(value: unknown): T | null { + if (!value) return null; + + try { + return typeof value === 'string' ? JSON.parse(value) as T : value as T; + } catch { + return null; + } +} + +export const SessionMessageToolCard: React.FC = React.memo(({ + toolItem, +}) => { + const { t } = useTranslation('flow-chat'); + const { toolCall, toolResult, status } = toolItem; + const [isExpanded, setIsExpanded] = useState(false); + + const inputData = useMemo( + () => parseData(toolCall?.input) ?? {}, + [toolCall?.input] + ); + + const resultData = useMemo( + () => parseData(toolResult?.result), + [toolResult?.result] + ); + + const targetSessionId = resultData?.target_session_id ?? inputData.session_id; + const workspace = resultData?.target_workspace ?? inputData.workspace; + const agentType = resultData?.target_agent_type ?? inputData.agent_type; + const message = inputData.message ?? ''; + const hasDetails = Boolean(targetSessionId || workspace || agentType || message || toolResult?.error); + + const getStatusIcon = () => { + switch (status) { + case 'running': + case 'streaming': + return ; + case 'completed': + return ; + case 'error': + case 'cancelled': + return ; + case 'pending': + case 'preparing': + default: + return ; + } + }; + + const targetLabel = targetSessionId || t('toolCards.sessionMessage.unknownSession'); + + const renderContent = () => { + if (status === 'completed') { + return <>{t('toolCards.sessionMessage.messageAccepted', { session: targetLabel })}; + } + + if (status === 'running' || status === 'streaming') { + return <>{t('toolCards.sessionMessage.sendingMessage', { session: targetLabel })}...; + } + + if (status === 'error' || status === 'cancelled') { + return <>{t('toolCards.sessionMessage.sendFailed', { session: targetLabel })}; + } + + return <>{t('toolCards.sessionMessage.preparingSend', { session: targetLabel })}; + }; + + const expandedContent = hasDetails ? ( +
+ {targetSessionId && ( +
+ {t('toolCards.sessionMessage.targetSession')}: + {targetSessionId} +
+ )} + + {workspace && ( +
+ {t('toolCards.sessionMessage.workspace')}: + {workspace} +
+ )} + + {agentType && ( +
+ {t('toolCards.sessionMessage.agentType')}: + {agentType} +
+ )} + + {message && ( +
+ {t('toolCards.sessionMessage.message')}: +
+            {message}
+          
+
+ )} + + {toolResult?.error && ( +
+ {toolResult.error} +
+ )} +
+ ) : null; + + return ( + { + if (hasDetails) { + setIsExpanded(prev => !prev); + } + }} + className="session-message-card" + clickable={hasDetails} + header={( + + )} + expandedContent={expandedContent} + /> + ); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index c5503590..73e10744 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -33,6 +33,8 @@ import { TerminalToolCard } from './TerminalToolCard'; import { TerminalControlDisplay } from './TerminalControlDisplay'; import { InitMiniAppDisplay } from './MiniAppToolDisplay'; import { BtwMarkerCard } from './BtwMarkerCard'; +import { SessionControlToolCard } from './SessionControlToolCard'; +import { SessionMessageToolCard } from './SessionMessageToolCard'; // Tool card config map - uses backend tool names export const TOOL_CARD_CONFIGS: Record = { @@ -298,6 +300,28 @@ export const TOOL_CARD_CONFIGS: Record = { primaryColor: '#ef4444' }, + 'SessionControl': { + toolName: 'SessionControl', + displayName: 'Session Control', + icon: 'SC', + requiresConfirmation: false, + resultDisplayType: 'summary', + description: 'Create, delete, or list sessions', + displayMode: 'compact', + primaryColor: '#3b82f6' + }, + + 'SessionMessage': { + toolName: 'SessionMessage', + displayName: 'Session Message', + icon: 'SM', + requiresConfirmation: false, + resultDisplayType: 'summary', + description: 'Send a message to another session', + displayMode: 'compact', + primaryColor: '#8b5cf6' + }, + // Bash terminal tool 'Bash': { toolName: 'Bash', @@ -381,6 +405,10 @@ export const TOOL_CARD_COMPONENTS = { // TerminalControl tool 'TerminalControl': TerminalControlDisplay, + // Session tools + 'SessionControl': SessionControlToolCard, + 'SessionMessage': SessionMessageToolCard, + // Bash tool 'Bash': TerminalToolCard, 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 d0839e3a..f04094c9 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -63,6 +63,28 @@ export interface SessionInfo { createdAt: number; } +export interface EnsureAssistantBootstrapRequest { + sessionId: string; + workspacePath: string; +} + +export type EnsureAssistantBootstrapStatus = 'started' | 'skipped' | 'blocked'; + +export type EnsureAssistantBootstrapReason = + | 'bootstrap_started' + | 'bootstrap_not_required' + | 'session_has_existing_turns' + | 'session_not_idle' + | 'model_unavailable'; + +export interface EnsureAssistantBootstrapResponse { + status: EnsureAssistantBootstrapStatus; + reason: EnsureAssistantBootstrapReason; + sessionId: string; + turnId?: string; + detail?: string; +} + export interface Message { id: string; @@ -160,6 +182,18 @@ export class AgentAPI { } } + async ensureAssistantBootstrap( + request: EnsureAssistantBootstrapRequest + ): Promise { + try { + return await api.invoke('ensure_assistant_bootstrap', { + request + }); + } catch (error) { + throw createTauriCommandError('ensure_assistant_bootstrap', error, request); + } + } + async cancelDialogTurn(sessionId: string, dialogTurnId: string): Promise { try { 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 782fb690..e3212ff4 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -623,6 +623,40 @@ "interruptFailed": "Failed to interrupt terminal", "preparingInterrupt": "Preparing to interrupt terminal" }, + "sessionControl": { + "title": "Session", + "currentWorkspace": "Current workspace", + "defaultSessionName": "New Session", + "unknownSession": "Unknown session", + "preparingCreate": "Preparing to create session {{session}}", + "creatingSession": "Creating session {{session}}", + "createdSession": "Created session {{session}}", + "preparingDelete": "Preparing to delete session {{session}}", + "deletingSession": "Deleting session {{session}}", + "deletedSession": "Deleted session {{session}}", + "preparingList": "Preparing to list sessions", + "listingSessions": "Listing sessions", + "listedSessions": "Found {{count}} sessions", + "actionFailed": "Session operation failed", + "workspace": "Workspace", + "sessionId": "Session ID", + "sessionName": "Session name", + "agentType": "Agent type", + "sessionCount": "Session count", + "noSessions": "No sessions found" + }, + "sessionMessage": { + "title": "Session", + "unknownSession": "Unknown session", + "preparingSend": "Preparing to message {{session}}", + "sendingMessage": "Sending message to {{session}}", + "messageAccepted": "Message accepted for {{session}}", + "sendFailed": "Failed to message {{session}}", + "targetSession": "Target session", + "workspace": "Workspace", + "agentType": "Agent type", + "message": "Message" + }, "imageAnalysis": { "parsingAnalysisInfo": "Parsing analysis info...", "unknownImage": "Unknown image", diff --git a/src/web-ui/src/locales/en-US/notifications.json b/src/web-ui/src/locales/en-US/notifications.json index b01ae968..bf24a51e 100644 --- a/src/web-ui/src/locales/en-US/notifications.json +++ b/src/web-ui/src/locales/en-US/notifications.json @@ -47,7 +47,8 @@ "updating": "Updating...", "installing": "Installing...", "connecting": "Connecting...", - "syncing": "Syncing..." + "syncing": "Syncing...", + "assistantBootstrapWaitingForModelConfiguration": "Assistant bootstrap is waiting for AI model configuration." }, "confirm": { "deleteFile": "Are you sure you want to delete this file?", 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 a7516cf5..7dc7ffbc 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -617,6 +617,40 @@ "interruptFailed": "中断终端失败", "preparingInterrupt": "准备中断终端" }, + "sessionControl": { + "title": "会话", + "currentWorkspace": "当前工作区", + "defaultSessionName": "新会话", + "unknownSession": "未知会话", + "preparingCreate": "准备创建会话 {{session}}", + "creatingSession": "正在创建会话 {{session}}", + "createdSession": "已创建会话 {{session}}", + "preparingDelete": "准备删除会话 {{session}}", + "deletingSession": "正在删除会话 {{session}}", + "deletedSession": "已删除会话 {{session}}", + "preparingList": "准备列出会话", + "listingSessions": "正在列出会话", + "listedSessions": "共找到 {{count}} 个会话", + "actionFailed": "会话操作失败", + "workspace": "工作区", + "sessionId": "会话ID", + "sessionName": "会话名称", + "agentType": "Agent 类型", + "sessionCount": "会话数量", + "noSessions": "未找到会话" + }, + "sessionMessage": { + "title": "会话", + "unknownSession": "未知会话", + "preparingSend": "准备向 {{session}} 发送消息", + "sendingMessage": "正在向 {{session}} 发送消息", + "messageAccepted": "消息已提交给 {{session}}", + "sendFailed": "向 {{session}} 发送消息失败", + "targetSession": "目标会话", + "workspace": "工作区", + "agentType": "Agent 类型", + "message": "消息内容" + }, "imageAnalysis": { "parsingAnalysisInfo": "解析分析信息中...", "unknownImage": "未知图片", diff --git a/src/web-ui/src/locales/zh-CN/notifications.json b/src/web-ui/src/locales/zh-CN/notifications.json index 8e7319b0..25c2d5fe 100644 --- a/src/web-ui/src/locales/zh-CN/notifications.json +++ b/src/web-ui/src/locales/zh-CN/notifications.json @@ -47,7 +47,8 @@ "updating": "正在更新...", "installing": "正在安装...", "connecting": "正在连接...", - "syncing": "正在同步..." + "syncing": "正在同步...", + "assistantBootstrapWaitingForModelConfiguration": "Assistant 引导正在等待 AI 模型配置。" }, "confirm": { "deleteFile": "确定要删除此文件吗?",