| 1 | //! Engine-side authority for `/preview-request` (#1004, #3928). |
| 2 | //! |
| 3 | //! The preview lives here — not in the command layer — because only the |
| 4 | //! engine can rebuild the *exact* next-turn state: the tool catalog under the |
| 5 | //! live mode, gates, permission posture and connected MCP tools; the system |
| 6 | //! prompt for the route the next turn would use; the hypothetical next user |
| 7 | //! message in its production form; and the request the turn loop would hand |
| 8 | //! to `create_message_stream`. |
| 9 | //! |
| 10 | //! Four rules this module exists to enforce: |
| 11 | //! |
| 12 | //! - **Never `session.last_tool_catalog`.** That value is one turn stale and |
| 13 | //! stores the pre-activation catalog, so it cannot describe what the *next* |
| 14 | //! request would send. The catalog is rebuilt through |
| 15 | //! [`Engine::build_turn_tool_registry_and_catalog`], which returns the same |
| 16 | //! typed policy a real turn consumes. |
| 17 | //! - **Never invent a route.** For fixed routes, the host resolves the next |
| 18 | //! turn through the same shared planner production dispatch uses. Auto would |
| 19 | //! require a model-classifier call, so the human preview stops before the |
| 20 | //! planner and emits a typed unavailable state. No route, endpoint, wire |
| 21 | //! model, billing, tool budget, or body hash is recycled from the installed |
| 22 | //! route. |
| 23 | //! - **Never resolve by side effect.** The catalog build runs with |
| 24 | //! [`SubAgentWiring::Inert`] and [`McpAccess::PassiveSnapshot`]: no fork |
| 25 | //! snapshot, no spawned drainer, no MCP pool creation, no `connect_all`, no |
| 26 | //! status events. When the connected MCP state is not already exactly what |
| 27 | //! a turn would use, the tool section is reported unavailable rather than |
| 28 | //! made exact by connecting — **and so is the body**, because a body built |
| 29 | //! from a tool surface missing its MCP contribution is a body no turn would |
| 30 | //! send. |
| 31 | //! - **Never install anything, not even briefly.** The planned route is |
| 32 | //! projected into a throw-away client; `self.api_provider`, |
| 33 | //! `self.session.model`, `self.session.system_prompt`, and the MCP pool are |
| 34 | //! all left untouched. Everything a turn would *install before* building its |
| 35 | //! request — the command-scoped tool gate, the effective mode and approval |
| 36 | //! posture, the policy-narrowing event, the observed working set — is passed |
| 37 | //! as a value or snapshotted onto a clone. There is no write-then-restore |
| 38 | //! anywhere in this module: a restore is not atomic across an `.await`, and |
| 39 | //! it does not survive a cancellation or a panic. |
| 40 | //! - **Never claim exactness the runtime would break.** Mutable |
| 41 | //! `message_submit` hooks, background-shell completions, running or |
| 42 | //! terminal-undelivered sub-agent completions, |
| 43 | //! pending LSP diagnostics, auto-compaction, and context-overflow recovery |
| 44 | //! all rewrite the request between submit and the wire. An inspection may |
| 45 | //! neither run them nor consume them, so when any of them apply the affected |
| 46 | //! sections are typed unavailable instead of published. |
| 47 | //! |
| 48 | //! Scope: this describes the primary agent turn (`create_message_stream`). |
| 49 | //! Auxiliary provider calls are out of scope; see `docs/PREVIEW_REQUEST.md`. |
| 50 | //! |
| 51 | //! The `dryrun` concept — preview the next request from the real |
| 52 | //! request-building seam rather than a hand-rolled summary — is harvested |
| 53 | //! from PR #1099 by TaoMu (GTC2080). |
| 54 | |
| 55 | use super::*; |
| 56 | |
| 57 | use crate::client::PreparedOutboundRequest; |
| 58 | use crate::compaction::should_compact; |
| 59 | use crate::request_manifest::{ |
| 60 | Availability, BasePromptProvenance, BillingFacts, ManifestDraft, PreparedBodyInputs, |
| 61 | PromptProvenance, ReasoningResolution, RequestManifest, RouteFacts, SessionFacts, |
| 62 | SystemPromptAssembly, ToolSurfaceFacts, UnavailableReason, |
| 63 | }; |
| 64 | use crate::route_runtime::ResolvedRuntimeRoute; |
| 65 | use crate::safe_label::SafeLabel; |
| 66 | use codewhale_core::request::{PrimaryTurnRequest, prepare_primary_turn_request}; |
| 67 | |
| 68 | /// Everything the host must supply for the engine to describe the next |
| 69 | /// request. These are the same posture fields a `SendMessage` would carry, so |
| 70 | /// the preview describes the turn the user is actually about to run. |
| 71 | #[derive(Debug)] |
| 72 | pub struct PreviewRequestInputs { |
| 73 | pub mode: AppMode, |
| 74 | pub allow_shell: bool, |
| 75 | pub trust_mode: bool, |
| 76 | pub auto_approve: bool, |
| 77 | pub approval_mode: ApprovalMode, |
| 78 | pub allowed_tools: Option<Vec<String>>, |
| 79 | pub dynamic_tools: Vec<DynamicToolSpec>, |
| 80 | pub provenance: UserInputProvenance, |
| 81 | /// The model selector the user chose: `auto` when auto model routing is |
| 82 | /// on. Never the concrete model an unresolved auto route might pick. |
| 83 | pub requested_model: String, |
| 84 | /// Reasoning tier the user has selected (`auto`, `high`, `off`, …). |
| 85 | pub requested_reasoning: String, |
| 86 | pub auto_model: bool, |
| 87 | /// Whether the *caller* supplied a hypothetical next prompt. |
| 88 | /// |
| 89 | /// Deliberately independent of [`Self::next_turn`]: when planning that |
| 90 | /// prompt fails, the manifest must still say a prompt was supplied. |
| 91 | /// Deriving the flag from `next_turn.is_some()` told the user to "pass |
| 92 | /// `--prompt`" when they just had. |
| 93 | pub hypothetical_prompt_supplied: bool, |
| 94 | /// The exact next turn, resolved by the host's shared route planner. |
| 95 | /// `None` means no exact next turn exists to describe. |
| 96 | pub next_turn: Option<Box<PreviewNextTurn>>, |
| 97 | /// Why `next_turn` is absent. Ignored when `next_turn` is present. |
| 98 | pub unresolved: PreviewUnresolved, |
| 99 | } |
| 100 | |
| 101 | /// One hypothetical next turn, planned by the production route planner. |
| 102 | #[derive(Debug)] |
| 103 | pub struct PreviewNextTurn { |
| 104 | /// The model-facing text of the hypothetical user message, already |
| 105 | /// through the host's file-mention/skill resolution — the same string a |
| 106 | /// real `SendMessage` would carry. Never stored in the session and never |
| 107 | /// sent to a provider. |
| 108 | pub content: String, |
| 109 | /// The route the planner resolved for this turn. |
| 110 | pub route: Box<ResolvedRuntimeRoute>, |
| 111 | /// Immutable prompt facts captured from the same host state as the |
| 112 | /// matching production submit. |
| 113 | pub prompt_context: NextTurnPromptContext, |
| 114 | /// Normalized reasoning-effort api value from the planner, exactly as it |
| 115 | /// would be sent. |
| 116 | pub reasoning_effort: Option<String>, |
| 117 | /// True when the user selected auto reasoning and the planner picked that |
| 118 | /// tier. |
| 119 | pub reasoning_effort_auto: bool, |
| 120 | /// How the auto router chose this route, when auto routing ran. |
| 121 | pub auto_route_source: Option<String>, |
| 122 | /// Typed selection provenance captured by the shared production planner. |
| 123 | pub routing_source: crate::turn_route_plan::TurnRoutingSource, |
| 124 | /// The compaction policy the planner resolved for this route. A real turn |
| 125 | /// installs it before the turn loop decides whether to auto-compact, so |
| 126 | /// the preview evaluates that decision against the same policy. |
| 127 | pub compaction: crate::compaction::CompactionConfig, |
| 128 | } |
| 129 | |
| 130 | /// Why no exact next turn was planned. |
| 131 | #[derive(Debug, Clone)] |
| 132 | pub enum PreviewUnresolved { |
| 133 | /// Auto model routing is on and no hypothetical prompt was supplied. |
| 134 | AutoRouteNeedsPrompt, |
| 135 | /// Auto model routing needs a classifier provider call. A preview is |
| 136 | /// strictly offline, so it stops before invoking the shared route planner. |
| 137 | AutoRouteClassificationNotExecuted, |
| 138 | /// No hypothetical prompt was supplied, so there is no next-turn body. |
| 139 | NoPrompt, |
| 140 | /// The shared planner ran and failed. Carries raw host text; it crosses |
| 141 | /// the safe-label boundary before it reaches any surface. |
| 142 | PlanFailed(String), |
| 143 | /// Mutable `message_submit` hooks are configured. A real submit runs them |
| 144 | /// before file mentions, skill wrapping, route planning, and the tool |
| 145 | /// policy see the text, and they may replace or block it outright. An |
| 146 | /// inspection must not execute a hook, so nothing downstream of the text |
| 147 | /// — route, tools, or body — can be claimed exact. |
| 148 | MessageSubmitHooksConfigured, |
| 149 | /// Resolving the prompt into model-facing content failed exactly as a real |
| 150 | /// submit would have failed. Carries raw host text. |
| 151 | PromptResolutionFailed(String), |
| 152 | } |
| 153 | |
| 154 | impl PreviewUnresolved { |
| 155 | fn as_availability<T>(&self) -> Availability<T> { |
| 156 | match self { |
| 157 | Self::AutoRouteNeedsPrompt => { |
| 158 | Availability::unavailable(UnavailableReason::AutoRouteUnresolvedUntilNextPrompt) |
| 159 | } |
| 160 | Self::AutoRouteClassificationNotExecuted => { |
| 161 | Availability::unavailable(UnavailableReason::AutoRouteClassificationNotExecuted) |
| 162 | } |
| 163 | Self::NoPrompt => { |
| 164 | Availability::unavailable(UnavailableReason::NoHypotheticalPromptSupplied) |
| 165 | } |
| 166 | Self::PlanFailed(error) => { |
| 167 | Availability::unavailable_with(UnavailableReason::RoutePlanFailed, error.clone()) |
| 168 | } |
| 169 | Self::MessageSubmitHooksConfigured => { |
| 170 | Availability::unavailable(UnavailableReason::MessageSubmitHooksNotExecuted) |
| 171 | } |
| 172 | Self::PromptResolutionFailed(error) => Availability::unavailable_with( |
| 173 | UnavailableReason::PromptResolutionFailed, |
| 174 | error.clone(), |
| 175 | ), |
| 176 | } |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | impl Engine { |
| 181 | /// Describe the request the next turn would send, without sending it. |
| 182 | pub(super) async fn build_request_manifest( |
| 183 | &mut self, |
| 184 | inputs: PreviewRequestInputs, |
| 185 | ) -> RequestManifest { |
| 186 | let session = self.preview_session_facts(&inputs); |
| 187 | |
| 188 | // Mirror terminal continuation gates before request construction. |
| 189 | // Token budgets are telemetry-only in unbounded goal mode, so an |
| 190 | // active goal remains previewable after crossing or lowering a budget. |
| 191 | let goal_budget_exhausted = match self.config.goal_state.lock() { |
| 192 | Ok(state) => { |
| 193 | let snapshot = state.snapshot(); |
| 194 | Ok(snapshot.is_active() |
| 195 | && crate::goal_loop::token_budget_exhausted( |
| 196 | crate::goal_loop::GoalProgress { |
| 197 | tokens_used: snapshot.tokens_used, |
| 198 | time_used_seconds: snapshot.time_used_seconds, |
| 199 | continuations: snapshot.continuation_count, |
| 200 | }, |
| 201 | crate::goal_loop::GoalBudget { |
| 202 | token_budget: snapshot.token_budget.map(u64::from), |
| 203 | time_budget_seconds: None, |
| 204 | enforce_token_budget: self.config.goal_enforce_token_budget, |
| 205 | max_continuations: self.config.goal_max_continuations, |
| 206 | }, |
| 207 | )) |
| 208 | } |
| 209 | Err(err) => { |
| 210 | tracing::warn!("goal state lock poisoned while previewing request: {err}"); |
| 211 | Err(()) |
| 212 | } |
| 213 | }; |
| 214 | let unavailable_reason = match goal_budget_exhausted { |
| 215 | Ok(true) => Some(UnavailableReason::GoalTokenBudgetExhausted), |
| 216 | Ok(false) => None, |
| 217 | Err(()) => Some(UnavailableReason::GoalStateNotSnapshottable), |
| 218 | }; |
| 219 | if let Some(reason) = unavailable_reason { |
| 220 | return RequestManifest::build(ManifestDraft { |
| 221 | session, |
| 222 | route: Availability::unavailable(reason), |
| 223 | tools: Availability::unavailable(reason), |
| 224 | body: Availability::unavailable(reason), |
| 225 | }); |
| 226 | } |
| 227 | |
| 228 | let Some(next_turn) = inputs.next_turn else { |
| 229 | let unresolved = inputs.unresolved; |
| 230 | return RequestManifest::build(ManifestDraft { |
| 231 | session, |
| 232 | route: unresolved.as_availability(), |
| 233 | tools: unresolved.as_availability(), |
| 234 | body: unresolved.as_availability(), |
| 235 | }); |
| 236 | }; |
| 237 | |
| 238 | let PreviewNextTurn { |
| 239 | content: hypothetical_content, |
| 240 | route: planned_route, |
| 241 | prompt_context: planned_prompt_context, |
| 242 | reasoning_effort, |
| 243 | reasoning_effort_auto, |
| 244 | auto_route_source, |
| 245 | routing_source, |
| 246 | compaction: planned_compaction, |
| 247 | } = *next_turn; |
| 248 | |
| 249 | // Project the planned route into a throw-away client. `validate` |
| 250 | // reuses the host's preflighted client when there is one and never |
| 251 | // touches engine state — unlike `install_resolved_runtime_route`, |
| 252 | // which is what a real turn calls. |
| 253 | let route = match (*planned_route).validate() { |
| 254 | Ok(route) => route, |
| 255 | Err(error) => { |
| 256 | let unavailable = PreviewUnresolved::PlanFailed(error); |
| 257 | return RequestManifest::build(ManifestDraft { |
| 258 | session, |
| 259 | route: unavailable.as_availability(), |
| 260 | tools: unavailable.as_availability(), |
| 261 | body: unavailable.as_availability(), |
| 262 | }); |
| 263 | } |
| 264 | }; |
| 265 | |
| 266 | let provider = route.identity.provider; |
| 267 | let model = route.model.clone(); |
| 268 | let limits = crate::route_budget::known_route_limits(route.candidate.limits()); |
| 269 | let base_url = route.candidate.endpoint().base_url.clone(); |
| 270 | let route_context = TurnRouteContext { |
| 271 | provider, |
| 272 | model: model.clone(), |
| 273 | capabilities: route.candidate.capabilities(), |
| 274 | limits, |
| 275 | client: Some(route.client.clone()), |
| 276 | api_config: route.config.clone(), |
| 277 | locale_tag: self.config.locale_tag.clone(), |
| 278 | role_models: self.subagent_role_models(), |
| 279 | auto_model: inputs.auto_model, |
| 280 | reasoning_effort: reasoning_effort.clone(), |
| 281 | reasoning_effort_auto, |
| 282 | }; |
| 283 | |
| 284 | // Same policy derivation as `handle_send_message`, so the catalog is |
| 285 | // filtered under the posture the next turn would actually use. |
| 286 | let input_policy = effective_input_policy( |
| 287 | inputs.provenance, |
| 288 | inputs.mode, |
| 289 | &hypothetical_content, |
| 290 | inputs.allow_shell, |
| 291 | inputs.trust_mode, |
| 292 | inputs.auto_approve, |
| 293 | inputs.approval_mode, |
| 294 | ); |
| 295 | let prompt_context = NextTurnPromptContext { |
| 296 | mode: input_policy.mode, |
| 297 | ..planned_prompt_context |
| 298 | }; |
| 299 | |
| 300 | // The command-scoped allow gate is *passed*, never installed. The |
| 301 | // earlier shape wrote `self.config.allowed_tools`, awaited the whole |
| 302 | // catalog build, and wrote it back: for the duration of that await the |
| 303 | // engine carried a gate belonging to a turn that was never going to |
| 304 | // run, and a cancellation or panic in between would have left it |
| 305 | // installed for good. |
| 306 | let build = self |
| 307 | .build_turn_tool_registry_and_catalog( |
| 308 | &input_policy, |
| 309 | &inputs.dynamic_tools, |
| 310 | inputs.allowed_tools.clone(), |
| 311 | SubAgentWiring::Inert, |
| 312 | McpAccess::PassiveSnapshot, |
| 313 | route_context.clone(), |
| 314 | "", |
| 315 | ) |
| 316 | .await; |
| 317 | |
| 318 | // The build owns the exact same initial subset dispatch consumes. |
| 319 | let surface = &build.surface; |
| 320 | let active_tools = surface.active.clone().unwrap_or_default(); |
| 321 | let active_catalog_sha256 = active_tool_catalog_sha256(&active_tools); |
| 322 | |
| 323 | let tool_choice = surface.active.as_ref().map(|_| { |
| 324 | if surface.strict_tool_mode { |
| 325 | json!("required") |
| 326 | } else { |
| 327 | json!({ "type": "auto" }) |
| 328 | } |
| 329 | }); |
| 330 | |
| 331 | // The tool surface is only publishable when the MCP contribution is |
| 332 | // exactly known. Anything else would be "the tools of some other |
| 333 | // turn", which is the failure mode this command exists to avoid. |
| 334 | let tools = match build.mcp.server_count() { |
| 335 | Some(mcp_server_count) => Availability::Exact(ToolSurfaceFacts { |
| 336 | catalog_tool_count: surface.catalog.len(), |
| 337 | deferred_tool_count: surface |
| 338 | .catalog |
| 339 | .iter() |
| 340 | .filter(|tool| tool.defer_loading.unwrap_or(false)) |
| 341 | .count(), |
| 342 | active_tool_count: active_tools.len(), |
| 343 | active_tool_catalog_sha256: active_catalog_sha256, |
| 344 | tool_surface_budget: format!( |
| 345 | "{:?}", |
| 346 | route_context.capability_profile().tool_surface_budget |
| 347 | ), |
| 348 | standard_and_full_surfaces_collapsed: standard_and_full_collapse( |
| 349 | &surface.catalog, |
| 350 | &self.config.tools_always_load, |
| 351 | ), |
| 352 | mcp_server_count, |
| 353 | mcp_tool_count: active_tools |
| 354 | .iter() |
| 355 | .filter(|tool| build.mcp_tool_names.contains(&tool.name)) |
| 356 | .count(), |
| 357 | }), |
| 358 | None => match &build.mcp { |
| 359 | McpToolState::Unavailable { reason } => Availability::unavailable_with( |
| 360 | UnavailableReason::McpStateNotSnapshottable, |
| 361 | reason.label(), |
| 362 | ), |
| 363 | McpToolState::Disabled | McpToolState::Live { .. } => { |
| 364 | Availability::unavailable(UnavailableReason::McpStateNotSnapshottable) |
| 365 | } |
| 366 | }, |
| 367 | }; |
| 368 | |
| 369 | // The system prompt a turn would send is composed for *its* route, so |
| 370 | // an auto-routed preview must not reuse the installed model's prompt. |
| 371 | // A session-level override wins here exactly as it does in |
| 372 | // `refresh_system_prompt`. |
| 373 | // The header is pinned for the session: with unchanged explicit |
| 374 | // inputs a real turn reuses the pinned bytes (workspace drift arrives |
| 375 | // as a `<context_update>` message instead), so preview mirrors that. |
| 376 | let system_prompt = if self.session.system_prompt_override |
| 377 | || self.session.pinned_prompt_context.as_ref() == Some(&prompt_context) |
| 378 | { |
| 379 | self.session.system_prompt.clone() |
| 380 | } else { |
| 381 | self.compose_stable_system_prompt(&prompt_context) |
| 382 | }; |
| 383 | |
| 384 | // The hypothetical user message goes through the same constructor |
| 385 | // production uses — turn metadata, route stamp, and provenance — so |
| 386 | // the body being hashed is the body a real turn would build. It is |
| 387 | // appended to a *clone* of the history and discarded: the session |
| 388 | // never sees it. |
| 389 | // |
| 390 | // A real submit calls `working_set.observe_user_message` before it |
| 391 | // writes `<turn_meta>`, so the block reflects files the new message |
| 392 | // mentions. The preview observes the message on a **clone** of the |
| 393 | // working set and builds the block from that snapshot: same bytes, no |
| 394 | // session write. Nothing here restores state, because nothing here |
| 395 | // changes any. |
| 396 | let mut previewed_working_set = self.session.working_set.clone(); |
| 397 | previewed_working_set.observe_user_message(&hypothetical_content, &self.session.workspace); |
| 398 | // #5187: the git-snapshot line is emitted on change only, tracked in a |
| 399 | // session cache. Previewing a turn must not advance that cache — the |
| 400 | // model never saw the previewed block — so the cache is saved and |
| 401 | // restored around the hypothetical build, same as the working set. |
| 402 | let previewed_git_snapshot = self |
| 403 | .last_turn_meta_git_snapshot |
| 404 | .lock() |
| 405 | .unwrap_or_else(std::sync::PoisonError::into_inner) |
| 406 | .clone(); |
| 407 | let hypothetical_user_message = self.user_text_message_from_snapshot( |
| 408 | hypothetical_content.clone(), |
| 409 | &model, |
| 410 | inputs.auto_model, |
| 411 | reasoning_effort.as_deref(), |
| 412 | reasoning_effort_auto, |
| 413 | inputs.provenance, |
| 414 | TurnMetadataSnapshot { |
| 415 | prompt_context: &prompt_context, |
| 416 | system_prompt: system_prompt.as_ref(), |
| 417 | approval_mode: input_policy.approval_mode_for_session(), |
| 418 | working_set: &previewed_working_set, |
| 419 | policy_narrowing: input_policy.narrowing.as_ref(), |
| 420 | }, |
| 421 | ); |
| 422 | *self |
| 423 | .last_turn_meta_git_snapshot |
| 424 | .lock() |
| 425 | .unwrap_or_else(std::sync::PoisonError::into_inner) = previewed_git_snapshot; |
| 426 | // Classification input for the provenance section: the prompt this |
| 427 | // request actually carries, not the session's current one. |
| 428 | let system_prompt_text = |
| 429 | codewhale_core::prefix_cache::system_prompt_text(system_prompt.as_ref()); |
| 430 | |
| 431 | let mut messages = self.messages_with_turn_metadata(); |
| 432 | messages.push(hypothetical_user_message); |
| 433 | |
| 434 | // Transforms the turn loop would apply to this conversation between |
| 435 | // dispatch and the wire. Detected read-only; nothing pending is |
| 436 | // consumed, drained, or flushed by looking. |
| 437 | let mut runtime_transforms = self |
| 438 | .preview_runtime_transforms(&messages, system_prompt.as_ref(), &planned_compaction) |
| 439 | .await; |
| 440 | |
| 441 | // The turn loop resolves an `auto` sentinel tier to its declared |
| 442 | // policy value, *after* the planner normalized it. Skipping that step |
| 443 | // described a request carrying a literal `auto`, which no route |
| 444 | // receives. |
| 445 | let effective_reasoning_effort = super::turn_loop::resolve_auto_effort( |
| 446 | reasoning_effort.as_deref(), |
| 447 | provider, |
| 448 | &base_url, |
| 449 | &model, |
| 450 | ); |
| 451 | |
| 452 | // Production sends stored history and nothing else — no synthetic |
| 453 | // To-do block, on any step — so the previewed outbound message list is |
| 454 | // exactly the message list. |
| 455 | let outbound_messages = messages.clone(); |
| 456 | |
| 457 | // The production overflow gate estimates the logical messages and |
| 458 | // system prompt, not serialized provider-body bytes. Use that same |
| 459 | // contract here; the manifest keeps its wire estimate separately as |
| 460 | // an observability metric. |
| 461 | let production_input_estimate_tokens = |
| 462 | crate::compaction::estimate_input_tokens_conservative( |
| 463 | &messages, |
| 464 | system_prompt.as_ref(), |
| 465 | ); |
| 466 | |
| 467 | let request = prepare_primary_turn_request(PrimaryTurnRequest { |
| 468 | model: model.clone(), |
| 469 | messages: outbound_messages, |
| 470 | max_tokens: effective_max_output_tokens_for_route(provider, &model, limits), |
| 471 | system: system_prompt, |
| 472 | tools: surface.active.clone(), |
| 473 | tool_choice: tool_choice.clone(), |
| 474 | reasoning_effort: effective_reasoning_effort, |
| 475 | }); |
| 476 | |
| 477 | let prepared = match route.client.prepare_outbound_request(request, true) { |
| 478 | Ok(prepared) => prepared.with_route_id(route.identity.exact_id.clone()), |
| 479 | Err(error) => { |
| 480 | let detail = super::turn_loop::preview_request_error_user_message( |
| 481 | &self.config.locale_tag, |
| 482 | &error, |
| 483 | ); |
| 484 | // Route identity is read *off the prepared request*, so a |
| 485 | // preparation failure leaves the endpoint, wire model, and |
| 486 | // dialect unknown too. The tool surface survives: it was built |
| 487 | // before the body and does not depend on it. |
| 488 | return RequestManifest::build(ManifestDraft { |
| 489 | session, |
| 490 | route: Availability::unavailable_with( |
| 491 | UnavailableReason::RequestPreparationFailed, |
| 492 | detail.clone(), |
| 493 | ), |
| 494 | tools, |
| 495 | body: Availability::unavailable_with( |
| 496 | UnavailableReason::RequestPreparationFailed, |
| 497 | detail, |
| 498 | ), |
| 499 | }); |
| 500 | } |
| 501 | }; |
| 502 | |
| 503 | // `include` on a Responses body discloses reasoning output; it does not |
| 504 | // ask the route to think. Treating any control key as a reasoning |
| 505 | // request made every Codex turn read as an explicit user selection. |
| 506 | let reasoning_resolution = if !prepared.reasoning.controls_reasoning() { |
| 507 | ReasoningResolution::NotApplicable |
| 508 | } else if reasoning_effort_auto { |
| 509 | ReasoningResolution::ResolvedFromHypotheticalPrompt |
| 510 | } else if prepared.reasoning.requested_effort.is_none() { |
| 511 | ReasoningResolution::RouteDefault |
| 512 | } else { |
| 513 | ReasoningResolution::Explicit |
| 514 | }; |
| 515 | |
| 516 | // Headroom and overflow both follow production's message/system |
| 517 | // estimator. When an earlier runtime transform cannot be observed |
| 518 | // without mutation, `runtime_transforms` makes this body unavailable |
| 519 | // rather than publishing a guess. |
| 520 | let input_budget_ceiling_tokens = |
| 521 | context_input_budget_for_route(provider, &model, limits, 0); |
| 522 | if crate::request_manifest::production_input_budget_exceeded( |
| 523 | input_budget_ceiling_tokens, |
| 524 | production_input_estimate_tokens, |
| 525 | ) { |
| 526 | runtime_transforms |
| 527 | .push("context-overflow recovery would trim or compact the conversation"); |
| 528 | } |
| 529 | |
| 530 | let route_facts = RouteFacts { |
| 531 | provider_id: SafeLabel::identifier(&prepared.endpoint.provider_id), |
| 532 | provider_display: SafeLabel::phrase(&prepared.endpoint.provider_display), |
| 533 | route_id: prepared |
| 534 | .endpoint |
| 535 | .route_id |
| 536 | .as_deref() |
| 537 | .map(SafeLabel::identifier), |
| 538 | dialect: prepared.dialect.as_str().to_string(), |
| 539 | route_shape: prepared.endpoint.shape.as_str().to_string(), |
| 540 | endpoint_host_class: prepared.safe_endpoint_host_class(), |
| 541 | endpoint_fingerprint: prepared.endpoint_fingerprint(), |
| 542 | wire_model: SafeLabel::catalog_model(&prepared.wire_model), |
| 543 | caller_entrypoint: prepared.entrypoint.as_str().to_string(), |
| 544 | body_stream_field: prepared.wire_stream_field(), |
| 545 | context_limit_tokens: route.context_window.tokens, |
| 546 | context_limit_source: route.context_window.source, |
| 547 | route_input_limit_tokens: limits.and_then(|limits| limits.input_tokens), |
| 548 | route_output_limit_tokens: limits.and_then(|limits| limits.output_tokens), |
| 549 | billing: preview_billing_facts(&route.config, provider, &base_url), |
| 550 | routing_source: routing_source.label().to_string(), |
| 551 | auto_route_source: auto_route_source.as_deref().map(SafeLabel::phrase), |
| 552 | }; |
| 553 | |
| 554 | let prompt = self.preview_prompt_provenance(&prepared, system_prompt_text.as_str(), &model); |
| 555 | |
| 556 | // The body is a *dependent* fact. A tool surface whose MCP |
| 557 | // contribution is unknown does not yield "the same body with no MCP |
| 558 | // tools" — a real turn would connect and may send a different tool |
| 559 | // list, a different tool region, and therefore a different body, |
| 560 | // local component fingerprint, and hash. Publishing an exact body there was the reviewed |
| 561 | // defect: it fabricated an empty MCP contribution and hashed it. |
| 562 | // Likewise, a request the turn loop would rewrite before sending is |
| 563 | // not the request that would be sent. |
| 564 | let body = if let Some(inherited) = tools.propagate() { |
| 565 | inherited |
| 566 | } else if runtime_transforms.is_empty() { |
| 567 | Availability::Exact(PreparedBodyInputs { |
| 568 | prepared: &prepared, |
| 569 | reasoning_resolution, |
| 570 | prompt, |
| 571 | input_budget_ceiling_tokens, |
| 572 | production_input_estimate_tokens, |
| 573 | tool_surface_is_exact: true, |
| 574 | }) |
| 575 | } else { |
| 576 | Availability::unavailable_with( |
| 577 | UnavailableReason::RuntimeTransformsBeforeSend, |
| 578 | runtime_transforms.join("; "), |
| 579 | ) |
| 580 | }; |
| 581 | |
| 582 | RequestManifest::build(ManifestDraft { |
| 583 | session, |
| 584 | route: Availability::Exact(route_facts), |
| 585 | tools, |
| 586 | body, |
| 587 | }) |
| 588 | } |
| 589 | |
| 590 | /// Transforms the turn loop would apply to this conversation between |
| 591 | /// dispatch and the first provider request. |
| 592 | /// |
| 593 | /// Every check is **read-only**. Nothing here drains the steer channel, |
| 594 | /// receives a queued sub-agent completion, flushes an LSP block, or runs |
| 595 | /// compaction: an inspection that consumed pending state would change the |
| 596 | /// very turn it claims to describe. Where a queue can only be *counted* |
| 597 | /// rather than inspected, counting is what happens. |
| 598 | /// |
| 599 | /// Returned strings are compile-time constants. They are joined into a |
| 600 | /// typed unavailable detail, which still crosses the safe-label boundary. |
| 601 | async fn preview_runtime_transforms( |
| 602 | &self, |
| 603 | messages: &[Message], |
| 604 | system_prompt: Option<&SystemPrompt>, |
| 605 | compaction: &crate::compaction::CompactionConfig, |
| 606 | ) -> Vec<&'static str> { |
| 607 | let mut reasons = Vec::new(); |
| 608 | |
| 609 | if !self.pending_lsp_blocks.is_empty() { |
| 610 | reasons.push("pending LSP diagnostics would be injected as a synthetic message"); |
| 611 | } |
| 612 | |
| 613 | let shell_completion_may_be_injected = self.shell_manager.lock().map_or(true, |manager| { |
| 614 | manager.may_have_undelivered_completion_for_session(&self.session.id) |
| 615 | }); |
| 616 | if shell_completion_may_be_injected { |
| 617 | reasons.push("a background shell completion may be injected before the request"); |
| 618 | } |
| 619 | |
| 620 | let queued_completions = !self.rx_subagent_completion.is_empty() || { |
| 621 | let manager = self.subagent_manager.read().await; |
| 622 | manager.may_transform_next_parent_request_for_session( |
| 623 | &self.session.id, |
| 624 | &self.delivered_subagent_completion_ids, |
| 625 | ) |
| 626 | }; |
| 627 | if queued_completions { |
| 628 | reasons.push("a running or undelivered sub-agent completion may be injected"); |
| 629 | } |
| 630 | |
| 631 | if crate::compaction::compaction_pressure_reached(messages, system_prompt, compaction) { |
| 632 | let prepared = self.prepare_compaction_envelope(compaction.clone()); |
| 633 | if should_compact(messages, system_prompt, &prepared) { |
| 634 | reasons.push("auto-compaction would rewrite the conversation first"); |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | reasons |
| 639 | } |
| 640 | |
| 641 | /// Posture that depends on neither the route nor the next message. |
| 642 | fn preview_session_facts(&self, inputs: &PreviewRequestInputs) -> SessionFacts { |
| 643 | let base = crate::prompts::effective_base_prompt_text(); |
| 644 | let input_policy = effective_input_policy( |
| 645 | inputs.provenance, |
| 646 | inputs.mode, |
| 647 | "", |
| 648 | inputs.allow_shell, |
| 649 | inputs.trust_mode, |
| 650 | inputs.auto_approve, |
| 651 | inputs.approval_mode, |
| 652 | ); |
| 653 | SessionFacts { |
| 654 | agent_role: "primary".to_string(), |
| 655 | lane_kind: "interactive-primary".to_string(), |
| 656 | fleet_assignment: "not-applicable-primary-agent".to_string(), |
| 657 | requested_model: SafeLabel::catalog_model(&inputs.requested_model), |
| 658 | auto_model_routing: inputs.auto_model, |
| 659 | requested_reasoning: SafeLabel::identifier(&inputs.requested_reasoning), |
| 660 | // What the caller supplied, not what planning managed to do with |
| 661 | // it: a plan failure must not read as "you forgot `--prompt`". |
| 662 | hypothetical_prompt_supplied: inputs.hypothetical_prompt_supplied, |
| 663 | mode: input_policy.mode.label().to_string(), |
| 664 | approval_mode: format!("{:?}", input_policy.approval_mode_for_session()), |
| 665 | allowed_tool_gate_count: inputs.allowed_tools.as_ref().map(Vec::len), |
| 666 | disallowed_tool_gate_count: self.config.disallowed_tools.as_ref().map(Vec::len), |
| 667 | base_prompt: BasePromptProvenance { |
| 668 | origin: crate::prompts::base_prompt_origin().label().to_string(), |
| 669 | bytes: base.len(), |
| 670 | sha256: crate::hashing::sha256_hex(base.as_bytes()), |
| 671 | }, |
| 672 | } |
| 673 | } |
| 674 | |
| 675 | /// System-prompt provenance, as labels and hashes only. |
| 676 | /// |
| 677 | /// `effective` is the prompt of the request being described, so an |
| 678 | /// auto-routed preview classifies the prompt it would actually send |
| 679 | /// rather than the session's currently installed one. |
| 680 | fn preview_prompt_provenance( |
| 681 | &self, |
| 682 | prepared: &PreparedOutboundRequest, |
| 683 | effective: &str, |
| 684 | model: &str, |
| 685 | ) -> PromptProvenance { |
| 686 | let base = crate::prompts::effective_base_prompt_text(); |
| 687 | let configured = |
| 688 | crate::prompts::compose_default_static_layers(crate::prompts::Personality::Calm, model); |
| 689 | |
| 690 | let assembly = if effective.trim().is_empty() { |
| 691 | SystemPromptAssembly::None |
| 692 | } else if effective.trim() == base.trim() { |
| 693 | SystemPromptAssembly::BaseOnly |
| 694 | } else if effective.trim() == configured.trim() { |
| 695 | SystemPromptAssembly::BaseWithConfiguredLayers |
| 696 | } else { |
| 697 | SystemPromptAssembly::BaseWithRuntimeAdditions |
| 698 | }; |
| 699 | |
| 700 | let view = prepared.wire_view(); |
| 701 | PromptProvenance { |
| 702 | assembly, |
| 703 | // The hash of the prompt the *prepared request* carries, in its |
| 704 | // final wire form — not of an independently recomposed string. |
| 705 | effective_system_canonical_json_bytes: view.system_bytes, |
| 706 | effective_system_sha256: view.system_sha256.clone(), |
| 707 | } |
| 708 | } |
| 709 | } |
| 710 | |
| 711 | /// Typed billing facts for the planned route, from the same helper the footer |
| 712 | /// and sidebar read. Every label is a compile-time constant. |
| 713 | fn preview_billing_facts( |
| 714 | config: &crate::config::Config, |
| 715 | provider: crate::config::ApiProvider, |
| 716 | base_url: &str, |
| 717 | ) -> BillingFacts { |
| 718 | if let Some(surface) = crate::pricing::billing_surface_for_route(provider, Some(base_url)) { |
| 719 | return BillingFacts::Surface { surface }; |
| 720 | } |
| 721 | match crate::route_billing::for_route(config, provider) { |
| 722 | crate::route_billing::BillingPresentation::Metered => BillingFacts::Metered, |
| 723 | crate::route_billing::BillingPresentation::Subscription(plan) => { |
| 724 | BillingFacts::Subscription { plan } |
| 725 | } |
| 726 | crate::route_billing::BillingPresentation::Local => BillingFacts::Local, |
| 727 | crate::route_billing::BillingPresentation::Unknown => BillingFacts::Unknown, |
| 728 | } |
| 729 | } |
| 730 | |
| 731 | /// Stable hash over the exact active tool catalog: name, description, and |
| 732 | /// schema, in catalog order. Changes when a tool is added, removed, |
| 733 | /// reordered, or has its schema transformed. |
| 734 | /// |
| 735 | /// This is the *single* definition of the active-tool-catalog digest. The |
| 736 | /// request manifest fills `ToolSurfaceFacts::active_tool_catalog_sha256` from |
| 737 | /// it, and `crate::tool_inspection` reports the same value for the same |
| 738 | /// prepared request. Neither surface keeps a digest of its own, so a human |
| 739 | /// reading `/tools` and a human reading `/request` are looking at the same |
| 740 | /// accounting object rather than two hashes that can silently diverge. |
| 741 | pub(crate) fn active_tool_catalog_sha256(tools: &[Tool]) -> String { |
| 742 | let mut canonical = String::new(); |
| 743 | for tool in tools { |
| 744 | canonical.push_str(&tool.name); |
| 745 | canonical.push('\u{1}'); |
| 746 | canonical.push_str(&tool.description); |
| 747 | canonical.push('\u{1}'); |
| 748 | canonical.push_str(&crate::client::canonical_json(&tool.input_schema)); |
| 749 | canonical.push('\n'); |
| 750 | } |
| 751 | crate::hashing::sha256_hex(canonical.as_bytes()) |
| 752 | } |
| 753 | |
| 754 | /// Whether the Standard and Full tool surfaces currently produce the same |
| 755 | /// catalog. |
| 756 | /// |
| 757 | /// Derived, not asserted: the surface shaper is run over this exact catalog |
| 758 | /// under both budgets and the results compared. If Standard and Full ever |
| 759 | /// genuinely diverge, this reports `false` without anyone editing copy. |
| 760 | fn standard_and_full_collapse( |
| 761 | catalog: &[Tool], |
| 762 | always_load: &std::collections::HashSet<String>, |
| 763 | ) -> bool { |
| 764 | super::tool_catalog::surface_budgets_produce_same_catalog( |
| 765 | catalog, |
| 766 | always_load, |
| 767 | crate::model_profile::ToolSurfaceBudget::Standard, |
| 768 | crate::model_profile::ToolSurfaceBudget::Full, |
| 769 | ) |
| 770 | } |
| 771 | |
| 772 | #[cfg(test)] |
| 773 | #[path = "preview/tests.rs"] |
| 774 | mod tests; |
| 775 |