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