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