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