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