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