| 1 | //! Core engine for `DeepSeek` CLI. |
| 2 | //! |
| 3 | //! The engine handles all AI interactions in a background task, |
| 4 | //! communicating with the UI via channels. This enables: |
| 5 | //! - Non-blocking UI during API calls |
| 6 | //! - Real-time streaming updates |
| 7 | //! - Proper cancellation support |
| 8 | //! - Tool execution orchestration |
| 9 | |
| 10 | use std::collections::hash_map::DefaultHasher; |
| 11 | use std::collections::{HashMap, HashSet, VecDeque}; |
| 12 | use std::hash::{Hash, Hasher}; |
| 13 | use std::path::{Path, PathBuf}; |
| 14 | use std::sync::{Arc, Mutex as StdMutex}; |
| 15 | use std::time::{Duration, Instant}; |
| 16 | |
| 17 | use anyhow::Result; |
| 18 | use codewhale_config::route::CapabilityState; |
| 19 | use codewhale_execpolicy::{AskForApproval, ExecPolicyContext}; |
| 20 | use codewhale_protocol::runtime::DynamicToolSpec; |
| 21 | use futures_util::StreamExt; |
| 22 | use futures_util::stream::FuturesUnordered; |
| 23 | use serde_json::{Value, json}; |
| 24 | use tokio::sync::{Mutex as AsyncMutex, RwLock, mpsc}; |
| 25 | use tokio_util::sync::CancellationToken; |
| 26 | |
| 27 | use crate::approval_log::ApprovalReceiptStore; |
| 28 | use crate::client::CodewhaleClient; |
| 29 | use crate::compaction::{CompactionConfig, PreparedCompactionEnvelope, compact_messages_safe}; |
| 30 | use crate::config::{ApiProvider, Config, DEFAULT_MAX_SUBAGENTS, DEFAULT_TEXT_MODEL}; |
| 31 | use crate::core::model_client::SharedModelClient; |
| 32 | use crate::error_taxonomy::{ErrorCategory, ErrorEnvelope, ErrorSeverity, StreamError}; |
| 33 | use crate::features::{Feature, Features}; |
| 34 | use crate::mcp::{McpConfig, McpPool, McpSupervisorUpdate}; |
| 35 | use crate::prompts; |
| 36 | use crate::purge::{emit_purge_completed, emit_purge_failed, emit_purge_started, run_purge}; |
| 37 | #[cfg(test)] |
| 38 | use crate::route_runtime::resolve_runtime_route; |
| 39 | use crate::route_runtime::{ |
| 40 | ResolvedRuntimeRoute, ValidatedRuntimeRoute, resolve_runtime_route_for_identity, |
| 41 | }; |
| 42 | use crate::tools::goal::{ |
| 43 | GoalPauseReason, GoalSnapshot, GoalStatus, SharedGoalState, new_shared_goal_state, |
| 44 | }; |
| 45 | use crate::tools::plan::{SharedPlanState, new_shared_plan_state}; |
| 46 | use crate::tools::shell::{SharedShellManager, new_shared_shell_manager}; |
| 47 | use crate::tools::spec::{ |
| 48 | ApprovalRequirement, ResourceClaim, RichToolResult, ToolError, ToolExecutionOutcome, ToolResult, |
| 49 | }; |
| 50 | use crate::tools::spec::{ |
| 51 | RuntimeToolServices, SharedFileReadTracker, new_shared_file_read_tracker, |
| 52 | }; |
| 53 | use crate::tools::subagent::{ |
| 54 | FleetRole, ForegroundChildRegistry, Mailbox, MailboxMessage, SharedSubAgentManager, |
| 55 | SubAgentCompletion, SubAgentForkContext, SubAgentManager, SubAgentResult, SubAgentRuntime, |
| 56 | SubAgentStatus, agent_worker_owner_snapshot, |
| 57 | new_shared_subagent_manager_with_state_root_and_timeout, |
| 58 | }; |
| 59 | use crate::tools::todo::{SharedTodoList, new_shared_todo_list}; |
| 60 | use crate::tools::user_input::{UserInputRequest, UserInputResponse}; |
| 61 | use crate::tools::{ToolContext, ToolRegistryBuilder}; |
| 62 | use crate::utils::spawn_supervised; |
| 63 | use crate::worker_profile::WorkerRuntimeProfile; |
| 64 | use crate::working_set::WorkingSet; |
| 65 | use codewhale_config::AppMode; |
| 66 | use codewhale_execpolicy::ApprovalMode; |
| 67 | #[cfg(test)] |
| 68 | use codewhale_models::ToolCaller; |
| 69 | use codewhale_models::{ |
| 70 | ContentBlock, ContentBlockStart, Delta, Message, StreamEvent, SystemPrompt, Tool, Usage, |
| 71 | is_incomplete_stop_reason, is_output_limit_stop_reason, stop_reason_detail, |
| 72 | }; |
| 73 | |
| 74 | #[cfg(test)] |
| 75 | use super::authority::agent_approval_mode_for_turn; |
| 76 | use super::authority::{ |
| 77 | PolicyNarrowingEvent, TurnAuthority, effective_input_policy, shell_policy_for_mode, |
| 78 | }; |
| 79 | use super::events::{Event, TurnOutcomeStatus, TurnRoute}; |
| 80 | use super::ops::{ |
| 81 | McpManagerUpdate, Op, ProviderRuntimeStatus, SessionContextBudget, SessionSnapshot, TurnSpec, |
| 82 | USER_SHELL_TOOL_ID_PREFIX, UserInputProvenance, |
| 83 | }; |
| 84 | use super::session::Session; |
| 85 | use super::tool_parser; |
| 86 | use super::turn::{TurnContext, post_turn_snapshot, pre_turn_snapshot}; |
| 87 | use codewhale_models::Role; |
| 88 | |
| 89 | const ENGINE_OP_CHANNEL_CAPACITY: usize = 32; |
| 90 | /// Bound on the sub-agent completion inbox (#6147). One completion per |
| 91 | /// terminal child plus small re-queue batches; a full inbox drops the wake, |
| 92 | /// and the terminal-results synthesis path still delivers the content it |
| 93 | /// names at the next explicit turn — the same deferral a host-managed |
| 94 | /// engine already has. |
| 95 | const SUBAGENT_COMPLETION_CHANNEL_CAPACITY: usize = 256; |
| 96 | /// Bound on the MCP session-boot progress channel (#6147). One progress |
| 97 | /// update per pending server plus the terminal update; progress drops are |
| 98 | /// tolerated by design (`let _ =`), and the terminal `Finished` waits for a |
| 99 | /// slot instead of being dropped. |
| 100 | const MCP_BOOT_CHANNEL_CAPACITY: usize = 64; |
| 101 | const GOAL_CONTINUATION_FAILURE_DETAIL_MAX_BYTES: usize = 512; |
| 102 | const PLAN_SHELL_NETWORK_DENIED_HINT: &str = "Shell command blocked: Plan mode runs shell commands in a read-only sandbox — no writes, no network. Use Act mode (`/mode act`) for any command that creates or modifies files, or that needs network access."; |
| 103 | |
| 104 | fn context_pressure_message(usage_percent: f64) -> Option<&'static str> { |
| 105 | if usage_percent >= crate::tui::context_inspector::CONTEXT_CRITICAL_THRESHOLD_PERCENT { |
| 106 | Some( |
| 107 | "Context pressure: critical — CRITICAL: stop expanding scope; run /compact immediately or finish the current task", |
| 108 | ) |
| 109 | } else if usage_percent >= crate::tui::context_inspector::CONTEXT_WARNING_THRESHOLD_PERCENT { |
| 110 | Some( |
| 111 | "Context pressure: warning — ESCALATED: prefer /compact, narrow scope, or finish the current task", |
| 112 | ) |
| 113 | } else { |
| 114 | None |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | fn agent_list_event(manager: &SubAgentManager, active_session_id: &str) -> Event { |
| 119 | // One clock read shared by every row, so elapsed values in a single |
| 120 | // listing are consistent with each other (#5479). |
| 121 | let now_ms = crate::tools::subagent::epoch_millis_now(); |
| 122 | Event::AgentList { |
| 123 | owner_session_id: active_session_id.to_string(), |
| 124 | agents: manager.list_for_session(active_session_id), |
| 125 | coordination: manager.coordination_detail_projection_for_session( |
| 126 | active_session_id, |
| 127 | None, |
| 128 | 24, |
| 129 | ), |
| 130 | queued_follow_ups: manager.queued_follow_up_counts_for_session(active_session_id), |
| 131 | roster: crate::agent_roster::build_agent_roster( |
| 132 | &manager.list_worker_records_for_session(active_session_id), |
| 133 | now_ms, |
| 134 | ), |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | const MCP_REGISTRY_FIRST_INSTRUCTION_SOURCE: &str = "runtime:mcp-registry-first"; |
| 139 | const MCP_REGISTRY_FIRST_INSTRUCTION: &str = "## MCP Registry\n\nThe Registry installs and connects a local MCP server when this session lacks a capability. It is a fallback for a capability you do not have, not a step before ordinary work.\n\nPrefer what is already available, in order: tools already in this catalog, the project's own scripts, tests, and dev tooling, and platform capabilities. Creating a file, reading a fixture, running a repo command, and checking your own output are ordinary work — do them directly.\n\nReach for the Registry once you have identified a specific capability that no available tool covers and that you would otherwise install or reimplement, such as a document or media converter, access to an external database or service, or a protocol client. Then call `registry_sync` with a `query` naming that capability; it scores the local Registry snapshot host-side and returns at most eight matches, so the full index never enters the conversation. When a returned server plausibly covers that capability, call `start_registry_mcp_server` with its exact name rather than installing or running its package command through the shell. If nothing matches, refine the query once, then continue with local tools.\n\nBoth Registry tools are deferred: load one with `tool_search` before its first call, and use the returned schema. If a call instead reports that it only loaded the schema, retry once with that schema. Do not go searching for them for work you can already do."; |
| 140 | const ISOLATED_CHAT_ENGINE_PROMPT: &str = "You are Codewhale Chat. Answer the user's request directly and conversationally. This isolated chat-only session has no local workspace, project, memory, skill, account, credential, path, runtime context, or tools."; |
| 141 | |
| 142 | pub(crate) fn sanitize_isolated_chat_attachments(mut text: String) -> String { |
| 143 | let references = codewhale_core::media_attachment_references(&text); |
| 144 | for reference in references.into_iter().rev() { |
| 145 | let replacement = if text[reference.start_byte..reference.end_byte].ends_with('\n') { |
| 146 | "[Attachment omitted: Runtime Chat cannot read local file references.]\n" |
| 147 | } else { |
| 148 | "[Attachment omitted: Runtime Chat cannot read local file references.]" |
| 149 | }; |
| 150 | text.replace_range(reference.start_byte..reference.end_byte, replacement); |
| 151 | } |
| 152 | text |
| 153 | } |
| 154 | |
| 155 | /// Snapshot of parent state that can be passed to forked sub-agents without |
| 156 | /// rewriting the parent transcript. |
| 157 | /// |
| 158 | /// Deliberately **Work-free**: this is captured once at turn start, and Work |
| 159 | /// state changes during the turn. The To-do section of the fork-state block is |
| 160 | /// resolved at the actual fork seam instead (see |
| 161 | /// `SubAgentForkContext::with_resolved_state_block`), so a `work_update` |
| 162 | /// followed by an `agent` spawn in the same turn hands the child the current |
| 163 | /// list rather than the one that existed before the turn's first tool call. |
| 164 | #[derive(Debug, Clone, Default)] |
| 165 | struct StructuredState { |
| 166 | mode_label: String, |
| 167 | workspace: PathBuf, |
| 168 | cwd: Option<PathBuf>, |
| 169 | working_set_summary: Option<String>, |
| 170 | subagent_snapshots: Vec<SubAgentResult>, |
| 171 | } |
| 172 | |
| 173 | impl StructuredState { |
| 174 | async fn capture( |
| 175 | mode_label: impl Into<String>, |
| 176 | workspace: PathBuf, |
| 177 | cwd: Option<PathBuf>, |
| 178 | working_set: &WorkingSet, |
| 179 | subagents: Option<&SharedSubAgentManager>, |
| 180 | active_session_id: &str, |
| 181 | ) -> Self { |
| 182 | let working_set_summary = working_set.summary_block(&workspace); |
| 183 | |
| 184 | let subagent_snapshots = if let Some(handle) = subagents { |
| 185 | let mut guard = handle.write().await; |
| 186 | guard.cleanup_for_session(active_session_id, Duration::from_secs(60 * 60)); |
| 187 | guard |
| 188 | .list_for_session(active_session_id) |
| 189 | .into_iter() |
| 190 | .filter(|s| matches!(s.status, SubAgentStatus::Running)) |
| 191 | .collect() |
| 192 | } else { |
| 193 | Vec::new() |
| 194 | }; |
| 195 | |
| 196 | Self { |
| 197 | mode_label: mode_label.into(), |
| 198 | workspace, |
| 199 | cwd, |
| 200 | working_set_summary, |
| 201 | subagent_snapshots, |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | #[must_use] |
| 206 | fn to_system_block(&self) -> Option<String> { |
| 207 | let mut out = String::new(); |
| 208 | out.push_str("## Fork State\n\n"); |
| 209 | out.push_str(&format!("- Mode: `{}`\n", self.mode_label)); |
| 210 | out.push_str(&format!("- Workspace: `{}`\n", self.workspace.display())); |
| 211 | if let Some(cwd) = self.cwd.as_ref() { |
| 212 | out.push_str(&format!("- Cwd: `{}`\n", cwd.display())); |
| 213 | } |
| 214 | |
| 215 | // No Work section here on purpose: it is appended at the fork seam from |
| 216 | // the authoritative projection (#3983), because this block is captured |
| 217 | // at turn start and Work moves during the turn. |
| 218 | if !self.subagent_snapshots.is_empty() { |
| 219 | out.push_str("\n### Open Sub-Agents\n"); |
| 220 | for s in &self.subagent_snapshots { |
| 221 | let role = s.assignment.role.as_deref().unwrap_or("-"); |
| 222 | let goal = if s.assignment.objective.is_empty() { |
| 223 | "(no objective set)" |
| 224 | } else { |
| 225 | s.assignment.objective.as_str() |
| 226 | }; |
| 227 | out.push_str(&format!("- `{}` (role: {}) - {}\n", s.agent_id, role, goal)); |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | if let Some(working_set) = self.working_set_summary.as_deref() { |
| 232 | out.push('\n'); |
| 233 | out.push_str(working_set); |
| 234 | out.push('\n'); |
| 235 | } |
| 236 | |
| 237 | Some(out) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | fn user_shell_turn_outcome( |
| 242 | result: &Result<ToolResult, ToolError>, |
| 243 | cancel_requested: bool, |
| 244 | ) -> TurnOutcomeStatus { |
| 245 | let tool_reported_cancel = result.as_ref().is_ok_and(|tool_result| { |
| 246 | tool_result |
| 247 | .metadata |
| 248 | .as_ref() |
| 249 | .and_then(|metadata| metadata.get("canceled")) |
| 250 | .and_then(Value::as_bool) |
| 251 | .unwrap_or(false) |
| 252 | }); |
| 253 | |
| 254 | if cancel_requested || tool_reported_cancel { |
| 255 | TurnOutcomeStatus::Interrupted |
| 256 | } else if result.as_ref().is_ok_and(|tool_result| tool_result.success) { |
| 257 | TurnOutcomeStatus::Completed |
| 258 | } else { |
| 259 | TurnOutcomeStatus::Failed |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // === Types === |
| 264 | |
| 265 | /// Configuration for the engine |
| 266 | #[derive(Debug, Clone)] |
| 267 | pub struct EngineConfig { |
| 268 | /// Model identifier to use for responses. |
| 269 | pub model: String, |
| 270 | /// Route/offering limits for the active provider+model, when the runtime |
| 271 | /// route resolver had concrete catalog facts. |
| 272 | pub active_route_limits: Option<codewhale_config::route::RouteLimits>, |
| 273 | /// Workspace root for tool execution and file operations. |
| 274 | pub workspace: PathBuf, |
| 275 | /// Host-owned conversation id the engine adopts at construction. |
| 276 | /// |
| 277 | /// Interactive hosts claim a session id before the engine exists: the |
| 278 | /// per-session Runtime store lock and the first crash checkpoint are both |
| 279 | /// keyed by it. The engine must run the conversation the host persists, |
| 280 | /// so it adopts this id instead of minting a second one that the host |
| 281 | /// only learns about from the first `SessionUpdated` event (which left |
| 282 | /// the turn-start checkpoint orphaned under the host id). `None` |
| 283 | /// (headless/embed callers) keeps the generated id. |
| 284 | pub session_id: Option<String>, |
| 285 | /// Optional host-owned root for delegated-agent runtime state. |
| 286 | /// |
| 287 | /// When unset, the worker ledger, complete transcript artifacts and |
| 288 | /// coordination lock retain their historical location under |
| 289 | /// `workspace/.codewhale/state`. Embedders may set a session-scoped root |
| 290 | /// to separate that control-plane state from the execution workspace. |
| 291 | /// Child cwd and file authority still derive from `workspace`; hosts using |
| 292 | /// distinct state roots for the same workspace must coordinate conflicting |
| 293 | /// writes themselves or isolate writers with worktrees. |
| 294 | pub subagent_state_root: Option<PathBuf>, |
| 295 | /// Allow shell tool execution when true. |
| 296 | pub allow_shell: bool, |
| 297 | /// Enable trust mode (skip approvals) when true. |
| 298 | pub trust_mode: bool, |
| 299 | /// Path to the notes file used by the notes tool. |
| 300 | pub notes_path: PathBuf, |
| 301 | /// Path to the MCP configuration file. |
| 302 | pub mcp_config_path: PathBuf, |
| 303 | /// OAuth callback overrides (`mcp_oauth_callback_port` / `_url`) so the |
| 304 | /// self-serve MCP login tool honors a pre-registered redirect URI the |
| 305 | /// same way `/mcp login` does. |
| 306 | pub mcp_oauth_callback_port: Option<u16>, |
| 307 | pub mcp_oauth_callback_url: Option<String>, |
| 308 | /// Directory containing discoverable skills. |
| 309 | pub skills_dir: PathBuf, |
| 310 | /// Restrict skill discovery to CodeWhale-owned roots plus explicit |
| 311 | /// `skills_dir` configuration. |
| 312 | pub skills_scan_codewhale_only: bool, |
| 313 | /// Immutable plugin authority snapshot scoped to `workspace`. Normal App |
| 314 | /// hosts provide this explicitly; headless/embed callers that leave it |
| 315 | /// unset receive a fresh workspace-specific snapshot in [`Engine::new`]. |
| 316 | pub plugin_registry: Option<Arc<crate::plugins::PluginRegistry>>, |
| 317 | /// Sources injected as `<instructions source="…">` blocks in the system |
| 318 | /// prompt (#454). Each entry is either a disk path (read at render time) |
| 319 | /// or an inline string. Loaded in declared order from the user's |
| 320 | /// `instructions = [...]` config or constructed by embedders. |
| 321 | /// |
| 322 | /// Generalized from `Vec<PathBuf>` so embedders can inject inline content |
| 323 | /// without staging a disk file. `From<PathBuf>` impl keeps existing callers |
| 324 | /// working with `.into()` at the call site. |
| 325 | pub instructions: Vec<crate::prompts::InstructionSource>, |
| 326 | pub project_context_pack_enabled: bool, |
| 327 | /// When true, the model is instructed to respond in the current locale |
| 328 | /// and a post-hoc translation layer replaces remaining English output. |
| 329 | pub translation_enabled: bool, |
| 330 | pub verbosity: Option<String>, |
| 331 | /// Maximum number of assistant steps before stopping. Ordinary interactive |
| 332 | /// hosts use [`DEFAULT_MODEL_STEPS`]; explicit test/embed callers may |
| 333 | /// still install a finite boundary. |
| 334 | pub max_steps: u32, |
| 335 | /// Maximum number of concurrently active subagents. |
| 336 | pub max_subagents: usize, |
| 337 | /// Maximum queued + running sub-agents admitted for this engine session. |
| 338 | pub max_admitted_subagents: usize, |
| 339 | /// Number of direct (depth-1) sub-agents that may execute concurrently |
| 340 | /// before further launches queue for a launch slot (#3095). |
| 341 | /// Resolved from `[subagents] launch_concurrency`. |
| 342 | pub launch_concurrency: usize, |
| 343 | /// Whether the model-facing `agent` tool is available after applying |
| 344 | /// feature flags and `[subagents]` opt-out controls. |
| 345 | pub subagents_enabled: bool, |
| 346 | /// Feature flags controlling tool availability. |
| 347 | pub features: Features, |
| 348 | /// Deterministic auto-review policy for tool calls. |
| 349 | pub auto_review_policy: crate::tui::auto_review::AutoReviewPolicy, |
| 350 | /// Auto-compaction settings for long conversations. |
| 351 | pub compaction: CompactionConfig, |
| 352 | /// Shared Todo list state. |
| 353 | pub todos: SharedTodoList, |
| 354 | /// Shared Plan state. |
| 355 | pub plan_state: SharedPlanState, |
| 356 | /// Shared runtime goal state for model-visible goal tools. |
| 357 | pub goal_state: SharedGoalState, |
| 358 | /// Maximum sub-agent recursion depth (default 3). See |
| 359 | /// `SubAgentRuntime::max_spawn_depth`. Override via |
| 360 | /// `[subagents] max_depth = N` in `~/.codewhale/config.toml`. |
| 361 | pub max_spawn_depth: u32, |
| 362 | /// Per-domain network policy decider (#135). Shared across the session so |
| 363 | /// session-scoped approvals (`/network allow <host>`) persist for the |
| 364 | /// remainder of the run. |
| 365 | pub network_policy: Option<crate::network_policy::NetworkPolicyDecider>, |
| 366 | /// Whether to take side-git workspace snapshots before/after each turn. |
| 367 | pub snapshots_enabled: bool, |
| 368 | /// Maximum workspace size (in bytes) before snapshots self-disable on |
| 369 | /// first init. `0` disables the cap. Resolved from |
| 370 | /// `[snapshots] max_workspace_gb` × 1 GB at engine construction. |
| 371 | pub snapshots_max_workspace_bytes: u64, |
| 372 | /// Post-edit LSP diagnostics injection (#136). When `None`, the engine |
| 373 | /// constructs a disabled manager so the field is always present. |
| 374 | pub lsp_config: Option<crate::lsp::LspConfig>, |
| 375 | /// Durable runtime services exposed to model-visible tools. |
| 376 | pub runtime_services: RuntimeToolServices, |
| 377 | /// Per-role/type sub-agent model overrides already resolved from config. |
| 378 | pub subagent_model_overrides: HashMap<String, crate::config::SubagentModelOverride>, |
| 379 | /// Merged fleet roster (built-ins + config + personal/workspace agent |
| 380 | /// files) shared by model-spawned sub-agents and fleet dispatch |
| 381 | /// (#fleet-roster cutover (v0.8.67)). Defaults to built-ins only; the |
| 382 | /// engine-config construction sites load it at session start and the setup |
| 383 | /// wizard refreshes it after each successful profile save. |
| 384 | pub fleet_roster: std::sync::Arc<crate::fleet::roster::FleetRoster>, |
| 385 | /// Whether the user-memory feature is enabled (#489). When `true` the |
| 386 | /// engine reads `memory_path` on each prompt assembly and prepends a |
| 387 | /// `<user_memory>` block to the system prompt. |
| 388 | pub memory_enabled: bool, |
| 389 | /// Path to the user memory file (#489). Always populated; only |
| 390 | /// consulted when `memory_enabled` is `true`. |
| 391 | pub memory_path: PathBuf, |
| 392 | /// Default directory for Xiaomi MiMo speech/TTS tool outputs. |
| 393 | pub speech_output_dir: Option<PathBuf>, |
| 394 | pub vision_config: Option<crate::config::VisionModelConfig>, |
| 395 | pub goal_objective: Option<String>, |
| 396 | pub goal_token_budget: Option<u32>, |
| 397 | pub goal_status: GoalStatus, |
| 398 | /// Safety backstop on automatic goal continuation passes (#5052). |
| 399 | /// Resolved from `[goal] max_continuations` in config.toml; `0` disables |
| 400 | /// the backstop so only completion, blocked state, or the continuation |
| 401 | /// limit stops an operate-mode goal run. |
| 402 | pub goal_max_continuations: u32, |
| 403 | /// Delay between successful interactive goal turns. `0` continues |
| 404 | /// immediately; positive values opt coordinator goals into a cancellable |
| 405 | /// quiet period (#5508). |
| 406 | pub goal_continuation_delay_seconds: u64, |
| 407 | /// Whether a goal's `token_budget` is a hard stop (`BudgetLimit`) instead |
| 408 | /// of advisory telemetry. Resolved from `[goal] enforce_token_budget`; |
| 409 | /// default `false` (#6013). |
| 410 | pub goal_enforce_token_budget: bool, |
| 411 | /// Maximum number of automatic re-requests when the model returns only |
| 412 | /// reasoning without any answer or tool call. Defaults to 2. |
| 413 | /// Resolved from `[reasoning_only] max_reprompts` in config.toml. |
| 414 | pub reasoning_only_max_reprompts: u32, |
| 415 | /// Nudge carried by a reasoning-only re-request once a bare retry has |
| 416 | /// already come back answerless. `None` uses the built-in text; an empty |
| 417 | /// string disables the nudge and keeps every retry a bare one. |
| 418 | /// |
| 419 | /// The nudge is attached to one outbound request and never added to the |
| 420 | /// session — see the reasoning-only branch in `turn_loop`. |
| 421 | /// Resolved from `[reasoning_only] reprompt_message` in config.toml. |
| 422 | pub reasoning_only_reprompt_message: Option<String>, |
| 423 | /// Tool restriction from custom slash command frontmatter. |
| 424 | /// `None` means the current turn may use the normal tool set. |
| 425 | pub allowed_tools: Option<Vec<String>>, |
| 426 | /// Tool deny-list. Deny always wins over allow (#3027). |
| 427 | /// `None` means no tools are explicitly denied. |
| 428 | pub disallowed_tools: Option<Vec<String>>, |
| 429 | /// Hard per-turn cap on admitted tool calls (#4415). `None` (the default) |
| 430 | /// means unlimited and leaves the turn admission gate inert. Task hosts |
| 431 | /// set this from the task's structured `max_tool_calls` constraint; the |
| 432 | /// per-turn counter itself lives in the turn loop, not here. |
| 433 | pub max_tool_calls: Option<u32>, |
| 434 | /// Hook executor for control-plane hooks. |
| 435 | /// `ToolCallBefore` hooks may deny a tool call with exit code 2. |
| 436 | pub hook_executor: Option<std::sync::Arc<crate::hooks::HookExecutor>>, |
| 437 | /// Resolved BCP-47 locale tag (e.g. `"en"`, `"zh-Hans"`, `"ja"`) |
| 438 | /// for the `## Environment` block in the system prompt. The |
| 439 | /// caller resolves this from `Settings` once at engine |
| 440 | /// construction; the engine never touches disk for it. |
| 441 | pub locale_tag: String, |
| 442 | /// When true, force `tool_choice: "required"` and opt compatible function |
| 443 | /// schemas into DeepSeek beta strict mode. |
| 444 | pub strict_tool_mode: bool, |
| 445 | /// Workshop / large-tool-output routing (#548). `None` disables routing. |
| 446 | pub workshop: Option<crate::tools::large_output_router::WorkshopConfig>, |
| 447 | /// Which search backend `web_search` should use. Default: Firecrawl. |
| 448 | pub search_provider: crate::config::SearchProvider, |
| 449 | /// Optional Firecrawl key, or required key for other API search providers. |
| 450 | /// Metaso also falls back to the `METASO_API_KEY` env var. |
| 451 | /// Baidu also falls back to `BAIDU_SEARCH_API_KEY`. |
| 452 | pub search_api_key: Option<String>, |
| 453 | /// Optional DuckDuckGo-compatible HTML endpoint override. |
| 454 | pub search_base_url: Option<String>, |
| 455 | /// Per-step DeepSeek API timeout for sub-agent `create_message` requests. |
| 456 | /// Resolved from `[subagents] api_timeout_secs` (clamped to 1..=3600) |
| 457 | /// once at engine construction, then threaded onto every |
| 458 | /// `SubAgentRuntime` the engine builds (#1806, #1808). |
| 459 | pub subagent_api_timeout: Duration, |
| 460 | /// Per-SSE-chunk idle timeout for streamed model responses. |
| 461 | /// Resolved from `[tui].stream_chunk_timeout_secs` (or the legacy |
| 462 | /// `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS`) and updated live by `/config`. |
| 463 | pub stream_chunk_timeout: Duration, |
| 464 | /// Cumulative wall-clock budget for one turn (R1). Counted across every |
| 465 | /// model step of the turn, excluding time blocked on a human approval |
| 466 | /// decision. Resolved from `[tui].turn_wall_clock_secs`; always finite — |
| 467 | /// see [`turn_budget::resolve_turn_wall_clock`]. |
| 468 | pub turn_wall_clock: Duration, |
| 469 | /// Per-step cap on accumulated streamed content, in bytes (R1). Resolved |
| 470 | /// from `[tui].stream_max_content_mb`. Pre-R1 this was the hard-coded |
| 471 | /// `STREAM_MAX_CONTENT_BYTES`; it is still finite by default and now |
| 472 | /// overridable. |
| 473 | pub stream_max_content_bytes: usize, |
| 474 | /// Per-step cap on a single stream's wall-clock duration (R1). Resolved |
| 475 | /// from `[tui].stream_max_duration_secs`. Pre-R1 this was the hard-coded |
| 476 | /// `STREAM_MAX_DURATION_SECS`. |
| 477 | pub stream_max_duration: Duration, |
| 478 | /// No-progress heartbeat timeout for live sub-agents. Used by the manager |
| 479 | /// and parent wait loop to auto-cancel stuck children before they exhaust |
| 480 | /// the sub-agent slot pool indefinitely (#2614). |
| 481 | pub subagent_heartbeat_timeout: Duration, |
| 482 | /// Native tools that should stay in the model-visible catalog even when |
| 483 | /// they are outside the small default core surface (#2076). |
| 484 | pub tools_always_load: HashSet<String>, |
| 485 | /// Effective `request_user_input` payload ceilings resolved from `[tools]` |
| 486 | /// (#5949). One authority for the validator, the tool schema, and the |
| 487 | /// tool description. |
| 488 | pub user_input_limits: crate::tools::user_input::UserInputLimits, |
| 489 | /// Wait for a user-input answer before cancelling it (#6003). `None` |
| 490 | /// uses the built-in default (300s); `Some(Duration::ZERO)` waits |
| 491 | /// indefinitely. |
| 492 | pub user_input_timeout: Option<Duration>, |
| 493 | /// Per-turn step allowance while a goal is active (#5994). Hosts opt in |
| 494 | /// with their resolved `[goal] max_steps`; `None` keeps the ordinary |
| 495 | /// `max_steps` ceiling for goal turns too — which is what exec/worker |
| 496 | /// paths with explicit per-invocation ceilings must see. |
| 497 | pub goal_max_steps: Option<u32>, |
| 498 | /// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell |
| 499 | /// through bubblewrap (#2184). |
| 500 | pub prefer_bwrap: bool, |
| 501 | /// User-configured bwrap mount extensions (#5410): extra read-only roots |
| 502 | /// and writable device nodes such as `/dev/null`. |
| 503 | pub bwrap_extensions: crate::sandbox::BwrapMountExtensions, |
| 504 | /// Sandbox read deny-list (S1). One source of truth for two enforcement |
| 505 | /// points: the OS wrappers get its subtree paths, and the in-process |
| 506 | /// file-reading tools consult it directly (they run inside the harness |
| 507 | /// process and are never wrapped by `sandbox-exec` or `bwrap`). |
| 508 | /// Defense-in-depth, not a security boundary — see |
| 509 | /// `crate::sandbox::read_guard` for what it does and does not stop. |
| 510 | pub read_denylist: crate::sandbox::read_guard::ReadDenylist, |
| 511 | /// Tool override and plugin configuration (`[tools]` table in config.toml). |
| 512 | /// Applied to the per-turn tool registry after built-in tools are registered. |
| 513 | /// When `None`, no overrides or plugin loading occurs. |
| 514 | pub tools: Option<crate::config::ToolsConfig>, |
| 515 | /// Whether tools should follow symbolic links. When `true`, symlinked |
| 516 | /// directories are traversed by walk-based tools and symlinked paths |
| 517 | /// that resolve outside the workspace are still allowed (the symlink |
| 518 | /// itself must be inside the workspace). Mirrors the |
| 519 | /// `workspace_follow_symlinks` setting. |
| 520 | pub workspace_follow_symlinks: bool, |
| 521 | /// Ask-only permission rules loaded from sibling `permissions.toml`. |
| 522 | pub exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine, |
| 523 | /// Whether turn startup may write terminal title/taskbar OSC sequences. |
| 524 | /// Interactive TUI sessions enable this; headless and machine-readable |
| 525 | /// hosts disable it so stdout remains protocol-clean. |
| 526 | pub terminal_chrome_enabled: bool, |
| 527 | /// Resolved advisor watcher configuration (#3982). Off by default. |
| 528 | /// Updated live by `Op::SetAdvisorEnabled`. |
| 529 | pub advisor_config: crate::tools::subagent::AdvisorConfig, |
| 530 | } |
| 531 | |
| 532 | /// Uncapped model steps for hosts without an explicit configured ceiling. |
| 533 | /// Wall-clock and stream budgets are independent. See |
| 534 | /// [`turn_budget::resolve_max_model_steps`]. |
| 535 | pub(crate) const DEFAULT_MODEL_STEPS: u32 = turn_budget::DEFAULT_MAX_MODEL_STEPS; |
| 536 | |
| 537 | impl Default for EngineConfig { |
| 538 | fn default() -> Self { |
| 539 | Self { |
| 540 | model: DEFAULT_TEXT_MODEL.to_string(), |
| 541 | active_route_limits: None, |
| 542 | workspace: PathBuf::from("."), |
| 543 | session_id: None, |
| 544 | subagent_state_root: None, |
| 545 | allow_shell: true, |
| 546 | trust_mode: false, |
| 547 | notes_path: PathBuf::from("notes.txt"), |
| 548 | mcp_config_path: PathBuf::from("mcp.json"), |
| 549 | mcp_oauth_callback_port: None, |
| 550 | mcp_oauth_callback_url: None, |
| 551 | skills_dir: crate::skills::default_skills_dir(), |
| 552 | skills_scan_codewhale_only: false, |
| 553 | plugin_registry: None, |
| 554 | instructions: Vec::new(), |
| 555 | project_context_pack_enabled: false, |
| 556 | translation_enabled: false, |
| 557 | // Callers opt into a finite model-step boundary explicitly. |
| 558 | max_steps: DEFAULT_MODEL_STEPS, |
| 559 | max_subagents: DEFAULT_MAX_SUBAGENTS, |
| 560 | max_admitted_subagents: DEFAULT_MAX_SUBAGENTS, |
| 561 | launch_concurrency: DEFAULT_MAX_SUBAGENTS, |
| 562 | subagents_enabled: true, |
| 563 | features: Features::with_defaults(), |
| 564 | auto_review_policy: crate::tui::auto_review::AutoReviewPolicy::default(), |
| 565 | compaction: CompactionConfig::default(), |
| 566 | todos: new_shared_todo_list(), |
| 567 | plan_state: new_shared_plan_state(), |
| 568 | goal_state: new_shared_goal_state(), |
| 569 | max_spawn_depth: crate::tools::subagent::DEFAULT_MAX_SPAWN_DEPTH, |
| 570 | network_policy: None, |
| 571 | snapshots_enabled: true, |
| 572 | snapshots_max_workspace_bytes: |
| 573 | crate::snapshot::DEFAULT_MAX_WORKSPACE_BYTES_FOR_SNAPSHOT, |
| 574 | lsp_config: None, |
| 575 | runtime_services: RuntimeToolServices::default(), |
| 576 | subagent_model_overrides: HashMap::new(), |
| 577 | fleet_roster: std::sync::Arc::new(crate::fleet::roster::FleetRoster::built_ins_only()), |
| 578 | memory_enabled: false, |
| 579 | memory_path: PathBuf::from("./memory.md"), |
| 580 | speech_output_dir: None, |
| 581 | vision_config: None, |
| 582 | strict_tool_mode: false, |
| 583 | goal_objective: None, |
| 584 | goal_token_budget: None, |
| 585 | goal_status: GoalStatus::Active, |
| 586 | goal_max_continuations: crate::goal_loop::DEFAULT_MAX_GOAL_CONTINUATIONS, |
| 587 | goal_continuation_delay_seconds: 0, |
| 588 | goal_enforce_token_budget: false, |
| 589 | reasoning_only_max_reprompts: crate::config::DEFAULT_REASONING_ONLY_REPROMPTS, |
| 590 | // `None` means "use the built-in nudge". Storing the default text |
| 591 | // here instead would make an operator's explicit empty string |
| 592 | // indistinguishable from having set nothing. |
| 593 | reasoning_only_reprompt_message: None, |
| 594 | allowed_tools: None, |
| 595 | disallowed_tools: None, |
| 596 | max_tool_calls: None, |
| 597 | hook_executor: None, |
| 598 | locale_tag: "en".to_string(), |
| 599 | workshop: None, |
| 600 | search_provider: crate::config::SearchProvider::default(), |
| 601 | search_api_key: None, |
| 602 | search_base_url: None, |
| 603 | subagent_api_timeout: Duration::from_secs( |
| 604 | crate::config::DEFAULT_SUBAGENT_API_TIMEOUT_SECS, |
| 605 | ), |
| 606 | stream_chunk_timeout: Duration::from_secs( |
| 607 | crate::config::DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, |
| 608 | ), |
| 609 | turn_wall_clock: turn_budget::resolve_turn_wall_clock(None), |
| 610 | stream_max_content_bytes: turn_budget::DEFAULT_STREAM_MAX_CONTENT_BYTES, |
| 611 | stream_max_duration: Duration::from_secs(turn_budget::DEFAULT_STREAM_MAX_DURATION_SECS), |
| 612 | subagent_heartbeat_timeout: Duration::from_secs( |
| 613 | crate::config::DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, |
| 614 | ), |
| 615 | tools_always_load: HashSet::new(), |
| 616 | user_input_limits: crate::tools::user_input::UserInputLimits::default(), |
| 617 | user_input_timeout: None, |
| 618 | goal_max_steps: None, |
| 619 | prefer_bwrap: false, |
| 620 | bwrap_extensions: crate::sandbox::BwrapMountExtensions::default(), |
| 621 | // Fail-closed (F7): `Engine::new` unconditionally installs this |
| 622 | // list process-wide via `read_guard::set_active`, so a default |
| 623 | // here must be the built-in credential-store defaults — an empty |
| 624 | // list would override the safe fallback and fail open. Mirrors |
| 625 | // `Config::default`, where `sandbox_read_denylist_defaults` |
| 626 | // defaults to true. |
| 627 | read_denylist: crate::sandbox::read_guard::ReadDenylist::build(true, &[], &[]), |
| 628 | verbosity: None, |
| 629 | tools: None, |
| 630 | workspace_follow_symlinks: false, |
| 631 | exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::new(Vec::new(), Vec::new()), |
| 632 | terminal_chrome_enabled: true, |
| 633 | advisor_config: crate::tools::subagent::AdvisorConfig::disabled(), |
| 634 | } |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | /// Reason the active turn was cancelled. The token from `tokio_util` |
| 639 | /// does not carry a cause, so the engine keeps a sibling latch for |
| 640 | /// approval and user-input waits that need to explain cancellation. |
| 641 | /// |
| 642 | /// `External`, `Preempted`, and `Internal` are reserved for the |
| 643 | /// remaining direct cancellation paths tracked in #1541. |
| 644 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 645 | pub enum CancelReason { |
| 646 | /// User-initiated cancel (Esc, `/cancel`, click cancel on modal). |
| 647 | User, |
| 648 | /// External / runtime-API cancel (HTTP `DELETE /v1/threads/...`, |
| 649 | /// task manager stop, parent agent cancel). |
| 650 | External, |
| 651 | /// Cancel triggered when a new turn starts before the previous one |
| 652 | /// finished — e.g. plain Enter while busy after the queueing path |
| 653 | /// pre-empts the running turn. |
| 654 | #[expect(dead_code)] |
| 655 | Preempted, |
| 656 | /// Engine internals tore down the turn (drop, channel close, |
| 657 | /// shutdown). Rare — surfaced as an internal error. |
| 658 | Internal, |
| 659 | } |
| 660 | |
| 661 | impl CancelReason { |
| 662 | fn describe(self) -> &'static str { |
| 663 | match self { |
| 664 | Self::User => "user cancelled the request", |
| 665 | Self::External => "request cancelled by external caller", |
| 666 | Self::Preempted => "request was preempted by a new turn", |
| 667 | Self::Internal => "engine torn down before approval resolved", |
| 668 | } |
| 669 | } |
| 670 | } |
| 671 | |
| 672 | /// Handle to communicate with the engine |
| 673 | #[derive(Clone)] |
| 674 | pub struct EngineHandle { |
| 675 | goal_state: SharedGoalState, |
| 676 | /// Send operations to the engine |
| 677 | pub tx_op: mpsc::Sender<Op>, |
| 678 | /// Receive events from the engine |
| 679 | pub rx_event: Arc<RwLock<mpsc::Receiver<Event>>>, |
| 680 | /// Shared pointer to the cancellation token for the current request. |
| 681 | cancel_token: Arc<StdMutex<CancellationToken>>, |
| 682 | /// Latched reason for the most recent cancellation. Read by the |
| 683 | /// approval / user-input handlers to enrich their error strings. |
| 684 | /// Cleared by the engine when a fresh turn starts. |
| 685 | cancel_reason: Arc<StdMutex<Option<CancelReason>>>, |
| 686 | /// Send approval decisions to the engine |
| 687 | tx_approval: mpsc::Sender<ApprovalDecision>, |
| 688 | /// Send user input responses to the engine |
| 689 | tx_user_input: mpsc::Sender<UserInputDecision>, |
| 690 | /// Send steer input for an in-flight turn. |
| 691 | tx_steer: mpsc::Sender<handle::SteerInput>, |
| 692 | turn_controls: Arc<StdMutex<handle::TurnControls>>, |
| 693 | /// Shared pause flag set by the TUI and read by the turn loop. |
| 694 | shared_paused: Arc<StdMutex<bool>>, |
| 695 | /// Whether the host must construct the route's concrete provider client |
| 696 | /// before it mutates turn state. Real engines own concrete provider I/O; |
| 697 | /// explicit injected/mock engines own that seam themselves. |
| 698 | client_preflight_required: bool, |
| 699 | /// Typed live permission authority shared with the running turn. A mode |
| 700 | /// change publishes here before its mailbox op is queued, so gates never |
| 701 | /// consult a stale per-turn copy. |
| 702 | live_runtime_authority: Arc<StdMutex<LiveRuntimeAuthorityState>>, |
| 703 | /// Out-of-band authority for one exact compaction request. The engine can |
| 704 | /// be awaiting a provider while its bounded op mailbox is unable to drain, |
| 705 | /// so cancellation cannot depend on processing a later mailbox entry. |
| 706 | compaction_cancellation: Arc<StdMutex<CompactionCancellationState>>, |
| 707 | } |
| 708 | |
| 709 | const MAX_PENDING_COMPACTION_CANCELLATIONS: usize = 64; |
| 710 | |
| 711 | #[derive(Debug, Default)] |
| 712 | struct CompactionCancellationState { |
| 713 | active: Option<(String, CancellationToken)>, |
| 714 | pending: VecDeque<String>, |
| 715 | } |
| 716 | |
| 717 | impl CompactionCancellationState { |
| 718 | fn request(&mut self, id: &str) { |
| 719 | if let Some((active_id, token)) = self.active.as_ref() |
| 720 | && active_id == id |
| 721 | { |
| 722 | token.cancel(); |
| 723 | return; |
| 724 | } |
| 725 | if self.pending.iter().any(|pending| pending == id) { |
| 726 | return; |
| 727 | } |
| 728 | if self.pending.len() >= MAX_PENDING_COMPACTION_CANCELLATIONS { |
| 729 | self.pending.pop_front(); |
| 730 | } |
| 731 | self.pending.push_back(id.to_string()); |
| 732 | } |
| 733 | |
| 734 | fn claim(&mut self, id: &str) -> Option<CancellationToken> { |
| 735 | if let Some(index) = self.pending.iter().position(|pending| pending == id) { |
| 736 | self.pending.remove(index); |
| 737 | return None; |
| 738 | } |
| 739 | let token = CancellationToken::new(); |
| 740 | self.active = Some((id.to_string(), token.clone())); |
| 741 | Some(token) |
| 742 | } |
| 743 | |
| 744 | fn finish(&mut self, id: &str) { |
| 745 | if self |
| 746 | .active |
| 747 | .as_ref() |
| 748 | .is_some_and(|(active_id, _)| active_id == id) |
| 749 | { |
| 750 | self.active = None; |
| 751 | } |
| 752 | if let Some(index) = self.pending.iter().position(|pending| pending == id) { |
| 753 | self.pending.remove(index); |
| 754 | } |
| 755 | } |
| 756 | } |
| 757 | |
| 758 | impl EngineHandle { |
| 759 | /// Publish typed compaction cancellation immediately, then enqueue the |
| 760 | /// matching operation when capacity permits. The shared authority is what |
| 761 | /// stops a running provider future; the operation keeps the mailbox |
| 762 | /// protocol explicit and clears a late, already-settled request safely. |
| 763 | pub fn cancel_compaction(&self, id: impl Into<String>) -> Result<()> { |
| 764 | let id = id.into(); |
| 765 | self.compaction_cancellation |
| 766 | .lock() |
| 767 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 768 | .request(&id); |
| 769 | match self.tx_op.try_send(Op::CancelCompaction { id }) { |
| 770 | Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => Ok(()), |
| 771 | Err(mpsc::error::TrySendError::Closed(_)) => { |
| 772 | Err(anyhow::anyhow!("engine operation channel closed")) |
| 773 | } |
| 774 | } |
| 775 | } |
| 776 | } |
| 777 | |
| 778 | // `impl EngineHandle { ... }` moved to `engine/handle.rs` so the |
| 779 | // mailbox API can be reviewed independently of the engine internals. |
| 780 | |
| 781 | // === Engine === |
| 782 | |
| 783 | /// Background MCP boot progress from the spawn-time connect task. |
| 784 | enum McpBootUpdate { |
| 785 | Progress { |
| 786 | generation: u64, |
| 787 | authority_errors: Arc<HashMap<String, String>>, |
| 788 | connection_errors: HashMap<String, String>, |
| 789 | connecting: Vec<String>, |
| 790 | }, |
| 791 | Finished { |
| 792 | generation: u64, |
| 793 | authority_errors: Arc<HashMap<String, String>>, |
| 794 | connection_errors: HashMap<String, String>, |
| 795 | }, |
| 796 | } |
| 797 | |
| 798 | type ExplicitConnectJoin = |
| 799 | Result<(String, Result<crate::mcp::McpConnection, anyhow::Error>), tokio::task::JoinError>; |
| 800 | |
| 801 | /// In-flight connects a turn started because its explicit tool selection |
| 802 | /// named them (#6033). `names` tracks the still-unresolved servers so an |
| 803 | /// abort can clear their `connecting` marks in the pool; the catalog |
| 804 | /// generation pins the authority the batch was spawned under. |
| 805 | struct ExplicitMcpConnects { |
| 806 | connects: tokio::task::JoinSet<(String, Result<crate::mcp::McpConnection, anyhow::Error>)>, |
| 807 | names: HashSet<String>, |
| 808 | catalog_generation: u64, |
| 809 | } |
| 810 | |
| 811 | /// The core engine that processes operations and emits events |
| 812 | pub struct Engine { |
| 813 | config: EngineConfig, |
| 814 | api_config: Config, |
| 815 | /// Runtime-host authority consulted only when constructing a later turn |
| 816 | /// descriptor (goal continuation, idle child completion, `/edit`). Active |
| 817 | /// turns keep their already-installed immutable descriptor. |
| 818 | authoritative_route_config: Option<Arc<parking_lot::RwLock<Config>>>, |
| 819 | codewhale_client: Option<CodewhaleClient>, |
| 820 | /// Provider-neutral client used by the canonical main turn loop. Concrete |
| 821 | /// clients remain temporarily available to provider-specific helper tools |
| 822 | /// while those boundaries migrate independently. |
| 823 | model_client: Option<SharedModelClient>, |
| 824 | /// Test/embedding seam: an explicitly injected provider-neutral client |
| 825 | /// remains the I/O authority while typed routes still validate receipts, |
| 826 | /// endpoint metadata, and budgets. |
| 827 | model_client_injected: bool, |
| 828 | codewhale_client_error: Option<String>, |
| 829 | api_key_env_only_recovery: Option<String>, |
| 830 | session: Session, |
| 831 | /// One lazy, session-scoped working kernel for inline `repl` blocks. |
| 832 | /// Its context is refreshed before each run, while user-created Python |
| 833 | /// state stays alive across model turns. |
| 834 | repl_kernel: Option<crate::repl::PythonRuntime>, |
| 835 | subagent_manager: SharedSubAgentManager, |
| 836 | /// The deterministic Auto-Review policy shared with every child runtime |
| 837 | /// so children are gated by the same rules as the parent turn. |
| 838 | shared_auto_review_policy: Arc<crate::tui::auto_review::AutoReviewPolicy>, |
| 839 | shell_manager: SharedShellManager, |
| 840 | /// Read-before-edit snapshots live for the session, not for one turn's |
| 841 | /// transient `ToolContext` (#4475). |
| 842 | file_read_tracker: SharedFileReadTracker, |
| 843 | mcp_pool: Option<Arc<AsyncMutex<McpPool>>>, |
| 844 | /// The tool-surface budget the current turn's catalog was shaped with, |
| 845 | /// so a mid-turn MCP refresh reshapes its slice the same way (#5939). |
| 846 | turn_tool_surface_budget: Option<crate::model_profile::ToolSurfaceBudget>, |
| 847 | /// Last connection diagnosis for each configured MCP server. |
| 848 | /// |
| 849 | /// Failed transports are intentionally absent from `McpPool::connections`, |
| 850 | /// so a later one-server retry cannot reconstruct sibling failures from |
| 851 | /// the pool alone. Keeping the diagnoses beside the engine-owned pool |
| 852 | /// lets every full manager snapshot remain truthful without reconnecting |
| 853 | /// unrelated servers. |
| 854 | mcp_connection_errors: HashMap<String, String>, |
| 855 | /// True while the spawn-time concurrent connect pass is still running. |
| 856 | /// `mcp_tools` snapshots ready servers instead of waiting on optionals. |
| 857 | mcp_boot_in_flight: bool, |
| 858 | mcp_boot_rx: Option<mpsc::Receiver<McpBootUpdate>>, |
| 859 | /// Supervisor sweep updates. `Some` while the supervisor task is armed; |
| 860 | /// the channel closing (task exited with the pool) disarms it and the |
| 861 | /// next pool ensure respawns. |
| 862 | mcp_supervisor_rx: Option<mpsc::Receiver<McpSupervisorUpdate>>, |
| 863 | mcp_boot_done: Option<tokio::sync::watch::Receiver<bool>>, |
| 864 | /// Generation owned by the currently installed boot receiver. Terminal |
| 865 | /// cleanup is conditional on this exact value so an older pass can never |
| 866 | /// clear a newer receiver. |
| 867 | mcp_boot_generation: Option<u64>, |
| 868 | /// Monotonic generation for engine-authored MCP session snapshots. Boot |
| 869 | /// task updates retain their spawn generation so later passes can reject |
| 870 | /// only genuinely stale work. |
| 871 | mcp_event_generation: u64, |
| 872 | /// Workspace-scoped immutable plugin catalogue and authority receipts. |
| 873 | plugin_registry: Arc<crate::plugins::PluginRegistry>, |
| 874 | /// Keeps the append-only `<recommended_plugins>` fragment once-per- |
| 875 | /// Engine-lifetime per plugin id, and suppresses plugins whose name a |
| 876 | /// catalogue skill already covers (#6274). The skill-name snapshot is |
| 877 | /// taken at construction from the same catalogue the system prompt |
| 878 | /// indexes (see the gate's known-limitations note). |
| 879 | recommended_plugin_gate: StdMutex<crate::plugins::recommend::RecommendedPluginGate>, |
| 880 | api_provider: ApiProvider, |
| 881 | /// Exact configured route key. Named custom providers share the `Custom` |
| 882 | /// enum, so the enum alone cannot prove that the active client is current. |
| 883 | api_provider_identity: String, |
| 884 | /// Additive exact provider id. `None` preserves the legacy root-literal |
| 885 | /// custom route across snapshots and config reloads. |
| 886 | api_provider_id: Option<String>, |
| 887 | active_route_limits: Option<codewhale_config::route::RouteLimits>, |
| 888 | active_route_capabilities: codewhale_config::route::RouteCapabilities, |
| 889 | rx_op: mpsc::Receiver<Op>, |
| 890 | live_runtime_authority: Arc<StdMutex<LiveRuntimeAuthorityState>>, |
| 891 | compaction_cancellation: Arc<StdMutex<CompactionCancellationState>>, |
| 892 | /// Clone of the op-channel sender, so the engine can self-dispatch ops |
| 893 | /// (e.g. a goal-continuation `SendMessage` after a turn completes). |
| 894 | tx_op: mpsc::Sender<Op>, |
| 895 | /// At most one engine-owned continuation across capacity-waiting and |
| 896 | /// enqueued states. The authoritative dynamic-tool set stays here so a |
| 897 | /// later successful turn can refresh it without adding a second token. |
| 898 | scheduled_goal_continuation: Option<ScheduledGoalContinuation>, |
| 899 | goal_continuation_schedule_seq: u64, |
| 900 | rx_approval: mpsc::Receiver<ApprovalDecision>, |
| 901 | /// Canonical per-session approval evidence. A missing/unwritable store is |
| 902 | /// retained as an error so construction can stay infallible while every |
| 903 | /// approval gate still fails closed. |
| 904 | approval_receipt_store: Result<ApprovalReceiptStore, String>, |
| 905 | rx_user_input: mpsc::Receiver<UserInputDecision>, |
| 906 | rx_steer: mpsc::Receiver<handle::SteerInput>, |
| 907 | turn_controls: Arc<StdMutex<handle::TurnControls>>, |
| 908 | admitted_turn_control: Option<handle::TurnControl>, |
| 909 | tx_event: mpsc::Sender<Event>, |
| 910 | /// Wakeup channel for the parent turn loop when a direct child sub-agent |
| 911 | /// terminates (issue #756). Cloned into `SubAgentRuntime` so the runtime |
| 912 | /// can fan completion events back into the engine. |
| 913 | tx_subagent_completion: mpsc::Sender<SubAgentCompletion>, |
| 914 | /// Receiver paired with `tx_subagent_completion`. Drained at the |
| 915 | /// turn-loop's empty-tool_uses branch to surface `<codewhale:subagent.done>` |
| 916 | /// sentinels into the parent's transcript before deciding to end the turn. |
| 917 | pub(super) rx_subagent_completion: mpsc::Receiver<SubAgentCompletion>, |
| 918 | /// Sub-agent completions already injected into the parent transcript. |
| 919 | /// Channel delivery and watchdog reconciliation both mark this set so a |
| 920 | /// dropped event can be synthesized once without duplicating a later |
| 921 | /// delivery. |
| 922 | delivered_subagent_completion_ids: HashSet<String>, |
| 923 | cancel_token: CancellationToken, |
| 924 | shared_cancel_token: Arc<StdMutex<CancellationToken>>, |
| 925 | /// Latched reason for the current cancellation, mirrored to |
| 926 | /// `EngineHandle::cancel_reason`. Read by `approval.rs` when |
| 927 | /// surfacing the "Request cancelled while awaiting …" error so the |
| 928 | /// user-facing message names a cause. |
| 929 | pub(super) cancel_reason: Arc<StdMutex<Option<CancelReason>>>, |
| 930 | tool_exec_lock: Arc<RwLock<()>>, |
| 931 | turn_counter: u64, |
| 932 | /// Post-edit LSP diagnostics injection (#136). Populated unconditionally |
| 933 | /// — when LSP is disabled in config, this is an inert manager that |
| 934 | /// always returns `None` from `diagnostics_for`. |
| 935 | lsp_manager: Arc<crate::lsp::LspManager>, |
| 936 | /// External sandbox backend (#516). When `Some`, exec_shell routes commands |
| 937 | /// through this instead of spawning a local process. |
| 938 | sandbox_backend: Option<std::sync::Arc<dyn crate::sandbox::backend::SandboxBackend>>, |
| 939 | /// Session-pinned execution boundary used by model-visible sandbox labels. |
| 940 | /// This must not be re-probed per turn or metadata bytes can drift. |
| 941 | sandbox_enforcement: crate::sandbox::policy::SandboxEnforcement, |
| 942 | /// Diagnostics collected during the current step's tool calls. Drained |
| 943 | /// and forwarded as a synthetic user message before the next API call. |
| 944 | pending_lsp_blocks: Vec<crate::lsp::DiagnosticBlock>, |
| 945 | /// Current operating mode. Updated on `ChangeMode` and `SendMessage`. |
| 946 | current_mode: AppMode, |
| 947 | /// R1: cumulative wall-clock budget for the turn currently running. |
| 948 | /// Restarted at the top of every `run_turn`, checked at the |
| 949 | /// provider-request boundary, and paused while the turn is blocked on a |
| 950 | /// human approval decision. It lives on the engine rather than in |
| 951 | /// `TurnContext` so `request_tool_approval` — which never sees the turn |
| 952 | /// context — can pause it. |
| 953 | turn_wall_clock: turn_budget::TurnWallClock, |
| 954 | /// The most recent authority narrowing, if any (#3947). Kept on the engine |
| 955 | /// so doctor and debug surfaces can answer "why is this tool unavailable" |
| 956 | /// with the same record the user and the model already saw. |
| 957 | last_policy_narrowing: Option<PolicyNarrowingEvent>, |
| 958 | /// The git snapshot line last emitted in a `<turn_meta>` block this |
| 959 | /// session (#5187, k3-gap F3). The snapshot re-collects branch/dirty |
| 960 | /// state every turn, so without change-detection the block's bytes drift |
| 961 | /// after every edit the model itself makes, defeating cross-turn prefix |
| 962 | /// stability. `None` until the first block is built; the line is then |
| 963 | /// emitted only when the snapshot actually changed. |
| 964 | last_turn_meta_git_snapshot: StdMutex<Option<String>>, |
| 965 | /// Process-local cache for `estimated_input_tokens`. Memoizes the most |
| 966 | /// recent token estimate keyed on `(session.messages_revision, |
| 967 | /// system_prompt_fingerprint)`. Five call sites per turn consult this |
| 968 | /// (engine capacity checkpoints, seam manager, trim budget, etc.) plus |
| 969 | /// four TUI / command consumers; the cache turns N×O(messages) walks |
| 970 | /// into a single recompute on a content change. |
| 971 | token_estimate_cache: TokenEstimateCache, |
| 972 | /// Shared pause flag set by the TUI and read before tool execution. |
| 973 | shared_paused: Arc<StdMutex<bool>>, |
| 974 | /// Rate-limit + dedup guard for the background advisor watcher (#3982). |
| 975 | /// `None` until the first turn completes with the advisor enabled, then |
| 976 | /// held for the session lifetime so state persists across turns. |
| 977 | advisor_emission_guard: Option<Arc<tokio::sync::Mutex<crate::tools::subagent::EmissionGuard>>>, |
| 978 | } |
| 979 | |
| 980 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 981 | struct LiveRuntimeAuthority { |
| 982 | mode: AppMode, |
| 983 | allow_shell: bool, |
| 984 | trust_mode: bool, |
| 985 | auto_approve: bool, |
| 986 | approval_mode: ApprovalMode, |
| 987 | configured_sandbox_mode: Option<String>, |
| 988 | } |
| 989 | |
| 990 | impl LiveRuntimeAuthority { |
| 991 | fn from_fields( |
| 992 | mode: AppMode, |
| 993 | allow_shell: bool, |
| 994 | trust_mode: bool, |
| 995 | auto_approve: bool, |
| 996 | approval_mode: ApprovalMode, |
| 997 | configured_sandbox_mode: Option<String>, |
| 998 | ) -> Self { |
| 999 | let authority = TurnAuthority::from_effective_fields( |
| 1000 | mode, |
| 1001 | allow_shell, |
| 1002 | trust_mode, |
| 1003 | auto_approve, |
| 1004 | approval_mode, |
| 1005 | ); |
| 1006 | Self::from_turn_authority(&authority, configured_sandbox_mode) |
| 1007 | } |
| 1008 | |
| 1009 | fn from_turn_authority( |
| 1010 | authority: &TurnAuthority, |
| 1011 | configured_sandbox_mode: Option<String>, |
| 1012 | ) -> Self { |
| 1013 | let approval_mode = authority.approval_mode_for_session(); |
| 1014 | Self { |
| 1015 | mode: authority.mode, |
| 1016 | allow_shell: authority.allow_shell, |
| 1017 | trust_mode: authority.trust_mode, |
| 1018 | auto_approve: authority.auto_approve || approval_mode == ApprovalMode::Bypass, |
| 1019 | approval_mode, |
| 1020 | configured_sandbox_mode, |
| 1021 | } |
| 1022 | } |
| 1023 | |
| 1024 | fn permission_snapshot(&self) -> RuntimePermissionAuthority { |
| 1025 | RuntimePermissionAuthority { |
| 1026 | auto_approve: self.auto_approve, |
| 1027 | trust_mode: self.trust_mode, |
| 1028 | approval_mode: self.approval_mode, |
| 1029 | } |
| 1030 | } |
| 1031 | } |
| 1032 | |
| 1033 | #[derive(Debug)] |
| 1034 | struct LiveRuntimeAuthorityState { |
| 1035 | revision: u64, |
| 1036 | applied_revision: u64, |
| 1037 | authority: LiveRuntimeAuthority, |
| 1038 | } |
| 1039 | |
| 1040 | impl LiveRuntimeAuthorityState { |
| 1041 | fn new(authority: LiveRuntimeAuthority) -> Self { |
| 1042 | Self { |
| 1043 | revision: 0, |
| 1044 | applied_revision: 0, |
| 1045 | authority, |
| 1046 | } |
| 1047 | } |
| 1048 | } |
| 1049 | |
| 1050 | /// Runtime-facing view of the engine's exact live permission authority. |
| 1051 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 1052 | pub(crate) struct RuntimePermissionAuthority { |
| 1053 | pub(crate) auto_approve: bool, |
| 1054 | pub(crate) trust_mode: bool, |
| 1055 | pub(crate) approval_mode: ApprovalMode, |
| 1056 | } |
| 1057 | |
| 1058 | fn claim_subagent_completion( |
| 1059 | delivered_ids: &mut HashSet<String>, |
| 1060 | completion: SubAgentCompletion, |
| 1061 | ) -> Option<SubAgentCompletion> { |
| 1062 | delivered_ids |
| 1063 | .insert(completion.agent_id.clone()) |
| 1064 | .then_some(completion) |
| 1065 | } |
| 1066 | |
| 1067 | fn claim_subagent_completion_for_session( |
| 1068 | delivered_ids: &mut HashSet<String>, |
| 1069 | active_session_id: &str, |
| 1070 | completion: SubAgentCompletion, |
| 1071 | ) -> Option<SubAgentCompletion> { |
| 1072 | if completion.owner_session_id != active_session_id { |
| 1073 | tracing::warn!( |
| 1074 | target: "subagent", |
| 1075 | agent_id = %completion.agent_id, |
| 1076 | owner_session_id = %completion.owner_session_id, |
| 1077 | active_session_id, |
| 1078 | "discarding sub-agent completion for an inactive session" |
| 1079 | ); |
| 1080 | return None; |
| 1081 | } |
| 1082 | claim_subagent_completion(delivered_ids, completion) |
| 1083 | } |
| 1084 | |
| 1085 | #[derive(Debug)] |
| 1086 | enum GoalContinuationAction { |
| 1087 | Inactive, |
| 1088 | Dispatch { |
| 1089 | content: String, |
| 1090 | snapshot: Box<GoalSnapshot>, |
| 1091 | }, |
| 1092 | Stopped { |
| 1093 | message: String, |
| 1094 | reason: GoalPauseReason, |
| 1095 | }, |
| 1096 | } |
| 1097 | |
| 1098 | struct ScheduledGoalContinuation { |
| 1099 | id: u64, |
| 1100 | dynamic_tools: Vec<DynamicToolSpec>, |
| 1101 | enqueued: bool, |
| 1102 | /// `Some` only while the configured between-turn quiet period is active. |
| 1103 | /// Once it expires the same schedule record becomes the existing queued |
| 1104 | /// `ContinueGoal` token; there is no second scheduler. |
| 1105 | ready_at: Option<Instant>, |
| 1106 | /// Retained after expiry so a cancellation racing the timer can still |
| 1107 | /// publish an interrupted wait receipt before provider dispatch. |
| 1108 | was_delayed: bool, |
| 1109 | } |
| 1110 | |
| 1111 | enum SendMessageOutcome { |
| 1112 | NotStarted { |
| 1113 | error: Option<String>, |
| 1114 | }, |
| 1115 | Finished { |
| 1116 | status: TurnOutcomeStatus, |
| 1117 | error: Option<String>, |
| 1118 | }, |
| 1119 | } |
| 1120 | |
| 1121 | /// Idle-poll cadence for unclaimed background shell completion while a |
| 1122 | /// goal is active. Coarse on purpose: this is a liveness backstop, not an |
| 1123 | /// animation loop. |
| 1124 | const SHELL_WAKE_POLL_MS: u64 = 750; |
| 1125 | |
| 1126 | enum EngineRunInput { |
| 1127 | Operation(Box<Op>), |
| 1128 | SubAgentCompletion(SubAgentCompletion), |
| 1129 | /// A background shell job finished while the engine sat idle with an |
| 1130 | /// active goal. Shell completion is pull-only (no channel), so without |
| 1131 | /// this wake an active goal waiting on background work stayed inert until |
| 1132 | /// the user typed something (morning-report continuation gap). |
| 1133 | ShellCompletionWake, |
| 1134 | /// One MCP boot progress/settled update from the spawn-time connect task. |
| 1135 | McpBootUpdate(McpBootUpdate), |
| 1136 | /// One connection-supervisor sweep: deaths, recoveries, failed attempts. |
| 1137 | McpSupervisorUpdate(McpSupervisorUpdate), |
| 1138 | } |
| 1139 | |
| 1140 | impl SendMessageOutcome { |
| 1141 | fn started(&self) -> bool { |
| 1142 | matches!(self, Self::Finished { .. }) |
| 1143 | } |
| 1144 | } |
| 1145 | |
| 1146 | // === Internal tool helpers === |
| 1147 | |
| 1148 | fn subagent_mailbox_message_is_best_effort(message: &MailboxMessage) -> bool { |
| 1149 | matches!( |
| 1150 | message, |
| 1151 | MailboxMessage::Progress { .. } |
| 1152 | | MailboxMessage::ToolCallStarted { .. } |
| 1153 | | MailboxMessage::ToolCallCompleted { .. } |
| 1154 | ) |
| 1155 | } |
| 1156 | |
| 1157 | const SUBAGENT_MAILBOX_BEST_EFFORT_MIN_INTERVAL: Duration = Duration::from_millis(100); |
| 1158 | |
| 1159 | fn subagent_mailbox_best_effort_send_permitted( |
| 1160 | last_sent_at: &mut HashMap<String, Instant>, |
| 1161 | message: &MailboxMessage, |
| 1162 | now: Instant, |
| 1163 | ) -> bool { |
| 1164 | if !subagent_mailbox_message_is_best_effort(message) { |
| 1165 | return true; |
| 1166 | } |
| 1167 | |
| 1168 | let agent_id = message.agent_id().to_string(); |
| 1169 | if last_sent_at |
| 1170 | .get(&agent_id) |
| 1171 | .is_some_and(|last| now.duration_since(*last) < SUBAGENT_MAILBOX_BEST_EFFORT_MIN_INTERVAL) |
| 1172 | { |
| 1173 | return false; |
| 1174 | } |
| 1175 | |
| 1176 | last_sent_at.insert(agent_id, now); |
| 1177 | true |
| 1178 | } |
| 1179 | |
| 1180 | /// Forward one turn-scoped mailbox envelope. Returns `false` when the engine |
| 1181 | /// event channel is closed and the drainer should stop. |
| 1182 | async fn forward_subagent_mailbox_message( |
| 1183 | tx: &mpsc::Sender<Event>, |
| 1184 | owner_session_id: &str, |
| 1185 | turn_id: &str, |
| 1186 | seq: u64, |
| 1187 | message: MailboxMessage, |
| 1188 | best_effort_sent_at: &mut HashMap<String, Instant>, |
| 1189 | ) -> bool { |
| 1190 | let event = Event::SubAgentMailbox { |
| 1191 | owner_session_id: owner_session_id.to_string(), |
| 1192 | turn_id: turn_id.to_string(), |
| 1193 | seq, |
| 1194 | message, |
| 1195 | }; |
| 1196 | if let Event::SubAgentMailbox { message, .. } = &event |
| 1197 | && subagent_mailbox_message_is_best_effort(message) |
| 1198 | { |
| 1199 | if !subagent_mailbox_best_effort_send_permitted( |
| 1200 | best_effort_sent_at, |
| 1201 | message, |
| 1202 | Instant::now(), |
| 1203 | ) { |
| 1204 | return true; |
| 1205 | } |
| 1206 | return match tx.try_send(event) { |
| 1207 | Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => true, |
| 1208 | Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => false, |
| 1209 | }; |
| 1210 | } |
| 1211 | tx.send(event).await.is_ok() |
| 1212 | } |
| 1213 | |
| 1214 | /// Which config-source refresh precedes a connect pass. |
| 1215 | enum McpConnectRefresh { |
| 1216 | /// Session boot: re-read only when the sources moved (mtime/content). |
| 1217 | IfChanged, |
| 1218 | /// Explicit reload: force a re-read and drop every live connection |
| 1219 | /// first, so even a byte-identical config re-dials under the current |
| 1220 | /// credentials. A malformed source fails the pass before anything is |
| 1221 | /// dropped. |
| 1222 | Force, |
| 1223 | } |
| 1224 | |
| 1225 | impl Engine { |
| 1226 | /// Surface the snapshots-disabled notice a blocking snapshot task parked |
| 1227 | /// (#5930). Called at turn boundaries; each session gets its own notice. |
| 1228 | pub(super) async fn emit_pending_snapshot_notices(&self) { |
| 1229 | for notice in crate::core::turn::take_snapshots_disabled_notices( |
| 1230 | &self.session.workspace, |
| 1231 | Some(&self.session.id), |
| 1232 | ) { |
| 1233 | // One rendered line, localized once here: the TUI toasts it as-is |
| 1234 | // and `/status` re-renders it from the retained observation. |
| 1235 | let reason = notice.localize(codewhale_localization::resolve_locale( |
| 1236 | &self.config.locale_tag, |
| 1237 | )); |
| 1238 | let _ = self |
| 1239 | .tx_event |
| 1240 | .send(Event::SnapshotsDisabled { |
| 1241 | workspace: notice.workspace, |
| 1242 | reason, |
| 1243 | }) |
| 1244 | .await; |
| 1245 | } |
| 1246 | } |
| 1247 | |
| 1248 | fn begin_turn_control(&mut self) -> handle::TurnControlGuard { |
| 1249 | self.begin_turn_control_for_provenance(UserInputProvenance::ExternalUser) |
| 1250 | } |
| 1251 | |
| 1252 | fn begin_turn_control_for_provenance( |
| 1253 | &mut self, |
| 1254 | provenance: UserInputProvenance, |
| 1255 | ) -> handle::TurnControlGuard { |
| 1256 | let mut controls = self |
| 1257 | .turn_controls |
| 1258 | .lock() |
| 1259 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 1260 | let control = self.admitted_turn_control.take().unwrap_or_else(|| { |
| 1261 | let mut control = controls.fresh(); |
| 1262 | if !provenance.can_authorize_work() { |
| 1263 | // Idle handoffs are continuations of the existing user |
| 1264 | // request. Retain cancellation while holding the same |
| 1265 | // activation lock used by cancel_with_reason, so a cancel |
| 1266 | // during an earlier status send cannot be reset here. |
| 1267 | // Reuse the scope itself so cancelling the handoff also |
| 1268 | // stops siblings launched before the ordinary parent reply. |
| 1269 | control.cancel = self.cancel_token.clone(); |
| 1270 | control.reason = Arc::clone(&self.cancel_reason); |
| 1271 | } |
| 1272 | control |
| 1273 | }); |
| 1274 | self.cancel_token = control.cancel.clone(); |
| 1275 | self.cancel_reason = Arc::clone(&control.reason); |
| 1276 | *self |
| 1277 | .shared_cancel_token |
| 1278 | .lock() |
| 1279 | .unwrap_or_else(std::sync::PoisonError::into_inner) = control.cancel.clone(); |
| 1280 | *self |
| 1281 | .shared_paused |
| 1282 | .lock() |
| 1283 | .unwrap_or_else(std::sync::PoisonError::into_inner) = false; |
| 1284 | controls.active = Some(control.clone()); |
| 1285 | handle::TurnControlGuard { |
| 1286 | controls: Arc::clone(&self.turn_controls), |
| 1287 | id: control.id, |
| 1288 | } |
| 1289 | } |
| 1290 | |
| 1291 | /// Take the next steer belonging to the active turn. |
| 1292 | /// |
| 1293 | /// Steers addressed to a turn that has already moved on are discarded |
| 1294 | /// here; dropping their [`handle::SteerInput`] reports |
| 1295 | /// [`handle::SteerOutcome::Dropped`] to the sender, so a discard is never |
| 1296 | /// silent (#6276). The returned [`handle::PendingSteer`] is unsettled: |
| 1297 | /// the caller must `commit()` it once the text is in the turn's record, |
| 1298 | /// and dropping it otherwise reports `Dropped` too. |
| 1299 | fn next_turn_steer(&mut self) -> Option<handle::PendingSteer> { |
| 1300 | let active_id = self |
| 1301 | .turn_controls |
| 1302 | .lock() |
| 1303 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 1304 | .active |
| 1305 | .as_ref() |
| 1306 | .map(|control| control.id); |
| 1307 | while let Ok(steer) = self.rx_steer.try_recv() { |
| 1308 | if steer.turn_id == active_id { |
| 1309 | return Some(steer.into_pending()); |
| 1310 | } |
| 1311 | } |
| 1312 | None |
| 1313 | } |
| 1314 | |
| 1315 | fn env_only_api_key_recovery_hint(api_config: &Config) -> Option<String> { |
| 1316 | if !crate::config::active_provider_uses_env_only_api_key(api_config) { |
| 1317 | return None; |
| 1318 | } |
| 1319 | |
| 1320 | let provider = api_config.api_provider(); |
| 1321 | let env_var = provider.env_vars_label(); |
| 1322 | |
| 1323 | Some(format!( |
| 1324 | "The rejected key came from {env_var}; no saved config key is present.\n\ |
| 1325 | Run `codewhale auth status` to inspect credential sources, then \ |
| 1326 | `codewhale auth set --provider {provider}` to save a valid key in ~/.codewhale/config.toml, \ |
| 1327 | or remove the stale export and open a fresh shell.", |
| 1328 | provider = provider.as_str() |
| 1329 | )) |
| 1330 | } |
| 1331 | |
| 1332 | pub(super) fn decorate_auth_error_message(&self, message: String) -> String { |
| 1333 | let Some(hint) = self.api_key_env_only_recovery.as_ref() else { |
| 1334 | return message; |
| 1335 | }; |
| 1336 | if crate::error_taxonomy::classify_error_message(&message) != ErrorCategory::Authentication |
| 1337 | || message.contains("no saved config key is present") |
| 1338 | { |
| 1339 | return message; |
| 1340 | } |
| 1341 | format!("{message}\n\n{hint}") |
| 1342 | } |
| 1343 | |
| 1344 | /// Install a route that the host already resolved and client-preflighted. |
| 1345 | /// No identity guessing or config re-resolution is allowed at this |
| 1346 | /// boundary: the descriptor is the single authority for the turn. |
| 1347 | fn install_validated_runtime_route(&mut self, route: ValidatedRuntimeRoute) { |
| 1348 | let provider = route.identity.provider; |
| 1349 | let identity = route.identity.key; |
| 1350 | let provider_id = route.identity.exact_id; |
| 1351 | let model = route.model; |
| 1352 | let limits = crate::route_budget::known_route_limits(route.candidate.limits()); |
| 1353 | let capabilities = route.candidate.capabilities(); |
| 1354 | let api_config = *route.config; |
| 1355 | let client = route.client; |
| 1356 | |
| 1357 | self.api_provider = provider; |
| 1358 | self.api_provider_identity = identity; |
| 1359 | self.api_provider_id = provider_id; |
| 1360 | self.api_config = api_config; |
| 1361 | self.active_route_limits = limits; |
| 1362 | self.active_route_capabilities = capabilities; |
| 1363 | self.api_key_env_only_recovery = Self::env_only_api_key_recovery_hint(&self.api_config); |
| 1364 | self.codewhale_client = Some(client.clone()); |
| 1365 | if !self.model_client_injected { |
| 1366 | self.model_client = Some(Arc::new(client.clone())); |
| 1367 | } |
| 1368 | self.codewhale_client_error = None; |
| 1369 | self.session.model = model; |
| 1370 | self.config.model.clone_from(&self.session.model); |
| 1371 | } |
| 1372 | |
| 1373 | /// Activate a structurally resolved route at the engine boundary. Normal |
| 1374 | /// engines construct the concrete client before any turn state changes. |
| 1375 | /// Embedders/tests that explicitly injected a provider-neutral client keep |
| 1376 | /// that client as the I/O authority while still installing the exact route |
| 1377 | /// identity, model, config, and budget receipt. |
| 1378 | fn install_resolved_runtime_route( |
| 1379 | &mut self, |
| 1380 | mut route: ResolvedRuntimeRoute, |
| 1381 | ) -> Result<(), String> { |
| 1382 | if !self.model_client_injected { |
| 1383 | self.install_validated_runtime_route(route.validate()?); |
| 1384 | return Ok(()); |
| 1385 | } |
| 1386 | |
| 1387 | let preflighted_client = route.take_preflighted_client(); |
| 1388 | let provider = route.identity.provider; |
| 1389 | let identity = route.identity.key; |
| 1390 | let provider_id = route.identity.exact_id; |
| 1391 | let model = route.model; |
| 1392 | let limits = crate::route_budget::known_route_limits(route.candidate.limits()); |
| 1393 | let capabilities = route.candidate.capabilities(); |
| 1394 | let api_config = *route.config; |
| 1395 | let concrete_client = preflighted_client |
| 1396 | .map(Ok) |
| 1397 | .unwrap_or_else(|| CodewhaleClient::from_candidate(&api_config, &route.candidate)); |
| 1398 | |
| 1399 | self.api_provider = provider; |
| 1400 | self.api_provider_identity = identity; |
| 1401 | self.api_provider_id = provider_id; |
| 1402 | self.api_config = api_config; |
| 1403 | self.active_route_limits = limits; |
| 1404 | self.active_route_capabilities = capabilities; |
| 1405 | self.api_key_env_only_recovery = Self::env_only_api_key_recovery_hint(&self.api_config); |
| 1406 | match concrete_client { |
| 1407 | Ok(client) => { |
| 1408 | self.codewhale_client = Some(client.clone()); |
| 1409 | self.codewhale_client_error = None; |
| 1410 | } |
| 1411 | Err(err) => { |
| 1412 | self.codewhale_client = None; |
| 1413 | self.codewhale_client_error = Some(err.to_string()); |
| 1414 | } |
| 1415 | } |
| 1416 | self.session.model = model; |
| 1417 | self.config.model.clone_from(&self.session.model); |
| 1418 | Ok(()) |
| 1419 | } |
| 1420 | |
| 1421 | fn current_runtime_route(&self) -> Result<ResolvedRuntimeRoute, String> { |
| 1422 | let config = self |
| 1423 | .authoritative_route_config |
| 1424 | .as_ref() |
| 1425 | .map(|config| config.read().clone()) |
| 1426 | .unwrap_or_else(|| self.api_config.clone()); |
| 1427 | let identity = config.resolve_persisted_provider_identity( |
| 1428 | Some(self.api_provider.as_str()), |
| 1429 | self.api_provider_id.as_deref(), |
| 1430 | )?; |
| 1431 | resolve_runtime_route_for_identity(&config, &identity, Some(&self.session.model)) |
| 1432 | } |
| 1433 | |
| 1434 | /// Create a new engine with the given configuration |
| 1435 | pub fn new(mut config: EngineConfig, api_config: &Config) -> (Self, EngineHandle) { |
| 1436 | crate::tls::ensure_rustls_crypto_provider(); |
| 1437 | |
| 1438 | // Compaction re-states the user's `/anchor` file after its summary; |
| 1439 | // hand it the workspace root once so every prepared pass can read it. |
| 1440 | if config.compaction.workspace.is_none() && !api_config.runtime_chat_isolated { |
| 1441 | config.compaction.workspace = Some(config.workspace.clone()); |
| 1442 | } |
| 1443 | |
| 1444 | // Unlike a Skill body, this instruction is visible on the first model |
| 1445 | // request. Registry discovery is a fallback for missing capabilities; |
| 1446 | // result matching stays with the model and the index stays host-side. |
| 1447 | // |
| 1448 | // It describes when discovery is worth a turn; it is not a gate ahead |
| 1449 | // of ordinary work. The earlier "must call `registry_sync` before a |
| 1450 | // manual implementation" phrasing named two deferred tools as |
| 1451 | // mandatory, so a plain "write an HTML page and read a fixture" turn |
| 1452 | // spent its steps on `tool_search` for `registry_sync` and on starting |
| 1453 | // a browser server instead of writing the file. |
| 1454 | if config.features.enabled(Feature::Mcp) && !api_config.runtime_chat_isolated { |
| 1455 | config |
| 1456 | .instructions |
| 1457 | .push(crate::prompts::InstructionSource::Inline { |
| 1458 | name: MCP_REGISTRY_FIRST_INSTRUCTION_SOURCE.to_string(), |
| 1459 | content: MCP_REGISTRY_FIRST_INSTRUCTION.to_string(), |
| 1460 | }); |
| 1461 | } |
| 1462 | |
| 1463 | if let Some(objective) = normalized_goal_objective(config.goal_objective.as_deref()) { |
| 1464 | sync_goal_state_from_host( |
| 1465 | &config.goal_state, |
| 1466 | Some(&objective), |
| 1467 | config.goal_token_budget, |
| 1468 | config.goal_status, |
| 1469 | ); |
| 1470 | } |
| 1471 | |
| 1472 | let (tx_op, rx_op) = mpsc::channel(ENGINE_OP_CHANNEL_CAPACITY); |
| 1473 | let (tx_event, rx_event) = mpsc::channel(256); |
| 1474 | let (tx_approval, rx_approval) = mpsc::channel(64); |
| 1475 | let (tx_user_input, rx_user_input) = mpsc::channel(32); |
| 1476 | let (tx_steer, rx_steer) = mpsc::channel(64); |
| 1477 | let turn_controls = Arc::new(StdMutex::new(handle::TurnControls::default())); |
| 1478 | let (tx_subagent_completion, rx_subagent_completion) = |
| 1479 | mpsc::channel(SUBAGENT_COMPLETION_CHANNEL_CAPACITY); |
| 1480 | let cancel_token = CancellationToken::new(); |
| 1481 | let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); |
| 1482 | let cancel_reason: Arc<StdMutex<Option<CancelReason>>> = Arc::new(StdMutex::new(None)); |
| 1483 | let shared_paused = Arc::new(StdMutex::new(false)); |
| 1484 | let live_runtime_authority = Arc::new(StdMutex::new(LiveRuntimeAuthorityState::new( |
| 1485 | LiveRuntimeAuthority::from_fields( |
| 1486 | AppMode::Agent, |
| 1487 | config.allow_shell, |
| 1488 | config.trust_mode, |
| 1489 | false, |
| 1490 | ApprovalMode::Suggest, |
| 1491 | api_config.sandbox_mode.clone(), |
| 1492 | ), |
| 1493 | ))); |
| 1494 | let compaction_cancellation = |
| 1495 | Arc::new(StdMutex::new(CompactionCancellationState::default())); |
| 1496 | let tool_exec_lock = Arc::new(RwLock::new(())); |
| 1497 | let plugin_registry = config |
| 1498 | .plugin_registry |
| 1499 | .as_ref() |
| 1500 | .filter(|registry| registry.workspace() == config.workspace) |
| 1501 | .cloned() |
| 1502 | .unwrap_or_else(|| Arc::new(crate::plugins::PluginRegistry::empty(&config.workspace))); |
| 1503 | |
| 1504 | // Create clients for both providers |
| 1505 | let (codewhale_client, codewhale_client_error) = match CodewhaleClient::new(api_config) { |
| 1506 | Ok(client) => (Some(client), None), |
| 1507 | Err(err) => (None, Some(err.to_string())), |
| 1508 | }; |
| 1509 | let model_client = codewhale_client |
| 1510 | .as_ref() |
| 1511 | .map(|client| Arc::new(client.clone()) as SharedModelClient); |
| 1512 | let api_provider = api_config.api_provider(); |
| 1513 | let (api_provider_identity, api_provider_id) = api_config |
| 1514 | .active_provider_identity(api_provider) |
| 1515 | .map(|identity| (identity.key, identity.exact_id)) |
| 1516 | .unwrap_or_else(|_| { |
| 1517 | let key = api_config.provider_identity_for(api_provider); |
| 1518 | let exact_id = (!(api_provider == ApiProvider::Custom |
| 1519 | && api_config.uses_legacy_literal_custom_route())) |
| 1520 | .then(|| key.clone()); |
| 1521 | (key, exact_id) |
| 1522 | }); |
| 1523 | let api_key_env_only_recovery = Self::env_only_api_key_recovery_hint(api_config); |
| 1524 | |
| 1525 | let mut session = Session::new( |
| 1526 | config.model.clone(), |
| 1527 | config.workspace.clone(), |
| 1528 | config.allow_shell, |
| 1529 | config.trust_mode, |
| 1530 | config.notes_path.clone(), |
| 1531 | config.mcp_config_path.clone(), |
| 1532 | ); |
| 1533 | if let Some(session_id) = config |
| 1534 | .session_id |
| 1535 | .as_deref() |
| 1536 | .map(str::trim) |
| 1537 | .filter(|id| !id.is_empty()) |
| 1538 | { |
| 1539 | session.id = session_id.to_string(); |
| 1540 | } |
| 1541 | // Set up stable system prompt with project context (default to agent mode). |
| 1542 | // Per-turn working-set metadata is injected into the latest user |
| 1543 | // message at request time so file churn does not rewrite this prefix. |
| 1544 | // Session start boundary: reconcile this session's interrupted memory |
| 1545 | // contexts (prepared but never dispatch-acknowledged — e.g. the process |
| 1546 | // died mid-turn), then prepare this session's prompt packet through the |
| 1547 | // durable receipt path so the Context Lens can show what was assembled |
| 1548 | // for it. Both are inert when memory is disabled — no store I/O. |
| 1549 | if config.memory_enabled |
| 1550 | && let Some(store) = |
| 1551 | crate::native_memory::NativeMemoryStore::from_global_path(&config.memory_path) |
| 1552 | { |
| 1553 | match store.session_start(&config.workspace, &session.id) { |
| 1554 | Ok(0) => {} |
| 1555 | Ok(interrupted) => tracing::info!( |
| 1556 | interrupted, |
| 1557 | "memory contexts from this session never completed dispatch" |
| 1558 | ), |
| 1559 | Err(error) => { |
| 1560 | tracing::warn!(%error, "memory session-start reconcile failed") |
| 1561 | } |
| 1562 | } |
| 1563 | } |
| 1564 | let user_memory_block = crate::native_memory::native_prompt_block_traced( |
| 1565 | config.memory_enabled, |
| 1566 | &config.memory_path, |
| 1567 | &config.workspace, |
| 1568 | &session.id, |
| 1569 | ); |
| 1570 | let prompt_goal_objective = |
| 1571 | goal_objective_for_prompt(config.goal_objective.as_deref(), &config.goal_state); |
| 1572 | // #5715: name a prior workspace session that ended mid-turn so the |
| 1573 | // model can offer recovery without being asked. Frozen-prefix |
| 1574 | // contributor — computed once here, identical for every turn. |
| 1575 | let recovery_hint = crate::session_manager::session_recovery_hint( |
| 1576 | &config.workspace, |
| 1577 | Some(session.id.as_str()), |
| 1578 | ); |
| 1579 | let prompt_host = if config.terminal_chrome_enabled { |
| 1580 | prompts::PromptHost::Interactive |
| 1581 | } else { |
| 1582 | prompts::PromptHost::Headless |
| 1583 | }; |
| 1584 | let system_prompt = if api_config.runtime_chat_isolated { |
| 1585 | SystemPrompt::Text(ISOLATED_CHAT_ENGINE_PROMPT.to_string()) |
| 1586 | } else { |
| 1587 | prompts::system_prompt_for_mode_with_context_skills_session_and_approval_for_host( |
| 1588 | &config.workspace, |
| 1589 | None, |
| 1590 | Some(&config.skills_dir), |
| 1591 | Some(&config.instructions), |
| 1592 | prompts::PromptSessionContext { |
| 1593 | user_memory_block: user_memory_block.as_deref(), |
| 1594 | goal_objective: prompt_goal_objective.as_deref(), |
| 1595 | project_context_pack_enabled: config.project_context_pack_enabled, |
| 1596 | locale_tag: &config.locale_tag, |
| 1597 | translation_enabled: config.translation_enabled, |
| 1598 | model_id: &config.model, |
| 1599 | context_window_override: Some( |
| 1600 | crate::route_budget::route_context_window_tokens( |
| 1601 | api_provider, |
| 1602 | &config.model, |
| 1603 | config.active_route_limits, |
| 1604 | ), |
| 1605 | ), |
| 1606 | verbosity: config.verbosity.as_deref(), |
| 1607 | recovery_hint: recovery_hint.as_deref(), |
| 1608 | skills_scan_codewhale_only: config.skills_scan_codewhale_only, |
| 1609 | plugin_registry: Some(plugin_registry.as_ref()), |
| 1610 | // Matches `current_mode`'s initial value below; a later |
| 1611 | // `/mode` switch re-runs `refresh_system_prompt`. |
| 1612 | mode: AppMode::Agent, |
| 1613 | }, |
| 1614 | prompt_host, |
| 1615 | ) |
| 1616 | }; |
| 1617 | let stable_prompt = Some(system_prompt); |
| 1618 | session.last_system_prompt_hash = Some(system_prompt_hash(stable_prompt.as_ref())); |
| 1619 | session.system_prompt = stable_prompt; |
| 1620 | |
| 1621 | // Initialize prefix-cache stability monitor (lazy-pin). |
| 1622 | // The system prompt is available now but the tool catalog isn't |
| 1623 | // fully built until the first turn, so we start unpinned. The |
| 1624 | // first `check_and_update` call in the turn loop will pin the |
| 1625 | // fingerprint automatically. |
| 1626 | let _ = session.prefix_stability.get_or_insert_with(|| { |
| 1627 | // Use the tool registry's spec names for fingerprinting. |
| 1628 | // At this point tool spec builders may not be registered yet, |
| 1629 | // so we start with None — fingerprint will pin on first request. |
| 1630 | codewhale_core::prefix_cache::PrefixStabilityManager::new_unpinned() |
| 1631 | }); |
| 1632 | |
| 1633 | let subagent_state_root = config |
| 1634 | .subagent_state_root |
| 1635 | .clone() |
| 1636 | .unwrap_or_else(|| config.workspace.clone()); |
| 1637 | let subagent_manager = new_shared_subagent_manager_with_state_root_and_timeout( |
| 1638 | config.workspace.clone(), |
| 1639 | subagent_state_root, |
| 1640 | config.max_subagents, |
| 1641 | config.max_admitted_subagents, |
| 1642 | config.subagent_heartbeat_timeout, |
| 1643 | config.launch_concurrency, |
| 1644 | // #5324: per-child budget defaults are operator config, not |
| 1645 | // per-call schema fields. |
| 1646 | api_config.subagent_default_max_steps(), |
| 1647 | api_config |
| 1648 | .subagent_default_wall_time_secs() |
| 1649 | .map(std::time::Duration::from_secs), |
| 1650 | ); |
| 1651 | // The OS wrappers below only cover child processes. Codewhale's own |
| 1652 | // `read_file`/`read`/`read_media` tools read in-process, so the same |
| 1653 | // deny-list is installed process-wide for them to consult (S1). |
| 1654 | crate::sandbox::read_guard::set_active(config.read_denylist.clone()); |
| 1655 | let shell_manager = config |
| 1656 | .runtime_services |
| 1657 | .shell_manager |
| 1658 | .clone() |
| 1659 | .unwrap_or_else(|| new_shared_shell_manager(config.workspace.clone())); |
| 1660 | match shell_manager.lock() { |
| 1661 | Ok(mut manager) => { |
| 1662 | manager.set_prefer_bwrap(config.prefer_bwrap); |
| 1663 | manager.set_bwrap_extensions(config.bwrap_extensions.clone()); |
| 1664 | manager.set_denied_read_subpaths(config.read_denylist.subtree_paths()); |
| 1665 | } |
| 1666 | Err(poisoned) => { |
| 1667 | let mut manager = poisoned.into_inner(); |
| 1668 | manager.set_prefer_bwrap(config.prefer_bwrap); |
| 1669 | manager.set_bwrap_extensions(config.bwrap_extensions.clone()); |
| 1670 | manager.set_denied_read_subpaths(config.read_denylist.subtree_paths()); |
| 1671 | } |
| 1672 | } |
| 1673 | let file_read_tracker = new_shared_file_read_tracker(); |
| 1674 | let lsp_manager = Arc::new(match config.lsp_config.clone() { |
| 1675 | Some(cfg) => crate::lsp::LspManager::new(cfg, config.workspace.clone()), |
| 1676 | None => crate::lsp::LspManager::disabled(), |
| 1677 | }); |
| 1678 | |
| 1679 | // External sandbox backend (#516). Logged but non-fatal: if the |
| 1680 | // backend fails to construct, the engine continues with local |
| 1681 | // execution as the fallback. |
| 1682 | let sandbox_backend = crate::sandbox::backend::create_backend(api_config) |
| 1683 | .unwrap_or_else(|e| { |
| 1684 | tracing::warn!("Failed to create sandbox backend: {e}"); |
| 1685 | None |
| 1686 | }) |
| 1687 | .map(std::sync::Arc::from); |
| 1688 | let sandbox_enforcement = if sandbox_backend.is_some() { |
| 1689 | crate::sandbox::policy::SandboxEnforcement::ExternalBackend |
| 1690 | } else if crate::sandbox::get_platform_sandbox_with_bwrap_preference(config.prefer_bwrap) |
| 1691 | .is_some() |
| 1692 | { |
| 1693 | crate::sandbox::policy::SandboxEnforcement::LocalOs |
| 1694 | } else { |
| 1695 | crate::sandbox::policy::SandboxEnforcement::Unavailable |
| 1696 | }; |
| 1697 | |
| 1698 | let active_route_limits = config.active_route_limits; |
| 1699 | let shared_auto_review_policy = Arc::new(config.auto_review_policy.clone()); |
| 1700 | #[cfg(not(test))] |
| 1701 | let approval_receipt_store = |
| 1702 | ApprovalReceiptStore::default_location().map_err(|err| err.to_string()); |
| 1703 | #[cfg(test)] |
| 1704 | let approval_receipt_store = Ok(ApprovalReceiptStore::new( |
| 1705 | std::env::temp_dir().join(format!("codewhale-approval-tests-{}", uuid::Uuid::new_v4())), |
| 1706 | )); |
| 1707 | // R1: seed the wall clock from the config the engine is built with. |
| 1708 | // `run_turn` restarts it per turn; this initial value only matters |
| 1709 | // for hosts that inspect the engine before the first turn. |
| 1710 | let turn_wall_clock_budget = config.turn_wall_clock; |
| 1711 | let engine = Engine { |
| 1712 | config, |
| 1713 | api_config: api_config.clone(), |
| 1714 | authoritative_route_config: None, |
| 1715 | codewhale_client, |
| 1716 | model_client, |
| 1717 | model_client_injected: false, |
| 1718 | codewhale_client_error, |
| 1719 | api_key_env_only_recovery, |
| 1720 | session, |
| 1721 | repl_kernel: None, |
| 1722 | subagent_manager, |
| 1723 | shared_auto_review_policy, |
| 1724 | shell_manager, |
| 1725 | file_read_tracker, |
| 1726 | mcp_pool: None, |
| 1727 | turn_tool_surface_budget: None, |
| 1728 | mcp_connection_errors: HashMap::new(), |
| 1729 | mcp_boot_in_flight: false, |
| 1730 | mcp_boot_rx: None, |
| 1731 | mcp_supervisor_rx: None, |
| 1732 | mcp_boot_done: None, |
| 1733 | mcp_boot_generation: None, |
| 1734 | mcp_event_generation: 0, |
| 1735 | plugin_registry, |
| 1736 | recommended_plugin_gate: StdMutex::new( |
| 1737 | crate::plugins::recommend::RecommendedPluginGate::default(), |
| 1738 | ), |
| 1739 | api_provider, |
| 1740 | api_provider_identity, |
| 1741 | api_provider_id, |
| 1742 | active_route_limits, |
| 1743 | active_route_capabilities: codewhale_config::route::RouteCapabilities::default(), |
| 1744 | rx_op, |
| 1745 | live_runtime_authority: Arc::clone(&live_runtime_authority), |
| 1746 | compaction_cancellation: Arc::clone(&compaction_cancellation), |
| 1747 | tx_op: tx_op.clone(), |
| 1748 | scheduled_goal_continuation: None, |
| 1749 | goal_continuation_schedule_seq: 0, |
| 1750 | rx_approval, |
| 1751 | approval_receipt_store, |
| 1752 | rx_user_input, |
| 1753 | rx_steer, |
| 1754 | turn_controls: Arc::clone(&turn_controls), |
| 1755 | admitted_turn_control: None, |
| 1756 | tx_event, |
| 1757 | tx_subagent_completion, |
| 1758 | rx_subagent_completion, |
| 1759 | delivered_subagent_completion_ids: HashSet::new(), |
| 1760 | cancel_token: cancel_token.clone(), |
| 1761 | shared_cancel_token: shared_cancel_token.clone(), |
| 1762 | cancel_reason: cancel_reason.clone(), |
| 1763 | tool_exec_lock, |
| 1764 | turn_counter: 0, |
| 1765 | lsp_manager, |
| 1766 | pending_lsp_blocks: Vec::new(), |
| 1767 | sandbox_backend, |
| 1768 | sandbox_enforcement, |
| 1769 | current_mode: AppMode::Agent, |
| 1770 | turn_wall_clock: turn_budget::TurnWallClock::start(turn_wall_clock_budget), |
| 1771 | last_policy_narrowing: None, |
| 1772 | last_turn_meta_git_snapshot: StdMutex::new(None), |
| 1773 | token_estimate_cache: TokenEstimateCache::new(), |
| 1774 | shared_paused: shared_paused.clone(), |
| 1775 | advisor_emission_guard: None, |
| 1776 | }; |
| 1777 | let handle = EngineHandle { |
| 1778 | goal_state: engine.config.goal_state.clone(), |
| 1779 | tx_op, |
| 1780 | rx_event: Arc::new(RwLock::new(rx_event)), |
| 1781 | cancel_token: shared_cancel_token, |
| 1782 | cancel_reason, |
| 1783 | tx_approval, |
| 1784 | tx_user_input, |
| 1785 | tx_steer, |
| 1786 | turn_controls, |
| 1787 | shared_paused, |
| 1788 | client_preflight_required: true, |
| 1789 | live_runtime_authority, |
| 1790 | compaction_cancellation, |
| 1791 | }; |
| 1792 | |
| 1793 | (engine, handle) |
| 1794 | } |
| 1795 | |
| 1796 | /// Construct the real Engine with an injected provider-neutral model |
| 1797 | /// client. The event loop, prompt assembly, tool registry/execution, |
| 1798 | /// cancellation, and session projection are unchanged; only the model I/O |
| 1799 | /// boundary is replaced. |
| 1800 | #[allow(dead_code)] // Production injection seam; currently exercised by deterministic Engine tests. |
| 1801 | pub fn new_with_model_client( |
| 1802 | config: EngineConfig, |
| 1803 | api_config: &Config, |
| 1804 | client: SharedModelClient, |
| 1805 | ) -> (Self, EngineHandle) { |
| 1806 | let (mut engine, mut handle) = Self::new(config, api_config); |
| 1807 | engine.model_client = Some(client); |
| 1808 | engine.model_client_injected = true; |
| 1809 | engine.codewhale_client_error = None; |
| 1810 | handle.client_preflight_required = false; |
| 1811 | (engine, handle) |
| 1812 | } |
| 1813 | |
| 1814 | async fn handle_run_shell_command( |
| 1815 | &mut self, |
| 1816 | command: String, |
| 1817 | mode: AppMode, |
| 1818 | allow_shell: bool, |
| 1819 | trust_mode: bool, |
| 1820 | auto_approve: bool, |
| 1821 | approval_mode: ApprovalMode, |
| 1822 | ) { |
| 1823 | let turn_control = self.begin_turn_control(); |
| 1824 | self.turn_counter = self.turn_counter.saturating_add(1); |
| 1825 | |
| 1826 | let turn_id = format!( |
| 1827 | "{}{seq}", |
| 1828 | USER_SHELL_TOOL_ID_PREFIX, |
| 1829 | seq = self.turn_counter |
| 1830 | ); |
| 1831 | let tool_id = turn_id.clone(); |
| 1832 | let tool_name = "Bash".to_string(); |
| 1833 | let tool_input = json!({ "action": "run", "command": command, "source": "user" }); |
| 1834 | let snapshot_prompt = tool_input["command"] |
| 1835 | .as_str() |
| 1836 | .unwrap_or_default() |
| 1837 | .to_string(); |
| 1838 | |
| 1839 | let authority = TurnAuthority::from_effective_fields( |
| 1840 | mode, |
| 1841 | allow_shell, |
| 1842 | trust_mode, |
| 1843 | auto_approve, |
| 1844 | approval_mode, |
| 1845 | ); |
| 1846 | self.apply_runtime_mode_policy(&authority); |
| 1847 | |
| 1848 | let _ = self |
| 1849 | .tx_event |
| 1850 | .send(Event::TurnStarted { |
| 1851 | turn_id: turn_id.clone(), |
| 1852 | created_at: chrono::Utc::now(), |
| 1853 | route: None, |
| 1854 | }) |
| 1855 | .await; |
| 1856 | |
| 1857 | if self.config.snapshots_enabled { |
| 1858 | let pre_workspace = self.session.workspace.clone(); |
| 1859 | let pre_seq = self.turn_counter; |
| 1860 | let pre_cap = self.config.snapshots_max_workspace_bytes; |
| 1861 | let pre_prompt = snapshot_prompt.clone(); |
| 1862 | let pre_sid = self.session.id.clone(); |
| 1863 | let _ = tokio::task::spawn_blocking(move || { |
| 1864 | pre_turn_snapshot( |
| 1865 | &pre_workspace, |
| 1866 | pre_seq, |
| 1867 | pre_cap, |
| 1868 | Some(&pre_prompt), |
| 1869 | Some(&pre_sid), |
| 1870 | ) |
| 1871 | }) |
| 1872 | .await; |
| 1873 | } |
| 1874 | |
| 1875 | self.emit_pending_snapshot_notices().await; |
| 1876 | |
| 1877 | let _ = self |
| 1878 | .tx_event |
| 1879 | .send(Event::ToolCallStarted { |
| 1880 | id: tool_id.clone(), |
| 1881 | name: tool_name.clone(), |
| 1882 | input: tool_input.clone(), |
| 1883 | }) |
| 1884 | .await; |
| 1885 | |
| 1886 | let tool_context = self.build_tool_context(mode, auto_approve); |
| 1887 | let registry = ToolRegistryBuilder::new() |
| 1888 | .with_shell_tools() |
| 1889 | .build(tool_context); |
| 1890 | |
| 1891 | let result = if mode == AppMode::Plan { |
| 1892 | Err(ToolError::permission_denied( |
| 1893 | "Tool 'bash' is unavailable in Plan mode".to_string(), |
| 1894 | )) |
| 1895 | } else if !self.config.features.enabled(Feature::ShellTool) { |
| 1896 | Err(ToolError::not_available( |
| 1897 | "Tool 'bash' is disabled by feature flag".to_string(), |
| 1898 | )) |
| 1899 | } else if let Some(spec) = registry.get(&tool_name) { |
| 1900 | // #5191: the human typed this command — typing it IS the approval. |
| 1901 | // The tool-approval modal gates model-provenance calls; applying it |
| 1902 | // to a user-typed `!` command asks the user to re-approve what they |
| 1903 | // just typed. Typed exec ask-rules still apply as hard Block |
| 1904 | // denies, and the sandbox/execpolicy layer stays the real safety |
| 1905 | // boundary. Model-issued shell calls keep the standard approval |
| 1906 | // path; this branch is strictly composer provenance. |
| 1907 | let ask_rule_decision = exec_shell_ask_rule_decision( |
| 1908 | &self.config, |
| 1909 | &tool_name, |
| 1910 | &tool_input, |
| 1911 | &self.session.workspace, |
| 1912 | self.session.approval_mode, |
| 1913 | ); |
| 1914 | if let Some(ToolAskRuleDecision::Block(reason)) = ask_rule_decision { |
| 1915 | Err(ToolError::permission_denied(reason)) |
| 1916 | } else { |
| 1917 | emit_tool_audit(json!({ |
| 1918 | "event": "tool.user_provenance_preapproved", |
| 1919 | "tool_id": tool_id.clone(), |
| 1920 | "tool_name": tool_name.clone(), |
| 1921 | "source": "composer_bang", |
| 1922 | })); |
| 1923 | Self::execute_tool_with_lock( |
| 1924 | self.tool_exec_lock.clone(), |
| 1925 | spec.supports_parallel(), |
| 1926 | false, |
| 1927 | self.tx_event.clone(), |
| 1928 | Some(self.cancel_token.clone()), |
| 1929 | tool_name.clone(), |
| 1930 | tool_input.clone(), |
| 1931 | self.session.workspace.clone(), |
| 1932 | Some(®istry), |
| 1933 | None, |
| 1934 | None, |
| 1935 | ) |
| 1936 | .await |
| 1937 | .map(RichToolResult::into_result) |
| 1938 | } |
| 1939 | } else { |
| 1940 | Err(ToolError::not_available( |
| 1941 | "tool 'Bash' is not registered".to_string(), |
| 1942 | )) |
| 1943 | }; |
| 1944 | |
| 1945 | let mut result = result; |
| 1946 | if let Ok(tool_result) = result.as_mut() |
| 1947 | && let Some(path) = crate::tools::truncate::apply_spillover_with_artifact( |
| 1948 | tool_result, |
| 1949 | &tool_id, |
| 1950 | &tool_name, |
| 1951 | &self.session.id, |
| 1952 | ) |
| 1953 | { |
| 1954 | emit_tool_audit(json!({ |
| 1955 | "event": "tool.spillover", |
| 1956 | "tool_id": tool_id.clone(), |
| 1957 | "tool_name": tool_name.clone(), |
| 1958 | "path": path.display().to_string(), |
| 1959 | "source": "composer_bang", |
| 1960 | })); |
| 1961 | } |
| 1962 | |
| 1963 | let status = user_shell_turn_outcome(&result, self.cancel_token.is_cancelled()); |
| 1964 | let error = result.as_ref().err().map(ToString::to_string); |
| 1965 | |
| 1966 | let _ = self |
| 1967 | .tx_event |
| 1968 | .send(Event::ToolCallComplete { |
| 1969 | id: tool_id, |
| 1970 | name: tool_name, |
| 1971 | result, |
| 1972 | }) |
| 1973 | .await; |
| 1974 | |
| 1975 | if status == TurnOutcomeStatus::Interrupted { |
| 1976 | self.emit_interrupted_survivor_status().await; |
| 1977 | } |
| 1978 | drop(turn_control); |
| 1979 | let _ = self |
| 1980 | .tx_event |
| 1981 | .send(Event::TurnComplete { |
| 1982 | usage: Usage::default(), |
| 1983 | parent_route_usage: Usage::default(), |
| 1984 | routed_usage_dropped_records: 0, |
| 1985 | status, |
| 1986 | error, |
| 1987 | tool_catalog: None, |
| 1988 | base_url: None, |
| 1989 | }) |
| 1990 | .await; |
| 1991 | |
| 1992 | if self.config.snapshots_enabled { |
| 1993 | let post_workspace = self.session.workspace.clone(); |
| 1994 | let post_seq = self.turn_counter; |
| 1995 | let post_cap = self.config.snapshots_max_workspace_bytes; |
| 1996 | let post_sid = self.session.id.clone(); |
| 1997 | crate::utils::spawn_blocking_supervised("post-shell-turn-snapshot", move || { |
| 1998 | post_turn_snapshot( |
| 1999 | &post_workspace, |
| 2000 | post_seq, |
| 2001 | post_cap, |
| 2002 | Some(&snapshot_prompt), |
| 2003 | Some(&post_sid), |
| 2004 | ); |
| 2005 | }); |
| 2006 | } |
| 2007 | } |
| 2008 | |
| 2009 | /// Apply a user/host mode-or-posture change to the live session. |
| 2010 | /// |
| 2011 | /// Single authority source for mode/permission state: both the run loop |
| 2012 | /// and the active turn's typed live-authority drain land here. |
| 2013 | async fn apply_change_mode( |
| 2014 | &mut self, |
| 2015 | mode: AppMode, |
| 2016 | allow_shell: bool, |
| 2017 | trust_mode: bool, |
| 2018 | auto_approve: bool, |
| 2019 | approval_mode: ApprovalMode, |
| 2020 | configured_sandbox_mode: Option<String>, |
| 2021 | ) { |
| 2022 | let authority = TurnAuthority::from_effective_fields( |
| 2023 | mode, |
| 2024 | allow_shell, |
| 2025 | trust_mode, |
| 2026 | auto_approve, |
| 2027 | approval_mode, |
| 2028 | ); |
| 2029 | let effective_approval = authority.approval_mode_for_session(); |
| 2030 | let changed = self.current_mode != authority.mode |
| 2031 | || self.session.allow_shell != authority.allow_shell |
| 2032 | || self.session.trust_mode != authority.trust_mode |
| 2033 | || self.session.auto_approve |
| 2034 | != (authority.auto_approve || effective_approval == ApprovalMode::Bypass) |
| 2035 | || self.session.approval_mode != effective_approval |
| 2036 | || self.api_config.sandbox_mode != configured_sandbox_mode; |
| 2037 | self.api_config.sandbox_mode = configured_sandbox_mode; |
| 2038 | self.apply_runtime_mode_policy(&authority); |
| 2039 | if !changed { |
| 2040 | return; |
| 2041 | } |
| 2042 | self.emit_session_updated().await; |
| 2043 | let _ = self |
| 2044 | .tx_event |
| 2045 | .send(Event::status(format!( |
| 2046 | // Payload first, and short enough for the posture bar's right |
| 2047 | // slot. "Runtime policy changed to: X / Y" sheds at the colon — |
| 2048 | // the bar's notice shedder cuts at clause joints and keeps the |
| 2049 | // head — so the user read "Runtime policy changed to" with the |
| 2050 | // policy itself gone, which is the one word the notice exists |
| 2051 | // to carry. |
| 2052 | "Policy: {} / {}", |
| 2053 | effective_approval.permission_chip_label(), |
| 2054 | mode.label(), |
| 2055 | ))) |
| 2056 | .await; |
| 2057 | } |
| 2058 | |
| 2059 | fn take_pending_runtime_authority(&self) -> Option<LiveRuntimeAuthority> { |
| 2060 | let mut state = self |
| 2061 | .live_runtime_authority |
| 2062 | .lock() |
| 2063 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 2064 | if state.applied_revision == state.revision { |
| 2065 | return None; |
| 2066 | } |
| 2067 | state.applied_revision = state.revision; |
| 2068 | Some(state.authority.clone()) |
| 2069 | } |
| 2070 | |
| 2071 | fn runtime_authority_snapshot(&self) -> LiveRuntimeAuthority { |
| 2072 | self.live_runtime_authority |
| 2073 | .lock() |
| 2074 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 2075 | .authority |
| 2076 | .clone() |
| 2077 | } |
| 2078 | |
| 2079 | async fn apply_runtime_authority(&mut self, authority: LiveRuntimeAuthority) { |
| 2080 | self.apply_change_mode( |
| 2081 | authority.mode, |
| 2082 | authority.allow_shell, |
| 2083 | authority.trust_mode, |
| 2084 | authority.auto_approve, |
| 2085 | authority.approval_mode, |
| 2086 | authority.configured_sandbox_mode, |
| 2087 | ) |
| 2088 | .await; |
| 2089 | } |
| 2090 | |
| 2091 | async fn apply_pending_runtime_authority(&mut self) -> bool { |
| 2092 | let Some(authority) = self.take_pending_runtime_authority() else { |
| 2093 | return false; |
| 2094 | }; |
| 2095 | self.apply_runtime_authority(authority).await; |
| 2096 | true |
| 2097 | } |
| 2098 | |
| 2099 | fn record_applied_runtime_authority(&self, authority: &TurnAuthority) { |
| 2100 | let applied = LiveRuntimeAuthority::from_turn_authority( |
| 2101 | authority, |
| 2102 | self.api_config.sandbox_mode.clone(), |
| 2103 | ); |
| 2104 | let mut state = self |
| 2105 | .live_runtime_authority |
| 2106 | .lock() |
| 2107 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 2108 | // Never overwrite a newer, not-yet-applied user change with the turn |
| 2109 | // posture that preceded it. |
| 2110 | if state.revision == state.applied_revision || state.authority == applied { |
| 2111 | state.authority = applied; |
| 2112 | state.applied_revision = state.revision; |
| 2113 | } |
| 2114 | } |
| 2115 | |
| 2116 | fn apply_runtime_mode_policy(&mut self, authority: &TurnAuthority) { |
| 2117 | // Prompt composition is mode-agnostic. Keep the hash-guarded refresh |
| 2118 | // because embedders may still derive custom prompt bytes from session |
| 2119 | // context; bundled prompts remain byte-identical across modes. |
| 2120 | let mode_changed = self.current_mode != authority.mode; |
| 2121 | self.current_mode = authority.mode; |
| 2122 | if mode_changed { |
| 2123 | self.refresh_system_prompt_with_reason("mode"); |
| 2124 | } |
| 2125 | self.session.allow_shell = authority.allow_shell; |
| 2126 | self.config.allow_shell = authority.allow_shell; |
| 2127 | self.session.trust_mode = authority.trust_mode; |
| 2128 | self.config.trust_mode = authority.trust_mode; |
| 2129 | self.session.approval_mode = authority.approval_mode_for_session(); |
| 2130 | self.session.auto_approve = |
| 2131 | authority.auto_approve || self.session.approval_mode == ApprovalMode::Bypass; |
| 2132 | self.record_applied_runtime_authority(authority); |
| 2133 | } |
| 2134 | |
| 2135 | async fn schedule_goal_continuation(&mut self, dynamic_tools: Vec<DynamicToolSpec>) { |
| 2136 | let delay_seconds = self.config.goal_continuation_delay_seconds; |
| 2137 | let ready_at = |
| 2138 | (delay_seconds > 0).then(|| Instant::now() + Duration::from_secs(delay_seconds)); |
| 2139 | if self.scheduled_goal_continuation.is_some() { |
| 2140 | let should_announce = { |
| 2141 | let scheduled = self |
| 2142 | .scheduled_goal_continuation |
| 2143 | .as_mut() |
| 2144 | .expect("scheduled continuation checked above"); |
| 2145 | // A normal user turn or idle child handoff can finish while |
| 2146 | // the prior synthetic token is already queued. Refresh that |
| 2147 | // one token instead of multiplying autonomous turns and spend. |
| 2148 | scheduled.dynamic_tools = dynamic_tools; |
| 2149 | if !scheduled.enqueued { |
| 2150 | scheduled.ready_at = ready_at; |
| 2151 | } |
| 2152 | delay_seconds > 0 && !scheduled.enqueued |
| 2153 | }; |
| 2154 | self.try_flush_pending_goal_continuation(); |
| 2155 | if should_announce { |
| 2156 | let _ = self |
| 2157 | .tx_event |
| 2158 | .send(Event::GoalContinuationWaiting { delay_seconds }) |
| 2159 | .await; |
| 2160 | } |
| 2161 | return; |
| 2162 | } |
| 2163 | |
| 2164 | self.goal_continuation_schedule_seq = |
| 2165 | self.goal_continuation_schedule_seq.wrapping_add(1).max(1); |
| 2166 | self.scheduled_goal_continuation = Some(ScheduledGoalContinuation { |
| 2167 | id: self.goal_continuation_schedule_seq, |
| 2168 | dynamic_tools, |
| 2169 | enqueued: false, |
| 2170 | ready_at, |
| 2171 | was_delayed: delay_seconds > 0, |
| 2172 | }); |
| 2173 | self.try_flush_pending_goal_continuation(); |
| 2174 | if delay_seconds > 0 { |
| 2175 | let _ = self |
| 2176 | .tx_event |
| 2177 | .send(Event::GoalContinuationWaiting { delay_seconds }) |
| 2178 | .await; |
| 2179 | } |
| 2180 | } |
| 2181 | |
| 2182 | async fn cancel_scheduled_goal_continuation(&mut self, interrupted: bool) { |
| 2183 | if let Some(scheduled) = self.scheduled_goal_continuation.take() { |
| 2184 | tracing::debug!( |
| 2185 | "cancelled an outstanding goal continuation after a non-completed turn" |
| 2186 | ); |
| 2187 | if scheduled.was_delayed { |
| 2188 | let _ = self |
| 2189 | .tx_event |
| 2190 | .send(Event::GoalContinuationWaitEnded { interrupted }) |
| 2191 | .await; |
| 2192 | } |
| 2193 | } |
| 2194 | } |
| 2195 | |
| 2196 | fn take_scheduled_goal_continuation( |
| 2197 | &mut self, |
| 2198 | engine_schedule_id: Option<u64>, |
| 2199 | direct_dynamic_tools: Vec<DynamicToolSpec>, |
| 2200 | ) -> Option<Vec<DynamicToolSpec>> { |
| 2201 | let Some(schedule_id) = engine_schedule_id else { |
| 2202 | return Some(direct_dynamic_tools); |
| 2203 | }; |
| 2204 | let Some(scheduled) = self.scheduled_goal_continuation.take() else { |
| 2205 | tracing::warn!( |
| 2206 | schedule_id, |
| 2207 | "discarding stale engine-owned goal continuation token" |
| 2208 | ); |
| 2209 | return None; |
| 2210 | }; |
| 2211 | if scheduled.id != schedule_id { |
| 2212 | tracing::warn!( |
| 2213 | schedule_id, |
| 2214 | current_schedule_id = scheduled.id, |
| 2215 | "discarding superseded engine-owned goal continuation token" |
| 2216 | ); |
| 2217 | self.scheduled_goal_continuation = Some(scheduled); |
| 2218 | return None; |
| 2219 | } |
| 2220 | |
| 2221 | // Clear before executing the synthetic turn. A successful execution |
| 2222 | // may now schedule exactly one replacement; inactive/failed turns do |
| 2223 | // not leave a phantom outstanding marker behind. |
| 2224 | Some(scheduled.dynamic_tools) |
| 2225 | } |
| 2226 | |
| 2227 | fn has_scheduled_goal_continuation(&self) -> bool { |
| 2228 | self.scheduled_goal_continuation.is_some() |
| 2229 | } |
| 2230 | |
| 2231 | /// Install the conversation identity portion of `SyncSession` and clear |
| 2232 | /// process-local capabilities that must never cross that boundary. The |
| 2233 | /// returned id is the conversation being closed; callers use it to scope |
| 2234 | /// asynchronous fleet finalization before loading the new history. |
| 2235 | /// Conversation id this engine persists and reports in `SessionUpdated`. |
| 2236 | /// Test-only observation point for the host/engine id contract. |
| 2237 | #[cfg(test)] |
| 2238 | pub(crate) fn session_id(&self) -> &str { |
| 2239 | &self.session.id |
| 2240 | } |
| 2241 | |
| 2242 | fn install_synced_session_id(&mut self, next_session_id: String) -> Option<String> { |
| 2243 | let previous_session_id = self.session.id.clone(); |
| 2244 | if next_session_id == previous_session_id { |
| 2245 | return None; |
| 2246 | } |
| 2247 | // A synthetic token may already be queued in `rx_op`; dropping the |
| 2248 | // authoritative schedule makes that token fail closed when drained. |
| 2249 | self.scheduled_goal_continuation = None; |
| 2250 | // Runtime-added MCP servers are conversation capabilities even when |
| 2251 | // both conversations use the same workspace. Configured servers can |
| 2252 | // reconnect lazily after the new session is installed. |
| 2253 | self.mcp_pool = None; |
| 2254 | self.session.id = next_session_id; |
| 2255 | Some(previous_session_id) |
| 2256 | } |
| 2257 | |
| 2258 | fn bounded_redacted_goal_failure_detail(&self, detail: &str) -> Option<String> { |
| 2259 | let detail = detail.trim(); |
| 2260 | if detail.is_empty() { |
| 2261 | return None; |
| 2262 | } |
| 2263 | // This message becomes durable goal state. Reuse the model boundary's |
| 2264 | // exact configured-secret redactor when available; that helper also |
| 2265 | // applies the config persistence redactor as a universal backstop. |
| 2266 | let detail = self.codewhale_client.as_ref().map_or_else( |
| 2267 | || codewhale_config::persistence::redact_secrets(detail), |
| 2268 | |client| client.redact_model_bound_text(detail), |
| 2269 | ); |
| 2270 | Some(crate::utils::truncate_with_ellipsis( |
| 2271 | &detail, |
| 2272 | GOAL_CONTINUATION_FAILURE_DETAIL_MAX_BYTES, |
| 2273 | "…", |
| 2274 | )) |
| 2275 | } |
| 2276 | |
| 2277 | fn goal_continuation_failure_message(&self, error: Option<&str>) -> String { |
| 2278 | self.bounded_redacted_goal_failure_detail(error.unwrap_or_default()).map_or_else( |
| 2279 | || { |
| 2280 | "Goal continuation blocked because the model turn failed without a provider reason. Fix the provider route or credentials, then resume the goal." |
| 2281 | .to_string() |
| 2282 | }, |
| 2283 | |detail| { |
| 2284 | format!( |
| 2285 | "Goal continuation blocked because the model turn failed: {detail}. Fix the failure, then resume the goal." |
| 2286 | ) |
| 2287 | }, |
| 2288 | ) |
| 2289 | } |
| 2290 | |
| 2291 | fn goal_turn_not_started_message(&self, error: Option<&str>) -> String { |
| 2292 | self.bounded_redacted_goal_failure_detail(error.unwrap_or_default()).map_or_else( |
| 2293 | || { |
| 2294 | "Goal continuation blocked because the next model turn could not be started. Fix the provider route or credentials, then resume the goal." |
| 2295 | .to_string() |
| 2296 | }, |
| 2297 | |detail| { |
| 2298 | format!( |
| 2299 | "Goal continuation blocked because the next model turn could not be started: {detail}. Fix the provider route or credentials, then resume the goal." |
| 2300 | ) |
| 2301 | }, |
| 2302 | ) |
| 2303 | } |
| 2304 | |
| 2305 | fn try_flush_pending_goal_continuation(&mut self) { |
| 2306 | let Some(scheduled) = self.scheduled_goal_continuation.as_ref() else { |
| 2307 | return; |
| 2308 | }; |
| 2309 | if scheduled.enqueued { |
| 2310 | return; |
| 2311 | } |
| 2312 | if scheduled.ready_at.is_some() { |
| 2313 | return; |
| 2314 | } |
| 2315 | let schedule_id = scheduled.id; |
| 2316 | |
| 2317 | match self.tx_op.try_send(Op::ContinueGoal { |
| 2318 | // The authoritative set stays in `scheduled_goal_continuation` so |
| 2319 | // later completed turns can refresh it without moving this token. |
| 2320 | dynamic_tools: Vec::new(), |
| 2321 | engine_schedule_id: Some(schedule_id), |
| 2322 | }) { |
| 2323 | Ok(()) => { |
| 2324 | if let Some(scheduled) = self.scheduled_goal_continuation.as_mut() |
| 2325 | && scheduled.id == schedule_id |
| 2326 | { |
| 2327 | scheduled.enqueued = true; |
| 2328 | } |
| 2329 | } |
| 2330 | Err(mpsc::error::TrySendError::Closed(_)) => { |
| 2331 | tracing::warn!("goal continuation dropped because the engine mailbox is closed"); |
| 2332 | if self |
| 2333 | .scheduled_goal_continuation |
| 2334 | .as_ref() |
| 2335 | .is_some_and(|scheduled| scheduled.id == schedule_id) |
| 2336 | { |
| 2337 | self.scheduled_goal_continuation = None; |
| 2338 | } |
| 2339 | } |
| 2340 | Err(mpsc::error::TrySendError::Full(_)) => {} |
| 2341 | } |
| 2342 | } |
| 2343 | |
| 2344 | async fn next_run_input(&mut self, host_managed_turns: bool) -> Option<EngineRunInput> { |
| 2345 | loop { |
| 2346 | // A full mailbox means queued controls must run first. Retrying at |
| 2347 | // the top of each receive appends the continuation behind the |
| 2348 | // remaining controls as soon as one slot becomes available. |
| 2349 | self.try_flush_pending_goal_continuation(); |
| 2350 | if self.has_scheduled_goal_continuation() { |
| 2351 | let (enqueued, ready_at) = self |
| 2352 | .scheduled_goal_continuation |
| 2353 | .as_ref() |
| 2354 | .map(|scheduled| (scheduled.enqueued, scheduled.ready_at)) |
| 2355 | .expect("scheduled continuation checked above"); |
| 2356 | if enqueued { |
| 2357 | // The synthetic token sits behind every operation that was |
| 2358 | // already queued when it was scheduled. Drain FIFO through |
| 2359 | // that token before accepting an idle child completion. |
| 2360 | return self |
| 2361 | .rx_op |
| 2362 | .recv() |
| 2363 | .await |
| 2364 | .map(|op| EngineRunInput::Operation(Box::new(op))); |
| 2365 | } |
| 2366 | |
| 2367 | if let Some(ready_at) = ready_at { |
| 2368 | let cancel = self.cancel_token.clone(); |
| 2369 | tokio::select! { |
| 2370 | biased; |
| 2371 | () = cancel.cancelled() => { |
| 2372 | self.cancel_scheduled_goal_continuation(true).await; |
| 2373 | continue; |
| 2374 | } |
| 2375 | // Goal status controls and ordinary user messages stay |
| 2376 | // responsive throughout the wait. A pause/clear action |
| 2377 | // cancels this exact record in its normal handler. |
| 2378 | op = self.rx_op.recv() => { |
| 2379 | return op.map(|op| EngineRunInput::Operation(Box::new(op))); |
| 2380 | } |
| 2381 | () = tokio::time::sleep(ready_at.saturating_duration_since(Instant::now())) => { |
| 2382 | if let Some(scheduled) = self.scheduled_goal_continuation.as_mut() |
| 2383 | && scheduled.ready_at == Some(ready_at) |
| 2384 | { |
| 2385 | scheduled.ready_at = None; |
| 2386 | let _ = self.tx_event.send(Event::GoalContinuationWaitEnded { |
| 2387 | interrupted: false, |
| 2388 | }).await; |
| 2389 | } |
| 2390 | continue; |
| 2391 | } |
| 2392 | } |
| 2393 | } |
| 2394 | |
| 2395 | // A record that is ready but could not enter the full mailbox |
| 2396 | // waits for one queued control. The next loop pass retries the |
| 2397 | // same coalesced token, so there is no spin or duplicate turn. |
| 2398 | return self |
| 2399 | .rx_op |
| 2400 | .recv() |
| 2401 | .await |
| 2402 | .map(|op| EngineRunInput::Operation(Box::new(op))); |
| 2403 | } else { |
| 2404 | let subagent_wake_armed = !host_managed_turns && !self.cancel_token.is_cancelled(); |
| 2405 | let shell_wake_armed = !host_managed_turns && self.idle_shell_wake_armed(); |
| 2406 | let mcp_boot_armed = self.mcp_boot_rx.is_some(); |
| 2407 | let mcp_supervisor_armed = self.mcp_supervisor_rx.is_some(); |
| 2408 | tokio::select! { |
| 2409 | op = self.rx_op.recv() => { |
| 2410 | return op.map(|op| EngineRunInput::Operation(Box::new(op))); |
| 2411 | } |
| 2412 | completion = self.rx_subagent_completion.recv(), if subagent_wake_armed => { |
| 2413 | return completion.map(EngineRunInput::SubAgentCompletion); |
| 2414 | } |
| 2415 | // A background child may be waiting on a person's answer |
| 2416 | // while the parent turn is idle: route it. Any other |
| 2417 | // decision has no waiter and is dropped, as before. |
| 2418 | decision = self.rx_approval.recv() => { |
| 2419 | if let Some(decision) = decision { |
| 2420 | self.route_child_approval_decision(decision).await; |
| 2421 | } |
| 2422 | } |
| 2423 | update = async { |
| 2424 | match self.mcp_boot_rx.as_mut() { |
| 2425 | Some(rx) => rx.recv().await, |
| 2426 | None => None, |
| 2427 | } |
| 2428 | }, if mcp_boot_armed => { |
| 2429 | match update { |
| 2430 | Some(update) => return Some(EngineRunInput::McpBootUpdate(update)), |
| 2431 | None => self.mcp_boot_rx = None, |
| 2432 | } |
| 2433 | } |
| 2434 | update = async { |
| 2435 | match self.mcp_supervisor_rx.as_mut() { |
| 2436 | Some(rx) => rx.recv().await, |
| 2437 | None => None, |
| 2438 | } |
| 2439 | }, if mcp_supervisor_armed => { |
| 2440 | match update { |
| 2441 | Some(update) => { |
| 2442 | return Some(EngineRunInput::McpSupervisorUpdate(update)) |
| 2443 | } |
| 2444 | // Task exited with the pool; the next pool |
| 2445 | // ensure respawns against the new one. |
| 2446 | None => self.mcp_supervisor_rx = None, |
| 2447 | } |
| 2448 | } |
| 2449 | // Background shells have no completion channel, so an |
| 2450 | // idle engine polls while background work is outstanding, |
| 2451 | // unless the person interrupted the owning turn. |
| 2452 | () = tokio::time::sleep(Duration::from_millis(SHELL_WAKE_POLL_MS)), if shell_wake_armed => { |
| 2453 | if self.finished_background_shell_pending() { |
| 2454 | return Some(EngineRunInput::ShellCompletionWake); |
| 2455 | } |
| 2456 | } |
| 2457 | } |
| 2458 | } |
| 2459 | } |
| 2460 | } |
| 2461 | |
| 2462 | /// Deliver an approval decision to a child waiting on it. Returns whether |
| 2463 | /// a child took it; the parent's own awaiting call keeps every other id. |
| 2464 | async fn route_child_approval_decision( |
| 2465 | &self, |
| 2466 | decision: super::engine::approval::ApprovalDecision, |
| 2467 | ) -> bool { |
| 2468 | use crate::tools::subagent::{ChildApprovalOutcome, SubAgentManager}; |
| 2469 | let (id, outcome) = match &decision { |
| 2470 | super::engine::approval::ApprovalDecision::Approved { id } => { |
| 2471 | (id.clone(), ChildApprovalOutcome::Approved) |
| 2472 | } |
| 2473 | super::engine::approval::ApprovalDecision::Denied { id } => { |
| 2474 | (id.clone(), ChildApprovalOutcome::Denied) |
| 2475 | } |
| 2476 | // A child has no timeout outcome of its own (#6101); an expired |
| 2477 | // card is a deny for whichever call it was answering. |
| 2478 | super::engine::approval::ApprovalDecision::TimedOut { id } => { |
| 2479 | (id.clone(), ChildApprovalOutcome::Denied) |
| 2480 | } |
| 2481 | // A sandbox retry only exists for the parent's own tool call. |
| 2482 | super::engine::approval::ApprovalDecision::RetryWithPolicy { .. } => return false, |
| 2483 | }; |
| 2484 | if !SubAgentManager::is_child_approval_id(&id) { |
| 2485 | return false; |
| 2486 | } |
| 2487 | self.subagent_manager |
| 2488 | .write() |
| 2489 | .await |
| 2490 | .resolve_child_approval(&id, outcome) |
| 2491 | } |
| 2492 | |
| 2493 | /// Whether the idle loop should poll for background shell completion: a |
| 2494 | /// background job is running or has finished without being claimed yet. |
| 2495 | /// Plain interactive sessions arm exactly like goal sessions — a finished |
| 2496 | /// background task must reach the model without waiting for the user to |
| 2497 | /// type, the same wake an idle sub-agent completion already gets. |
| 2498 | fn idle_shell_wake_armed(&self) -> bool { |
| 2499 | if self.cancel_token.is_cancelled() { |
| 2500 | return false; |
| 2501 | } |
| 2502 | self.shell_manager |
| 2503 | .lock() |
| 2504 | .map(|manager| manager.may_have_undelivered_completion_for_session(&self.session.id)) |
| 2505 | .unwrap_or(false) |
| 2506 | } |
| 2507 | |
| 2508 | /// Whether a finished background job is waiting to be claimed. |
| 2509 | fn finished_background_shell_pending(&self) -> bool { |
| 2510 | self.shell_manager |
| 2511 | .lock() |
| 2512 | .map(|mut manager| manager.has_finished_unreported_jobs_for_session(&self.session.id)) |
| 2513 | .unwrap_or(false) |
| 2514 | } |
| 2515 | |
| 2516 | /// An idle-engine wake for finished background shell work. With an active |
| 2517 | /// goal this queues a goal continuation; without one it starts an ordinary |
| 2518 | /// runtime turn so the completion reaches the model immediately instead of |
| 2519 | /// sitting unclaimed until the user types. Either way the evidence itself |
| 2520 | /// is claimed by the boundary drain in `handle_send_message`, so the |
| 2521 | /// follow-up turn reads the completion payload the same way a |
| 2522 | /// user-initiated turn would. |
| 2523 | async fn handle_idle_shell_completion_wake(&mut self) { |
| 2524 | // Cancellation can arrive after the idle poll selected this wake. |
| 2525 | // Keep the evidence unclaimed for the next requested turn or /jobs; |
| 2526 | // a surviving shell must not silently restart an interrupted model. |
| 2527 | if self.cancel_token.is_cancelled() { |
| 2528 | return; |
| 2529 | } |
| 2530 | let goal_active = self |
| 2531 | .config |
| 2532 | .goal_state |
| 2533 | .lock() |
| 2534 | .map(|state| state.snapshot().is_active()) |
| 2535 | .unwrap_or(false); |
| 2536 | if goal_active { |
| 2537 | let _ = self |
| 2538 | .tx_event |
| 2539 | .send(Event::status( |
| 2540 | "Background shell work finished; continuing the active goal".to_string(), |
| 2541 | )) |
| 2542 | .await; |
| 2543 | self.schedule_goal_continuation(Vec::new()).await; |
| 2544 | return; |
| 2545 | } |
| 2546 | let route = match self.current_runtime_route() { |
| 2547 | Ok(route) => route, |
| 2548 | Err(err) => { |
| 2549 | // No route, no turn. Claim the once-only completion now so a |
| 2550 | // dead route cannot re-arm the wake into the same error every |
| 2551 | // poll tick; the user sees what finished and where the output |
| 2552 | // lives, and the next healthy turn proceeds normally. |
| 2553 | let finished = self |
| 2554 | .shell_manager |
| 2555 | .lock() |
| 2556 | .map(|mut manager| { |
| 2557 | manager |
| 2558 | .drain_finished_jobs_with_evidence_for_session(&self.session.id) |
| 2559 | .len() |
| 2560 | }) |
| 2561 | .unwrap_or(0); |
| 2562 | let _ = self |
| 2563 | .tx_event |
| 2564 | .send(Event::error(ErrorEnvelope::fatal_auth(format!( |
| 2565 | "{finished} background shell task(s) finished, but the turn cannot resume because the provider route is no longer valid: {err}. Their output stays available via /jobs." |
| 2566 | )))) |
| 2567 | .await; |
| 2568 | return; |
| 2569 | } |
| 2570 | }; |
| 2571 | let _ = self |
| 2572 | .tx_event |
| 2573 | .send(Event::status( |
| 2574 | "Background shell work finished; resuming the turn".to_string(), |
| 2575 | )) |
| 2576 | .await; |
| 2577 | let _ = self |
| 2578 | .handle_send_message(TurnSpec { |
| 2579 | content: |
| 2580 | "[runtime] A background shell task finished; its completion evidence follows." |
| 2581 | .to_string(), |
| 2582 | mode: self.current_mode, |
| 2583 | route: Box::new(route), |
| 2584 | compaction: Box::new(self.config.compaction.clone()), |
| 2585 | initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()), |
| 2586 | goal_objective: self.config.goal_objective.clone(), |
| 2587 | goal_token_budget: self.config.goal_token_budget, |
| 2588 | goal_status: self.config.goal_status, |
| 2589 | reasoning_effort: self.session.reasoning_effort.clone(), |
| 2590 | reasoning_effort_auto: self.session.reasoning_effort_auto, |
| 2591 | auto_model: self.session.auto_model, |
| 2592 | allow_shell: self.session.allow_shell, |
| 2593 | trust_mode: self.session.trust_mode, |
| 2594 | auto_approve: self.session.auto_approve, |
| 2595 | approval_mode: self.session.approval_mode, |
| 2596 | translation_enabled: self.config.translation_enabled, |
| 2597 | allowed_tools: self.config.allowed_tools.clone(), |
| 2598 | dynamic_tools: Vec::new(), |
| 2599 | hook_executor: self.config.hook_executor.clone(), |
| 2600 | verbosity: self.config.verbosity.clone(), |
| 2601 | provenance: UserInputProvenance::Runtime, |
| 2602 | images: Vec::new(), |
| 2603 | max_output_tokens: None, |
| 2604 | }) |
| 2605 | .await; |
| 2606 | } |
| 2607 | |
| 2608 | /// Run the engine event loop |
| 2609 | #[allow(clippy::too_many_lines)] |
| 2610 | pub async fn run(mut self) { |
| 2611 | // RuntimeThreadManager owns durable turn claims and installs a thread |
| 2612 | // id in runtime services. Only the interactive TUI may autonomously |
| 2613 | // create a new turn while the engine is otherwise idle; a hosted |
| 2614 | // engine must wait for its host to claim and explicitly dispatch the |
| 2615 | // next turn so events cannot be attached to the wrong durable record. |
| 2616 | let host_managed_turns = self.host_managed_turns(); |
| 2617 | if let Err(error) = self |
| 2618 | .start_mcp_session_boot(McpConnectRefresh::IfChanged) |
| 2619 | .await |
| 2620 | { |
| 2621 | tracing::debug!( |
| 2622 | "MCP session boot failed: {}", |
| 2623 | crate::mcp::format_mcp_error_for_display(&error) |
| 2624 | ); |
| 2625 | } |
| 2626 | |
| 2627 | loop { |
| 2628 | let Some(input) = self.next_run_input(host_managed_turns).await else { |
| 2629 | break; |
| 2630 | }; |
| 2631 | |
| 2632 | // Runtime posture updates publish through shared typed state |
| 2633 | // before attempting their best-effort wake-up. If the mailbox was |
| 2634 | // already full, its next queued operation is the wake-up: apply |
| 2635 | // the latest authority before doing any work under an obsolete |
| 2636 | // policy. |
| 2637 | if matches!(&input, EngineRunInput::Operation(_)) { |
| 2638 | self.apply_pending_runtime_authority().await; |
| 2639 | } |
| 2640 | |
| 2641 | match input { |
| 2642 | EngineRunInput::SubAgentCompletion(completion) => { |
| 2643 | self.handle_idle_subagent_completion(completion).await; |
| 2644 | } |
| 2645 | EngineRunInput::McpBootUpdate(update) => { |
| 2646 | self.apply_mcp_boot_update(update).await; |
| 2647 | } |
| 2648 | EngineRunInput::McpSupervisorUpdate(update) => { |
| 2649 | self.apply_mcp_supervisor_update(update).await; |
| 2650 | } |
| 2651 | EngineRunInput::ShellCompletionWake => { |
| 2652 | self.handle_idle_shell_completion_wake().await; |
| 2653 | } |
| 2654 | EngineRunInput::Operation(op) => match *op { |
| 2655 | Op::SendMessage(spec) => { |
| 2656 | self.admitted_turn_control = { |
| 2657 | let mut controls = self |
| 2658 | .turn_controls |
| 2659 | .lock() |
| 2660 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 2661 | let control = controls.pending.pop_front(); |
| 2662 | controls.active = control.clone(); |
| 2663 | control |
| 2664 | }; |
| 2665 | // Keep the send-message state machine out of this |
| 2666 | // event-loop future's stack frame. |
| 2667 | Box::pin(self.handle_send_message(spec)).await; |
| 2668 | } |
| 2669 | Op::ContinueGoal { |
| 2670 | dynamic_tools, |
| 2671 | engine_schedule_id, |
| 2672 | } => { |
| 2673 | // Cancellation can race the delay expiry after the |
| 2674 | // coalesced token entered the mailbox. Re-check the |
| 2675 | // same turn token before consuming the schedule so an |
| 2676 | // interrupt at the boundary never starts a provider |
| 2677 | // request and is not erased by the next turn's reset. |
| 2678 | if engine_schedule_id.is_some() && self.cancel_token.is_cancelled() { |
| 2679 | self.cancel_scheduled_goal_continuation(true).await; |
| 2680 | continue; |
| 2681 | } |
| 2682 | let Some(dynamic_tools) = self |
| 2683 | .take_scheduled_goal_continuation(engine_schedule_id, dynamic_tools) |
| 2684 | else { |
| 2685 | continue; |
| 2686 | }; |
| 2687 | // Host-injected tokens carry their own quiet period: |
| 2688 | // host-managed sessions never run the engine-owned |
| 2689 | // scheduler, so this arm is their only continuation |
| 2690 | // dispatch site and must honor |
| 2691 | // [goal] continuation_delay_seconds itself before |
| 2692 | // dispatching. The wait is biased-cancellable (Esc, |
| 2693 | // steer, or host cancel always wins over a racing |
| 2694 | // expiry), and the live goal is re-read below only |
| 2695 | // after it, so a pause/clear/complete/blocked landing |
| 2696 | // during the quiet period cancels the pass and |
| 2697 | // failures never continue. Engine-owned tokens (Some) |
| 2698 | // already waited out ready_at in the scheduler and |
| 2699 | // keep those semantics untouched. |
| 2700 | if engine_schedule_id.is_none() |
| 2701 | && crate::goal_loop::await_continuation_wait( |
| 2702 | crate::goal_loop::continuation_wait( |
| 2703 | self.config.goal_continuation_delay_seconds, |
| 2704 | ), |
| 2705 | &self.cancel_token, |
| 2706 | ) |
| 2707 | .await |
| 2708 | == crate::goal_loop::ContinuationWaitOutcome::Cancelled |
| 2709 | { |
| 2710 | continue; |
| 2711 | } |
| 2712 | // Status controls queued while the previous turn was |
| 2713 | // running are processed before this operation, and a |
| 2714 | // host-injected quiet period has now elapsed. Re-read |
| 2715 | // the live goal so pause/clear/complete/blocked can |
| 2716 | // cancel a stale continuation without starting a turn. |
| 2717 | let (content, goal_snapshot) = match self.goal_continuation_if_active() { |
| 2718 | GoalContinuationAction::Inactive => continue, |
| 2719 | GoalContinuationAction::Dispatch { content, snapshot } => { |
| 2720 | (content, *snapshot) |
| 2721 | } |
| 2722 | GoalContinuationAction::Stopped { message, reason } => { |
| 2723 | self.pause_goal_continuation(reason, message).await; |
| 2724 | continue; |
| 2725 | } |
| 2726 | }; |
| 2727 | // Budget and inactive-state decisions are route |
| 2728 | // independent. Resolve the live route only for a real |
| 2729 | // dispatch so an exhausted goal still reaches its |
| 2730 | // truthful terminal state when provider config drifted. |
| 2731 | let route = match self.current_runtime_route() { |
| 2732 | Ok(route) => route, |
| 2733 | Err(err) => { |
| 2734 | let message = format!( |
| 2735 | "Goal continuation blocked because its provider route is no longer valid: {err}. Fix the route, then resume the goal." |
| 2736 | ); |
| 2737 | let _ = self |
| 2738 | .tx_event |
| 2739 | .send(Event::error(ErrorEnvelope::fatal_auth(format!( |
| 2740 | "Goal continuation stopped because its provider route is no longer valid: {err}" |
| 2741 | )))) |
| 2742 | .await; |
| 2743 | self.block_goal_continuation(message).await; |
| 2744 | continue; |
| 2745 | } |
| 2746 | }; |
| 2747 | |
| 2748 | let _ = self |
| 2749 | .handle_send_message(TurnSpec { |
| 2750 | content, |
| 2751 | mode: self.current_mode, |
| 2752 | route: Box::new(route), |
| 2753 | compaction: Box::new(self.config.compaction.clone()), |
| 2754 | initial_routed_usage: Box::new( |
| 2755 | crate::cost_status::RuntimeUsageBatch::default(), |
| 2756 | ), |
| 2757 | goal_objective: goal_snapshot.objective, |
| 2758 | goal_token_budget: goal_snapshot.token_budget, |
| 2759 | goal_status: GoalStatus::Active, |
| 2760 | reasoning_effort: self.session.reasoning_effort.clone(), |
| 2761 | reasoning_effort_auto: self.session.reasoning_effort_auto, |
| 2762 | auto_model: self.session.auto_model, |
| 2763 | allow_shell: self.session.allow_shell, |
| 2764 | trust_mode: self.session.trust_mode, |
| 2765 | auto_approve: self.session.auto_approve, |
| 2766 | approval_mode: self.session.approval_mode, |
| 2767 | translation_enabled: self.config.translation_enabled, |
| 2768 | allowed_tools: self.config.allowed_tools.clone(), |
| 2769 | dynamic_tools, |
| 2770 | hook_executor: self.config.hook_executor.clone(), |
| 2771 | verbosity: self.config.verbosity.clone(), |
| 2772 | provenance: UserInputProvenance::Runtime, |
| 2773 | images: Vec::new(), |
| 2774 | max_output_tokens: None, |
| 2775 | }) |
| 2776 | .await; |
| 2777 | } |
| 2778 | Op::RunShellCommand { |
| 2779 | command, |
| 2780 | mode, |
| 2781 | allow_shell, |
| 2782 | trust_mode, |
| 2783 | auto_approve, |
| 2784 | approval_mode, |
| 2785 | } => { |
| 2786 | self.handle_run_shell_command( |
| 2787 | command, |
| 2788 | mode, |
| 2789 | allow_shell, |
| 2790 | trust_mode, |
| 2791 | auto_approve, |
| 2792 | approval_mode, |
| 2793 | ) |
| 2794 | .await; |
| 2795 | } |
| 2796 | Op::SetGoalStatus { |
| 2797 | status, |
| 2798 | clear, |
| 2799 | goal_id, |
| 2800 | } => { |
| 2801 | self.handle_set_goal_status(status, clear, goal_id).await; |
| 2802 | } |
| 2803 | Op::SetGoalObjective { |
| 2804 | objective, |
| 2805 | token_budget, |
| 2806 | goal_id, |
| 2807 | } => { |
| 2808 | self.handle_set_goal_objective(objective, token_budget, goal_id) |
| 2809 | .await; |
| 2810 | } |
| 2811 | Op::PreviewOutboundRequest { |
| 2812 | inputs, |
| 2813 | json, |
| 2814 | base_prompt_only, |
| 2815 | } => { |
| 2816 | // Pure inspection: no turn is started, no message is |
| 2817 | // added, no engine state is written, and no provider |
| 2818 | // request is sent. Facts that are not exactly knowable |
| 2819 | // come back as typed unavailable sections rather than |
| 2820 | // as an error or a guess. |
| 2821 | let rendered = if base_prompt_only { |
| 2822 | crate::request_manifest::exact_base_prompt_only() |
| 2823 | } else { |
| 2824 | let manifest = self.build_request_manifest(*inputs).await; |
| 2825 | if json { |
| 2826 | manifest.to_json() |
| 2827 | } else { |
| 2828 | manifest.render() |
| 2829 | } |
| 2830 | }; |
| 2831 | let _ = self |
| 2832 | .tx_event |
| 2833 | .send(Event::RequestManifestReady { rendered }) |
| 2834 | .await; |
| 2835 | } |
| 2836 | Op::ListSubAgents => { |
| 2837 | // #3803: the sidebar refresh is a read-only snapshot. |
| 2838 | // Render from a read lock; only take the write lock to |
| 2839 | // run cleanup on a bounded cadence, so a UI refresh storm |
| 2840 | // during a sub-agent fanout no longer contends for the |
| 2841 | // write lock (against completions/persistence) on every |
| 2842 | // request. Cleanup still auto-cancels stale agents. |
| 2843 | let active_session_id = self.session.id.clone(); |
| 2844 | self.touch_workers_with_running_shells().await; |
| 2845 | let due = { |
| 2846 | let manager = self.subagent_manager.read().await; |
| 2847 | manager.cleanup_due( |
| 2848 | crate::tools::subagent::SUBAGENT_LIST_CLEANUP_MIN_INTERVAL, |
| 2849 | ) |
| 2850 | }; |
| 2851 | let event = if due { |
| 2852 | let mut manager = self.subagent_manager.write().await; |
| 2853 | manager.cleanup_for_session( |
| 2854 | &active_session_id, |
| 2855 | Duration::from_secs(60 * 60), |
| 2856 | ); |
| 2857 | agent_list_event(&manager, &active_session_id) |
| 2858 | } else { |
| 2859 | let manager = self.subagent_manager.read().await; |
| 2860 | agent_list_event(&manager, &active_session_id) |
| 2861 | }; |
| 2862 | // #3802: use non-blocking send — this is a refresh event |
| 2863 | // that can safely be dropped when the channel is full. |
| 2864 | // The next drain cycle will re-request the list. |
| 2865 | if let Err(_e) = self.tx_event.try_send(event) { |
| 2866 | tracing::debug!( |
| 2867 | "Event channel full; dropping ListSubAgents refresh (will retry next drain)" |
| 2868 | ); |
| 2869 | } |
| 2870 | } |
| 2871 | Op::GetSubAgentSettlement { tx } => { |
| 2872 | let snapshot = self.subagent_settlement_snapshot().await; |
| 2873 | if let Some(tx) = tx |
| 2874 | .lock() |
| 2875 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 2876 | .take() |
| 2877 | { |
| 2878 | let _ = tx.send(snapshot); |
| 2879 | } |
| 2880 | } |
| 2881 | Op::CancelSubAgent { agent_id } => { |
| 2882 | let active_session_id = self.session.id.clone(); |
| 2883 | let result = { |
| 2884 | let mut manager = self.subagent_manager.write().await; |
| 2885 | match manager.cancel_agent_for_session(&active_session_id, &agent_id) { |
| 2886 | Ok(_) => Ok(agent_list_event(&manager, &active_session_id)), |
| 2887 | Err(err) => Err(err), |
| 2888 | } |
| 2889 | }; |
| 2890 | match result { |
| 2891 | Ok(event) => { |
| 2892 | if let Err(_e) = self.tx_event.try_send(event) { |
| 2893 | tracing::debug!( |
| 2894 | "Event channel full; dropping CancelSubAgent refresh" |
| 2895 | ); |
| 2896 | } |
| 2897 | } |
| 2898 | Err(err) => { |
| 2899 | let _ = |
| 2900 | self.tx_event |
| 2901 | .try_send(Event::error(ErrorEnvelope::transient(format!( |
| 2902 | "Failed to cancel sub-agent {agent_id}: {err}" |
| 2903 | )))); |
| 2904 | } |
| 2905 | } |
| 2906 | } |
| 2907 | Op::FollowUpSubAgent { agent_id, text } => { |
| 2908 | let active_session_id = self.session.id.clone(); |
| 2909 | let runtime = self.off_turn_subagent_runtime(); |
| 2910 | let manager_handle = Arc::clone(&self.subagent_manager); |
| 2911 | let (outcome, refresh) = { |
| 2912 | let mut manager = self.subagent_manager.write().await; |
| 2913 | let outcome = manager |
| 2914 | .continue_child_from_user_for_session( |
| 2915 | &active_session_id, |
| 2916 | manager_handle, |
| 2917 | runtime, |
| 2918 | &agent_id, |
| 2919 | &text, |
| 2920 | ) |
| 2921 | .map_err(|err| err.to_string()); |
| 2922 | (outcome, agent_list_event(&manager, &active_session_id)) |
| 2923 | }; |
| 2924 | let _ = self |
| 2925 | .tx_event |
| 2926 | .send(Event::SubAgentFollowUp { |
| 2927 | owner_session_id: active_session_id, |
| 2928 | agent_id, |
| 2929 | outcome, |
| 2930 | }) |
| 2931 | .await; |
| 2932 | if let Err(_e) = self.tx_event.try_send(refresh) { |
| 2933 | tracing::debug!( |
| 2934 | "Event channel full; dropping FollowUpSubAgent refresh" |
| 2935 | ); |
| 2936 | } |
| 2937 | } |
| 2938 | Op::ChangeMode { .. } => { |
| 2939 | // The mailbox payload may predate a newer posture that |
| 2940 | // was published while the channel was full. Apply the |
| 2941 | // single live snapshot so a stale queued ChangeMode |
| 2942 | // can never roll authority backward. |
| 2943 | let authority = self.runtime_authority_snapshot(); |
| 2944 | self.apply_runtime_authority(authority).await; |
| 2945 | } |
| 2946 | Op::SetModel { |
| 2947 | model, |
| 2948 | mode: _, |
| 2949 | route_limits, |
| 2950 | } => { |
| 2951 | self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); |
| 2952 | self.session.model = model; |
| 2953 | self.config.model.clone_from(&self.session.model); |
| 2954 | self.active_route_limits = route_limits; |
| 2955 | // This lightweight operation carries no executable |
| 2956 | // route candidate, so old provider/model capability |
| 2957 | // facts must not bleed into the new model. |
| 2958 | self.active_route_capabilities = |
| 2959 | codewhale_config::route::RouteCapabilities::default(); |
| 2960 | self.refresh_system_prompt_with_reason("model"); |
| 2961 | self.emit_session_updated().await; |
| 2962 | let _ = self |
| 2963 | .tx_event |
| 2964 | .send(Event::status(format!( |
| 2965 | "Model set to: {}", |
| 2966 | self.session.model |
| 2967 | ))) |
| 2968 | .await; |
| 2969 | } |
| 2970 | Op::SetCompaction { config } => { |
| 2971 | let enabled = config.enabled; |
| 2972 | self.config.compaction = config; |
| 2973 | let _ = self |
| 2974 | .tx_event |
| 2975 | .send(Event::status(format!( |
| 2976 | "Auto-compaction {}", |
| 2977 | if enabled { "enabled" } else { "disabled" } |
| 2978 | ))) |
| 2979 | .await; |
| 2980 | } |
| 2981 | Op::SetStreamChunkTimeout { timeout_secs } => { |
| 2982 | self.config.stream_chunk_timeout = Duration::from_secs(timeout_secs); |
| 2983 | let _ = self |
| 2984 | .tx_event |
| 2985 | .send(Event::status(format!( |
| 2986 | "Stream chunk timeout set to {timeout_secs}s" |
| 2987 | ))) |
| 2988 | .await; |
| 2989 | } |
| 2990 | Op::SetSubagentRuntimeConfig { |
| 2991 | enabled, |
| 2992 | max_subagents, |
| 2993 | launch_concurrency, |
| 2994 | max_spawn_depth, |
| 2995 | api_timeout_secs, |
| 2996 | heartbeat_timeout_secs, |
| 2997 | } => { |
| 2998 | self.config.subagents_enabled = enabled; |
| 2999 | self.config.max_subagents = |
| 3000 | max_subagents.clamp(1, crate::config::MAX_SUBAGENTS); |
| 3001 | self.config.launch_concurrency = |
| 3002 | launch_concurrency.clamp(1, self.config.max_subagents); |
| 3003 | self.config.max_spawn_depth = |
| 3004 | max_spawn_depth.min(codewhale_config::MAX_SPAWN_DEPTH_CEILING); |
| 3005 | self.config.subagent_api_timeout = Duration::from_secs(api_timeout_secs); |
| 3006 | self.config.subagent_heartbeat_timeout = |
| 3007 | Duration::from_secs(heartbeat_timeout_secs); |
| 3008 | let launch_gate_applied = { |
| 3009 | let mut manager = self.subagent_manager.write().await; |
| 3010 | manager.update_runtime_limits( |
| 3011 | self.config.max_subagents, |
| 3012 | self.config.max_admitted_subagents, |
| 3013 | self.config.subagent_heartbeat_timeout, |
| 3014 | self.config.launch_concurrency, |
| 3015 | ) |
| 3016 | }; |
| 3017 | let launch_note = if launch_gate_applied { |
| 3018 | "" |
| 3019 | } else { |
| 3020 | "; launch_concurrency takes full effect after active sub-agents finish or the session restarts" |
| 3021 | }; |
| 3022 | let _ = self |
| 3023 | .tx_event |
| 3024 | .send(Event::status(format!( |
| 3025 | "Sub-agent runtime updated: enabled={enabled}, max_subagents={}, launch_concurrency={}, max_depth={}{}", |
| 3026 | self.config.max_subagents, |
| 3027 | self.config.launch_concurrency, |
| 3028 | self.config.max_spawn_depth, |
| 3029 | launch_note |
| 3030 | ))) |
| 3031 | .await; |
| 3032 | } |
| 3033 | Op::SetFleetRoster { roster } => { |
| 3034 | self.config.fleet_roster = roster; |
| 3035 | let _ = self |
| 3036 | .tx_event |
| 3037 | .send(Event::status( |
| 3038 | "Fleet roster refreshed for subsequent turns".to_string(), |
| 3039 | )) |
| 3040 | .await; |
| 3041 | } |
| 3042 | Op::SyncSession { |
| 3043 | session_id, |
| 3044 | messages, |
| 3045 | system_prompt, |
| 3046 | system_prompt_override, |
| 3047 | model, |
| 3048 | workspace, |
| 3049 | mode, |
| 3050 | } => { |
| 3051 | // Deferred tool activations belong to one |
| 3052 | // conversation. SyncSession installs a conversation's |
| 3053 | // identity, history, and workspace (including the |
| 3054 | // generated-ID new-session path), so never carry the |
| 3055 | // previous conversation's toolbox across this edge. |
| 3056 | self.session.tool_activation_cache.clear(); |
| 3057 | let plugin_workspace_changed = |
| 3058 | self.plugin_registry.workspace() != workspace.as_path(); |
| 3059 | let previous_session_id = self.session.id.clone(); |
| 3060 | let next_session_id = if let Some(session_id) = session_id { |
| 3061 | session_id |
| 3062 | } else if messages.is_empty() && system_prompt.is_none() { |
| 3063 | uuid::Uuid::new_v4().to_string() |
| 3064 | } else { |
| 3065 | previous_session_id.clone() |
| 3066 | }; |
| 3067 | let closed_session_id = self.install_synced_session_id(next_session_id); |
| 3068 | // SyncSession installs a conversation's identity; an id |
| 3069 | // change IS a conversation boundary in this runtime — |
| 3070 | // callers must pass their own conversation id for |
| 3071 | // same-conversation re-syncs. A boundary in the same |
| 3072 | // process does not rebuild the sub-agent manager, so the |
| 3073 | // previous conversation's live children and write claims |
| 3074 | // must be finalized here or they keep gating writers in |
| 3075 | // the new conversation (#5372). Same-session reloads |
| 3076 | // keep their id and are deliberately left untouched. |
| 3077 | if let Some(closed_session_id) = closed_session_id { |
| 3078 | let finalized = self |
| 3079 | .subagent_manager |
| 3080 | .write() |
| 3081 | .await |
| 3082 | .finalize_session_close_for_session(&closed_session_id); |
| 3083 | if finalized > 0 { |
| 3084 | tracing::info!( |
| 3085 | target: "subagent", |
| 3086 | finalized, |
| 3087 | "finalized sub-agent fleet for closed session" |
| 3088 | ); |
| 3089 | } |
| 3090 | } |
| 3091 | let compaction_checkpoint = |
| 3092 | extract_compaction_summary_prompt(system_prompt.clone()); |
| 3093 | let restored_messages = |
| 3094 | crate::runtime_handoff::project_messages_for_restore(&messages); |
| 3095 | // Replace the checkpoint in place so turns after the |
| 3096 | // compaction boundary keep their chronology. |
| 3097 | let restored_messages = crate::compaction::restore_compaction_checkpoint( |
| 3098 | restored_messages, |
| 3099 | compaction_checkpoint.as_ref(), |
| 3100 | ); |
| 3101 | self.session.messages = restored_messages.into(); |
| 3102 | // Direct field assignment bypasses `add_message` / |
| 3103 | // `replace_messages`, which own the messages-revision |
| 3104 | // bump the token-estimate cache keys on (#perf-r5). |
| 3105 | // Without this bump the first estimate after a |
| 3106 | // session restore is computed against whatever |
| 3107 | // history revision was current before the sync — a |
| 3108 | // stale number can flow into capacity checkpoints. |
| 3109 | self.session.bump_messages_revision(); |
| 3110 | self.session.latest_parent_input_tokens = None; |
| 3111 | self.session.compaction_summary_prompt = compaction_checkpoint; |
| 3112 | self.session.system_prompt = |
| 3113 | crate::compaction::strip_compaction_summaries(system_prompt.as_ref()); |
| 3114 | self.session.last_system_prompt_hash = |
| 3115 | Some(system_prompt_hash(self.session.system_prompt.as_ref())); |
| 3116 | // Prompt pins and drift baselines describe the |
| 3117 | // conversation that was active before this sync. The |
| 3118 | // next submitted turn must establish the installed |
| 3119 | // conversation's own full prefix instead of comparing |
| 3120 | // it with that stale baseline and emitting a |
| 3121 | // `<context_update>` from an empty/restored prompt. |
| 3122 | // Host-owned overrides remain byte-stable because the |
| 3123 | // refresh path exits early while the override is set. |
| 3124 | self.session.pinned_prompt_context = None; |
| 3125 | self.session.context_update_baseline = None; |
| 3126 | // A session sync installs a new (or restored) prefix. |
| 3127 | // Declare it so the next request re-pins the KV-cache |
| 3128 | // prefix under a logged `resume` reason instead of |
| 3129 | // reporting undeclared drift. |
| 3130 | self.session.pending_prefix_change_reason = Some("resume".to_string()); |
| 3131 | // Host-supplied prompts are persisted prefixes. Keep them |
| 3132 | // byte-stable; mode/runtime state is projected per request. |
| 3133 | self.session.system_prompt_override = |
| 3134 | system_prompt_override && self.session.system_prompt.is_some(); |
| 3135 | self.session.auto_model = model.trim().eq_ignore_ascii_case("auto"); |
| 3136 | self.session.model = model; |
| 3137 | self.session.workspace = workspace.clone(); |
| 3138 | self.current_mode = mode; |
| 3139 | self.config.model.clone_from(&self.session.model); |
| 3140 | self.config.workspace = workspace.clone(); |
| 3141 | if plugin_workspace_changed { |
| 3142 | self.plugin_registry = |
| 3143 | self.plugin_registry.rediscover_for_workspace(&workspace); |
| 3144 | self.config.plugin_registry = Some(Arc::clone(&self.plugin_registry)); |
| 3145 | // A pool may contain plugin servers and authority |
| 3146 | // receipts from the previous workspace snapshot. |
| 3147 | self.mcp_pool = None; |
| 3148 | } |
| 3149 | let ctx = |
| 3150 | crate::project_context::load_project_context_with_parents(&workspace); |
| 3151 | self.session.project_context = if ctx.has_instructions() { |
| 3152 | Some(ctx) |
| 3153 | } else { |
| 3154 | None |
| 3155 | }; |
| 3156 | self.session.rebuild_working_set(); |
| 3157 | self.reconcile_restored_work_bindings().await; |
| 3158 | self.emit_session_updated().await; |
| 3159 | let _ = self |
| 3160 | .tx_event |
| 3161 | .send(Event::status("Session context synced".to_string())) |
| 3162 | .await; |
| 3163 | } |
| 3164 | Op::CompactContext { |
| 3165 | id, |
| 3166 | route, |
| 3167 | compaction, |
| 3168 | } => { |
| 3169 | self.handle_manual_compaction_op(id, *route, *compaction) |
| 3170 | .await; |
| 3171 | } |
| 3172 | Op::CancelCompaction { id } => { |
| 3173 | // Cancellation is published out-of-band by the handle |
| 3174 | // so a provider await cannot block it. Draining the |
| 3175 | // typed op only clears a late, already-settled marker. |
| 3176 | self.finish_compaction(&id); |
| 3177 | } |
| 3178 | Op::GetSessionSnapshot { tx } => { |
| 3179 | let total_tokens = self.session.total_usage.input_tokens |
| 3180 | + self.session.total_usage.output_tokens; |
| 3181 | let snapshot = SessionSnapshot { |
| 3182 | messages: self.session.messages.to_vec(), |
| 3183 | total_tokens, |
| 3184 | model: self.session.model.clone(), |
| 3185 | model_provider: self.api_provider.as_str().to_string(), |
| 3186 | model_provider_id: self.api_provider_id.clone(), |
| 3187 | workspace: self.session.workspace.clone(), |
| 3188 | system_prompt: self.session.system_prompt.clone(), |
| 3189 | mode: self.current_mode.as_setting().to_string(), |
| 3190 | }; |
| 3191 | if let Some(tx) = tx.lock().ok().and_then(|mut g| g.take()) { |
| 3192 | let _ = tx.send(snapshot); |
| 3193 | } |
| 3194 | } |
| 3195 | Op::GetContextBudget { tx } => { |
| 3196 | let input_tokens = self.estimated_input_tokens() as u64; |
| 3197 | let budget = route_context_budget_for_route( |
| 3198 | self.api_provider, |
| 3199 | &self.session.model, |
| 3200 | self.active_route_limits, |
| 3201 | usize::try_from(input_tokens).unwrap_or(usize::MAX), |
| 3202 | ); |
| 3203 | let snapshot = budget.map(|budget| SessionContextBudget { |
| 3204 | window_tokens: budget.window_tokens, |
| 3205 | input_tokens, |
| 3206 | billed_input_tokens: self |
| 3207 | .session |
| 3208 | .latest_parent_input_tokens |
| 3209 | .map(u64::from), |
| 3210 | output_cap_tokens: budget.output_cap_tokens, |
| 3211 | input_budget_ceiling: budget.input_budget_ceiling, |
| 3212 | available_input_tokens: budget.available_input_tokens, |
| 3213 | compaction_trigger_tokens: budget.compaction_trigger_tokens, |
| 3214 | usage_percent: budget.usage_percent(), |
| 3215 | pressure: budget.pressure.label(), |
| 3216 | model: self.session.model.clone(), |
| 3217 | provider: self.api_provider.as_str().to_string(), |
| 3218 | model_provider_id: self.api_provider_id.clone(), |
| 3219 | }); |
| 3220 | if let Some(tx) = tx.lock().ok().and_then(|mut g| g.take()) { |
| 3221 | let _ = tx.send(snapshot); |
| 3222 | } |
| 3223 | } |
| 3224 | Op::GetProviderRuntimeStatus { tx } => { |
| 3225 | let status = if let Some(client) = self.codewhale_client.as_ref() { |
| 3226 | ProviderRuntimeStatus { |
| 3227 | provider: client.api_provider(), |
| 3228 | request_concurrency_limit: client |
| 3229 | .provider_request_concurrency_limit(), |
| 3230 | active_provider_requests: client.active_provider_requests(), |
| 3231 | } |
| 3232 | } else { |
| 3233 | let provider = self.api_config.api_provider(); |
| 3234 | ProviderRuntimeStatus { |
| 3235 | provider, |
| 3236 | request_concurrency_limit: self |
| 3237 | .api_config |
| 3238 | .provider_max_concurrency(provider), |
| 3239 | active_provider_requests: 0, |
| 3240 | } |
| 3241 | }; |
| 3242 | if let Some(tx) = tx.lock().ok().and_then(|mut g| g.take()) { |
| 3243 | let _ = tx.send(status); |
| 3244 | } |
| 3245 | } |
| 3246 | Op::BootstrapMcp { tx } => { |
| 3247 | let result = self.bootstrap_mcp_pool().await.map_err(|error| { |
| 3248 | codewhale_config::persistence::redact_secrets(&format!("{error:#}")) |
| 3249 | }); |
| 3250 | if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) { |
| 3251 | let _ = tx.send(result); |
| 3252 | } |
| 3253 | } |
| 3254 | Op::RetryMcpServer { name, tx } => { |
| 3255 | let result = self.retry_mcp_server(&name).await.map_err(|error| { |
| 3256 | codewhale_config::persistence::redact_secrets(&format!("{error:#}")) |
| 3257 | }); |
| 3258 | if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) { |
| 3259 | let _ = tx.send(result); |
| 3260 | } |
| 3261 | } |
| 3262 | Op::ReloadMcp { config_path, tx } => { |
| 3263 | let result = self.reload_mcp_pool(config_path).await.map_err(|error| { |
| 3264 | codewhale_config::persistence::redact_secrets(&format!("{error:#}")) |
| 3265 | }); |
| 3266 | if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) { |
| 3267 | let _ = tx.send(result); |
| 3268 | } |
| 3269 | } |
| 3270 | Op::PurgeContext => { |
| 3271 | if let Some(pm) = self.session.prefix_stability.as_mut() { |
| 3272 | pm.note_history_reset("clear"); |
| 3273 | } |
| 3274 | self.handle_purge().await; |
| 3275 | } |
| 3276 | Op::EditLastTurn { new_message } => { |
| 3277 | let route = match self.current_runtime_route() { |
| 3278 | Ok(route) => route, |
| 3279 | Err(err) => { |
| 3280 | self.reject_edit_last_turn(ErrorEnvelope::new( |
| 3281 | ErrorCategory::Authentication, |
| 3282 | ErrorSeverity::Critical, |
| 3283 | false, |
| 3284 | "edit_last_turn_invalid_route", |
| 3285 | format!( |
| 3286 | "Cannot edit the last turn because its provider route is no longer valid: {err}" |
| 3287 | ), |
| 3288 | )) |
| 3289 | .await; |
| 3290 | continue; |
| 3291 | } |
| 3292 | }; |
| 3293 | // #383: /edit — remove the last user+assistant exchange |
| 3294 | // from the session, then re-send with the new content. |
| 3295 | // Tool results and runtime-owned internal envelopes are |
| 3296 | // also persisted with role "user", so locate the cut |
| 3297 | // point by genuine user prompt — a bare role scan would |
| 3298 | // land mid-turn on a tool_result and keep the old |
| 3299 | // prompt plus its tool round-trips in history. |
| 3300 | let idx = match crate::runtime_handoff::edit_last_turn_target( |
| 3301 | &self.session.messages, |
| 3302 | ) { |
| 3303 | crate::runtime_handoff::EditLastTurnTarget::Editable(idx) => idx, |
| 3304 | crate::runtime_handoff::EditLastTurnTarget::Unsupported => { |
| 3305 | self.reject_edit_last_turn(ErrorEnvelope::new( |
| 3306 | ErrorCategory::InvalidInput, |
| 3307 | ErrorSeverity::Error, |
| 3308 | false, |
| 3309 | "edit_last_turn_unsupported_user_content", |
| 3310 | "Cannot edit the last turn because the latest user message has no editable text content.", |
| 3311 | )) |
| 3312 | .await; |
| 3313 | continue; |
| 3314 | } |
| 3315 | crate::runtime_handoff::EditLastTurnTarget::Missing => { |
| 3316 | self.reject_edit_last_turn(ErrorEnvelope::new( |
| 3317 | ErrorCategory::State, |
| 3318 | ErrorSeverity::Error, |
| 3319 | false, |
| 3320 | "edit_last_turn_no_user_prompt", |
| 3321 | "Cannot edit the last turn because the session history has no user message to replace.", |
| 3322 | )) |
| 3323 | .await; |
| 3324 | continue; |
| 3325 | } |
| 3326 | }; |
| 3327 | self.session.messages.truncate_to(idx); |
| 3328 | self.session.bump_messages_revision(); |
| 3329 | // Now dispatch the new message as a normal send, |
| 3330 | // reusing the engine's stored mode/model config. |
| 3331 | let mode = self.current_mode; |
| 3332 | self.handle_send_message(TurnSpec { |
| 3333 | content: new_message.clone(), |
| 3334 | mode, |
| 3335 | route: Box::new(route), |
| 3336 | compaction: Box::new(self.config.compaction.clone()), |
| 3337 | initial_routed_usage: Box::new( |
| 3338 | crate::cost_status::RuntimeUsageBatch::default(), |
| 3339 | ), |
| 3340 | goal_objective: self.config.goal_objective.clone(), |
| 3341 | goal_token_budget: self.config.goal_token_budget, |
| 3342 | goal_status: self.config.goal_status, |
| 3343 | reasoning_effort: self.session.reasoning_effort.clone(), |
| 3344 | reasoning_effort_auto: self.session.reasoning_effort_auto, |
| 3345 | auto_model: self.session.auto_model, |
| 3346 | allow_shell: self.session.allow_shell, |
| 3347 | trust_mode: self.session.trust_mode, |
| 3348 | auto_approve: self.session.auto_approve, |
| 3349 | approval_mode: self.session.approval_mode, |
| 3350 | translation_enabled: self.config.translation_enabled, |
| 3351 | allowed_tools: self.config.allowed_tools.clone(), |
| 3352 | dynamic_tools: Vec::new(), |
| 3353 | hook_executor: self.config.hook_executor.clone(), |
| 3354 | verbosity: self.config.verbosity.clone(), |
| 3355 | provenance: UserInputProvenance::ExternalUser, |
| 3356 | images: Vec::new(), |
| 3357 | max_output_tokens: None, |
| 3358 | }) |
| 3359 | .await; |
| 3360 | } |
| 3361 | Op::SetAdvisorEnabled { enabled } => { |
| 3362 | self.config.advisor_config.enabled = enabled; |
| 3363 | let state = if enabled { "enabled" } else { "disabled" }; |
| 3364 | let _ = self |
| 3365 | .tx_event |
| 3366 | .send(Event::status(format!( |
| 3367 | "Advisor watcher {state}. Notes will appear after turns with tool calls." |
| 3368 | ))) |
| 3369 | .await; |
| 3370 | tracing::info!(target: "advisor", "advisor watcher {state}"); |
| 3371 | } |
| 3372 | Op::SetSearchProvider { provider } => { |
| 3373 | self.config.search_provider = provider; |
| 3374 | } |
| 3375 | Op::Shutdown => { |
| 3376 | break; |
| 3377 | } |
| 3378 | }, |
| 3379 | } |
| 3380 | } |
| 3381 | |
| 3382 | // #freeze: flush any sub-agent checkpoint that the hot-path debounce |
| 3383 | // coalesced away, so a graceful shutdown keeps the latest progress. |
| 3384 | { |
| 3385 | let mut manager = self.subagent_manager.write().await; |
| 3386 | let children = manager.list_for_session(&self.session.id); |
| 3387 | for child in children { |
| 3388 | if child.status == SubAgentStatus::Running { |
| 3389 | let _ = manager.cancel_agent_for_session(&self.session.id, &child.agent_id); |
| 3390 | } |
| 3391 | } |
| 3392 | manager.flush_pending_persist(); |
| 3393 | } |
| 3394 | |
| 3395 | // #420: graceful MCP shutdown — send SIGTERM and give stdio servers |
| 3396 | // a brief window to exit before drop fires SIGKILL via kill_on_drop. |
| 3397 | // Best-effort: pool may not exist (no MCP configured) and the lock |
| 3398 | // can fail under contention; either way the kill_on_drop fallback |
| 3399 | // still reaps the children. |
| 3400 | if let Some(pool) = self.mcp_pool.as_ref() { |
| 3401 | let mut guard = pool.lock().await; |
| 3402 | guard.shutdown_all().await; |
| 3403 | } |
| 3404 | } |
| 3405 | |
| 3406 | fn host_managed_turns(&self) -> bool { |
| 3407 | self.config.runtime_services.active_thread_id.is_some() |
| 3408 | } |
| 3409 | |
| 3410 | async fn subagent_settlement_snapshot(&self) -> crate::core::ops::SubAgentSettlement { |
| 3411 | // Terminal delivery enqueues the completion while holding this write |
| 3412 | // lock, before changing Running to terminal. Keep the read guard until |
| 3413 | // both observations are captured so no completion can fall in the gap. |
| 3414 | let manager = self.subagent_manager.read().await; |
| 3415 | crate::core::ops::SubAgentSettlement { |
| 3416 | running_children: manager.live_count_for_session(&self.session.id), |
| 3417 | // Workflow terminal delivery queues its receipt before removing |
| 3418 | // the controller. Observe controllers before the inbox so a gap |
| 3419 | // between phases cannot look like a settled parent. |
| 3420 | running_workflows: crate::tools::workflow::live_workflow_count( |
| 3421 | &self.session.workspace, |
| 3422 | &self.session.id, |
| 3423 | ), |
| 3424 | pending_completions: self.rx_subagent_completion.len(), |
| 3425 | } |
| 3426 | } |
| 3427 | |
| 3428 | async fn emit_session_updated(&self) { |
| 3429 | let _ = self |
| 3430 | .tx_event |
| 3431 | .send(Event::SessionUpdated { |
| 3432 | session_id: self.session.id.clone(), |
| 3433 | messages: self.session.messages.snapshot(), |
| 3434 | system_prompt: self.session.system_prompt.clone(), |
| 3435 | model: self.session.model.clone(), |
| 3436 | workspace: self.session.workspace.clone(), |
| 3437 | }) |
| 3438 | .await; |
| 3439 | } |
| 3440 | |
| 3441 | fn goal_snapshot_for_event(&self) -> Option<GoalSnapshot> { |
| 3442 | match self.config.goal_state.lock() { |
| 3443 | Ok(state) => { |
| 3444 | let snapshot = state.snapshot(); |
| 3445 | snapshot.objective.is_some().then_some(snapshot) |
| 3446 | } |
| 3447 | Err(err) => { |
| 3448 | tracing::warn!("goal state lock poisoned while emitting goal update: {err}"); |
| 3449 | None |
| 3450 | } |
| 3451 | } |
| 3452 | } |
| 3453 | |
| 3454 | async fn emit_goal_updated(&self) { |
| 3455 | if let Some(snapshot) = self.goal_snapshot_for_event() { |
| 3456 | let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; |
| 3457 | } |
| 3458 | } |
| 3459 | |
| 3460 | fn record_goal_usage_for_turn(&self, usage: &Usage, elapsed: std::time::Duration) { |
| 3461 | let token_delta = |
| 3462 | u64::from(usage.input_tokens).saturating_add(u64::from(usage.output_tokens)); |
| 3463 | let time_delta_seconds = elapsed.as_secs(); |
| 3464 | if token_delta == 0 && time_delta_seconds == 0 { |
| 3465 | return; |
| 3466 | } |
| 3467 | match self.config.goal_state.lock() { |
| 3468 | Ok(mut state) => state.record_usage(token_delta, time_delta_seconds), |
| 3469 | Err(err) => tracing::warn!("goal state lock poisoned while recording usage: {err}"), |
| 3470 | } |
| 3471 | } |
| 3472 | |
| 3473 | fn active_input_tokens_with_current_text( |
| 3474 | &self, |
| 3475 | current_text: &str, |
| 3476 | system_prompt: Option<&SystemPrompt>, |
| 3477 | ) -> usize { |
| 3478 | // Estimate the installed history IN PLACE — no full-transcript clone |
| 3479 | // per `<turn_meta>` build (#perf-r5). `&AppendLog` deref-coerces to |
| 3480 | // `&[Message]` exactly like the cache call site. |
| 3481 | let base = estimate_input_tokens_conservative(&self.session.messages, system_prompt); |
| 3482 | if current_text.trim().is_empty() { |
| 3483 | return base; |
| 3484 | } |
| 3485 | // Arithmetic equivalent of pushing one more user message: `own` |
| 3486 | // un-inflated tokens (Text block rule, `len()/4` — same as the |
| 3487 | // estimator's per-message byte sum S) plus one framing increment. |
| 3488 | // The estimator inflates S by ceil(3/2) as a WHOLE, so |
| 3489 | // ceil((S+own)*3/2) − ceil(S*3/2) = floor(own*3/2) + 1 exactly when |
| 3490 | // S is even and own is odd; pinned exhaustively (80k pairs) and per |
| 3491 | // case by `context_pressure_delta_matches_clone_and_push_reference`. |
| 3492 | let sum: usize = self |
| 3493 | .session |
| 3494 | .messages |
| 3495 | .iter() |
| 3496 | .map(|m| { |
| 3497 | crate::compaction::estimate_tokens_for_message( |
| 3498 | m, |
| 3499 | crate::compaction::message_has_tool_use(m), |
| 3500 | ) |
| 3501 | }) |
| 3502 | .sum(); |
| 3503 | let own = current_text.len() / 4; |
| 3504 | let mut inflated_delta = own * 3 / 2; |
| 3505 | if sum.is_multiple_of(2) && own % 2 == 1 { |
| 3506 | inflated_delta += 1; |
| 3507 | } |
| 3508 | base.saturating_add(inflated_delta).saturating_add(12) |
| 3509 | } |
| 3510 | |
| 3511 | fn append_resource_metadata_lines( |
| 3512 | &self, |
| 3513 | lines: &mut Vec<String>, |
| 3514 | current_text: &str, |
| 3515 | prompt_context: &NextTurnPromptContext, |
| 3516 | system_prompt: Option<&SystemPrompt>, |
| 3517 | ) { |
| 3518 | if let Some(line) = self.context_pressure_line(current_text, prompt_context, system_prompt) |
| 3519 | { |
| 3520 | lines.push(line); |
| 3521 | } |
| 3522 | if let Some(line) = self.active_goal_token_budget_line(prompt_context) { |
| 3523 | lines.push(line); |
| 3524 | } |
| 3525 | } |
| 3526 | |
| 3527 | /// Goal pacing for the model: the budget figure only, and only while a |
| 3528 | /// goal is actually active. Usage/time deltas, rates, and continuation |
| 3529 | /// counts are UI telemetry — they changed every turn and invalidated the |
| 3530 | /// prefix cache without adding model-steering signal. |
| 3531 | fn active_goal_token_budget_line( |
| 3532 | &self, |
| 3533 | prompt_context: &NextTurnPromptContext, |
| 3534 | ) -> Option<String> { |
| 3535 | let objective = prompt_context.goal_objective.as_deref()?; |
| 3536 | let snapshot = self.config.goal_state.lock().ok()?.snapshot(); |
| 3537 | let same_goal = |
| 3538 | normalized_goal_objective(snapshot.objective.as_deref()).as_deref() == Some(objective); |
| 3539 | let token_budget = if same_goal { |
| 3540 | snapshot.token_budget |
| 3541 | } else { |
| 3542 | prompt_context.goal_token_budget |
| 3543 | }?; |
| 3544 | Some(format!("Active goal token budget: {token_budget}")) |
| 3545 | } |
| 3546 | |
| 3547 | async fn add_session_message(&mut self, message: Message) { |
| 3548 | self.session.add_message(message); |
| 3549 | self.emit_session_updated().await; |
| 3550 | } |
| 3551 | |
| 3552 | async fn add_interrupted_assistant_text(&mut self, text: &str) { |
| 3553 | if text.is_empty() { |
| 3554 | return; |
| 3555 | } |
| 3556 | let message = Message { |
| 3557 | role: Role::InterruptedAssistant, |
| 3558 | content: vec![ContentBlock::Text { |
| 3559 | text: text.to_string(), |
| 3560 | cache_control: None, |
| 3561 | }], |
| 3562 | }; |
| 3563 | let already_committed = self.session.messages.last().is_some_and(|last| { |
| 3564 | matches!( |
| 3565 | last.role.as_str(), |
| 3566 | "assistant" | codewhale_models::INTERRUPTED_ASSISTANT_ROLE |
| 3567 | ) && last.content == message.content |
| 3568 | }); |
| 3569 | if already_committed { |
| 3570 | return; |
| 3571 | } |
| 3572 | self.add_session_message(message).await; |
| 3573 | } |
| 3574 | |
| 3575 | #[allow(clippy::too_many_arguments)] |
| 3576 | fn turn_metadata_block( |
| 3577 | &self, |
| 3578 | routed_model: &str, |
| 3579 | auto_model: bool, |
| 3580 | reasoning_effort: Option<&str>, |
| 3581 | reasoning_effort_auto: bool, |
| 3582 | provenance: UserInputProvenance, |
| 3583 | current_text: &str, |
| 3584 | policy_narrowing: Option<&PolicyNarrowingEvent>, |
| 3585 | ) -> ContentBlock { |
| 3586 | let prompt_context = self.installed_next_turn_prompt_context(); |
| 3587 | self.turn_metadata_block_from_snapshot( |
| 3588 | routed_model, |
| 3589 | auto_model, |
| 3590 | reasoning_effort, |
| 3591 | reasoning_effort_auto, |
| 3592 | provenance, |
| 3593 | current_text, |
| 3594 | TurnMetadataSnapshot { |
| 3595 | prompt_context: &prompt_context, |
| 3596 | system_prompt: self.session.system_prompt.as_ref(), |
| 3597 | approval_mode: self.session.approval_mode, |
| 3598 | working_set: &self.session.working_set, |
| 3599 | policy_narrowing, |
| 3600 | }, |
| 3601 | ) |
| 3602 | } |
| 3603 | |
| 3604 | /// Build `<turn_meta>` from an explicit snapshot of the session state a |
| 3605 | /// turn installs *before* it writes the block. |
| 3606 | /// |
| 3607 | /// Production installs approval posture, policy narrowing, and the |
| 3608 | /// observed working set on `self`, then reads them back here. |
| 3609 | /// `/preview-request` cannot install any of that — it describes a turn |
| 3610 | /// that has not started — so it passes the values it would have installed, |
| 3611 | /// including a *clone* of the working set with the hypothetical message |
| 3612 | /// already observed. That is what makes the previewed block byte-identical |
| 3613 | /// to the real one without a single write. |
| 3614 | #[allow(clippy::too_many_arguments)] |
| 3615 | fn turn_metadata_block_from_snapshot( |
| 3616 | &self, |
| 3617 | _routed_model: &str, |
| 3618 | _auto_model: bool, |
| 3619 | _reasoning_effort: Option<&str>, |
| 3620 | _reasoning_effort_auto: bool, |
| 3621 | provenance: UserInputProvenance, |
| 3622 | current_text: &str, |
| 3623 | snapshot: TurnMetadataSnapshot<'_>, |
| 3624 | ) -> ContentBlock { |
| 3625 | let TurnMetadataSnapshot { |
| 3626 | prompt_context, |
| 3627 | system_prompt, |
| 3628 | approval_mode, |
| 3629 | working_set, |
| 3630 | policy_narrowing, |
| 3631 | } = snapshot; |
| 3632 | let today = chrono::Local::now().format("%Y-%m-%d").to_string(); |
| 3633 | let working_set_summary = working_set |
| 3634 | .summary_block(&self.config.workspace) |
| 3635 | .map(|s| s.trim().to_string()) |
| 3636 | .filter(|s| !s.is_empty()); |
| 3637 | |
| 3638 | // Facts only (#4780 + turn-meta diet). Mode behavior lives in runtime |
| 3639 | // policy and the tool catalog, not prose. Preserve the compact |
| 3640 | // permission label so the model can distinguish Ask, Auto-Review, Full |
| 3641 | // Access, and Never without repeating question-discipline prose. |
| 3642 | // Route/effort/model lines are telemetry the model cannot act on. |
| 3643 | // DGF-02 (dogfood 2026-08-02): the model was never told its own |
| 3644 | // sandbox posture, so an approved-then-sandbox-blocked write read as |
| 3645 | // a mystery failure it burned turns "debugging". Derive the posture |
| 3646 | // from the same resolver tool execution uses. The execution boundary |
| 3647 | // is snapshotted at engine construction: local OS wrapper, configured |
| 3648 | // external backend, or unavailable. External raw-command backends do |
| 3649 | // not inherit local workspace/network enforcement claims. Stable per |
| 3650 | // session, so ordinary turns stay byte-identical. |
| 3651 | let sandbox_posture = crate::core::authority::sandbox_policy_for_turn( |
| 3652 | prompt_context.mode, |
| 3653 | approval_mode, |
| 3654 | self.api_config.sandbox_mode.as_deref(), |
| 3655 | &self.config.workspace, |
| 3656 | crate::core::authority::SandboxNetworkAccess::from_config( |
| 3657 | self.api_config.sandbox_network_access, |
| 3658 | ), |
| 3659 | ); |
| 3660 | let mut lines = vec![ |
| 3661 | format!("Current local date: {today}"), |
| 3662 | // Workspace path moved here from the static `## Environment` block so |
| 3663 | // the static system prefix stays byte-stable across sessions (see |
| 3664 | // `render_environment_block` for the prefix-cache rationale). |
| 3665 | format!("Current workspace: {}", self.config.workspace.display()), |
| 3666 | format!( |
| 3667 | "Current permission posture: {}", |
| 3668 | approval_mode.permission_chip_label() |
| 3669 | ), |
| 3670 | format!( |
| 3671 | "Current sandbox posture: {}", |
| 3672 | sandbox_posture.posture_label_with_enforcement_and_no_new_privs( |
| 3673 | self.sandbox_enforcement, |
| 3674 | // Fixed at process start, so the per-turn line stays |
| 3675 | // byte-stable for the session. |
| 3676 | crate::sandbox::process_hardening::no_new_privs_active(), |
| 3677 | ) |
| 3678 | ), |
| 3679 | ]; |
| 3680 | if approval_mode == ApprovalMode::Never { |
| 3681 | lines.push( |
| 3682 | "Approval prompts are disabled; do not request escalation for this turn." |
| 3683 | .to_string(), |
| 3684 | ); |
| 3685 | } |
| 3686 | // On ordinary external turns the user's own message is authoritative by |
| 3687 | // construction, so provenance is redundant. On non-external turns |
| 3688 | // (sub-agent handoff, runtime events) the *reduced* authority is the |
| 3689 | // sole signal, so surface it as one condensed line. |
| 3690 | if !provenance.can_authorize_work() { |
| 3691 | lines.push(format!( |
| 3692 | "Input provenance: {} (non-authoritative)", |
| 3693 | provenance.as_str() |
| 3694 | )); |
| 3695 | } |
| 3696 | // #3947: when runtime policy narrowed this turn's authority, the model |
| 3697 | // learns that it happened, why, and the exact sentence the user saw. |
| 3698 | // Emitted only on a narrowed turn, so the ordinary turn's metadata |
| 3699 | // stays byte-stable. |
| 3700 | if let Some(event) = policy_narrowing { |
| 3701 | lines.push(format!("Authority narrowing: {}", event.reason().as_str())); |
| 3702 | lines.push(format!("Authority transition: {}", event.transition())); |
| 3703 | lines.push(format!("Authority narrowing status: {}", event.message())); |
| 3704 | } |
| 3705 | self.append_resource_metadata_lines( |
| 3706 | &mut lines, |
| 3707 | current_text, |
| 3708 | prompt_context, |
| 3709 | system_prompt, |
| 3710 | ); |
| 3711 | if let Some(working_set_summary) = working_set_summary { |
| 3712 | lines.push(working_set_summary); |
| 3713 | } |
| 3714 | // #5187 (k3-gap F3): the git snapshot re-collects branch/dirty state |
| 3715 | // every turn, so the line's bytes changed after every edit the model |
| 3716 | // itself made — churning the block and priming caution each turn. |
| 3717 | // Emit it only when the snapshot actually changed since the last |
| 3718 | // emitted block; the model can always run `git status` for a fresh |
| 3719 | // read. |
| 3720 | if let Some(git_snapshot) = crate::tui::workspace_context::collect(&self.config.workspace) { |
| 3721 | let mut last = self |
| 3722 | .last_turn_meta_git_snapshot |
| 3723 | .lock() |
| 3724 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 3725 | if last.as_deref() != Some(git_snapshot.as_str()) { |
| 3726 | *last = Some(git_snapshot.clone()); |
| 3727 | lines.push(format!("Git workspace: {git_snapshot}")); |
| 3728 | } |
| 3729 | } |
| 3730 | let summary = lines.join("\n"); |
| 3731 | |
| 3732 | ContentBlock::Text { |
| 3733 | text: format!("<turn_meta>\n{summary}\n</turn_meta>"), |
| 3734 | cache_control: None, |
| 3735 | } |
| 3736 | } |
| 3737 | |
| 3738 | /// Assemble the content blocks of a user turn. |
| 3739 | /// |
| 3740 | /// The text comes first and the turn metadata last — both positions are |
| 3741 | /// load-bearing for prompt caching (see |
| 3742 | /// [`Self::turn_metadata_block`]), so resolved images are inserted between |
| 3743 | /// them rather than at either end. |
| 3744 | /// |
| 3745 | /// The composer stores an attachment as a `[Attached image: …]` text line |
| 3746 | /// and the bytes are read here, once, as the message is built. That keeps |
| 3747 | /// multi-megabyte payloads out of the composer and undo history, and it |
| 3748 | /// means deleting the line deletes the attachment for free. Anything that |
| 3749 | /// cannot be attached becomes a visible notice instead of vanishing. |
| 3750 | /// |
| 3751 | /// Whether the model can *see* the result is decided per request, not |
| 3752 | /// here — see `image_attach::strip_images_when_unsupported`. |
| 3753 | fn user_content_blocks(&self, text: String) -> Vec<ContentBlock> { |
| 3754 | // Managed Chat accepts validated inline bytes, never host paths. Treat |
| 3755 | // attachment-marker syntax as an omitted attachment so an account prompt can never make |
| 3756 | // this host read a local path or echo that host path to a provider. |
| 3757 | if self.api_config.runtime_chat_isolated { |
| 3758 | return vec![ContentBlock::Text { |
| 3759 | text: sanitize_isolated_chat_attachments(text), |
| 3760 | cache_control: None, |
| 3761 | }]; |
| 3762 | } |
| 3763 | let recommended_plugins = { |
| 3764 | let mut recommended_plugin_gate = self |
| 3765 | .recommended_plugin_gate |
| 3766 | .lock() |
| 3767 | .unwrap_or_else(std::sync::PoisonError::into_inner); |
| 3768 | crate::plugins::recommend::recommended_plugins_user_fragment( |
| 3769 | &text, |
| 3770 | self.plugin_registry.as_ref(), |
| 3771 | &crate::plugins::recommend::load_marketplace_candidates( |
| 3772 | self.plugin_registry.state_path(), |
| 3773 | ), |
| 3774 | &mut recommended_plugin_gate, |
| 3775 | ) |
| 3776 | }; |
| 3777 | let expanded = crate::image_attach::expand_attachment_blocks(&text); |
| 3778 | let mut content = Vec::with_capacity(3 + expanded.blocks.len()); |
| 3779 | content.push(ContentBlock::Text { |
| 3780 | text, |
| 3781 | cache_control: None, |
| 3782 | }); |
| 3783 | // Append-only on this turn. Never spliced into the pinned system prefix. |
| 3784 | if let Some(fragment) = recommended_plugins { |
| 3785 | content.push(ContentBlock::Text { |
| 3786 | text: fragment, |
| 3787 | cache_control: None, |
| 3788 | }); |
| 3789 | } |
| 3790 | content.extend(expanded.blocks); |
| 3791 | if let Some(notice) = crate::image_attach::notice_block(&expanded.notices) { |
| 3792 | content.push(notice); |
| 3793 | } |
| 3794 | content |
| 3795 | } |
| 3796 | |
| 3797 | /// The user message a turn would build, from an explicit state snapshot. |
| 3798 | /// |
| 3799 | /// Same block order and same constructor as |
| 3800 | /// [`Self::user_text_message_with_turn_metadata_for_route_and_provenance`]; |
| 3801 | /// only the source of the turn-metadata inputs differs. See |
| 3802 | /// [`Self::turn_metadata_block_from_snapshot`]. |
| 3803 | #[allow(clippy::too_many_arguments)] |
| 3804 | pub(super) fn user_text_message_from_snapshot( |
| 3805 | &self, |
| 3806 | text: String, |
| 3807 | routed_model: &str, |
| 3808 | auto_model: bool, |
| 3809 | reasoning_effort: Option<&str>, |
| 3810 | reasoning_effort_auto: bool, |
| 3811 | provenance: UserInputProvenance, |
| 3812 | snapshot: TurnMetadataSnapshot<'_>, |
| 3813 | ) -> Message { |
| 3814 | let turn_metadata = (!self.api_config.runtime_chat_isolated).then(|| { |
| 3815 | self.turn_metadata_block_from_snapshot( |
| 3816 | routed_model, |
| 3817 | auto_model, |
| 3818 | reasoning_effort, |
| 3819 | reasoning_effort_auto, |
| 3820 | provenance, |
| 3821 | &text, |
| 3822 | snapshot, |
| 3823 | ) |
| 3824 | }); |
| 3825 | let mut content = self.user_content_blocks(text); |
| 3826 | if let Some(turn_metadata) = turn_metadata { |
| 3827 | content.push(turn_metadata); |
| 3828 | } |
| 3829 | Message { |
| 3830 | role: Role::User, |
| 3831 | content, |
| 3832 | } |
| 3833 | } |
| 3834 | |
| 3835 | fn user_text_message_with_turn_metadata(&self, text: String) -> Message { |
| 3836 | self.user_text_message_with_turn_metadata_for_route( |
| 3837 | text, |
| 3838 | &self.session.model, |
| 3839 | self.session.auto_model, |
| 3840 | self.session.reasoning_effort.as_deref(), |
| 3841 | self.session.reasoning_effort_auto, |
| 3842 | ) |
| 3843 | } |
| 3844 | |
| 3845 | fn user_text_message_with_turn_metadata_for_route( |
| 3846 | &self, |
| 3847 | text: String, |
| 3848 | routed_model: &str, |
| 3849 | auto_model: bool, |
| 3850 | reasoning_effort: Option<&str>, |
| 3851 | reasoning_effort_auto: bool, |
| 3852 | ) -> Message { |
| 3853 | self.user_text_message_with_turn_metadata_for_route_and_provenance( |
| 3854 | text, |
| 3855 | routed_model, |
| 3856 | auto_model, |
| 3857 | reasoning_effort, |
| 3858 | reasoning_effort_auto, |
| 3859 | UserInputProvenance::ExternalUser, |
| 3860 | ) |
| 3861 | } |
| 3862 | |
| 3863 | fn runtime_text_message_with_turn_metadata( |
| 3864 | &self, |
| 3865 | text: String, |
| 3866 | provenance: UserInputProvenance, |
| 3867 | ) -> Message { |
| 3868 | self.user_text_message_with_turn_metadata_for_route_and_provenance( |
| 3869 | text, |
| 3870 | &self.session.model, |
| 3871 | self.session.auto_model, |
| 3872 | self.session.reasoning_effort.as_deref(), |
| 3873 | self.session.reasoning_effort_auto, |
| 3874 | provenance, |
| 3875 | ) |
| 3876 | } |
| 3877 | |
| 3878 | fn user_text_message_with_turn_metadata_for_route_and_provenance( |
| 3879 | &self, |
| 3880 | text: String, |
| 3881 | routed_model: &str, |
| 3882 | auto_model: bool, |
| 3883 | reasoning_effort: Option<&str>, |
| 3884 | reasoning_effort_auto: bool, |
| 3885 | provenance: UserInputProvenance, |
| 3886 | ) -> Message { |
| 3887 | // Place the user text first and turn_meta last so that the leading |
| 3888 | // bytes of each user message stay stable across date / model-route / |
| 3889 | // working-set changes. DeepSeek's KV prefix cache matches byte |
| 3890 | // sequences from the start of each message; when turn_meta (which |
| 3891 | // contains the current date) sits at position 0 the entire user |
| 3892 | // message prefix is invalidated at every date boundary. Moving it |
| 3893 | // to the tail preserves the user-input prefix and limits cache |
| 3894 | // invalidation to the trailing metadata block. |
| 3895 | let turn_metadata = (!self.api_config.runtime_chat_isolated).then(|| { |
| 3896 | self.turn_metadata_block( |
| 3897 | routed_model, |
| 3898 | auto_model, |
| 3899 | reasoning_effort, |
| 3900 | reasoning_effort_auto, |
| 3901 | provenance, |
| 3902 | &text, |
| 3903 | self.last_policy_narrowing.as_ref(), |
| 3904 | ) |
| 3905 | }); |
| 3906 | let mut content = self.user_content_blocks(text); |
| 3907 | if let Some(turn_metadata) = turn_metadata { |
| 3908 | content.push(turn_metadata); |
| 3909 | } |
| 3910 | Message { |
| 3911 | role: Role::User, |
| 3912 | content, |
| 3913 | } |
| 3914 | } |
| 3915 | |
| 3916 | async fn handle_idle_subagent_completion(&mut self, first: SubAgentCompletion) { |
| 3917 | // Cancellation can race the idle receive, just as it can race a |
| 3918 | // background-shell wake. Keep the receipt queued for the next explicit |
| 3919 | // turn; canceled workers must not restart their interrupted parent. |
| 3920 | if self.cancel_token.is_cancelled() { |
| 3921 | let _ = self.tx_subagent_completion.try_send(first); |
| 3922 | return; |
| 3923 | } |
| 3924 | let mut completions = Vec::new(); |
| 3925 | if let Some(completion) = claim_subagent_completion_for_session( |
| 3926 | &mut self.delivered_subagent_completion_ids, |
| 3927 | &self.session.id, |
| 3928 | first, |
| 3929 | ) { |
| 3930 | completions.push(completion); |
| 3931 | } |
| 3932 | while let Ok(completion) = self.rx_subagent_completion.try_recv() { |
| 3933 | if let Some(completion) = claim_subagent_completion_for_session( |
| 3934 | &mut self.delivered_subagent_completion_ids, |
| 3935 | &self.session.id, |
| 3936 | completion, |
| 3937 | ) { |
| 3938 | completions.push(completion); |
| 3939 | } |
| 3940 | } |
| 3941 | |
| 3942 | if completions.is_empty() { |
| 3943 | return; |
| 3944 | } |
| 3945 | |
| 3946 | let claimed_ids = completions |
| 3947 | .iter() |
| 3948 | .map(|completion| completion.agent_id.clone()) |
| 3949 | .collect::<Vec<_>>(); |
| 3950 | let route = match self.current_runtime_route() { |
| 3951 | Ok(route) => route, |
| 3952 | Err(err) => { |
| 3953 | for agent_id in claimed_ids { |
| 3954 | self.delivered_subagent_completion_ids.remove(&agent_id); |
| 3955 | } |
| 3956 | let _ = self |
| 3957 | .tx_event |
| 3958 | .send(Event::error(ErrorEnvelope::fatal_auth(format!( |
| 3959 | "Cannot resume the turn because its provider route is no longer valid: {err}" |
| 3960 | )))) |
| 3961 | .await; |
| 3962 | let outcome = SendMessageOutcome::NotStarted { |
| 3963 | error: Some(format!("provider route is no longer valid: {err}")), |
| 3964 | }; |
| 3965 | self.reconcile_non_completed_goal_turn(&outcome).await; |
| 3966 | return; |
| 3967 | } |
| 3968 | }; |
| 3969 | |
| 3970 | let count = completions.len(); |
| 3971 | let content = completions |
| 3972 | .iter() |
| 3973 | .map(|completion| { |
| 3974 | if completion.is_high_priority_failure() { |
| 3975 | crate::runtime_handoff::subagent_failure_runtime_text(&completion.payload) |
| 3976 | } else { |
| 3977 | crate::runtime_handoff::subagent_completion_runtime_text(&completion.payload) |
| 3978 | } |
| 3979 | }) |
| 3980 | .collect::<Vec<_>>() |
| 3981 | .join("\n\n"); |
| 3982 | |
| 3983 | let failed = completions |
| 3984 | .iter() |
| 3985 | .filter(|completion| completion.is_high_priority_failure()) |
| 3986 | .count(); |
| 3987 | let failure_suffix = if failed == 0 { |
| 3988 | String::new() |
| 3989 | } else { |
| 3990 | format!(" ({failed} failed)") |
| 3991 | }; |
| 3992 | |
| 3993 | let _ = self |
| 3994 | .tx_event |
| 3995 | .send(Event::status(format!( |
| 3996 | "Resuming turn with {count} idle sub-agent completion(s){failure_suffix}" |
| 3997 | ))) |
| 3998 | .await; |
| 3999 | |
| 4000 | let outcome = self |
| 4001 | .handle_send_message(TurnSpec { |
| 4002 | content, |
| 4003 | mode: self.current_mode, |
| 4004 | route: Box::new(route), |
| 4005 | compaction: Box::new(self.config.compaction.clone()), |
| 4006 | initial_routed_usage: Box::new(crate::cost_status::RuntimeUsageBatch::default()), |
| 4007 | goal_objective: self.config.goal_objective.clone(), |
| 4008 | goal_token_budget: self.config.goal_token_budget, |
| 4009 | goal_status: self.config.goal_status, |
| 4010 | reasoning_effort: self.session.reasoning_effort.clone(), |
| 4011 | reasoning_effort_auto: self.session.reasoning_effort_auto, |
| 4012 | auto_model: self.session.auto_model, |
| 4013 | allow_shell: self.session.allow_shell, |
| 4014 | trust_mode: self.session.trust_mode, |
| 4015 | auto_approve: self.session.auto_approve, |
| 4016 | approval_mode: self.session.approval_mode, |
| 4017 | translation_enabled: self.config.translation_enabled, |
| 4018 | allowed_tools: self.config.allowed_tools.clone(), |
| 4019 | dynamic_tools: Vec::new(), |
| 4020 | hook_executor: self.config.hook_executor.clone(), |
| 4021 | verbosity: self.config.verbosity.clone(), |
| 4022 | provenance: UserInputProvenance::SubAgentHandoff, |
| 4023 | images: Vec::new(), |
| 4024 | max_output_tokens: None, |
| 4025 | }) |
| 4026 | .await; |
| 4027 | if !outcome.started() { |
| 4028 | for agent_id in claimed_ids { |
| 4029 | self.delivered_subagent_completion_ids.remove(&agent_id); |
| 4030 | } |
| 4031 | if self.cancel_token.is_cancelled() { |
| 4032 | // Admission lost to cancellation before the transcript took |
| 4033 | // ownership. Leave these receipts for the next explicit turn. |
| 4034 | for completion in completions { |
| 4035 | let _ = self.tx_subagent_completion.try_send(completion); |
| 4036 | } |
| 4037 | } |
| 4038 | } |
| 4039 | } |
| 4040 | |
| 4041 | /// Handle a send message operation |
| 4042 | #[allow(clippy::too_many_arguments)] |
| 4043 | /// After a turn completes, decide whether an active goal should keep going. |
| 4044 | /// Returns a continuation to dispatch, an explicit terminal backstop stop, |
| 4045 | /// or Inactive when no follow-up turn belongs in the queue. |
| 4046 | /// |
| 4047 | /// A goal runs until the model self-reports done/blocked or the user pauses |
| 4048 | /// or clears. Token/time accounting remains telemetry. The loop is "until |
| 4049 | /// done," not "until N turns" (#5052); a configurable safety |
| 4050 | /// backstop (`[goal] max_continuations`, `0` = unlimited) still halts a |
| 4051 | /// pathological loop that never emits a terminal signal. |
| 4052 | fn goal_continuation_if_active(&self) -> GoalContinuationAction { |
| 4053 | let mut state = match self.config.goal_state.lock() { |
| 4054 | Ok(state) => state, |
| 4055 | Err(err) => { |
| 4056 | tracing::warn!("goal state lock poisoned during continuation check: {err}"); |
| 4057 | return GoalContinuationAction::Inactive; |
| 4058 | } |
| 4059 | }; |
| 4060 | let snapshot = state.snapshot(); |
| 4061 | if !snapshot.is_active() { |
| 4062 | return GoalContinuationAction::Inactive; |
| 4063 | } |
| 4064 | |
| 4065 | // The snapshot status is a string ("active", "paused", "complete", |
| 4066 | // "blocked"). Map it to the goal-loop decision core's status enum. |
| 4067 | let status = match snapshot.status.as_str() { |
| 4068 | "active" => crate::goal_loop::GoalRunStatus::Active, |
| 4069 | "complete" => crate::goal_loop::GoalRunStatus::Completed, |
| 4070 | // Paused / Blocked / unknown → no continuation. |
| 4071 | _ => return GoalContinuationAction::Inactive, |
| 4072 | }; |
| 4073 | |
| 4074 | let decision = crate::goal_loop::decide_continuation( |
| 4075 | status, |
| 4076 | crate::goal_loop::GoalProgress { |
| 4077 | tokens_used: snapshot.tokens_used, |
| 4078 | time_used_seconds: snapshot.time_used_seconds, |
| 4079 | continuations: snapshot.continuation_count, |
| 4080 | }, |
| 4081 | // Unbounded like grokbuild (agent-call cap) and kimicode swarm |
| 4082 | // (turnBudget per-task, resumable): token/time are telemetry only |
| 4083 | // unless `[goal] enforce_token_budget` opts a set budget into a |
| 4084 | // hard stop (#6013); otherwise only Completed/Blocked/ |
| 4085 | // ContinuationLimit pause the loop. |
| 4086 | crate::goal_loop::GoalBudget::unbounded() |
| 4087 | .with_enforced_token_budget(self.config.goal_enforce_token_budget) |
| 4088 | .with_max_continuations(self.config.goal_max_continuations), |
| 4089 | ); |
| 4090 | |
| 4091 | match decision { |
| 4092 | crate::goal_loop::ContinuationDecision::Continue => { |
| 4093 | // A cross-turn dispatch is a real continuation pass just like |
| 4094 | // the bounded intra-turn retry in `turn_loop`. Record it before |
| 4095 | // rendering and carrying the snapshot so the durable prompt, |
| 4096 | // telemetry, and next host sync all agree on the pass number. |
| 4097 | state.record_continuation(); |
| 4098 | let snapshot = state.snapshot(); |
| 4099 | GoalContinuationAction::Dispatch { |
| 4100 | content: crate::tools::goal::render_continuation_prompt( |
| 4101 | &snapshot, |
| 4102 | snapshot.continuation_count, |
| 4103 | ), |
| 4104 | snapshot: Box::new(snapshot), |
| 4105 | } |
| 4106 | } |
| 4107 | crate::goal_loop::ContinuationDecision::Stop(reason) => { |
| 4108 | tracing::info!(?reason, "goal continuation stopped"); |
| 4109 | let (message, pause_reason) = match reason { |
| 4110 | crate::goal_loop::StopReason::ContinuationLimit => ( |
| 4111 | format!( |
| 4112 | "Goal paused after {} automatic continuations without a terminal result (safety backstop; raise or disable via [goal] max_continuations); inspect progress, then resume if useful.", |
| 4113 | self.config.goal_max_continuations, |
| 4114 | ), |
| 4115 | GoalPauseReason::Backoff, |
| 4116 | ), |
| 4117 | crate::goal_loop::StopReason::BudgetLimit => ( |
| 4118 | "Goal paused: the goal's token budget was reached and \ |
| 4119 | [goal] enforce_token_budget makes that a hard stop; \ |
| 4120 | raise the budget or resume to continue." |
| 4121 | .to_string(), |
| 4122 | GoalPauseReason::BudgetLimit, |
| 4123 | ), |
| 4124 | crate::goal_loop::StopReason::Completed |
| 4125 | | crate::goal_loop::StopReason::Blocked => { |
| 4126 | return GoalContinuationAction::Inactive; |
| 4127 | } |
| 4128 | }; |
| 4129 | GoalContinuationAction::Stopped { |
| 4130 | message, |
| 4131 | reason: pause_reason, |
| 4132 | } |
| 4133 | } |
| 4134 | } |
| 4135 | } |
| 4136 | |
| 4137 | /// Reject an edit operation before model dispatch while still completing |
| 4138 | /// the submitted host lifecycle. `Event::Error` is advisory to embedded |
| 4139 | /// hosts; `TurnComplete(Failed)` is the authoritative terminal signal that |
| 4140 | /// releases their busy state and closes the admitted operation. |
| 4141 | async fn reject_edit_last_turn(&mut self, envelope: ErrorEnvelope) { |
| 4142 | let message = envelope.message.clone(); |
| 4143 | let _ = self.tx_event.send(Event::error(envelope)).await; |
| 4144 | let _ = self |
| 4145 | .tx_event |
| 4146 | .send(Event::TurnComplete { |
| 4147 | usage: Usage::default(), |
| 4148 | parent_route_usage: Usage::default(), |
| 4149 | routed_usage_dropped_records: 0, |
| 4150 | status: TurnOutcomeStatus::Failed, |
| 4151 | error: Some(message.clone()), |
| 4152 | tool_catalog: None, |
| 4153 | base_url: None, |
| 4154 | }) |
| 4155 | .await; |
| 4156 | let outcome = SendMessageOutcome::NotStarted { |
| 4157 | error: Some(message), |
| 4158 | }; |
| 4159 | self.reconcile_non_completed_goal_turn(&outcome).await; |
| 4160 | } |
| 4161 | |
| 4162 | /// Reconcile a turn that did not complete with the autonomous goal loop. |
| 4163 | /// Hosted engines leave lifecycle decisions to their durable host. The |
| 4164 | /// interactive engine must cancel any older queued synthetic token first, |
| 4165 | /// then project an active goal into a truthful non-running state. |
| 4166 | async fn reconcile_non_completed_goal_turn(&mut self, outcome: &SendMessageOutcome) { |
| 4167 | if self.host_managed_turns() { |
| 4168 | return; |
| 4169 | } |
| 4170 | |
| 4171 | self.cancel_scheduled_goal_continuation(false).await; |
| 4172 | match outcome { |
| 4173 | SendMessageOutcome::NotStarted { error } => { |
| 4174 | let message = self.goal_turn_not_started_message(error.as_deref()); |
| 4175 | self.block_goal_continuation(message).await; |
| 4176 | } |
| 4177 | SendMessageOutcome::Finished { |
| 4178 | status: TurnOutcomeStatus::Failed, |
| 4179 | error, |
| 4180 | } => { |
| 4181 | let message = self.goal_continuation_failure_message(error.as_deref()); |
| 4182 | self.block_goal_continuation(message).await; |
| 4183 | } |
| 4184 | SendMessageOutcome::Finished { |
| 4185 | status: TurnOutcomeStatus::Interrupted, |
| 4186 | .. |
| 4187 | } => { |
| 4188 | // Goals are durable session objectives. An interrupted model |
| 4189 | // turn (Esc, steer, compaction, cancel) must cancel only the |
| 4190 | // auto-continuation timer — already done above — and leave the |
| 4191 | // goal Active. pause_reason=User is reserved for explicit |
| 4192 | // `/goal pause`. Requiring `/goal resume` after every interrupt |
| 4193 | // was a dogfood lie (2026-07-24). |
| 4194 | let message = if self |
| 4195 | .goal_snapshot_for_event() |
| 4196 | .is_some_and(|goal| goal.is_active()) |
| 4197 | { |
| 4198 | "Turn interrupted; session goal stays active." |
| 4199 | } else { |
| 4200 | "Turn interrupted." |
| 4201 | }; |
| 4202 | let _ = self.tx_event.send(Event::status(message.to_string())).await; |
| 4203 | } |
| 4204 | SendMessageOutcome::Finished { |
| 4205 | status: TurnOutcomeStatus::Completed, |
| 4206 | .. |
| 4207 | } => {} |
| 4208 | } |
| 4209 | } |
| 4210 | |
| 4211 | /// A route/client rejection can happen before normal turn setup copies the |
| 4212 | /// host's just-declared goal into SharedGoalState. Seed only that goal |
| 4213 | /// descriptor so the rejection can publish a truthful Blocked snapshot; |
| 4214 | /// no user message or provider turn state is mutated here. |
| 4215 | fn sync_unstarted_goal_for_terminal_projection( |
| 4216 | &mut self, |
| 4217 | objective: Option<&str>, |
| 4218 | token_budget: Option<u32>, |
| 4219 | status: GoalStatus, |
| 4220 | ) { |
| 4221 | let objective = normalized_goal_objective(objective); |
| 4222 | if objective.is_none() || status != GoalStatus::Active { |
| 4223 | return; |
| 4224 | } |
| 4225 | sync_goal_state_from_host( |
| 4226 | &self.config.goal_state, |
| 4227 | objective.as_deref(), |
| 4228 | token_budget, |
| 4229 | status, |
| 4230 | ); |
| 4231 | self.config.goal_objective = objective; |
| 4232 | self.config.goal_token_budget = token_budget; |
| 4233 | self.config.goal_status = status; |
| 4234 | } |
| 4235 | |
| 4236 | /// Transition a still-active interactive goal to Blocked and publish every |
| 4237 | /// host projection in one ordered path. Continuation failures happen |
| 4238 | /// outside a model tool call, so without this bridge the loop can stop while |
| 4239 | /// the prompt and sidebar continue to claim the goal is actively running. |
| 4240 | async fn block_goal_continuation(&mut self, message: String) { |
| 4241 | let snapshot = match self.config.goal_state.lock() { |
| 4242 | Ok(mut state) => { |
| 4243 | if state.is_active() |
| 4244 | && let Err(err) = state.mark_blocked(message.clone()) |
| 4245 | { |
| 4246 | tracing::warn!("failed to mark goal continuation blocked: {err}"); |
| 4247 | return; |
| 4248 | } |
| 4249 | let snapshot = state.snapshot(); |
| 4250 | if snapshot.status != GoalStatus::Blocked.as_str() { |
| 4251 | tracing::warn!( |
| 4252 | status = %snapshot.status, |
| 4253 | "goal changed before continuation blocker could be published" |
| 4254 | ); |
| 4255 | return; |
| 4256 | } |
| 4257 | snapshot |
| 4258 | } |
| 4259 | Err(err) => { |
| 4260 | tracing::warn!("goal state lock poisoned while blocking continuation: {err}"); |
| 4261 | return; |
| 4262 | } |
| 4263 | }; |
| 4264 | |
| 4265 | self.config.goal_objective.clone_from(&snapshot.objective); |
| 4266 | self.config.goal_token_budget = snapshot.token_budget; |
| 4267 | self.config.goal_status = GoalStatus::Blocked; |
| 4268 | self.refresh_system_prompt_with_reason("goal"); |
| 4269 | self.emit_session_updated().await; |
| 4270 | let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; |
| 4271 | let _ = self.tx_event.send(Event::status(message)).await; |
| 4272 | } |
| 4273 | |
| 4274 | /// Pause a still-active goal with an inspectable reason and publish every |
| 4275 | /// host projection in one ordered path. |
| 4276 | async fn pause_goal_continuation(&mut self, reason: GoalPauseReason, message: String) { |
| 4277 | let snapshot = match self.config.goal_state.lock() { |
| 4278 | Ok(mut state) => { |
| 4279 | if !state.is_active() { |
| 4280 | return; |
| 4281 | } |
| 4282 | if let Err(err) = state.mark_paused(reason) { |
| 4283 | tracing::warn!("failed to pause goal continuation: {err}"); |
| 4284 | return; |
| 4285 | } |
| 4286 | state.snapshot() |
| 4287 | } |
| 4288 | Err(err) => { |
| 4289 | tracing::warn!("goal state lock poisoned while pausing interruption: {err}"); |
| 4290 | return; |
| 4291 | } |
| 4292 | }; |
| 4293 | |
| 4294 | self.config.goal_objective.clone_from(&snapshot.objective); |
| 4295 | self.config.goal_token_budget = snapshot.token_budget; |
| 4296 | self.config.goal_status = GoalStatus::Paused; |
| 4297 | self.refresh_system_prompt_with_reason("goal"); |
| 4298 | self.emit_session_updated().await; |
| 4299 | let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; |
| 4300 | let _ = self.tx_event.send(Event::status(message)).await; |
| 4301 | } |
| 4302 | |
| 4303 | /// Handle `/goal pause|resume|clear|complete|blocked` by writing the new |
| 4304 | /// status to `SharedGoalState` so the cross-turn continuation loop respects |
| 4305 | /// it. This does NOT dispatch a model turn — it's a control-plane update. |
| 4306 | async fn handle_set_goal_status( |
| 4307 | &mut self, |
| 4308 | status: GoalStatus, |
| 4309 | clear: bool, |
| 4310 | goal_id: Option<String>, |
| 4311 | ) { |
| 4312 | if clear || status != GoalStatus::Active { |
| 4313 | self.cancel_scheduled_goal_continuation(true).await; |
| 4314 | } |
| 4315 | // A continuation is scheduled only on a real transition INTO Active |
| 4316 | // from a non-active state (paused/blocked resume). Re-asserting |
| 4317 | // Active on an already-active goal must not stack a second |
| 4318 | // autonomous turn on top of the loop that is already running. |
| 4319 | let was_active = self |
| 4320 | .config |
| 4321 | .goal_state |
| 4322 | .lock() |
| 4323 | .map(|state| state.is_active()) |
| 4324 | .unwrap_or(false); |
| 4325 | let snapshot = match self.config.goal_state.lock() { |
| 4326 | Ok(mut state) => { |
| 4327 | if clear { |
| 4328 | // `/goal clear` — wipe the objective entirely. |
| 4329 | state.sync_from_host_status(None, None, GoalStatus::Active); |
| 4330 | } else { |
| 4331 | // Update only the status; keep the objective and budget. |
| 4332 | // `sync_from_host_status` resets usage when the objective |
| 4333 | // changes, but here we pass the existing objective so usage |
| 4334 | // is preserved (pause/resume shouldn't reset the counter). |
| 4335 | let objective = state.objective().map(str::to_string); |
| 4336 | let budget = state.token_budget(); |
| 4337 | if status == GoalStatus::Active { |
| 4338 | state.resume(goal_id); |
| 4339 | } else { |
| 4340 | state.sync_from_host_status(objective.as_deref(), budget, status); |
| 4341 | } |
| 4342 | } |
| 4343 | state.snapshot() |
| 4344 | } |
| 4345 | Err(err) => { |
| 4346 | tracing::warn!("goal state lock poisoned during SetGoalStatus: {err}"); |
| 4347 | return; |
| 4348 | } |
| 4349 | }; |
| 4350 | |
| 4351 | // Keep every host-side projection aligned with the authoritative |
| 4352 | // SharedGoalState. In particular, a cleared state must also clear the |
| 4353 | // configured fallback used by `goal_objective_for_prompt`; otherwise a |
| 4354 | // prompt refresh would silently restore the old <session_goal> block. |
| 4355 | self.config.goal_objective.clone_from(&snapshot.objective); |
| 4356 | self.config.goal_token_budget = snapshot.token_budget; |
| 4357 | self.config.goal_status = if snapshot.objective.is_some() { |
| 4358 | status |
| 4359 | } else { |
| 4360 | GoalStatus::Active |
| 4361 | }; |
| 4362 | self.refresh_system_prompt_with_reason("goal"); |
| 4363 | self.emit_session_updated().await; |
| 4364 | // Unlike routine end-of-turn updates, an explicit clear must publish |
| 4365 | // the canonical empty snapshot. Keeping this scoped to the control op |
| 4366 | // avoids an unrelated no-goal turn racing with a newly declared goal in |
| 4367 | // the UI while still letting the clear win over a preceding active |
| 4368 | // TurnComplete snapshot. |
| 4369 | let snapshot_has_objective = snapshot.objective.is_some(); |
| 4370 | let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; |
| 4371 | |
| 4372 | let label = if clear { |
| 4373 | "cleared" |
| 4374 | } else { |
| 4375 | match status { |
| 4376 | GoalStatus::Active => "resumed", |
| 4377 | GoalStatus::Paused => "paused", |
| 4378 | GoalStatus::Complete => "complete", |
| 4379 | GoalStatus::Blocked => "blocked", |
| 4380 | } |
| 4381 | }; |
| 4382 | let _ = self |
| 4383 | .tx_event |
| 4384 | .send(Event::status(format!("Goal {label}."))) |
| 4385 | .await; |
| 4386 | |
| 4387 | // Resuming an objective-bearing goal restarts the runtime's own |
| 4388 | // steering loop — the kickoff is a continuation turn, never a raw |
| 4389 | // user message echoing the objective (codex `/goal resume` parity). |
| 4390 | let resumed_into_active = !clear && status == GoalStatus::Active && !was_active; |
| 4391 | if resumed_into_active && snapshot_has_objective { |
| 4392 | self.schedule_goal_continuation(Vec::new()).await; |
| 4393 | } |
| 4394 | } |
| 4395 | |
| 4396 | /// `/goal <objective>` — control-plane goal set (codex `/goal` parity). |
| 4397 | /// The engine is authoritative: the objective lands in |
| 4398 | /// `SharedGoalState`, every host projection is refreshed, `GoalUpdated` |
| 4399 | /// publishes the new snapshot, and the first goal turn is dispatched as |
| 4400 | /// runtime steering (the continuation prompt built from the goal |
| 4401 | /// snapshot). The objective is never echoed as a raw user message. |
| 4402 | async fn handle_set_goal_objective( |
| 4403 | &mut self, |
| 4404 | objective: String, |
| 4405 | token_budget: Option<u32>, |
| 4406 | goal_id: Option<String>, |
| 4407 | ) { |
| 4408 | let Some(objective) = normalized_goal_objective(Some(&objective)) else { |
| 4409 | let _ = self |
| 4410 | .tx_event |
| 4411 | .send(Event::status( |
| 4412 | "Goal not set: the objective is empty after trimming.".to_string(), |
| 4413 | )) |
| 4414 | .await; |
| 4415 | return; |
| 4416 | }; |
| 4417 | match self.config.goal_state.lock() { |
| 4418 | Ok(mut state) => state.replace(&objective, token_budget, goal_id), |
| 4419 | Err(error) => { |
| 4420 | tracing::warn!("goal state lock poisoned during replacement: {error}"); |
| 4421 | return; |
| 4422 | } |
| 4423 | } |
| 4424 | self.config.goal_objective = Some(objective); |
| 4425 | self.config.goal_token_budget = token_budget; |
| 4426 | self.config.goal_status = GoalStatus::Active; |
| 4427 | self.refresh_system_prompt_with_reason("goal"); |
| 4428 | self.emit_session_updated().await; |
| 4429 | let snapshot = match self.config.goal_state.lock() { |
| 4430 | Ok(state) => state.snapshot(), |
| 4431 | Err(err) => { |
| 4432 | tracing::warn!("goal state lock poisoned during SetGoalObjective: {err}"); |
| 4433 | return; |
| 4434 | } |
| 4435 | }; |
| 4436 | let _ = self.tx_event.send(Event::GoalUpdated { snapshot }).await; |
| 4437 | let _ = self |
| 4438 | .tx_event |
| 4439 | .send(Event::status("Goal set; starting goal work.".to_string())) |
| 4440 | .await; |
| 4441 | self.schedule_goal_continuation(Vec::new()).await; |
| 4442 | } |
| 4443 | |
| 4444 | /// Build the turn's tool registry and the model-facing tool catalog. |
| 4445 | /// |
| 4446 | /// This is the single authority for "what tools would the next request |
| 4447 | /// carry". `handle_send_message` calls it with [`SubAgentWiring::Live`] |
| 4448 | /// and [`McpAccess::Connect`]; `/preview-request` calls it with |
| 4449 | /// [`SubAgentWiring::Inert`] and [`McpAccess::PassiveSnapshot`], which |
| 4450 | /// together remove every side effect of the build — no fork snapshot, no |
| 4451 | /// spawned mailbox drainer, no pool creation, no `connect_all`, no status |
| 4452 | /// events — while producing a byte-identical catalog for the state that |
| 4453 | /// is already live. |
| 4454 | /// |
| 4455 | /// The session's `last_tool_catalog` is never an acceptable substitute: |
| 4456 | /// it is one turn stale and stores the pre-activation catalog rather than |
| 4457 | /// the active subset the provider would actually receive. |
| 4458 | /// |
| 4459 | /// `allowed_tools` is the command-scoped allow-list gate the catalog is |
| 4460 | /// filtered under. It is an explicit **parameter**, not a read of |
| 4461 | /// `self.config.allowed_tools`, because the preview's gate belongs to a |
| 4462 | /// turn that has not been installed: writing it onto the engine and |
| 4463 | /// restoring it afterwards would leave the wrong gate installed across |
| 4464 | /// every `.await` in this function, and would leave it installed |
| 4465 | /// permanently if the task were cancelled or panicked between the two |
| 4466 | /// writes. |
| 4467 | #[allow(clippy::too_many_arguments)] |
| 4468 | async fn build_turn_tool_registry_and_catalog( |
| 4469 | &mut self, |
| 4470 | input_policy: &TurnAuthority, |
| 4471 | dynamic_tools: &[DynamicToolSpec], |
| 4472 | allowed_tools: Option<Vec<String>>, |
| 4473 | wiring: SubAgentWiring, |
| 4474 | mcp_access: McpAccess, |
| 4475 | route: TurnRouteContext, |
| 4476 | turn_id: &str, |
| 4477 | ) -> TurnToolBuild { |
| 4478 | // Account-owned Chat is a text-only inference boundary. Do not build |
| 4479 | // native/plugin/dynamic registries, connect or snapshot MCP, capture a |
| 4480 | // sub-agent fork context, or create a sub-agent mailbox before an |
| 4481 | // empty allow-list later filters the wire catalog. |
| 4482 | if self.api_config.runtime_chat_isolated { |
| 4483 | let registry = ToolRegistryBuilder::new().build(ToolContext::for_empty_registry()); |
| 4484 | return TurnToolBuild { |
| 4485 | surface: ToolSurfacePolicy::new( |
| 4486 | registry, |
| 4487 | Some(Vec::new()), |
| 4488 | input_policy.mode, |
| 4489 | &HashSet::new(), |
| 4490 | &[], |
| 4491 | false, |
| 4492 | Some(Vec::new()), |
| 4493 | None, |
| 4494 | Some(0), |
| 4495 | input_policy.approval_mode_for_session(), |
| 4496 | tool_catalog::ToolMode::Direct, |
| 4497 | ), |
| 4498 | mcp_tool_names: Vec::new(), |
| 4499 | mcp: McpToolState::Disabled, |
| 4500 | subagent_runtime_model: None, |
| 4501 | mailbox: None, |
| 4502 | plugin_tool_names: HashSet::new(), |
| 4503 | }; |
| 4504 | } |
| 4505 | // Build tool registry and tool list for the current mode |
| 4506 | let todo_list = self.config.todos.clone(); |
| 4507 | let plan_state = self.config.plan_state.clone(); |
| 4508 | |
| 4509 | let tool_context = self.build_tool_context_for_turn(input_policy, &route); |
| 4510 | // Ensure MCP pool is initialized before building the tool registry, |
| 4511 | // so start_mcp_server can be registered when Feature::Mcp is enabled. |
| 4512 | // A passive snapshot must not create the pool: allocating it is engine |
| 4513 | // state a preview has no business writing. |
| 4514 | if self.config.features.enabled(Feature::Mcp) && mcp_access.may_connect() { |
| 4515 | let _ = self.ensure_mcp_pool().await; |
| 4516 | self.wait_for_explicit_mcp_boot(allowed_tools.as_deref()) |
| 4517 | .await; |
| 4518 | } |
| 4519 | let builder = self |
| 4520 | .build_turn_tool_registry_builder_for_route( |
| 4521 | input_policy.mode, |
| 4522 | input_policy.allow_shell, |
| 4523 | route.client.clone(), |
| 4524 | &route.model, |
| 4525 | todo_list, |
| 4526 | plan_state, |
| 4527 | ) |
| 4528 | .with_dynamic_tools(dynamic_tools); |
| 4529 | |
| 4530 | let subagents_available = |
| 4531 | self.config.subagents_enabled && self.config.features.enabled(Feature::Subagents); |
| 4532 | |
| 4533 | let fork_context_for_runtime = if subagents_available && wiring.is_live() { |
| 4534 | let state = StructuredState::capture( |
| 4535 | input_policy.mode.label(), |
| 4536 | self.config.workspace.clone(), |
| 4537 | std::env::current_dir().ok(), |
| 4538 | &self.session.working_set, |
| 4539 | Some(&self.subagent_manager), |
| 4540 | &self.session.id, |
| 4541 | ) |
| 4542 | .await; |
| 4543 | Some(SubAgentForkContext { |
| 4544 | messages: self.messages_with_turn_metadata(), |
| 4545 | structured_state_block: state.to_system_block(), |
| 4546 | // Resolve at spawn time so a todo_write earlier in this turn |
| 4547 | // reaches the child rather than freezing turn-start state. |
| 4548 | work_source: Some(self.todo_source()), |
| 4549 | }) |
| 4550 | } else { |
| 4551 | None |
| 4552 | }; |
| 4553 | |
| 4554 | // Mailbox for structured sub-agent envelopes (#128/#130). One per |
| 4555 | // turn: the receiver is drained by a short-lived task that converts |
| 4556 | // envelopes into `Event::SubAgentMailbox` so the UI can route them |
| 4557 | // to the matching in-transcript card. The drainer exits naturally |
| 4558 | // when every cloned sender is dropped at turn-end. |
| 4559 | let mailbox_for_runtime = if subagents_available && wiring.is_live() { |
| 4560 | let cancel_token = self.cancel_token.child_token(); |
| 4561 | let foreground_children = Arc::new(ForegroundChildRegistry::new()); |
| 4562 | let (mailbox, mut receiver) = Mailbox::new(cancel_token.clone()); |
| 4563 | let tx_event_clone = self.tx_event.clone(); |
| 4564 | let mailbox_owner_session_id = self.session.id.clone(); |
| 4565 | let mailbox_turn_id = turn_id.to_string(); |
| 4566 | let (flush_tx, mut flush_rx) = tokio::sync::oneshot::channel(); |
| 4567 | let drain_handle = spawn_supervised( |
| 4568 | "subagent-mailbox-drainer", |
| 4569 | std::panic::Location::caller(), |
| 4570 | async move { |
| 4571 | let mut best_effort_sent_at: HashMap<String, Instant> = HashMap::new(); |
| 4572 | 'drain: loop { |
| 4573 | tokio::select! { |
| 4574 | biased; |
| 4575 | _ = &mut flush_rx => { |
| 4576 | for envelope in receiver.drain_available() { |
| 4577 | if !forward_subagent_mailbox_message( |
| 4578 | &tx_event_clone, |
| 4579 | &mailbox_owner_session_id, |
| 4580 | &mailbox_turn_id, |
| 4581 | envelope.seq, |
| 4582 | envelope.message, |
| 4583 | &mut best_effort_sent_at, |
| 4584 | ).await { |
| 4585 | break 'drain; |
| 4586 | } |
| 4587 | } |
| 4588 | break; |
| 4589 | } |
| 4590 | envelope = receiver.recv() => { |
| 4591 | let Some(envelope) = envelope else { break }; |
| 4592 | if !forward_subagent_mailbox_message( |
| 4593 | &tx_event_clone, |
| 4594 | &mailbox_owner_session_id, |
| 4595 | &mailbox_turn_id, |
| 4596 | envelope.seq, |
| 4597 | envelope.message, |
| 4598 | &mut best_effort_sent_at, |
| 4599 | ).await { |
| 4600 | break; |
| 4601 | } |
| 4602 | } |
| 4603 | } |
| 4604 | } |
| 4605 | }, |
| 4606 | ); |
| 4607 | Some(TurnMailboxBarrier { |
| 4608 | mailbox, |
| 4609 | cancel_token, |
| 4610 | foreground_children, |
| 4611 | flush_tx, |
| 4612 | drain_handle, |
| 4613 | settle_grace: FOREGROUND_CHILD_SETTLE_GRACE, |
| 4614 | }) |
| 4615 | } else { |
| 4616 | None |
| 4617 | }; |
| 4618 | |
| 4619 | let mcp_pool = if self.config.features.enabled(Feature::Mcp) { |
| 4620 | if mcp_access.may_connect() { |
| 4621 | self.ensure_mcp_pool().await.ok() |
| 4622 | } else { |
| 4623 | self.mcp_pool.clone() |
| 4624 | } |
| 4625 | } else { |
| 4626 | None |
| 4627 | }; |
| 4628 | |
| 4629 | let mut subagent_runtime_model = None; |
| 4630 | let mut tool_registry = if subagents_available { |
| 4631 | let runtime = if let Some(client) = route.client.clone() { |
| 4632 | let runtime_allow_shell = |
| 4633 | input_policy.allow_shell && !matches!(input_policy.mode, AppMode::Plan); |
| 4634 | let runtime_shell_policy = |
| 4635 | shell_policy_for_mode(input_policy.mode, runtime_allow_shell); |
| 4636 | subagent_runtime_model = Some(route.model.clone()); |
| 4637 | let mut rt = SubAgentRuntime::new( |
| 4638 | client, |
| 4639 | route.model.clone(), |
| 4640 | tool_context.clone(), |
| 4641 | runtime_allow_shell, |
| 4642 | Some(self.tx_event.clone()), |
| 4643 | Arc::clone(&self.subagent_manager), |
| 4644 | ) |
| 4645 | .with_locale_tag(route.locale_tag.clone()) |
| 4646 | .with_role_models(route.role_models.clone()) |
| 4647 | .with_api_config((*route.api_config).clone()) |
| 4648 | .with_auto_model(route.auto_model) |
| 4649 | .with_reasoning_effort(route.reasoning_effort.clone(), route.reasoning_effort_auto) |
| 4650 | .with_agent_tool_surface_options( |
| 4651 | self.agent_tool_surface_options(runtime_shell_policy), |
| 4652 | ) |
| 4653 | .with_max_spawn_depth(self.config.max_spawn_depth) |
| 4654 | .with_step_api_timeout(self.config.subagent_api_timeout) |
| 4655 | .with_speech_output_dir(self.config.speech_output_dir.clone()) |
| 4656 | .with_mcp_pool(mcp_pool.clone()) |
| 4657 | .with_todos(self.config.todos.clone()) |
| 4658 | .with_parent_completion_tx(self.tx_subagent_completion.clone()) |
| 4659 | .with_runtime_cost_owner(self.config.compaction.runtime_cost_owner.as_deref()) |
| 4660 | .with_parent_mode(input_policy.mode) |
| 4661 | .with_approval_receipt_store(self.approval_receipt_store.clone()) |
| 4662 | .with_permission_posture( |
| 4663 | self.session.approval_mode, |
| 4664 | Arc::clone(&self.shared_auto_review_policy), |
| 4665 | self.config.terminal_chrome_enabled, |
| 4666 | ); |
| 4667 | if matches!(input_policy.mode, AppMode::Plan) { |
| 4668 | rt.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Planner); |
| 4669 | } |
| 4670 | // #4042: stamp the session's --disallowed-tools onto the parent |
| 4671 | // runtime so every model-spawned sub-agent inherits the deny-list |
| 4672 | // (plan-mode role override above is intentionally before this). |
| 4673 | rt.worker_profile.denied_tools = |
| 4674 | self.config.disallowed_tools.clone().unwrap_or_default(); |
| 4675 | if let Some(context) = fork_context_for_runtime.clone() { |
| 4676 | rt = rt.with_fork_context(context); |
| 4677 | } |
| 4678 | if let Some(barrier) = mailbox_for_runtime.as_ref() { |
| 4679 | rt = rt |
| 4680 | .with_mailbox(barrier.mailbox.clone()) |
| 4681 | .with_cancel_token(barrier.cancel_token.clone()) |
| 4682 | .with_foreground_children(Arc::clone(&barrier.foreground_children)); |
| 4683 | } |
| 4684 | Some(rt) |
| 4685 | } else { |
| 4686 | None |
| 4687 | }; |
| 4688 | if let Some(subagent_runtime) = runtime { |
| 4689 | builder |
| 4690 | .with_subagent_tools(self.subagent_manager.clone(), subagent_runtime) |
| 4691 | .build(tool_context) |
| 4692 | } else { |
| 4693 | tracing::warn!( |
| 4694 | "Sub-agents enabled but no API client available, falling back to basic tool set" |
| 4695 | ); |
| 4696 | builder.build(tool_context) |
| 4697 | } |
| 4698 | } else { |
| 4699 | builder.build(tool_context) |
| 4700 | }; |
| 4701 | |
| 4702 | // Load plugin tools from the user's tools directory and apply any |
| 4703 | // config.toml overrides. Explicit overrides win over auto-discovered |
| 4704 | // scripts with the same tool name. |
| 4705 | let plugin_tool_names = |
| 4706 | configure_plugin_tools(&mut tool_registry, self.config.tools.as_ref()); |
| 4707 | |
| 4708 | let mcp_state = if self.config.features.enabled(Feature::Mcp) { |
| 4709 | if mcp_access.may_connect() { |
| 4710 | let tools = self.mcp_tools().await; |
| 4711 | let server_count = match self.mcp_pool.as_ref() { |
| 4712 | Some(pool) => pool.lock().await.connected_servers().len(), |
| 4713 | None => 0, |
| 4714 | }; |
| 4715 | McpToolState::Live { |
| 4716 | tools, |
| 4717 | server_count, |
| 4718 | } |
| 4719 | } else { |
| 4720 | self.passive_mcp_snapshot().await |
| 4721 | } |
| 4722 | } else { |
| 4723 | McpToolState::Disabled |
| 4724 | }; |
| 4725 | // Captured before the catalog closure consumes the tool list, so a |
| 4726 | // caller can attribute MCP contributions without a second connect. |
| 4727 | let mcp_tools = mcp_state.tools().to_vec(); |
| 4728 | let mcp_tool_names: Vec<String> = mcp_tools.iter().map(|tool| tool.name.clone()).collect(); |
| 4729 | // The surface budget belongs to the route the request would go to, |
| 4730 | // which is not necessarily the installed one under auto routing. |
| 4731 | let capability = route.capability_profile(); |
| 4732 | let always_load = self.config.tools_always_load.clone(); |
| 4733 | self.turn_tool_surface_budget = Some(capability.tool_surface_budget); |
| 4734 | let catalog = build_model_tool_catalog_with_surface( |
| 4735 | tool_registry.to_api_tools_with_cache(true), |
| 4736 | mcp_tools, |
| 4737 | input_policy.mode, |
| 4738 | &always_load, |
| 4739 | capability.tool_surface_budget, |
| 4740 | ); |
| 4741 | let surface = ToolSurfacePolicy::new( |
| 4742 | tool_registry, |
| 4743 | Some(catalog), |
| 4744 | input_policy.mode, |
| 4745 | &always_load, |
| 4746 | &input_policy.dynamic_active_tools, |
| 4747 | self.config.strict_tool_mode, |
| 4748 | allowed_tools, |
| 4749 | self.config.disallowed_tools.clone(), |
| 4750 | self.config.max_tool_calls, |
| 4751 | input_policy.approval_mode_for_session(), |
| 4752 | // Model metadata wins once wired; today the hint is always None |
| 4753 | // and the [features] flags decide (model_registry follow-up). |
| 4754 | tool_catalog::requested_tool_mode(None, &self.config.features), |
| 4755 | ); |
| 4756 | TurnToolBuild { |
| 4757 | surface, |
| 4758 | mcp_tool_names, |
| 4759 | mcp: mcp_state, |
| 4760 | subagent_runtime_model, |
| 4761 | mailbox: mailbox_for_runtime, |
| 4762 | plugin_tool_names, |
| 4763 | } |
| 4764 | } |
| 4765 | |
| 4766 | /// Read-only MCP snapshot for `/preview-request` (#1004). |
| 4767 | /// |
| 4768 | /// Never creates the pool, never calls `connect_all`, never reloads a |
| 4769 | /// config source, never starts a server, and never emits a status event. |
| 4770 | /// It answers exactly one question: *is the tool set the next turn would |
| 4771 | /// send already known?* It is known only when the pool exists, every |
| 4772 | /// enabled server is connected, and no config source has changed since |
| 4773 | /// the pool last read them. Otherwise the honest answer is "unavailable", |
| 4774 | /// because a real turn would connect and discover more tools. |
| 4775 | async fn passive_mcp_snapshot(&self) -> McpToolState { |
| 4776 | let Some(pool) = self.mcp_pool.as_ref() else { |
| 4777 | return McpToolState::Unavailable { |
| 4778 | reason: McpUnavailable::PoolNotStarted, |
| 4779 | }; |
| 4780 | }; |
| 4781 | let pool = pool.lock().await; |
| 4782 | if !pool.config_sources_unchanged() { |
| 4783 | return McpToolState::Unavailable { |
| 4784 | reason: McpUnavailable::ConfigChangedSinceConnect, |
| 4785 | }; |
| 4786 | } |
| 4787 | let connected: Vec<&str> = pool.connected_servers(); |
| 4788 | let pending = pool |
| 4789 | .enabled_server_names() |
| 4790 | .into_iter() |
| 4791 | .filter(|name| !connected.iter().any(|connected| *connected == name)) |
| 4792 | .count(); |
| 4793 | if pending > 0 { |
| 4794 | return McpToolState::Unavailable { |
| 4795 | reason: McpUnavailable::ServersNotConnected { pending }, |
| 4796 | }; |
| 4797 | } |
| 4798 | McpToolState::Live { |
| 4799 | tools: pool.to_api_tools(), |
| 4800 | server_count: connected.len(), |
| 4801 | } |
| 4802 | } |
| 4803 | |
| 4804 | async fn handle_send_message(&mut self, spec: TurnSpec) -> SendMessageOutcome { |
| 4805 | let TurnSpec { |
| 4806 | max_output_tokens, |
| 4807 | content, |
| 4808 | images, |
| 4809 | mode, |
| 4810 | route, |
| 4811 | compaction, |
| 4812 | initial_routed_usage, |
| 4813 | goal_objective, |
| 4814 | goal_token_budget, |
| 4815 | goal_status, |
| 4816 | reasoning_effort, |
| 4817 | reasoning_effort_auto, |
| 4818 | auto_model, |
| 4819 | allow_shell, |
| 4820 | trust_mode, |
| 4821 | auto_approve, |
| 4822 | approval_mode, |
| 4823 | translation_enabled, |
| 4824 | allowed_tools, |
| 4825 | dynamic_tools, |
| 4826 | hook_executor, |
| 4827 | verbosity, |
| 4828 | provenance, |
| 4829 | } = spec; |
| 4830 | let route = *route; |
| 4831 | let compaction = *compaction; |
| 4832 | let initial_routed_usage = *initial_routed_usage; |
| 4833 | // All surfaces reuse the same bounded validator. Runtime already checks |
| 4834 | // before admission; this also protects direct in-process operations. |
| 4835 | let images = match crate::image_attach::prepare_stored_images(&images) { |
| 4836 | Ok(images) => images, |
| 4837 | Err(error) => { |
| 4838 | let message = error.to_string(); |
| 4839 | let _ = self |
| 4840 | .tx_event |
| 4841 | .send(Event::error(ErrorEnvelope::new( |
| 4842 | crate::error_taxonomy::ErrorCategory::InvalidInput, |
| 4843 | crate::error_taxonomy::ErrorSeverity::Error, |
| 4844 | true, |
| 4845 | "image_input_invalid", |
| 4846 | message.clone(), |
| 4847 | ))) |
| 4848 | .await; |
| 4849 | return SendMessageOutcome::NotStarted { |
| 4850 | error: Some(message), |
| 4851 | }; |
| 4852 | } |
| 4853 | }; |
| 4854 | let autonomous = self.admitted_turn_control.is_none() && !provenance.can_authorize_work(); |
| 4855 | let turn_control = self.begin_turn_control_for_provenance(provenance); |
| 4856 | if autonomous && self.cancel_token.is_cancelled() { |
| 4857 | return SendMessageOutcome::NotStarted { error: None }; |
| 4858 | } |
| 4859 | let initial_usage_owner = compaction.runtime_cost_owner.clone(); |
| 4860 | |
| 4861 | // Goals are created by the model (`create_goal`) or by the leading |
| 4862 | // `/goal <objective>` command; the host never infers one from |
| 4863 | // wording (docs/design/TUI_DECONSTRUCTION.md — founder clarification |
| 4864 | // 2026-09-09: the model decides when a goal is useful). The |
| 4865 | // natural-language `/goal` prose parser that used to recognize |
| 4866 | // "make it your /goal to ..." is gone with the #6290 rework — a |
| 4867 | // prose ask reaches the model, which calls `create_goal` when a goal |
| 4868 | // is actually useful. |
| 4869 | // |
| 4870 | // KV-cache effect: none. Goal state still flows through the existing |
| 4871 | // volatile <session_goal> contributor; nothing here touches the |
| 4872 | // stable prefix. |
| 4873 | |
| 4874 | let effective_provider = route.identity.provider; |
| 4875 | let provider_identity = route.identity.key.clone(); |
| 4876 | let model = route.model.clone(); |
| 4877 | let route_limits = crate::route_budget::known_route_limits(route.candidate.limits()); |
| 4878 | let route_capabilities = route.candidate.capabilities(); |
| 4879 | let route_api_config = route.config.clone(); |
| 4880 | // Freeze the billing receipt here, while `route` is still the single |
| 4881 | // authority for this turn: `route.config` is the identity-scoped |
| 4882 | // Config the client is being built from, and `route.candidate` names |
| 4883 | // the endpoint it will call. After `install_resolved_runtime_route` |
| 4884 | // consumes `route`, the only sound source for these facts is this |
| 4885 | // receipt — an ambient `Config` read at TurnStarted or TurnComplete |
| 4886 | // would follow a later provider switch, auto-router hop, or custom |
| 4887 | // table change onto the wrong vendor. |
| 4888 | let dispatched_base_url = route.candidate.endpoint().base_url.clone(); |
| 4889 | let dispatched_product = |
| 4890 | crate::route_billing::capture_product(&route.config, effective_provider); |
| 4891 | if let Err(err) = self.install_resolved_runtime_route(route) { |
| 4892 | let cost_scope = crate::cost_status::scope_token(); |
| 4893 | crate::cost_status::report_runtime_usage_batch( |
| 4894 | cost_scope, |
| 4895 | initial_usage_owner.as_deref(), |
| 4896 | &initial_routed_usage, |
| 4897 | ); |
| 4898 | let _ = self |
| 4899 | .tx_event |
| 4900 | .send(Event::error(ErrorEnvelope::fatal_auth(format!( |
| 4901 | "Cannot start the turn because its provider route is not ready: {err}" |
| 4902 | )))) |
| 4903 | .await; |
| 4904 | self.sync_unstarted_goal_for_terminal_projection( |
| 4905 | goal_objective.as_deref(), |
| 4906 | goal_token_budget, |
| 4907 | goal_status, |
| 4908 | ); |
| 4909 | let outcome = SendMessageOutcome::NotStarted { error: Some(err) }; |
| 4910 | self.reconcile_non_completed_goal_turn(&outcome).await; |
| 4911 | return outcome; |
| 4912 | } |
| 4913 | |
| 4914 | // Deliver completions that arrived after the previous turn before the |
| 4915 | // next user request is sent. This keeps background shell work |
| 4916 | // model-visible without requiring an explicit wait/poll tool call. |
| 4917 | let shell_completions = self.drain_shell_completion_events(); |
| 4918 | if !shell_completions.is_empty() { |
| 4919 | self.add_session_message(crate::runtime_handoff::shell_completion_runtime_message( |
| 4920 | &shell_completions, |
| 4921 | )) |
| 4922 | .await; |
| 4923 | if let Some(status) = |
| 4924 | crate::core::engine::turn_loop::shell_completion_status_text(&shell_completions, "") |
| 4925 | { |
| 4926 | let _ = self.tx_event.send(Event::status(status)).await; |
| 4927 | } |
| 4928 | } |
| 4929 | |
| 4930 | let input_policy = effective_input_policy( |
| 4931 | provenance, |
| 4932 | mode, |
| 4933 | &content, |
| 4934 | allow_shell, |
| 4935 | trust_mode, |
| 4936 | auto_approve, |
| 4937 | approval_mode, |
| 4938 | ); |
| 4939 | let prompt_context = NextTurnPromptContext::for_planned_turn( |
| 4940 | effective_provider, |
| 4941 | model.clone(), |
| 4942 | route_limits, |
| 4943 | input_policy.mode, |
| 4944 | goal_objective.clone(), |
| 4945 | goal_status, |
| 4946 | goal_token_budget, |
| 4947 | translation_enabled, |
| 4948 | verbosity.clone(), |
| 4949 | ); |
| 4950 | // #3947: an effective-mode change is never silent. The structured |
| 4951 | // event is recorded first (so doctor and this turn's metadata can read |
| 4952 | // it), then rendered to the UI from that same value. |
| 4953 | self.last_policy_narrowing = input_policy.narrowing.clone(); |
| 4954 | if let Some(status) = input_policy.status() { |
| 4955 | let _ = self.tx_event.send(Event::status(status)).await; |
| 4956 | } |
| 4957 | |
| 4958 | // Track the complete effective mode policy so mid-turn metadata, `/edit`, |
| 4959 | // idle worker resumptions, and approval gates cannot read a stale policy |
| 4960 | // after the UI changed modes (#3568). |
| 4961 | self.apply_runtime_mode_policy(&input_policy); |
| 4962 | |
| 4963 | // Create turn context first so start event includes a stable turn id. |
| 4964 | // An active goal gets the host's goal allowance (#5994); turns with |
| 4965 | // an explicit per-invocation ceiling (exec --max-turns, child |
| 4966 | // workers) never see it because those hosts leave `goal_max_steps` |
| 4967 | // unset. |
| 4968 | let goal_turn = goal_objective.is_some() && goal_status == GoalStatus::Active; |
| 4969 | let mut turn = if goal_turn && let Some(goal_max_steps) = self.config.goal_max_steps { |
| 4970 | TurnContext::with_budget_source( |
| 4971 | goal_max_steps, |
| 4972 | crate::core::turn::StepBudgetSource::Goal, |
| 4973 | ) |
| 4974 | } else { |
| 4975 | TurnContext::new(self.config.max_steps) |
| 4976 | }; |
| 4977 | turn.max_output_tokens = max_output_tokens; |
| 4978 | self.turn_counter = self.turn_counter.saturating_add(1); |
| 4979 | let turn_started_at = chrono::Utc::now(); |
| 4980 | // Mint the route receipt from the client that `install_resolved_runtime_route` |
| 4981 | // actually installed above — the same client `Event::TurnComplete` |
| 4982 | // reports `base_url` from. Hosts must not re-derive this from config |
| 4983 | // when they process `TurnStarted`: by then config may already describe |
| 4984 | // a different endpoint or credential. |
| 4985 | let route_receipt = if self.model_client_injected { |
| 4986 | // Provider-neutral injected clients are the I/O authority, while |
| 4987 | // `codewhale_client` is only an auxiliary route-shaping client. |
| 4988 | // It cannot truthfully receipt a transport it did not perform. |
| 4989 | None |
| 4990 | } else { |
| 4991 | self.codewhale_client |
| 4992 | .as_ref() |
| 4993 | .map(|client| client.turn_route_receipt(&provider_identity)) |
| 4994 | }; |
| 4995 | let route_base_url = self |
| 4996 | .codewhale_client |
| 4997 | .as_ref() |
| 4998 | .map(|client| client.base_url()); |
| 4999 | let turn_route = TurnRoute { |
| 5000 | provider: effective_provider, |
| 5001 | provider_identity, |
| 5002 | model: model.clone(), |
| 5003 | auto_model, |
| 5004 | receipt: route_receipt, |
| 5005 | // A start is not an application dispatch. The billing envelope is |
| 5006 | // attached below, then stamped at the pre-permit admission boundary. |
| 5007 | billing: None, |
| 5008 | // The classification receipt, by contrast, is frozen here at the |
| 5009 | // client-freeze boundary and is readable from `TurnStarted` on. |
| 5010 | base_url: dispatched_base_url, |
| 5011 | billing_product: dispatched_product, |
| 5012 | }; |
| 5013 | // Billing provenance follows the *route* that was installed for this |
| 5014 | // turn, which is authoritative even when a test or embedder injected the |
| 5015 | // transport: `codewhale_client`'s base URL is the resolved route's |
| 5016 | // endpoint either way. This is a weaker claim than `receipt`, which |
| 5017 | // digests the credential an injected client did not use and is therefore |
| 5018 | // withheld above. |
| 5019 | let dispatch_billing = crate::core::events::RouteBillingEnvelope { |
| 5020 | openrouter_vendor: self |
| 5021 | .codewhale_client |
| 5022 | .as_ref() |
| 5023 | .and_then(|client| client.openrouter_vendor().map(str::to_string)), |
| 5024 | billing_surface: crate::route_billing::billing_surface_for_dispatch( |
| 5025 | Some(&self.api_config), |
| 5026 | effective_provider, |
| 5027 | route_base_url, |
| 5028 | ) |
| 5029 | .map(str::to_string), |
| 5030 | endpoint_fingerprint: route_base_url.and_then(crate::cost_status::endpoint_fingerprint), |
| 5031 | // A live rate is not evidence at turn creation. `turn_loop` |
| 5032 | // freezes it from the exact fresh cache scope at CodeWhale's |
| 5033 | // pre-permit application-dispatch boundary. |
| 5034 | provider_live_pricing: None, |
| 5035 | // Classified from this turn's own frozen receipt, not from a |
| 5036 | // second ambient `for_route` read. Both halves of the route then |
| 5037 | // answer from the same captured endpoint + credential product, so |
| 5038 | // the application-dispatch envelope and the receipt carried on |
| 5039 | // `TurnRoute` cannot disagree about how this turn bills. |
| 5040 | billing_mode: crate::route_billing::for_dispatched_receipt( |
| 5041 | crate::route_billing::DispatchedReceipt { |
| 5042 | provider: effective_provider, |
| 5043 | identity: Some(turn_route.provider_identity.as_str()), |
| 5044 | base_url: turn_route.base_url.as_str(), |
| 5045 | product: turn_route.billing_product, |
| 5046 | }, |
| 5047 | ) |
| 5048 | .into(), |
| 5049 | // Provisional. Replaced with the pre-permit application-dispatch |
| 5050 | // instant when `run_turn` emits `Event::RouteDispatched`. |
| 5051 | dispatched_at: turn_started_at, |
| 5052 | }; |
| 5053 | turn.pending_route = Some(TurnRoute { |
| 5054 | billing: Some(dispatch_billing), |
| 5055 | ..turn_route.clone() |
| 5056 | }); |
| 5057 | |
| 5058 | // Emit turn started event IMMEDIATELY so the UI knows the turn is |
| 5059 | // active. The snapshot below can take 30+ seconds on slow filesystems |
| 5060 | // (e.g. WSL2 /mnt/c) and must not delay the TurnStarted event. |
| 5061 | let _ = self |
| 5062 | .tx_event |
| 5063 | .send(Event::TurnStarted { |
| 5064 | turn_id: turn.id.clone(), |
| 5065 | created_at: turn_started_at, |
| 5066 | route: Some(turn_route), |
| 5067 | }) |
| 5068 | .await; |
| 5069 | |
| 5070 | // Auto's classifier completed before this parent turn was admitted. |
| 5071 | // Bind its exact routed records to the now-accepted turn: total tokens |
| 5072 | // and model-call telemetry include the work, while parent_route_usage |
| 5073 | // remains untouched so the parent quote can never price it. |
| 5074 | turn.add_routed_usages( |
| 5075 | initial_routed_usage |
| 5076 | .records |
| 5077 | .iter() |
| 5078 | .map(|record| &record.usage.usage), |
| 5079 | ); |
| 5080 | // Exact missing-usage records are persisted route-aware by the runtime |
| 5081 | // sink. TurnComplete carries only any count whose exact route record |
| 5082 | // was truncated, otherwise the terminal merge would count the same |
| 5083 | // provider response twice and misclassify subscription/local calls. |
| 5084 | let residual_dropped_records = initial_routed_usage.dropped_records.saturating_sub( |
| 5085 | u64::try_from(initial_routed_usage.drop_records.len()).unwrap_or(u64::MAX), |
| 5086 | ); |
| 5087 | turn.add_routed_usage_dropped_records(residual_dropped_records); |
| 5088 | let initial_cost_scope = crate::cost_status::scope_token(); |
| 5089 | for record in &initial_routed_usage.records { |
| 5090 | crate::cost_status::report_effective_route_for_runtime( |
| 5091 | initial_cost_scope, |
| 5092 | initial_usage_owner.as_deref(), |
| 5093 | &record.source_id, |
| 5094 | &record.usage.route, |
| 5095 | &record.usage.usage, |
| 5096 | ); |
| 5097 | let _ = self |
| 5098 | .tx_event |
| 5099 | .send(Event::RoutedTurnUsage { |
| 5100 | usage: record.usage.usage.clone(), |
| 5101 | duration_ms: 0, |
| 5102 | first_token_ms: None, |
| 5103 | request_ms: None, |
| 5104 | }) |
| 5105 | .await; |
| 5106 | } |
| 5107 | for record in &initial_routed_usage.drop_records { |
| 5108 | crate::cost_status::report_unreceipted_provider_success( |
| 5109 | initial_cost_scope, |
| 5110 | initial_usage_owner.as_deref(), |
| 5111 | &record.source_id, |
| 5112 | &record.route, |
| 5113 | ); |
| 5114 | } |
| 5115 | |
| 5116 | // Apply the host-resolved route budget before building the request. |
| 5117 | // The model, limits, and compaction policy arrive in one operation so |
| 5118 | // no provider request can observe a partially updated route. |
| 5119 | self.active_route_limits = route_limits; |
| 5120 | self.config.compaction = compaction; |
| 5121 | // Snapshot the workspace BEFORE we touch a single tool. Run the git |
| 5122 | // work on the blocking pool so the async runtime stays responsive; |
| 5123 | // failure is non-fatal (the helper logs at WARN). |
| 5124 | if self.config.snapshots_enabled { |
| 5125 | // Clone the user prompt now — `content` is moved into |
| 5126 | // `user_text_message_with_turn_metadata_for_route` below, so we need |
| 5127 | // a copy for both pre- and post-turn snapshot labels. The |
| 5128 | // label carries a truncated first line so `/restore` |
| 5129 | // listings are human-readable. |
| 5130 | let snapshot_prompt = content.clone(); |
| 5131 | let pre_workspace = self.session.workspace.clone(); |
| 5132 | let pre_seq = self.turn_counter; |
| 5133 | let pre_cap = self.config.snapshots_max_workspace_bytes; |
| 5134 | let pre_sid = self.session.id.clone(); |
| 5135 | let _ = tokio::task::spawn_blocking(move || { |
| 5136 | pre_turn_snapshot( |
| 5137 | &pre_workspace, |
| 5138 | pre_seq, |
| 5139 | pre_cap, |
| 5140 | Some(&snapshot_prompt), |
| 5141 | Some(&pre_sid), |
| 5142 | ) |
| 5143 | }) |
| 5144 | .await; |
| 5145 | } |
| 5146 | |
| 5147 | self.emit_pending_snapshot_notices().await; |
| 5148 | |
| 5149 | // A new turn means any leftover retry banner (success cleared |
| 5150 | // it, failure pinned it) is no longer relevant — reset to idle |
| 5151 | // so the footer doesn't display a stale failure row across |
| 5152 | // turns (#499). |
| 5153 | crate::retry_status::clear(); |
| 5154 | |
| 5155 | // Clone user prompt for post-turn snapshot label before `content` |
| 5156 | // is moved into `user_text_message_with_turn_metadata_for_route` below. |
| 5157 | let snapshot_prompt_post = content.clone(); |
| 5158 | |
| 5159 | if self.model_client.is_none() { |
| 5160 | let message = self |
| 5161 | .codewhale_client_error |
| 5162 | .as_deref() |
| 5163 | .map(|err| format!("Failed to send message: {err}")) |
| 5164 | .unwrap_or_else(|| "Failed to send message: API client not configured".to_string()); |
| 5165 | let _ = self |
| 5166 | .tx_event |
| 5167 | .send(Event::error(ErrorEnvelope::fatal_auth(message.clone()))) |
| 5168 | .await; |
| 5169 | let _ = self |
| 5170 | .tx_event |
| 5171 | .send(Event::TurnComplete { |
| 5172 | usage: turn.usage.clone(), |
| 5173 | parent_route_usage: turn.parent_route_usage.clone(), |
| 5174 | routed_usage_dropped_records: turn.routed_usage_dropped_records, |
| 5175 | status: TurnOutcomeStatus::Failed, |
| 5176 | error: Some(message.clone()), |
| 5177 | tool_catalog: None, |
| 5178 | base_url: None, |
| 5179 | }) |
| 5180 | .await; |
| 5181 | self.sync_unstarted_goal_for_terminal_projection( |
| 5182 | goal_objective.as_deref(), |
| 5183 | goal_token_budget, |
| 5184 | goal_status, |
| 5185 | ); |
| 5186 | let outcome = SendMessageOutcome::NotStarted { |
| 5187 | error: Some(message), |
| 5188 | }; |
| 5189 | self.reconcile_non_completed_goal_turn(&outcome).await; |
| 5190 | return outcome; |
| 5191 | } |
| 5192 | |
| 5193 | // Headless/runtime hosts supply their durable turn owner. Interactive |
| 5194 | // turns historically supplied none, leaving a detached child with |
| 5195 | // only the soon-to-be-sealed mailbox. Install this turn-local sink |
| 5196 | // only after every pre-dispatch failure return, and retire/clear it at |
| 5197 | // settlement so the next turn always receives a fresh owner. |
| 5198 | let interactive_runtime_cost_owner = if self.config.terminal_chrome_enabled |
| 5199 | && self.config.compaction.runtime_cost_owner.is_none() |
| 5200 | { |
| 5201 | let owner = format!("interactive:{}:{}", self.session.id, turn.id); |
| 5202 | crate::cost_status::register_persistent_interactive_runtime_usage_sink( |
| 5203 | &owner, |
| 5204 | crate::cost_status::scope_token(), |
| 5205 | &self.session.id, |
| 5206 | &turn.id, |
| 5207 | ); |
| 5208 | self.config.compaction.runtime_cost_owner = Some(owner.clone()); |
| 5209 | Some(owner) |
| 5210 | } else { |
| 5211 | None |
| 5212 | }; |
| 5213 | |
| 5214 | let previous_goal_objective = self.config.goal_objective.clone(); |
| 5215 | let previous_goal_token_budget = self.config.goal_token_budget; |
| 5216 | let previous_goal_status = self.config.goal_status; |
| 5217 | |
| 5218 | self.session.model = model.clone(); |
| 5219 | self.config.model.clone_from(&self.session.model); |
| 5220 | self.config.goal_objective = goal_objective.clone(); |
| 5221 | self.config.goal_token_budget = goal_token_budget; |
| 5222 | self.config.goal_status = goal_status; |
| 5223 | if normalized_goal_objective(previous_goal_objective.as_deref()) |
| 5224 | != normalized_goal_objective(goal_objective.as_deref()) |
| 5225 | || previous_goal_token_budget != goal_token_budget |
| 5226 | || previous_goal_status != goal_status |
| 5227 | { |
| 5228 | sync_goal_state_from_host( |
| 5229 | &self.config.goal_state, |
| 5230 | normalized_goal_objective(goal_objective.as_deref()).as_deref(), |
| 5231 | goal_token_budget, |
| 5232 | goal_status, |
| 5233 | ); |
| 5234 | } |
| 5235 | self.config.allowed_tools = allowed_tools; |
| 5236 | self.config.hook_executor = hook_executor; |
| 5237 | self.session.reasoning_effort = reasoning_effort; |
| 5238 | self.session.reasoning_effort_auto = reasoning_effort_auto; |
| 5239 | self.session.auto_model = auto_model; |
| 5240 | self.config.translation_enabled = translation_enabled; |
| 5241 | self.config.verbosity = verbosity; |
| 5242 | |
| 5243 | // Compose from the immutable values accepted for this turn. Preview |
| 5244 | // receives the same context before anything is installed, so prompt |
| 5245 | // bytes cannot depend on stale session state or mutation order. The |
| 5246 | // pinned header only moves on an explicit-input change; workspace |
| 5247 | // drift arrives as a `<context_update>` user message appended below. |
| 5248 | let context_update = self.refresh_pinned_header_for_turn(&prompt_context); |
| 5249 | if let Some(update) = context_update { |
| 5250 | self.session.add_message(Message { |
| 5251 | role: Role::User, |
| 5252 | content: vec![ContentBlock::Text { |
| 5253 | text: update, |
| 5254 | cache_control: None, |
| 5255 | }], |
| 5256 | }); |
| 5257 | } |
| 5258 | |
| 5259 | // The Operate contract (docs/MODES.md) precedes the first Operate |
| 5260 | // prompt. KV-cache effect: append-only history, one user-role runtime |
| 5261 | // message; it is derived from the session log rather than a flag so a |
| 5262 | // cleared, restored, or compacted session gets it again exactly once |
| 5263 | // and every later Operate turn does not repeat it. |
| 5264 | if mode == AppMode::Operate |
| 5265 | && !self |
| 5266 | .session |
| 5267 | .messages |
| 5268 | .iter() |
| 5269 | .any(crate::runtime_handoff::is_current_operate_contract_message) |
| 5270 | { |
| 5271 | self.session |
| 5272 | .add_message(crate::runtime_handoff::operate_contract_runtime_message()); |
| 5273 | } |
| 5274 | |
| 5275 | self.session |
| 5276 | .working_set |
| 5277 | .observe_user_message(&content, &self.session.workspace); |
| 5278 | |
| 5279 | // Add the user message through the same explicit snapshot constructor |
| 5280 | // preview uses. Route limits and mode in resource metadata therefore |
| 5281 | // belong to this turn even when the previous route was different. |
| 5282 | let mut user_msg = self.user_text_message_from_snapshot( |
| 5283 | content, |
| 5284 | &model, |
| 5285 | auto_model, |
| 5286 | self.session.reasoning_effort.as_deref(), |
| 5287 | self.session.reasoning_effort_auto, |
| 5288 | provenance, |
| 5289 | TurnMetadataSnapshot { |
| 5290 | prompt_context: &prompt_context, |
| 5291 | system_prompt: self.session.system_prompt.as_ref(), |
| 5292 | approval_mode: self.session.approval_mode, |
| 5293 | working_set: &self.session.working_set, |
| 5294 | policy_narrowing: self.last_policy_narrowing.as_ref(), |
| 5295 | }, |
| 5296 | ); |
| 5297 | let image_index = if self.api_config.runtime_chat_isolated { |
| 5298 | user_msg.content.len() |
| 5299 | } else { |
| 5300 | user_msg.content.len().saturating_sub(1) |
| 5301 | }; |
| 5302 | user_msg.content.splice(image_index..image_index, images); |
| 5303 | self.session.add_message(user_msg); |
| 5304 | |
| 5305 | self.emit_session_updated().await; |
| 5306 | |
| 5307 | // Build tool registry and tool list for the current mode |
| 5308 | let turn_id_for_mailbox = turn.id.clone(); |
| 5309 | let TurnToolBuild { |
| 5310 | surface, |
| 5311 | mailbox: mut mailbox_for_runtime, |
| 5312 | plugin_tool_names, |
| 5313 | .. |
| 5314 | } = self |
| 5315 | .build_turn_tool_registry_and_catalog( |
| 5316 | &input_policy, |
| 5317 | &dynamic_tools, |
| 5318 | self.config.allowed_tools.clone(), |
| 5319 | SubAgentWiring::Live, |
| 5320 | McpAccess::Connect, |
| 5321 | TurnRouteContext { |
| 5322 | provider: self.api_config.api_provider(), |
| 5323 | model: self.config.model.clone(), |
| 5324 | capabilities: route_capabilities, |
| 5325 | limits: self.active_route_limits, |
| 5326 | client: self.codewhale_client.clone(), |
| 5327 | api_config: route_api_config, |
| 5328 | locale_tag: self.config.locale_tag.clone(), |
| 5329 | role_models: self.subagent_role_models(), |
| 5330 | auto_model, |
| 5331 | reasoning_effort: self.session.reasoning_effort.clone(), |
| 5332 | reasoning_effort_auto: self.session.reasoning_effort_auto, |
| 5333 | }, |
| 5334 | &turn_id_for_mailbox, |
| 5335 | ) |
| 5336 | .await; |
| 5337 | let tool_catalog_for_event = Some(surface.catalog.clone()); |
| 5338 | |
| 5339 | // Resolve, once per turn, the out-of-request facts the read-only |
| 5340 | // request projection is allowed to report: flattened registry facts, |
| 5341 | // the MCP pool's own server attribution, and the engine-injected |
| 5342 | // catalog names. This is where `plugin_tool_names` and the pool lock |
| 5343 | // live; the snapshot itself is built later, at the request seam, from |
| 5344 | // the tools actually prepared for that step. |
| 5345 | let mut tool_surface = crate::tool_inspection::ToolSurfaceContext { |
| 5346 | registry: surface.registry.registry_facts(&plugin_tool_names), |
| 5347 | mcp_servers: match self.mcp_pool.as_ref() { |
| 5348 | Some(pool) => pool.lock().await.resolved_tool_servers(), |
| 5349 | None => std::collections::BTreeMap::new(), |
| 5350 | }, |
| 5351 | synthetic_names: default_synthetic_catalog_tool_names(), |
| 5352 | provider: crate::tool_inspection::ProviderAvailability::Unknown, |
| 5353 | }; |
| 5354 | tool_surface.provider = self.tool_surface_provider_receipt(); |
| 5355 | |
| 5356 | let base_url_for_event = if self.model_client_injected { |
| 5357 | None |
| 5358 | } else { |
| 5359 | self.codewhale_client |
| 5360 | .as_ref() |
| 5361 | .map(|client| client.base_url().to_string()) |
| 5362 | }; |
| 5363 | |
| 5364 | // Main turn loop. Catch panics here so an internal error surfaces as a |
| 5365 | // failed TurnComplete instead of unwinding through `engine.run()` and |
| 5366 | // killing the whole engine-event-loop task — which left the UI stuck |
| 5367 | // on "working" forever with the engine silently dead (#2583, #1269). |
| 5368 | use futures_util::FutureExt as _; |
| 5369 | let foreground_children_for_turn = mailbox_for_runtime |
| 5370 | .as_ref() |
| 5371 | .map(|barrier| Arc::clone(&barrier.foreground_children)); |
| 5372 | let turn_result = std::panic::AssertUnwindSafe(async { |
| 5373 | // Keep the turn state machine out of the enclosing event-loop |
| 5374 | // futures. Their nested poll frames must fit ordinary thread |
| 5375 | // stacks while cloning route config or executing tools. |
| 5376 | Box::pin(self.run_turn( |
| 5377 | &mut turn, |
| 5378 | surface, |
| 5379 | foreground_children_for_turn, |
| 5380 | Some(tool_surface), |
| 5381 | )) |
| 5382 | .await |
| 5383 | }) |
| 5384 | .catch_unwind() |
| 5385 | .await; |
| 5386 | let (mut status, error) = match turn_result { |
| 5387 | Ok(outcome) => outcome, |
| 5388 | Err(panic) => { |
| 5389 | let detail = crate::utils::panic_message(&*panic); |
| 5390 | crate::utils::record_caught_panic("engine-event-loop", &detail); |
| 5391 | ( |
| 5392 | TurnOutcomeStatus::Failed, |
| 5393 | Some(format!( |
| 5394 | "The engine hit an internal error and stopped this turn: {detail}. \ |
| 5395 | Your session is intact — send your message again to retry. \ |
| 5396 | A crash report was saved to ~/.codewhale/crashes/." |
| 5397 | )), |
| 5398 | ) |
| 5399 | } |
| 5400 | }; |
| 5401 | |
| 5402 | // Update session usage |
| 5403 | self.session.total_usage.add(&turn.usage); |
| 5404 | self.record_goal_usage_for_turn(&turn.usage, turn.elapsed()); |
| 5405 | |
| 5406 | // Cancellation wins until the terminal settlement decision. `run_turn` |
| 5407 | // performs its own final check, but an Esc/interrupt can arrive while |
| 5408 | // its clean-exit receipts are being appended. Recheck at this seam so |
| 5409 | // that pre-settlement cancellation remains terminal Cancelled child |
| 5410 | // work rather than continuing after a normal answer. |
| 5411 | let status_at_settlement = |
| 5412 | terminal_turn_status_at_settlement(status, self.cancel_token.is_cancelled()); |
| 5413 | if status_at_settlement != status { |
| 5414 | status = status_at_settlement; |
| 5415 | let _ = self |
| 5416 | .tx_event |
| 5417 | .send(Event::status( |
| 5418 | "Request cancelled while settling turn-owned sub-agents", |
| 5419 | )) |
| 5420 | .await; |
| 5421 | } |
| 5422 | |
| 5423 | // Seal and fully forward every accepted mailbox envelope before the |
| 5424 | // terminal event. This is the durability barrier for child usage: an |
| 5425 | // event can no longer arrive after `TurnComplete` and be mistaken for |
| 5426 | // the following turn (or lost by a runtime monitor that already |
| 5427 | // settled the record). |
| 5428 | if let Some(barrier) = mailbox_for_runtime.take() { |
| 5429 | if status == TurnOutcomeStatus::Completed && !turn.budget_exhausted_final_report { |
| 5430 | barrier.continue_and_flush().await; |
| 5431 | } else { |
| 5432 | // The join is deadline-bounded: a child that never observes |
| 5433 | // its cancel token is named and left shutting down rather |
| 5434 | // than withholding `TurnComplete` forever (#6184). |
| 5435 | let unsettled = barrier.cancel_and_flush().await; |
| 5436 | if !unsettled.is_empty() { |
| 5437 | let _ = self |
| 5438 | .tx_event |
| 5439 | .send(Event::status(format!( |
| 5440 | "Turn ended while sub-agent(s) were still shutting down: {}. Their late receipts were dropped.", |
| 5441 | unsettled.join(", ") |
| 5442 | ))) |
| 5443 | .await; |
| 5444 | } |
| 5445 | } |
| 5446 | } |
| 5447 | // The advisor is dispatched after TurnComplete, but its usage still |
| 5448 | // belongs to this originating turn. Acquire the owner lease before an |
| 5449 | // interactive owner is marked terminal so a late provider response |
| 5450 | // retains its exact sink instead of falling into a later session. |
| 5451 | let advisor_usage_context = (self.config.advisor_config.enabled |
| 5452 | && status == TurnOutcomeStatus::Completed |
| 5453 | && self.codewhale_client.is_some()) |
| 5454 | .then(|| { |
| 5455 | crate::tools::subagent::advisor::AdvisorUsageContext::capture( |
| 5456 | self.config.compaction.runtime_cost_owner.as_deref(), |
| 5457 | ) |
| 5458 | }); |
| 5459 | if let Some(owner) = interactive_runtime_cost_owner.as_deref() { |
| 5460 | crate::cost_status::finish_runtime_usage_owner(owner); |
| 5461 | // This owner is turn-local. Leaving it in the reusable engine |
| 5462 | // config makes the next interactive turn skip registration and |
| 5463 | // route background usage into a retired sink/journal. Host-owned |
| 5464 | // runtime turn ids never enter this branch and remain untouched. |
| 5465 | self.config.compaction.runtime_cost_owner = None; |
| 5466 | } |
| 5467 | |
| 5468 | // Emit turn complete event — after all post-turn bookkeeping so |
| 5469 | // the terminal is immediately responsive when the UI receives it. |
| 5470 | self.emit_goal_updated().await; |
| 5471 | if status == TurnOutcomeStatus::Interrupted { |
| 5472 | self.emit_interrupted_survivor_status().await; |
| 5473 | } |
| 5474 | if let Some(snapshot) = turn.terminal_request_snapshot(status) { |
| 5475 | let _ = self |
| 5476 | .tx_event |
| 5477 | .send(Event::ToolRequestSnapshot { snapshot }) |
| 5478 | .await; |
| 5479 | } |
| 5480 | drop(turn_control); |
| 5481 | // `event_sent` means the TurnComplete event reached the UI channel — |
| 5482 | // never that the user saw model output. (#6184: the old `delivered` |
| 5483 | // name was read as user-visible delivery on Interrupted turns that |
| 5484 | // rendered nothing.) |
| 5485 | let turn_complete_event_sent = self |
| 5486 | .tx_event |
| 5487 | .send(Event::TurnComplete { |
| 5488 | usage: turn.usage, |
| 5489 | parent_route_usage: turn.parent_route_usage, |
| 5490 | routed_usage_dropped_records: turn.routed_usage_dropped_records, |
| 5491 | status, |
| 5492 | error: error.clone(), |
| 5493 | tool_catalog: tool_catalog_for_event, |
| 5494 | base_url: base_url_for_event, |
| 5495 | }) |
| 5496 | .await |
| 5497 | .is_ok(); |
| 5498 | tracing::info!( |
| 5499 | target: "engine.turn", |
| 5500 | status = ?status, |
| 5501 | event_sent = turn_complete_event_sent, |
| 5502 | "engine turn completion settled" |
| 5503 | ); |
| 5504 | |
| 5505 | // Post-turn snapshot. Fire-and-forget: TurnComplete is already |
| 5506 | // emitted, so the UI is unblocked and the user can type / select / |
| 5507 | // paste immediately (#234). The git work proceeds on the blocking |
| 5508 | // pool without forcing the engine loop to await it. |
| 5509 | if self.config.snapshots_enabled { |
| 5510 | // `snapshot_prompt_post` was cloned from `content` above, |
| 5511 | // before `content` was moved into the session messages. |
| 5512 | let post_workspace = self.session.workspace.clone(); |
| 5513 | let post_seq = self.turn_counter; |
| 5514 | let post_cap = self.config.snapshots_max_workspace_bytes; |
| 5515 | let post_sid = self.session.id.clone(); |
| 5516 | crate::utils::spawn_blocking_supervised("post-turn-snapshot", move || { |
| 5517 | post_turn_snapshot( |
| 5518 | &post_workspace, |
| 5519 | post_seq, |
| 5520 | post_cap, |
| 5521 | Some(&snapshot_prompt_post), |
| 5522 | Some(&post_sid), |
| 5523 | ); |
| 5524 | }); |
| 5525 | } |
| 5526 | |
| 5527 | // ── Background advisor watcher (#3982) ──────────────────────────── |
| 5528 | // Fire-and-forget: TurnComplete is already emitted. The advisor |
| 5529 | // reads a bounded snapshot of session messages (immutable clone), |
| 5530 | // makes a short LLM advisory call, and emits `Event::AdvisoryNote`. |
| 5531 | // Any failure is logged and swallowed — it must never affect the |
| 5532 | // parent turn's outcome. |
| 5533 | if self.config.advisor_config.enabled |
| 5534 | && matches!(status, TurnOutcomeStatus::Completed) |
| 5535 | && let Some(client) = self.codewhale_client.clone() |
| 5536 | && let Some(usage_context) = advisor_usage_context |
| 5537 | { |
| 5538 | // Lazily create the shared emission guard on first use. |
| 5539 | let guard = self |
| 5540 | .advisor_emission_guard |
| 5541 | .get_or_insert_with(|| { |
| 5542 | Arc::new(tokio::sync::Mutex::new( |
| 5543 | crate::tools::subagent::EmissionGuard::new(), |
| 5544 | )) |
| 5545 | }) |
| 5546 | .clone(); |
| 5547 | |
| 5548 | let advisor_messages: Vec<codewhale_models::Message> = self.session.messages.to_vec(); |
| 5549 | let advisor_config = self.config.advisor_config.clone(); |
| 5550 | // This clone is frozen before the detached task starts and keeps |
| 5551 | // every configured provider route available for an explicit |
| 5552 | // cross-provider advisor model without consulting later UI state. |
| 5553 | let advisor_route_config = self.api_config.clone(); |
| 5554 | let advisor_model = self.session.model.clone(); |
| 5555 | let advisor_tx = self.tx_event.clone(); |
| 5556 | let advisor_turn_id = turn.id.clone(); |
| 5557 | |
| 5558 | crate::utils::spawn_supervised( |
| 5559 | "advisor-watcher", |
| 5560 | std::panic::Location::caller(), |
| 5561 | async move { |
| 5562 | crate::tools::subagent::run_advisor_for_turn( |
| 5563 | advisor_turn_id, |
| 5564 | advisor_messages, |
| 5565 | advisor_config, |
| 5566 | client, |
| 5567 | advisor_route_config, |
| 5568 | advisor_model, |
| 5569 | usage_context, |
| 5570 | guard, |
| 5571 | advisor_tx, |
| 5572 | ) |
| 5573 | .await; |
| 5574 | }, |
| 5575 | ); |
| 5576 | } |
| 5577 | |
| 5578 | // ── Cross-turn goal continuation ─────────────────────────────────── |
| 5579 | // When the interactive engine owns turn lifecycle, a successful turn |
| 5580 | // with an active goal re-dispatches a synthetic continuation through |
| 5581 | // its own op channel. RuntimeThreadManager engines instead yield here: |
| 5582 | // their host must create the next durable claim before dispatching any |
| 5583 | // further turn. A Failed or Interrupted turn never continues. |
| 5584 | // |
| 5585 | // #5994: a turn that exhausted the goal step budget got its bounded |
| 5586 | // final report already. An unfinished goal pauses with BudgetLimit |
| 5587 | // instead of silently re-arming another full goal turn; a verified |
| 5588 | // completion reported in that final turn still wins. |
| 5589 | let goal_budget_exhausted = turn.budget_source == crate::core::turn::StepBudgetSource::Goal |
| 5590 | && turn.budget_exhausted_final_report; |
| 5591 | if goal_budget_exhausted { |
| 5592 | let goal_still_active = self |
| 5593 | .config |
| 5594 | .goal_state |
| 5595 | .lock() |
| 5596 | .map(|state| state.is_active()) |
| 5597 | .unwrap_or(false); |
| 5598 | if goal_still_active { |
| 5599 | self.pause_goal_continuation( |
| 5600 | GoalPauseReason::BudgetLimit, |
| 5601 | format!( |
| 5602 | "Goal paused: the [goal] max_steps budget ({}) was exhausted. Review the final report, then resume the goal explicitly to continue.", |
| 5603 | turn.max_steps |
| 5604 | ), |
| 5605 | ) |
| 5606 | .await; |
| 5607 | } |
| 5608 | } |
| 5609 | let outcome = SendMessageOutcome::Finished { status, error }; |
| 5610 | if !goal_budget_exhausted |
| 5611 | && !self.host_managed_turns() |
| 5612 | && matches!( |
| 5613 | &outcome, |
| 5614 | SendMessageOutcome::Finished { |
| 5615 | status: TurnOutcomeStatus::Completed, |
| 5616 | .. |
| 5617 | } |
| 5618 | ) |
| 5619 | { |
| 5620 | // Queue a typed continuation instead of freezing an Active goal |
| 5621 | // snapshot into a generic message. The operation re-reads the live |
| 5622 | // state when consumed, after any already-queued goal controls. |
| 5623 | self.schedule_goal_continuation(dynamic_tools).await; |
| 5624 | } else { |
| 5625 | self.reconcile_non_completed_goal_turn(&outcome).await; |
| 5626 | } |
| 5627 | outcome |
| 5628 | } |
| 5629 | |
| 5630 | async fn handle_purge(&mut self) { |
| 5631 | let zero_usage = Usage { |
| 5632 | input_tokens: 0, |
| 5633 | output_tokens: 0, |
| 5634 | ..Usage::default() |
| 5635 | }; |
| 5636 | let Some(client) = self.codewhale_client.clone() else { |
| 5637 | let message = "Purge unavailable: API client not configured".to_string(); |
| 5638 | emit_purge_failed(&self.tx_event, message.clone()).await; |
| 5639 | let _ = self |
| 5640 | .tx_event |
| 5641 | .send(Event::error(ErrorEnvelope::fatal_auth(message.clone()))) |
| 5642 | .await; |
| 5643 | let _ = self |
| 5644 | .tx_event |
| 5645 | .send(Event::TurnComplete { |
| 5646 | usage: zero_usage, |
| 5647 | parent_route_usage: Usage::default(), |
| 5648 | routed_usage_dropped_records: 0, |
| 5649 | status: TurnOutcomeStatus::Failed, |
| 5650 | error: Some(message), |
| 5651 | tool_catalog: None, |
| 5652 | base_url: None, |
| 5653 | }) |
| 5654 | .await; |
| 5655 | return; |
| 5656 | }; |
| 5657 | |
| 5658 | emit_purge_started( |
| 5659 | &self.tx_event, |
| 5660 | "Agent context purge in progress\u{2026}".to_string(), |
| 5661 | ) |
| 5662 | .await; |
| 5663 | let messages_before = self.session.messages.len(); |
| 5664 | |
| 5665 | let (status, error) = match run_purge( |
| 5666 | &client, |
| 5667 | self.api_provider, |
| 5668 | &self.session.id, |
| 5669 | &self.session.messages, |
| 5670 | &self.session.model, |
| 5671 | self.session.reasoning_effort.clone(), |
| 5672 | client.effective_max_output_tokens(&self.session.model), |
| 5673 | ) |
| 5674 | .await |
| 5675 | { |
| 5676 | Ok(result) => { |
| 5677 | let messages_after = result.messages.len(); |
| 5678 | self.session.replace_messages(result.messages); |
| 5679 | self.emit_session_updated().await; |
| 5680 | |
| 5681 | let summary = format!( |
| 5682 | "Purge complete: {messages_before} → {messages_after} messages \ |
| 5683 | ({} removed, {} condensed, {} offloaded)", |
| 5684 | result.removed_count, result.replaced_count, result.offloaded_count, |
| 5685 | ); |
| 5686 | emit_purge_completed( |
| 5687 | &self.tx_event, |
| 5688 | messages_before, |
| 5689 | messages_after, |
| 5690 | result.removed_count, |
| 5691 | result.replaced_count, |
| 5692 | summary, |
| 5693 | ) |
| 5694 | .await; |
| 5695 | (TurnOutcomeStatus::Completed, None) |
| 5696 | } |
| 5697 | Err(e) => { |
| 5698 | emit_purge_failed(&self.tx_event, e.clone()).await; |
| 5699 | (TurnOutcomeStatus::Failed, Some(e)) |
| 5700 | } |
| 5701 | }; |
| 5702 | |
| 5703 | let _ = self |
| 5704 | .tx_event |
| 5705 | .send(Event::TurnComplete { |
| 5706 | usage: zero_usage, |
| 5707 | parent_route_usage: Usage::default(), |
| 5708 | routed_usage_dropped_records: 0, |
| 5709 | status, |
| 5710 | error, |
| 5711 | tool_catalog: None, |
| 5712 | base_url: None, |
| 5713 | }) |
| 5714 | .await; |
| 5715 | } |
| 5716 | |
| 5717 | /// Turn-visible background shell jobs still running right now, formatted |
| 5718 | /// for the interrupt-honesty status line (DGF-03, dogfood 2026-08-02): |
| 5719 | /// Esc stops the model turn, not detached shell work. Without this, |
| 5720 | /// files landing on disk after "Turn interrupted" read as a lie. |
| 5721 | fn running_background_shell_survivors(&self) -> Vec<String> { |
| 5722 | let Ok(mut manager) = self.shell_manager.lock() else { |
| 5723 | return Vec::new(); |
| 5724 | }; |
| 5725 | manager |
| 5726 | .list_jobs_for_session(&self.session.id) |
| 5727 | .into_iter() |
| 5728 | .filter(|job| matches!(job.status, crate::tools::shell::ShellStatus::Running)) |
| 5729 | .map(|job| { |
| 5730 | const MAX_COMMAND_CHARS: usize = 48; |
| 5731 | let mut command: String = job.command.chars().take(MAX_COMMAND_CHARS).collect(); |
| 5732 | if job.command.chars().count() > MAX_COMMAND_CHARS { |
| 5733 | command.push('…'); |
| 5734 | } |
| 5735 | format!("{} `{command}`", job.id) |
| 5736 | }) |
| 5737 | .collect() |
| 5738 | } |
| 5739 | |
| 5740 | /// Emit the interrupt-honesty status naming still-running background |
| 5741 | /// shell jobs. Called on the paths that can classify a turn as |
| 5742 | /// Interrupted, immediately before their `TurnComplete` event. |
| 5743 | async fn emit_interrupted_survivor_status(&self) { |
| 5744 | let survivors = self.running_background_shell_survivors(); |
| 5745 | if survivors.is_empty() { |
| 5746 | return; |
| 5747 | } |
| 5748 | let _ = self |
| 5749 | .tx_event |
| 5750 | .send(Event::status(format!( |
| 5751 | "Turn interrupted, but {} background shell job(s) continue and may still write files: {}. Use /jobs to inspect or kill.", |
| 5752 | survivors.len(), |
| 5753 | survivors.join(", ") |
| 5754 | ))) |
| 5755 | .await; |
| 5756 | } |
| 5757 | |
| 5758 | fn estimated_input_tokens(&mut self) -> usize { |
| 5759 | // Memoized on (session.messages_revision, system-prompt fingerprint). |
| 5760 | // The cache invalidates as soon as either input changes; until then |
| 5761 | // repeated calls (capacity checkpoints, /status, context inspector, |
| 5762 | // TUI footer) all hit the cached value. |
| 5763 | self.token_estimate_cache.lookup_or_compute( |
| 5764 | self.session.messages_revision, |
| 5765 | self.session.system_prompt.as_ref(), |
| 5766 | &self.session.messages, |
| 5767 | ) |
| 5768 | } |
| 5769 | |
| 5770 | /// Role/type model map for sub-agent runtimes: roster member pins first, |
| 5771 | /// then explicit `[subagents]` overrides on top so explicit config wins |
| 5772 | /// (#fleet-roster cutover (v0.8.67)). |
| 5773 | fn subagent_role_models(&self) -> HashMap<String, crate::config::SubagentModelOverride> { |
| 5774 | let mut models = self.config.fleet_roster.model_overrides(); |
| 5775 | models.extend( |
| 5776 | self.config |
| 5777 | .subagent_model_overrides |
| 5778 | .iter() |
| 5779 | .map(|(key, value)| (key.clone(), value.clone())), |
| 5780 | ); |
| 5781 | models |
| 5782 | } |
| 5783 | |
| 5784 | fn build_tool_context(&self, mode: AppMode, auto_approve: bool) -> ToolContext { |
| 5785 | let authority = TurnAuthority::from_effective_fields( |
| 5786 | mode, |
| 5787 | self.session.allow_shell, |
| 5788 | self.session.trust_mode, |
| 5789 | auto_approve, |
| 5790 | self.session.approval_mode, |
| 5791 | ); |
| 5792 | let route = TurnRouteContext { |
| 5793 | provider: self.api_provider, |
| 5794 | model: self.session.model.clone(), |
| 5795 | capabilities: self.active_route_capabilities, |
| 5796 | limits: self.active_route_limits, |
| 5797 | client: self.codewhale_client.clone(), |
| 5798 | api_config: Box::new(self.api_config.clone()), |
| 5799 | locale_tag: self.config.locale_tag.clone(), |
| 5800 | role_models: self.subagent_role_models(), |
| 5801 | auto_model: self.session.auto_model, |
| 5802 | reasoning_effort: self.session.reasoning_effort.clone(), |
| 5803 | reasoning_effort_auto: self.session.reasoning_effort_auto, |
| 5804 | }; |
| 5805 | self.build_tool_context_for_turn(&authority, &route) |
| 5806 | } |
| 5807 | |
| 5808 | /// Build a child runtime from the installed session route, outside any |
| 5809 | /// turn, for operator follow-ups that continue a child from its checkpoint |
| 5810 | /// (`Op::FollowUpSubAgent`). Mirrors the per-turn runtime the `agent` tool |
| 5811 | /// receives, minus the turn-scoped fork context and mailbox barrier: a |
| 5812 | /// continued fork is a background child of the session, not of a turn. |
| 5813 | fn off_turn_subagent_runtime(&self) -> Option<SubAgentRuntime> { |
| 5814 | let client = self.codewhale_client.clone()?; |
| 5815 | let mode = self.current_mode; |
| 5816 | let allow_shell = self.session.allow_shell && !matches!(mode, AppMode::Plan); |
| 5817 | let shell_policy = shell_policy_for_mode(mode, allow_shell); |
| 5818 | let tool_context = self.build_tool_context(mode, self.session.auto_approve); |
| 5819 | let mut rt = SubAgentRuntime::new( |
| 5820 | client, |
| 5821 | self.session.model.clone(), |
| 5822 | tool_context, |
| 5823 | allow_shell, |
| 5824 | Some(self.tx_event.clone()), |
| 5825 | Arc::clone(&self.subagent_manager), |
| 5826 | ) |
| 5827 | .with_locale_tag(self.config.locale_tag.clone()) |
| 5828 | .with_role_models(self.subagent_role_models()) |
| 5829 | .with_api_config(self.api_config.clone()) |
| 5830 | .with_auto_model(self.session.auto_model) |
| 5831 | .with_reasoning_effort( |
| 5832 | self.session.reasoning_effort.clone(), |
| 5833 | self.session.reasoning_effort_auto, |
| 5834 | ) |
| 5835 | .with_agent_tool_surface_options(self.agent_tool_surface_options(shell_policy)) |
| 5836 | .with_max_spawn_depth(self.config.max_spawn_depth) |
| 5837 | .with_step_api_timeout(self.config.subagent_api_timeout) |
| 5838 | .with_speech_output_dir(self.config.speech_output_dir.clone()) |
| 5839 | .with_mcp_pool(self.mcp_pool.clone()) |
| 5840 | .with_todos(self.config.todos.clone()) |
| 5841 | .with_parent_completion_tx(self.tx_subagent_completion.clone()) |
| 5842 | .with_runtime_cost_owner(self.config.compaction.runtime_cost_owner.as_deref()) |
| 5843 | .with_parent_mode(mode) |
| 5844 | .with_approval_receipt_store(self.approval_receipt_store.clone()) |
| 5845 | .with_permission_posture( |
| 5846 | self.session.approval_mode, |
| 5847 | Arc::clone(&self.shared_auto_review_policy), |
| 5848 | self.config.terminal_chrome_enabled, |
| 5849 | ); |
| 5850 | if matches!(mode, AppMode::Plan) { |
| 5851 | rt.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Planner); |
| 5852 | } |
| 5853 | rt.worker_profile.denied_tools = self.config.disallowed_tools.clone().unwrap_or_default(); |
| 5854 | Some(rt) |
| 5855 | } |
| 5856 | |
| 5857 | /// Project the current engine authority onto an already-built registry. |
| 5858 | /// Registries own long-lived services and tool definitions; permission, |
| 5859 | /// shell, and sandbox policy are live turn state and must not be read from |
| 5860 | /// the registry's start-of-turn snapshot after a Runtime posture switch. |
| 5861 | fn live_tool_context( |
| 5862 | &self, |
| 5863 | registry: Option<&crate::tools::ToolRegistry>, |
| 5864 | ) -> Option<ToolContext> { |
| 5865 | let mut context = registry?.context().clone(); |
| 5866 | let authority = TurnAuthority::from_effective_fields( |
| 5867 | self.current_mode, |
| 5868 | self.session.allow_shell, |
| 5869 | self.session.trust_mode, |
| 5870 | self.session.auto_approve, |
| 5871 | self.session.approval_mode, |
| 5872 | ); |
| 5873 | context.trust_mode = authority.trust_mode; |
| 5874 | context.auto_approve = authority.auto_approve; |
| 5875 | context.set_shell_policy(authority.shell_policy()); |
| 5876 | context.elevated_sandbox_policy = Some(authority.sandbox_policy( |
| 5877 | &self.session.workspace, |
| 5878 | self.api_config.sandbox_mode.as_deref(), |
| 5879 | crate::core::authority::SandboxNetworkAccess::from_config( |
| 5880 | self.api_config.sandbox_network_access, |
| 5881 | ), |
| 5882 | )); |
| 5883 | context.shell_network_denied_hint = matches!(authority.mode, AppMode::Plan) |
| 5884 | .then(|| PLAN_SHELL_NETWORK_DENIED_HINT.to_string()); |
| 5885 | Some(context) |
| 5886 | } |
| 5887 | |
| 5888 | /// Build one tool context from the already-resolved turn authority and |
| 5889 | /// route. A preview owns values that are deliberately not installed on the |
| 5890 | /// session; rebuilding either from `self.session` would give it the prior |
| 5891 | /// turn's shell posture, context window, model, route capabilities, and |
| 5892 | /// provider-native search client. |
| 5893 | fn build_tool_context_for_turn( |
| 5894 | &self, |
| 5895 | authority: &TurnAuthority, |
| 5896 | route: &TurnRouteContext, |
| 5897 | ) -> ToolContext { |
| 5898 | // Load the per-workspace trusted-paths list (#29) on every tool-context |
| 5899 | // build. Cheap (a small JSON file) and always reflects the latest |
| 5900 | // `/trust add` / `/trust remove` mutations without an explicit cache |
| 5901 | // refresh hook. |
| 5902 | let trusted = crate::workspace_trust::WorkspaceTrust::load_for(&self.session.workspace); |
| 5903 | let mut trusted_external_paths = trusted.paths().to_vec(); |
| 5904 | let clipboard_images_dir = |
| 5905 | crate::tui::clipboard::clipboard_images_dir(&self.session.workspace); |
| 5906 | if !trusted_external_paths |
| 5907 | .iter() |
| 5908 | .any(|path| path == &clipboard_images_dir) |
| 5909 | { |
| 5910 | trusted_external_paths.push(clipboard_images_dir); |
| 5911 | } |
| 5912 | let mut ctx = ToolContext::with_auto_approve( |
| 5913 | self.session.workspace.clone(), |
| 5914 | authority.trust_mode, |
| 5915 | self.session.notes_path.clone(), |
| 5916 | self.session.mcp_config_path.clone(), |
| 5917 | authority.auto_approve, |
| 5918 | ) |
| 5919 | .with_state_namespace(self.session.id.clone()) |
| 5920 | .with_route_context_window(crate::route_budget::route_context_window_tokens( |
| 5921 | route.provider, |
| 5922 | &route.model, |
| 5923 | route.limits, |
| 5924 | )) |
| 5925 | .with_features(self.config.features.clone()) |
| 5926 | .with_shell_manager(self.shell_manager.clone()) |
| 5927 | .with_file_read_tracker(self.file_read_tracker.clone()) |
| 5928 | .with_runtime_services(self.config.runtime_services.clone()) |
| 5929 | .with_skills_config( |
| 5930 | self.config.skills_dir.clone(), |
| 5931 | self.config.skills_scan_codewhale_only, |
| 5932 | ) |
| 5933 | .with_plugin_registry(Arc::clone(&self.plugin_registry)) |
| 5934 | .with_session_objects(crate::rlm::session::SessionObjectSnapshot::new( |
| 5935 | self.session.id.clone(), |
| 5936 | route.model.clone(), |
| 5937 | self.session.workspace.clone(), |
| 5938 | self.session.system_prompt.clone(), |
| 5939 | self.session.messages.clone().into(), |
| 5940 | )) |
| 5941 | .with_cancel_token(self.cancel_token.clone()) |
| 5942 | .with_shell_policy(authority.shell_policy()) |
| 5943 | .with_trusted_external_paths(trusted_external_paths) |
| 5944 | .with_follow_symlinks(self.config.workspace_follow_symlinks); |
| 5945 | ctx.disallowed_tools = self.config.disallowed_tools.clone().unwrap_or_default(); |
| 5946 | ctx.persist_services_enabled = self.config.runtime_services.persist_services_enabled; |
| 5947 | |
| 5948 | // Hand the user-memory path to tools so the model-callable |
| 5949 | // `remember` tool can append entries (#489). `None` when the |
| 5950 | // feature is disabled — tools short-circuit on that. |
| 5951 | if self.config.memory_enabled { |
| 5952 | ctx.memory_path = Some(self.config.memory_path.clone()); |
| 5953 | } |
| 5954 | |
| 5955 | if let Some(decider) = self.config.network_policy.as_ref() { |
| 5956 | ctx = ctx.with_network_policy(decider.clone()); |
| 5957 | } |
| 5958 | |
| 5959 | // Adaptive evidence routing is engine-native and opt-in |
| 5960 | // (`CODEWHALE_ADAPTIVE_OUTPUT_ROUTING`); `[workshop]` only customizes |
| 5961 | // thresholds. The router stays attached so an enabled process stamps |
| 5962 | // routing metadata without rebuilding the context. |
| 5963 | let router = crate::tools::large_output_router::LargeOutputRouter::new( |
| 5964 | self.config.workshop.clone().unwrap_or_default(), |
| 5965 | ); |
| 5966 | ctx = ctx.with_large_output_router(router); |
| 5967 | |
| 5968 | // Wire the external sandbox backend (#516). exec_shell checks this |
| 5969 | // field and routes commands through the backend instead of spawning |
| 5970 | // a local process when it's set. |
| 5971 | if let Some(backend) = self.sandbox_backend.as_ref() { |
| 5972 | ctx = ctx.with_sandbox_backend(std::sync::Arc::clone(backend)); |
| 5973 | } |
| 5974 | |
| 5975 | // Wire search provider config. |
| 5976 | ctx.search_provider = self.config.search_provider; |
| 5977 | ctx.search_api_key = self.config.search_api_key.clone(); |
| 5978 | ctx.search_base_url = self.config.search_base_url.clone(); |
| 5979 | ctx.route_capabilities = route.capabilities; |
| 5980 | if route.capabilities.server_side_web_search.is_supported() { |
| 5981 | ctx.provider_native_search = route |
| 5982 | .client |
| 5983 | .as_ref() |
| 5984 | .cloned() |
| 5985 | .and_then(crate::client::ProviderNativeSearchClient::new); |
| 5986 | } |
| 5987 | |
| 5988 | let policy = authority.sandbox_policy( |
| 5989 | &self.session.workspace, |
| 5990 | self.api_config.sandbox_mode.as_deref(), |
| 5991 | crate::core::authority::SandboxNetworkAccess::from_config( |
| 5992 | self.api_config.sandbox_network_access, |
| 5993 | ), |
| 5994 | ); |
| 5995 | let mut ctx = ctx.with_elevated_sandbox_policy(policy); |
| 5996 | if matches!(authority.mode, AppMode::Plan) { |
| 5997 | ctx = ctx.with_shell_network_denied_hint(PLAN_SHELL_NETWORK_DENIED_HINT); |
| 5998 | } |
| 5999 | ctx |
| 6000 | } |
| 6001 | |
| 6002 | /// Revalidate durable owners after a saved session is installed. Owner |
| 6003 | /// stores apply restart recovery first; the graph consumes only their |
| 6004 | /// monotonic snapshots and never infers liveness from prior UI state. |
| 6005 | async fn reconcile_restored_work_bindings(&self) { |
| 6006 | let Some(work) = self.config.runtime_services.work.as_ref() else { |
| 6007 | return; |
| 6008 | }; |
| 6009 | let session_id = self.session.id.as_str(); |
| 6010 | let candidates = work |
| 6011 | .reconcilable_durable_bindings(Some(session_id)) |
| 6012 | .into_iter() |
| 6013 | .collect::<HashSet<_>>(); |
| 6014 | let checked_at = chrono::Utc::now().timestamp_millis(); |
| 6015 | |
| 6016 | let mut seen_tasks = HashSet::new(); |
| 6017 | let mut task_inventory_available = false; |
| 6018 | if let Some(task_manager) = self.config.runtime_services.task_manager.as_ref() { |
| 6019 | match task_manager |
| 6020 | .list_tasks_for_owner(None, None, session_id) |
| 6021 | .await |
| 6022 | { |
| 6023 | Ok(tasks) => { |
| 6024 | task_inventory_available = true; |
| 6025 | for task in tasks { |
| 6026 | let external = format!("task:{}", task.id); |
| 6027 | if !candidates.contains(&external) { |
| 6028 | continue; |
| 6029 | } |
| 6030 | seen_tasks.insert(external.clone()); |
| 6031 | if !task.execution_binding_known { |
| 6032 | continue; |
| 6033 | } |
| 6034 | if let Err(err) = work.reconcile_operation( |
| 6035 | session_id, |
| 6036 | crate::work_graph::task_owner_snapshot( |
| 6037 | &task.id, |
| 6038 | task.status, |
| 6039 | task.lifecycle_seq, |
| 6040 | task.created_at, |
| 6041 | task.started_at, |
| 6042 | task.ended_at, |
| 6043 | ), |
| 6044 | ) { |
| 6045 | tracing::warn!(task_id = %task.id, error = %err, "failed to reconcile restored task owner"); |
| 6046 | } |
| 6047 | } |
| 6048 | } |
| 6049 | Err(error) => { |
| 6050 | tracing::warn!(%error, "Task owner inventory unavailable; retaining Work bindings") |
| 6051 | } |
| 6052 | } |
| 6053 | } |
| 6054 | for external in candidates |
| 6055 | .iter() |
| 6056 | .filter(|_| task_inventory_available) |
| 6057 | .filter(|external| external.starts_with("task:")) |
| 6058 | .filter(|external| !seen_tasks.contains(*external)) |
| 6059 | { |
| 6060 | if let Err(err) = work.reconcile_observation( |
| 6061 | session_id, |
| 6062 | external, |
| 6063 | crate::work_graph::OperationObservation::OwnerMissing { checked_at }, |
| 6064 | ) { |
| 6065 | tracing::warn!(%external, error = %err, "failed to mark missing task owner"); |
| 6066 | } |
| 6067 | } |
| 6068 | |
| 6069 | let worker_records = self.subagent_manager.read().await.list_worker_records(); |
| 6070 | let mut seen_workers = HashSet::new(); |
| 6071 | for record in worker_records { |
| 6072 | let Some(snapshot) = agent_worker_owner_snapshot(&record) else { |
| 6073 | continue; |
| 6074 | }; |
| 6075 | if !candidates.contains(&snapshot.external) { |
| 6076 | continue; |
| 6077 | } |
| 6078 | seen_workers.insert(snapshot.external.clone()); |
| 6079 | if let Err(err) = work.reconcile_operation(session_id, snapshot) { |
| 6080 | tracing::warn!(worker_id = %record.spec.worker_id, error = %err, "failed to reconcile restored worker owner"); |
| 6081 | } |
| 6082 | } |
| 6083 | for external in candidates |
| 6084 | .iter() |
| 6085 | .filter(|external| external.starts_with("worker:")) |
| 6086 | .filter(|external| !seen_workers.contains(*external)) |
| 6087 | { |
| 6088 | if let Err(err) = work.reconcile_observation( |
| 6089 | session_id, |
| 6090 | external, |
| 6091 | crate::work_graph::OperationObservation::OwnerMissing { checked_at }, |
| 6092 | ) { |
| 6093 | tracing::warn!(%external, error = %err, "failed to mark missing worker owner"); |
| 6094 | } |
| 6095 | } |
| 6096 | |
| 6097 | if let Err(err) = crate::tools::workflow::reconcile_persisted_workflow_bindings( |
| 6098 | work, |
| 6099 | session_id, |
| 6100 | &self.session.workspace, |
| 6101 | ) { |
| 6102 | tracing::warn!(error = %err, "failed to reconcile restored workflow owners"); |
| 6103 | } |
| 6104 | } |
| 6105 | |
| 6106 | async fn ensure_mcp_pool(&mut self) -> Result<Arc<AsyncMutex<McpPool>>, ToolError> { |
| 6107 | if let Some(pool) = self.mcp_pool.clone() { |
| 6108 | self.ensure_mcp_supervisor(); |
| 6109 | return Ok(pool); |
| 6110 | } |
| 6111 | let mut pool = McpPool::from_config_path_with_workspace_and_plugins( |
| 6112 | &self.session.mcp_config_path, |
| 6113 | &self.session.workspace, |
| 6114 | Arc::clone(&self.plugin_registry), |
| 6115 | ) |
| 6116 | .unwrap_or_else(|e| { |
| 6117 | tracing::debug!( |
| 6118 | "MCP config unavailable: {}", |
| 6119 | crate::mcp::format_mcp_error_for_display(&e) |
| 6120 | ); |
| 6121 | McpPool::empty_with_workspace_config_sources( |
| 6122 | &self.session.mcp_config_path, |
| 6123 | &self.session.workspace, |
| 6124 | Arc::clone(&self.plugin_registry), |
| 6125 | ) |
| 6126 | .unwrap_or_else(|fallback_error| { |
| 6127 | tracing::debug!( |
| 6128 | "MCP reload source setup failed: {}", |
| 6129 | crate::mcp::format_mcp_error_for_display(&fallback_error) |
| 6130 | ); |
| 6131 | McpPool::new(McpConfig::default()) |
| 6132 | }) |
| 6133 | }); |
| 6134 | pool = pool.with_disallowed_tools(self.config.disallowed_tools.clone().unwrap_or_default()); |
| 6135 | if let Some(decider) = self.config.network_policy.as_ref() { |
| 6136 | pool = pool.with_network_policy(decider.clone()); |
| 6137 | } |
| 6138 | // The self-serve login tool honors the same pre-registered redirect |
| 6139 | // overrides `/mcp login` uses, or providers with pinned callback |
| 6140 | // URIs reject its ephemeral loopback. |
| 6141 | pool = pool.with_oauth_callback( |
| 6142 | self.config.mcp_oauth_callback_port, |
| 6143 | self.config.mcp_oauth_callback_url.clone(), |
| 6144 | ); |
| 6145 | let pool = Arc::new(AsyncMutex::new(pool)); |
| 6146 | self.mcp_pool = Some(Arc::clone(&pool)); |
| 6147 | self.ensure_mcp_supervisor(); |
| 6148 | Ok(pool) |
| 6149 | } |
| 6150 | |
| 6151 | /// Start the connection supervisor once per pool. The task holds only a |
| 6152 | /// Weak: pool replacement lets the old task exit, its channel closes, the |
| 6153 | /// run loop disarms, and the next ensure respawns against the new pool. |
| 6154 | fn ensure_mcp_supervisor(&mut self) { |
| 6155 | if self.mcp_supervisor_rx.is_some() { |
| 6156 | return; |
| 6157 | } |
| 6158 | let Some(pool) = self.mcp_pool.as_ref() else { |
| 6159 | return; |
| 6160 | }; |
| 6161 | let (tx, rx) = mpsc::channel(16); |
| 6162 | self.mcp_supervisor_rx = Some(rx); |
| 6163 | let weak = Arc::downgrade(pool); |
| 6164 | spawn_supervised( |
| 6165 | "mcp-supervisor", |
| 6166 | std::panic::Location::caller(), |
| 6167 | McpPool::supervise_pool(weak, tx), |
| 6168 | ); |
| 6169 | } |
| 6170 | |
| 6171 | /// Apply one supervisor sweep: deaths and failures refresh the engine's |
| 6172 | /// error map, recoveries clear it, parking writes the suspended notice. |
| 6173 | /// Emits a finished boot update exactly when something changed, so the |
| 6174 | /// Extensions rows flip with liveness instead of parking on stale-ready. |
| 6175 | async fn apply_mcp_supervisor_update(&mut self, update: McpSupervisorUpdate) { |
| 6176 | if update.is_empty() { |
| 6177 | return; |
| 6178 | } |
| 6179 | for (name, error) in update.died.into_iter().chain(update.failed) { |
| 6180 | self.mcp_connection_errors.insert(name, error); |
| 6181 | } |
| 6182 | for name in &update.recovered { |
| 6183 | self.mcp_connection_errors.remove(name); |
| 6184 | } |
| 6185 | for name in update.parked { |
| 6186 | self.mcp_connection_errors.insert( |
| 6187 | name.clone(), |
| 6188 | format!( |
| 6189 | "Auto-reconnect suspended after repeated failures; `/mcp retry {name}` to try again." |
| 6190 | ), |
| 6191 | ); |
| 6192 | } |
| 6193 | let generation = self.next_mcp_event_generation(); |
| 6194 | self.emit_mcp_session_boot(generation, true).await; |
| 6195 | } |
| 6196 | |
| 6197 | /// Force the engine-owned pool to re-read its config sources and start |
| 6198 | /// the reconnect pass, returning the interim snapshot immediately. |
| 6199 | /// |
| 6200 | /// This is the explicit `/mcp reload` path. It deliberately does **not** |
| 6201 | /// wait for the connect batch: a config with many servers can take |
| 6202 | /// minutes to settle, and a caller that waited from the TUI starved |
| 6203 | /// input and redraw for the whole batch. The batch is the same |
| 6204 | /// supervised pass session boot already uses, so progress and the |
| 6205 | /// finished receipt arrive as `Event::McpSessionBoot` updates under the |
| 6206 | /// returned generation. A malformed source returns Err **before** any |
| 6207 | /// live connection is dropped. |
| 6208 | async fn reload_mcp_pool(&mut self, config_path: PathBuf) -> anyhow::Result<McpManagerUpdate> { |
| 6209 | if self.mcp_boot_in_flight { |
| 6210 | self.wait_for_mcp_boot().await; |
| 6211 | } |
| 6212 | if config_path != self.session.mcp_config_path { |
| 6213 | // Transactional swap without handshakes under the lock; the |
| 6214 | // forced re-read below then re-dials the freshly installed |
| 6215 | // sources. |
| 6216 | let pool = self |
| 6217 | .ensure_mcp_pool() |
| 6218 | .await |
| 6219 | .map_err(|error| anyhow::anyhow!(error.to_string()))?; |
| 6220 | { |
| 6221 | let mut pool = pool.lock().await; |
| 6222 | pool.switch_workspace_config_source( |
| 6223 | &config_path, |
| 6224 | &self.session.workspace, |
| 6225 | Arc::clone(&self.plugin_registry), |
| 6226 | )?; |
| 6227 | } |
| 6228 | self.session.mcp_config_path = config_path; |
| 6229 | } |
| 6230 | let generation = self |
| 6231 | .start_mcp_session_boot(McpConnectRefresh::Force) |
| 6232 | .await?; |
| 6233 | let snapshot = self.mcp_session_snapshot().await?; |
| 6234 | Ok(McpManagerUpdate { |
| 6235 | snapshot, |
| 6236 | generation, |
| 6237 | }) |
| 6238 | } |
| 6239 | |
| 6240 | async fn mcp_session_snapshot(&self) -> anyhow::Result<crate::mcp::McpManagerSnapshot> { |
| 6241 | let pool = self |
| 6242 | .mcp_pool |
| 6243 | .as_ref() |
| 6244 | .ok_or_else(|| anyhow::anyhow!("MCP pool is not started"))?; |
| 6245 | let pool = pool.lock().await; |
| 6246 | Ok(pool.manager_snapshot( |
| 6247 | &self.session.mcp_config_path, |
| 6248 | false, |
| 6249 | &self.mcp_connection_errors, |
| 6250 | )) |
| 6251 | } |
| 6252 | |
| 6253 | fn mcp_connecting_names(pool: &McpPool, errors: &HashMap<String, String>) -> Vec<String> { |
| 6254 | // The pool tracks spawned-but-unresolved connects (#6033). Inferring |
| 6255 | // "connecting" from enabled-minus-connected mislabels every lazy — |
| 6256 | // configured but never-started — server as mid-handshake. |
| 6257 | pool.connecting_servers() |
| 6258 | .into_iter() |
| 6259 | .filter(|name| !errors.contains_key(name)) |
| 6260 | .collect() |
| 6261 | } |
| 6262 | |
| 6263 | fn next_mcp_event_generation(&mut self) -> u64 { |
| 6264 | self.mcp_event_generation = self.mcp_event_generation.saturating_add(1); |
| 6265 | self.mcp_event_generation |
| 6266 | } |
| 6267 | |
| 6268 | fn replace_mcp_boot_errors( |
| 6269 | &mut self, |
| 6270 | authority_errors: &HashMap<String, String>, |
| 6271 | mut connection_errors: HashMap<String, String>, |
| 6272 | ) { |
| 6273 | // Each update owns the ordinary connection diagnoses for this pass, |
| 6274 | // so replacing the map drops stale transport errors. Reviewed-plugin |
| 6275 | // authority failures are a separate, non-pending set and must remain |
| 6276 | // visible throughout the pass; they win if a name ever overlaps. |
| 6277 | connection_errors.extend(authority_errors.clone()); |
| 6278 | self.mcp_connection_errors = connection_errors; |
| 6279 | } |
| 6280 | |
| 6281 | fn finish_mcp_boot_generation(&mut self, generation: u64) -> bool { |
| 6282 | if self.mcp_boot_generation != Some(generation) { |
| 6283 | return false; |
| 6284 | } |
| 6285 | self.mcp_boot_generation = None; |
| 6286 | self.mcp_boot_in_flight = false; |
| 6287 | self.mcp_boot_rx = None; |
| 6288 | self.mcp_boot_done = None; |
| 6289 | true |
| 6290 | } |
| 6291 | |
| 6292 | async fn emit_mcp_session_boot(&self, generation: u64, finished: bool) { |
| 6293 | let Ok(snapshot) = self.mcp_session_snapshot().await else { |
| 6294 | return; |
| 6295 | }; |
| 6296 | let connecting = if finished { |
| 6297 | Vec::new() |
| 6298 | } else if let Some(pool) = self.mcp_pool.as_ref() { |
| 6299 | let pool = pool.lock().await; |
| 6300 | Self::mcp_connecting_names(&pool, &self.mcp_connection_errors) |
| 6301 | } else { |
| 6302 | Vec::new() |
| 6303 | }; |
| 6304 | // Zero servers and nothing connecting is not a session-boot surface. |
| 6305 | if snapshot.servers.is_empty() && connecting.is_empty() { |
| 6306 | return; |
| 6307 | } |
| 6308 | let _ = self.tx_event.try_send(Event::McpSessionBoot { |
| 6309 | generation, |
| 6310 | snapshot, |
| 6311 | connecting, |
| 6312 | finished, |
| 6313 | }); |
| 6314 | } |
| 6315 | |
| 6316 | async fn apply_mcp_boot_update(&mut self, update: McpBootUpdate) { |
| 6317 | match update { |
| 6318 | McpBootUpdate::Progress { |
| 6319 | generation, |
| 6320 | authority_errors, |
| 6321 | connection_errors, |
| 6322 | connecting, |
| 6323 | } => { |
| 6324 | if self.mcp_boot_generation != Some(generation) { |
| 6325 | return; |
| 6326 | } |
| 6327 | if generation < self.mcp_event_generation { |
| 6328 | return; |
| 6329 | } |
| 6330 | self.mcp_event_generation = generation; |
| 6331 | self.replace_mcp_boot_errors(&authority_errors, connection_errors); |
| 6332 | self.session.pending_prefix_change_reason = Some("mcp-session-boot".to_string()); |
| 6333 | if let Ok(snapshot) = self.mcp_session_snapshot().await { |
| 6334 | let _ = self.tx_event.try_send(Event::McpSessionBoot { |
| 6335 | generation, |
| 6336 | snapshot, |
| 6337 | connecting, |
| 6338 | finished: false, |
| 6339 | }); |
| 6340 | } |
| 6341 | } |
| 6342 | McpBootUpdate::Finished { |
| 6343 | generation, |
| 6344 | authority_errors, |
| 6345 | connection_errors, |
| 6346 | } => { |
| 6347 | if self.mcp_boot_generation != Some(generation) { |
| 6348 | return; |
| 6349 | } |
| 6350 | if generation < self.mcp_event_generation { |
| 6351 | self.finish_mcp_boot_generation(generation); |
| 6352 | return; |
| 6353 | } |
| 6354 | self.mcp_event_generation = generation; |
| 6355 | self.replace_mcp_boot_errors(&authority_errors, connection_errors); |
| 6356 | self.finish_mcp_boot_generation(generation); |
| 6357 | self.session.pending_prefix_change_reason = Some("mcp-session-boot".to_string()); |
| 6358 | self.emit_mcp_session_boot(generation, true).await; |
| 6359 | } |
| 6360 | } |
| 6361 | } |
| 6362 | |
| 6363 | async fn drain_mcp_boot_updates(&mut self) { |
| 6364 | let receiver_generation = self.mcp_boot_generation; |
| 6365 | let Some(mut rx) = self.mcp_boot_rx.take() else { |
| 6366 | return; |
| 6367 | }; |
| 6368 | while let Ok(update) = rx.try_recv() { |
| 6369 | // Apply without emitting until the last queued update so the UI |
| 6370 | // sees one settled receipt rather than a burst. |
| 6371 | match update { |
| 6372 | McpBootUpdate::Progress { |
| 6373 | generation, |
| 6374 | authority_errors, |
| 6375 | connection_errors, |
| 6376 | connecting: _, |
| 6377 | } => { |
| 6378 | if self.mcp_boot_generation != Some(generation) { |
| 6379 | continue; |
| 6380 | } |
| 6381 | if generation < self.mcp_event_generation { |
| 6382 | continue; |
| 6383 | } |
| 6384 | self.mcp_event_generation = generation; |
| 6385 | self.replace_mcp_boot_errors(&authority_errors, connection_errors); |
| 6386 | self.session.pending_prefix_change_reason = |
| 6387 | Some("mcp-session-boot".to_string()); |
| 6388 | } |
| 6389 | McpBootUpdate::Finished { |
| 6390 | generation, |
| 6391 | authority_errors, |
| 6392 | connection_errors, |
| 6393 | } => { |
| 6394 | if self.mcp_boot_generation != Some(generation) { |
| 6395 | continue; |
| 6396 | } |
| 6397 | if generation < self.mcp_event_generation { |
| 6398 | self.finish_mcp_boot_generation(generation); |
| 6399 | break; |
| 6400 | } |
| 6401 | self.mcp_event_generation = generation; |
| 6402 | self.replace_mcp_boot_errors(&authority_errors, connection_errors); |
| 6403 | self.finish_mcp_boot_generation(generation); |
| 6404 | self.session.pending_prefix_change_reason = |
| 6405 | Some("mcp-session-boot".to_string()); |
| 6406 | break; |
| 6407 | } |
| 6408 | } |
| 6409 | } |
| 6410 | if self.mcp_boot_in_flight && self.mcp_boot_generation == receiver_generation { |
| 6411 | self.mcp_boot_rx = Some(rx); |
| 6412 | } |
| 6413 | } |
| 6414 | |
| 6415 | /// How long a caller will block on the session boot before proceeding with |
| 6416 | /// whatever has connected so far. |
| 6417 | /// |
| 6418 | /// This bounds the *wait*, never the boot: the supervised boot task keeps |
| 6419 | /// running, so a slow server still lands through the normal progress |
| 6420 | /// updates and appears once it is ready. The per-server connect timeout |
| 6421 | /// does not cover everything that can stall a stdio server — `npx -y` and |
| 6422 | /// `uvx` download their package on first run — so without an outer bound a |
| 6423 | /// single cold fetch left `/mcp` waiting on `mcp_boot_done` forever, which |
| 6424 | /// reads to the user as a frozen application. |
| 6425 | const MCP_BOOT_UI_WAIT: std::time::Duration = std::time::Duration::from_secs(5); |
| 6426 | |
| 6427 | async fn wait_for_mcp_boot(&mut self) { |
| 6428 | if let Some(rx) = self.mcp_boot_done.as_mut() { |
| 6429 | let settled = tokio::time::timeout(Self::MCP_BOOT_UI_WAIT, async { |
| 6430 | while !*rx.borrow() { |
| 6431 | if rx.changed().await.is_err() { |
| 6432 | break; |
| 6433 | } |
| 6434 | } |
| 6435 | }) |
| 6436 | .await; |
| 6437 | if settled.is_err() { |
| 6438 | // Not an error: the boot continues in the background and its |
| 6439 | // progress updates still arrive. Say so rather than silently |
| 6440 | // returning a short server list as if it were complete. |
| 6441 | tracing::info!( |
| 6442 | wait_secs = Self::MCP_BOOT_UI_WAIT.as_secs(), |
| 6443 | "MCP session boot still connecting; continuing with the servers ready so far" |
| 6444 | ); |
| 6445 | } |
| 6446 | } |
| 6447 | self.drain_mcp_boot_updates().await; |
| 6448 | } |
| 6449 | |
| 6450 | /// `tools_always_load` plus a turn's `allowed_tools`, normalized to the |
| 6451 | /// lowercase `mcp_*` names the selection grammar uses. |
| 6452 | fn explicit_mcp_tool_names(&self, allowed_tools: Option<&[String]>) -> Vec<String> { |
| 6453 | self.config |
| 6454 | .tools_always_load |
| 6455 | .iter() |
| 6456 | .chain(allowed_tools.into_iter().flatten()) |
| 6457 | .map(|name| name.trim().to_ascii_lowercase()) |
| 6458 | .filter(|name| name.starts_with("mcp_")) |
| 6459 | .collect() |
| 6460 | } |
| 6461 | |
| 6462 | /// Explicit MCP tool selections need their schemas on the first request. |
| 6463 | /// Under lazy boot a selected server may never have been started, so this |
| 6464 | /// begins those connects itself — off the mailbox — and then waits on the |
| 6465 | /// boot pass and the explicit connects together under the one deadline. |
| 6466 | async fn wait_for_explicit_mcp_boot(&mut self, allowed_tools: Option<&[String]>) { |
| 6467 | let requested = self.explicit_mcp_tool_names(allowed_tools); |
| 6468 | if requested.is_empty() { |
| 6469 | return; |
| 6470 | } |
| 6471 | // A turn must start even when a selected server never answers. An |
| 6472 | // unreachable or un-authenticated MCP server is an ordinary state, not |
| 6473 | // an exceptional one, so waiting without a deadline here turned one bad |
| 6474 | // row in the config into an unresponsive session. Past the deadline the |
| 6475 | // turn proceeds with the tools that are ready; the connects keep |
| 6476 | // running, and the missing server's tools become available on a later |
| 6477 | // turn. |
| 6478 | let deadline = tokio::time::Instant::now() + Self::MCP_BOOT_UI_WAIT; |
| 6479 | let mut explicit = self.start_explicit_mcp_connects(&requested).await; |
| 6480 | let started_explicit = !explicit.names.is_empty(); |
| 6481 | if started_explicit { |
| 6482 | // The connects are in flight now — surfaces should show the |
| 6483 | // selected servers as connecting, not configured. |
| 6484 | let generation = self.next_mcp_event_generation(); |
| 6485 | self.emit_mcp_session_boot(generation, false).await; |
| 6486 | } |
| 6487 | while self.mcp_boot_in_flight || !explicit.connects.is_empty() { |
| 6488 | if tokio::time::Instant::now() >= deadline { |
| 6489 | tracing::info!( |
| 6490 | waited_secs = Self::MCP_BOOT_UI_WAIT.as_secs(), |
| 6491 | "starting the turn before every selected MCP server is ready" |
| 6492 | ); |
| 6493 | break; |
| 6494 | } |
| 6495 | self.drain_mcp_boot_updates().await; |
| 6496 | let Some(pool) = self.mcp_pool.as_ref() else { |
| 6497 | break; |
| 6498 | }; |
| 6499 | let connecting = |
| 6500 | Self::mcp_connecting_names(&*pool.lock().await, &self.mcp_connection_errors); |
| 6501 | let needs_schema = connecting |
| 6502 | .iter() |
| 6503 | .any(|server| crate::mcp::tool_selection_covers_server(&requested, server)); |
| 6504 | if !needs_schema { |
| 6505 | break; |
| 6506 | } |
| 6507 | // The deadline has to cover this await too: a server that accepts |
| 6508 | // the connection and then goes quiet sends no progress update at |
| 6509 | // all, so checking only at the top of the loop would still park the |
| 6510 | // turn here indefinitely. |
| 6511 | enum WaitOutcome { |
| 6512 | Cancel, |
| 6513 | Deadline, |
| 6514 | Boot(Option<McpBootUpdate>), |
| 6515 | Connect(Option<Box<ExplicitConnectJoin>>), |
| 6516 | } |
| 6517 | let outcome = tokio::select! { |
| 6518 | _ = self.cancel_token.cancelled() => WaitOutcome::Cancel, |
| 6519 | () = tokio::time::sleep_until(deadline) => WaitOutcome::Deadline, |
| 6520 | update = async { |
| 6521 | match self.mcp_boot_rx.as_mut() { |
| 6522 | Some(rx) => rx.recv().await, |
| 6523 | None => std::future::pending().await, |
| 6524 | } |
| 6525 | } => WaitOutcome::Boot(update), |
| 6526 | joined = explicit.connects.join_next(), if !explicit.connects.is_empty() => { |
| 6527 | WaitOutcome::Connect(joined.map(Box::new)) |
| 6528 | } |
| 6529 | }; |
| 6530 | match outcome { |
| 6531 | WaitOutcome::Cancel | WaitOutcome::Deadline => break, |
| 6532 | WaitOutcome::Boot(Some(update)) => self.apply_mcp_boot_update(update).await, |
| 6533 | // The boot channel closing means the pass ended without a |
| 6534 | // Finished update; explicit connects may still be running. |
| 6535 | WaitOutcome::Boot(None) => { |
| 6536 | if let Some(generation) = self.mcp_boot_generation { |
| 6537 | self.finish_mcp_boot_generation(generation); |
| 6538 | } |
| 6539 | } |
| 6540 | WaitOutcome::Connect(Some(joined)) => { |
| 6541 | self.store_explicit_connect_result(&mut explicit, *joined) |
| 6542 | .await; |
| 6543 | } |
| 6544 | WaitOutcome::Connect(None) => {} |
| 6545 | } |
| 6546 | } |
| 6547 | if !explicit.connects.is_empty() { |
| 6548 | explicit.connects.abort_all(); |
| 6549 | if let Some(pool) = self.mcp_pool.as_ref() { |
| 6550 | pool.lock().await.cancel_connecting(&explicit.names); |
| 6551 | } |
| 6552 | } |
| 6553 | if started_explicit { |
| 6554 | // Close out the in-flight marks: a deadline-aborted server must |
| 6555 | // stop reading "connecting" on the next paint. |
| 6556 | let generation = self.next_mcp_event_generation(); |
| 6557 | self.emit_mcp_session_boot(generation, !self.mcp_boot_in_flight) |
| 6558 | .await; |
| 6559 | } |
| 6560 | } |
| 6561 | |
| 6562 | /// Start connects for servers an explicit tool selection covers but the |
| 6563 | /// boot pass left lazy (#6033). Selection is intent: cooldowns do not |
| 6564 | /// apply, but enabled/allowed/plugin authority checks do. |
| 6565 | async fn start_explicit_mcp_connects(&mut self, requested: &[String]) -> ExplicitMcpConnects { |
| 6566 | let mut state = ExplicitMcpConnects { |
| 6567 | connects: tokio::task::JoinSet::new(), |
| 6568 | names: HashSet::new(), |
| 6569 | catalog_generation: 0, |
| 6570 | }; |
| 6571 | let Some(pool) = self.mcp_pool.as_ref() else { |
| 6572 | return state; |
| 6573 | }; |
| 6574 | let (pending, errors, timeouts, network_policy, generation) = { |
| 6575 | let mut pool = pool.lock().await; |
| 6576 | let names = pool.explicitly_selected_server_names(requested); |
| 6577 | let (pending, errors) = pool.take_pending_connects_for(&names); |
| 6578 | ( |
| 6579 | pending, |
| 6580 | errors, |
| 6581 | pool.connect_timeouts(), |
| 6582 | pool.cloned_network_policy(), |
| 6583 | pool.current_catalog_generation(), |
| 6584 | ) |
| 6585 | }; |
| 6586 | for (name, error) in errors { |
| 6587 | self.mcp_connection_errors |
| 6588 | .insert(name, crate::mcp::format_mcp_error_for_display(&error)); |
| 6589 | } |
| 6590 | state.catalog_generation = generation; |
| 6591 | state.names = pending.iter().map(|(name, _)| name.clone()).collect(); |
| 6592 | state.connects = |
| 6593 | McpPool::spawn_pending_connects(pending, timeouts, network_policy, generation); |
| 6594 | state |
| 6595 | } |
| 6596 | |
| 6597 | /// Store one resolved explicit connect under the same authority |
| 6598 | /// discipline as the boot pass: a config reload mid-handshake invalidates |
| 6599 | /// the rest of the batch instead of letting old-authority results land. |
| 6600 | async fn store_explicit_connect_result( |
| 6601 | &mut self, |
| 6602 | explicit: &mut ExplicitMcpConnects, |
| 6603 | joined: ExplicitConnectJoin, |
| 6604 | ) { |
| 6605 | let (name, result) = |
| 6606 | joined.unwrap_or_else(|error| ("connection task".to_string(), Err(error.into()))); |
| 6607 | explicit.names.remove(&name); |
| 6608 | let Some(pool) = self.mcp_pool.as_ref() else { |
| 6609 | return; |
| 6610 | }; |
| 6611 | { |
| 6612 | let mut pool = pool.lock().await; |
| 6613 | let reload = pool.reload_if_config_changed().await; |
| 6614 | if reload.is_err() || pool.current_catalog_generation() != explicit.catalog_generation { |
| 6615 | explicit.connects.abort_all(); |
| 6616 | // `name` already left `names` above — its mark dies with the |
| 6617 | // aborted batch too. |
| 6618 | explicit.names.insert(name); |
| 6619 | pool.cancel_connecting(&explicit.names); |
| 6620 | explicit.names.clear(); |
| 6621 | if let Err(error) = reload { |
| 6622 | self.mcp_connection_errors.insert( |
| 6623 | "configuration".to_string(), |
| 6624 | crate::mcp::format_mcp_error_for_display(&error), |
| 6625 | ); |
| 6626 | } |
| 6627 | return; |
| 6628 | } |
| 6629 | let result = |
| 6630 | result.and_then(|connection| pool.store_ready_connection(name.clone(), connection)); |
| 6631 | match result { |
| 6632 | Ok(()) => { |
| 6633 | self.mcp_connection_errors.remove(&name); |
| 6634 | } |
| 6635 | Err(error) => { |
| 6636 | pool.note_connect_failure(&name, &error); |
| 6637 | self.mcp_connection_errors |
| 6638 | .insert(name, crate::mcp::format_mcp_error_for_display(&error)); |
| 6639 | } |
| 6640 | } |
| 6641 | } |
| 6642 | self.session.pending_prefix_change_reason = Some("mcp-session-boot".to_string()); |
| 6643 | // A resolved selection connect refreshes the server surfaces the |
| 6644 | // same way a `/mcp` retry does, without waiting for the boot pass. |
| 6645 | let generation = self.next_mcp_event_generation(); |
| 6646 | self.emit_mcp_session_boot( |
| 6647 | generation, |
| 6648 | !self.mcp_boot_in_flight && explicit.connects.is_empty(), |
| 6649 | ) |
| 6650 | .await; |
| 6651 | } |
| 6652 | |
| 6653 | /// Start the concurrent connect pass without occupying the engine mailbox. |
| 6654 | /// Optional servers stay in the background unless the task explicitly |
| 6655 | /// selects their tools; `mcp_tools` snapshots whatever is already ready. |
| 6656 | /// |
| 6657 | /// Returns the event generation the pass owns, or `Ok(0)` when nothing |
| 6658 | /// was started (the feature gate skipped a session boot). Progress and |
| 6659 | /// the finished receipt flow as `Event::McpSessionBoot` updates under |
| 6660 | /// that generation. |
| 6661 | async fn start_mcp_session_boot(&mut self, refresh: McpConnectRefresh) -> anyhow::Result<u64> { |
| 6662 | if matches!(refresh, McpConnectRefresh::IfChanged) |
| 6663 | && !self.config.features.enabled(Feature::Mcp) |
| 6664 | { |
| 6665 | // Nothing to start. The only caller that reads the generation is |
| 6666 | // the explicit reload, which never takes this branch. |
| 6667 | return Ok(0); |
| 6668 | } |
| 6669 | let pool = match self.ensure_mcp_pool().await { |
| 6670 | Ok(pool) => pool, |
| 6671 | Err(error) => { |
| 6672 | if matches!(refresh, McpConnectRefresh::Force) { |
| 6673 | return Err(anyhow::anyhow!(error.to_string())); |
| 6674 | } |
| 6675 | tracing::debug!("MCP session boot skipped: {error}"); |
| 6676 | return Ok(0); |
| 6677 | } |
| 6678 | }; |
| 6679 | |
| 6680 | // Boot is lazy (#6033): a configured server nobody asked for is not |
| 6681 | // spawned at session start. The eager set is `required` servers plus |
| 6682 | // whatever the session's explicit tool selections cover; everything |
| 6683 | // else connects on demand — a selected turn, a `/mcp` connect, or a |
| 6684 | // lazy tool-name resolution. |
| 6685 | let requested = self.explicit_mcp_tool_names(self.config.allowed_tools.as_deref()); |
| 6686 | let (pending, auth_errors, timeouts, network_policy, catalog_generation) = { |
| 6687 | let mut pool = pool.lock().await; |
| 6688 | match refresh { |
| 6689 | McpConnectRefresh::IfChanged => { |
| 6690 | if let Err(error) = pool.reload_if_config_changed().await { |
| 6691 | tracing::debug!( |
| 6692 | "MCP session boot config reload failed: {}", |
| 6693 | crate::mcp::format_mcp_error_for_display(&error) |
| 6694 | ); |
| 6695 | } |
| 6696 | } |
| 6697 | // A malformed source returns Err before anything is dropped, |
| 6698 | // so a failed explicit reload leaves the live pool intact. |
| 6699 | McpConnectRefresh::Force => pool.force_reload_config_sources()?, |
| 6700 | } |
| 6701 | let eager = pool.eager_boot_server_names(&requested); |
| 6702 | let (pending, auth_errors) = pool.collect_pending_connects(Some(&eager)); |
| 6703 | ( |
| 6704 | pending, |
| 6705 | auth_errors, |
| 6706 | pool.connect_timeouts(), |
| 6707 | pool.cloned_network_policy(), |
| 6708 | pool.current_catalog_generation(), |
| 6709 | ) |
| 6710 | }; |
| 6711 | |
| 6712 | let authority_errors = auth_errors |
| 6713 | .into_iter() |
| 6714 | .map(|(name, error)| (name, crate::mcp::format_mcp_error_for_display(&error))) |
| 6715 | .collect::<HashMap<_, _>>(); |
| 6716 | self.mcp_connection_errors = authority_errors.clone(); |
| 6717 | let authority_errors = Arc::new(authority_errors); |
| 6718 | let generation = self.next_mcp_event_generation(); |
| 6719 | |
| 6720 | if pending.is_empty() { |
| 6721 | self.mcp_boot_in_flight = false; |
| 6722 | self.mcp_boot_generation = None; |
| 6723 | self.emit_mcp_session_boot(generation, true).await; |
| 6724 | return Ok(generation); |
| 6725 | } |
| 6726 | |
| 6727 | self.mcp_boot_in_flight = true; |
| 6728 | self.mcp_boot_generation = Some(generation); |
| 6729 | let (progress_tx, progress_rx) = mpsc::channel(MCP_BOOT_CHANNEL_CAPACITY); |
| 6730 | let (done_tx, done_rx) = tokio::sync::watch::channel(false); |
| 6731 | self.mcp_boot_rx = Some(progress_rx); |
| 6732 | self.mcp_boot_done = Some(done_rx); |
| 6733 | |
| 6734 | self.emit_mcp_session_boot(generation, false).await; |
| 6735 | |
| 6736 | let pool_for_task = Arc::clone(&pool); |
| 6737 | spawn_supervised( |
| 6738 | "mcp-session-boot", |
| 6739 | std::panic::Location::caller(), |
| 6740 | async move { |
| 6741 | let mut remaining: HashSet<String> = |
| 6742 | pending.iter().map(|(name, _)| name.clone()).collect(); |
| 6743 | let mut connects = McpPool::spawn_pending_connects( |
| 6744 | pending, |
| 6745 | timeouts, |
| 6746 | network_policy, |
| 6747 | catalog_generation, |
| 6748 | ); |
| 6749 | let mut connection_errors = HashMap::new(); |
| 6750 | while let Some(joined) = connects.join_next().await { |
| 6751 | let (name, result) = joined |
| 6752 | .unwrap_or_else(|error| ("connection task".to_string(), Err(error.into()))); |
| 6753 | remaining.remove(&name); |
| 6754 | let connecting = { |
| 6755 | let mut pool = pool_for_task.lock().await; |
| 6756 | // A turn may have reloaded the pool while these handshakes |
| 6757 | // were in flight. Never let their old authority or failures |
| 6758 | // overwrite the newly installed configuration. |
| 6759 | let reload = pool.reload_if_config_changed().await; |
| 6760 | if reload.is_err() |
| 6761 | || pool.current_catalog_generation() != catalog_generation |
| 6762 | { |
| 6763 | connects.abort_all(); |
| 6764 | // `name` already left `remaining` above — its |
| 6765 | // mark dies with the aborted pass too. |
| 6766 | remaining.insert(name.clone()); |
| 6767 | pool.cancel_connecting(&remaining); |
| 6768 | connection_errors.clear(); |
| 6769 | if let Err(error) = reload { |
| 6770 | connection_errors.insert( |
| 6771 | "configuration".to_string(), |
| 6772 | crate::mcp::format_mcp_error_for_display(&error), |
| 6773 | ); |
| 6774 | } |
| 6775 | break; |
| 6776 | } |
| 6777 | let result = result.and_then(|connection| { |
| 6778 | pool.store_ready_connection(name.clone(), connection) |
| 6779 | }); |
| 6780 | if let Err(error) = result { |
| 6781 | pool.note_connect_failure(&name, &error); |
| 6782 | connection_errors |
| 6783 | .insert(name, crate::mcp::format_mcp_error_for_display(&error)); |
| 6784 | } |
| 6785 | // The pool's in-flight set also names connects a turn |
| 6786 | // started on an explicit selection while this pass was |
| 6787 | // running — report what is actually connecting. |
| 6788 | pool.connecting_servers() |
| 6789 | }; |
| 6790 | let _ = progress_tx.try_send(McpBootUpdate::Progress { |
| 6791 | generation, |
| 6792 | authority_errors: Arc::clone(&authority_errors), |
| 6793 | connection_errors: connection_errors.clone(), |
| 6794 | connecting, |
| 6795 | }); |
| 6796 | } |
| 6797 | { |
| 6798 | let pool = pool_for_task.lock().await; |
| 6799 | let mut required = Vec::new(); |
| 6800 | pool.push_required_server_errors(&mut required); |
| 6801 | for (name, error) in required { |
| 6802 | connection_errors |
| 6803 | .entry(name) |
| 6804 | .or_insert_with(|| crate::mcp::format_mcp_error_for_display(&error)); |
| 6805 | } |
| 6806 | } |
| 6807 | // The terminal update carries the boot's settlement signal; |
| 6808 | // wait for a slot instead of dropping it (#6147). |
| 6809 | let _ = progress_tx |
| 6810 | .send(McpBootUpdate::Finished { |
| 6811 | generation, |
| 6812 | authority_errors, |
| 6813 | connection_errors, |
| 6814 | }) |
| 6815 | .await; |
| 6816 | let _ = done_tx.send(true); |
| 6817 | }, |
| 6818 | ); |
| 6819 | |
| 6820 | Ok(generation) |
| 6821 | } |
| 6822 | |
| 6823 | /// Connect the configured servers through the one engine-owned pool and |
| 6824 | /// snapshot that exact pool for the boot UI. `connect_all` is bounded and |
| 6825 | /// concurrent; already-ready connections are preserved, and unlike the |
| 6826 | /// explicit reload path no config source is force-reloaded. |
| 6827 | async fn bootstrap_mcp_pool(&mut self) -> anyhow::Result<McpManagerUpdate> { |
| 6828 | if self.mcp_pool.is_none() { |
| 6829 | let _ = self.ensure_mcp_pool().await; |
| 6830 | } |
| 6831 | // Wait for the boot, but never without a deadline. Servers that fail |
| 6832 | // fast — a missing binary, a refused connection — are diagnosed in |
| 6833 | // milliseconds, and that diagnosis is the whole value of `/mcp`, so |
| 6834 | // returning before it lands would report an empty picture. Servers that |
| 6835 | // *stall* are the problem: `npx -y` and `uvx` fetch their package on |
| 6836 | // first run, and one cold fetch used to hold the view open forever. |
| 6837 | // Past the deadline the view renders what is known and the background |
| 6838 | // boot keeps running, so slower servers land on a later snapshot. |
| 6839 | if self.mcp_boot_in_flight { |
| 6840 | self.wait_for_mcp_boot().await; |
| 6841 | } |
| 6842 | self.drain_mcp_boot_updates().await; |
| 6843 | let snapshot = self.mcp_session_snapshot().await?; |
| 6844 | let generation = self.next_mcp_event_generation(); |
| 6845 | Ok(McpManagerUpdate { |
| 6846 | snapshot, |
| 6847 | generation, |
| 6848 | }) |
| 6849 | } |
| 6850 | |
| 6851 | async fn retry_mcp_server(&mut self, name: &str) -> anyhow::Result<McpManagerUpdate> { |
| 6852 | if self.mcp_boot_in_flight { |
| 6853 | self.wait_for_mcp_boot().await; |
| 6854 | } |
| 6855 | let pool = self |
| 6856 | .ensure_mcp_pool() |
| 6857 | .await |
| 6858 | .map_err(|error| anyhow::anyhow!(error.to_string()))?; |
| 6859 | let mut pool = pool.lock().await; |
| 6860 | match pool.retry_connection(name).await { |
| 6861 | Ok(_) => { |
| 6862 | self.mcp_connection_errors.remove(name); |
| 6863 | } |
| 6864 | Err(error) => { |
| 6865 | self.mcp_connection_errors.insert( |
| 6866 | name.to_string(), |
| 6867 | crate::mcp::format_mcp_error_for_display(&error), |
| 6868 | ); |
| 6869 | } |
| 6870 | } |
| 6871 | let snapshot = pool.manager_snapshot( |
| 6872 | &self.session.mcp_config_path, |
| 6873 | false, |
| 6874 | &self.mcp_connection_errors, |
| 6875 | ); |
| 6876 | self.mcp_connection_errors.retain(|server, _| { |
| 6877 | snapshot |
| 6878 | .servers |
| 6879 | .iter() |
| 6880 | .any(|configured| configured.name == *server) |
| 6881 | }); |
| 6882 | drop(pool); |
| 6883 | let generation = self.next_mcp_event_generation(); |
| 6884 | let _ = self.tx_event.try_send(Event::McpSessionBoot { |
| 6885 | generation, |
| 6886 | snapshot: snapshot.clone(), |
| 6887 | connecting: Vec::new(), |
| 6888 | finished: true, |
| 6889 | }); |
| 6890 | Ok(McpManagerUpdate { |
| 6891 | snapshot, |
| 6892 | generation, |
| 6893 | }) |
| 6894 | } |
| 6895 | |
| 6896 | async fn mcp_tools(&mut self) -> Vec<Tool> { |
| 6897 | let pool = match self.ensure_mcp_pool().await { |
| 6898 | Ok(pool) => pool, |
| 6899 | Err(err) => { |
| 6900 | tracing::debug!("MCP tools unavailable: {err}"); |
| 6901 | return Vec::new(); |
| 6902 | } |
| 6903 | }; |
| 6904 | |
| 6905 | if self.mcp_boot_in_flight { |
| 6906 | // Optional servers are still connecting in the background. Snapshot |
| 6907 | // currently-ready tools so the first LLM call is not serialized |
| 6908 | // behind the slowest handshake. Declare the refresh here as well |
| 6909 | // as on Progress: a ready connection can precede its mailbox update. |
| 6910 | self.session.pending_prefix_change_reason = Some("mcp-session-boot".to_string()); |
| 6911 | return pool.lock().await.to_api_tools(); |
| 6912 | } |
| 6913 | |
| 6914 | // Boot is lazy (#6033): unselected servers stay unconnected on |
| 6915 | // purpose, so there is no per-turn sweep here. A `required` server |
| 6916 | // that never got an attempt still owes the session an honest error |
| 6917 | // row — `push_required_server_errors` only fills gaps a real |
| 6918 | // diagnosis did not already cover. |
| 6919 | let mut gaps = Vec::new(); |
| 6920 | { |
| 6921 | let pool = pool.lock().await; |
| 6922 | pool.push_required_server_errors(&mut gaps); |
| 6923 | } |
| 6924 | let mut inserted = false; |
| 6925 | for (name, error) in gaps { |
| 6926 | self.mcp_connection_errors.entry(name).or_insert_with(|| { |
| 6927 | inserted = true; |
| 6928 | crate::mcp::format_mcp_error_for_display(&error) |
| 6929 | }); |
| 6930 | } |
| 6931 | if inserted { |
| 6932 | // Failures stay on the session-boot snapshot, not as Status toasts. |
| 6933 | let generation = self.next_mcp_event_generation(); |
| 6934 | self.emit_mcp_session_boot(generation, true).await; |
| 6935 | } |
| 6936 | pool.lock().await.to_api_tools() |
| 6937 | } |
| 6938 | |
| 6939 | /// Handle a turn using the DeepSeek API. |
| 6940 | #[allow(clippy::too_many_lines)] |
| 6941 | /// Refresh the stable system prompt based on current non-mode context. |
| 6942 | #[cfg_attr(not(test), expect(dead_code))] |
| 6943 | fn refresh_system_prompt(&mut self) { |
| 6944 | self.refresh_system_prompt_with_reason("system"); |
| 6945 | } |
| 6946 | |
| 6947 | fn refresh_system_prompt_with_reason(&mut self, reason: &str) { |
| 6948 | let context = self.installed_next_turn_prompt_context(); |
| 6949 | self.refresh_system_prompt_from_context_with_reason(&context, reason); |
| 6950 | } |
| 6951 | |
| 6952 | /// Recompose the stable system prompt from current context. When the bytes |
| 6953 | /// actually change (hash differs), record `reason` as the declared cause |
| 6954 | /// so the turn loop's prefix check re-pins the KV-cache prefix under a |
| 6955 | /// logged reason instead of reporting undeclared drift. This is only ever |
| 6956 | /// called from explicit header-change edges (session construction, submit |
| 6957 | /// turn boundary, `/model`, mode change, goal edits) — never mid-tool-loop, |
| 6958 | /// so an agent writing a file cannot silently move the pinned prefix. |
| 6959 | fn refresh_system_prompt_from_context_with_reason( |
| 6960 | &mut self, |
| 6961 | context: &NextTurnPromptContext, |
| 6962 | reason: &str, |
| 6963 | ) { |
| 6964 | let stable_prompt = self.compose_stable_system_prompt(context); |
| 6965 | |
| 6966 | let stable_hash = system_prompt_hash(stable_prompt.as_ref()); |
| 6967 | if self.session.system_prompt_override { |
| 6968 | return; |
| 6969 | } |
| 6970 | self.session.pinned_prompt_context = Some(context.clone()); |
| 6971 | if self.session.last_system_prompt_hash != Some(stable_hash) { |
| 6972 | self.session.system_prompt = stable_prompt; |
| 6973 | self.session.last_system_prompt_hash = Some(stable_hash); |
| 6974 | self.session.pending_prefix_change_reason = Some(reason.to_string()); |
| 6975 | // A re-pinned header carries every workspace change; the delta |
| 6976 | // baseline restarts from it. |
| 6977 | self.session.context_update_baseline = None; |
| 6978 | } |
| 6979 | } |
| 6980 | |
| 6981 | /// New-user-turn header policy. Called once per submitted user turn, |
| 6982 | /// never mid-tool-loop. |
| 6983 | /// |
| 6984 | /// - When the explicit prompt inputs (model, mode, goal, route, |
| 6985 | /// translation, verbosity) changed, that is a declared header change: |
| 6986 | /// recompose and re-pin under a `change:<field>` reason. |
| 6987 | /// - Otherwise the pinned header stays byte-identical. If a fresh compose |
| 6988 | /// would differ (workspace files, AGENTS.md, skills, memory drifted), the |
| 6989 | /// delta is returned as a bounded `<context_update>` snapshot for the |
| 6990 | /// caller to append as a user-role message *before* the user's message |
| 6991 | /// — a normal history append, so the prefix still extends. |
| 6992 | /// - Returns `None` when nothing changed or a header re-pin absorbed it. |
| 6993 | fn refresh_pinned_header_for_turn( |
| 6994 | &mut self, |
| 6995 | context: &NextTurnPromptContext, |
| 6996 | ) -> Option<String> { |
| 6997 | if self.session.system_prompt_override { |
| 6998 | return None; |
| 6999 | } |
| 7000 | let explicit_reason = match self.session.pinned_prompt_context.as_ref() { |
| 7001 | None => Some("system".to_string()), |
| 7002 | Some(pinned) if pinned != context => { |
| 7003 | Some(explicit_prompt_context_change_reason(pinned, context)) |
| 7004 | } |
| 7005 | Some(_) => None, |
| 7006 | }; |
| 7007 | if let Some(reason) = explicit_reason { |
| 7008 | self.refresh_system_prompt_from_context_with_reason(context, &reason); |
| 7009 | return None; |
| 7010 | } |
| 7011 | |
| 7012 | let composed = self.compose_stable_system_prompt(context); |
| 7013 | let composed_hash = system_prompt_hash(composed.as_ref()); |
| 7014 | if self.session.last_system_prompt_hash == Some(composed_hash) { |
| 7015 | return None; |
| 7016 | } |
| 7017 | let pinned_text = |
| 7018 | codewhale_core::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref()); |
| 7019 | let known_text = self |
| 7020 | .session |
| 7021 | .context_update_baseline |
| 7022 | .clone() |
| 7023 | .unwrap_or(pinned_text); |
| 7024 | let current_text = codewhale_core::prefix_cache::system_prompt_text(composed.as_ref()); |
| 7025 | if known_text == current_text { |
| 7026 | return None; |
| 7027 | } |
| 7028 | let summary = |
| 7029 | codewhale_core::prefix_cache::context_update_message(&known_text, ¤t_text)?; |
| 7030 | self.session.context_update_baseline = Some(current_text); |
| 7031 | if let Some(pm) = self.session.prefix_stability.as_mut() { |
| 7032 | pm.note_context_update(); |
| 7033 | } |
| 7034 | Some(summary) |
| 7035 | } |
| 7036 | |
| 7037 | /// Compose the stable system prompt for an explicit route, without |
| 7038 | /// touching session state. |
| 7039 | /// |
| 7040 | /// [`Self::refresh_system_prompt`] calls it for the installed route; |
| 7041 | /// `/preview-request` calls it for the route the *next* turn would use, |
| 7042 | /// which may be a different model with a different context window when |
| 7043 | /// auto routing is on. Extracting it is what lets a preview describe the |
| 7044 | /// next prompt exactly without mutating the session to find out. |
| 7045 | pub(super) fn compose_stable_system_prompt( |
| 7046 | &self, |
| 7047 | context: &NextTurnPromptContext, |
| 7048 | ) -> Option<SystemPrompt> { |
| 7049 | if self.api_config.runtime_chat_isolated { |
| 7050 | return Some(SystemPrompt::Text(ISOLATED_CHAT_ENGINE_PROMPT.to_string())); |
| 7051 | } |
| 7052 | let user_memory_block = crate::native_memory::native_prompt_block_traced( |
| 7053 | self.config.memory_enabled, |
| 7054 | &self.config.memory_path, |
| 7055 | &self.config.workspace, |
| 7056 | &self.session.id, |
| 7057 | ); |
| 7058 | let prompt_host = if self.config.terminal_chrome_enabled { |
| 7059 | prompts::PromptHost::Interactive |
| 7060 | } else { |
| 7061 | prompts::PromptHost::Headless |
| 7062 | }; |
| 7063 | // Recomputed on each refresh (#5715): the prior session's checkpoint |
| 7064 | // may have settled or been resumed since construction. |
| 7065 | let recovery_hint = crate::session_manager::session_recovery_hint( |
| 7066 | &self.config.workspace, |
| 7067 | Some(self.session.id.as_str()), |
| 7068 | ); |
| 7069 | let base = |
| 7070 | prompts::system_prompt_for_mode_with_context_skills_session_and_approval_for_host( |
| 7071 | &self.config.workspace, |
| 7072 | None, |
| 7073 | Some(&self.config.skills_dir), |
| 7074 | Some(&self.config.instructions), |
| 7075 | prompts::PromptSessionContext { |
| 7076 | user_memory_block: user_memory_block.as_deref(), |
| 7077 | goal_objective: context.goal_objective.as_deref(), |
| 7078 | project_context_pack_enabled: self.config.project_context_pack_enabled, |
| 7079 | locale_tag: &self.config.locale_tag, |
| 7080 | translation_enabled: context.translation_enabled, |
| 7081 | model_id: &context.model, |
| 7082 | context_window_override: Some( |
| 7083 | crate::route_budget::route_context_window_tokens( |
| 7084 | context.provider, |
| 7085 | &context.model, |
| 7086 | context.route_limits, |
| 7087 | ), |
| 7088 | ), |
| 7089 | verbosity: context.verbosity.as_deref(), |
| 7090 | recovery_hint: recovery_hint.as_deref(), |
| 7091 | skills_scan_codewhale_only: self.config.skills_scan_codewhale_only, |
| 7092 | plugin_registry: Some(self.plugin_registry.as_ref()), |
| 7093 | mode: context.mode, |
| 7094 | }, |
| 7095 | prompt_host, |
| 7096 | ); |
| 7097 | Some(base) |
| 7098 | } |
| 7099 | |
| 7100 | fn installed_next_turn_prompt_context(&self) -> NextTurnPromptContext { |
| 7101 | NextTurnPromptContext::for_planned_turn( |
| 7102 | self.api_provider, |
| 7103 | self.config.model.clone(), |
| 7104 | self.active_route_limits, |
| 7105 | self.current_mode, |
| 7106 | goal_objective_for_prompt( |
| 7107 | self.config.goal_objective.as_deref(), |
| 7108 | &self.config.goal_state, |
| 7109 | ), |
| 7110 | self.config.goal_status, |
| 7111 | self.config.goal_token_budget, |
| 7112 | self.config.translation_enabled, |
| 7113 | self.config.verbosity.clone(), |
| 7114 | ) |
| 7115 | } |
| 7116 | } |
| 7117 | |
| 7118 | fn default_plugin_tools_dir() -> PathBuf { |
| 7119 | codewhale_config::codewhale_home() |
| 7120 | .unwrap_or_else(|_| { |
| 7121 | crate::config::effective_home_dir() |
| 7122 | .map_or_else(|| PathBuf::from(".codewhale"), |h| h.join(".codewhale")) |
| 7123 | }) |
| 7124 | .join("tools") |
| 7125 | } |
| 7126 | |
| 7127 | fn plugin_tools_dir(tools_config: Option<&crate::config::ToolsConfig>) -> PathBuf { |
| 7128 | if let Some(tools_config) = tools_config |
| 7129 | && let Some(custom_dir) = tools_config.plugin_dir.as_deref() |
| 7130 | { |
| 7131 | return PathBuf::from(shellexpand::tilde(custom_dir).as_ref()); |
| 7132 | } |
| 7133 | default_plugin_tools_dir() |
| 7134 | } |
| 7135 | |
| 7136 | fn configure_plugin_tools( |
| 7137 | tool_registry: &mut crate::tools::ToolRegistry, |
| 7138 | tools_config: Option<&crate::config::ToolsConfig>, |
| 7139 | ) -> std::collections::HashSet<String> { |
| 7140 | let names_before: std::collections::HashSet<String> = tool_registry |
| 7141 | .names() |
| 7142 | .into_iter() |
| 7143 | .map(|s| s.to_string()) |
| 7144 | .collect(); |
| 7145 | |
| 7146 | let plugin_dir = plugin_tools_dir(tools_config); |
| 7147 | tool_registry.load_plugins(&plugin_dir); |
| 7148 | |
| 7149 | if let Some(tools_config) = tools_config |
| 7150 | && let Some(ref overrides) = tools_config.overrides |
| 7151 | { |
| 7152 | tool_registry.apply_overrides(overrides, &plugin_dir); |
| 7153 | } |
| 7154 | |
| 7155 | let names_after: std::collections::HashSet<String> = tool_registry |
| 7156 | .names() |
| 7157 | .into_iter() |
| 7158 | .map(|s| s.to_string()) |
| 7159 | .collect(); |
| 7160 | &names_after - &names_before |
| 7161 | } |
| 7162 | |
| 7163 | fn system_prompt_hash(prompt: Option<&SystemPrompt>) -> u64 { |
| 7164 | let mut hasher = DefaultHasher::new(); |
| 7165 | match prompt { |
| 7166 | Some(SystemPrompt::Text(text)) => { |
| 7167 | 0u8.hash(&mut hasher); |
| 7168 | text.hash(&mut hasher); |
| 7169 | } |
| 7170 | Some(SystemPrompt::Blocks(blocks)) => { |
| 7171 | 1u8.hash(&mut hasher); |
| 7172 | for block in blocks { |
| 7173 | block.block_type.hash(&mut hasher); |
| 7174 | block.text.hash(&mut hasher); |
| 7175 | if let Some(cache_control) = &block.cache_control { |
| 7176 | cache_control.cache_type.hash(&mut hasher); |
| 7177 | } |
| 7178 | } |
| 7179 | } |
| 7180 | None => { |
| 7181 | 2u8.hash(&mut hasher); |
| 7182 | } |
| 7183 | } |
| 7184 | hasher.finish() |
| 7185 | } |
| 7186 | |
| 7187 | fn normalized_goal_objective(value: Option<&str>) -> Option<String> { |
| 7188 | value |
| 7189 | .map(str::trim) |
| 7190 | .filter(|value| !value.is_empty()) |
| 7191 | .map(str::to_string) |
| 7192 | } |
| 7193 | |
| 7194 | fn sync_goal_state_from_host( |
| 7195 | goal_state: &SharedGoalState, |
| 7196 | objective: Option<&str>, |
| 7197 | token_budget: Option<u32>, |
| 7198 | status: GoalStatus, |
| 7199 | ) { |
| 7200 | match goal_state.lock() { |
| 7201 | Ok(mut state) => state.sync_from_host_status(objective, token_budget, status), |
| 7202 | Err(err) => tracing::warn!("goal state lock poisoned while syncing host goal: {err}"), |
| 7203 | } |
| 7204 | } |
| 7205 | |
| 7206 | fn goal_objective_for_prompt( |
| 7207 | configured_goal: Option<&str>, |
| 7208 | goal_state: &SharedGoalState, |
| 7209 | ) -> Option<String> { |
| 7210 | match goal_state.lock() { |
| 7211 | Ok(state) => { |
| 7212 | if let Some(objective) = state.objective() { |
| 7213 | // Preserve original behavior: return None (not fallback) when |
| 7214 | // objective exists but goal is inactive. |
| 7215 | return state.is_active().then(|| objective.to_string()); |
| 7216 | } |
| 7217 | } |
| 7218 | Err(err) => tracing::warn!("goal state lock poisoned while building prompt: {err}"), |
| 7219 | } |
| 7220 | normalized_goal_objective(configured_goal) |
| 7221 | } |
| 7222 | |
| 7223 | // ── Mode & approval prompts as request-time runtime metadata ───────── |
| 7224 | // |
| 7225 | // Mode contracts and approval policies are not persisted in the session |
| 7226 | // history and are not sent as extra system messages. Instead, each API |
| 7227 | // request projects a transient user-role runtime metadata message at the |
| 7228 | // tail. The stable system prompt remains byte-stable, stored history remains |
| 7229 | // byte-stable, and strict chat-template providers never see a system message |
| 7230 | // outside messages[0]. |
| 7231 | |
| 7232 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 7233 | pub(crate) enum ToolAskRuleDecision { |
| 7234 | Allow, |
| 7235 | Prompt(String), |
| 7236 | Block(String), |
| 7237 | } |
| 7238 | |
| 7239 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 7240 | pub(crate) enum AutoReviewPlanDecision { |
| 7241 | NoChange, |
| 7242 | Allow, |
| 7243 | ForcePrompt(String), |
| 7244 | Block(String), |
| 7245 | /// Fallback hold routed to the model guardian in interactive Auto posture |
| 7246 | /// instead of a hard block. |
| 7247 | ConsultReviewer(String), |
| 7248 | } |
| 7249 | |
| 7250 | pub(super) fn auto_review_run_origin_for_plan( |
| 7251 | detached_start: bool, |
| 7252 | ) -> crate::tui::auto_review::RunOrigin { |
| 7253 | if detached_start { |
| 7254 | crate::tui::auto_review::RunOrigin::Background |
| 7255 | } else { |
| 7256 | crate::tui::auto_review::RunOrigin::Interactive |
| 7257 | } |
| 7258 | } |
| 7259 | |
| 7260 | pub(crate) fn auto_review_plan_decision_for_context( |
| 7261 | policy: &crate::tui::auto_review::AutoReviewPolicy, |
| 7262 | context: &crate::tui::auto_review::AutoReviewContext<'_>, |
| 7263 | ) -> (AutoReviewPlanDecision, Value) { |
| 7264 | let decision = policy.evaluate(context); |
| 7265 | let audit_event = policy.audit_event(context, &decision); |
| 7266 | let plan_decision = if context.approval_mode == ApprovalMode::Auto |
| 7267 | && context.tool_name == REQUEST_USER_INPUT_NAME |
| 7268 | { |
| 7269 | // This synthetic tool does not execute user work. Let the turn loop |
| 7270 | // return its ordinary autonomous guidance result instead of treating |
| 7271 | // a hallucinated question as an unknown external action. |
| 7272 | AutoReviewPlanDecision::Allow |
| 7273 | } else { |
| 7274 | match decision.action { |
| 7275 | crate::tui::auto_review::AutoReviewAction::Allow |
| 7276 | if context.approval_mode == ApprovalMode::Auto => |
| 7277 | { |
| 7278 | AutoReviewPlanDecision::Allow |
| 7279 | } |
| 7280 | crate::tui::auto_review::AutoReviewAction::Allow => AutoReviewPlanDecision::NoChange, |
| 7281 | crate::tui::auto_review::AutoReviewAction::AskUser if decision.built_in_safety_gate => { |
| 7282 | // Name the built-in gate honestly. |
| 7283 | let reason = format!( |
| 7284 | "Built-in safety gate requires approval: {}", |
| 7285 | decision.reason |
| 7286 | ); |
| 7287 | if matches!( |
| 7288 | context.approval_mode, |
| 7289 | ApprovalMode::Auto | ApprovalMode::Never | ApprovalMode::Bypass |
| 7290 | ) { |
| 7291 | // Auto-Review, Never, and Full Access are non-interactive for |
| 7292 | // approval holds. Full Access auto-runs ordinary calls, but a |
| 7293 | // non-bypassable safety floor always fails closed. |
| 7294 | AutoReviewPlanDecision::Block(reason) |
| 7295 | } else { |
| 7296 | AutoReviewPlanDecision::ForcePrompt(reason) |
| 7297 | } |
| 7298 | } |
| 7299 | crate::tui::auto_review::AutoReviewAction::AskUser |
| 7300 | if context.approval_mode == ApprovalMode::Auto => |
| 7301 | { |
| 7302 | AutoReviewPlanDecision::ConsultReviewer(decision.reason.clone()) |
| 7303 | } |
| 7304 | crate::tui::auto_review::AutoReviewAction::AskUser => AutoReviewPlanDecision::NoChange, |
| 7305 | crate::tui::auto_review::AutoReviewAction::Block => { |
| 7306 | AutoReviewPlanDecision::Block(format!( |
| 7307 | "Auto-review policy blocked tool '{}': {}", |
| 7308 | context.tool_name, decision.reason |
| 7309 | )) |
| 7310 | } |
| 7311 | } |
| 7312 | }; |
| 7313 | (plan_decision, audit_event) |
| 7314 | } |
| 7315 | |
| 7316 | pub(super) fn exec_shell_ask_rule_decision( |
| 7317 | config: &EngineConfig, |
| 7318 | tool_name: &str, |
| 7319 | tool_input: &Value, |
| 7320 | workspace: &Path, |
| 7321 | approval_mode: ApprovalMode, |
| 7322 | ) -> Option<ToolAskRuleDecision> { |
| 7323 | exec_shell_ask_rule_decision_for_policy( |
| 7324 | &config.exec_policy_engine, |
| 7325 | tool_name, |
| 7326 | tool_input, |
| 7327 | workspace, |
| 7328 | approval_mode, |
| 7329 | ) |
| 7330 | } |
| 7331 | |
| 7332 | /// Evaluate the persisted shell ask/allow/deny rules without requiring a full |
| 7333 | /// [`EngineConfig`]. Headless protocol adapters use this seam so they enforce |
| 7334 | /// the same sibling `permissions.toml` policy as the interactive engine. |
| 7335 | pub(crate) fn exec_shell_ask_rule_decision_for_policy( |
| 7336 | exec_policy_engine: &codewhale_execpolicy::ExecPolicyEngine, |
| 7337 | tool_name: &str, |
| 7338 | tool_input: &Value, |
| 7339 | workspace: &Path, |
| 7340 | approval_mode: ApprovalMode, |
| 7341 | ) -> Option<ToolAskRuleDecision> { |
| 7342 | let policy_tool_name = |
| 7343 | crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input); |
| 7344 | if policy_tool_name != "exec_shell" { |
| 7345 | return None; |
| 7346 | } |
| 7347 | let command = tool_input.get("command").and_then(Value::as_str)?; |
| 7348 | tool_ask_rule_decision_for_context( |
| 7349 | exec_policy_engine, |
| 7350 | policy_tool_name, |
| 7351 | command, |
| 7352 | None, |
| 7353 | workspace, |
| 7354 | approval_mode, |
| 7355 | ) |
| 7356 | } |
| 7357 | |
| 7358 | pub(super) fn file_tool_ask_rule_decision( |
| 7359 | config: &EngineConfig, |
| 7360 | tool_name: &str, |
| 7361 | tool_input: &Value, |
| 7362 | workspace: &Path, |
| 7363 | approval_mode: ApprovalMode, |
| 7364 | ) -> Option<ToolAskRuleDecision> { |
| 7365 | file_tool_ask_rule_decision_for_policy( |
| 7366 | &config.exec_policy_engine, |
| 7367 | tool_name, |
| 7368 | tool_input, |
| 7369 | workspace, |
| 7370 | approval_mode, |
| 7371 | ) |
| 7372 | } |
| 7373 | |
| 7374 | /// Evaluate the persisted file ask/allow/deny rules without requiring a full |
| 7375 | /// [`EngineConfig`]. This keeps protocol adapters on the canonical path and |
| 7376 | /// preserves the all-targets-must-match rule for multi-file patches. |
| 7377 | pub(crate) fn file_tool_ask_rule_decision_for_policy( |
| 7378 | exec_policy_engine: &codewhale_execpolicy::ExecPolicyEngine, |
| 7379 | tool_name: &str, |
| 7380 | tool_input: &Value, |
| 7381 | workspace: &Path, |
| 7382 | approval_mode: ApprovalMode, |
| 7383 | ) -> Option<ToolAskRuleDecision> { |
| 7384 | let policy_tool_name = |
| 7385 | crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input); |
| 7386 | let paths = file_tool_permission_paths(policy_tool_name, tool_input)?; |
| 7387 | if paths.is_empty() { |
| 7388 | return tool_ask_rule_decision_for_context( |
| 7389 | exec_policy_engine, |
| 7390 | policy_tool_name, |
| 7391 | "", |
| 7392 | None, |
| 7393 | workspace, |
| 7394 | approval_mode, |
| 7395 | ); |
| 7396 | } |
| 7397 | |
| 7398 | let mut prompt: Option<String> = None; |
| 7399 | let mut all_allowed = true; |
| 7400 | for path in paths { |
| 7401 | match tool_ask_rule_decision_for_context( |
| 7402 | exec_policy_engine, |
| 7403 | policy_tool_name, |
| 7404 | "", |
| 7405 | Some(&path), |
| 7406 | workspace, |
| 7407 | approval_mode, |
| 7408 | ) { |
| 7409 | Some(ToolAskRuleDecision::Block(reason)) => { |
| 7410 | return Some(ToolAskRuleDecision::Block(reason)); |
| 7411 | } |
| 7412 | Some(ToolAskRuleDecision::Prompt(reason)) => { |
| 7413 | prompt.get_or_insert(reason); |
| 7414 | all_allowed = false; |
| 7415 | } |
| 7416 | Some(ToolAskRuleDecision::Allow) => {} |
| 7417 | None => all_allowed = false, |
| 7418 | } |
| 7419 | } |
| 7420 | if let Some(prompt) = prompt { |
| 7421 | Some(ToolAskRuleDecision::Prompt(prompt)) |
| 7422 | } else if all_allowed { |
| 7423 | Some(ToolAskRuleDecision::Allow) |
| 7424 | } else { |
| 7425 | None |
| 7426 | } |
| 7427 | } |
| 7428 | |
| 7429 | fn tool_ask_rule_decision_for_context( |
| 7430 | exec_policy_engine: &codewhale_execpolicy::ExecPolicyEngine, |
| 7431 | tool_name: &str, |
| 7432 | command: &str, |
| 7433 | path: Option<&str>, |
| 7434 | workspace: &Path, |
| 7435 | approval_mode: ApprovalMode, |
| 7436 | ) -> Option<ToolAskRuleDecision> { |
| 7437 | let cwd = workspace.to_string_lossy(); |
| 7438 | let ask_for_approval = match approval_mode { |
| 7439 | ApprovalMode::Never => AskForApproval::Never, |
| 7440 | ApprovalMode::Auto | ApprovalMode::Bypass | ApprovalMode::Suggest => { |
| 7441 | AskForApproval::OnFailure |
| 7442 | } |
| 7443 | }; |
| 7444 | let decision = exec_policy_engine |
| 7445 | .check(ExecPolicyContext { |
| 7446 | command, |
| 7447 | cwd: cwd.as_ref(), |
| 7448 | tool: Some(tool_name), |
| 7449 | path, |
| 7450 | ask_for_approval, |
| 7451 | sandbox_mode: None, |
| 7452 | }) |
| 7453 | .ok()?; |
| 7454 | if !decision.allow { |
| 7455 | Some(ToolAskRuleDecision::Block(decision.reason().to_string())) |
| 7456 | } else if decision.requires_approval { |
| 7457 | Some(ToolAskRuleDecision::Prompt(decision.reason().to_string())) |
| 7458 | } else if decision.matched_action == Some(codewhale_execpolicy::PermissionAction::Allow) { |
| 7459 | // Count only. Never `matched_rule`, never `reason()`, never the |
| 7460 | // command or its argv: `auto_allow` patterns are user-authored command |
| 7461 | // strings. |
| 7462 | codewhale_telemetry::session_counters() |
| 7463 | .bump(codewhale_telemetry::Counter::ApprovalAutoAllowed); |
| 7464 | Some(ToolAskRuleDecision::Allow) |
| 7465 | } else { |
| 7466 | None |
| 7467 | } |
| 7468 | } |
| 7469 | |
| 7470 | fn file_tool_permission_paths(tool_name: &str, input: &Value) -> Option<Vec<String>> { |
| 7471 | match tool_name { |
| 7472 | "read_file" | "write_file" | "edit_file" | "file_search" | "grep_files" => { |
| 7473 | Some(string_field(input, "path").into_iter().collect()) |
| 7474 | } |
| 7475 | "list_dir" => Some(vec![ |
| 7476 | string_field(input, "path").unwrap_or_else(|| ".".to_string()), |
| 7477 | ]), |
| 7478 | "apply_patch" => Some(apply_patch_permission_paths(input)), |
| 7479 | _ => None, |
| 7480 | } |
| 7481 | } |
| 7482 | |
| 7483 | /// Target paths when a call is one of the canonical workspace file-write |
| 7484 | /// tools (`write_file` / `edit_file` / `apply_patch`), `None` for any other |
| 7485 | /// tool. Feeds the in-workspace write carve-out (#5185). |
| 7486 | fn file_write_tool_target_paths(tool_name: &str, input: &Value) -> Option<Vec<String>> { |
| 7487 | let canonical = crate::tools::canonical_action::canonical_action_alias(tool_name, input); |
| 7488 | if !matches!(canonical, "write_file" | "edit_file" | "apply_patch") { |
| 7489 | return None; |
| 7490 | } |
| 7491 | file_tool_permission_paths(canonical, input) |
| 7492 | } |
| 7493 | |
| 7494 | fn string_field(input: &Value, key: &str) -> Option<String> { |
| 7495 | input |
| 7496 | .get(key) |
| 7497 | .and_then(Value::as_str) |
| 7498 | .map(str::trim) |
| 7499 | .filter(|value| !value.is_empty()) |
| 7500 | .map(str::to_string) |
| 7501 | } |
| 7502 | |
| 7503 | fn apply_patch_permission_paths(input: &Value) -> Vec<String> { |
| 7504 | crate::tools::apply_patch::preflight_apply_patch(input) |
| 7505 | .map(|preflight| preflight.touched_files) |
| 7506 | .unwrap_or_default() |
| 7507 | } |
| 7508 | |
| 7509 | /// Spawn the engine in a background task |
| 7510 | pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle { |
| 7511 | let (engine, handle) = Engine::new(config, api_config); |
| 7512 | |
| 7513 | // Box the run future before supervision. An extra async wrapper embeds |
| 7514 | // the large engine state again in both its own and the supervisor's poll |
| 7515 | // frames, which can overflow an ordinary worker-thread stack. |
| 7516 | spawn_supervised( |
| 7517 | "engine-event-loop", |
| 7518 | std::panic::Location::caller(), |
| 7519 | Box::pin(engine.run()), |
| 7520 | ); |
| 7521 | |
| 7522 | handle |
| 7523 | } |
| 7524 | |
| 7525 | /// Spawn a runtime-owned engine whose autonomous later turns resolve against |
| 7526 | /// the manager's atomic config snapshot. This does not mutate an active turn. |
| 7527 | pub(crate) fn spawn_engine_with_authoritative_route_config( |
| 7528 | config: EngineConfig, |
| 7529 | api_config: &Config, |
| 7530 | authoritative_route_config: Arc<parking_lot::RwLock<Config>>, |
| 7531 | ) -> (EngineHandle, tokio::task::JoinHandle<()>) { |
| 7532 | let (mut engine, handle) = Engine::new(config, api_config); |
| 7533 | engine.authoritative_route_config = Some(authoritative_route_config); |
| 7534 | |
| 7535 | let worker = spawn_supervised( |
| 7536 | "engine-event-loop", |
| 7537 | std::panic::Location::caller(), |
| 7538 | Box::pin(engine.run()), |
| 7539 | ); |
| 7540 | |
| 7541 | (handle, worker) |
| 7542 | } |
| 7543 | |
| 7544 | #[cfg(test)] |
| 7545 | pub(crate) struct MockEngineHandle { |
| 7546 | pub handle: EngineHandle, |
| 7547 | pub rx_op: mpsc::Receiver<Op>, |
| 7548 | rx_approval: mpsc::Receiver<ApprovalDecision>, |
| 7549 | rx_user_input: mpsc::Receiver<UserInputDecision>, |
| 7550 | pub rx_steer: mpsc::Receiver<handle::SteerInput>, |
| 7551 | pub tx_event: mpsc::Sender<Event>, |
| 7552 | pub cancel_token: CancellationToken, |
| 7553 | } |
| 7554 | |
| 7555 | #[cfg(test)] |
| 7556 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 7557 | pub(crate) enum MockApprovalEvent { |
| 7558 | Approved { |
| 7559 | id: String, |
| 7560 | }, |
| 7561 | Denied { |
| 7562 | id: String, |
| 7563 | }, |
| 7564 | TimedOut { |
| 7565 | id: String, |
| 7566 | }, |
| 7567 | RetryWithPolicy { |
| 7568 | id: String, |
| 7569 | policy: crate::sandbox::SandboxPolicy, |
| 7570 | }, |
| 7571 | } |
| 7572 | |
| 7573 | #[cfg(test)] |
| 7574 | impl MockEngineHandle { |
| 7575 | pub(crate) async fn recv_approval_event(&mut self) -> Option<MockApprovalEvent> { |
| 7576 | match self.rx_approval.recv().await? { |
| 7577 | ApprovalDecision::Approved { id } => Some(MockApprovalEvent::Approved { id }), |
| 7578 | ApprovalDecision::Denied { id } => Some(MockApprovalEvent::Denied { id }), |
| 7579 | ApprovalDecision::TimedOut { id } => Some(MockApprovalEvent::TimedOut { id }), |
| 7580 | ApprovalDecision::RetryWithPolicy { id, policy } => { |
| 7581 | Some(MockApprovalEvent::RetryWithPolicy { id, policy }) |
| 7582 | } |
| 7583 | } |
| 7584 | } |
| 7585 | |
| 7586 | pub(crate) async fn recv_user_input_submission( |
| 7587 | &mut self, |
| 7588 | ) -> Option<(String, UserInputResponse)> { |
| 7589 | match self.rx_user_input.recv().await? { |
| 7590 | UserInputDecision::Submitted { id, response } => Some((id, response)), |
| 7591 | UserInputDecision::Cancelled { .. } => None, |
| 7592 | } |
| 7593 | } |
| 7594 | |
| 7595 | pub(crate) async fn recv_user_input_cancellation(&mut self) -> Option<String> { |
| 7596 | match self.rx_user_input.recv().await? { |
| 7597 | UserInputDecision::Cancelled { id } => Some(id), |
| 7598 | UserInputDecision::Submitted { .. } => None, |
| 7599 | } |
| 7600 | } |
| 7601 | |
| 7602 | /// Close the engine event stream without moving fields out of the handle, |
| 7603 | /// so failure-path tests can keep using the receiver helpers afterwards. |
| 7604 | pub(crate) fn close_event_stream(&mut self) { |
| 7605 | let (tx_event, _rx_event) = mpsc::channel(1); |
| 7606 | self.tx_event = tx_event; |
| 7607 | } |
| 7608 | } |
| 7609 | |
| 7610 | #[cfg(test)] |
| 7611 | pub(crate) fn mock_engine_handle() -> MockEngineHandle { |
| 7612 | let (tx_op, rx_op) = mpsc::channel(32); |
| 7613 | let (tx_event, rx_event) = mpsc::channel(256); |
| 7614 | let (tx_approval, rx_approval) = mpsc::channel(64); |
| 7615 | let (tx_user_input, rx_user_input) = mpsc::channel(32); |
| 7616 | let (tx_steer, rx_steer) = mpsc::channel(64); |
| 7617 | let cancel_token = CancellationToken::new(); |
| 7618 | let shared_cancel_token = Arc::new(StdMutex::new(cancel_token.clone())); |
| 7619 | let cancel_reason: Arc<StdMutex<Option<CancelReason>>> = Arc::new(StdMutex::new(None)); |
| 7620 | let shared_paused = Arc::new(StdMutex::new(false)); |
| 7621 | let live_runtime_authority = Arc::new(StdMutex::new(LiveRuntimeAuthorityState::new( |
| 7622 | LiveRuntimeAuthority::from_fields( |
| 7623 | AppMode::Agent, |
| 7624 | false, |
| 7625 | false, |
| 7626 | false, |
| 7627 | ApprovalMode::Suggest, |
| 7628 | None, |
| 7629 | ), |
| 7630 | ))); |
| 7631 | let compaction_cancellation = Arc::new(StdMutex::new(CompactionCancellationState::default())); |
| 7632 | let handle = EngineHandle { |
| 7633 | goal_state: new_shared_goal_state(), |
| 7634 | tx_op, |
| 7635 | rx_event: Arc::new(RwLock::new(rx_event)), |
| 7636 | cancel_token: shared_cancel_token, |
| 7637 | cancel_reason, |
| 7638 | tx_approval, |
| 7639 | tx_user_input, |
| 7640 | tx_steer, |
| 7641 | turn_controls: Arc::new(StdMutex::new(handle::TurnControls::default())), |
| 7642 | shared_paused, |
| 7643 | client_preflight_required: false, |
| 7644 | live_runtime_authority, |
| 7645 | compaction_cancellation, |
| 7646 | }; |
| 7647 | |
| 7648 | MockEngineHandle { |
| 7649 | handle, |
| 7650 | rx_op, |
| 7651 | rx_approval, |
| 7652 | rx_user_input, |
| 7653 | rx_steer, |
| 7654 | tx_event, |
| 7655 | cancel_token, |
| 7656 | } |
| 7657 | } |
| 7658 | |
| 7659 | /// The session state a turn installs before it writes `<turn_meta>`. |
| 7660 | /// |
| 7661 | /// Production reads it back off `self` after installing it; `/preview-request` |
| 7662 | /// supplies the values it *would* install, so an inspection can reproduce the |
| 7663 | /// block exactly without writing any of them. |
| 7664 | pub(crate) struct TurnMetadataSnapshot<'a> { |
| 7665 | pub(crate) prompt_context: &'a NextTurnPromptContext, |
| 7666 | pub(crate) system_prompt: Option<&'a SystemPrompt>, |
| 7667 | pub(crate) approval_mode: ApprovalMode, |
| 7668 | pub(crate) working_set: &'a crate::working_set::WorkingSet, |
| 7669 | pub(crate) policy_narrowing: Option<&'a PolicyNarrowingEvent>, |
| 7670 | } |
| 7671 | |
| 7672 | /// Immutable prompt facts for the next accepted turn. |
| 7673 | /// |
| 7674 | /// Both production and `/preview-request` compose through this value. It owns |
| 7675 | /// every per-turn field resolved by submit or route planning that can change |
| 7676 | /// the stable system prompt, so a hypothetical route cannot accidentally |
| 7677 | /// inherit the installed turn's goal, translation, verbosity, mode, |
| 7678 | /// model, or context window. Workspace-scoped prompt inputs remain engine |
| 7679 | /// configuration and are documented separately as snapshot dependencies. |
| 7680 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 7681 | pub(crate) struct NextTurnPromptContext { |
| 7682 | pub(crate) provider: ApiProvider, |
| 7683 | pub(crate) model: String, |
| 7684 | pub(crate) route_limits: Option<codewhale_config::route::RouteLimits>, |
| 7685 | pub(crate) mode: AppMode, |
| 7686 | pub(crate) goal_objective: Option<String>, |
| 7687 | pub(crate) goal_token_budget: Option<u32>, |
| 7688 | pub(crate) translation_enabled: bool, |
| 7689 | pub(crate) verbosity: Option<String>, |
| 7690 | } |
| 7691 | |
| 7692 | /// Name the explicit prompt inputs that differ between two contexts, for the |
| 7693 | /// `change:<what>` prefix-pin reason. |
| 7694 | pub(crate) fn explicit_prompt_context_change_reason( |
| 7695 | pinned: &NextTurnPromptContext, |
| 7696 | next: &NextTurnPromptContext, |
| 7697 | ) -> String { |
| 7698 | let mut fields = Vec::new(); |
| 7699 | if pinned.provider != next.provider { |
| 7700 | fields.push("provider"); |
| 7701 | } |
| 7702 | if pinned.model != next.model { |
| 7703 | fields.push("model"); |
| 7704 | } |
| 7705 | if pinned.route_limits != next.route_limits { |
| 7706 | fields.push("route"); |
| 7707 | } |
| 7708 | if pinned.mode != next.mode { |
| 7709 | fields.push("mode"); |
| 7710 | } |
| 7711 | if pinned.goal_objective != next.goal_objective |
| 7712 | || pinned.goal_token_budget != next.goal_token_budget |
| 7713 | { |
| 7714 | fields.push("goal"); |
| 7715 | } |
| 7716 | if pinned.translation_enabled != next.translation_enabled { |
| 7717 | fields.push("translation"); |
| 7718 | } |
| 7719 | if pinned.verbosity != next.verbosity { |
| 7720 | fields.push("verbosity"); |
| 7721 | } |
| 7722 | if fields.is_empty() { |
| 7723 | "system".to_string() |
| 7724 | } else { |
| 7725 | fields.join("+") |
| 7726 | } |
| 7727 | } |
| 7728 | |
| 7729 | impl NextTurnPromptContext { |
| 7730 | #[allow(clippy::too_many_arguments)] |
| 7731 | pub(crate) fn for_planned_turn( |
| 7732 | provider: ApiProvider, |
| 7733 | model: String, |
| 7734 | route_limits: Option<codewhale_config::route::RouteLimits>, |
| 7735 | mode: AppMode, |
| 7736 | goal_objective: Option<String>, |
| 7737 | goal_status: GoalStatus, |
| 7738 | goal_token_budget: Option<u32>, |
| 7739 | translation_enabled: bool, |
| 7740 | verbosity: Option<String>, |
| 7741 | ) -> Self { |
| 7742 | Self { |
| 7743 | provider, |
| 7744 | model, |
| 7745 | route_limits, |
| 7746 | mode, |
| 7747 | goal_objective: (goal_status == GoalStatus::Active) |
| 7748 | .then(|| normalized_goal_objective(goal_objective.as_deref())) |
| 7749 | .flatten(), |
| 7750 | goal_token_budget, |
| 7751 | translation_enabled, |
| 7752 | verbosity, |
| 7753 | } |
| 7754 | } |
| 7755 | } |
| 7756 | |
| 7757 | /// Grace period for cancelled turn-owned children to release their barrier |
| 7758 | /// registration before the terminal turn event is emitted anyway (#6184). |
| 7759 | /// Cooperative children settle in milliseconds; the bound exists so a child |
| 7760 | /// parked on an await that never observes its cancel token cannot withhold |
| 7761 | /// `TurnComplete` — a child still shutting down is strictly less harmful |
| 7762 | /// than a turn that silently never finishes. |
| 7763 | const FOREGROUND_CHILD_SETTLE_GRACE: Duration = Duration::from_secs(5); |
| 7764 | |
| 7765 | /// Turn-scoped mailbox handle plus the machinery needed to close it exactly |
| 7766 | /// once. Held by the engine (never by the child runtime) so the flush barrier |
| 7767 | /// is owned by the same code that emits the terminal turn event. |
| 7768 | pub(crate) struct TurnMailboxBarrier { |
| 7769 | pub(crate) mailbox: Mailbox, |
| 7770 | pub(crate) cancel_token: tokio_util::sync::CancellationToken, |
| 7771 | pub(crate) foreground_children: Arc<ForegroundChildRegistry>, |
| 7772 | pub(crate) flush_tx: tokio::sync::oneshot::Sender<()>, |
| 7773 | pub(crate) drain_handle: tokio::task::JoinHandle<()>, |
| 7774 | /// Bound on the cancelled-child join and on the mailbox-drainer flush |
| 7775 | /// inside the barrier (#6184). |
| 7776 | pub(crate) settle_grace: Duration, |
| 7777 | } |
| 7778 | |
| 7779 | fn terminal_turn_status_at_settlement( |
| 7780 | status: TurnOutcomeStatus, |
| 7781 | cancellation_requested: bool, |
| 7782 | ) -> TurnOutcomeStatus { |
| 7783 | if status == TurnOutcomeStatus::Completed && cancellation_requested { |
| 7784 | TurnOutcomeStatus::Interrupted |
| 7785 | } else { |
| 7786 | status |
| 7787 | } |
| 7788 | } |
| 7789 | |
| 7790 | impl TurnMailboxBarrier { |
| 7791 | /// Settle the foreground subtree before closing the turn's mailbox. The |
| 7792 | /// ordering is intentional: a terminal turn event must never be emitted |
| 7793 | /// while an owned child can still publish into this turn's shared state. |
| 7794 | /// |
| 7795 | /// The join is best-effort and bounded by `settle_grace` (#6184): a |
| 7796 | /// child parked on an await that never observes its cancel token must |
| 7797 | /// not withhold `TurnComplete`. Returns the labels of any children |
| 7798 | /// still registered when the join gave up — empty on a clean settle — |
| 7799 | /// so the caller can name them in the terminal turn event. |
| 7800 | pub(crate) async fn cancel_and_flush(self) -> Vec<String> { |
| 7801 | let unsettled = self.join_foreground_children().await; |
| 7802 | self.flush().await; |
| 7803 | unsettled |
| 7804 | } |
| 7805 | |
| 7806 | /// A normal answer closes this turn's UI mailbox without cancelling |
| 7807 | /// healthy children. Their manager registration, transcript, immutable |
| 7808 | /// usage owner and completion inbox survive this turn. Explicit stop, |
| 7809 | /// failed turns and budget stops still use `cancel_and_flush`. |
| 7810 | pub(crate) async fn continue_and_flush(self) { |
| 7811 | self.flush().await; |
| 7812 | } |
| 7813 | |
| 7814 | /// Wait for cancelled foreground children to release their |
| 7815 | /// registration, giving up at `settle_grace` or when a *new* |
| 7816 | /// cancellation lands mid-wait. The Esc path reaches this barrier with |
| 7817 | /// the turn token already cancelled, so the early-exit arm is only |
| 7818 | /// armed when it is not — otherwise every interrupted turn's receipt |
| 7819 | /// window would collapse to zero instead of merely being bounded. |
| 7820 | async fn join_foreground_children(&self) -> Vec<String> { |
| 7821 | let join = self.foreground_children.cancel_and_wait(); |
| 7822 | tokio::pin!(join); |
| 7823 | let fresh_cancel = !self.cancel_token.is_cancelled(); |
| 7824 | let gave_up = tokio::select! { |
| 7825 | biased; |
| 7826 | () = &mut join => false, |
| 7827 | () = self.cancel_token.cancelled(), if fresh_cancel => true, |
| 7828 | () = tokio::time::sleep(self.settle_grace) => true, |
| 7829 | }; |
| 7830 | if !gave_up { |
| 7831 | return Vec::new(); |
| 7832 | } |
| 7833 | let labels = self.foreground_children.unsettled_labels(); |
| 7834 | tracing::warn!( |
| 7835 | unsettled_children = ?labels, |
| 7836 | "foreground child join exceeded its bound; sealing the turn mailbox with children still registered" |
| 7837 | ); |
| 7838 | labels |
| 7839 | } |
| 7840 | |
| 7841 | /// Seal the turn mailbox and wait for the drainer *under a bound* (#6184). |
| 7842 | /// The drainer forwards into the event channel with an untimed send; a |
| 7843 | /// UI that has stopped draining parks it, and the flush signal cannot be |
| 7844 | /// observed from inside that send. Without the bound this await held the |
| 7845 | /// turn — and every later user message — for hours. |
| 7846 | async fn flush(self) { |
| 7847 | self.mailbox.seal(); |
| 7848 | let _ = self.flush_tx.send(()); |
| 7849 | let mut drain_handle = self.drain_handle; |
| 7850 | await_mailbox_drain_bounded(&mut drain_handle, self.settle_grace).await; |
| 7851 | } |
| 7852 | } |
| 7853 | |
| 7854 | /// Bound the mailbox drainer's exit (#6184). |
| 7855 | /// |
| 7856 | /// The drainer forwards envelopes into the 256-slot event channel with an |
| 7857 | /// untimed `send().await`; when the UI stops draining that channel the |
| 7858 | /// forward parks, and because `select!` stops observing its other branches |
| 7859 | /// once one is taken, a flush signal sent afterwards is never seen. Awaiting |
| 7860 | /// the drainer then holds the turn with no clock on it. On expiry the |
| 7861 | /// drainer is aborted: a UI that is not draining the event channel cannot |
| 7862 | /// receive these envelopes anyway, so turn liveness wins over best-effort |
| 7863 | /// delivery of the in-flight message — the same trade the bounded child |
| 7864 | /// join above makes for children. |
| 7865 | async fn await_mailbox_drain_bounded( |
| 7866 | drain_handle: &mut tokio::task::JoinHandle<()>, |
| 7867 | grace: Duration, |
| 7868 | ) { |
| 7869 | if tokio::time::timeout(grace, &mut *drain_handle) |
| 7870 | .await |
| 7871 | .is_err() |
| 7872 | { |
| 7873 | drain_handle.abort(); |
| 7874 | tracing::warn!( |
| 7875 | grace_ms = u64::try_from(grace.as_millis()).unwrap_or(u64::MAX), |
| 7876 | "subagent-mailbox drainer exceeded its bound with the event channel not draining; aborted so the turn can settle" |
| 7877 | ); |
| 7878 | } |
| 7879 | } |
| 7880 | |
| 7881 | /// Result of one turn tool-catalog build. |
| 7882 | struct TurnToolBuild { |
| 7883 | /// One authority for executable, searchable, and initially active tools. |
| 7884 | surface: ToolSurfacePolicy, |
| 7885 | /// Names of the MCP-contributed tools in this build. |
| 7886 | mcp_tool_names: Vec<String>, |
| 7887 | /// What is known about the MCP contribution to this catalog. |
| 7888 | mcp: McpToolState, |
| 7889 | /// Route model installed into the child runtime, when sub-agent tools were |
| 7890 | /// available. This is an internal receipt, not a manifest field. |
| 7891 | #[cfg_attr(not(test), expect(dead_code))] |
| 7892 | subagent_runtime_model: Option<String>, |
| 7893 | /// Turn-scoped sub-agent mailbox and its flush barrier, when sub-agent |
| 7894 | /// wiring was live. The engine must seal, flush, and await this before it |
| 7895 | /// emits `TurnComplete`. Detached children never reopen this ordering |
| 7896 | /// boundary; their owner-scoped usage lease is the separate durable path. |
| 7897 | mailbox: Option<TurnMailboxBarrier>, |
| 7898 | /// Tools this build loaded from the plugin surface rather than the built-in |
| 7899 | /// registry builder. Carried out so the read-only request projection can |
| 7900 | /// tell `plugin` provenance from `builtin` instead of collapsing both. |
| 7901 | plugin_tool_names: std::collections::HashSet<String>, |
| 7902 | } |
| 7903 | |
| 7904 | /// The route a tool catalog is being shaped for. |
| 7905 | /// |
| 7906 | /// A real turn installs its route before building the catalog, so this is |
| 7907 | /// simply the installed route. `/preview-request` has a *planned* route that |
| 7908 | /// is deliberately not installed, so it passes that one instead — otherwise |
| 7909 | /// an auto-routed preview would report the previous route's tool budget. |
| 7910 | #[derive(Clone)] |
| 7911 | pub(crate) struct TurnRouteContext { |
| 7912 | pub(crate) provider: ApiProvider, |
| 7913 | pub(crate) model: String, |
| 7914 | pub(crate) capabilities: codewhale_config::route::RouteCapabilities, |
| 7915 | pub(crate) limits: Option<codewhale_config::route::RouteLimits>, |
| 7916 | /// Client for this exact route. Tool contexts use it only for |
| 7917 | /// provider-native helper capabilities; previews pass their throw-away |
| 7918 | /// planned client instead of inheriting the installed session client. |
| 7919 | pub(crate) client: Option<CodewhaleClient>, |
| 7920 | /// Route-scoped runtime config, captured by the planner. A preview must |
| 7921 | /// never construct child agents from the previously installed config. |
| 7922 | pub(crate) api_config: Box<crate::config::Config>, |
| 7923 | pub(crate) locale_tag: String, |
| 7924 | pub(crate) role_models: HashMap<String, crate::config::SubagentModelOverride>, |
| 7925 | pub(crate) auto_model: bool, |
| 7926 | pub(crate) reasoning_effort: Option<String>, |
| 7927 | pub(crate) reasoning_effort_auto: bool, |
| 7928 | } |
| 7929 | |
| 7930 | impl TurnRouteContext { |
| 7931 | pub(crate) fn capability_profile(&self) -> crate::model_profile::CapabilityProfile { |
| 7932 | crate::model_profile::resolved_capability_profile_for_route( |
| 7933 | self.provider, |
| 7934 | &self.model, |
| 7935 | self.capabilities, |
| 7936 | self.limits.unwrap_or_default(), |
| 7937 | ) |
| 7938 | } |
| 7939 | } |
| 7940 | |
| 7941 | /// Whether a tool-catalog build may start or connect MCP servers. |
| 7942 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 7943 | pub(crate) enum McpAccess { |
| 7944 | /// A real turn: create the pool if needed and connect every enabled |
| 7945 | /// server, exactly as before. |
| 7946 | Connect, |
| 7947 | /// An inspection: use only what is already connected, and report the |
| 7948 | /// tool surface as unavailable when that is not the whole picture. |
| 7949 | PassiveSnapshot, |
| 7950 | } |
| 7951 | |
| 7952 | impl McpAccess { |
| 7953 | fn may_connect(self) -> bool { |
| 7954 | matches!(self, Self::Connect) |
| 7955 | } |
| 7956 | } |
| 7957 | |
| 7958 | /// The MCP contribution to one tool-catalog build. |
| 7959 | #[derive(Debug, Clone)] |
| 7960 | pub(crate) enum McpToolState { |
| 7961 | /// MCP is off for this session; a turn would send no MCP tools. |
| 7962 | Disabled, |
| 7963 | /// The exact MCP tool set the next request would carry. |
| 7964 | Live { |
| 7965 | tools: Vec<Tool>, |
| 7966 | server_count: usize, |
| 7967 | }, |
| 7968 | /// The exact set is not knowable without connecting, which an inspection |
| 7969 | /// must not do. |
| 7970 | Unavailable { reason: McpUnavailable }, |
| 7971 | } |
| 7972 | |
| 7973 | impl McpToolState { |
| 7974 | pub(crate) fn tools(&self) -> &[Tool] { |
| 7975 | match self { |
| 7976 | Self::Live { tools, .. } => tools, |
| 7977 | Self::Disabled | Self::Unavailable { .. } => &[], |
| 7978 | } |
| 7979 | } |
| 7980 | |
| 7981 | /// Connected server count, or `None` when the state is unavailable. |
| 7982 | pub(crate) fn server_count(&self) -> Option<usize> { |
| 7983 | match self { |
| 7984 | Self::Disabled => Some(0), |
| 7985 | Self::Live { server_count, .. } => Some(*server_count), |
| 7986 | Self::Unavailable { .. } => None, |
| 7987 | } |
| 7988 | } |
| 7989 | } |
| 7990 | |
| 7991 | /// Why a passive MCP snapshot could not describe the next turn exactly. |
| 7992 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 7993 | pub(crate) enum McpUnavailable { |
| 7994 | /// No pool exists yet: the first turn of the session would create and |
| 7995 | /// connect one. |
| 7996 | PoolNotStarted, |
| 7997 | /// An MCP config source changed since the pool last read it, so the next |
| 7998 | /// turn would reload before connecting. |
| 7999 | ConfigChangedSinceConnect, |
| 8000 | /// Some enabled servers are configured but not connected. |
| 8001 | ServersNotConnected { pending: usize }, |
| 8002 | } |
| 8003 | |
| 8004 | impl McpUnavailable { |
| 8005 | /// Short, path-free explanation for the manifest. |
| 8006 | pub(crate) fn label(self) -> String { |
| 8007 | match self { |
| 8008 | Self::PoolNotStarted => { |
| 8009 | "MCP is enabled but no server has been connected in this session yet".to_string() |
| 8010 | } |
| 8011 | Self::ConfigChangedSinceConnect => { |
| 8012 | "an MCP configuration source changed since the last connect".to_string() |
| 8013 | } |
| 8014 | Self::ServersNotConnected { pending } => { |
| 8015 | format!("{pending} enabled MCP server(s) are not connected yet") |
| 8016 | } |
| 8017 | } |
| 8018 | } |
| 8019 | } |
| 8020 | |
| 8021 | /// Whether a tool-catalog build may establish sub-agent runtime side effects. |
| 8022 | /// |
| 8023 | /// Both variants register exactly the same tools; only the runtime plumbing |
| 8024 | /// differs (the structured fork snapshot and the spawned mailbox drainer), |
| 8025 | /// which is what makes an offline inspection safe to run at any time. |
| 8026 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 8027 | pub(crate) enum SubAgentWiring { |
| 8028 | /// A real turn: wire the fork snapshot and the mailbox drainer. |
| 8029 | Live, |
| 8030 | /// An inspection: build the catalog, spawn nothing. |
| 8031 | Inert, |
| 8032 | } |
| 8033 | |
| 8034 | impl SubAgentWiring { |
| 8035 | fn is_live(self) -> bool { |
| 8036 | matches!(self, Self::Live) |
| 8037 | } |
| 8038 | } |
| 8039 | |
| 8040 | mod approval; |
| 8041 | mod compaction; |
| 8042 | mod context; |
| 8043 | pub(crate) mod handle; |
| 8044 | pub mod preview; |
| 8045 | use crate::compaction::estimate_input_tokens_conservative; |
| 8046 | #[cfg(test)] |
| 8047 | pub(crate) use context::compact_tool_result_for_context; |
| 8048 | pub(crate) use context::compact_tool_result_for_route; |
| 8049 | /// Public so external hosts/wrappers can reuse the engine's input-budget math |
| 8050 | /// (see `context_input_budget_for_route`'s doc) instead of re-deriving it. |
| 8051 | pub use context::context_input_budget_for_route; |
| 8052 | #[cfg(test)] |
| 8053 | use context::route_context_budget_for_provider; |
| 8054 | use context::{ |
| 8055 | MAX_CONTEXT_RECOVERY_ATTEMPTS, effective_max_output_tokens_for_route, |
| 8056 | extract_compaction_summary_prompt, is_context_length_error_message, |
| 8057 | is_image_input_rejection_message, route_context_budget_for_route, summarize_text, |
| 8058 | }; |
| 8059 | #[cfg(test)] |
| 8060 | use context::{context_input_budget_for_provider, effective_max_output_tokens}; |
| 8061 | mod dispatch; |
| 8062 | mod lsp_hooks; |
| 8063 | pub(crate) mod reviewer; |
| 8064 | mod streaming; |
| 8065 | mod token_estimate_cache; |
| 8066 | pub(crate) mod tool_catalog; |
| 8067 | mod tool_execution; |
| 8068 | mod tool_media; |
| 8069 | mod tool_preparation; |
| 8070 | mod tool_setup; |
| 8071 | pub(crate) mod turn_budget; |
| 8072 | pub(crate) mod turn_loop; |
| 8073 | pub(crate) use dispatch::{ |
| 8074 | FLEET_FINAL_REPORT_NOTICE, FLEET_NO_PROGRESS_STOP, FLEET_STRATEGY_SWITCH_NOTICE, |
| 8075 | FleetDenialAction, FleetDenialBatch, FleetDenialGuard, |
| 8076 | }; |
| 8077 | pub(crate) use token_estimate_cache::TokenEstimateCache; |
| 8078 | |
| 8079 | pub(super) const MAX_PARALLEL_SHELL_EXEC: usize = 4; |
| 8080 | |
| 8081 | #[cfg(test)] |
| 8082 | pub(crate) fn default_active_native_tool_names() -> &'static [&'static str] { |
| 8083 | tool_catalog::DEFAULT_ACTIVE_NATIVE_TOOLS |
| 8084 | } |
| 8085 | |
| 8086 | use self::approval::{ApprovalDecision, ApprovalResult, UserInputDecision}; |
| 8087 | use self::dispatch::{ |
| 8088 | ParallelToolResult, ParallelToolResultEntry, ToolApprovalStamp, ToolExecGuard, ToolExecOutcome, |
| 8089 | ToolExecutionBatch, ToolExecutionPlan, caller_allowed_for_tool, caller_type_for_tool_use, |
| 8090 | final_tool_input, format_tool_error_with_schema, malformed_tool_arguments_error, |
| 8091 | malformed_tool_arguments_input, mcp_tool_is_parallel_safe, parse_parallel_tool_calls, |
| 8092 | parse_tool_input, plan_tool_execution_batches, stamp_tool_result_approval, |
| 8093 | }; |
| 8094 | #[cfg(test)] |
| 8095 | use self::dispatch::{format_tool_error, should_parallelize_tool_batch}; |
| 8096 | #[cfg(test)] |
| 8097 | use self::lsp_hooks::edited_paths_for_tool; |
| 8098 | #[cfg(test)] |
| 8099 | use self::streaming::TOOL_CALL_START_MARKERS; |
| 8100 | #[cfg(test)] |
| 8101 | use self::streaming::filter_tool_call_delta; |
| 8102 | use self::streaming::{ |
| 8103 | ContentBlockKind, FAKE_WRAPPER_NOTICE, MAX_STREAM_ERRORS_BEFORE_FAIL, MAX_STREAM_RETRIES, |
| 8104 | MAX_TRANSPARENT_STREAM_RETRIES, StreamResume, StreamRetryBudget, ToolCallDeltaFilterState, |
| 8105 | ToolUseState, contains_fake_tool_wrapper, filter_tool_call_delta_with_state, |
| 8106 | flush_tool_call_delta_state, should_resume_after_network_drop, should_resume_after_sleep, |
| 8107 | should_resume_interactive_after_network_drop, should_transparently_retry_stream, |
| 8108 | sleep_gap_detected, stream_read_error_user_message, |
| 8109 | }; |
| 8110 | use self::tool_catalog::{ |
| 8111 | CODE_EXECUTION_TOOL_NAME, EXECUTE_TOOLS_TOOL_NAME, JS_EXECUTION_TOOL_NAME, |
| 8112 | MULTI_TOOL_PARALLEL_NAME, REQUEST_USER_INPUT_NAME, ToolSurfacePolicy, active_tools_for_request, |
| 8113 | build_model_tool_catalog_with_surface, default_synthetic_catalog_tool_names, |
| 8114 | execute_code_execution_tool, is_tool_search_tool, maybe_hydrate_requested_deferred_tool, |
| 8115 | missing_tool_error_message, |
| 8116 | }; |
| 8117 | #[cfg(test)] |
| 8118 | use self::tool_catalog::{ |
| 8119 | TOOL_SEARCH_NAME, active_tools_for_step, build_model_tool_catalog, ensure_advanced_tooling, |
| 8120 | execute_tool_search, initial_active_tools, preflight_requested_deferred_tool, |
| 8121 | should_default_defer_tool, tool_allowed, tool_catalog_consistency_issues, tool_denied, |
| 8122 | }; |
| 8123 | pub(crate) use self::tool_execution::emit_tool_audit; |
| 8124 | use self::tool_preparation::{prepare_tool_call, reprepare_tool_call_after_hook}; |
| 8125 | use crate::tools::js_execution::execute_js_execution_tool; |
| 8126 | |
| 8127 | #[cfg(test)] |
| 8128 | mod tests; |
| 8129 |