Skip to main content

vtcode_core/core/agent/runner/
execute.rs

1use super::AgentRunner;
2use super::continuation::{CompletionAssessment, VerificationResult};
3use super::escalation::{EscalationDecision, EscalationGate};
4use super::execute_helpers::{
5    discard_refused_assistant_message, emit_blocked_handoff_events, prepare_responses_request_messages,
6    record_terminal_turn_event, stop_reason_from_finish_reason, summarize_verification_output,
7};
8use super::helpers::detect_textual_exec_tool_call;
9use super::orchestration::EvaluatorGateOutcome;
10use super::prompt_alignment;
11use crate::config::constants::tools;
12use crate::config::models::{ModelId, Provider as ModelProvider};
13use crate::config::tool_loop_limit_reached;
14use crate::config::types::{ReasoningEffortLevel, SystemPromptMode, VerbosityLevel};
15use crate::config::{build_session_affinity_prompt_cache_key, session_affinity_key_enabled};
16use crate::core::agent::blocked_handoff::{BlockedHandoffResume, write_blocked_handoff_with_resume};
17use crate::core::agent::completion::{check_completion_candidate, check_for_response_loop};
18use crate::core::agent::events::ExecEventRecorder;
19use crate::core::agent::harness_artifacts::existing_harness_artifact_paths;
20use crate::core::agent::harness_kernel::{
21    HarnessRequestPlanInput, SessionToolCatalogSnapshot, build_harness_request_plan,
22};
23use crate::core::agent::hash_utils::stable_system_prefix_hash;
24use crate::core::agent::refusal;
25use crate::core::agent::runtime::{AgentRuntime, RuntimeControl};
26use crate::core::agent::session::AgentSessionState;
27use crate::core::agent::task::{ContextItem, Task, TaskOutcome, TaskResults};
28use crate::exec::events::HarnessEventKind;
29use crate::llm::provider::{Message, ToolCall, ToolChoice, ToolDefinition, supports_responses_chaining};
30use crate::llm::providers::gemini::wire::Part;
31use crate::prompts::{
32    PromptContext, RuntimePromptContract, append_runtime_mode_sections, append_runtime_tool_prompt_sections_for_model,
33    upsert_harness_limits_section,
34};
35use crate::utils::colors::style;
36use anyhow::{Context, Result};
37use serde_json::json;
38use std::sync::Arc;
39use tracing::{debug, warn};
40
41pub(super) struct RuntimePromptBundle {
42    prompt_policy_hash: u64,
43    pub(super) request_envelope: crate::core::agent::request_envelope::SessionRequestEnvelope,
44    tool_snapshot: SessionToolCatalogSnapshot,
45    /// Estimated token overhead of `request_tools`, computed once per
46    /// snapshot (see [`crate::llm::usage_cost::estimate_tool_definition_tokens`]).
47    tool_def_tokens: u64,
48    /// Token-budget report for the composed system instruction sections
49    /// (measured before the runtime-mode/tool-guideline/harness-limits
50    /// sections appended in `build_runtime_prompt_bundle`, which are runtime
51    /// scaffolding rather than trimmable prompt layers). Drives the
52    /// preflight token check and the over-budget user warning.
53    pub(super) system_prompt_report: crate::prompts::system::SystemPromptReport,
54}
55
56impl RuntimePromptBundle {
57    /// Provider-visible prefix unchanged across a catalog version bump.
58    ///
59    /// Compares only wire-visible identity (stable prompt digest, tool bytes,
60    /// active set, planning flags). Version/epoch and token estimates are
61    /// bookkeeping and intentionally excluded so no-op MCP refreshes preserve
62    /// the frozen envelope and keep the provider prefix cache hot.
63    fn has_same_provider_visible_prefix(&self, other: &Self) -> bool {
64        self.prompt_policy_hash == other.prompt_policy_hash
65            && self.tool_snapshot.tool_catalog_hash == other.tool_snapshot.tool_catalog_hash
66            && self.tool_snapshot.planning_active == other.tool_snapshot.planning_active
67            && self.tool_snapshot.request_user_input_enabled == other.tool_snapshot.request_user_input_enabled
68            && self.tool_snapshot.active_tool_names == other.tool_snapshot.active_tool_names
69            && self.request_envelope.instruction_digest() == other.request_envelope.instruction_digest()
70            && self.request_envelope.catalog_hash() == other.request_envelope.catalog_hash()
71    }
72}
73
74/// Outcome of [`AgentRunner::resolve_completion_assessment`].
75///
76/// Collapses the duplicated `CompletionAssessment` handling (pre- and
77/// post-verification) into a single signal the turn loop reacts to.
78enum AssessmentResolution {
79    /// Assessment resolved to acceptance or skip — the turn loop should break.
80    Break,
81    /// Assessment requested more work — the turn loop should force a continuation.
82    ForceContinue,
83    /// Assessment is `Verify`; the caller runs verification and re-dispatches the
84    /// `after_verification` result through the same helper.
85    VerifyNotHandled,
86}
87
88/// An aborted headless parent cannot leave scheduler-owned commands running.
89struct MatrixExecutionCleanup(Option<Arc<crate::subagents::SubagentController>>);
90impl Drop for MatrixExecutionCleanup {
91    fn drop(&mut self) {
92        if let Some(controller) = self.0.take().filter(|controller| controller.matrix_is_driving()) {
93            controller.request_matrix_stop();
94            if let Ok(handle) = tokio::runtime::Handle::try_current() {
95                handle.spawn(async move {
96                    if let Err(error) = controller.cancel_matrix().await {
97                        tracing::warn!(%error, "failed to persist aborted matrix cancellation");
98                    }
99                });
100            }
101        }
102    }
103}
104
105impl AgentRunner {
106    fn runtime_prompt_policy_hash(&self) -> u64 {
107        let model = self.get_selected_model();
108        let cfg = self.config();
109        let identity = crate::core::agent::hash_utils::PromptCapabilityIdentity::resolve(
110            self.provider_client.as_ref(),
111            &model,
112            self.provider_client
113                .sampling_overrides(&model)
114                .reasoning_effort
115                .or(self.reasoning_effort),
116            0,
117        );
118        crate::core::agent::hash_utils::hash_value(&(
119            identity,
120            cfg.context.max_context_tokens,
121            cfg.agent.max_system_prompt_tokens,
122            cfg.agent.harness.max_budget_usd.map(f64::to_bits),
123            format!("{:?}", cfg.agent.shell_prompt_profile),
124            cfg.agent.harness.max_tool_calls_per_turn,
125            cfg.agent.harness.max_tool_wall_clock_secs,
126            cfg.agent.harness.max_tool_retries,
127        ))
128    }
129
130    async fn compose_task_system_prompt(
131        &self,
132        prompt_tools: Arc<Vec<ToolDefinition>>,
133        is_simple_task: bool,
134    ) -> Result<(String, crate::prompts::system::SystemPromptReport)> {
135        if !is_simple_task {
136            return Ok((self.system_prompt.clone(), self.system_prompt_report.clone()));
137        }
138
139        let mut config = self.config().clone();
140        config.agent.system_prompt_mode = SystemPromptMode::Minimal;
141        let mut prompt_context = PromptContext::from_workspace_tools(
142            self._workspace.as_path(),
143            prompt_tools.iter().map(|tool| tool.function_name().to_string()),
144        );
145        prompt_context.set_current_directory(self._workspace.clone());
146        prompt_context.load_available_skills_async().await;
147
148        let (prompt, report) =
149            super::helpers::compose_system_prompt_with_appendix(self._workspace.as_path(), &config, &prompt_context)
150                .await?;
151
152        Ok((prompt, report))
153    }
154
155    async fn build_runtime_prompt_bundle(&self, is_simple_task: bool) -> Result<RuntimePromptBundle> {
156        let tool_snapshot = self.build_universal_tool_snapshot().await?;
157        let request_tools = tool_snapshot.snapshot.clone();
158        let prompt_tools = request_tools.clone().unwrap_or_else(|| Arc::new(Vec::new()));
159        let (mut system_prompt, mut system_prompt_report) =
160            self.compose_task_system_prompt(prompt_tools, is_simple_task).await?;
161        self.append_active_primary_agent_context(&mut system_prompt);
162
163        let planning_active = self.tool_registry.is_planning_active();
164        let request_user_input_enabled = self.features().request_user_input_enabled(planning_active, false);
165        let full_auto_active = self.tool_registry.current_full_auto_allowlist().await.is_some();
166
167        append_runtime_mode_sections(
168            &mut system_prompt,
169            RuntimePromptContract {
170                full_auto: full_auto_active,
171                planning_active,
172                request_user_input_enabled,
173            },
174        );
175        upsert_harness_limits_section(
176            &mut system_prompt,
177            self.config().agent.harness.max_tool_calls_per_turn,
178            self.config().agent.harness.max_tool_wall_clock_secs,
179            self.config().agent.harness.max_tool_retries,
180        );
181        let shell_profile = self.config().agent.shell_prompt_profile.resolve_for_current_platform();
182        append_runtime_tool_prompt_sections_for_model(
183            &mut system_prompt,
184            &tool_snapshot,
185            true,
186            shell_profile,
187            self.provider_client.as_ref(),
188            &self.get_selected_model(),
189            Some(self.config()),
190        );
191
192        let tool_def_tokens = request_tools
193            .as_deref()
194            .map(|tools| crate::llm::usage_cost::estimate_tool_definition_tokens(tools))
195            .unwrap_or(0);
196        let tool_count = request_tools.as_deref().map_or(0, Vec::len);
197        self.tool_registry
198            .metrics_collector()
199            .record_sdk_tool_definition_tokens(tool_def_tokens);
200        debug!(tool_def_tokens, tool_count, "tool definition overhead");
201
202        let instruction_digest = stable_system_prefix_hash(&system_prompt);
203        let capability_digest = crate::core::agent::hash_utils::PromptCapabilityIdentity::resolve(
204            self.provider_client.as_ref(),
205            &self.get_selected_model(),
206            self.reasoning_effort,
207            tool_snapshot.epoch,
208        )
209        .digest();
210        let system_instruction_prefix_hash =
211            crate::core::agent::hash_utils::hash_value(&(instruction_digest, capability_digest));
212        system_prompt_report.token_estimate = crate::prompts::system::estimate_token_count(&system_prompt);
213        system_prompt_report.over_budget =
214            system_prompt_report.token_estimate > self.config().agent.max_system_prompt_tokens;
215        let request_envelope = crate::core::agent::request_envelope::SessionRequestEnvelope::with_prefix_hash(
216            format!("{}-catalog-{}-{}", self.session_id, tool_snapshot.version, tool_snapshot.epoch),
217            system_prompt,
218            request_tools.as_deref().map_or_else(Vec::new, |tools| tools.clone()),
219            instruction_digest,
220            system_instruction_prefix_hash,
221        );
222        Ok(RuntimePromptBundle {
223            prompt_policy_hash: self.runtime_prompt_policy_hash(),
224            request_envelope,
225            tool_snapshot,
226            tool_def_tokens,
227            system_prompt_report,
228        })
229    }
230
231    fn append_active_primary_agent_context(&self, system_prompt: &mut String) {
232        let Some(active_primary_agent) = self.active_primary_agent.as_ref() else {
233            return;
234        };
235        crate::prompts::apply_coordinator_role_guidance(
236            system_prompt,
237            active_primary_agent.identity.name == "coordinator",
238        );
239
240        system_prompt.push_str("\n\n## Active Primary Agent Runtime State\n");
241        system_prompt.push_str("- Active agent: ");
242        system_prompt.push_str(&active_primary_agent.display_name);
243        system_prompt.push_str("\n- Spec name: ");
244        system_prompt.push_str(&active_primary_agent.identity.name);
245        if let Some(model) = active_primary_agent
246            .model
247            .as_deref()
248            .map(str::trim)
249            .filter(|model| !model.is_empty() && !model.eq_ignore_ascii_case("inherit"))
250        {
251            system_prompt.push_str("\n- Agent model: ");
252            system_prompt.push_str(model);
253        }
254        if let Some(reasoning_effort) = active_primary_agent.reasoning_effort.as_ref().map(|e| e.as_str()) {
255            system_prompt.push_str("\n- Agent reasoning effort: ");
256            system_prompt.push_str(reasoning_effort);
257        }
258        system_prompt.push_str("\n\n## Active Primary Agent Instructions\n");
259        system_prompt.push_str(active_primary_agent.instructions.trim());
260    }
261
262    pub(super) async fn build_validated_runtime_prompt_bundle(
263        &self,
264        is_simple_task: bool,
265    ) -> Result<RuntimePromptBundle> {
266        let mut runner = self;
267        let bundle = runner.build_runtime_prompt_bundle(is_simple_task).await?;
268        prompt_alignment::rebuild_once_on_alignment_mismatch(
269            &mut runner,
270            bundle,
271            |runner| Box::pin((*runner).build_runtime_prompt_bundle(is_simple_task)),
272            |runner, bundle| {
273                let planning_active = runner.tool_registry.is_planning_active();
274                let request_user_input_enabled = runner.features().request_user_input_enabled(planning_active, false);
275                prompt_alignment::validate_prompt_catalog_alignment(
276                    bundle.request_envelope.system_prompt().as_ref(),
277                    &bundle.tool_snapshot,
278                    planning_active,
279                    request_user_input_enabled,
280                )
281            },
282            "prompt/catalog alignment mismatch; rebuilding runtime prompt bundle",
283            "prompt/catalog alignment mismatch persisted after rebuild",
284        )
285        .await
286    }
287
288    async fn refresh_runtime_prompt_bundle_if_catalog_changed(
289        &self,
290        bundle: &mut RuntimePromptBundle,
291        is_simple_task: bool,
292    ) -> Result<bool> {
293        let current_version = self.tool_registry.tool_catalog_state().current_version();
294        let current_policy = self.runtime_prompt_policy_hash();
295        if current_version == bundle.tool_snapshot.version && current_policy == bundle.prompt_policy_hash {
296            return Ok(false);
297        }
298
299        debug!(
300            old_version = bundle.tool_snapshot.version,
301            new_version = current_version,
302            "Tool catalog changed mid-task; refreshing runtime prompt bundle"
303        );
304        let new_bundle = self.build_validated_runtime_prompt_bundle(is_simple_task).await?;
305        // No-op refresh: version/epoch bumped without provider-visible change
306        // (e.g. MCP refresh returning identical tools). Preserve the frozen
307        // envelope, token estimates, and reports so the provider prefix cache
308        // stays hot; only advance snapshot bookkeeping so the next version
309        // check passes.
310        if new_bundle.has_same_provider_visible_prefix(bundle) {
311            debug!(
312                old_version = bundle.tool_snapshot.version,
313                new_version = new_bundle.tool_snapshot.version,
314                "Tool catalog version bumped without provider-visible change; preserving request envelope"
315            );
316            bundle.tool_snapshot = new_bundle.tool_snapshot;
317            return Ok(false);
318        }
319        *bundle = new_bundle;
320        Ok(true)
321    }
322
323    async fn resolve_completion_acceptance(
324        &mut self,
325        effective_task: &Task,
326        session_state: &mut AgentSessionState,
327        event_recorder: &mut ExecEventRecorder,
328        orchestration_enabled: bool,
329        verification_results: &[VerificationResult],
330        revision_rounds_used: &mut usize,
331        max_revision_rounds: usize,
332        should_write_blocked_handoff: &mut bool,
333    ) -> Result<bool> {
334        if !orchestration_enabled {
335            session_state.is_completed = true;
336            session_state.outcome = TaskOutcome::Success;
337            return Ok(true);
338        }
339
340        match self
341            .apply_evaluator_gate(
342                effective_task,
343                session_state,
344                event_recorder,
345                verification_results,
346                revision_rounds_used,
347                max_revision_rounds,
348            )
349            .await?
350        {
351            EvaluatorGateOutcome::Accept => {
352                session_state.is_completed = true;
353                session_state.outcome = TaskOutcome::Success;
354                Ok(true)
355            }
356            EvaluatorGateOutcome::Continue { prompt } => {
357                session_state.add_user_message(prompt);
358                Ok(false)
359            }
360            EvaluatorGateOutcome::Exhausted { reason } => {
361                session_state.outcome = TaskOutcome::failed(reason, vec![], None, None);
362                *should_write_blocked_handoff = true;
363                Ok(true)
364            }
365        }
366    }
367
368    /// Resolve a [`CompletionAssessment`] produced by either
369    /// [`super::continuation::ContinuationController::assess_completion`]
370    /// (pre-verification) or
371    /// [`super::continuation::ContinuationController::after_verification`]
372    /// (post-verification) into a
373    /// single [`AssessmentResolution`] the turn loop reacts to.
374    ///
375    /// This consolidates the previously duplicated match arms for `Accept`,
376    /// `SkipAccept`, and `Continue`. `Verify` is returned as
377    /// [`AssessmentResolution::VerifyNotHandled`] so the caller can run
378    /// verification commands and re-dispatch the `after_verification` result
379    /// through this same helper.
380    ///
381    /// Note: `SkipAccept` intentionally bypasses the evaluator gate (mirroring
382    /// the original pre-verification path) regardless of which controller call
383    /// produced it.
384    #[allow(
385        clippy::too_many_arguments,
386        reason = "Intentional compatibility, platform, or test-only suppression."
387    )]
388    async fn resolve_completion_assessment(
389        &mut self,
390        assessment: CompletionAssessment,
391        verification_results: &[VerificationResult],
392        effective_task: &Task,
393        runtime: &mut AgentRuntime,
394        event_recorder: &mut ExecEventRecorder,
395        orchestration_enabled: bool,
396        revision_rounds_used: &mut usize,
397        max_revision_rounds: usize,
398        should_write_blocked_handoff: &mut bool,
399    ) -> Result<AssessmentResolution> {
400        match assessment {
401            CompletionAssessment::Accept => {
402                if self
403                    .resolve_completion_acceptance(
404                        effective_task,
405                        &mut runtime.state,
406                        event_recorder,
407                        orchestration_enabled,
408                        verification_results,
409                        revision_rounds_used,
410                        max_revision_rounds,
411                        should_write_blocked_handoff,
412                    )
413                    .await?
414                {
415                    return Ok(AssessmentResolution::Break);
416                }
417                Ok(AssessmentResolution::ForceContinue)
418            }
419            CompletionAssessment::SkipAccept { reason } => {
420                event_recorder.harness_event(
421                    HarnessEventKind::ContinuationSkipped,
422                    Some(reason),
423                    None,
424                    None,
425                    None,
426                    None,
427                    None,
428                );
429                runtime.state.is_completed = true;
430                runtime.state.outcome = TaskOutcome::Success;
431                Ok(AssessmentResolution::Break)
432            }
433            CompletionAssessment::Continue { reason, prompt } => {
434                self.emit_continuation_started(event_recorder, reason);
435                runtime.state.add_user_message(prompt);
436                Ok(AssessmentResolution::ForceContinue)
437            }
438            // Handled by the caller, which has access to the continuation
439            // controller and runs verification before re-dispatching.
440            CompletionAssessment::Verify { .. } => Ok(AssessmentResolution::VerifyNotHandled),
441        }
442    }
443
444    async fn run_verification_commands(
445        &self,
446        commands: &[String],
447        event_recorder: &mut ExecEventRecorder,
448    ) -> Result<Vec<VerificationResult>> {
449        let mut results = Vec::with_capacity(commands.len());
450        for command in commands {
451            let command_event = event_recorder.command_started(command);
452            let payload = json!({
453                "action": "run",
454                "command": command,
455                "workdir": self._workspace.display().to_string(),
456                "yield_time_ms": 1000,
457            });
458            let result = self.tool_registry.execute_harness_command_session(payload).await?;
459            let exit_code = result
460                .get("exit_code")
461                .and_then(serde_json::Value::as_i64)
462                .map(|value| value as i32);
463            let success = exit_code.unwrap_or(0) == 0;
464            let output = summarize_verification_output(&result);
465            event_recorder.command_finished(
466                &command_event,
467                if success {
468                    crate::exec::events::CommandExecutionStatus::Completed
469                } else {
470                    crate::exec::events::CommandExecutionStatus::Failed
471                },
472                exit_code,
473                &output,
474            );
475            results.push(VerificationResult {
476                command: command.clone(),
477                success,
478                exit_code,
479                output,
480            });
481            if !success {
482                break;
483            }
484        }
485        Ok(results)
486    }
487
488    /// Emit the `ContinuationStarted` harness event for a forced-continuation
489    /// assessment. Centralizes the (reason-only) event shape used by both the
490    /// pre- and post-verification continuation paths.
491    fn emit_continuation_started(&self, event_recorder: &mut ExecEventRecorder, reason: String) {
492        event_recorder.harness_event(HarnessEventKind::ContinuationStarted, Some(reason), None, None, None, None, None);
493    }
494
495    /// Emit `VerificationFailed` (for the first failing command) or
496    /// `VerificationPassed` based on the verification run. Reuses
497    /// [`super::continuation::build_verification_failure_payload`] so the failure
498    /// headline stays in sync with the continuation prompt builder.
499    fn emit_verification_outcome(
500        &self,
501        event_recorder: &mut ExecEventRecorder,
502        commands: &[String],
503        results: &[VerificationResult],
504    ) {
505        if let Some(failure) = results.iter().find(|result| !result.success) {
506            event_recorder.harness_event(
507                HarnessEventKind::VerificationFailed,
508                Some(super::continuation::build_verification_failure_payload(failure)),
509                Some(failure.command.clone()),
510                None,
511                failure.exit_code,
512                None,
513                None,
514            );
515        } else {
516            event_recorder.harness_event(
517                HarnessEventKind::VerificationPassed,
518                Some(format!("Verification passed: {}", commands.join(", "))),
519                commands.last().cloned(),
520                None,
521                Some(0),
522                None,
523                None,
524            );
525        }
526    }
527
528    /// Execute a task with this agent
529    pub async fn execute_task(&mut self, task: &Task, contexts: &[ContextItem]) -> Result<TaskResults> {
530        let _matrix_cleanup = MatrixExecutionCleanup(self.tool_registry.subagent_controller());
531        self.tool_registry.begin_tracker_request(false);
532        self.tool_registry.begin_patch_recovery_turn();
533        // Phase 1: Setup — harness alignment, conversation building, session init,
534        // orchestration planning. Extracted to `prepare_task_execution` for testability.
535        let setup = self.prepare_task_execution(task, contexts).await?;
536
537        let agent_prefix = setup.agent_prefix;
538        let session_store_handle = setup.session_store_handle;
539        let mut event_recorder = setup.event_recorder;
540        let run_started_at = setup.run_started_at;
541        let is_simple_task = setup.is_simple_task;
542        let mut prompt_bundle = setup.prompt_bundle;
543        let preserve_recent_turns = setup.preserve_recent_turns;
544        let max_tool_loops = setup.max_tool_loops;
545        let max_context_tokens = setup.max_context_tokens;
546        let mut runtime = setup.runtime;
547        // Normalize usage from the provider actually serving this session;
548        // configuration may contain an alias (or be inferred from the model).
549        runtime.state.stats.provider_name = self.provider_client.name().to_string();
550        let mut continuation_controller = setup.continuation_controller;
551        let effective_task = setup.effective_task;
552        let orchestration_enabled = setup.orchestration_enabled;
553        let mut budget_warning_emitted = false;
554        let mut session_costs = crate::llm::usage_cost::SessionCostAccumulator::default();
555        let max_budget_usd = setup.max_budget_usd;
556        let max_revision_rounds = setup.max_revision_rounds;
557        let mut revision_rounds_used = 0usize;
558        let mut should_write_blocked_handoff = false;
559
560        let result: Result<_> = {
561            for turn in 0..self.max_turns {
562                if matches!(runtime.poll_turn_control().await, RuntimeControl::StopRequested) {
563                    self.runner_println(format_args!(
564                        "{} {}",
565                        agent_prefix,
566                        style("Stopped by steering signal.").red().bold()
567                    ));
568                    runtime.state.outcome = TaskOutcome::Cancelled;
569                    break;
570                }
571
572                if let Some(input) = runtime.run_until_idle() {
573                    self.runner_println(format_args!(
574                        "{} {}: {}",
575                        agent_prefix,
576                        style("Follow-up Input").cyan().bold(),
577                        input
578                    ));
579                }
580
581                if runtime.state.is_completed {
582                    break;
583                }
584
585                self.runner_println(format_args!(
586                    "{} {} is processing turn {}...",
587                    agent_prefix,
588                    style("(PROC)").cyan().bold(),
589                    turn + 1
590                ));
591
592                let turn_model = self.get_selected_model();
593                self.refresh_runtime_prompt_bundle_if_catalog_changed(&mut prompt_bundle, is_simple_task)
594                    .await?;
595                let provider_name = self.provider_client.name().to_string();
596                if let Err(error) =
597                    crate::llm::usage_cost::require_budget_pricing(&provider_name, &turn_model, max_budget_usd)
598                {
599                    let message = error.to_string();
600                    event_recorder.turn_blocked(vtcode_exec_events::TurnBlockedEvent {
601                        completed_at: None,
602                        message: message.clone(),
603                        last_tool: None,
604                        blocked_streak: 0,
605                        blocked_total: 0,
606                        consecutive_cap: 0,
607                        total_cap: 0,
608                        recovery_active: false,
609                        usage: Some(runtime.state.stats.total_usage.clone()),
610                    });
611                    should_write_blocked_handoff = true;
612                    runtime.state.outcome = TaskOutcome::failed(
613                        message,
614                        Vec::new(),
615                        Some(
616                            "Choose a model route with complete pricing or remove the USD budget explicitly"
617                                .to_string(),
618                        ),
619                        None,
620                    );
621                    break;
622                }
623
624                if std::env::var_os("VTCODE_DEBUG_PROVIDER").is_some() {
625                    tracing::debug!(
626                        provider_client = self.provider_client.name(),
627                        turn_model,
628                        "Provider debug turn selection"
629                    );
630                }
631                let sampling_overrides = self.provider_client.sampling_overrides(&turn_model);
632                let turn_reasoning = if is_simple_task
633                    && self
634                        .provider_client
635                        .supported_reasoning_efforts(&turn_model)
636                        .contains(&ReasoningEffortLevel::Minimal.as_str())
637                {
638                    Some(ReasoningEffortLevel::Minimal)
639                } else {
640                    sampling_overrides.reasoning_effort.or(self.reasoning_effort)
641                };
642                let turn_verbosity = if is_simple_task {
643                    Some(VerbosityLevel::Low)
644                } else {
645                    self.verbosity
646                };
647                let max_tokens = sampling_overrides
648                    .max_tokens
649                    .or(if is_simple_task { Some(800) } else { Some(2000) });
650
651                let provider_kind = turn_model
652                    .parse::<ModelId>()
653                    .map(|model| model.provider())
654                    .unwrap_or(ModelProvider::Gemini);
655
656                if matches!(provider_kind, ModelProvider::Gemini)
657                    && runtime.state.conversation.len() > runtime.state.last_processed_message_idx
658                {
659                    // Collect new messages first to avoid borrow conflict:
660                    // conversation is immutably borrowed in the loop, but
661                    // adjust_token_count/push need &mut state.
662                    let new_messages: Vec<Message> = runtime.state.conversation
663                        [runtime.state.last_processed_message_idx..]
664                        .iter()
665                        .map(|content| {
666                            let mut text = String::new();
667                            for part in &content.parts {
668                                if let Part::Text { text: part_text, .. } = part {
669                                    if !text.is_empty() {
670                                        text.push('\n');
671                                    }
672                                    text.push_str(part_text);
673                                }
674                            }
675                            match content.role.as_str() {
676                                "model" => Message::assistant(text),
677                                _ => Message::user(text),
678                            }
679                        })
680                        .collect();
681                    let batch_tokens: usize = new_messages.iter().map(|m| m.estimate_tokens()).sum();
682                    runtime.state.adjust_token_count(batch_tokens as isize);
683                    runtime.state.messages_mut().extend(new_messages);
684                    runtime.state.last_processed_message_idx = runtime.state.conversation.len();
685                }
686
687                let context_budget = crate::compaction::effective_context_budget(
688                    Some(self.config()),
689                    self.provider_client.as_ref(),
690                    &turn_model,
691                );
692                runtime.state.constraints.max_context_tokens = context_budget;
693                let reserved_output_tokens = max_tokens
694                    .map_or(crate::compaction::memory_envelope::DEFAULT_OUTPUT_RESERVE_TOKENS, |value| value as usize);
695                let prompt_overhead_tokens = (prompt_bundle.system_prompt_report.token_estimate as usize)
696                    .saturating_add(prompt_bundle.tool_def_tokens as usize);
697                tracing::info!(
698                    turn, model = %turn_model, context_budget,
699                    reserved_output_tokens, prompt_overhead_tokens,
700                    "Resolved per-turn context budget denominator"
701                );
702                let mut normalized_messages = self.project_request_history(
703                    &runtime.state,
704                    &turn_model,
705                    prompt_overhead_tokens,
706                    reserved_output_tokens,
707                );
708                let projected_token_usage = normalized_messages
709                    .iter()
710                    .map(Message::estimate_tokens)
711                    .fold(0usize, usize::saturating_add)
712                    .saturating_add(prompt_overhead_tokens);
713                let compacted = self
714                    .maybe_auto_compact(
715                        &mut runtime.state,
716                        &mut event_recorder,
717                        &turn_model,
718                        preserve_recent_turns,
719                        projected_token_usage,
720                        reserved_output_tokens,
721                        &mut prompt_bundle.request_envelope,
722                    )
723                    .await;
724                let estimated = if compacted {
725                    normalized_messages = self.project_request_history(
726                        &runtime.state,
727                        &turn_model,
728                        prompt_overhead_tokens,
729                        reserved_output_tokens,
730                    );
731                    normalized_messages
732                        .iter()
733                        .map(Message::estimate_tokens)
734                        .fold(0usize, usize::saturating_add)
735                        .saturating_add(prompt_overhead_tokens)
736                } else {
737                    projected_token_usage
738                };
739                let budget = context_budget.saturating_sub(reserved_output_tokens);
740                let fits = context_budget == 0 || estimated <= budget;
741                if !fits {
742                    // Advisory only: the estimate can overshoot (un-droppable
743                    // messages, suppressed compaction), and the provider owns
744                    // the authoritative context limit. Aborting here turned a
745                    // recoverable situation into a dead `exec`/auto run.
746                    tracing::warn!(
747                        estimated,
748                        budget,
749                        "Pre-flight token check failed: prompt exceeds context budget after compaction"
750                    );
751                    #[allow(
752                        clippy::cast_sign_loss,
753                        reason = "Intentional compatibility, platform, or test-only suppression."
754                    )]
755                    let pct = (estimated as f64 / budget.max(1) as f64 * 100.0) as u32;
756                    runtime
757                        .state
758                        .warnings
759                        .push(format!("Pre-flight check: {pct}% of context budget used before LLM call"));
760                }
761
762                let parallel_tool_config = if self.provider_client.supports_parallel_tool_config(&turn_model) {
763                    Some(Box::new(crate::llm::provider::ParallelToolConfig::anthropic_optimized()))
764                } else {
765                    None
766                };
767
768                let reasoning_mapping = turn_reasoning
769                    .map(|requested| {
770                        crate::llm::reasoning_effort::ReasoningEffortMapper::resolve(
771                            self.provider_client.as_ref(),
772                            &turn_model,
773                            requested,
774                            self.config().agent.allow_reasoning_effort_downgrade,
775                        )
776                    })
777                    .transpose();
778                let reasoning_effort = match reasoning_mapping {
779                    Ok(mapping) => mapping,
780                    Err(error) => {
781                        let message = error.to_string();
782                        event_recorder.turn_blocked(vtcode_exec_events::TurnBlockedEvent {
783                            completed_at: None,
784                            message: message.clone(),
785                            last_tool: None,
786                            blocked_streak: 0,
787                            blocked_total: 0,
788                            consecutive_cap: 0,
789                            total_cap: 0,
790                            recovery_active: false,
791                            usage: Some(runtime.state.stats.total_usage.clone()),
792                        });
793                        should_write_blocked_handoff = true;
794                        runtime.state.outcome = TaskOutcome::failed(
795                            message,
796                            Vec::new(),
797                            Some("Choose a supported reasoning effort or explicitly enable downgrade".to_string()),
798                            None,
799                        );
800                        break;
801                    }
802                }
803                .map(|mapping| {
804                    if mapping.degraded() {
805                        tracing::warn!(requested = %mapping.requested, effective = %mapping.effective,
806                            model = %turn_model, "Harness reasoning effort explicitly downgraded");
807                    }
808                    mapping.effective
809                })
810                .filter(|effort| *effort != ReasoningEffortLevel::None);
811                let reasoning_active = reasoning_effort.is_some_and(|effort| {
812                    !matches!(effort, ReasoningEffortLevel::None | ReasoningEffortLevel::Unknown)
813                });
814                let capability_prefix_hash = crate::core::agent::hash_utils::PromptCapabilityIdentity::resolve(
815                    self.provider_client.as_ref(),
816                    &turn_model,
817                    reasoning_effort,
818                    prompt_bundle.tool_snapshot.epoch,
819                )
820                .prefix_hash(prompt_bundle.request_envelope.prefix_hash());
821
822                // Reasoning-effort-change advisory (Phase E4): a mid-task
823                // change to the reasoning effort alters the request prefix,
824                // which invalidates the provider prompt cache for the next
825                // request.
826                if runtime.state.note_reasoning_effort_change(reasoning_effort) {
827                    let message = "Reasoning effort changed mid-task; provider prompt cache \
828                                    will be invalidated and the next request re-pays full \
829                                    input cost."
830                        .to_string();
831                    tracing::warn!("{message}");
832                    runtime.state.push_warning(message);
833                }
834
835                // Model-change advisory: prompt caches are unique per model,
836                // so a mid-task switch rebuilds the cache at full input cost
837                // even when the rest of the prefix is unchanged. Prefer
838                // resolving the model up front; when a switch is unavoidable,
839                // expect one full-price request before hits resume.
840                if runtime.state.note_model_change(&turn_model) {
841                    let message = "Model changed mid-task; provider prompt cache \
842                                    will be invalidated and the next request re-pays full \
843                                    input cost."
844                        .to_string();
845                    tracing::warn!("{message}");
846                    runtime.state.push_warning(message);
847                }
848
849                let anthropic_shaped = matches!(provider_kind, ModelProvider::Anthropic | ModelProvider::Minimax);
850                let temperature = if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
851                    None
852                } else {
853                    Some(sampling_overrides.temperature.unwrap_or(self.config().agent.temperature))
854                };
855                let mut top_p_override = sampling_overrides.top_p;
856                let mut top_k_override = sampling_overrides.top_k;
857                if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
858                    // Keep the payload inside what our Anthropic reasoning
859                    // validator accepts: `validate_reasoning_constraints`
860                    // rejects any top_k and requires top_p in [0.95, 1.0]
861                    // while extended thinking is active.
862                    top_k_override = None;
863                    top_p_override = top_p_override.filter(|value| *value >= 0.95);
864                }
865
866                let (request_messages, previous_response_id) = prepare_responses_request_messages(
867                    &mut runtime.state.previous_response_chains,
868                    &provider_name,
869                    self.provider_client.supports_responses_compaction(&turn_model),
870                    &turn_model,
871                    &normalized_messages,
872                );
873                let request_messages = match request_messages {
874                    std::borrow::Cow::Borrowed(_) => Arc::clone(&normalized_messages),
875                    std::borrow::Cow::Owned(messages) => Arc::new(messages),
876                };
877                let request = build_harness_request_plan(HarnessRequestPlanInput {
878                    messages: request_messages,
879                    system_prompt: prompt_bundle.request_envelope.system_prompt(),
880                    tools: (!prompt_bundle.request_envelope.ordered_tools().is_empty())
881                        .then(|| prompt_bundle.request_envelope.ordered_tools()),
882                    model: turn_model.clone(),
883                    max_tokens,
884                    temperature,
885                    top_p: top_p_override,
886                    top_k: top_k_override,
887                    presence_penalty: if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
888                        None
889                    } else {
890                        sampling_overrides.presence_penalty
891                    },
892                    frequency_penalty: if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
893                        None
894                    } else {
895                        sampling_overrides.frequency_penalty
896                    },
897                    stream: self.provider_client.supports_streaming(),
898                    tool_choice: (provider_name.eq_ignore_ascii_case("openai")
899                        && !prompt_bundle.tool_snapshot.active_tool_names.is_empty())
900                    .then(|| {
901                        ToolChoice::allowed_tools_auto(prompt_bundle.tool_snapshot.active_tool_names.as_ref().clone())
902                    }),
903                    parallel_tool_config,
904                    reasoning_effort,
905                    verbosity: turn_verbosity,
906                    metadata: None,
907                    context_management: None,
908                    previous_response_id,
909                    // Keep the wire key stable per session. OpenAI routes by
910                    // (prefix hash + key); the key must stay consistent across
911                    // requests sharing a prefix ("Use prompt_cache_key
912                    // consistently"). Per-turn suffixes (capability/catalog
913                    // hashes) fragment routing buckets without benefit: distinct
914                    // prefixes already route distinctly via their prefix hash,
915                    // and a suffix that changes on tool-catalog churn busts an
916                    // otherwise reusable routing affinity every few turns.
917                    // Prefix identity stays tracked per request via
918                    // `tool_catalog_hash` / `system_prompt_prefix_hash` below.
919                    // OpenRouter/xAI receive namespaced session lineage for
920                    // documented sticky routing even when the OpenAI cache
921                    // block is off.
922                    prompt_cache_key: build_session_affinity_prompt_cache_key(
923                        &provider_name,
924                        session_affinity_key_enabled(
925                            &provider_name,
926                            self.config().prompt_cache.enabled,
927                            self.config().prompt_cache.providers.openai.enabled,
928                        ),
929                        &self.config().prompt_cache.providers.openai.prompt_cache_key_mode,
930                        Some(&self.session_id),
931                    ),
932                    prompt_cache_profile: None,
933                    tool_catalog_hash: prompt_bundle.request_envelope.catalog_hash(),
934                    system_prompt_prefix_hash: Some(capability_prefix_hash),
935                })
936                .request;
937                // Cheap pre-flight: catch malformed requests (empty system
938                // prompt, no messages, duplicate tool names, missing required
939                // properties) before paying for an API round-trip.
940                self.validate_llm_request(&request)?;
941                let previous_response_chain_present = request.previous_response_id.is_some();
942                // O(1) Arc bump: keeps the sent history available for
943                // set_previous_response_chain while the request still holds it
944                // for providers that validate messages (e.g. MiMo) during
945                // stream().
946                let sent_messages = Arc::clone(&request.messages);
947                // Compute timeout before the call to avoid simultaneous mutable/immutable
948                // borrows of `self` (provider_client vs config).
949                let streaming_timeout = self
950                    .config()
951                    .timeouts
952                    .ceiling_duration(self.config().timeouts.streaming_ceiling_seconds);
953
954                // Cache-gap advisory (Phase E1): warn once per gap when the
955                // provider prompt cache has likely expired since the last
956                // request, so this request may unexpectedly re-pay full
957                // input cost.
958                if let Some(threshold) = self
959                    .config()
960                    .prompt_cache
961                    .gap_threshold_secs(self.config().agent.provider.as_str())
962                {
963                    let threshold = std::time::Duration::from_secs(threshold);
964                    if runtime.state.stats.total_usage.cached_input_tokens > 0
965                        && let Some(elapsed) = runtime.state.cache_gap_exceeds(threshold)
966                    {
967                        let gap = crate::llm::request_gap::format_gap(elapsed);
968                        let message = format!(
969                            "~{gap} since the last request; the provider prompt cache has likely expired, so this request may re-pay full input cost."
970                        );
971                        tracing::warn!("{message}");
972                        runtime.state.push_warning(message);
973                    }
974                }
975                runtime.state.note_request_sent();
976
977                let turn_output = runtime
978                    .run_turn_once(&mut self.provider_client, request, streaming_timeout)
979                    .await?;
980                super::tool_dispatch_common::drain_and_record_runtime_events(&mut runtime, &mut event_recorder);
981                let response = turn_output.response;
982                runtime.state.stop_reason = Some(stop_reason_from_finish_reason(&response.finish_reason));
983                let refused = refusal::is_refusal(&response);
984
985                // --- Progress stagnation detection ---
986                // If the assistant produces near-identical responses across consecutive
987                // turns (no tool calls, no progress), inject a nudge to break the loop.
988                if !refused && !runtime.state.is_completed && runtime.state.record_progress_hash_and_check_stagnation()
989                {
990                    let nudge = "It looks like you're repeating the same response. \
991                                 If you're stuck, try a different approach: break the \
992                                 problem into smaller steps, use different tools, or \
993                                 declare the task complete if you have enough information.";
994                    self.runner_println(format_args!(
995                        "{} {}",
996                        agent_prefix,
997                        style("[STAGNATION WARNING]").yellow().bold(),
998                    ));
999                    runtime.state.add_user_message(nudge.into());
1000                }
1001                if !refused
1002                    && supports_responses_chaining(
1003                        &provider_name,
1004                        self.provider_client.supports_responses_compaction(&turn_model),
1005                    )
1006                {
1007                    runtime.state.set_previous_response_chain_shared(
1008                        &provider_name,
1009                        &turn_model,
1010                        response.request_id.as_deref(),
1011                        Arc::clone(&sent_messages),
1012                    );
1013                }
1014                let turn_cost = response.usage.as_ref().and_then(|usage| {
1015                    let normalized = crate::llm::usage_cost::normalized_turn_usage(&provider_name, usage);
1016                    crate::llm::usage_cost::estimate_session_costs(&provider_name, &turn_model, &normalized)
1017                });
1018                match session_costs.record(turn_cost) {
1019                    Some(estimate) => {
1020                        runtime.state.total_cost_usd = Some(estimate.effective_usd);
1021                        let threshold = self.config().agent.harness.budget_warning_threshold;
1022                        match crate::llm::usage_cost::BudgetStatus::classify(
1023                            estimate.raw_usd,
1024                            max_budget_usd,
1025                            threshold,
1026                        ) {
1027                            crate::llm::usage_cost::BudgetStatus::Exceeded { max, .. } => {
1028                                runtime.state.outcome = TaskOutcome::budget_limit_reached(max, estimate.raw_usd);
1029                                break;
1030                            }
1031                            crate::llm::usage_cost::BudgetStatus::Warning { max, .. } if !budget_warning_emitted => {
1032                                budget_warning_emitted = true;
1033                                warn!(
1034                                    provider = %self.config().agent.provider,
1035                                    model = %turn_model,
1036                                    cost_usd = estimate.raw_usd,
1037                                    max_budget_usd = max,
1038                                    "Session cost approaching budget limit"
1039                                );
1040                                runtime.state.push_warning(format!(
1041                                    "Session cost ${:.4} has reached {:.0}% of the ${max:.2} budget. {}",
1042                                    estimate.raw_usd,
1043                                    threshold * 100.0,
1044                                    runtime.state.stats.total_usage.cache_summary()
1045                                ));
1046                            }
1047                            _ => {}
1048                        }
1049                    }
1050                    None => {
1051                        runtime.state.total_cost_usd = None;
1052                        if max_budget_usd.is_some() {
1053                            runtime.state.outcome = TaskOutcome::failed(
1054                                format!(
1055                                    "Pricing or usage became unavailable for `{provider_name}/{turn_model}`; budget enforcement stopped execution"
1056                                ),
1057                                Vec::new(),
1058                                Some("Select a model route with complete pricing".to_string()),
1059                                None,
1060                            );
1061                            should_write_blocked_handoff = true;
1062                            break;
1063                        }
1064                    }
1065                }
1066                // A refusal is terminal for this prompt: idle recovery would
1067                // resend it and be refused again, and any partial output or
1068                // tool calls were cut off by the provider, so neither is
1069                // committed as an answer nor executed.
1070                if refused {
1071                    let reason = refusal::refusal_reason(&response);
1072                    discard_refused_assistant_message(&mut runtime.state);
1073                    // The refused response is not part of the kept history, so
1074                    // a stored continuation id would point past it.
1075                    runtime.state.clear_previous_response_chain_for(&provider_name, &turn_model);
1076                    self.runner_println(format_args!(
1077                        "{} {} {}",
1078                        agent_prefix,
1079                        style("(REFUSED)").red().bold(),
1080                        reason
1081                    ));
1082                    runtime.state.outcome = TaskOutcome::refused(reason);
1083                    break;
1084                }
1085
1086                self.runner_println(format_args!(
1087                    "{} {} {} received response, processing...",
1088                    agent_prefix,
1089                    self.agent_type,
1090                    style("(RECV)").green().bold()
1091                ));
1092
1093                self.warn_on_empty_response(
1094                    &agent_prefix,
1095                    response.content.as_deref().unwrap_or(""),
1096                    response.tool_calls.as_ref().is_some_and(|tool_calls| !tool_calls.is_empty()),
1097                );
1098
1099                let response_text = response.content_string();
1100                if !response_text.trim().is_empty() {
1101                    self.emit_final_assistant_message(&self.agent_type, &response_text);
1102                }
1103
1104                let mut effective_tool_calls = response.tool_calls.clone();
1105                let mut forced_continuation = false;
1106
1107                if effective_tool_calls.is_none()
1108                    && response.content_text().len() < 150
1109                    && let Some(args_value) = detect_textual_exec_tool_call(response.content_text())
1110                {
1111                    effective_tool_calls = Some(vec![ToolCall::function(
1112                        format!("call_text_{turn}"),
1113                        tools::EXEC_COMMAND.to_string(),
1114                        args_value.to_string(),
1115                    )]);
1116                }
1117
1118                let is_gemini = matches!(provider_kind, ModelProvider::Gemini);
1119
1120                // --- Confidence-based escalation gate + chain ---
1121                // Evaluate tool calls before dispatching them.  Implements the
1122                // escalation chain: re-plan → prompt user → abort with partial
1123                // results.  (Steps 1–2 already exist at the tool-exec level:
1124                // auto-retry and alternative-tool fallback are handled in
1125                // tool_exec.rs and execution_facade.rs.)
1126                #[allow(
1127                    unused_assignments,
1128                    reason = "Intentional compatibility, platform, or test-only suppression."
1129                )] // compiler keeps flags but this is clear
1130                if self.config().agent.harness.confidence_escalation.enabled
1131                    && !runtime.state.is_completed
1132                    && let Some(tool_calls) = effective_tool_calls.as_ref()
1133                    && !tool_calls.is_empty()
1134                {
1135                    let escalation_config = &self.config().agent.harness.confidence_escalation;
1136                    let orchestration_mode = if orchestration_enabled {
1137                        "plan_build_evaluate"
1138                    } else {
1139                        "single"
1140                    };
1141
1142                    // Reuse the per-turn cost estimate from the budget check above.
1143                    let cost_estimate = runtime.state.total_cost_usd;
1144
1145                    let result = EscalationGate::decide(
1146                        tool_calls,
1147                        escalation_config,
1148                        &runtime.state.error_recovery.lock(),
1149                        cost_estimate,
1150                        orchestration_mode,
1151                    );
1152
1153                    if result.any_escalated {
1154                        let reasons: Vec<String> = result
1155                            .decisions
1156                            .iter()
1157                            .filter_map(|d| match d {
1158                                EscalationDecision::Escalate { reason, .. } => Some(reason.clone()),
1159                                EscalationDecision::Proceed => None,
1160                            })
1161                            .collect();
1162                        let summary = reasons.join("; ");
1163
1164                        self.runner_println(format_args!(
1165                            "{} {}: {}",
1166                            agent_prefix,
1167                            style("[ESCALATION REQUIRED]").red().bold(),
1168                            summary
1169                        ));
1170
1171                        event_recorder.harness_event(
1172                            HarnessEventKind::EscalationTriggered,
1173                            Some(summary.clone()),
1174                            None,
1175                            None,
1176                            None,
1177                            None,
1178                            None,
1179                        );
1180
1181                        let esc_count = runtime.state.consecutive_escalations;
1182
1183                        // --- Step 3: Re-plan via conversation injection ---
1184                        if esc_count < escalation_config.max_replan_attempts {
1185                            runtime.state.consecutive_escalations += 1;
1186                            let replan_msg = format!(
1187                                "The following tool calls were blocked by the safety \
1188                                 escalation gate:\n\n{summary}\n\n\
1189                                 Please try a different approach. You can use alternative \
1190                                 tools, break the task into smaller steps, or provide a \
1191                                 direct answer based on what you have already learned."
1192                            );
1193                            runtime.state.add_user_message(replan_msg);
1194                            // Skip dispatching blocked tool calls — let the LLM re-plan
1195                            effective_tool_calls = None;
1196                            forced_continuation = true;
1197                        }
1198                        // --- Step 4: Prompt user for guidance ---
1199                        else if esc_count < escalation_config.max_total_escalations
1200                            && escalation_config.prompt_user_on_exhaust
1201                        {
1202                            runtime.state.consecutive_escalations += 1;
1203                            let user_msg = format!(
1204                                "I've tried multiple approaches but the safety escalation \
1205                                 gate keeps blocking my tool calls:\n\n{summary}\n\n\
1206                                 Could you provide guidance on how to proceed? \
1207                                 What approach should I use instead?"
1208                            );
1209                            runtime.state.add_user_message(user_msg);
1210                            should_write_blocked_handoff = true;
1211                            runtime.state.outcome = TaskOutcome::escalated(summary, "multi_tool".to_string());
1212                            break;
1213                        }
1214                        // --- Step 5: Abort with partial results ---
1215                        else {
1216                            runtime.state.consecutive_escalations += 1;
1217                            should_write_blocked_handoff = true;
1218                            runtime.state.outcome = TaskOutcome::failed(
1219                                format!("Escalation chain exhausted: {summary}"),
1220                                Vec::new(),
1221                                Some(
1222                                    "The safety escalation gate repeatedly blocked tool calls. \
1223                                     Consider disabling the gate or adjusting the confidence \
1224                                     threshold if this action should be permitted."
1225                                        .into(),
1226                                ),
1227                                None,
1228                            );
1229                            break;
1230                        }
1231                    } else {
1232                        // Tool calls passed escalation gate — reset chain counter
1233                        runtime.state.consecutive_escalations = 0;
1234
1235                        event_recorder.harness_event(
1236                            HarnessEventKind::EscalationBypassed,
1237                            Some("All tool calls passed escalation gate".into()),
1238                            None,
1239                            None,
1240                            None,
1241                            None,
1242                            None,
1243                        );
1244                    }
1245                }
1246
1247                if self
1248                    .active_primary_agent
1249                    .as_ref()
1250                    .is_some_and(|agent| agent.identity.name == "coordinator")
1251                    && effective_tool_calls.as_ref().is_none_or(|calls| calls.is_empty())
1252                    && let Some(controller) = self.tool_registry.subagent_controller()
1253                    && let Some(snapshot) = controller.matrix_snapshot().await
1254                {
1255                    use crate::exec::events::matrix::MatrixLifecycle;
1256                    match snapshot.lifecycle {
1257                        MatrixLifecycle::Running | MatrixLifecycle::Verifying => {
1258                            let settled = controller
1259                                .wait_matrix_idle()
1260                                .await
1261                                .context("matrix disappeared before completion")?;
1262                            anyhow::ensure!(
1263                                !matches!(settled.lifecycle, MatrixLifecycle::Running | MatrixLifecycle::Verifying),
1264                                "matrix stopped without a durable result; reconcile owned cleanup before continuation"
1265                            );
1266                            runtime.state.is_completed = false;
1267                            runtime.state.add_user_message(format!(
1268                                "Durable matrix results: {}. Report outcomes from this snapshot; matrix success requires Succeeded. Other states require a coordinator decision.",
1269                                serde_json::to_string(&settled)?
1270                            ));
1271                            forced_continuation = true;
1272                        }
1273                        MatrixLifecycle::Succeeded => {
1274                            runtime.state.is_completed = true;
1275                            runtime.state.outcome = TaskOutcome::Success;
1276                            break;
1277                        }
1278                        MatrixLifecycle::Cancelled => {
1279                            runtime.state.outcome = TaskOutcome::Cancelled;
1280                            break;
1281                        }
1282                        MatrixLifecycle::Blocked => {
1283                            runtime.state.outcome = TaskOutcome::Failed {
1284                                reason: "matrix requires a coordinator decision or confirmed owned cleanup".into(),
1285                                accomplished: vec![],
1286                                recovery_suggestion: Some(format!(
1287                                    "Resume session and inspect matrix {} status",
1288                                    snapshot.spec.id
1289                                )),
1290                                checkpoint_path: None,
1291                            };
1292                            should_write_blocked_handoff = true;
1293                            break;
1294                        }
1295                        MatrixLifecycle::Created | MatrixLifecycle::Paused => {}
1296                    }
1297                }
1298
1299                if !forced_continuation
1300                    && !runtime.state.is_completed
1301                    && effective_tool_calls.as_ref().is_none_or(|tool_calls| tool_calls.is_empty())
1302                    && !response.content_text().is_empty()
1303                {
1304                    if check_for_response_loop(response.content_text(), &mut runtime.state) {
1305                        self.runner_println(format_args!(
1306                            "[{}] {}",
1307                            self.agent_type,
1308                            style("Repetitive assistant response detected. Breaking potential loop.")
1309                                .red()
1310                                .bold()
1311                        ));
1312                        runtime.state.outcome = TaskOutcome::LoopDetected;
1313                        break;
1314                    }
1315
1316                    if check_completion_candidate(response.content_text()) {
1317                        self.runner_println(format_args!(
1318                            "[{}] {}",
1319                            self.agent_type,
1320                            style("Completion candidate detected; checking tracker and verification state.")
1321                                .green()
1322                                .bold()
1323                        ));
1324                        let assessment = continuation_controller
1325                            .assess_completion(
1326                                &effective_task,
1327                                &runtime.state,
1328                                self.tool_registry.tracker_adopted_for_request(),
1329                            )
1330                            .await?;
1331
1332                        // Verify requires running verification commands before
1333                        // re-dispatching the after_verification result through the
1334                        // same helper. All other variants are resolved directly.
1335                        if let CompletionAssessment::Verify { commands } = &assessment {
1336                            event_recorder.harness_event(
1337                                HarnessEventKind::VerificationStarted,
1338                                Some(format!("Running verification: {}", commands.join(", "))),
1339                                commands.first().cloned(),
1340                                None,
1341                                None,
1342                                None,
1343                                None,
1344                            );
1345                            let verification_results =
1346                                self.run_verification_commands(commands, &mut event_recorder).await?;
1347                            self.emit_verification_outcome(&mut event_recorder, commands, &verification_results);
1348
1349                            let post_verification =
1350                                continuation_controller.after_verification(&verification_results).await?;
1351                            match self
1352                                .resolve_completion_assessment(
1353                                    post_verification,
1354                                    &verification_results,
1355                                    &effective_task,
1356                                    &mut runtime,
1357                                    &mut event_recorder,
1358                                    orchestration_enabled,
1359                                    &mut revision_rounds_used,
1360                                    max_revision_rounds,
1361                                    &mut should_write_blocked_handoff,
1362                                )
1363                                .await?
1364                            {
1365                                AssessmentResolution::Break => break,
1366                                AssessmentResolution::ForceContinue => {
1367                                    forced_continuation = true;
1368                                }
1369                                // `after_verification` never yields `Verify`.
1370                                AssessmentResolution::VerifyNotHandled => {}
1371                            }
1372                        } else {
1373                            match self
1374                                .resolve_completion_assessment(
1375                                    assessment,
1376                                    &[],
1377                                    &effective_task,
1378                                    &mut runtime,
1379                                    &mut event_recorder,
1380                                    orchestration_enabled,
1381                                    &mut revision_rounds_used,
1382                                    max_revision_rounds,
1383                                    &mut should_write_blocked_handoff,
1384                                )
1385                                .await?
1386                            {
1387                                AssessmentResolution::Break => break,
1388                                AssessmentResolution::ForceContinue => {
1389                                    forced_continuation = true;
1390                                }
1391                                AssessmentResolution::VerifyNotHandled => {
1392                                    // Verify is handled in the if-branch above;
1393                                    // the helper only returns this for Verify.
1394                                    return Err(anyhow::anyhow!("unexpected VerifyNotHandled from assess_completion"));
1395                                }
1396                            }
1397                        }
1398                    } else {
1399                        // Tracker-aware status continuation: text-only responses
1400                        // that are not completion candidates still must not end
1401                        // the run while the task tracker has incomplete steps.
1402                        //
1403                        // Use a read-only tracker probe — never `assess_completion`
1404                        // — so status text cannot create/complete the internal
1405                        // scaffold or invent a pending verify step under
1406                        // ContinuationPolicy::All.
1407                        let tracker_auto_continue = self.config().agent.harness.continuation.auto_continue_tracker;
1408                        let idle_limit = self.config().agent.idle_turn_limit;
1409                        let idle_limit_hit = runtime.state.consecutive_idle_turns >= idle_limit;
1410                        let asks_user = response.content_text().contains('?');
1411                        if tracker_auto_continue && !idle_limit_hit && !asks_user {
1412                            let status_text = response.content_text();
1413                            let safety_handoff =
1414                                crate::core::agent::completion::tracker_final_text_is_safety_handoff(status_text);
1415                            if !safety_handoff {
1416                                let incomplete = continuation_controller
1417                                    .incomplete_tracker_labels(self.tool_registry.tracker_adopted_for_request())
1418                                    .await?;
1419                                if !incomplete.is_empty() {
1420                                    let joined = incomplete.join(", ");
1421                                    let reason = format!("Task tracker is incomplete: {joined}.");
1422                                    if super::continuation::tracker_status_force_continue_eligible(
1423                                        tracker_auto_continue,
1424                                        idle_limit_hit,
1425                                        asks_user,
1426                                        &reason,
1427                                    ) {
1428                                        let prompt = super::continuation::tracker_incomplete_continue_prompt(&joined);
1429                                        self.runner_println(format_args!(
1430                                            "[{}] {}: {}",
1431                                            self.agent_type,
1432                                            style("[TRACKER CONTINUE]").yellow().bold(),
1433                                            reason
1434                                        ));
1435                                        runtime.state.add_user_message(prompt);
1436                                        forced_continuation = true;
1437                                    }
1438                                }
1439                            }
1440                        }
1441                    }
1442                }
1443
1444                if let Some(tool_calls) = effective_tool_calls
1445                    .as_ref()
1446                    .filter(|tool_calls| !tool_calls.is_empty())
1447                    .cloned()
1448                {
1449                    self.execute_tool_call_batches(
1450                        tool_calls,
1451                        &mut runtime,
1452                        &mut event_recorder,
1453                        &agent_prefix,
1454                        is_gemini,
1455                        previous_response_chain_present,
1456                    )
1457                    .await?;
1458                    super::tool_dispatch_common::drain_and_record_runtime_events(&mut runtime, &mut event_recorder);
1459
1460                    if let Some(outcome) = self.tool_registry.matrix_worker_outcome() {
1461                        runtime.state.outcome = if outcome == crate::exec::events::matrix::MatrixOutcome::Success {
1462                            TaskOutcome::Success
1463                        } else {
1464                            TaskOutcome::failed(format!("Matrix worker reported {outcome:?}"), vec![], None, None)
1465                        };
1466                        runtime.state.is_completed = true;
1467                        break;
1468                    }
1469
1470                    if self
1471                        .active_primary_agent
1472                        .as_ref()
1473                        .is_some_and(|agent| agent.identity.name == "coordinator")
1474                        && let Some(controller) = self.tool_registry.subagent_controller()
1475                        && let Some(snapshot) = controller.matrix_snapshot().await
1476                        && (matches!(
1477                            snapshot.lifecycle,
1478                            crate::exec::events::matrix::MatrixLifecycle::Running
1479                                | crate::exec::events::matrix::MatrixLifecycle::Verifying
1480                        ) || controller.matrix_is_driving())
1481                    {
1482                        // Headless sessions await owned work before another model turn;
1483                        // status polling must not exhaust the coordinator's loop budget.
1484                        let settled = loop {
1485                            tokio::select! {
1486                                settled = controller.wait_matrix_idle() => break settled.context("matrix disappeared before completion")?,
1487                                () = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
1488                                    if matches!(runtime.poll_turn_control().await, RuntimeControl::StopRequested) {
1489                                        controller.cancel_matrix().await?;
1490                                        runtime.state.outcome = TaskOutcome::Cancelled;
1491                                    }
1492                                }
1493                            }
1494                        };
1495                        anyhow::ensure!(
1496                            !matches!(
1497                                settled.lifecycle,
1498                                crate::exec::events::matrix::MatrixLifecycle::Running
1499                                    | crate::exec::events::matrix::MatrixLifecycle::Verifying
1500                            ),
1501                            "matrix stopped without a durable result; reconcile owned cleanup before continuation"
1502                        );
1503                        if matches!(runtime.state.outcome, TaskOutcome::Cancelled) {
1504                            break;
1505                        }
1506                        runtime.state.add_user_message(format!(
1507                            "Durable matrix results: {}. Report success only for Succeeded; other states require a coordinator decision.",
1508                            serde_json::to_string(&settled)?
1509                        ));
1510                    }
1511                }
1512
1513                // Refresh tool definitions if the catalog was mutated during tool
1514                // execution (e.g. tools.load / tools.unload / skill activation).
1515                if self
1516                    .refresh_runtime_prompt_bundle_if_catalog_changed(&mut prompt_bundle, is_simple_task)
1517                    .await?
1518                {
1519                    tracing::debug!("runtime prompt bundle refreshed after tool catalog mutation");
1520                }
1521
1522                // --- Emit tool latency events ---
1523                if !runtime.state.turn_tool_observations.is_empty() {
1524                    let observations = std::mem::take(&mut runtime.state.turn_tool_observations);
1525                    for observation in &observations {
1526                        event_recorder.record_tool_outcome(
1527                            &observation.tool_name,
1528                            observation.attempts,
1529                            observation.duration_ms,
1530                            observation.error_category.as_ref().map(vtcode_commons::ErrorCategory::as_str),
1531                        );
1532                    }
1533                }
1534
1535                let had_effective_shell_tool_call = effective_tool_calls.as_ref().is_some_and(|calls| {
1536                    calls.iter().any(|call| {
1537                        call.function.as_ref().map(|function| function.name.as_str()) == Some(tools::UNIFIED_EXEC)
1538                    })
1539                });
1540                let had_tool_call = response.tool_calls.as_ref().is_some_and(|tool_calls| !tool_calls.is_empty())
1541                    || had_effective_shell_tool_call;
1542
1543                if had_tool_call {
1544                    let loops = runtime.state.register_tool_loop();
1545                    if tool_loop_limit_reached(loops, runtime.state.constraints.max_tool_loops) {
1546                        let warning_message = format!(
1547                            "You have reached the tool-call iteration limit of {}. \
1548                             This typically means you are in a loop — repeatedly calling tools \
1549                             without making progress toward the task goal.\n\n\
1550                             To proceed:\n\
1551                             1. Review what you have already learned from previous tool outputs.\n\
1552                             2. Synthesize your findings into a concrete answer or implementation.\n\
1553                             3. If you need more information, use a different approach or tool.\n\
1554                             4. If you are truly stuck, explain what you have accomplished so far \
1555                             and what is blocking you.",
1556                            runtime.state.constraints.max_tool_loops
1557                        );
1558                        self.record_warning(
1559                            &agent_prefix,
1560                            &mut runtime.state,
1561                            &mut event_recorder,
1562                            warning_message.clone(),
1563                        );
1564                        runtime.state.add_user_message(warning_message);
1565                        runtime.state.mark_tool_loop_limit_hit();
1566                        break;
1567                    }
1568                    runtime.state.consecutive_idle_turns = 0;
1569                } else {
1570                    runtime.state.reset_tool_loop_guard();
1571                    if forced_continuation {
1572                        runtime.state.consecutive_idle_turns = 0;
1573                    } else if !runtime.state.is_completed {
1574                        runtime.state.consecutive_idle_turns = runtime.state.consecutive_idle_turns.saturating_add(1);
1575                        let idle_turn_limit = self.config().agent.idle_turn_limit;
1576                        if runtime.state.consecutive_idle_turns >= idle_turn_limit {
1577                            let warning_message = format!(
1578                                "No tool calls or completion for {} consecutive turns. \
1579                                 The agent appears to be idle — it is responding without \
1580                                 taking actions or declaring the task complete.\n\n\
1581                                 To proceed:\n\
1582                                 1. Take concrete action using the available tools.\n\
1583                                 2. If you have enough information, present your solution.\n\
1584                                 3. If you are waiting for something, explain the situation.",
1585                                runtime.state.consecutive_idle_turns
1586                            );
1587                            self.record_warning(
1588                                &agent_prefix,
1589                                &mut runtime.state,
1590                                &mut event_recorder,
1591                                warning_message.clone(),
1592                            );
1593                            runtime.state.add_user_message(warning_message);
1594                            runtime.state.outcome = TaskOutcome::StoppedNoAction;
1595                            break;
1596                        }
1597                    }
1598                }
1599
1600                let should_continue = forced_continuation
1601                    || had_tool_call
1602                    || runtime.has_pending_follow_up_inputs()
1603                    || (!runtime.state.is_completed && (turn + 1) < self.max_turns);
1604
1605                if !should_continue {
1606                    if runtime.state.is_completed {
1607                        runtime.state.outcome = TaskOutcome::Success;
1608                    } else if (turn + 1) >= self.max_turns {
1609                        runtime.state.outcome = TaskOutcome::turn_limit_reached(self.max_turns, turn + 1);
1610                    } else {
1611                        runtime.state.outcome = TaskOutcome::StoppedNoAction;
1612                    }
1613                    break;
1614                }
1615            }
1616
1617            runtime.state.finalize_outcome(self.max_turns);
1618
1619            let total_duration_ms = run_started_at.elapsed().as_millis();
1620
1621            // Agent execution completed
1622            self.runner_println(format_args!("{agent_prefix} Done"));
1623
1624            // Generate meaningful summary based on agent actions
1625            let average_turn_duration_ms = if runtime.state.turn_count > 0 {
1626                Some(runtime.state.turn_total_ms as f64 / runtime.state.turn_count as f64)
1627            } else {
1628                None
1629            };
1630
1631            let max_turn_duration_ms = if runtime.state.turn_count > 0 {
1632                Some(runtime.state.turn_max_ms)
1633            } else {
1634                None
1635            };
1636
1637            let outcome = runtime.state.outcome.clone();
1638            self.thread_handle.replace_messages((*runtime.state.messages).clone());
1639            let summary = self.generate_task_summary(
1640                &effective_task,
1641                &runtime.state.modified_files,
1642                &runtime.state.executed_commands,
1643                &runtime.state.warnings,
1644                &runtime.state.messages,
1645                runtime.state.stats.turns_executed,
1646                runtime.state.max_tool_loop_streak,
1647                max_tool_loops,
1648                outcome,
1649                total_duration_ms,
1650                average_turn_duration_ms,
1651                max_turn_duration_ms,
1652                &runtime.state.stats.total_usage,
1653            );
1654
1655            if !summary.trim().is_empty() {
1656                // Record summary as agent message for event stream
1657                event_recorder.agent_message(&summary);
1658                // Also display summary prominently for immediate visibility in TUI transcript
1659                self.runner_println(format_args!(
1660                    "\n{} Agent Task Summary\n{}",
1661                    style("[Task]").cyan().bold(),
1662                    summary
1663                ));
1664            }
1665
1666            let runtime_agent_config = self.core_agent_config();
1667            if let Err(err) = crate::persistent_memory::finalize_persistent_memory(
1668                &runtime_agent_config,
1669                Some(self.config()),
1670                &runtime.state.messages,
1671                &self.session_id,
1672            )
1673            .await
1674            {
1675                warn!(
1676                    error = %err,
1677                    session_id = %self.session_id,
1678                    "Failed to update persistent memory"
1679                );
1680            }
1681
1682            if runtime.state.outcome.is_hard_block() || should_write_blocked_handoff {
1683                let relevant_paths = existing_harness_artifact_paths(&self._workspace);
1684                match write_blocked_handoff_with_resume(
1685                    &self._workspace,
1686                    &self.session_id,
1687                    runtime.state.outcome.code(),
1688                    &runtime.state.outcome.description(),
1689                    &relevant_paths,
1690                    BlockedHandoffResume::Unavailable(
1691                        "Resume unavailable because the legacy agent runner does not create a session archive.",
1692                    ),
1693                    self.tool_registry.is_planning_active(),
1694                ) {
1695                    Ok(artifacts) => emit_blocked_handoff_events(
1696                        &mut event_recorder,
1697                        &runtime.state.outcome.description(),
1698                        &artifacts.current_path,
1699                        &artifacts.archive_path,
1700                    ),
1701                    Err(err) => warn!(
1702                        error = %err,
1703                        session_id = %self.session_id,
1704                        "Failed to persist blocked handoff"
1705                    ),
1706                }
1707            }
1708
1709            let total_usage = runtime.state.stats.total_usage.clone();
1710            record_terminal_turn_event(&mut event_recorder, &runtime.state.outcome, total_usage.clone());
1711            event_recorder.thread_completed(
1712                &self.session_id,
1713                runtime.state.outcome.thread_completion_subtype(),
1714                runtime.state.outcome.code(),
1715                runtime.state.outcome.is_success().then_some(summary.as_str()),
1716                runtime.state.stop_reason.as_deref(),
1717                total_usage,
1718                session_costs
1719                    .total()
1720                    .and_then(|cost| serde_json::Number::from_f64(cost.effective_usd)),
1721                runtime.state.stats.turns_executed,
1722            );
1723            let thread_events = event_recorder.take_events();
1724            let steering_receiver = runtime.take_steering_receiver();
1725            let state = std::mem::replace(
1726                &mut runtime.state,
1727                AgentSessionState::new(self.session_id.clone(), self.max_turns, max_tool_loops, max_context_tokens),
1728            );
1729
1730            Ok((state.into_results(summary, thread_events, total_duration_ms), steering_receiver))
1731        };
1732
1733        let result = match result {
1734            Ok((task_results, steering_receiver)) => {
1735                *self.steering_receiver.lock() = steering_receiver;
1736                Ok(task_results)
1737            }
1738            Err(err) => {
1739                event_recorder.thread_failed(
1740                    &self.session_id,
1741                    &err.to_string(),
1742                    runtime.state.stats.turns_executed.max(1),
1743                );
1744                *self.steering_receiver.lock() = runtime.take_steering_receiver();
1745                Err(err)
1746            }
1747        };
1748
1749        let persistence_result = if let Some(session_store_handle) = session_store_handle {
1750            session_store_handle
1751                .close()
1752                .await
1753                .context("authoritative session event persistence failed")
1754        } else {
1755            Ok(())
1756        };
1757
1758        self.tool_registry.set_harness_task(None);
1759        persistence_result?;
1760        result
1761    }
1762}
1763
1764#[cfg(test)]
1765mod tests {
1766    use super::{prepare_responses_request_messages, record_terminal_turn_event, tool_loop_limit_reached};
1767    use crate::core::agent::events::ExecEventRecorder;
1768    use crate::core::agent::session::AgentSessionState;
1769    use crate::core::agent::state::should_apply_local_tool_result_clearing;
1770    use crate::core::agent::task::TaskOutcome;
1771    use crate::exec::events::ThreadEvent;
1772    use crate::llm::provider::{Message, records_responses_continuation_state};
1773
1774    #[test]
1775    fn headless_local_clearing_gate_ignores_provider_context_edit_capability() {
1776        // Headless requests never attach native context management, so the
1777        // gate must run with context_edits == false even for Anthropic;
1778        // otherwise headless Anthropic gets neither native nor local
1779        // clearing and old tool bodies ride every request.
1780        assert!(should_apply_local_tool_result_clearing("anthropic", false, true));
1781        assert!(!should_apply_local_tool_result_clearing("anthropic", false, false));
1782        // Interactive semantics stay pinned: native edits exclude the local
1783        // pass, other providers use it regardless of capability.
1784        assert!(!should_apply_local_tool_result_clearing("anthropic", true, true));
1785        assert!(should_apply_local_tool_result_clearing("openai", true, true));
1786    }
1787
1788    #[test]
1789    fn failed_outcome_emits_only_turn_failed() {
1790        let mut recorder = ExecEventRecorder::new("thread", None, None);
1791        recorder.turn_started();
1792
1793        record_terminal_turn_event(
1794            &mut recorder,
1795            &TaskOutcome::failed("boom".to_string(), vec![], None, None),
1796            Default::default(),
1797        );
1798
1799        let events = recorder.into_events();
1800        assert_eq!(
1801            events
1802                .iter()
1803                .filter(|event| matches!(event, ThreadEvent::TurnFailed(_)))
1804                .count(),
1805            1
1806        );
1807        assert_eq!(
1808            events
1809                .iter()
1810                .filter(|event| matches!(event, ThreadEvent::TurnCompleted(_)))
1811                .count(),
1812            0
1813        );
1814    }
1815
1816    #[test]
1817    fn successful_outcome_emits_only_turn_completed() {
1818        let mut recorder = ExecEventRecorder::new("thread", None, None);
1819        recorder.turn_started();
1820
1821        record_terminal_turn_event(&mut recorder, &TaskOutcome::Success, Default::default());
1822
1823        let events = recorder.into_events();
1824        assert_eq!(
1825            events
1826                .iter()
1827                .filter(|event| matches!(event, ThreadEvent::TurnCompleted(_)))
1828                .count(),
1829            1
1830        );
1831        assert_eq!(
1832            events
1833                .iter()
1834                .filter(|event| matches!(event, ThreadEvent::TurnFailed(_)))
1835                .count(),
1836            0
1837        );
1838    }
1839
1840    #[test]
1841    fn disabled_tool_loop_limit_never_trips() {
1842        assert!(!tool_loop_limit_reached(1, 0));
1843        assert!(!tool_loop_limit_reached(32, 0));
1844    }
1845
1846    #[test]
1847    fn openai_prepare_responses_request_messages_keeps_full_history_without_previous_response_id() {
1848        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1849        let prior_messages = vec![Message::user("hello".to_string())];
1850        let current_messages = vec![
1851            Message::user("hello".to_string()),
1852            Message::user("continue".to_string()),
1853        ];
1854        state.set_previous_response_chain("openai", "gpt-5.6-sol", Some("resp_123"), prior_messages);
1855
1856        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1857            &mut state.previous_response_chains,
1858            "openai",
1859            false,
1860            "gpt-5.6-sol",
1861            &current_messages,
1862        );
1863
1864        assert_eq!(previous_response_id, None);
1865        assert_eq!(request_messages.as_ref(), current_messages.as_slice());
1866    }
1867
1868    #[test]
1869    fn openai_runner_success_path_does_not_record_previous_response_chain() {
1870        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1871        let messages = vec![Message::user("hello".to_string())];
1872
1873        if records_responses_continuation_state("openai", true) {
1874            state.set_previous_response_chain("openai", "gpt-5.6-sol", Some("resp_123"), messages);
1875        }
1876
1877        assert_eq!(state.previous_response_chain_for("openai", "gpt-5.6-sol"), None);
1878    }
1879
1880    #[test]
1881    fn compatible_runner_success_path_does_not_record_previous_response_chain() {
1882        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1883        let messages = vec![Message::user("hello".to_string())];
1884
1885        if records_responses_continuation_state("mycorp", true) {
1886            state.set_previous_response_chain("mycorp", "gpt-5.6-sol", Some("resp_123"), messages);
1887        }
1888
1889        assert_eq!(state.previous_response_chain_for("mycorp", "gpt-5.6-sol"), None);
1890    }
1891
1892    #[test]
1893    fn gemini_prepare_responses_request_messages_keeps_full_history() {
1894        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1895        let prior_messages = vec![Message::user("hello".to_string())];
1896        let current_messages = vec![
1897            Message::user("hello".to_string()),
1898            Message::user("continue".to_string()),
1899        ];
1900        state.set_previous_response_chain("gemini", "gemini-2.5-pro", Some("resp_123"), prior_messages);
1901
1902        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1903            &mut state.previous_response_chains,
1904            "gemini",
1905            false,
1906            "gemini-2.5-pro",
1907            &current_messages,
1908        );
1909
1910        assert_eq!(previous_response_id.as_deref(), Some("resp_123"));
1911        assert_eq!(request_messages.as_ref(), current_messages.as_slice());
1912    }
1913
1914    #[test]
1915    fn compatible_prepare_responses_request_messages_keeps_custom_provider_stateless() {
1916        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1917        let prior_messages = vec![Message::user("hello".to_string())];
1918        let current_messages = vec![
1919            Message::user("hello".to_string()),
1920            Message::user("continue".to_string()),
1921        ];
1922        state.set_previous_response_chain("mycorp", "gpt-5.6-sol", Some("resp_123"), prior_messages);
1923
1924        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1925            &mut state.previous_response_chains,
1926            "mycorp",
1927            true,
1928            "gpt-5.6-sol",
1929            &current_messages,
1930        );
1931
1932        assert_eq!(previous_response_id, None);
1933        assert_eq!(request_messages.as_ref(), current_messages.as_slice());
1934    }
1935}