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,
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    /// Emit the `ContinuationStarted` harness event for a forced-continuation
445    /// assessment. Centralizes the (reason-only) event shape used by both the
446    /// pre- and post-verification continuation paths.
447    fn emit_continuation_started(&self, event_recorder: &mut ExecEventRecorder, reason: String) {
448        event_recorder.harness_event(HarnessEventKind::ContinuationStarted, Some(reason), None, None, None, None, None);
449    }
450
451    /// Emit `VerificationFailed` (for the first failing command) or
452    /// `VerificationPassed` based on the verification run. Reuses
453    /// [`super::continuation::build_verification_failure_payload`] so the failure
454    /// headline stays in sync with the continuation prompt builder.
455    fn emit_verification_outcome(
456        &self,
457        event_recorder: &mut ExecEventRecorder,
458        commands: &[String],
459        results: &[VerificationResult],
460    ) {
461        if let Some(failure) = results.iter().find(|result| !result.success) {
462            event_recorder.harness_event(
463                HarnessEventKind::VerificationFailed,
464                Some(super::continuation::build_verification_failure_payload(failure)),
465                Some(failure.command.clone()),
466                None,
467                failure.exit_code,
468                None,
469                None,
470            );
471        } else if !commands.is_empty()
472            && results.len() == commands.len()
473            && results.iter().all(|result| result.exit_code == Some(0))
474        {
475            event_recorder.harness_event(
476                HarnessEventKind::VerificationPassed,
477                Some(format!("Verification passed: {}", commands.join(", "))),
478                commands.last().cloned(),
479                None,
480                Some(0),
481                None,
482                None,
483            );
484        }
485    }
486
487    /// Execute a task with this agent
488    pub async fn execute_task(&mut self, task: &Task, contexts: &[ContextItem]) -> Result<TaskResults> {
489        let _matrix_cleanup = MatrixExecutionCleanup(self.tool_registry.subagent_controller());
490        self.tool_registry.begin_tracker_request(false);
491        self.tool_registry.begin_patch_recovery_turn();
492        // Phase 1: Setup — harness alignment, conversation building, session init,
493        // orchestration planning. Extracted to `prepare_task_execution` for testability.
494        let setup = self.prepare_task_execution(task, contexts).await?;
495
496        let agent_prefix = setup.agent_prefix;
497        let session_store_handle = setup.session_store_handle;
498        let mut event_recorder = setup.event_recorder;
499        let run_started_at = setup.run_started_at;
500        let is_simple_task = setup.is_simple_task;
501        let mut prompt_bundle = setup.prompt_bundle;
502        let preserve_recent_turns = setup.preserve_recent_turns;
503        let max_tool_loops = setup.max_tool_loops;
504        let max_context_tokens = setup.max_context_tokens;
505        let mut runtime = setup.runtime;
506        self.tool_registry
507            .restore_completion_verification_from_history(&runtime.state.messages);
508        // Normalize usage from the provider actually serving this session;
509        // configuration may contain an alias (or be inferred from the model).
510        runtime.state.stats.provider_name = self.provider_client.name().to_string();
511        let mut continuation_controller = setup.continuation_controller;
512        let effective_task = setup.effective_task;
513        let orchestration_enabled = setup.orchestration_enabled;
514        let mut budget_warning_emitted = false;
515        let mut session_costs = crate::llm::usage_cost::SessionCostAccumulator::default();
516        let max_budget_usd = setup.max_budget_usd;
517        let max_revision_rounds = setup.max_revision_rounds;
518        let mut revision_rounds_used = 0usize;
519        let mut should_write_blocked_handoff = false;
520        let mut planning_synthesis_required = false;
521
522        // Keep fallible execution inside its own return boundary so every error
523        // reaches terminal event recording and canonical persistence cleanup.
524        let result: Result<_> = async {
525            for turn in 0..self.max_turns {
526                if matches!(runtime.poll_turn_control().await, RuntimeControl::StopRequested) {
527                    self.runner_println(format_args!(
528                        "{} {}",
529                        agent_prefix,
530                        style("Stopped by steering signal.").red().bold()
531                    ));
532                    runtime.state.outcome = TaskOutcome::Cancelled;
533                    break;
534                }
535
536                if let Some(input) = runtime.run_until_idle() {
537                    self.runner_println(format_args!(
538                        "{} {}: {}",
539                        agent_prefix,
540                        style("Follow-up Input").cyan().bold(),
541                        input
542                    ));
543                }
544
545                if runtime.state.is_completed {
546                    break;
547                }
548
549                self.runner_println(format_args!(
550                    "{} {} is processing turn {}...",
551                    agent_prefix,
552                    style("(PROC)").cyan().bold(),
553                    turn + 1
554                ));
555
556                let turn_model = self.get_selected_model();
557                self.refresh_runtime_prompt_bundle_if_catalog_changed(&mut prompt_bundle, is_simple_task)
558                    .await?;
559                let provider_name = self.provider_client.name().to_string();
560                if let Err(error) =
561                    crate::llm::usage_cost::require_budget_pricing(&provider_name, &turn_model, max_budget_usd)
562                {
563                    let message = error.to_string();
564                    event_recorder.turn_blocked(vtcode_exec_events::TurnBlockedEvent {
565                        completed_at: None,
566                        message: message.clone(),
567                        last_tool: None,
568                        blocked_streak: 0,
569                        blocked_total: 0,
570                        consecutive_cap: 0,
571                        total_cap: 0,
572                        recovery_active: false,
573                        usage: Some(runtime.state.stats.total_usage.clone()),
574                    });
575                    should_write_blocked_handoff = true;
576                    runtime.state.outcome = TaskOutcome::failed(
577                        message,
578                        Vec::new(),
579                        Some(
580                            "Choose a model route with complete pricing or remove the USD budget explicitly"
581                                .to_string(),
582                        ),
583                        None,
584                    );
585                    break;
586                }
587
588                if std::env::var_os("VTCODE_DEBUG_PROVIDER").is_some() {
589                    tracing::debug!(
590                        provider_client = self.provider_client.name(),
591                        turn_model,
592                        "Provider debug turn selection"
593                    );
594                }
595                let sampling_overrides = self.provider_client.sampling_overrides(&turn_model);
596                let turn_reasoning = if is_simple_task
597                    && self
598                        .provider_client
599                        .supported_reasoning_efforts(&turn_model)
600                        .contains(&ReasoningEffortLevel::Minimal.as_str())
601                {
602                    Some(ReasoningEffortLevel::Minimal)
603                } else {
604                    sampling_overrides.reasoning_effort.or(self.reasoning_effort)
605                };
606                let turn_verbosity = if is_simple_task {
607                    Some(VerbosityLevel::Low)
608                } else {
609                    self.verbosity
610                };
611                let max_tokens = sampling_overrides
612                    .max_tokens
613                    .or(Some(if self.tool_registry.is_planning_active() {
614                        4096
615                    } else if is_simple_task {
616                        800
617                    } else {
618                        2000
619                    }));
620
621                let provider_kind = turn_model
622                    .parse::<ModelId>()
623                    .map(|model| model.provider())
624                    .unwrap_or(ModelProvider::Gemini);
625
626                if matches!(provider_kind, ModelProvider::Gemini)
627                    && runtime.state.conversation.len() > runtime.state.last_processed_message_idx
628                {
629                    // Collect new messages first to avoid borrow conflict:
630                    // conversation is immutably borrowed in the loop, but
631                    // adjust_token_count/push need &mut state.
632                    let new_messages: Vec<Message> = runtime.state.conversation
633                        [runtime.state.last_processed_message_idx..]
634                        .iter()
635                        .map(|content| {
636                            let mut text = String::new();
637                            for part in &content.parts {
638                                if let Part::Text { text: part_text, .. } = part {
639                                    if !text.is_empty() {
640                                        text.push('\n');
641                                    }
642                                    text.push_str(part_text);
643                                }
644                            }
645                            match content.role.as_str() {
646                                "model" => Message::assistant(text),
647                                _ => Message::user(text),
648                            }
649                        })
650                        .collect();
651                    let batch_tokens: usize = new_messages.iter().map(|m| m.estimate_tokens()).sum();
652                    runtime.state.adjust_token_count(batch_tokens as isize);
653                    runtime.state.messages_mut().extend(new_messages);
654                    runtime.state.last_processed_message_idx = runtime.state.conversation.len();
655                }
656
657                let planning_synthesis_turn = self.tool_registry.is_planning_active()
658                    && (planning_synthesis_required || turn + 1 == self.max_turns);
659                if planning_synthesis_turn {
660                    runtime.state.add_user_message(
661                        "This is the final planning turn within the configured budget. Return a concise, complete plan now using only evidence already collected. Fit the entire plan within the response token limit; shorten detail rather than repeating a truncated draft. Make no tool calls. State missing evidence explicitly; do not claim implementation or verification.".to_string(),
662                    );
663                }
664                let context_budget = crate::compaction::effective_context_budget(
665                    Some(self.config()),
666                    self.provider_client.as_ref(),
667                    &turn_model,
668                );
669                runtime.state.constraints.max_context_tokens = context_budget;
670                let reserved_output_tokens = max_tokens
671                    .map_or(crate::compaction::memory_envelope::DEFAULT_OUTPUT_RESERVE_TOKENS, |value| value as usize);
672                let prompt_overhead_tokens = (prompt_bundle.system_prompt_report.token_estimate as usize)
673                    .saturating_add(prompt_bundle.tool_def_tokens as usize);
674                tracing::info!(
675                    turn, model = %turn_model, context_budget,
676                    reserved_output_tokens, prompt_overhead_tokens,
677                    "Resolved per-turn context budget denominator"
678                );
679                let mut normalized_messages = self.project_request_history(
680                    &runtime.state,
681                    &turn_model,
682                    prompt_overhead_tokens,
683                    reserved_output_tokens,
684                );
685                let projected_token_usage = normalized_messages
686                    .iter()
687                    .map(Message::estimate_tokens)
688                    .fold(0usize, usize::saturating_add)
689                    .saturating_add(prompt_overhead_tokens);
690                let compacted = self
691                    .maybe_auto_compact(
692                        &mut runtime.state,
693                        &mut event_recorder,
694                        &turn_model,
695                        preserve_recent_turns,
696                        projected_token_usage,
697                        reserved_output_tokens,
698                        &mut prompt_bundle.request_envelope,
699                    )
700                    .await;
701                let estimated = if compacted {
702                    normalized_messages = self.project_request_history(
703                        &runtime.state,
704                        &turn_model,
705                        prompt_overhead_tokens,
706                        reserved_output_tokens,
707                    );
708                    normalized_messages
709                        .iter()
710                        .map(Message::estimate_tokens)
711                        .fold(0usize, usize::saturating_add)
712                        .saturating_add(prompt_overhead_tokens)
713                } else {
714                    projected_token_usage
715                };
716                let budget = context_budget.saturating_sub(reserved_output_tokens);
717                let fits = context_budget == 0 || estimated <= budget;
718                if !fits {
719                    // Advisory only: the estimate can overshoot (un-droppable
720                    // messages, suppressed compaction), and the provider owns
721                    // the authoritative context limit. Aborting here turned a
722                    // recoverable situation into a dead `exec`/auto run.
723                    tracing::warn!(
724                        estimated,
725                        budget,
726                        "Pre-flight token check failed: prompt exceeds context budget after compaction"
727                    );
728                    #[allow(
729                        clippy::cast_sign_loss,
730                        reason = "Intentional compatibility, platform, or test-only suppression."
731                    )]
732                    let pct = (estimated as f64 / budget.max(1) as f64 * 100.0) as u32;
733                    runtime
734                        .state
735                        .warnings
736                        .push(format!("Pre-flight check: {pct}% of context budget used before LLM call"));
737                }
738
739                let parallel_tool_config = if self.provider_client.supports_parallel_tool_config(&turn_model) {
740                    Some(Box::new(crate::llm::provider::ParallelToolConfig::anthropic_optimized()))
741                } else {
742                    None
743                };
744
745                let reasoning_mapping = turn_reasoning
746                    .map(|requested| {
747                        crate::llm::reasoning_effort::ReasoningEffortMapper::resolve(
748                            self.provider_client.as_ref(),
749                            &turn_model,
750                            requested,
751                            self.config().agent.allow_reasoning_effort_downgrade,
752                        )
753                    })
754                    .transpose();
755                let reasoning_effort = match reasoning_mapping {
756                    Ok(mapping) => mapping,
757                    Err(error) => {
758                        let message = error.to_string();
759                        event_recorder.turn_blocked(vtcode_exec_events::TurnBlockedEvent {
760                            completed_at: None,
761                            message: message.clone(),
762                            last_tool: None,
763                            blocked_streak: 0,
764                            blocked_total: 0,
765                            consecutive_cap: 0,
766                            total_cap: 0,
767                            recovery_active: false,
768                            usage: Some(runtime.state.stats.total_usage.clone()),
769                        });
770                        should_write_blocked_handoff = true;
771                        runtime.state.outcome = TaskOutcome::failed(
772                            message,
773                            Vec::new(),
774                            Some("Choose a supported reasoning effort or explicitly enable downgrade".to_string()),
775                            None,
776                        );
777                        break;
778                    }
779                }
780                .map(|mapping| {
781                    if mapping.degraded() {
782                        tracing::warn!(requested = %mapping.requested, effective = %mapping.effective,
783                            model = %turn_model, "Harness reasoning effort explicitly downgraded");
784                    }
785                    mapping.effective
786                })
787                .filter(|effort| *effort != ReasoningEffortLevel::None);
788                let reasoning_active = reasoning_effort.is_some_and(|effort| {
789                    !matches!(effort, ReasoningEffortLevel::None | ReasoningEffortLevel::Unknown)
790                });
791                let capability_prefix_hash = crate::core::agent::hash_utils::PromptCapabilityIdentity::resolve(
792                    self.provider_client.as_ref(),
793                    &turn_model,
794                    reasoning_effort,
795                    prompt_bundle.tool_snapshot.epoch,
796                )
797                .prefix_hash(prompt_bundle.request_envelope.prefix_hash());
798
799                // Reasoning-effort-change advisory (Phase E4): a mid-task
800                // change to the reasoning effort alters the request prefix,
801                // which invalidates the provider prompt cache for the next
802                // request.
803                if runtime.state.note_reasoning_effort_change(reasoning_effort) {
804                    let message = "Reasoning effort changed mid-task; provider prompt cache \
805                                    will be invalidated and the next request re-pays full \
806                                    input cost."
807                        .to_string();
808                    tracing::warn!("{message}");
809                    runtime.state.push_warning(message);
810                }
811
812                // Model-change advisory: prompt caches are unique per model,
813                // so a mid-task switch rebuilds the cache at full input cost
814                // even when the rest of the prefix is unchanged. Prefer
815                // resolving the model up front; when a switch is unavoidable,
816                // expect one full-price request before hits resume.
817                if runtime.state.note_model_change(&turn_model) {
818                    let message = "Model changed mid-task; provider prompt cache \
819                                    will be invalidated and the next request re-pays full \
820                                    input cost."
821                        .to_string();
822                    tracing::warn!("{message}");
823                    runtime.state.push_warning(message);
824                }
825
826                let anthropic_shaped = matches!(provider_kind, ModelProvider::Anthropic | ModelProvider::Minimax);
827                let temperature = if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
828                    None
829                } else {
830                    Some(sampling_overrides.temperature.unwrap_or(self.config().agent.temperature))
831                };
832                let mut top_p_override = sampling_overrides.top_p;
833                let mut top_k_override = sampling_overrides.top_k;
834                if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
835                    // Keep the payload inside what our Anthropic reasoning
836                    // validator accepts: `validate_reasoning_constraints`
837                    // rejects any top_k and requires top_p in [0.95, 1.0]
838                    // while extended thinking is active.
839                    top_k_override = None;
840                    top_p_override = top_p_override.filter(|value| *value >= 0.95);
841                }
842
843                self.tool_registry
844                    .set_inspection_mode(self.config().agent.harness.inspection.mode);
845                self.tool_registry.reconcile_source_inspection(&normalized_messages);
846                let (request_messages, previous_response_id) = prepare_responses_request_messages(
847                    &mut runtime.state.previous_response_chains,
848                    &provider_name,
849                    self.provider_client.supports_responses_compaction(&turn_model),
850                    &turn_model,
851                    &normalized_messages,
852                );
853                let request_messages = match request_messages {
854                    std::borrow::Cow::Borrowed(_) => Arc::clone(&normalized_messages),
855                    std::borrow::Cow::Owned(messages) => Arc::new(messages),
856                };
857                let request = build_harness_request_plan(HarnessRequestPlanInput {
858                    messages: request_messages,
859                    system_prompt: prompt_bundle.request_envelope.system_prompt(),
860                    tools: (!planning_synthesis_turn && !prompt_bundle.request_envelope.ordered_tools().is_empty())
861                        .then(|| prompt_bundle.request_envelope.ordered_tools()),
862                    model: turn_model.clone(),
863                    max_tokens,
864                    temperature,
865                    top_p: top_p_override,
866                    top_k: top_k_override,
867                    presence_penalty: if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
868                        None
869                    } else {
870                        sampling_overrides.presence_penalty
871                    },
872                    frequency_penalty: if sampling_overrides.suppresses_sampling(anthropic_shaped, reasoning_active) {
873                        None
874                    } else {
875                        sampling_overrides.frequency_penalty
876                    },
877                    stream: self.provider_client.supports_streaming(),
878                    tool_choice: (!planning_synthesis_turn
879                        && provider_name.eq_ignore_ascii_case("openai")
880                        && !prompt_bundle.tool_snapshot.active_tool_names.is_empty())
881                    .then(|| {
882                        ToolChoice::allowed_tools_auto(prompt_bundle.tool_snapshot.active_tool_names.as_ref().clone())
883                    }),
884                    parallel_tool_config,
885                    reasoning_effort,
886                    verbosity: turn_verbosity,
887                    metadata: None,
888                    context_management: None,
889                    previous_response_id,
890                    // Keep the wire key stable per session. OpenAI routes by
891                    // (prefix hash + key); the key must stay consistent across
892                    // requests sharing a prefix ("Use prompt_cache_key
893                    // consistently"). Per-turn suffixes (capability/catalog
894                    // hashes) fragment routing buckets without benefit: distinct
895                    // prefixes already route distinctly via their prefix hash,
896                    // and a suffix that changes on tool-catalog churn busts an
897                    // otherwise reusable routing affinity every few turns.
898                    // Prefix identity stays tracked per request via
899                    // `tool_catalog_hash` / `system_prompt_prefix_hash` below.
900                    // OpenRouter/xAI receive namespaced session lineage for
901                    // documented sticky routing even when the OpenAI cache
902                    // block is off.
903                    prompt_cache_key: build_session_affinity_prompt_cache_key(
904                        &provider_name,
905                        session_affinity_key_enabled(
906                            &provider_name,
907                            self.config().prompt_cache.enabled,
908                            self.config().prompt_cache.providers.openai.enabled,
909                        ),
910                        &self.config().prompt_cache.providers.openai.prompt_cache_key_mode,
911                        Some(&self.session_id),
912                    ),
913                    prompt_cache_profile: None,
914                    tool_catalog_hash: prompt_bundle.request_envelope.catalog_hash(),
915                    system_prompt_prefix_hash: Some(capability_prefix_hash),
916                })
917                .request;
918                // Cheap pre-flight: catch malformed requests (empty system
919                // prompt, no messages, duplicate tool names, missing required
920                // properties) before paying for an API round-trip.
921                self.validate_llm_request(&request)?;
922                let previous_response_chain_present = request.previous_response_id.is_some();
923                // O(1) Arc bump: keeps the sent history available for
924                // set_previous_response_chain while the request still holds it
925                // for providers that validate messages (e.g. MiMo) during
926                // stream().
927                let sent_messages = Arc::clone(&request.messages);
928                // Compute timeout before the call to avoid simultaneous mutable/immutable
929                // borrows of `self` (provider_client vs config).
930                let streaming_timeout = self
931                    .config()
932                    .timeouts
933                    .ceiling_duration(self.config().timeouts.streaming_ceiling_seconds);
934
935                // Cache-gap advisory (Phase E1): warn once per gap when the
936                // provider prompt cache has likely expired since the last
937                // request, so this request may unexpectedly re-pay full
938                // input cost.
939                if let Some(threshold) = self
940                    .config()
941                    .prompt_cache
942                    .gap_threshold_secs_for_model(&provider_name, &turn_model)
943                {
944                    let threshold = std::time::Duration::from_secs(threshold);
945                    if runtime.state.stats.total_usage.cached_input_tokens > 0
946                        && let Some(elapsed) = runtime.state.cache_gap_exceeds(threshold)
947                    {
948                        let gap = crate::llm::request_gap::format_gap(elapsed);
949                        let message = format!(
950                            "~{gap} since the last request; the provider prompt cache may have expired, so this request may rebuild cached input."
951                        );
952                        tracing::warn!("{message}");
953                        runtime.state.push_warning(message);
954                    }
955                }
956                runtime.state.note_request_sent();
957
958                let turn_output = runtime
959                    .run_turn_once(&mut self.provider_client, request, streaming_timeout)
960                    .await?;
961                super::tool_dispatch_common::drain_and_record_runtime_events(&mut runtime, &mut event_recorder);
962                let response = turn_output.response;
963                runtime.state.stop_reason = Some(stop_reason_from_finish_reason(&response.finish_reason));
964                let refused = refusal::is_refusal(&response);
965
966                // --- Progress stagnation detection ---
967                // If the assistant produces near-identical responses across consecutive
968                // turns (no tool calls, no progress), inject a nudge to break the loop.
969                if !refused && !runtime.state.is_completed && runtime.state.record_progress_hash_and_check_stagnation()
970                {
971                    let nudge = "It looks like you're repeating the same response. \
972                                 If you're stuck, try a different approach: break the \
973                                 problem into smaller steps, use different tools, or \
974                                 declare the task complete if you have enough information.";
975                    self.runner_println(format_args!(
976                        "{} {}",
977                        agent_prefix,
978                        style("[STAGNATION WARNING]").yellow().bold(),
979                    ));
980                    runtime.state.add_user_message(nudge.into());
981                }
982                if !refused
983                    && supports_responses_chaining(
984                        &provider_name,
985                        self.provider_client.supports_responses_compaction(&turn_model),
986                    )
987                {
988                    runtime.state.set_previous_response_chain_shared(
989                        &provider_name,
990                        &turn_model,
991                        response.request_id.as_deref(),
992                        Arc::clone(&sent_messages),
993                    );
994                }
995                let turn_cost = response.usage.as_ref().and_then(|usage| {
996                    let normalized = crate::llm::usage_cost::normalized_turn_usage(&provider_name, usage);
997                    crate::llm::usage_cost::estimate_session_costs(&provider_name, &turn_model, &normalized)
998                });
999                match session_costs.record(turn_cost) {
1000                    Some(estimate) => {
1001                        runtime.state.total_cost_usd = Some(estimate.effective_usd);
1002                        let threshold = self.config().agent.harness.budget_warning_threshold;
1003                        match crate::llm::usage_cost::BudgetStatus::classify(
1004                            estimate.raw_usd,
1005                            max_budget_usd,
1006                            threshold,
1007                        ) {
1008                            crate::llm::usage_cost::BudgetStatus::Exceeded { max, .. } => {
1009                                runtime.state.outcome = TaskOutcome::budget_limit_reached(max, estimate.raw_usd);
1010                                break;
1011                            }
1012                            crate::llm::usage_cost::BudgetStatus::Warning { max, .. } if !budget_warning_emitted => {
1013                                budget_warning_emitted = true;
1014                                warn!(
1015                                    provider = %self.config().agent.provider,
1016                                    model = %turn_model,
1017                                    cost_usd = estimate.raw_usd,
1018                                    max_budget_usd = max,
1019                                    "Session cost approaching budget limit"
1020                                );
1021                                runtime.state.push_warning(format!(
1022                                    "Session cost ${:.4} has reached {:.0}% of the ${max:.2} budget. {}",
1023                                    estimate.raw_usd,
1024                                    threshold * 100.0,
1025                                    runtime.state.stats.total_usage.cache_summary()
1026                                ));
1027                            }
1028                            _ => {}
1029                        }
1030                    }
1031                    None => {
1032                        runtime.state.total_cost_usd = None;
1033                        if max_budget_usd.is_some() {
1034                            runtime.state.outcome = TaskOutcome::failed(
1035                                format!(
1036                                    "Pricing or usage became unavailable for `{provider_name}/{turn_model}`; budget enforcement stopped execution"
1037                                ),
1038                                Vec::new(),
1039                                Some("Select a model route with complete pricing".to_string()),
1040                                None,
1041                            );
1042                            should_write_blocked_handoff = true;
1043                            break;
1044                        }
1045                    }
1046                }
1047                // A refusal is terminal for this prompt: idle recovery would
1048                // resend it and be refused again, and any partial output or
1049                // tool calls were cut off by the provider, so neither is
1050                // committed as an answer nor executed.
1051                if refused {
1052                    let reason = refusal::refusal_reason(&response);
1053                    discard_refused_assistant_message(&mut runtime.state);
1054                    // The refused response is not part of the kept history, so
1055                    // a stored continuation id would point past it.
1056                    runtime.state.clear_previous_response_chain_for(&provider_name, &turn_model);
1057                    self.runner_println(format_args!(
1058                        "{} {} {}",
1059                        agent_prefix,
1060                        style("(REFUSED)").red().bold(),
1061                        reason
1062                    ));
1063                    runtime.state.outcome = TaskOutcome::refused(reason);
1064                    break;
1065                }
1066
1067                self.runner_println(format_args!(
1068                    "{} {} {} received response, processing...",
1069                    agent_prefix,
1070                    self.agent_type,
1071                    style("(RECV)").green().bold()
1072                ));
1073
1074                self.warn_on_empty_response(
1075                    &agent_prefix,
1076                    response.content.as_deref().unwrap_or(""),
1077                    response.tool_calls.as_ref().is_some_and(|tool_calls| !tool_calls.is_empty()),
1078                );
1079
1080                let response_text = response.content_string();
1081                if !response_text.trim().is_empty() {
1082                    self.emit_final_assistant_message(&self.agent_type, &response_text);
1083                }
1084
1085                let mut effective_tool_calls = response.tool_calls.clone();
1086                let mut forced_continuation = false;
1087
1088                if effective_tool_calls.is_none()
1089                    && response.content_text().len() < 150
1090                    && let Some(args_value) = detect_textual_exec_tool_call(response.content_text())
1091                {
1092                    effective_tool_calls = Some(vec![ToolCall::function(
1093                        format!("call_text_{turn}"),
1094                        tools::EXEC_COMMAND.to_string(),
1095                        args_value.to_string(),
1096                    )]);
1097                }
1098
1099                // A provider may ignore an empty tool catalog or emit textual
1100                // tool markup. The reserved synthesis turn cannot dispatch it.
1101                if planning_synthesis_turn && effective_tool_calls.as_ref().is_some_and(|calls| !calls.is_empty()) {
1102                    runtime.state.outcome = TaskOutcome::turn_limit_reached(self.max_turns, turn + 1);
1103                    break;
1104                }
1105                if self.tool_registry.is_planning_active()
1106                    && matches!(response.finish_reason, crate::llm::provider::FinishReason::Length)
1107                    && effective_tool_calls.as_ref().is_none_or(|calls| calls.is_empty())
1108                {
1109                    if planning_synthesis_turn {
1110                        runtime.state.outcome = TaskOutcome::failed(
1111                            "Planning response exceeded the output token limit during final synthesis".to_string(),
1112                            vec![],
1113                            None,
1114                            None,
1115                        );
1116                        break;
1117                    }
1118                    // Rewrite once within the existing turn/output budgets.
1119                    // Never accept completion keywords from a truncated plan.
1120                    planning_synthesis_required = true;
1121                    continue;
1122                }
1123
1124                let is_gemini = matches!(provider_kind, ModelProvider::Gemini);
1125
1126                // --- Confidence-based escalation gate + chain ---
1127                // Evaluate tool calls before dispatching them.  Implements the
1128                // escalation chain: re-plan → prompt user → abort with partial
1129                // results.  (Steps 1–2 already exist at the tool-exec level:
1130                // auto-retry and alternative-tool fallback are handled in
1131                // tool_exec.rs and execution_facade.rs.)
1132                #[allow(
1133                    unused_assignments,
1134                    reason = "Intentional compatibility, platform, or test-only suppression."
1135                )] // compiler keeps flags but this is clear
1136                if self.config().agent.harness.confidence_escalation.enabled
1137                    && !runtime.state.is_completed
1138                    && let Some(tool_calls) = effective_tool_calls.as_ref()
1139                    && !tool_calls.is_empty()
1140                {
1141                    let escalation_config = &self.config().agent.harness.confidence_escalation;
1142                    let orchestration_mode = if orchestration_enabled {
1143                        "plan_build_evaluate"
1144                    } else {
1145                        "single"
1146                    };
1147
1148                    // Reuse the per-turn cost estimate from the budget check above.
1149                    let cost_estimate = runtime.state.total_cost_usd;
1150
1151                    let result = EscalationGate::decide(
1152                        tool_calls,
1153                        escalation_config,
1154                        &runtime.state.error_recovery.lock(),
1155                        cost_estimate,
1156                        orchestration_mode,
1157                    );
1158
1159                    if result.any_escalated {
1160                        let reasons: Vec<String> = result
1161                            .decisions
1162                            .iter()
1163                            .filter_map(|d| match d {
1164                                EscalationDecision::Escalate { reason, .. } => Some(reason.clone()),
1165                                EscalationDecision::Proceed => None,
1166                            })
1167                            .collect();
1168                        let summary = reasons.join("; ");
1169
1170                        self.runner_println(format_args!(
1171                            "{} {}: {}",
1172                            agent_prefix,
1173                            style("[ESCALATION REQUIRED]").red().bold(),
1174                            summary
1175                        ));
1176
1177                        event_recorder.harness_event(
1178                            HarnessEventKind::EscalationTriggered,
1179                            Some(summary.clone()),
1180                            None,
1181                            None,
1182                            None,
1183                            None,
1184                            None,
1185                        );
1186
1187                        let esc_count = runtime.state.consecutive_escalations;
1188
1189                        // --- Step 3: Re-plan via conversation injection ---
1190                        if esc_count < escalation_config.max_replan_attempts {
1191                            runtime.state.consecutive_escalations += 1;
1192                            let replan_msg = format!(
1193                                "The following tool calls were blocked by the safety \
1194                                 escalation gate:\n\n{summary}\n\n\
1195                                 Please try a different approach. You can use alternative \
1196                                 tools, break the task into smaller steps, or provide a \
1197                                 direct answer based on what you have already learned."
1198                            );
1199                            runtime.state.add_user_message(replan_msg);
1200                            // Skip dispatching blocked tool calls — let the LLM re-plan
1201                            effective_tool_calls = None;
1202                            forced_continuation = true;
1203                        }
1204                        // --- Step 4: Prompt user for guidance ---
1205                        else if esc_count < escalation_config.max_total_escalations
1206                            && escalation_config.prompt_user_on_exhaust
1207                        {
1208                            runtime.state.consecutive_escalations += 1;
1209                            let user_msg = format!(
1210                                "I've tried multiple approaches but the safety escalation \
1211                                 gate keeps blocking my tool calls:\n\n{summary}\n\n\
1212                                 Could you provide guidance on how to proceed? \
1213                                 What approach should I use instead?"
1214                            );
1215                            runtime.state.add_user_message(user_msg);
1216                            should_write_blocked_handoff = true;
1217                            runtime.state.outcome = TaskOutcome::escalated(summary, "multi_tool".to_string());
1218                            break;
1219                        }
1220                        // --- Step 5: Abort with partial results ---
1221                        else {
1222                            runtime.state.consecutive_escalations += 1;
1223                            should_write_blocked_handoff = true;
1224                            runtime.state.outcome = TaskOutcome::failed(
1225                                format!("Escalation chain exhausted: {summary}"),
1226                                Vec::new(),
1227                                Some(
1228                                    "The safety escalation gate repeatedly blocked tool calls. \
1229                                     Consider disabling the gate or adjusting the confidence \
1230                                     threshold if this action should be permitted."
1231                                        .into(),
1232                                ),
1233                                None,
1234                            );
1235                            break;
1236                        }
1237                    } else {
1238                        // Tool calls passed escalation gate — reset chain counter
1239                        runtime.state.consecutive_escalations = 0;
1240
1241                        event_recorder.harness_event(
1242                            HarnessEventKind::EscalationBypassed,
1243                            Some("All tool calls passed escalation gate".into()),
1244                            None,
1245                            None,
1246                            None,
1247                            None,
1248                            None,
1249                        );
1250                    }
1251                }
1252
1253                if self
1254                    .active_primary_agent
1255                    .as_ref()
1256                    .is_some_and(|agent| agent.identity.name == "coordinator")
1257                    && effective_tool_calls.as_ref().is_none_or(|calls| calls.is_empty())
1258                    && let Some(controller) = self.tool_registry.subagent_controller()
1259                    && let Some(snapshot) = controller.matrix_snapshot().await
1260                {
1261                    use crate::exec::events::matrix::MatrixLifecycle;
1262                    match snapshot.lifecycle {
1263                        MatrixLifecycle::Running | MatrixLifecycle::Verifying => {
1264                            let settled = controller
1265                                .wait_matrix_idle()
1266                                .await
1267                                .context("matrix disappeared before completion")?;
1268                            anyhow::ensure!(
1269                                !matches!(settled.lifecycle, MatrixLifecycle::Running | MatrixLifecycle::Verifying),
1270                                "matrix stopped without a durable result; reconcile owned cleanup before continuation"
1271                            );
1272                            runtime.state.is_completed = false;
1273                            runtime.state.add_user_message(format!(
1274                                "Durable matrix results: {}. Report outcomes from this snapshot; matrix success requires Succeeded. Other states require a coordinator decision.",
1275                                serde_json::to_string(&settled)?
1276                            ));
1277                            forced_continuation = true;
1278                        }
1279                        MatrixLifecycle::Succeeded => {
1280                            runtime.state.is_completed = true;
1281                            runtime.state.outcome = TaskOutcome::Success;
1282                            break;
1283                        }
1284                        MatrixLifecycle::Cancelled => {
1285                            runtime.state.outcome = TaskOutcome::Cancelled;
1286                            break;
1287                        }
1288                        MatrixLifecycle::Blocked => {
1289                            runtime.state.outcome = TaskOutcome::Failed {
1290                                reason: "matrix requires a coordinator decision or confirmed owned cleanup".into(),
1291                                accomplished: vec![],
1292                                recovery_suggestion: Some(format!(
1293                                    "Resume session and inspect matrix {} status",
1294                                    snapshot.spec.id
1295                                )),
1296                                checkpoint_path: None,
1297                            };
1298                            should_write_blocked_handoff = true;
1299                            break;
1300                        }
1301                        MatrixLifecycle::Created | MatrixLifecycle::Paused => {}
1302                    }
1303                }
1304
1305                if !forced_continuation
1306                    && !runtime.state.is_completed
1307                    && effective_tool_calls.as_ref().is_none_or(|tool_calls| tool_calls.is_empty())
1308                    && !response.content_text().trim().is_empty()
1309                {
1310                    if check_for_response_loop(response.content_text(), &mut runtime.state) {
1311                        self.runner_println(format_args!(
1312                            "[{}] {}",
1313                            self.agent_type,
1314                            style("Repetitive assistant response detected. Breaking potential loop.")
1315                                .red()
1316                                .bold()
1317                        ));
1318                        runtime.state.outcome = TaskOutcome::LoopDetected;
1319                        break;
1320                    }
1321
1322                    if (self.tool_registry.is_planning_active()
1323                        && matches!(response.finish_reason, crate::llm::provider::FinishReason::Stop))
1324                        || check_completion_candidate(response.content_text())
1325                        || self.tool_registry.completion_verification_pending()
1326                    {
1327                        self.runner_println(format_args!(
1328                            "[{}] {}",
1329                            self.agent_type,
1330                            style("Completion candidate detected; checking tracker and verification state.")
1331                                .green()
1332                                .bold()
1333                        ));
1334                        let mut assessment = continuation_controller
1335                            .assess_completion(
1336                                &effective_task,
1337                                &runtime.state,
1338                                self.tool_registry.tracker_adopted_for_request(),
1339                            )
1340                            .await?;
1341
1342                        if self.tool_registry.completion_verification_pending()
1343                            && !matches!(assessment, CompletionAssessment::Continue { .. })
1344                        {
1345                            let mut commands = match &assessment {
1346                                CompletionAssessment::Verify { commands } => commands.clone(),
1347                                _ => Vec::new(),
1348                            };
1349                            if commands.is_empty() {
1350                                commands = self.tool_registry.missing_required_completion_verifiers();
1351                            }
1352                            if commands.is_empty() {
1353                                let override_command =
1354                                    self.config().agent.harness.verification.default_verifier_override.as_deref();
1355                                commands = override_command
1356                                    .filter(|command| {
1357                                        crate::tools::tool_intent::classify_shell_activity(
1358                                            tools::EXEC_COMMAND,
1359                                            &json!({"cmd":command}),
1360                                        ) == crate::tools::tool_intent::ShellActivity::Verification
1361                                    })
1362                                    .map(str::to_owned)
1363                                    .or_else(|| {
1364                                        crate::tools::tool_intent::default_verifier_for_workspace(&self._workspace)
1365                                    })
1366                                    .into_iter()
1367                                    .collect();
1368                            }
1369                            if commands.is_empty() {
1370                                runtime.state.outcome = TaskOutcome::failed(
1371                                    self.tool_registry.unverified_completion_reason(),
1372                                    vec![],
1373                                    None,
1374                                    None,
1375                                );
1376                                should_write_blocked_handoff = true;
1377                                break;
1378                            }
1379                            assessment = CompletionAssessment::Verify { commands };
1380                        }
1381
1382                        // Verify requires running verification commands before
1383                        // re-dispatching the after_verification result through the
1384                        // same helper. All other variants are resolved directly.
1385                        if let CompletionAssessment::Verify { commands } = &assessment {
1386                            event_recorder.harness_event(
1387                                HarnessEventKind::VerificationStarted,
1388                                Some(format!("Running verification: {}", commands.join(", "))),
1389                                commands.first().cloned(),
1390                                None,
1391                                None,
1392                                None,
1393                                None,
1394                            );
1395                            let verification_results = self
1396                                .run_verification_commands(commands, &mut event_recorder, &mut runtime)
1397                                .await?;
1398                            self.emit_verification_outcome(&mut event_recorder, commands, &verification_results);
1399                            if matches!(runtime.state.outcome, TaskOutcome::Cancelled) {
1400                                break;
1401                            }
1402
1403                            let post_verification =
1404                                continuation_controller.after_verification(&verification_results).await?;
1405                            match self
1406                                .resolve_completion_assessment(
1407                                    post_verification,
1408                                    &verification_results,
1409                                    &effective_task,
1410                                    &mut runtime,
1411                                    &mut event_recorder,
1412                                    orchestration_enabled,
1413                                    &mut revision_rounds_used,
1414                                    max_revision_rounds,
1415                                    &mut should_write_blocked_handoff,
1416                                )
1417                                .await?
1418                            {
1419                                AssessmentResolution::Break => break,
1420                                AssessmentResolution::ForceContinue => {
1421                                    forced_continuation = true;
1422                                }
1423                                // `after_verification` never yields `Verify`.
1424                                AssessmentResolution::VerifyNotHandled => {}
1425                            }
1426                        } else {
1427                            match self
1428                                .resolve_completion_assessment(
1429                                    assessment,
1430                                    &[],
1431                                    &effective_task,
1432                                    &mut runtime,
1433                                    &mut event_recorder,
1434                                    orchestration_enabled,
1435                                    &mut revision_rounds_used,
1436                                    max_revision_rounds,
1437                                    &mut should_write_blocked_handoff,
1438                                )
1439                                .await?
1440                            {
1441                                AssessmentResolution::Break => break,
1442                                AssessmentResolution::ForceContinue => {
1443                                    forced_continuation = true;
1444                                }
1445                                AssessmentResolution::VerifyNotHandled => {
1446                                    // Verify is handled in the if-branch above;
1447                                    // the helper only returns this for Verify.
1448                                    return Err(anyhow::anyhow!("unexpected VerifyNotHandled from assess_completion"));
1449                                }
1450                            }
1451                        }
1452                    } else {
1453                        // Tracker-aware status continuation: text-only responses
1454                        // that are not completion candidates still must not end
1455                        // the run while the task tracker has incomplete steps.
1456                        //
1457                        // Use a read-only tracker probe — never `assess_completion`
1458                        // — so status text cannot create/complete the internal
1459                        // scaffold or invent a pending verify step under
1460                        // ContinuationPolicy::All.
1461                        let tracker_auto_continue = self.config().agent.harness.continuation.auto_continue_tracker;
1462                        let idle_limit = self.config().agent.idle_turn_limit;
1463                        let idle_limit_hit = runtime.state.consecutive_idle_turns >= idle_limit;
1464                        let asks_user = response.content_text().contains('?');
1465                        if tracker_auto_continue && !idle_limit_hit && !asks_user {
1466                            let status_text = response.content_text();
1467                            let safety_handoff =
1468                                crate::core::agent::completion::tracker_final_text_is_safety_handoff(status_text);
1469                            if !safety_handoff {
1470                                let incomplete = continuation_controller
1471                                    .incomplete_tracker_labels(self.tool_registry.tracker_adopted_for_request())
1472                                    .await?;
1473                                if !incomplete.is_empty() {
1474                                    let joined = incomplete.join(", ");
1475                                    let reason = format!("Task tracker is incomplete: {joined}.");
1476                                    if super::continuation::tracker_status_force_continue_eligible(
1477                                        tracker_auto_continue,
1478                                        idle_limit_hit,
1479                                        asks_user,
1480                                        &reason,
1481                                    ) {
1482                                        let prompt = super::continuation::tracker_incomplete_continue_prompt(&joined);
1483                                        self.runner_println(format_args!(
1484                                            "[{}] {}: {}",
1485                                            self.agent_type,
1486                                            style("[TRACKER CONTINUE]").yellow().bold(),
1487                                            reason
1488                                        ));
1489                                        runtime.state.add_user_message(prompt);
1490                                        forced_continuation = true;
1491                                    }
1492                                }
1493                            }
1494                        }
1495                    }
1496                }
1497
1498                if let Some(tool_calls) = effective_tool_calls
1499                    .as_ref()
1500                    .filter(|tool_calls| !tool_calls.is_empty())
1501                    .cloned()
1502                {
1503                    self.execute_tool_call_batches(
1504                        tool_calls,
1505                        &mut runtime,
1506                        &mut event_recorder,
1507                        &agent_prefix,
1508                        is_gemini,
1509                        previous_response_chain_present,
1510                    )
1511                    .await?;
1512                    super::tool_dispatch_common::drain_and_record_runtime_events(&mut runtime, &mut event_recorder);
1513
1514                    if let Some(outcome) = self.tool_registry.matrix_worker_outcome() {
1515                        runtime.state.outcome = if outcome == crate::exec::events::matrix::MatrixOutcome::Success {
1516                            TaskOutcome::Success
1517                        } else {
1518                            TaskOutcome::failed(format!("Matrix worker reported {outcome:?}"), vec![], None, None)
1519                        };
1520                        runtime.state.is_completed = true;
1521                        break;
1522                    }
1523
1524                    if self
1525                        .active_primary_agent
1526                        .as_ref()
1527                        .is_some_and(|agent| agent.identity.name == "coordinator")
1528                        && let Some(controller) = self.tool_registry.subagent_controller()
1529                        && let Some(snapshot) = controller.matrix_snapshot().await
1530                        && (matches!(
1531                            snapshot.lifecycle,
1532                            crate::exec::events::matrix::MatrixLifecycle::Running
1533                                | crate::exec::events::matrix::MatrixLifecycle::Verifying
1534                        ) || controller.matrix_is_driving())
1535                    {
1536                        // Headless sessions await owned work before another model turn;
1537                        // status polling must not exhaust the coordinator's loop budget.
1538                        let settled = loop {
1539                            tokio::select! {
1540                                settled = controller.wait_matrix_idle() => break settled.context("matrix disappeared before completion")?,
1541                                () = tokio::time::sleep(std::time::Duration::from_millis(100)) => {
1542                                    if matches!(runtime.poll_turn_control().await, RuntimeControl::StopRequested) {
1543                                        controller.cancel_matrix().await?;
1544                                        runtime.state.outcome = TaskOutcome::Cancelled;
1545                                    }
1546                                }
1547                            }
1548                        };
1549                        anyhow::ensure!(
1550                            !matches!(
1551                                settled.lifecycle,
1552                                crate::exec::events::matrix::MatrixLifecycle::Running
1553                                    | crate::exec::events::matrix::MatrixLifecycle::Verifying
1554                            ),
1555                            "matrix stopped without a durable result; reconcile owned cleanup before continuation"
1556                        );
1557                        if matches!(runtime.state.outcome, TaskOutcome::Cancelled) {
1558                            break;
1559                        }
1560                        runtime.state.add_user_message(format!(
1561                            "Durable matrix results: {}. Report success only for Succeeded; other states require a coordinator decision.",
1562                            serde_json::to_string(&settled)?
1563                        ));
1564                    }
1565                }
1566
1567                // Refresh tool definitions if the catalog was mutated during tool
1568                // execution (e.g. tools.load / tools.unload / skill activation).
1569                if self
1570                    .refresh_runtime_prompt_bundle_if_catalog_changed(&mut prompt_bundle, is_simple_task)
1571                    .await?
1572                {
1573                    tracing::debug!("runtime prompt bundle refreshed after tool catalog mutation");
1574                }
1575
1576                // --- Emit tool latency events ---
1577                if !runtime.state.turn_tool_observations.is_empty() {
1578                    let observations = std::mem::take(&mut runtime.state.turn_tool_observations);
1579                    for observation in &observations {
1580                        event_recorder.record_tool_outcome(
1581                            &observation.tool_name,
1582                            observation.attempts,
1583                            observation.duration_ms,
1584                            observation.error_category.as_ref().map(vtcode_commons::ErrorCategory::as_str),
1585                        );
1586                    }
1587                }
1588
1589                let had_effective_shell_tool_call = effective_tool_calls.as_ref().is_some_and(|calls| {
1590                    calls.iter().any(|call| {
1591                        call.function.as_ref().map(|function| function.name.as_str()) == Some(tools::UNIFIED_EXEC)
1592                    })
1593                });
1594                let had_tool_call = response.tool_calls.as_ref().is_some_and(|tool_calls| !tool_calls.is_empty())
1595                    || had_effective_shell_tool_call;
1596
1597                if had_tool_call {
1598                    let loops = runtime.state.register_tool_loop();
1599                    if tool_loop_limit_reached(loops, runtime.state.constraints.max_tool_loops) {
1600                        let warning_message = format!(
1601                            "You have reached the tool-call iteration limit of {}. \
1602                             This typically means you are in a loop — repeatedly calling tools \
1603                             without making progress toward the task goal.\n\n\
1604                             To proceed:\n\
1605                             1. Review what you have already learned from previous tool outputs.\n\
1606                             2. Synthesize your findings into a concrete answer or implementation.\n\
1607                             3. If you need more information, use a different approach or tool.\n\
1608                             4. If you are truly stuck, explain what you have accomplished so far \
1609                             and what is blocking you.",
1610                            runtime.state.constraints.max_tool_loops
1611                        );
1612                        self.record_warning(
1613                            &agent_prefix,
1614                            &mut runtime.state,
1615                            &mut event_recorder,
1616                            warning_message.clone(),
1617                        );
1618                        runtime.state.add_user_message(warning_message);
1619                        runtime.state.mark_tool_loop_limit_hit();
1620                        break;
1621                    }
1622                    runtime.state.consecutive_idle_turns = 0;
1623                } else {
1624                    runtime.state.reset_tool_loop_guard();
1625                    if forced_continuation {
1626                        runtime.state.consecutive_idle_turns = 0;
1627                    } else if !runtime.state.is_completed {
1628                        runtime.state.consecutive_idle_turns = runtime.state.consecutive_idle_turns.saturating_add(1);
1629                        let idle_turn_limit = self.config().agent.idle_turn_limit;
1630                        if runtime.state.consecutive_idle_turns >= idle_turn_limit {
1631                            let warning_message = format!(
1632                                "No tool calls or completion for {} consecutive turns. \
1633                                 The agent appears to be idle — it is responding without \
1634                                 taking actions or declaring the task complete.\n\n\
1635                                 To proceed:\n\
1636                                 1. Take concrete action using the available tools.\n\
1637                                 2. If you have enough information, present your solution.\n\
1638                                 3. If you are waiting for something, explain the situation.",
1639                                runtime.state.consecutive_idle_turns
1640                            );
1641                            self.record_warning(
1642                                &agent_prefix,
1643                                &mut runtime.state,
1644                                &mut event_recorder,
1645                                warning_message.clone(),
1646                            );
1647                            runtime.state.add_user_message(warning_message);
1648                            runtime.state.outcome = TaskOutcome::StoppedNoAction;
1649                            break;
1650                        }
1651                    }
1652                }
1653
1654                let should_continue = forced_continuation
1655                    || had_tool_call
1656                    || runtime.has_pending_follow_up_inputs()
1657                    || (!runtime.state.is_completed && (turn + 1) < self.max_turns);
1658
1659                if !should_continue {
1660                    if runtime.state.is_completed {
1661                        runtime.state.outcome = TaskOutcome::Success;
1662                    } else if (turn + 1) >= self.max_turns {
1663                        runtime.state.outcome = TaskOutcome::turn_limit_reached(self.max_turns, turn + 1);
1664                    } else {
1665                        runtime.state.outcome = TaskOutcome::StoppedNoAction;
1666                    }
1667                    break;
1668                }
1669            }
1670
1671            runtime.state.finalize_outcome(self.max_turns);
1672            if runtime.state.outcome.is_success() && self.tool_registry.completion_verification_pending() {
1673                runtime.state.is_completed = false;
1674                runtime.state.outcome =
1675                    TaskOutcome::failed(self.tool_registry.unverified_completion_reason(), vec![], None, None);
1676                should_write_blocked_handoff = true;
1677            }
1678
1679            if self.tool_registry.completion_verification_pending() && !runtime.state.outcome.is_success() {
1680                runtime.state.warnings.push(format!(
1681                    "{} Blocker: {}",
1682                    self.tool_registry.unverified_completion_reason(),
1683                    runtime.state.outcome.description()
1684                ));
1685            }
1686            let total_duration_ms = run_started_at.elapsed().as_millis();
1687
1688            // Agent execution completed
1689            self.runner_println(format_args!("{agent_prefix} Done"));
1690
1691            // Generate meaningful summary based on agent actions
1692            let average_turn_duration_ms = if runtime.state.turn_count > 0 {
1693                Some(runtime.state.turn_total_ms as f64 / runtime.state.turn_count as f64)
1694            } else {
1695                None
1696            };
1697
1698            let max_turn_duration_ms = if runtime.state.turn_count > 0 {
1699                Some(runtime.state.turn_max_ms)
1700            } else {
1701                None
1702            };
1703
1704            let outcome = runtime.state.outcome.clone();
1705            self.thread_handle.replace_messages((*runtime.state.messages).clone());
1706            let summary = self.generate_task_summary(
1707                &effective_task,
1708                &runtime.state.modified_files,
1709                &runtime.state.executed_commands,
1710                &runtime.state.warnings,
1711                &runtime.state.messages,
1712                runtime.state.stats.turns_executed,
1713                runtime.state.max_tool_loop_streak,
1714                max_tool_loops,
1715                outcome,
1716                total_duration_ms,
1717                average_turn_duration_ms,
1718                max_turn_duration_ms,
1719                &runtime.state.stats.total_usage,
1720            );
1721
1722            // A completed read-only task must return the actual plan, rather
1723            // than replacing it with operational task-summary bookkeeping.
1724            let planning_response =
1725                if self.tool_registry.is_planning_active() && matches!(runtime.state.outcome, TaskOutcome::Success) {
1726                    runtime
1727                        .state
1728                        .messages
1729                        .iter()
1730                        .rev()
1731                        .find(|message| {
1732                            message.role == crate::llm::provider::MessageRole::Assistant
1733                                && !message.content.as_text().trim().is_empty()
1734                        })
1735                        .map(|message| message.content.as_text().into_owned())
1736                } else {
1737                    None
1738                };
1739            if !summary.trim().is_empty() {
1740                // Preserve the user-facing result in the event stream.
1741                event_recorder.agent_message(planning_response.as_deref().unwrap_or(&summary));
1742                // Also display summary prominently for immediate visibility in TUI transcript
1743                self.runner_println(format_args!(
1744                    "\n{} Agent Task Summary\n{}",
1745                    style("[Task]").cyan().bold(),
1746                    summary
1747                ));
1748            }
1749
1750            let runtime_agent_config = self.core_agent_config();
1751            if let Err(err) = crate::persistent_memory::finalize_persistent_memory(
1752                &runtime_agent_config,
1753                Some(self.config()),
1754                &runtime.state.messages,
1755                &self.session_id,
1756            )
1757            .await
1758            {
1759                warn!(
1760                    error = %err,
1761                    session_id = %self.session_id,
1762                    "Failed to update persistent memory"
1763                );
1764            }
1765
1766            if runtime.state.outcome.is_hard_block() || should_write_blocked_handoff {
1767                let relevant_paths = existing_harness_artifact_paths(&self._workspace);
1768                match write_blocked_handoff_with_resume(
1769                    &self._workspace,
1770                    &self.session_id,
1771                    runtime.state.outcome.code(),
1772                    &runtime.state.outcome.description(),
1773                    &relevant_paths,
1774                    BlockedHandoffResume::Unavailable(
1775                        "Resume unavailable because the legacy agent runner does not create a session archive.",
1776                    ),
1777                    self.tool_registry.is_planning_active(),
1778                ) {
1779                    Ok(artifacts) => emit_blocked_handoff_events(
1780                        &mut event_recorder,
1781                        &runtime.state.outcome.description(),
1782                        &artifacts.current_path,
1783                        &artifacts.archive_path,
1784                    ),
1785                    Err(err) => warn!(
1786                        error = %err,
1787                        session_id = %self.session_id,
1788                        "Failed to persist blocked handoff"
1789                    ),
1790                }
1791            }
1792
1793            let total_usage = runtime.state.stats.total_usage.clone();
1794            record_terminal_turn_event(&mut event_recorder, &runtime.state.outcome, total_usage.clone());
1795            event_recorder.thread_completed(
1796                &self.session_id,
1797                runtime.state.outcome.thread_completion_subtype(),
1798                runtime.state.outcome.code(),
1799                runtime
1800                    .state
1801                    .outcome
1802                    .is_success()
1803                    .then_some(planning_response.as_deref().unwrap_or(&summary)),
1804                runtime.state.stop_reason.as_deref(),
1805                total_usage,
1806                session_costs
1807                    .total()
1808                    .and_then(|cost| serde_json::Number::from_f64(cost.effective_usd)),
1809                runtime.state.stats.turns_executed,
1810            );
1811            let thread_events = event_recorder.take_events();
1812            let steering_receiver = runtime.take_steering_receiver();
1813            let state = std::mem::replace(
1814                &mut runtime.state,
1815                AgentSessionState::new(self.session_id.clone(), self.max_turns, max_tool_loops, max_context_tokens),
1816            );
1817
1818            Ok((state.into_results(summary, thread_events, total_duration_ms), steering_receiver))
1819        }
1820        .await;
1821
1822        let result = match result {
1823            Ok((task_results, steering_receiver)) => {
1824                *self.steering_receiver.lock() = steering_receiver;
1825                Ok(task_results)
1826            }
1827            Err(err) => {
1828                event_recorder.thread_failed(
1829                    &self.session_id,
1830                    &format!("{err:#}"),
1831                    runtime.state.stats.turns_executed.max(1),
1832                );
1833                *self.steering_receiver.lock() = runtime.take_steering_receiver();
1834                Err(err)
1835            }
1836        };
1837
1838        let persistence_result = if let Some(session_store_handle) = session_store_handle {
1839            session_store_handle
1840                .close()
1841                .await
1842                .context("authoritative session event persistence failed")
1843        } else {
1844            Ok(())
1845        };
1846
1847        self.tool_registry.set_harness_task(None);
1848        match (result, persistence_result) {
1849            (Err(error), Err(persistence_error)) => Err(error
1850                .context(format!("failed to finalize canonical session after execution error: {persistence_error:#}"))),
1851            (Err(error), Ok(())) => Err(error),
1852            (Ok(_), Err(error)) => Err(error),
1853            (Ok(results), Ok(())) => Ok(results),
1854        }
1855    }
1856}
1857
1858#[cfg(test)]
1859mod tests {
1860    use super::{prepare_responses_request_messages, record_terminal_turn_event, tool_loop_limit_reached};
1861    use crate::core::agent::events::ExecEventRecorder;
1862    use crate::core::agent::session::AgentSessionState;
1863    use crate::core::agent::state::should_apply_local_tool_result_clearing;
1864    use crate::core::agent::task::TaskOutcome;
1865    use crate::exec::events::ThreadEvent;
1866    use crate::llm::provider::{Message, records_responses_continuation_state};
1867
1868    #[test]
1869    fn headless_local_clearing_gate_ignores_provider_context_edit_capability() {
1870        // Headless requests never attach native context management, so the
1871        // gate must run with context_edits == false even for Anthropic;
1872        // otherwise headless Anthropic gets neither native nor local
1873        // clearing and old tool bodies ride every request.
1874        assert!(should_apply_local_tool_result_clearing("anthropic", false, true));
1875        assert!(!should_apply_local_tool_result_clearing("anthropic", false, false));
1876        // Interactive semantics stay pinned: native edits exclude the local
1877        // pass, other providers use it regardless of capability.
1878        assert!(!should_apply_local_tool_result_clearing("anthropic", true, true));
1879        assert!(should_apply_local_tool_result_clearing("openai", true, true));
1880    }
1881
1882    #[test]
1883    fn failed_outcome_emits_only_turn_failed() {
1884        let mut recorder = ExecEventRecorder::new("thread", None, None);
1885        recorder.turn_started();
1886
1887        record_terminal_turn_event(
1888            &mut recorder,
1889            &TaskOutcome::failed("boom".to_string(), vec![], None, None),
1890            Default::default(),
1891        );
1892
1893        let events = recorder.into_events();
1894        assert_eq!(
1895            events
1896                .iter()
1897                .filter(|event| matches!(event, ThreadEvent::TurnFailed(_)))
1898                .count(),
1899            1
1900        );
1901        assert_eq!(
1902            events
1903                .iter()
1904                .filter(|event| matches!(event, ThreadEvent::TurnCompleted(_)))
1905                .count(),
1906            0
1907        );
1908    }
1909
1910    #[test]
1911    fn successful_outcome_emits_only_turn_completed() {
1912        let mut recorder = ExecEventRecorder::new("thread", None, None);
1913        recorder.turn_started();
1914
1915        record_terminal_turn_event(&mut recorder, &TaskOutcome::Success, Default::default());
1916
1917        let events = recorder.into_events();
1918        assert_eq!(
1919            events
1920                .iter()
1921                .filter(|event| matches!(event, ThreadEvent::TurnCompleted(_)))
1922                .count(),
1923            1
1924        );
1925        assert_eq!(
1926            events
1927                .iter()
1928                .filter(|event| matches!(event, ThreadEvent::TurnFailed(_)))
1929                .count(),
1930            0
1931        );
1932    }
1933
1934    #[test]
1935    fn disabled_tool_loop_limit_never_trips() {
1936        assert!(!tool_loop_limit_reached(1, 0));
1937        assert!(!tool_loop_limit_reached(32, 0));
1938    }
1939
1940    #[test]
1941    fn openai_prepare_responses_request_messages_keeps_full_history_without_previous_response_id() {
1942        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1943        let prior_messages = vec![Message::user("hello".to_string())];
1944        let current_messages = vec![
1945            Message::user("hello".to_string()),
1946            Message::user("continue".to_string()),
1947        ];
1948        state.set_previous_response_chain("openai", "gpt-5.6-sol", Some("resp_123"), prior_messages);
1949
1950        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1951            &mut state.previous_response_chains,
1952            "openai",
1953            false,
1954            "gpt-5.6-sol",
1955            &current_messages,
1956        );
1957
1958        assert_eq!(previous_response_id, None);
1959        assert_eq!(request_messages.as_ref(), current_messages.as_slice());
1960    }
1961
1962    #[test]
1963    fn openai_runner_success_path_does_not_record_previous_response_chain() {
1964        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1965        let messages = vec![Message::user("hello".to_string())];
1966
1967        if records_responses_continuation_state("openai", true) {
1968            state.set_previous_response_chain("openai", "gpt-5.6-sol", Some("resp_123"), messages);
1969        }
1970
1971        assert_eq!(state.previous_response_chain_for("openai", "gpt-5.6-sol"), None);
1972    }
1973
1974    #[test]
1975    fn compatible_runner_success_path_does_not_record_previous_response_chain() {
1976        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1977        let messages = vec![Message::user("hello".to_string())];
1978
1979        if records_responses_continuation_state("mycorp", true) {
1980            state.set_previous_response_chain("mycorp", "gpt-5.6-sol", Some("resp_123"), messages);
1981        }
1982
1983        assert_eq!(state.previous_response_chain_for("mycorp", "gpt-5.6-sol"), None);
1984    }
1985
1986    #[test]
1987    fn gemini_prepare_responses_request_messages_keeps_full_history() {
1988        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1989        let prior_messages = vec![Message::user("hello".to_string())];
1990        let current_messages = vec![
1991            Message::user("hello".to_string()),
1992            Message::user("continue".to_string()),
1993        ];
1994        state.set_previous_response_chain("gemini", "gemini-2.5-pro", Some("resp_123"), prior_messages);
1995
1996        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1997            &mut state.previous_response_chains,
1998            "gemini",
1999            false,
2000            "gemini-2.5-pro",
2001            &current_messages,
2002        );
2003
2004        assert_eq!(previous_response_id.as_deref(), Some("resp_123"));
2005        assert_eq!(request_messages.as_ref(), current_messages.as_slice());
2006    }
2007
2008    #[test]
2009    fn compatible_prepare_responses_request_messages_keeps_custom_provider_stateless() {
2010        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
2011        let prior_messages = vec![Message::user("hello".to_string())];
2012        let current_messages = vec![
2013            Message::user("hello".to_string()),
2014            Message::user("continue".to_string()),
2015        ];
2016        state.set_previous_response_chain("mycorp", "gpt-5.6-sol", Some("resp_123"), prior_messages);
2017
2018        let (request_messages, previous_response_id) = prepare_responses_request_messages(
2019            &mut state.previous_response_chains,
2020            "mycorp",
2021            true,
2022            "gpt-5.6-sol",
2023            &current_messages,
2024        );
2025
2026        assert_eq!(previous_response_id, None);
2027        assert_eq!(request_messages.as_ref(), current_messages.as_slice());
2028    }
2029}