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