Skip to main content

vtcode_core/core/agent/runner/
execute.rs

1use super::AgentRunner;
2use super::continuation::{
3    CompletionAssessment, ContinuationController, VerificationResult, is_review_like_task,
4};
5use super::helpers::detect_textual_exec_tool_call;
6use super::orchestration::EvaluatorGateOutcome;
7use super::prompt_alignment;
8use crate::config::build_openai_prompt_cache_key;
9use crate::config::constants::tools;
10use crate::config::models::{ModelId, Provider as ModelProvider};
11use crate::config::tool_loop_limit_reached;
12use crate::config::types::{ReasoningEffortLevel, SystemPromptMode, VerbosityLevel};
13use crate::core::agent::blocked_handoff::write_blocked_handoff;
14use crate::core::agent::completion::{check_completion_candidate, check_for_response_loop};
15use crate::core::agent::conversation::{
16    build_conversation, build_messages_from_conversation, conversation_from_messages,
17};
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::runtime::{AgentRuntime, RuntimeControl};
24use crate::core::agent::session::AgentSessionState;
25use crate::core::agent::task::{ContextItem, Task, TaskOutcome, TaskResults};
26use crate::exec::events::HarnessEventKind;
27use crate::llm::model_resolver::ModelResolver;
28use crate::llm::provider::{
29    FinishReason, Message, ResponsesContinuationState, ToolCall, ToolChoice, ToolDefinition,
30    prepare_responses_continuation_request, responses_continuation_key,
31    supports_responses_chaining,
32};
33use crate::llm::providers::gemini::wire::Part;
34use crate::prompts::{
35    PromptContext, RuntimePromptContract, append_runtime_mode_sections,
36    append_runtime_tool_prompt_sections, upsert_harness_limits_section,
37};
38use crate::utils::colors::style;
39use anyhow::Result;
40use serde_json::json;
41use std::sync::Arc;
42use tracing::{debug, warn};
43
44fn record_terminal_turn_event(
45    event_recorder: &mut ExecEventRecorder,
46    outcome: &TaskOutcome,
47    usage: vtcode_exec_events::Usage,
48) {
49    if outcome.is_success() {
50        event_recorder.record_thread_event(vtcode_exec_events::ThreadEvent::TurnCompleted(
51            vtcode_exec_events::TurnCompletedEvent { usage },
52        ));
53    } else {
54        event_recorder.record_thread_event(vtcode_exec_events::ThreadEvent::TurnFailed(
55            vtcode_exec_events::TurnFailedEvent {
56                message: outcome.description(),
57                usage: Some(usage),
58            },
59        ));
60    }
61}
62
63fn emit_blocked_handoff_events(
64    event_recorder: &mut ExecEventRecorder,
65    current_path: &std::path::Path,
66    archive_path: &std::path::Path,
67) {
68    for path in [current_path, archive_path] {
69        event_recorder.harness_event(
70            HarnessEventKind::BlockedHandoffWritten,
71            Some("Blocked handoff written".to_string()),
72            None,
73            Some(path.display().to_string()),
74            None,
75        );
76    }
77}
78
79fn summarize_verification_output(result: &serde_json::Value) -> String {
80    result
81        .get("output")
82        .and_then(serde_json::Value::as_str)
83        .or_else(|| result.get("stderr").and_then(serde_json::Value::as_str))
84        .or_else(|| result.get("stdout").and_then(serde_json::Value::as_str))
85        .map(str::trim)
86        .filter(|text| !text.is_empty())
87        .map(|text| {
88            let truncated = text
89                .lines()
90                .take(20)
91                .collect::<Vec<_>>()
92                .join("\n")
93                .trim()
94                .to_string();
95            if truncated.len() < text.len() {
96                format!("{truncated}\n...")
97            } else {
98                truncated
99            }
100        })
101        .unwrap_or_default()
102}
103
104fn prepare_responses_request_messages<'a>(
105    previous_chains: &mut hashbrown::HashMap<(String, String), ResponsesContinuationState>,
106    provider_name: &str,
107    provider_supports_responses_compaction: bool,
108    model: &str,
109    messages: &'a [Message],
110) -> (std::borrow::Cow<'a, [Message]>, Option<String>) {
111    let key = responses_continuation_key(provider_name, model);
112    let continuation = key.as_ref().and_then(|k| previous_chains.get(k));
113    let prepared = prepare_responses_continuation_request(
114        provider_name,
115        provider_supports_responses_compaction,
116        messages,
117        continuation,
118    );
119    if prepared.clear_stale_chain
120        && let Some(key) = key
121    {
122        previous_chains.remove(&key);
123    }
124
125    (prepared.messages, prepared.previous_response_id)
126}
127
128fn stop_reason_from_finish_reason(finish_reason: &FinishReason) -> String {
129    match finish_reason {
130        FinishReason::Stop => "end_turn".to_string(),
131        FinishReason::Length => "max_tokens".to_string(),
132        FinishReason::ToolCalls => "tool_calls".to_string(),
133        FinishReason::ContentFilter => "content_filter".to_string(),
134        FinishReason::Pause => "pause_turn".to_string(),
135        FinishReason::Refusal => "refusal".to_string(),
136        FinishReason::Error(message) => message.clone(),
137    }
138}
139
140fn estimate_session_cost_usd(
141    provider: &str,
142    model: &str,
143    usage: &vtcode_exec_events::Usage,
144) -> Option<f64> {
145    let usage = crate::llm::provider::Usage {
146        prompt_tokens: u32::try_from(usage.input_tokens).unwrap_or(u32::MAX),
147        completion_tokens: u32::try_from(usage.output_tokens).unwrap_or(u32::MAX),
148        total_tokens: u32::try_from(usage.input_tokens.saturating_add(usage.output_tokens))
149            .unwrap_or(u32::MAX),
150        cached_prompt_tokens: Some(u32::try_from(usage.cached_input_tokens).unwrap_or(u32::MAX)),
151        cache_creation_tokens: Some(u32::try_from(usage.cache_creation_tokens).unwrap_or(u32::MAX)),
152        cache_read_tokens: Some(u32::try_from(usage.cached_input_tokens).unwrap_or(u32::MAX)),
153        iterations: None,
154    };
155    let resolved = ModelResolver::resolve(Some(provider), model, &[], None)?;
156    let pricing = resolved.pricing()?;
157    ModelResolver::estimate_cost(pricing, &usage)
158}
159
160struct RuntimePromptBundle {
161    system_instruction: Arc<String>,
162    tool_snapshot: SessionToolCatalogSnapshot,
163    request_tools: Option<Arc<Vec<ToolDefinition>>>,
164}
165
166impl AgentRunner {
167    async fn compose_task_system_prompt(
168        &self,
169        prompt_tools: Arc<Vec<ToolDefinition>>,
170        is_simple_task: bool,
171    ) -> Result<String> {
172        if !is_simple_task {
173            return Ok(self.system_prompt.clone());
174        }
175
176        let mut config = self.config().clone();
177        config.agent.system_prompt_mode = SystemPromptMode::Minimal;
178        let mut prompt_context = PromptContext::from_workspace_tools(
179            self._workspace.as_path(),
180            prompt_tools
181                .iter()
182                .map(|tool| tool.function_name().to_string()),
183        );
184        prompt_context.load_available_skills();
185
186        let prompt = super::helpers::compose_system_prompt_with_appendix(
187            self._workspace.as_path(),
188            &config,
189            &prompt_context,
190        )
191        .await?;
192
193        Ok(prompt)
194    }
195
196    async fn build_runtime_prompt_bundle(
197        &self,
198        is_simple_task: bool,
199    ) -> Result<RuntimePromptBundle> {
200        let tool_snapshot = self.build_universal_tool_snapshot().await?;
201        let request_tools = tool_snapshot.snapshot.clone();
202        let prompt_tools = request_tools
203            .clone()
204            .unwrap_or_else(|| Arc::new(Vec::new()));
205        let mut system_prompt = self
206            .compose_task_system_prompt(prompt_tools, is_simple_task)
207            .await?;
208        self.append_active_primary_agent_context(&mut system_prompt);
209
210        let planning_active = self.tool_registry.is_planning_active();
211        let request_user_input_enabled = self
212            .features()
213            .request_user_input_enabled(planning_active, false);
214        let full_auto_active = self
215            .tool_registry
216            .current_full_auto_allowlist()
217            .await
218            .is_some();
219
220        append_runtime_mode_sections(
221            &mut system_prompt,
222            RuntimePromptContract {
223                full_auto: full_auto_active,
224                planning_active,
225                request_user_input_enabled,
226            },
227        );
228        upsert_harness_limits_section(
229            &mut system_prompt,
230            self.config().agent.harness.max_tool_calls_per_turn,
231            self.config().agent.harness.max_tool_wall_clock_secs,
232            self.config().agent.harness.max_tool_retries,
233        );
234        append_runtime_tool_prompt_sections(&mut system_prompt, &tool_snapshot, true);
235
236        Ok(RuntimePromptBundle {
237            system_instruction: Arc::new(system_prompt),
238            tool_snapshot,
239            request_tools,
240        })
241    }
242
243    fn append_active_primary_agent_context(&self, system_prompt: &mut String) {
244        let Some(active_primary_agent) = self.active_primary_agent.as_ref() else {
245            return;
246        };
247
248        system_prompt.push_str("\n\n## Active Primary Agent Runtime State\n");
249        system_prompt.push_str("- Active agent: ");
250        system_prompt.push_str(&active_primary_agent.display_name);
251        system_prompt.push_str("\n- Spec name: ");
252        system_prompt.push_str(&active_primary_agent.identity.name);
253        if let Some(model) = active_primary_agent
254            .model
255            .as_deref()
256            .map(str::trim)
257            .filter(|model| !model.is_empty() && !model.eq_ignore_ascii_case("inherit"))
258        {
259            system_prompt.push_str("\n- Agent model: ");
260            system_prompt.push_str(model);
261        }
262        if let Some(reasoning_effort) = active_primary_agent
263            .reasoning_effort
264            .as_deref()
265            .map(str::trim)
266            .filter(|reasoning_effort| !reasoning_effort.is_empty())
267        {
268            system_prompt.push_str("\n- Agent reasoning effort: ");
269            system_prompt.push_str(reasoning_effort);
270        }
271        system_prompt.push_str("\n\n## Active Primary Agent Instructions\n");
272        system_prompt.push_str(active_primary_agent.instructions.trim());
273    }
274
275    async fn build_validated_runtime_prompt_bundle(
276        &self,
277        is_simple_task: bool,
278    ) -> Result<RuntimePromptBundle> {
279        let mut runner = self;
280        let bundle = runner.build_runtime_prompt_bundle(is_simple_task).await?;
281        prompt_alignment::rebuild_once_on_alignment_mismatch(
282            &mut runner,
283            bundle,
284            |runner| Box::pin((*runner).build_runtime_prompt_bundle(is_simple_task)),
285            |runner, bundle| {
286                let planning_active = runner.tool_registry.is_planning_active();
287                let request_user_input_enabled = runner
288                    .features()
289                    .request_user_input_enabled(planning_active, false);
290                prompt_alignment::validate_prompt_catalog_alignment(
291                    &bundle.system_instruction,
292                    &bundle.tool_snapshot,
293                    planning_active,
294                    request_user_input_enabled,
295                )
296            },
297            "prompt/catalog alignment mismatch; rebuilding runtime prompt bundle",
298            "prompt/catalog alignment mismatch persisted after rebuild",
299        )
300        .await
301    }
302
303    async fn refresh_runtime_prompt_bundle_if_catalog_changed(
304        &self,
305        bundle: &mut RuntimePromptBundle,
306        is_simple_task: bool,
307    ) -> Result<bool> {
308        let current_version = self.tool_registry.tool_catalog_state().current_version();
309        if current_version == bundle.tool_snapshot.version {
310            return Ok(false);
311        }
312
313        debug!(
314            old_version = bundle.tool_snapshot.version,
315            new_version = current_version,
316            "Tool catalog changed mid-task; refreshing runtime prompt bundle"
317        );
318        *bundle = self
319            .build_validated_runtime_prompt_bundle(is_simple_task)
320            .await?;
321        Ok(true)
322    }
323
324    async fn resolve_completion_acceptance(
325        &mut self,
326        effective_task: &Task,
327        session_state: &mut AgentSessionState,
328        event_recorder: &mut ExecEventRecorder,
329        orchestration_enabled: bool,
330        verification_results: &[VerificationResult],
331        revision_rounds_used: &mut usize,
332        max_revision_rounds: usize,
333        should_write_blocked_handoff: &mut bool,
334    ) -> Result<bool> {
335        if !orchestration_enabled {
336            session_state.is_completed = true;
337            session_state.outcome = TaskOutcome::Success;
338            return Ok(true);
339        }
340
341        match self
342            .apply_evaluator_gate(
343                effective_task,
344                session_state,
345                event_recorder,
346                verification_results,
347                revision_rounds_used,
348                max_revision_rounds,
349            )
350            .await?
351        {
352            EvaluatorGateOutcome::Accept => {
353                session_state.is_completed = true;
354                session_state.outcome = TaskOutcome::Success;
355                Ok(true)
356            }
357            EvaluatorGateOutcome::Continue { prompt } => {
358                session_state.add_user_message(prompt);
359                Ok(false)
360            }
361            EvaluatorGateOutcome::Exhausted { reason } => {
362                session_state.outcome = TaskOutcome::Failed { reason };
363                *should_write_blocked_handoff = true;
364                Ok(true)
365            }
366        }
367    }
368
369    async fn run_verification_commands(
370        &self,
371        commands: &[String],
372        event_recorder: &mut ExecEventRecorder,
373    ) -> Result<Vec<VerificationResult>> {
374        let mut results = Vec::with_capacity(commands.len());
375        for command in commands {
376            let command_event = event_recorder.command_started(command);
377            let payload = json!({
378                "action": "run",
379                "command": command,
380                "workdir": self._workspace.display().to_string(),
381                "yield_time_ms": 1000,
382            });
383            let result = self
384                .tool_registry
385                .execute_harness_unified_exec(payload)
386                .await?;
387            let exit_code = result
388                .get("exit_code")
389                .and_then(serde_json::Value::as_i64)
390                .map(|value| value as i32);
391            let success = exit_code.unwrap_or(0) == 0;
392            let output = summarize_verification_output(&result);
393            event_recorder.command_finished(
394                &command_event,
395                if success {
396                    crate::exec::events::CommandExecutionStatus::Completed
397                } else {
398                    crate::exec::events::CommandExecutionStatus::Failed
399                },
400                exit_code,
401                &output,
402            );
403            results.push(VerificationResult {
404                command: command.clone(),
405                success,
406                exit_code,
407                output,
408            });
409            if !success {
410                break;
411            }
412        }
413        Ok(results)
414    }
415
416    /// Execute a task with this agent
417    pub async fn execute_task(
418        &mut self,
419        task: &Task,
420        contexts: &[ContextItem],
421    ) -> Result<TaskResults> {
422        // Align harness context with runner session/task for structured telemetry
423        self.tool_registry
424            .set_harness_session(self.session_id.clone());
425        self.tool_registry.set_harness_task(Some(task.id.clone()));
426
427        let steering_receiver = self.steering_receiver.lock().take();
428        let runtime_setup = {
429            // Agent execution status
430            let agent_prefix = format!("[{}]", self.agent_type);
431            // OPTIMIZATION: Avoid cloning session_id repeatedly by using reference
432            let mut event_recorder = ExecEventRecorder::new(
433                self.session_id.clone(),
434                self.event_sink.clone(),
435                Some(self.thread_handle.clone()),
436            );
437            event_recorder.turn_started();
438            self.runner_println(format_args!(
439                "{agent_prefix} Analyzing request and planning approach..."
440            ));
441
442            self.runner_println(format_args!(
443                "{} Executing {} task: {}",
444                style("[AGENT]").magenta().bold().on_black(),
445                self.agent_type,
446                task.title
447            ));
448
449            let run_started_at = std::time::Instant::now();
450            let is_simple_task = Self::is_simple_task(task, contexts);
451            let prompt_bundle = self
452                .build_validated_runtime_prompt_bundle(is_simple_task)
453                .await?;
454
455            let review_like = is_review_like_task(task);
456            let full_auto_active = self
457                .tool_registry
458                .current_full_auto_allowlist()
459                .await
460                .is_some();
461
462            let mut conversation = conversation_from_messages(&self.bootstrap_messages);
463            conversation.extend(build_conversation(task, contexts));
464
465            // Maintain a mirrored conversation history for providers that expect
466            // OpenAI/Anthropic style message roles.
467            let conversation_messages = build_messages_from_conversation(&conversation);
468
469            // Track execution results
470            // Determine loop guards via cached configuration
471            let max_tool_loops = self.config().tools.max_tool_loops;
472            let preserve_recent_turns = self.config().context.preserve_recent_turns;
473            let max_context_tokens = self.config().context.max_context_tokens;
474
475            let mut session_state = AgentSessionState::new(
476                self.session_id.clone(),
477                self.max_turns,
478                max_tool_loops,
479                max_context_tokens,
480            );
481            session_state.conversation = conversation;
482            session_state.messages = conversation_messages;
483            session_state.last_processed_message_idx = session_state.conversation.len();
484
485            let mut runtime = AgentRuntime::new(session_state, None, steering_receiver);
486
487            if let Err(err) = self.tool_registry.initialize_async().await {
488                warn!(
489                    error = %err,
490                    "Tool registry initialization failed at task start"
491                );
492                runtime
493                    .state
494                    .warnings
495                    .push(format!("Tool registry init failed: {err}"));
496            }
497            let orchestration_enabled =
498                self.harness_plan_build_evaluate_enabled(full_auto_active, review_like);
499            let planner_artifacts = if orchestration_enabled {
500                Some(self.run_planner_phase(task, &mut event_recorder).await?)
501            } else {
502                None
503            };
504            let effective_task = planner_artifacts
505                .as_ref()
506                .map(|artifacts| self.augment_generator_task(task, artifacts))
507                .unwrap_or_else(|| task.clone());
508
509            let mut continuation_controller = ContinuationController::new(
510                self._workspace.clone(),
511                self.tool_registry.planning_workflow_state(),
512                self.config().agent.harness.continuation_policy.clone(),
513                full_auto_active,
514                self.tool_registry.is_planning_active(),
515                review_like,
516            );
517            continuation_controller.prepare(&effective_task).await?;
518
519            (
520                agent_prefix,
521                event_recorder,
522                run_started_at,
523                is_simple_task,
524                prompt_bundle,
525                preserve_recent_turns,
526                max_tool_loops,
527                max_context_tokens,
528                runtime,
529                continuation_controller,
530                effective_task,
531                orchestration_enabled,
532            )
533        };
534
535        let (
536            agent_prefix,
537            mut event_recorder,
538            run_started_at,
539            is_simple_task,
540            mut prompt_bundle,
541            preserve_recent_turns,
542            max_tool_loops,
543            max_context_tokens,
544            mut runtime,
545            mut continuation_controller,
546            effective_task,
547            orchestration_enabled,
548        ) = runtime_setup;
549        let mut cost_warning_emitted = false;
550        let max_budget_usd = self.config().agent.harness.max_budget_usd;
551        let max_revision_rounds = self.config().agent.harness.max_revision_rounds.max(1);
552        let mut revision_rounds_used = 0usize;
553        let mut should_write_blocked_handoff = false;
554
555        let result = {
556            for turn in 0..self.max_turns {
557                if matches!(
558                    runtime.poll_turn_control().await,
559                    RuntimeControl::StopRequested
560                ) {
561                    self.runner_println(format_args!(
562                        "{} {}",
563                        agent_prefix,
564                        style("Stopped by steering signal.").red().bold()
565                    ));
566                    runtime.state.outcome = TaskOutcome::Cancelled;
567                    break;
568                }
569
570                if let Some(input) = runtime.run_until_idle() {
571                    self.runner_println(format_args!(
572                        "{} {}: {}",
573                        agent_prefix,
574                        style("Follow-up Input").cyan().bold(),
575                        input
576                    ));
577                }
578
579                let utilization = runtime.state.utilization();
580                if utilization > 0.90 {
581                    warn!("Context at {:.1}% - approaching limit", utilization * 100.0);
582                    runtime.state.warnings.push(format!(
583                        "Token budget at {}% - approaching context limit",
584                        (utilization * 100.0) as u32
585                    ));
586                }
587
588                if runtime.state.is_completed {
589                    runtime.state.outcome = TaskOutcome::Success;
590                    break;
591                }
592
593                self.runner_println(format_args!(
594                    "{} {} is processing turn {}...",
595                    agent_prefix,
596                    style("(PROC)").cyan().bold(),
597                    turn + 1
598                ));
599
600                let turn_model = self.get_selected_model();
601                let provider_name = self.provider_client.name().to_string();
602                if std::env::var_os("VTCODE_DEBUG_PROVIDER").is_some() {
603                    tracing::debug!(
604                        provider_client = self.provider_client.name(),
605                        turn_model,
606                        "Provider debug turn selection"
607                    );
608                }
609                let turn_reasoning = if is_simple_task {
610                    Some(ReasoningEffortLevel::Minimal)
611                } else {
612                    self.reasoning_effort
613                };
614                let turn_verbosity = if is_simple_task {
615                    Some(VerbosityLevel::Low)
616                } else {
617                    self.verbosity
618                };
619                let max_tokens = if is_simple_task {
620                    Some(800)
621                } else {
622                    Some(2000)
623                };
624
625                self.summarize_conversation_if_needed(
626                    &mut runtime.state,
627                    preserve_recent_turns,
628                    utilization,
629                );
630
631                let parallel_tool_config = if self.model.len() < 20 {
632                    None
633                } else if self
634                    .provider_client
635                    .supports_parallel_tool_config(&turn_model)
636                {
637                    Some(Box::new(
638                        crate::llm::provider::ParallelToolConfig::anthropic_optimized(),
639                    ))
640                } else {
641                    None
642                };
643
644                let provider_kind = turn_model
645                    .parse::<ModelId>()
646                    .map(|model| model.provider())
647                    .unwrap_or(ModelProvider::Gemini);
648
649                if matches!(provider_kind, ModelProvider::Gemini)
650                    && runtime.state.conversation.len() > runtime.state.last_processed_message_idx
651                {
652                    for content in
653                        &runtime.state.conversation[runtime.state.last_processed_message_idx..]
654                    {
655                        let mut text = String::new();
656                        for part in &content.parts {
657                            if let Part::Text {
658                                text: part_text, ..
659                            } = part
660                            {
661                                if !text.is_empty() {
662                                    text.push('\n');
663                                }
664                                text.push_str(part_text);
665                            }
666                        }
667                        let message = match content.role.as_str() {
668                            "model" => Message::assistant(text),
669                            _ => Message::user(text),
670                        };
671                        runtime.state.messages.push(message);
672                    }
673                    runtime.state.last_processed_message_idx = runtime.state.conversation.len();
674                }
675
676                let reasoning_effort =
677                    if self.provider_client.supports_reasoning_effort(&turn_model) {
678                        turn_reasoning
679                    } else {
680                        None
681                    };
682                let temperature = if reasoning_effort.is_some()
683                    && matches!(
684                        provider_kind,
685                        ModelProvider::Anthropic | ModelProvider::Minimax
686                    ) {
687                    None
688                } else {
689                    Some(0.7)
690                };
691
692                let (request_messages, previous_response_id) = prepare_responses_request_messages(
693                    &mut runtime.state.previous_response_chains,
694                    &provider_name,
695                    self.provider_client
696                        .supports_responses_compaction(&turn_model),
697                    &turn_model,
698                    &runtime.state.messages,
699                );
700
701                let request = build_harness_request_plan(HarnessRequestPlanInput {
702                    messages: request_messages.into_owned(),
703                    system_prompt: prompt_bundle.system_instruction.as_ref().clone(),
704                    tools: prompt_bundle.request_tools.clone(),
705                    model: turn_model.clone(),
706                    max_tokens,
707                    temperature,
708                    stream: self.provider_client.supports_streaming(),
709                    tool_choice: (provider_name.eq_ignore_ascii_case("openai")
710                        && !prompt_bundle.tool_snapshot.active_tool_names.is_empty())
711                    .then(|| {
712                        ToolChoice::allowed_tools_auto(
713                            prompt_bundle
714                                .tool_snapshot
715                                .active_tool_names
716                                .as_ref()
717                                .clone(),
718                        )
719                    }),
720                    parallel_tool_config,
721                    reasoning_effort,
722                    verbosity: turn_verbosity,
723                    metadata: None,
724                    context_management: None,
725                    previous_response_id,
726                    prompt_cache_key: build_openai_prompt_cache_key(
727                        provider_name.eq_ignore_ascii_case("openai")
728                            && self.config().prompt_cache.enabled
729                            && self.config().prompt_cache.providers.openai.enabled,
730                        &self
731                            .config()
732                            .prompt_cache
733                            .providers
734                            .openai
735                            .prompt_cache_key_mode,
736                        Some(&self.session_id),
737                    ),
738                    prompt_cache_profile: None,
739                    tool_catalog_hash: prompt_bundle.tool_snapshot.tool_catalog_hash,
740                })
741                .request;
742                // Cheap pre-flight: catch malformed requests (empty system
743                // prompt, no messages, duplicate tool names, missing required
744                // properties) before paying for an API round-trip.
745                self.validate_llm_request(&request)?;
746                let previous_response_chain_present = request.previous_response_id.is_some();
747                // Clone messages for set_previous_response_chain while keeping
748                // the original in the request so providers that validate messages
749                // (e.g. MiMo) see a non-empty list during stream().
750                let sent_messages = request.messages.clone();
751                // Compute timeout before the call to avoid simultaneous mutable/immutable
752                // borrows of `self` (provider_client vs config).
753                let streaming_timeout = self
754                    .config()
755                    .timeouts
756                    .ceiling_duration(self.config().timeouts.streaming_ceiling_seconds);
757
758                let turn_output = runtime
759                    .run_turn_once(&mut self.provider_client, request, streaming_timeout)
760                    .await?;
761                event_recorder.record_thread_events(runtime.take_emitted_events());
762                let response = turn_output.response;
763                runtime.state.stop_reason =
764                    Some(stop_reason_from_finish_reason(&response.finish_reason));
765                if supports_responses_chaining(
766                    &provider_name,
767                    self.provider_client
768                        .supports_responses_compaction(&turn_model),
769                ) {
770                    runtime.state.set_previous_response_chain(
771                        &provider_name,
772                        &turn_model,
773                        response.request_id.as_deref(),
774                        sent_messages,
775                    );
776                }
777                match estimate_session_cost_usd(
778                    self.config().agent.provider.as_str(),
779                    &turn_model,
780                    &runtime.state.stats.total_usage,
781                ) {
782                    Some(total_cost_usd) => {
783                        runtime.state.total_cost_usd = Some(total_cost_usd);
784                        if let Some(max_budget_usd) = max_budget_usd
785                            && total_cost_usd > max_budget_usd
786                        {
787                            runtime.state.outcome =
788                                TaskOutcome::budget_limit_reached(max_budget_usd, total_cost_usd);
789                            break;
790                        }
791                    }
792                    None => {
793                        runtime.state.total_cost_usd = None;
794                        if max_budget_usd.is_some() && !cost_warning_emitted {
795                            cost_warning_emitted = true;
796                            let warning_message = format!(
797                                "Budget enforcement disabled for model `{turn_model}` because pricing metadata is unavailable"
798                            );
799                            warn!(
800                                provider = %self.config().agent.provider,
801                                model = %turn_model,
802                                "Budget enforcement disabled because pricing metadata is unavailable"
803                            );
804                            runtime.state.warnings.push(warning_message);
805                        }
806                    }
807                }
808                self.runner_println(format_args!(
809                    "{} {} {} received response, processing...",
810                    agent_prefix,
811                    self.agent_type,
812                    style("(RECV)").green().bold()
813                ));
814
815                self.warn_on_empty_response(
816                    &agent_prefix,
817                    response.content.as_deref().unwrap_or(""),
818                    response
819                        .tool_calls
820                        .as_ref()
821                        .is_some_and(|tool_calls| !tool_calls.is_empty()),
822                );
823
824                let response_text = response.content_string();
825                if !response_text.trim().is_empty() {
826                    self.emit_final_assistant_message(&self.agent_type, &response_text);
827                }
828
829                let mut effective_tool_calls = response.tool_calls.clone();
830                let mut forced_continuation = false;
831
832                if effective_tool_calls.is_none()
833                    && response.content_text().len() < 150
834                    && let Some(args_value) = detect_textual_exec_tool_call(response.content_text())
835                {
836                    effective_tool_calls = Some(vec![ToolCall::function(
837                        format!("call_text_{}", turn),
838                        tools::UNIFIED_EXEC.to_string(),
839                        args_value.to_string(),
840                    )]);
841                }
842
843                let is_gemini = matches!(provider_kind, ModelProvider::Gemini);
844
845                if !runtime.state.is_completed
846                    && effective_tool_calls
847                        .as_ref()
848                        .is_none_or(|tool_calls| tool_calls.is_empty())
849                    && !response.content_text().is_empty()
850                {
851                    if check_for_response_loop(response.content_text(), &mut runtime.state) {
852                        self.runner_println(format_args!(
853                            "[{}] {}",
854                            self.agent_type,
855                            style(
856                                "Repetitive assistant response detected. Breaking potential loop."
857                            )
858                            .red()
859                            .bold()
860                        ));
861                        runtime.state.outcome = TaskOutcome::LoopDetected;
862                        break;
863                    }
864
865                    if check_completion_candidate(response.content_text()) {
866                        self.runner_println(format_args!(
867                            "[{}] {}",
868                            self.agent_type,
869                            style("Completion candidate detected; checking tracker and verification state.")
870                                .green()
871                                .bold()
872                        ));
873                        match continuation_controller
874                            .assess_completion(&effective_task, &runtime.state)
875                            .await?
876                        {
877                            CompletionAssessment::Accept => {
878                                if self
879                                    .resolve_completion_acceptance(
880                                        &effective_task,
881                                        &mut runtime.state,
882                                        &mut event_recorder,
883                                        orchestration_enabled,
884                                        &[],
885                                        &mut revision_rounds_used,
886                                        max_revision_rounds,
887                                        &mut should_write_blocked_handoff,
888                                    )
889                                    .await?
890                                {
891                                    break;
892                                }
893                                forced_continuation = true;
894                            }
895                            CompletionAssessment::SkipAccept { reason } => {
896                                event_recorder.harness_event(
897                                    HarnessEventKind::ContinuationSkipped,
898                                    Some(reason),
899                                    None,
900                                    None,
901                                    None,
902                                );
903                                runtime.state.is_completed = true;
904                                runtime.state.outcome = TaskOutcome::Success;
905                                break;
906                            }
907                            CompletionAssessment::Continue { reason, prompt } => {
908                                event_recorder.harness_event(
909                                    HarnessEventKind::ContinuationStarted,
910                                    Some(reason),
911                                    None,
912                                    None,
913                                    None,
914                                );
915                                runtime.state.add_user_message(prompt);
916                                forced_continuation = true;
917                            }
918                            CompletionAssessment::Verify { commands } => {
919                                event_recorder.harness_event(
920                                    HarnessEventKind::VerificationStarted,
921                                    Some(format!("Running verification: {}", commands.join(", "))),
922                                    commands.first().cloned(),
923                                    None,
924                                    None,
925                                );
926                                let verification_results = self
927                                    .run_verification_commands(&commands, &mut event_recorder)
928                                    .await?;
929                                if let Some(failure) =
930                                    verification_results.iter().find(|result| !result.success)
931                                {
932                                    event_recorder.harness_event(
933                                        HarnessEventKind::VerificationFailed,
934                                        Some(format!(
935                                            "{}{}",
936                                            match failure.exit_code {
937                                                Some(code) => format!(
938                                                    "Verification failed: {} (exit code {}).",
939                                                    failure.command, code
940                                                ),
941                                                None => format!(
942                                                    "Verification failed: {}.",
943                                                    failure.command
944                                                ),
945                                            },
946                                            if failure.output.trim().is_empty() {
947                                                String::new()
948                                            } else {
949                                                format!("\n{}", failure.output.trim())
950                                            }
951                                        )),
952                                        Some(failure.command.clone()),
953                                        None,
954                                        failure.exit_code,
955                                    );
956                                } else {
957                                    event_recorder.harness_event(
958                                        HarnessEventKind::VerificationPassed,
959                                        Some(format!(
960                                            "Verification passed: {}",
961                                            commands.join(", ")
962                                        )),
963                                        commands.last().cloned(),
964                                        None,
965                                        Some(0),
966                                    );
967                                }
968
969                                match continuation_controller
970                                    .after_verification(&verification_results)
971                                    .await?
972                                {
973                                    CompletionAssessment::Accept
974                                    | CompletionAssessment::SkipAccept { .. } => {
975                                        if self
976                                            .resolve_completion_acceptance(
977                                                &effective_task,
978                                                &mut runtime.state,
979                                                &mut event_recorder,
980                                                orchestration_enabled,
981                                                &verification_results,
982                                                &mut revision_rounds_used,
983                                                max_revision_rounds,
984                                                &mut should_write_blocked_handoff,
985                                            )
986                                            .await?
987                                        {
988                                            break;
989                                        }
990                                        forced_continuation = true;
991                                    }
992                                    CompletionAssessment::Continue { reason, prompt } => {
993                                        event_recorder.harness_event(
994                                            HarnessEventKind::ContinuationStarted,
995                                            Some(reason),
996                                            None,
997                                            None,
998                                            None,
999                                        );
1000                                        runtime.state.add_user_message(prompt);
1001                                        forced_continuation = true;
1002                                    }
1003                                    CompletionAssessment::Verify { .. } => {}
1004                                }
1005                            }
1006                        }
1007                    }
1008                }
1009
1010                if let Some(tool_calls) = effective_tool_calls
1011                    .as_ref()
1012                    .filter(|tool_calls| !tool_calls.is_empty())
1013                    .cloned()
1014                {
1015                    self.execute_tool_call_batches(
1016                        tool_calls,
1017                        &mut runtime,
1018                        &mut event_recorder,
1019                        &agent_prefix,
1020                        is_gemini,
1021                        previous_response_chain_present,
1022                    )
1023                    .await?;
1024                    event_recorder.record_thread_events(runtime.take_emitted_events());
1025                }
1026
1027                // Refresh tool definitions if the catalog was mutated during tool
1028                // execution (e.g. tools.load / tools.unload / skill activation).
1029                let _ = self
1030                    .refresh_runtime_prompt_bundle_if_catalog_changed(
1031                        &mut prompt_bundle,
1032                        is_simple_task,
1033                    )
1034                    .await?;
1035
1036                let had_effective_shell_tool_call =
1037                    effective_tool_calls.as_ref().is_some_and(|calls| {
1038                        calls.iter().any(|call| {
1039                            call.function
1040                                .as_ref()
1041                                .map(|function| function.name.as_str())
1042                                == Some(tools::UNIFIED_EXEC)
1043                        })
1044                    });
1045                let had_tool_call = response
1046                    .tool_calls
1047                    .as_ref()
1048                    .is_some_and(|tool_calls| !tool_calls.is_empty())
1049                    || had_effective_shell_tool_call;
1050
1051                if had_tool_call {
1052                    let loops = runtime.state.register_tool_loop();
1053                    if tool_loop_limit_reached(loops, runtime.state.constraints.max_tool_loops) {
1054                        let warning_message = format!(
1055                            "Reached tool-call limit of {} iterations; pausing autonomous loop",
1056                            runtime.state.constraints.max_tool_loops
1057                        );
1058                        self.record_warning(
1059                            &agent_prefix,
1060                            &mut runtime.state,
1061                            &mut event_recorder,
1062                            warning_message,
1063                        );
1064                        runtime.state.mark_tool_loop_limit_hit();
1065                        break;
1066                    }
1067                    runtime.state.consecutive_idle_turns = 0;
1068                } else {
1069                    runtime.state.reset_tool_loop_guard();
1070                    if forced_continuation {
1071                        runtime.state.consecutive_idle_turns = 0;
1072                    } else if !runtime.state.is_completed {
1073                        runtime.state.consecutive_idle_turns =
1074                            runtime.state.consecutive_idle_turns.saturating_add(1);
1075                        let idle_turn_limit = self.config().agent.idle_turn_limit;
1076                        if runtime.state.consecutive_idle_turns >= idle_turn_limit {
1077                            let warning_message = format!(
1078                                "No tool calls or completion for {} consecutive turns; halting to avoid idle loop",
1079                                runtime.state.consecutive_idle_turns
1080                            );
1081                            self.record_warning(
1082                                &agent_prefix,
1083                                &mut runtime.state,
1084                                &mut event_recorder,
1085                                warning_message,
1086                            );
1087                            runtime.state.outcome = TaskOutcome::StoppedNoAction;
1088                            break;
1089                        }
1090                    }
1091                }
1092
1093                let should_continue = forced_continuation
1094                    || had_tool_call
1095                    || runtime.has_pending_follow_up_inputs()
1096                    || (!runtime.state.is_completed && (turn + 1) < self.max_turns);
1097
1098                if !should_continue {
1099                    if runtime.state.is_completed {
1100                        runtime.state.outcome = TaskOutcome::Success;
1101                    } else if (turn + 1) >= self.max_turns {
1102                        runtime.state.outcome =
1103                            TaskOutcome::turn_limit_reached(self.max_turns, turn + 1);
1104                    } else {
1105                        runtime.state.outcome = TaskOutcome::StoppedNoAction;
1106                    }
1107                    break;
1108                }
1109            }
1110
1111            runtime.state.finalize_outcome(self.max_turns);
1112
1113            let total_duration_ms = run_started_at.elapsed().as_millis();
1114
1115            // Agent execution completed
1116            self.runner_println(format_args!("{} Done", agent_prefix));
1117
1118            // Generate meaningful summary based on agent actions
1119            let average_turn_duration_ms = if runtime.state.turn_count > 0 {
1120                Some(runtime.state.turn_total_ms as f64 / runtime.state.turn_count as f64)
1121            } else {
1122                None
1123            };
1124
1125            let max_turn_duration_ms = if runtime.state.turn_count > 0 {
1126                Some(runtime.state.turn_max_ms)
1127            } else {
1128                None
1129            };
1130
1131            let outcome = runtime.state.outcome.clone();
1132            self.thread_handle
1133                .replace_messages(runtime.state.messages.clone());
1134            let summary = self.generate_task_summary(
1135                &effective_task,
1136                &runtime.state.modified_files,
1137                &runtime.state.executed_commands,
1138                &runtime.state.warnings,
1139                &runtime.state.messages,
1140                runtime.state.stats.turns_executed,
1141                runtime.state.max_tool_loop_streak,
1142                max_tool_loops,
1143                outcome,
1144                total_duration_ms,
1145                average_turn_duration_ms,
1146                max_turn_duration_ms,
1147            );
1148
1149            if !summary.trim().is_empty() {
1150                // Record summary as agent message for event stream
1151                event_recorder.agent_message(&summary);
1152                // Also display summary prominently for immediate visibility in TUI transcript
1153                self.runner_println(format_args!(
1154                    "\n{} Agent Task Summary\n{}",
1155                    style("[Task]").cyan().bold(),
1156                    summary
1157                ));
1158            }
1159
1160            let runtime_agent_config = self.core_agent_config();
1161            if let Err(err) = crate::persistent_memory::finalize_persistent_memory(
1162                &runtime_agent_config,
1163                Some(self.config()),
1164                &runtime.state.messages,
1165            )
1166            .await
1167            {
1168                warn!(
1169                    error = %err,
1170                    session_id = %self.session_id,
1171                    "Failed to update persistent memory"
1172                );
1173            }
1174
1175            if runtime.state.outcome.is_hard_block() || should_write_blocked_handoff {
1176                let relevant_paths = existing_harness_artifact_paths(&self._workspace);
1177                match write_blocked_handoff(
1178                    &self._workspace,
1179                    &self.session_id,
1180                    runtime.state.outcome.code(),
1181                    &runtime.state.outcome.description(),
1182                    &relevant_paths,
1183                ) {
1184                    Ok(artifacts) => emit_blocked_handoff_events(
1185                        &mut event_recorder,
1186                        &artifacts.current_path,
1187                        &artifacts.archive_path,
1188                    ),
1189                    Err(err) => warn!(
1190                        error = %err,
1191                        session_id = %self.session_id,
1192                        "Failed to persist blocked handoff"
1193                    ),
1194                }
1195            }
1196
1197            let total_usage = runtime.state.stats.total_usage.clone();
1198            record_terminal_turn_event(
1199                &mut event_recorder,
1200                &runtime.state.outcome,
1201                total_usage.clone(),
1202            );
1203            event_recorder.thread_completed(
1204                &self.session_id,
1205                runtime.state.outcome.thread_completion_subtype(),
1206                runtime.state.outcome.code(),
1207                runtime
1208                    .state
1209                    .outcome
1210                    .is_success()
1211                    .then_some(summary.as_str()),
1212                runtime.state.stop_reason.as_deref(),
1213                total_usage,
1214                runtime
1215                    .state
1216                    .total_cost_usd
1217                    .and_then(serde_json::Number::from_f64),
1218                runtime.state.stats.turns_executed,
1219            );
1220            let thread_events = event_recorder.into_events();
1221            let steering_receiver = runtime.take_steering_receiver();
1222            let state = std::mem::replace(
1223                &mut runtime.state,
1224                AgentSessionState::new(
1225                    self.session_id.clone(),
1226                    self.max_turns,
1227                    max_tool_loops,
1228                    max_context_tokens,
1229                ),
1230            );
1231
1232            Ok((
1233                state.into_results(summary, thread_events, total_duration_ms),
1234                steering_receiver,
1235            ))
1236        };
1237
1238        let result = match result {
1239            Ok((task_results, steering_receiver)) => {
1240                *self.steering_receiver.lock() = steering_receiver;
1241                Ok(task_results)
1242            }
1243            Err(err) => {
1244                *self.steering_receiver.lock() = runtime.take_steering_receiver();
1245                Err(err)
1246            }
1247        };
1248
1249        self.tool_registry.set_harness_task(None);
1250        result
1251    }
1252}
1253
1254#[cfg(test)]
1255mod tests {
1256    use super::{
1257        prepare_responses_request_messages, record_terminal_turn_event, tool_loop_limit_reached,
1258    };
1259    use crate::core::agent::events::ExecEventRecorder;
1260    use crate::core::agent::session::AgentSessionState;
1261    use crate::core::agent::task::TaskOutcome;
1262    use crate::exec::events::ThreadEvent;
1263    use crate::llm::provider::Message;
1264
1265    #[test]
1266    fn failed_outcome_emits_only_turn_failed() {
1267        let mut recorder = ExecEventRecorder::new("thread", None, None);
1268        recorder.turn_started();
1269
1270        record_terminal_turn_event(
1271            &mut recorder,
1272            &TaskOutcome::Failed {
1273                reason: "boom".to_string(),
1274            },
1275            Default::default(),
1276        );
1277
1278        let events = recorder.into_events();
1279        assert_eq!(
1280            events
1281                .iter()
1282                .filter(|event| matches!(event, ThreadEvent::TurnFailed(_)))
1283                .count(),
1284            1
1285        );
1286        assert_eq!(
1287            events
1288                .iter()
1289                .filter(|event| matches!(event, ThreadEvent::TurnCompleted(_)))
1290                .count(),
1291            0
1292        );
1293    }
1294
1295    #[test]
1296    fn successful_outcome_emits_only_turn_completed() {
1297        let mut recorder = ExecEventRecorder::new("thread", None, None);
1298        recorder.turn_started();
1299
1300        record_terminal_turn_event(&mut recorder, &TaskOutcome::Success, Default::default());
1301
1302        let events = recorder.into_events();
1303        assert_eq!(
1304            events
1305                .iter()
1306                .filter(|event| matches!(event, ThreadEvent::TurnCompleted(_)))
1307                .count(),
1308            1
1309        );
1310        assert_eq!(
1311            events
1312                .iter()
1313                .filter(|event| matches!(event, ThreadEvent::TurnFailed(_)))
1314                .count(),
1315            0
1316        );
1317    }
1318
1319    #[test]
1320    fn disabled_tool_loop_limit_never_trips() {
1321        assert!(!tool_loop_limit_reached(1, 0));
1322        assert!(!tool_loop_limit_reached(32, 0));
1323    }
1324
1325    #[test]
1326    fn openai_prepare_responses_request_messages_uses_incremental_suffix() {
1327        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1328        let prior_messages = vec![Message::user("hello".to_string())];
1329        let current_messages = vec![
1330            Message::user("hello".to_string()),
1331            Message::user("continue".to_string()),
1332        ];
1333        state.set_previous_response_chain("openai", "gpt-5.4", Some("resp_123"), prior_messages);
1334
1335        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1336            &mut state.previous_response_chains,
1337            "openai",
1338            false,
1339            "gpt-5.4",
1340            &current_messages,
1341        );
1342
1343        assert_eq!(previous_response_id.as_deref(), Some("resp_123"));
1344        assert_eq!(
1345            request_messages.as_ref(),
1346            [Message::user("continue".to_string())].as_slice()
1347        );
1348    }
1349
1350    #[test]
1351    fn gemini_prepare_responses_request_messages_keeps_full_history() {
1352        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1353        let prior_messages = vec![Message::user("hello".to_string())];
1354        let current_messages = vec![
1355            Message::user("hello".to_string()),
1356            Message::user("continue".to_string()),
1357        ];
1358        state.set_previous_response_chain(
1359            "gemini",
1360            "gemini-2.5-pro",
1361            Some("resp_123"),
1362            prior_messages,
1363        );
1364
1365        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1366            &mut state.previous_response_chains,
1367            "gemini",
1368            false,
1369            "gemini-2.5-pro",
1370            &current_messages,
1371        );
1372
1373        assert_eq!(previous_response_id.as_deref(), Some("resp_123"));
1374        assert_eq!(request_messages.as_ref(), current_messages.as_slice());
1375    }
1376
1377    #[test]
1378    fn compatible_prepare_responses_request_messages_uses_incremental_suffix() {
1379        let mut state = AgentSessionState::new("session".to_string(), 4, 4, 16_000);
1380        let prior_messages = vec![Message::user("hello".to_string())];
1381        let current_messages = vec![
1382            Message::user("hello".to_string()),
1383            Message::user("continue".to_string()),
1384        ];
1385        state.set_previous_response_chain("mycorp", "gpt-5.4", Some("resp_123"), prior_messages);
1386
1387        let (request_messages, previous_response_id) = prepare_responses_request_messages(
1388            &mut state.previous_response_chains,
1389            "mycorp",
1390            true,
1391            "gpt-5.4",
1392            &current_messages,
1393        );
1394
1395        assert_eq!(previous_response_id.as_deref(), Some("resp_123"));
1396        assert_eq!(
1397            request_messages.as_ref(),
1398            [Message::user("continue".to_string())].as_slice()
1399        );
1400    }
1401}