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 tool_def_tokens: u64,
48 pub(super) system_prompt_report: crate::prompts::system::SystemPromptReport,
54}
55
56impl RuntimePromptBundle {
57 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
74enum AssessmentResolution {
79 Break,
81 ForceContinue,
83 VerifyNotHandled,
86}
87
88struct 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 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 #[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 CompletionAssessment::Verify { .. } => Ok(AssessmentResolution::VerifyNotHandled),
441 }
442 }
443
444 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 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 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 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 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 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 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 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 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 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 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 self.validate_llm_request(&request)?;
905 let previous_response_chain_present = request.previous_response_id.is_some();
906 let sent_messages = Arc::clone(&request.messages);
911 let streaming_timeout = self
914 .config()
915 .timeouts
916 .ceiling_duration(self.config().timeouts.streaming_ceiling_seconds);
917
918 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 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 if refused {
1035 let reason = refusal::refusal_reason(&response);
1036 discard_refused_assistant_message(&mut runtime.state);
1037 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 #[allow(
1091 unused_assignments,
1092 reason = "Intentional compatibility, platform, or test-only suppression."
1093 )] 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 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 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 effective_tool_calls = None;
1160 forced_continuation = true;
1161 }
1162 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 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 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 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 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 return Err(anyhow::anyhow!("unexpected VerifyNotHandled from assess_completion"));
1405 }
1406 }
1407 }
1408 } else {
1409 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 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 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 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 self.runner_println(format_args!("{agent_prefix} Done"));
1646
1647 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 event_recorder.agent_message(&summary);
1681 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 assert!(should_apply_local_tool_result_clearing("anthropic", false, true));
1804 assert!(!should_apply_local_tool_result_clearing("anthropic", false, false));
1805 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 ¤t_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 ¤t_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 ¤t_messages,
1953 );
1954
1955 assert_eq!(previous_response_id, None);
1956 assert_eq!(request_messages.as_ref(), current_messages.as_slice());
1957 }
1958}