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