返回 CodeWhale
engine.rs
根目录 / crates / tui / src / core / engine.rs
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(&registry),
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, &current_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
8129 lines RUST