use super::*;
use crate::client::PreparedOutboundRequest;
use crate::compaction::should_compact;
use crate::request_manifest::{
Availability, BasePromptProvenance, BillingFacts, ManifestDraft, PreparedBodyInputs,
PromptProvenance, ReasoningResolution, RequestManifest, RouteFacts, SessionFacts,
SystemPromptAssembly, ToolSurfaceFacts, UnavailableReason,
};
use crate::route_runtime::ResolvedRuntimeRoute;
use crate::safe_label::SafeLabel;
use codewhale_core::request::{PrimaryTurnRequest, prepare_primary_turn_request};
#[derive(Debug)]
pub struct PreviewRequestInputs {
pub mode: AppMode,
pub allow_shell: bool,
pub trust_mode: bool,
pub auto_approve: bool,
pub approval_mode: crate::tui::approval::ApprovalMode,
pub allowed_tools: Option<Vec<String>>,
pub dynamic_tools: Vec<DynamicToolSpec>,
pub provenance: UserInputProvenance,
pub requested_model: String,
pub requested_reasoning: String,
pub auto_model: bool,
pub hypothetical_prompt_supplied: bool,
pub next_turn: Option<Box<PreviewNextTurn>>,
pub unresolved: PreviewUnresolved,
}
#[derive(Debug)]
pub struct PreviewNextTurn {
pub content: String,
pub route: Box<ResolvedRuntimeRoute>,
pub prompt_context: NextTurnPromptContext,
pub reasoning_effort: Option<String>,
pub reasoning_effort_auto: bool,
pub auto_route_source: Option<String>,
pub routing_source: crate::turn_route_plan::TurnRoutingSource,
pub compaction: crate::compaction::CompactionConfig,
}
#[derive(Debug, Clone)]
pub enum PreviewUnresolved {
AutoRouteNeedsPrompt,
AutoRouteClassificationNotExecuted,
NoPrompt,
PlanFailed(String),
MessageSubmitHooksConfigured,
PromptResolutionFailed(String),
}
impl PreviewUnresolved {
fn as_availability<T>(&self) -> Availability<T> {
match self {
Self::AutoRouteNeedsPrompt => {
Availability::unavailable(UnavailableReason::AutoRouteUnresolvedUntilNextPrompt)
}
Self::AutoRouteClassificationNotExecuted => {
Availability::unavailable(UnavailableReason::AutoRouteClassificationNotExecuted)
}
Self::NoPrompt => {
Availability::unavailable(UnavailableReason::NoHypotheticalPromptSupplied)
}
Self::PlanFailed(error) => {
Availability::unavailable_with(UnavailableReason::RoutePlanFailed, error.clone())
}
Self::MessageSubmitHooksConfigured => {
Availability::unavailable(UnavailableReason::MessageSubmitHooksNotExecuted)
}
Self::PromptResolutionFailed(error) => Availability::unavailable_with(
UnavailableReason::PromptResolutionFailed,
error.clone(),
),
}
}
}
impl Engine {
pub(super) async fn build_request_manifest(
&mut self,
inputs: PreviewRequestInputs,
) -> RequestManifest {
let session = self.preview_session_facts(&inputs);
let goal_budget_exhausted = match self.config.goal_state.lock() {
Ok(state) => {
let snapshot = state.snapshot();
Ok(snapshot.is_active()
&& crate::goal_loop::token_budget_exhausted(
crate::goal_loop::GoalProgress {
tokens_used: snapshot.tokens_used,
time_used_seconds: snapshot.time_used_seconds,
continuations: snapshot.continuation_count,
},
crate::goal_loop::GoalBudget {
token_budget: snapshot.token_budget.map(u64::from),
time_budget_seconds: None,
max_continuations: self.config.goal_max_continuations,
},
))
}
Err(err) => {
tracing::warn!("goal state lock poisoned while previewing request: {err}");
Err(())
}
};
let unavailable_reason = match goal_budget_exhausted {
Ok(true) => Some(UnavailableReason::GoalTokenBudgetExhausted),
Ok(false) => None,
Err(()) => Some(UnavailableReason::GoalStateNotSnapshottable),
};
if let Some(reason) = unavailable_reason {
return RequestManifest::build(ManifestDraft {
session,
route: Availability::unavailable(reason),
tools: Availability::unavailable(reason),
body: Availability::unavailable(reason),
});
}
let Some(next_turn) = inputs.next_turn else {
let unresolved = inputs.unresolved;
return RequestManifest::build(ManifestDraft {
session,
route: unresolved.as_availability(),
tools: unresolved.as_availability(),
body: unresolved.as_availability(),
});
};
let PreviewNextTurn {
content: hypothetical_content,
route: planned_route,
prompt_context: planned_prompt_context,
reasoning_effort,
reasoning_effort_auto,
auto_route_source,
routing_source,
compaction: planned_compaction,
} = *next_turn;
let route = match (*planned_route).validate() {
Ok(route) => route,
Err(error) => {
let unavailable = PreviewUnresolved::PlanFailed(error);
return RequestManifest::build(ManifestDraft {
session,
route: unavailable.as_availability(),
tools: unavailable.as_availability(),
body: unavailable.as_availability(),
});
}
};
let provider = route.identity.provider;
let model = route.model.clone();
let limits = crate::route_budget::known_route_limits(route.candidate.limits());
let base_url = route.candidate.endpoint().base_url.clone();
let route_context = TurnRouteContext {
provider,
model: model.clone(),
capabilities: route.candidate.capabilities(),
limits,
client: Some(route.client.clone()),
api_config: route.config.clone(),
locale_tag: self.config.locale_tag.clone(),
role_models: self.subagent_role_models(),
fleet_roster: self.config.fleet_roster.clone(),
auto_model: inputs.auto_model,
reasoning_effort: reasoning_effort.clone(),
reasoning_effort_auto,
};
let input_policy = effective_input_policy(
inputs.provenance,
inputs.mode,
&hypothetical_content,
inputs.allow_shell,
inputs.trust_mode,
inputs.mode == AppMode::Yolo || inputs.auto_approve,
inputs.approval_mode,
);
let prompt_context = NextTurnPromptContext {
mode: input_policy.mode,
..planned_prompt_context
};
let build = self
.build_turn_tool_registry_and_catalog(
&input_policy,
&inputs.dynamic_tools,
inputs.allowed_tools.clone(),
SubAgentWiring::Inert,
McpAccess::PassiveSnapshot,
route_context.clone(),
"",
)
.await;
let surface = &build.surface;
let active_tools = surface.active.clone().unwrap_or_default();
let active_catalog_sha256 = active_tool_catalog_sha256(&active_tools);
let tool_choice = surface.active.as_ref().map(|_| {
if surface.strict_tool_mode {
json!("required")
} else {
json!({ "type": "auto" })
}
});
let tools = match build.mcp.server_count() {
Some(mcp_server_count) => Availability::Exact(ToolSurfaceFacts {
catalog_tool_count: surface.catalog.len(),
deferred_tool_count: surface
.catalog
.iter()
.filter(|tool| tool.defer_loading.unwrap_or(false))
.count(),
active_tool_count: active_tools.len(),
active_tool_catalog_sha256: active_catalog_sha256,
tool_surface_budget: format!(
"{:?}",
route_context.capability_profile().tool_surface_budget
),
standard_and_full_surfaces_collapsed: standard_and_full_collapse(
&surface.catalog,
&self.config.tools_always_load,
),
mcp_server_count,
mcp_tool_count: active_tools
.iter()
.filter(|tool| build.mcp_tool_names.contains(&tool.name))
.count(),
}),
None => match &build.mcp {
McpToolState::Unavailable { reason } => Availability::unavailable_with(
UnavailableReason::McpStateNotSnapshottable,
reason.label(),
),
McpToolState::Disabled | McpToolState::Live { .. } => {
Availability::unavailable(UnavailableReason::McpStateNotSnapshottable)
}
},
};
let system_prompt = if self.session.system_prompt_override
|| self.session.pinned_prompt_context.as_ref() == Some(&prompt_context)
{
self.session.system_prompt.clone()
} else {
self.compose_stable_system_prompt(&prompt_context)
};
let mut previewed_working_set = self.session.working_set.clone();
previewed_working_set.observe_user_message(&hypothetical_content, &self.session.workspace);
let previewed_git_snapshot = self
.last_turn_meta_git_snapshot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
let hypothetical_user_message = self.user_text_message_from_snapshot(
hypothetical_content.clone(),
&model,
inputs.auto_model,
reasoning_effort.as_deref(),
reasoning_effort_auto,
inputs.provenance,
TurnMetadataSnapshot {
prompt_context: &prompt_context,
system_prompt: system_prompt.as_ref(),
approval_mode: input_policy.approval_mode_for_session(),
working_set: &previewed_working_set,
policy_narrowing: input_policy.narrowing.as_ref(),
},
);
*self
.last_turn_meta_git_snapshot
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = previewed_git_snapshot;
let system_prompt_text = crate::prefix_cache::system_prompt_text(system_prompt.as_ref());
let mut messages = self.messages_with_turn_metadata();
messages.push(hypothetical_user_message);
let mut runtime_transforms = self
.preview_runtime_transforms(&messages, system_prompt.as_ref(), &planned_compaction)
.await;
let effective_reasoning_effort = super::turn_loop::resolve_auto_effort(
reasoning_effort.as_deref(),
&messages,
provider,
&base_url,
&model,
);
let outbound_messages = messages.clone();
let production_input_estimate_tokens =
crate::compaction::estimate_input_tokens_conservative(
&messages,
system_prompt.as_ref(),
);
let request = prepare_primary_turn_request(PrimaryTurnRequest {
model: model.clone(),
messages: outbound_messages,
max_tokens: effective_max_output_tokens_for_route(provider, &model, limits),
system: system_prompt,
tools: surface.active.clone(),
tool_choice: tool_choice.clone(),
reasoning_effort: effective_reasoning_effort,
});
let prepared = match route.client.prepare_outbound_request(request, true) {
Ok(prepared) => prepared.with_route_id(route.identity.exact_id.clone()),
Err(error) => {
let detail = super::turn_loop::preview_request_error_user_message(
&self.config.locale_tag,
&error,
);
return RequestManifest::build(ManifestDraft {
session,
route: Availability::unavailable_with(
UnavailableReason::RequestPreparationFailed,
detail.clone(),
),
tools,
body: Availability::unavailable_with(
UnavailableReason::RequestPreparationFailed,
detail,
),
});
}
};
let reasoning_resolution = if !prepared.reasoning.controls_reasoning() {
ReasoningResolution::NotApplicable
} else if reasoning_effort_auto {
ReasoningResolution::ResolvedFromHypotheticalPrompt
} else if prepared.reasoning.requested_effort.is_none() {
ReasoningResolution::RouteDefault
} else {
ReasoningResolution::Explicit
};
let input_budget_ceiling_tokens =
context_input_budget_for_route(provider, &model, limits, 0);
if crate::request_manifest::production_input_budget_exceeded(
input_budget_ceiling_tokens,
production_input_estimate_tokens,
) {
runtime_transforms
.push("context-overflow recovery would trim or compact the conversation");
}
let route_facts = RouteFacts {
provider_id: SafeLabel::identifier(&prepared.endpoint.provider_id),
provider_display: SafeLabel::phrase(&prepared.endpoint.provider_display),
route_id: prepared
.endpoint
.route_id
.as_deref()
.map(SafeLabel::identifier),
dialect: prepared.dialect.as_str().to_string(),
route_shape: prepared.endpoint.shape.as_str().to_string(),
endpoint_host_class: prepared.safe_endpoint_host_class(),
endpoint_fingerprint: prepared.endpoint_fingerprint(),
wire_model: SafeLabel::catalog_model(&prepared.wire_model),
caller_entrypoint: prepared.entrypoint.as_str().to_string(),
body_stream_field: prepared.wire_stream_field(),
context_limit_tokens: route.context_window.tokens,
context_limit_source: route.context_window.source,
route_input_limit_tokens: limits.and_then(|limits| limits.input_tokens),
route_output_limit_tokens: limits.and_then(|limits| limits.output_tokens),
billing: preview_billing_facts(&route.config, provider, &base_url),
routing_source: routing_source.label().to_string(),
auto_route_source: auto_route_source.as_deref().map(SafeLabel::phrase),
};
let prompt = self.preview_prompt_provenance(&prepared, system_prompt_text.as_str(), &model);
let body = if let Some(inherited) = tools.propagate() {
inherited
} else if runtime_transforms.is_empty() {
Availability::Exact(PreparedBodyInputs {
prepared: &prepared,
reasoning_resolution,
prompt,
input_budget_ceiling_tokens,
production_input_estimate_tokens,
tool_surface_is_exact: true,
})
} else {
Availability::unavailable_with(
UnavailableReason::RuntimeTransformsBeforeSend,
runtime_transforms.join("; "),
)
};
RequestManifest::build(ManifestDraft {
session,
route: Availability::Exact(route_facts),
tools,
body,
})
}
async fn preview_runtime_transforms(
&self,
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
compaction: &crate::compaction::CompactionConfig,
) -> Vec<&'static str> {
let mut reasons = Vec::new();
if !self.pending_lsp_blocks.is_empty() {
reasons.push("pending LSP diagnostics would be injected as a synthetic message");
}
let shell_completion_may_be_injected = self
.shell_manager
.lock()
.map_or(true, |manager| manager.may_have_undelivered_completion());
if shell_completion_may_be_injected {
reasons.push("a background shell completion may be injected before the request");
}
let queued_completions = !self.rx_subagent_completion.is_empty() || {
let manager = self.subagent_manager.read().await;
manager.may_transform_next_parent_request(&self.delivered_subagent_completion_ids)
};
if queued_completions {
reasons.push("a running or undelivered sub-agent completion may be injected");
}
if crate::compaction::compaction_pressure_reached(messages, system_prompt, compaction) {
let prepared = self.prepare_compaction_envelope(compaction.clone());
if should_compact(messages, system_prompt, &prepared) {
reasons.push("auto-compaction would rewrite the conversation first");
}
}
reasons
}
fn preview_session_facts(&self, inputs: &PreviewRequestInputs) -> SessionFacts {
let base = crate::prompts::effective_base_prompt_text();
let input_policy = effective_input_policy(
inputs.provenance,
inputs.mode,
"",
inputs.allow_shell,
inputs.trust_mode,
inputs.mode == AppMode::Yolo || inputs.auto_approve,
inputs.approval_mode,
);
SessionFacts {
agent_role: "primary".to_string(),
lane_kind: "interactive-primary".to_string(),
fleet_assignment: "not-applicable-primary-agent".to_string(),
requested_model: SafeLabel::catalog_model(&inputs.requested_model),
auto_model_routing: inputs.auto_model,
requested_reasoning: SafeLabel::identifier(&inputs.requested_reasoning),
hypothetical_prompt_supplied: inputs.hypothetical_prompt_supplied,
mode: input_policy.mode.label().to_string(),
approval_mode: format!("{:?}", input_policy.approval_mode_for_session()),
allowed_tool_gate_count: inputs.allowed_tools.as_ref().map(Vec::len),
disallowed_tool_gate_count: self.config.disallowed_tools.as_ref().map(Vec::len),
base_prompt: BasePromptProvenance {
origin: crate::prompts::base_prompt_origin().label().to_string(),
bytes: base.len(),
sha256: crate::hashing::sha256_hex(base.as_bytes()),
},
}
}
fn preview_prompt_provenance(
&self,
prepared: &PreparedOutboundRequest,
effective: &str,
model: &str,
) -> PromptProvenance {
let base = crate::prompts::effective_base_prompt_text();
let configured =
crate::prompts::compose_default_static_layers(crate::prompts::Personality::Calm, model);
let assembly = if effective.trim().is_empty() {
SystemPromptAssembly::None
} else if effective.trim() == base.trim() {
SystemPromptAssembly::BaseOnly
} else if effective.trim() == configured.trim() {
SystemPromptAssembly::BaseWithConfiguredLayers
} else {
SystemPromptAssembly::BaseWithRuntimeAdditions
};
let view = prepared.wire_view();
PromptProvenance {
assembly,
effective_system_canonical_json_bytes: view.system_bytes,
effective_system_sha256: view.system_sha256.clone(),
}
}
}
fn preview_billing_facts(
config: &crate::config::Config,
provider: crate::config::ApiProvider,
base_url: &str,
) -> BillingFacts {
if let Some(surface) = crate::pricing::billing_surface_for_route(provider, Some(base_url)) {
return BillingFacts::Surface { surface };
}
match crate::route_billing::for_route(config, provider) {
crate::route_billing::BillingPresentation::Metered => BillingFacts::Metered,
crate::route_billing::BillingPresentation::Subscription(plan) => {
BillingFacts::Subscription { plan }
}
crate::route_billing::BillingPresentation::Local => BillingFacts::Local,
crate::route_billing::BillingPresentation::Unknown => BillingFacts::Unknown,
}
}
pub(crate) fn active_tool_catalog_sha256(tools: &[Tool]) -> String {
let mut canonical = String::new();
for tool in tools {
canonical.push_str(&tool.name);
canonical.push('\u{1}');
canonical.push_str(&tool.description);
canonical.push('\u{1}');
canonical.push_str(&crate::client::canonical_json(&tool.input_schema));
canonical.push('\n');
}
crate::hashing::sha256_hex(canonical.as_bytes())
}
fn standard_and_full_collapse(
catalog: &[Tool],
always_load: &std::collections::HashSet<String>,
) -> bool {
super::tool_catalog::surface_budgets_produce_same_catalog(
catalog,
always_load,
crate::model_profile::ToolSurfaceBudget::Standard,
crate::model_profile::ToolSurfaceBudget::Full,
)
}
#[cfg(test)]
#[path = "preview/tests.rs"]
mod tests;