diff --git a/crates/hi-agent/src/agent/lifecycle.rs b/crates/hi-agent/src/agent/lifecycle.rs index 4bbccfb..2e997df 100644 --- a/crates/hi-agent/src/agent/lifecycle.rs +++ b/crates/hi-agent/src/agent/lifecycle.rs @@ -334,14 +334,7 @@ impl crate::Agent { // Clear per-turn / transient state from the previous session, matching // what `with_messages` initializes to None/empty for a fresh agent. self.goals.free_text = None; - self.goals.last_plan = if plan - .iter() - .any(|step| step.status != hi_tools::PlanStatus::Done) - { - plan - } else { - Vec::new() - }; + self.goals.set_plan_if_pending(plan); self.last_changed_files = Vec::new(); self.last_turn_telemetry = TurnTelemetry::default(); self.last_verify = None; @@ -354,18 +347,11 @@ impl crate::Agent { /// Install an unfinished plan reconstructed by session storage. pub fn restore_plan(&mut self, plan: Vec) { - self.goals.last_plan = if plan - .iter() - .any(|step| step.status != hi_tools::PlanStatus::Done) - { - plan - } else { - Vec::new() - }; + self.goals.set_plan_if_pending(plan); } pub fn current_plan(&self) -> &[hi_tools::PlanStep] { - &self.goals.last_plan + self.goals.plan() } /// Attach the runner that executes write-capable `delegate` subagents. Without @@ -454,11 +440,12 @@ impl crate::Agent { /// interrupt can discard the attempt without leaking decisions/goals/plans /// recorded during it. pub fn state_snapshot(&self) -> crate::AgentStateSnapshot { + let (goal, structured_goal, last_plan) = self.goals.snapshot_triple(); crate::AgentStateSnapshot { - goal: self.goals.free_text.clone(), - structured_goal: self.goals.structured.clone(), + goal, + structured_goal, decisions: self.decisions.clone(), - last_plan: self.goals.last_plan.clone(), + last_plan, } } @@ -466,10 +453,12 @@ impl crate::Agent { /// fallback after a failed durable discard so the current process still /// reflects the user's explicit interrupt. pub fn restore_state_snapshot(&mut self, snapshot: &crate::AgentStateSnapshot) { - self.goals.free_text = snapshot.goal.clone(); - self.goals.structured = snapshot.structured_goal.clone(); + self.goals.restore_triple( + snapshot.goal.clone(), + snapshot.structured_goal.clone(), + snapshot.last_plan.clone(), + ); self.decisions = snapshot.decisions.clone(); - self.goals.last_plan = snapshot.last_plan.clone(); self.refresh_system_message(); } @@ -507,10 +496,8 @@ impl crate::Agent { } self.messages.replace_all(next); self.persisted = self.messages.len(); - self.goals.free_text = snapshot.goal.clone(); - self.goals.structured = structured_goal; + self.goals.restore_triple(snapshot.goal.clone(), structured_goal, snapshot.last_plan.clone()); self.decisions = snapshot.decisions.clone(); - self.goals.last_plan = snapshot.last_plan.clone(); Ok(()) } @@ -996,10 +983,7 @@ impl crate::Agent { /// Set or clear the transient session goal and inject it into the system prompt. pub fn set_goal(&mut self, goal: Option) { - self.goals.free_text = goal.and_then(|g| { - let g = g.trim().to_string(); - (!g.is_empty()).then_some(g) - }); + self.goals.set_free_text(goal); self.refresh_system_message(); } @@ -1026,7 +1010,7 @@ impl crate::Agent { session.clear_goal()?; } } - self.goals.structured = goal; + self.goals.set_structured(goal); self.refresh_system_message(); Ok(true) } diff --git a/crates/hi-agent/src/agent/mutation_recovery_turn.rs b/crates/hi-agent/src/agent/mutation_recovery_turn.rs index f1da2ab..2642f51 100644 --- a/crates/hi-agent/src/agent/mutation_recovery_turn.rs +++ b/crates/hi-agent/src/agent/mutation_recovery_turn.rs @@ -1,6 +1,5 @@ //! Agent-facing UI and transcript handling for bounded mutation discovery. -use crate::heuristics::plan_has_pending_steps; use crate::steering::{ DiscoveryRecovery, EvidenceTracker, IMPLEMENTATION_NO_CHANGES_NUDGE, ImplementationTracker, MutationRecovery, @@ -32,7 +31,7 @@ impl Agent { if !expected_mutation { return MutationRecoveryControl::None; } - let has_pending_plan = plan_has_pending_steps(&self.goals.last_plan); + let has_pending_plan = self.goals.plan_incomplete(); if recovery.transition_after_plan(tracker, plan_changed, has_pending_plan) { *force_tools_next = true; ui.nudge( diff --git a/crates/hi-agent/src/agent/turn/loop_.rs b/crates/hi-agent/src/agent/turn/loop_.rs index 22e7474..719a298 100644 --- a/crates/hi-agent/src/agent/turn/loop_.rs +++ b/crates/hi-agent/src/agent/turn/loop_.rs @@ -1,8 +1,8 @@ //! The main turn loop: user message → model → tools → steer → workspace repair. //! -//! Control flow still lives in one async method, but Tools and Steer phases are -//! delegated to [`super::tools`] and [`super::steer`]. Pipeline phases are named -//! in [`super::phase::TurnPhase`]: +//! Model I/O lives in [`super::model_round`]; Tools and Steer are delegated to +//! [`super::tools`] and [`super::steer`]. Pipeline phases are named in +//! [`super::phase::TurnPhase`]: //! `Setup → (Model → Tools → Steer)* → WorkspaceRepair → Settle → Finalize → Done`. //! //! Two repair systems (do not conflate): @@ -12,38 +12,22 @@ use std::collections::BTreeSet; use anyhow::Result; -use hi_ai::{ - ChatRequest, Content, ProviderErrorKind, RequestProfile, Role, StreamEvent, ToolMode, - estimate_text_tokens, provider_error_kind, -}; -use hi_tools::PlanStatus; +use hi_ai::{ToolMode, estimate_text_tokens}; use crate::command; use crate::compaction; -use crate::heuristics::{ - RECOVERY_SAMPLING, StallMode, looks_like_continue, looks_like_unfinished_step, - parse_text_tool_calls, plan_has_pending_steps, recovery_sampling, recovery_telemetry, - textcall_id_offset, tool_mode_label, -}; -use crate::snapshot::changed_files_between; +use crate::heuristics::{looks_like_continue, tool_mode_label}; use crate::steering::{ - BOOKKEEPING_REPOST_NUDGE, EvidenceTracker, IMPLEMENTATION_EMPTY_TUI_NUDGE, - IMPLEMENTATION_NO_CHANGES_NUDGE, ImplementationIntent, ImplementationTracker, MutationRecovery, - PLAN_REPOST_NUDGE, READ_AFTER_SEARCH_NUDGE, READ_ONLY_SAFE_CONTEXT_WINDOW, REPEAT_NUDGE, - REREAD_NUDGE, ReviewIntent, SKIPPED_BOOKKEEPING_REPOST_RESULT, SKIPPED_PLAN_REPOST_RESULT, - SKIPPED_REPEATED_CALL_RESULT, TOOL_PROTOCOL_RETRY_NUDGE, TOOL_PROTOCOL_TEXT_FALLBACK_NUDGE, - ToolLoopGuardrail, active_read_only_inspection_cap, bash_call_waits, bash_no_progress_signature, + EvidenceTracker, IMPLEMENTATION_EMPTY_TUI_NUDGE, ImplementationIntent, ImplementationTracker, + MutationRecovery, ReviewIntent, ToolLoopGuardrail, active_read_only_inspection_cap, classify_implementation_intent, classify_read_only_intent, implementation_mentions_tui, - implementation_text_tool_nudge, implementation_turn_prompt, inspected_paths_for_prompt, - inspection_sprawl_exhausted, inspection_sprawl_nudge, read_only_turn_prompt, - should_nudge_inspection_sprawl, should_nudge_read_after_repeated_search, + implementation_turn_prompt, read_only_turn_prompt, }; use crate::transcript::NudgeKind; use crate::verify::{Snapshot, WorkspaceRepairVerifier}; use crate::{ - AUTO_KEEP_RECENT, MAX_TOOL_PROTOCOL_RETRIES, ReviewStatus, TRUNCATED_TOOL_CALL_NUDGE, - TRUNCATION_NUDGE, TaskContract, TaskIntent, ToolCallEntry, TurnOutcome, TurnStatus, - TurnStopReason, TurnTelemetry, Ui, VerificationMode, VerificationStatus, + AUTO_KEEP_RECENT, ReviewStatus, TaskContract, TaskIntent, ToolCallEntry, TurnOutcome, + TurnStatus, TurnStopReason, TurnTelemetry, Ui, VerificationMode, VerificationStatus, }; use super::helpers::{ @@ -51,16 +35,8 @@ use super::helpers::{ task_needs_repository_context, }; use super::phase::TurnPhase; -use super::progress::{ - NO_PROGRESS_FINAL_ANSWER_NUDGE, ProgressKind, ProgressTracker, forced_final_answer_is_unusable, - no_progress_signature_for_calls, -}; -use super::retry::{ - INCOMPLETE_STATUS, MAX_PROVIDER_OVERLOAD_RETRIES, MAX_TRANSIENT_ROUTE_RETRIES, - ReviewRepairState, TurnRetryState, delay_label, estimate_tool_schema_tokens, - output_cap_retry_tokens, provider_error_is_backoff_retryable, provider_overload_retry_delay, - transient_route_retry_delay, -}; +use super::progress::ProgressTracker; +use super::retry::{ReviewRepairState, TurnRetryState}; impl crate::Agent { /// Run one user turn to completion, emitting output through `ui`. @@ -239,9 +215,8 @@ impl crate::Agent { self.last_compat_fallbacks.clear(); self.last_turn_telemetry = TurnTelemetry::default(); let preserve_plan = (goal_drive_turn || looks_like_continue(&context_task)) - && plan_has_pending_steps(&self.goals.last_plan); - if !preserve_plan && !self.goals.last_plan.is_empty() { - self.goals.last_plan.clear(); + && self.goals.plan_incomplete(); + if self.goals.clear_plan_unless(preserve_plan) { if let Some(session) = self.session.as_mut() { session.clear_plan()?; } @@ -325,9 +300,8 @@ impl crate::Agent { // Clearing must also be emitted: the TUI owns a pinned copy and cannot // infer that the agent cleared its internal state. let preserve_plan = (goal_drive_turn || looks_like_continue(&context_task)) - && plan_has_pending_steps(&self.goals.last_plan); - if !preserve_plan && !self.goals.last_plan.is_empty() { - self.goals.last_plan.clear(); + && self.goals.plan_incomplete(); + if self.goals.clear_plan_unless(preserve_plan) { if let Some(session) = self.session.as_mut() { session.clear_plan()?; } @@ -390,7 +364,7 @@ impl crate::Agent { // against the sub-goal that was active *before* the turn (update_plan may // have marked it done mid-turn) and, on an objection, revert the turn's // goal progress. - let goal_before = self.goals.structured.clone(); + let goal_before = self.goals.clone_structured(); // Scheduler parallelism counters: how many calls ran this turn, the // largest concurrent ready-batch, and how many ran serially (bash or a // lone ready call). Flushed into telemetry so the dep-aware scheduler's @@ -527,1356 +501,124 @@ impl crate::Agent { 'turn: loop { // Inner loop: Model → Tools → Steer until tools stop, or step cap. let hit_cap = loop { - self.set_turn_phase(TurnPhase::Model); - if steps >= max_steps { - break true; - } - steps += 1; - - // Mid-turn steering: inject any messages the user typed while - // the turn was running, as genuine user messages, before the - // next model round. This is a safe transcript boundary — the - // prior round's tool calls are all resolved — so the folding - // nudge push keeps provider alternation valid. The model - // decides how to weigh them; we add no deferral directive. - let interjected = self.interjections.drain(); - if !interjected.is_empty() { - for message in &interjected { - self.messages.push_nudge_or_fold( - NudgeKind::Interjection, - format!( - "The user sent this message while you were working — take it into account now:\n{message}" - ), - ); - } - ui.status(&format!( - "✉ received {} message(s) from you mid-turn — factoring them in", - interjected.len() - )); - } - - // After a content-less/garbled round, resample hotter and with - // nucleus + frequency penalty on the retry to break out of the - // low-entropy attractor that produced it (cf. minion's recovery - // sampling). Bounded, and only while consecutively stalling — - // `empty_retries` resets on real output, so a normal round runs at - // the configured sampling. Toggleable via HI_RECOVERY_SAMPLING for - // A/B-ing on the eval harness. - let sampling_retries = empty_retries - .max(retry_state.protocol_retries) - .max(repeat_sampling_rounds); - let (sampling_mode, sampling_budget) = if repeat_sampling_rounds > 0 - && repeat_sampling_rounds >= empty_retries - && repeat_sampling_rounds >= retry_state.protocol_retries - { - // The model is deterministically re-emitting the same tool - // call round after round (observed live: four byte-identical - // `update_plan` calls despite nudges and withheld tools). - // Hotter sampling + a frequency penalty is what actually - // breaks a token-level loop; nudge text alone doesn't. - (StallMode::Repeat, self.config.loop_limits.max_repeat_nudges) - } else if retry_state.protocol_retries > empty_retries { - (StallMode::Empty, MAX_TOOL_PROTOCOL_RETRIES) - } else { - (StallMode::Empty, self.config.loop_limits.max_empty_retries) - }; - let (temperature, top_p, frequency_penalty) = recovery_sampling( - sampling_retries, - self.config.routing.temperature, - *RECOVERY_SAMPLING, - ); - - // Telemetry for the recovery-sampling A/B: emit a concise debug - // line only when sampling is actually being changed (recovery on - // and this is a retry), so ordinary runs stay quiet. - if let Some(line) = recovery_telemetry( - sampling_mode, - sampling_retries, - sampling_budget, - temperature, - top_p, - frequency_penalty, - *RECOVERY_SAMPLING, - ) { - ui.nudge(&line); - } - - let context_safety_window = read_only_intent - .is_some() - .then_some(READ_ONLY_SAFE_CONTEXT_WINDOW); - self.elide_in_turn_context_if_needed(ui, context_safety_window); - - self.refresh_active_task_context( - &context_task, - repository_context_enabled, - turn_ledger_revision, - &mut ranked_context_paths, - &mut context_generation_seen, - &mut indexed_ledger_revision, - ); - - self.messages.repair_invalid_tool_call_arguments(); - - // Debug-mode invariant check: the transcript we're about to send - // must be provider-safe (every tool_use answered, no consecutive - // user messages). Cheap in release builds; in debug it catches - // the orphan-tool_use class of bug at the source. - debug_assert!( - self.messages.validate_for_provider().is_ok(), - "transcript invariant violated before provider send" - ); - - let request_text_tool_fallback = text_tool_fallback_next; - text_tool_fallback_next = false; - let request_text_answer = force_text_answer_next; - force_text_answer_next = false; - let request_no_progress_final_answer = force_no_progress_final_answer_next; - if request_no_progress_final_answer { - progress_tracker.record_forced_final_answer_attempt(); - } - force_no_progress_final_answer_next = false; - - // After a continue-nudge, force this round to call a tool rather - // than narrate again or come back empty. Only when tools are - // freely available (Auto): never override an intentional - // ChatOnly/ReadOnly restriction, and Required already forces. - let tool_mode = if request_text_tool_fallback - || request_text_answer - || request_no_progress_final_answer - { - ToolMode::ChatOnly - } else if force_tools_next && self.config.routing.tool_mode == ToolMode::Auto { - ToolMode::Required - } else { - self.config.routing.tool_mode - }; - let tool_availability_mode = if request_text_tool_fallback - || request_text_answer - || request_no_progress_final_answer - { - ToolMode::ChatOnly - } else if read_only_intent.is_some() - && !matches!(self.config.routing.tool_mode, ToolMode::ChatOnly) - { - ToolMode::ReadOnly - } else { - self.config.routing.tool_mode - }; - let requested_request_max_tokens = - request_max_tokens_override.unwrap_or(self.config.routing.max_tokens); - let mut request_tools = self.request_tools_for(tool_availability_mode); - if suppress_bookkeeping_tools_next { - suppress_bookkeeping_tools_next = false; - // Only withhold when other tools remain — an empty tool - // list with tool_choice=required would be a provider error. - if request_tools - .iter() - .any(|tool| !hi_tools::is_coordination(&tool.name)) - { - request_tools = request_tools - .iter() - .filter(|tool| !hi_tools::is_coordination(&tool.name)) - .cloned() - .collect(); - } - } - advertised_tool_names.extend(request_tools.iter().map(|tool| tool.name.clone())); - let request_tool_schema_tokens = estimate_tool_schema_tokens(&request_tools); - tool_schema_tokens = tool_schema_tokens.max(request_tool_schema_tokens); - let context_preflight = match self.ensure_request_fits_context( - input, - turn_start, - requested_request_max_tokens, - request_tool_schema_tokens, - context_safety_window, - ui, - ) { - Ok(context_preflight) => context_preflight, - Err(err) => { - self.reconcile_error_turn_changes(turn_ledger_revision)?; - self.truncate_messages(turn_start); - self.add_error_usage(&err); - self.emit_usage(ui); - self.last_compat_fallbacks = compat_fallbacks.clone(); - self.last_turn_telemetry = build_turn_telemetry( - max_steps, - verifier.round(), - empty_retries, - repeat_nudges, - continue_total_nudges, - truncation_total_retries, - &progress_tracker, - ended_at_cap, - stalled_unfinished, - stalled_repeating, - &last_verify_attributions, - verifier.executions(), - sched_tool_calls, - sched_max_concurrent, - sched_serial_runs, - &tool_timeline, - &evidence, - &review_repair, - ); - let _ = self.persist(); - let (kind, guidance) = crate::ui::classify_error(&err); - ui.turn_error(kind, &err.to_string(), guidance); - self.last_effective_route = effective_model_route( - &self.config, - effective_fallback_route.as_deref(), - ); - return Err(err); - } - }; - if context_preflight.dropped_prior_context { - turn_start = self.messages.len().saturating_sub(1); - } - // Context fitting may itself compact or elide the transcript. - // Consume that generation before constructing the request. - self.refresh_active_task_context( - &context_task, - repository_context_enabled, - turn_ledger_revision, - &mut ranked_context_paths, - &mut context_generation_seen, - &mut indexed_ledger_revision, - ); - let request_max_tokens = context_preflight.max_tokens; - if request_max_tokens != requested_request_max_tokens { - request_max_tokens_override = Some(request_max_tokens); - } - let request = ChatRequest { - model: self.config.routing.model.clone(), - user_turn: true, - canonical_objective: Some(context_task.clone()), - messages: self.messages.arc(), - tools: request_tools, - max_tokens: request_max_tokens, - temperature, - top_p, - frequency_penalty, - thinking_budget: self.config.routing.thinking_budget, - reasoning_effort: self.config.routing.reasoning_effort, - profile: RequestProfile { - compat: self.config.routing.compat, - tool_mode, - stream_usage: None, - }, - }; - - let buffer_read_only_review_text = - read_only_intent.is_some() || implementation_intent.is_some(); - let mut buffered_assistant_text = String::new(); - let mut streamed_assistant_text = false; - let mut sink = |event: StreamEvent| match event { - StreamEvent::Text(text) => { - if buffer_read_only_review_text { - buffered_assistant_text.push_str(&text); - } else { - streamed_assistant_text = true; - ui.assistant_text(&text); - } - } - StreamEvent::Reasoning(text) => ui.assistant_reasoning(&text), - StreamEvent::Status(text) => { - if let Some(fallback) = text.strip_prefix("compat: ") { - compat_fallbacks.push(fallback.to_string()); - } - if let Some(route) = text.rsplit_once("falling back to ").map(|(_, r)| r) { - effective_fallback_route = Some(route.trim().to_string()); - } - ui.status(&text); - } - }; - let mut completion = match self.provider.stream(request, &mut sink).await { - Ok(completion) => { - retry_state.record_provider_success(); - completion - } - Err(err) - if !retry_state.output_cap_retry_attempted - && hi_ai::provider_output_cap_error(&err) - .and_then(|cap| output_cap_retry_tokens(request_max_tokens, cap)) - .is_some() => - { - ui.assistant_end(); - self.add_error_usage(&err); - self.emit_usage(ui); - retry_state.output_cap_retry_attempted = true; - let new_max = hi_ai::provider_output_cap_error(&err) - .and_then(|cap| output_cap_retry_tokens(request_max_tokens, cap)) - .expect("guard checked retry tokens"); - request_max_tokens_override = Some(new_max); - ui.nudge(&format!( - "provider rejected the output budget; retrying this turn with max_tokens={new_max}" - )); - continue; - } - Err(err) - if retry_state.provider_overload_retries - < MAX_PROVIDER_OVERLOAD_RETRIES - && provider_error_is_backoff_retryable(&err) => - { - ui.assistant_end(); - self.add_error_usage(&err); - self.emit_usage(ui); - retry_state.provider_overload_retries += 1; - let retry = retry_state.provider_overload_retries; - let delay = provider_overload_retry_delay(retry, &err); - let reason = if provider_error_kind(&err) - == Some(ProviderErrorKind::RateLimit) - { - "rate limited" - } else { - "request did not complete" - }; - ui.nudge(&format!( - "{reason}; retrying {} ({retry}/{MAX_PROVIDER_OVERLOAD_RETRIES})", - delay_label(delay) - )); - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - continue; - } - Err(err) - if retry_state.transient_route_retries < MAX_TRANSIENT_ROUTE_RETRIES - && hi_ai::provider_route_error_is_retryable(&err) => - { - ui.assistant_end(); - self.add_error_usage(&err); - self.emit_usage(ui); - retry_state.transient_route_retries += 1; - let retry = retry_state.transient_route_retries; - let delay = transient_route_retry_delay(retry, &err); - ui.nudge(&format!( - "request did not complete; retrying {} ({retry}/{MAX_TRANSIENT_ROUTE_RETRIES})", - delay_label(delay) - )); - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - continue; - } - Err(err) - if provider_error_kind(&err) - == Some(ProviderErrorKind::RequestTooLarge) => - { - let mut context_drop_persistence_failed = false; - if !retry_state.request_too_large_retried { - match self.retry_after_request_too_large(input, turn_start, ui) { - Ok(true) => { - retry_state.request_too_large_retried = true; - turn_start = self.messages.len().saturating_sub(1); - continue; - } - Ok(false) => {} - Err(persist_err) => { - ui.status(&format!( - "couldn't persist dropped-context retry state: {persist_err}" - )); - context_drop_persistence_failed = true; - } - } - } - self.truncate_messages(turn_start); - if context_drop_persistence_failed { - ui.status( - "request exceeds the provider limit, and prior context could not be \ - safely dropped because the session boundary was not persisted; fix \ - session storage or start a fresh/cleared session, then retry", - ); - } else { - ui.status( - "request still exceeds the provider limit with prior context removed; \ - shorten the prompt or attached input, then retry", - ); - } - self.add_error_usage(&err); - self.reconcile_error_turn_changes(turn_ledger_revision)?; - self.emit_usage(ui); - self.last_compat_fallbacks = compat_fallbacks.clone(); - self.last_turn_telemetry = build_turn_telemetry( - max_steps, - verifier.round(), - empty_retries, - repeat_nudges, - continue_total_nudges, - truncation_total_retries, - &progress_tracker, - ended_at_cap, - stalled_unfinished, - stalled_repeating, - &last_verify_attributions, - verifier.executions(), - sched_tool_calls, - sched_max_concurrent, - sched_serial_runs, - &tool_timeline, - &evidence, - &review_repair, - ); - let _ = self.persist(); - let (kind, guidance) = crate::ui::classify_error(&err); - ui.turn_error(kind, &err.to_string(), guidance); - self.last_effective_route = effective_model_route( - &self.config, - effective_fallback_route.as_deref(), - ); - return Err(err); - } - Err(err) - if provider_error_kind(&err) == Some(ProviderErrorKind::ToolProtocol) - && retry_state.protocol_retries < MAX_TOOL_PROTOCOL_RETRIES - && retry_state.protocol_failures_total - < crate::MAX_TOOL_PROTOCOL_FAILURES => - { - ui.assistant_end(); - self.add_error_usage(&err); - self.emit_usage(ui); - retry_state.protocol_retries += 1; - retry_state.protocol_failures_total += 1; - let protocol_retries = retry_state.protocol_retries; - if implementation_intent.is_some() || made_tool_call { - force_tools_next = true; - } - ui.nudge(&format!( - "⚠ the model emitted an invalid tool turn — retrying with tool-format guidance ({protocol_retries}/{MAX_TOOL_PROTOCOL_RETRIES})" - )); - if self - .messages - .as_slice() - .last() - .is_some_and(|message| message.role == Role::User) - { - self.messages.push_user_or_fold(TOOL_PROTOCOL_RETRY_NUDGE); - } else { - self.messages - .push_nudge(NudgeKind::Continue, TOOL_PROTOCOL_RETRY_NUDGE); - } - continue; - } - Err(err) - if provider_error_kind(&err) == Some(ProviderErrorKind::ToolProtocol) - && implementation_intent.is_some() - && retry_state.protocol_text_fallbacks < 1 => - { - ui.assistant_end(); - self.add_error_usage(&err); - self.emit_usage(ui); - retry_state.protocol_text_fallbacks += 1; - text_tool_fallback_next = true; - force_tools_next = false; - ui.status( - "structured tool calls kept failing; falling back to plain-text tool-call parsing", - ); - if self - .messages - .as_slice() - .last() - .is_some_and(|message| message.role == Role::User) - { - self.messages - .push_user_or_fold(TOOL_PROTOCOL_TEXT_FALLBACK_NUDGE); - } else { - self.messages - .push_nudge(NudgeKind::Continue, TOOL_PROTOCOL_TEXT_FALLBACK_NUDGE); - } - continue; - } - Err(err) - if provider_error_kind(&err) == Some(ProviderErrorKind::ToolProtocol) => - { - // Both the consecutive and cumulative invalid-tool-turn - // budgets are spent. A model that alternates a valid tool - // call with an invalid turn keeps resetting the consecutive - // counter, so without the cumulative cap this nudge-and-retry - // loop runs forever (spinning CPU, burning tokens). End the - // turn instead so the driver/user regains control; on a - // long-horizon drive the next turn resumes with a fresh budget. - ui.assistant_end(); - self.add_error_usage(&err); - self.emit_usage(ui); - ui.status( - "⚠ the model kept emitting invalid tool turns — ending the turn; /retry or continue to resume", - ); - break false; - } - // A transient generation flake — a malformed/garbled stream or - // an empty completion. Treat it like a content-less response: - // flush, then silently re-run with hotter recovery sampling (a - // fresh request, with its own transport retries) up to the same - // budget, instead of failing the turn. Terminal errors (auth, - // rate limits, ...) fall through to the abort below. Invalid tool turns - // use the protocol-specific nudge path above. - Err(err) - if empty_retries < self.config.loop_limits.max_empty_retries - && matches!( - provider_error_kind(&err), - Some( - ProviderErrorKind::MalformedStream - | ProviderErrorKind::EmptyCompletion - ) - ) => - { - ui.assistant_end(); - self.add_error_usage(&err); - self.emit_usage(ui); - empty_retries += 1; - if made_tool_call { - self.nudge_after_post_tool_empty_response( - &mut force_tools_next, - implementation_intent.is_some(), - ); - } - ui.nudge(&format!( - "⚠ the model's response didn't come through cleanly — \ - retrying ({empty_retries}/{})", - self.config.loop_limits.max_empty_retries - )); - continue; - } - Err(err) => { - self.add_error_usage(&err); - self.reconcile_error_turn_changes(turn_ledger_revision)?; - self.emit_usage(ui); - if self.last_changed_files.is_empty() - && let Some(turn_snapshot) = turn_snapshot.as_ref() - { - self.messages.strip_trailing_nudges(); - if let Ok(end_snapshot) = self.snapshot_cached().await { - self.last_changed_files = - changed_files_between(turn_snapshot, &end_snapshot); - } - } - // With no model tool call, any concurrent workspace - // change was external to this failed attempt. Preserve - // it in the report, but never retain the failed user - // prompt or retry guidance in conversation history. - if !made_tool_call { - self.truncate_messages(turn_start); - } - self.last_compat_fallbacks = compat_fallbacks.clone(); - self.last_turn_telemetry = build_turn_telemetry( + match self + .run_model_round( + &mut super::model_round::ModelRoundState { + steps: &mut steps, + empty_retries: &mut empty_retries, + truncation_retries: &mut truncation_retries, + truncation_total_retries: &mut truncation_total_retries, + silent_continues: &mut silent_continues, + continue_total_nudges: &mut continue_total_nudges, + repeat_nudges: &mut repeat_nudges, + repeat_sampling_rounds: &mut repeat_sampling_rounds, + force_tools_next: &mut force_tools_next, + text_tool_fallback_next: &mut text_tool_fallback_next, + force_text_answer_next: &mut force_text_answer_next, + force_no_progress_final_answer_next: &mut force_no_progress_final_answer_next, + suppress_bookkeeping_tools_next: &mut suppress_bookkeeping_tools_next, + prev_call_sig: &mut prev_call_sig, + prev_added_no_evidence: &mut prev_added_no_evidence, + made_tool_call: &mut made_tool_call, + retry_state: &mut retry_state, + request_max_tokens_override: &mut request_max_tokens_override, + turn_start: &mut turn_start, + stalled_repeating: &mut stalled_repeating, + stalled_unfinished: &mut stalled_unfinished, + compat_fallbacks: &mut compat_fallbacks, + effective_fallback_route: &mut effective_fallback_route, + ranked_context_paths: &mut ranked_context_paths, + context_generation_seen: &mut context_generation_seen, + indexed_ledger_revision: &mut indexed_ledger_revision, + progress_tracker: &mut progress_tracker, + evidence: &mut evidence, + implementation_tracker: &mut implementation_tracker, + review_repair: &mut review_repair, + tool_guardrail: &mut tool_guardrail, + last_verify_attributions: &mut last_verify_attributions, + tool_timeline: &mut tool_timeline, + sched_tool_calls: &mut sched_tool_calls, + sched_max_concurrent: &mut sched_max_concurrent, + sched_serial_runs: &mut sched_serial_runs, + advertised_tool_names: &mut advertised_tool_names, + tool_schema_tokens: &mut tool_schema_tokens, + ended_at_cap: &mut ended_at_cap, + turn_snapshot: &mut turn_snapshot, max_steps, - verifier.round(), - empty_retries, - repeat_nudges, - continue_total_nudges, - truncation_total_retries, - &progress_tracker, - ended_at_cap, - stalled_unfinished, - stalled_repeating, - &last_verify_attributions, - verifier.executions(), - sched_tool_calls, - sched_max_concurrent, - sched_serial_runs, - &tool_timeline, - &evidence, - &review_repair, - ); - let _ = self.persist(); - let (kind, guidance) = crate::ui::classify_error(&err); - ui.turn_error(kind, &err.to_string(), guidance); - self.last_effective_route = effective_model_route( - &self.config, - effective_fallback_route.as_deref(), - ); - return Err(err); - } - }; - if !buffer_read_only_review_text { - ui.assistant_end(); - } - - self.add_usage(completion.usage); - // Let the frontend show the running total climb mid-turn. - self.emit_usage(ui); - - // Truncation recovery: the model hit the output token cap - // (`stop_reason: "length"` / `"max_tokens"`) mid-generation. - // The response was cut off, not finished — record what it - // produced and nudge it to continue from the cutoff, instead - // of treating the truncation as a natural stop (which would - // end the turn on a half-finished output and leave the model - // "picking up where it stalled" on the next prompt). Bounded - // by a *dedicated* truncation budget (separate from - // `empty_retries`) so a big task that legitimately hits the - // cap several times can still finish without the user typing - // "continue". - let truncated = matches!( - completion.stop_reason.as_deref(), - Some("length" | "max_tokens") - ); - if truncated && truncation_retries < self.config.loop_limits.max_truncation_retries { - truncation_retries += 1; - truncation_total_retries += 1; - ui.nudge(&format!( - "⚠ the model hit the output token limit — continuing ({truncation_retries}/{})", - self.config.loop_limits.max_truncation_retries - )); - // Clean text-embedded tool-call JSON (local models) from the - // truncated content before recording. Complete tool calls are - // extracted and stripped; partial JSON (cut off mid-generation) - // stays as text so the model can continue from the cutoff. - // Structured ToolCall blocks are stripped: a truncated tool call - // has partial/malformed arguments and was never executed, so it - // has no matching tool_result. Leaving it in would create an - // orphan tool_use that providers reject on the next request. - let partial_tool_call = - self.clean_text_tool_calls_from_content(&mut completion.content); - let truncated_text = completion - .content - .iter() - .filter_map(|c| match c { - Content::Text(t) => Some(t.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - let active_tool_work = read_only_intent.is_none() - && (implementation_intent.is_some() - || made_tool_call - || implementation_tracker.mutation_seen - || plan_has_pending_steps(&self.goals.last_plan) - || looks_like_unfinished_step(&truncated_text)); - if (partial_tool_call || active_tool_work) - && self.config.routing.tool_mode == ToolMode::Auto - { - force_tools_next = true; - } - self.messages - .push_assistant_text_only(std::mem::take(&mut completion.content)); - self.messages.push_nudge( - NudgeKind::Truncation, - if partial_tool_call || active_tool_work { - TRUNCATED_TOOL_CALL_NUDGE - } else { - TRUNCATION_NUDGE + context_task: &context_task, + repository_context_enabled, + turn_ledger_revision, + read_only_intent, + implementation_intent, + read_only_inspection_cap, + expected_mutation, + input, + user_prompt_tokens, + inspection_sprawl_intent, + verifier: &verifier, }, - ); - continue; - } - // Truncation budget exhausted: the model kept hitting the output - // token cap through the whole retry budget. Record the truncated - // output (stripping partial tool calls, as above) and warn the - // user — the task may be incomplete. Don't silently end the turn - // on a half-finished output without surfacing what happened. - if truncated { - self.clean_text_tool_calls_from_content(&mut completion.content); - self.messages - .push_assistant_text_only(std::mem::take(&mut completion.content)); - stalled_unfinished = true; - ui.nudge(&format!( - "⚠ the model hit the output token limit {max} times — the task may be \ - incomplete. /retry, or send 'continue'.", - max = self.config.loop_limits.max_truncation_retries, - )); - break false; - } - // A public RSI response is terminal, not a local planning round to nudge. - if completion.stop_reason.as_deref() == Some("rsi_remote_completed") { - let answer = completion - .content - .iter() - .filter_map(|content| match content { - Content::Text(text) => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - if !answer.trim().is_empty() - && (buffer_read_only_review_text || !streamed_assistant_text) - { - ui.assistant_text(&answer); - ui.assistant_end(); - } - self.messages - .push_assistant(std::mem::take(&mut completion.content)); - progress_tracker.record_final_answer(); - break false; - } - - let calls: Vec<(String, String, String)> = - if request_text_answer || request_no_progress_final_answer { - Vec::new() - } else { - completion - .tool_calls() - .into_iter() - .map(|c| { - ( - c.id.to_string(), - c.name.to_string(), - c.arguments.to_string(), - ) - }) - .collect() - }; - - // Fallback for local models (Ollama, llama.cpp, etc.) that emit - // tool calls as text — raw JSON like {"name":"bash","arguments":…} - // — instead of using the structured `tool_calls` API field. When - // the API returned no structured calls, scan the assistant text - // for tool-call JSON and promote any matches to real ToolCall - // blocks so they actually execute. The raw JSON is stripped from - // the recorded text so history stays clean. - let calls = if calls.is_empty() - && !request_text_answer - && !request_no_progress_final_answer + ui, + ) + .await? { - let full_text: String = completion - .content - .iter() - .filter_map(|c| match c { - Content::Text(t) => Some(t.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - let parsed = - parse_text_tool_calls(&full_text, textcall_id_offset(&self.messages)); - if parsed.iter().any(|c| matches!(c, Content::ToolCall { .. })) { - // Replace text blocks with the interleaved content - // (prose segments + ToolCall blocks in emission order), - // preserving any Thinking blocks from the original. - let mut new_content = Vec::new(); - let mut parsed_iter = parsed.into_iter().peekable(); - for c in completion.content.iter() { - match c { - Content::Text(_) => { - // Drain the parsed content that corresponds to - // this text block (all of it — the original had - // one Text block with the full raw text). - for p in parsed_iter.by_ref() { - new_content.push(p); - } - } - Content::Thinking { .. } => new_content.push(c.clone()), - _ => {} - } - } - // If the original had no Text block (shouldn't happen for - // the local-model path, but be safe), drain remaining. - for p in parsed_iter { - new_content.push(p); - } - completion.content = new_content; - completion - .tool_calls() - .into_iter() - .map(|c| { - ( - c.id.to_string(), - c.name.to_string(), - c.arguments.to_string(), - ) - }) - .collect() - } else { - Vec::new() - } - } else { - calls - }; - - // Repetition guard: the model re-issued the exact same tool - // calls (same names, same arguments, same order) as the previous - // round. Re-running most tools can only reproduce the same - // output, so don't execute — nudge the model to act on the output - // it already has. `bash_output` is intentionally excluded from - // this exact-match shortcut because a live background process is - // time-dependent and can emit new output between identical polls; - // completed/missing/pruned handles are caught below by the - // stale-background no-new-evidence path. Bounded; past the - // budget the turn ends with an honest "stuck repeating" notice - // rather than looping until `max_steps`. - let call_sig: Vec<(String, String)> = calls - .iter() - .map(|(_, name, args)| (name.clone(), args.clone())) - .collect(); - let has_background_output_poll = calls - .iter() - .any(|(_, name, _)| name.as_str() == "bash_output"); - let has_background_handle_call = calls - .iter() - .any(|(_, name, _)| matches!(name.as_str(), "bash_output" | "bash_kill")); - let has_no_progress_bash = calls.iter().any(|(_, name, args)| { - name == "bash" && bash_no_progress_signature(args).is_some() - }); - // A bash command that deliberately waits before sampling state - // ("sleep 300 && du -sh models/") is time-dependent the same - // way a `bash_output` poll is: re-running it verbatim is how - // the model watches a slow external process (a download, a - // long build, a warming server), and each run can return new - // output. Exempt such rounds from the signature-based repeat - // guards; the result-hash guard below still catches the - // static case (the same poll returning byte-identical output), - // so a wait loop stays bounded without punishing legitimate - // progress-watching. - let has_wait_poll_bash = calls - .iter() - .any(|(_, name, args)| name == "bash" && bash_call_waits(args)); - let exact_repeat = !calls.is_empty() - && !has_background_output_poll - && !has_wait_poll_bash - && prev_call_sig.as_ref() == Some(&call_sig); - // No-new-evidence cycle guard: a round whose every call is a - // read-only inspection (read/list/grep/glob) or stale background - // handle operation already performed earlier this turn. This - // catches multi-step cycles like - // A→B→C→A→B→C — including grep/list cycles, not just re-reads — - // that evade the exact-match check because each round differs - // from the one right before it. On large workspaces such a cycle - // can otherwise loop until `max_steps` without ever re-issuing an - // identical round. `EvidenceTracker::round_adds_evidence` keys on - // a stable per-inspection signature (read path/page, list path, - // grep pattern/glob/path/context, stale background handle id), so - // any re-inspection is caught regardless of cycle length or tool - // mix. Shares the same - // `repeat_nudges` budget as the exact-match guard so it stays - // bounded. - // - // Fires only on the *second* consecutive no-new-evidence round - // (`prev_added_no_evidence`): a single re-inspection right after - // new evidence is allowed through (e.g. re-reading a file once a - // broader search has surfaced something to re-examine, or paging - // further into a file). Once the turn has made a successful - // mutation, this guard is advisory only: after the nudge budget - // is spent, execute the inspection rather than hard-stalling a - // long implementation harness in the middle of a later plan step. - let no_new_evidence = !calls.is_empty() && !evidence.round_adds_evidence(&calls); - let stale_background_handle_call = no_new_evidence && has_background_handle_call; - // A wait-poll round re-runs a seen inspection signature by - // design, so it must not trip the no-new-evidence cycle guard - // either — its staleness is judged by output, below. - let is_repeat = exact_repeat - || (no_new_evidence - && !has_wait_poll_bash - && (prev_added_no_evidence || stale_background_handle_call)); - let no_new_after_mutation = is_repeat - && no_new_evidence - && implementation_tracker.mutation_seen - && !stale_background_handle_call; - let repeat_budget_available = repeat_nudges < self.config.loop_limits.max_repeat_nudges; - let should_skip_for_repeat = - is_repeat && (!no_new_after_mutation || repeat_budget_available); - if should_skip_for_repeat { - // We deliberately do NOT execute the repeated tool calls, - // but the calls stay in the transcript, each paired with a - // synthetic result that says why it was skipped. Stripping - // them (as this path once did) left the model's turn as a - // bare placeholder with no result for the call it just - // made — weak models concluded the tool layer was broken - // ("my tool calls aren't producing visible output") and - // gave up instead of correcting course. Pairing every - // skipped `tool_use` with a `tool_result` also keeps the - // transcript in the shape providers require. - let all_plan_reposts = calls.iter().all(|(_, name, _)| name == "update_plan"); - let all_bookkeeping_reposts = calls - .iter() - .all(|(_, name, _)| hi_tools::is_coordination(name)); - let skip_results: Vec<(String, String)> = calls - .iter() - .map(|(id, name, _)| { - let note = if name == "update_plan" { - SKIPPED_PLAN_REPOST_RESULT - } else if hi_tools::is_coordination(name) { - SKIPPED_BOOKKEEPING_REPOST_RESULT - } else { - SKIPPED_REPEATED_CALL_RESULT - }; - (id.clone(), note.to_string()) - }) - .collect(); - self.messages.push_assistant_with_results( - std::mem::take(&mut completion.content), - skip_results, - ); - if repeat_budget_available { - repeat_nudges += 1; - repeat_sampling_rounds += 1; - stalled_repeating = true; - let stall_reason = if all_plan_reposts { - "unchanged plan repost" - } else if all_bookkeeping_reposts { - "repeated bookkeeping call" - } else if stale_background_handle_call { - "stale background handle" - } else if has_no_progress_bash { - "semantic no-op bash command" - } else if no_new_evidence { - "repeated inspection signature" - } else { - "skipped repeated calls" - }; - // Never force a chat-only "final answer" after bookkeeping - // loops on a mutation turn. That path exists for inspection - // stalls where the model already has evidence to summarize; - // on an edit request it just ends the turn incomplete with - // zero file changes (live: "I started the fix but didn't - // land the edit"). Keep tools required and let the - // budget-exhausted branch hand off to implementation repair. - let force_final_after_nudge = progress_tracker.record_no_progress_nudge( - stall_reason, - no_progress_signature_for_calls(&calls), - ) && !no_new_after_mutation - && implementation_intent.is_none() - && !(expected_mutation && all_bookkeeping_reposts); - let nudge = if all_bookkeeping_reposts { - if all_plan_reposts { - ui.nudge(&format!( - "the model re-posted an unchanged plan — withholding \ - bookkeeping tools for a round and nudging it to execute \ - the next step ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - } else { - ui.nudge(&format!( - "the model repeated bookkeeping calls without real work — \ - withholding bookkeeping tools for a round \ - ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - } - suppress_bookkeeping_tools_next = true; - force_tools_next = true; - // Cancel any prior force-final from a mixed stall so the - // bookkeeping withhold round still has real tools. - force_no_progress_final_answer_next = false; - if all_plan_reposts { - PLAN_REPOST_NUDGE.to_string() - } else { - BOOKKEEPING_REPOST_NUDGE.to_string() - } - } else if stale_background_handle_call { - if has_background_output_poll { - ui.nudge(&format!( - "the model kept polling stale background process handles — \ - nudging it to stop polling them ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - "The background process handle you just polled is completed, missing, or pruned, so polling it again cannot produce new output. Do not call bash_output for that handle again. Continue from the available output, restart the command if you still need it, or finish with the current result.".to_string() - } else { - ui.nudge(&format!( - "the model kept using stale background process handles — \ - nudging it to stop using them ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - "The background process handle you just used is already killed, already exited, missing, or pruned, so calling bash_kill for it again cannot change anything. Do not call bash_kill for that handle again. Continue from the available output, restart the command if you still need it, or finish with the current result.".to_string() - } - } else if should_nudge_read_after_repeated_search( + super::model_round::ModelRoundControl::Continue => continue, + super::model_round::ModelRoundControl::BreakInner(hit) => break hit, + super::model_round::ModelRoundControl::RunTools { + calls, + completion_content, + } => { + let mut completion_content = completion_content; + made_tool_call = true; + silent_continues = 0; + force_tools_next = false; + self.set_turn_phase(TurnPhase::Tools); + let batch = self + .execute_tool_batch( + &calls, + &mut completion_content, read_only_intent, - &evidence, - ) { - ui.nudge(&format!( - "the model re-ran the same search — nudging it to read a matching file ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - READ_AFTER_SEARCH_NUDGE.to_string() - } else if implementation_intent.is_some() - && no_new_evidence - && (evidence.saw_read || evidence.saw_search) - { - // Concrete, actionable nudge for implementation tasks: - // name the inspected files and the next plan step (if - // any) so the model has a specific action to take - // instead of a generic "start editing." A strong model - // responds to one concrete nudge; a weak one won't - // respond to any number, so the budget stays tight (2). - // Only fires for no-new-evidence cycles (re-reading - // already-inspected files); exact repeats of non-read - // tools (e.g. re-running a bash command) fall through - // to the generic REPEAT_NUDGE below, which says "don't - // re-run that command" — the right message for that case. - ui.nudge(&format!( - "the model re-read files it already inspected — their contents are \ - already above; nudging it to act on them ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - let paths = inspected_paths_for_prompt(&evidence); - let plan_step = self - .goals.last_plan - .iter() - .find(|s| { - s.status == PlanStatus::Pending - || s.status == PlanStatus::Active - }) - .map(|s| s.title.as_str()); - if let Some(step) = plan_step { - format!( - "You already inspected these files: {paths}. Their contents are in the conversation above — do not re-read them. \ -Your plan's next step is: \"{step}\". Execute it now with write/edit/multi_edit/apply_patch. \ -Do not read more files first — you have enough context. Act on the next plan step immediately." - ) - } else { - format!( - "You already inspected these files: {paths}. Their contents are in the conversation above — do not re-read them. \ -You have enough context to make progress. Edit one of the inspected files now with write/edit/multi_edit/apply_patch. \ -If the task is already complete, stop and give your final recap." - ) - } - } else if has_no_progress_bash { - ui.nudge(&format!( - "the model kept running no-op shell commands — nudging it to finish without more bash calls ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - "The bash command you just called only says stop/quit/done or otherwise does no work. Do not call bash for that. If the task is complete, finish with a text answer; otherwise use a tool that inspects or changes the workspace.".to_string() - } else if no_new_evidence && !exact_repeat { - ui.nudge(&format!( - "the model re-read files it already inspected — their contents are \ - already above; nudging it to act on them ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - REREAD_NUDGE.to_string() - } else { - ui.nudge(&format!( - "the model re-ran the same command — its output is already above; \ - nudging it to act on it ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - REPEAT_NUDGE.to_string() - }; - let nudge = if force_final_after_nudge { - force_no_progress_final_answer_next = true; - force_tools_next = false; - format!("{nudge}\n\n{NO_PROGRESS_FINAL_ANSWER_NUDGE}") - } else { - nudge - }; - self.messages.push_nudge(NudgeKind::Repeat, nudge); - // Keep prev_call_sig as-is so a further repeat is still - // detected against the same signature. - continue; - } - if stale_background_handle_call { - ui.status( - "background process handles were completed, missing, or pruned (or already killed) and the model kept using them — the task may be incomplete. /retry, or send 'continue'.", - ); - break false; - } - if has_no_progress_bash { - stalled_unfinished = true; - ui.nudge("model repeated no-op shell commands; stopping incomplete"); - ui.status(INCOMPLETE_STATUS); - break false; - } - if read_only_intent.is_some() && evidence.saw_search && !evidence.saw_read { - stalled_unfinished = true; - ui.nudge( - "review repeated the same search without reading files; stopping incomplete", - ); - ui.status(INCOMPLETE_STATUS); - break false; - } - if let Some(intent) = read_only_intent - && (evidence.saw_read || evidence.saw_search) - { - stalled_unfinished = true; - ui.nudge( - "review repeated the same command after inspection; stopping incomplete", - ); - let _ = (intent, &evidence); - ui.status(INCOMPLETE_STATUS); - break false; - } - // Implementation / explicit-mutation turns that burned the - // repeat budget on non-mutating work must not hard-stop yet. - // Two live failure modes share this path: - // 1. re-reading already-inspected files without editing - // 2. pure bookkeeping loops (identical update_plan / - // record_decision) that never even inspected the tree - // Case (2) used to fall through to the generic "kept - // re-running the same command" stop because the old gate - // required saw_read/saw_search. That branded turns as - // `incomplete · stalled` after two plan re-posts even when - // the model still had the implementation repair budget — - // exactly the "I started that fix but didn't land the - // edit" stall. Bookkeeping is zero-progress meta-work, not - // a dangerous inspection loop; hand it the same edit nudge. - let bookkeeping_only_stall = calls - .iter() - .all(|(_, name, _)| hi_tools::is_coordination(name)); - let implementation_needs_mutation = !implementation_tracker.mutation_seen - && (implementation_intent.is_some() || expected_mutation) - && ((evidence.saw_read || evidence.saw_search) || bookkeeping_only_stall); - if implementation_needs_mutation { - if implementation_tracker.no_change_nudges < 2 { - implementation_tracker.no_change_nudges += 1; - evidence.quality_repair_nudges = - evidence.quality_repair_nudges.saturating_add(1); - let use_text_fallback = implementation_tracker.no_change_nudges >= 2; - force_tools_next = !use_text_fallback; - text_tool_fallback_next = use_text_fallback; - // Clear the sticky repeat stall: we are converting it - // into an implementation-repair continue, not ending - // the turn as stalled_repeating. - stalled_repeating = false; - // Drop the sticky prev signature so the next real - // tool call isn't immediately compared against the - // bookkeeping-only round that just exhausted the - // repeat budget. - prev_call_sig = None; - prev_added_no_evidence = false; - if bookkeeping_only_stall { - // Keep bookkeeping withheld while we demand real - // work — otherwise the model just re-posts the - // plan again on the repair round. - suppress_bookkeeping_tools_next = true; - ui.nudge( - "implementation burned the bookkeeping-repeat budget without editing; nudging the model to edit or scaffold", - ); - } else { - ui.nudge( - "implementation kept repeating without editing; nudging the model to edit or scaffold", - ); - } - let nudge = if use_text_fallback { - implementation_text_tool_nudge(IMPLEMENTATION_NO_CHANGES_NUDGE) - } else { - IMPLEMENTATION_NO_CHANGES_NUDGE.to_string() - }; - self.messages.push_nudge(NudgeKind::Continue, nudge); - continue; - } - - stalled_unfinished = true; - ui.nudge( - "implementation kept repeating without editing; no file changes were made", - ); - ui.status(INCOMPLETE_STATUS); - break false; - } - ui.status( - "⚠ the model kept re-running the same command without acting on the \ - result — the task may be incomplete. /retry, or send 'continue'.", - ); - break false; - } - // A different set of calls (or none) this round — the model moved - // on, so clear any pending repeat-stall state. A wait-poll - // round is not counted as the first wasted round of a cycle: - // waiting on external state is progress-neutral, not evidence - // of a loop. - stalled_repeating = false; - repeat_sampling_rounds = 0; - prev_call_sig = Some(call_sig); - prev_added_no_evidence = no_new_evidence && !has_wait_poll_bash; - - // Inspection-sprawl guard: a read-only review turn that keeps - // reading *distinct* files (each a new inspection signature, so - // the repeat/cycle guard above never fires) without ever - // producing findings. Once enough evidence has accumulated, - // nudge the model to answer; if it keeps sprawling past the - // budget, stop incomplete rather than fabricate an answer. This is - // the only guard that catches the "read 100 files, never - // answer" failure mode — all review-quality guards fire only - // on a final text answer, which never comes while the model - // keeps issuing tool calls. - if inspection_sprawl_exhausted( - inspection_sprawl_intent, - &evidence, - &calls, - read_only_inspection_cap, - ) { - stalled_unfinished = true; - ui.nudge( - "review kept inspecting new files without producing findings; stopping incomplete", - ); - ui.status(INCOMPLETE_STATUS); - break false; - } - if should_nudge_inspection_sprawl( - inspection_sprawl_intent, - &evidence, - &calls, - read_only_inspection_cap, - ) { - evidence.inspection_sprawl_nudges = - evidence.inspection_sprawl_nudges.saturating_add(1); - force_text_answer_next = true; - let cap = read_only_inspection_cap - .unwrap_or_else(|| evidence.inspection_attempt_count()); - ui.nudge(&format!( - "review inspected {} files/searches without answering; nudging it to produce findings", - evidence.inspection_attempt_count() - )); - self.messages - .push_assistant_text_only(std::mem::take(&mut completion.content)); - self.messages.push_nudge( - NudgeKind::Continue, - inspection_sprawl_nudge(cap, evidence.inspection_attempt_count()), - ); - continue; - } - - // This round's assistant text, joined and captured before the - // content is moved into history. Used both to detect a content-less - // response (a reasoning model can return only reasoning tokens or - // whitespace) and to spot an announced-but-unperformed next step. - let assistant_text: String = completion - .content - .iter() - .filter_map(|c| match c { - Content::Text(t) => Some(t.as_str()), - _ => None, - }) - .collect::>() - .join("\n"); - let has_text = !assistant_text.trim().is_empty(); - - if request_no_progress_final_answer { - let unusable = forced_final_answer_is_unusable( - &assistant_text, - plan_has_pending_steps(&self.goals.last_plan), - ); - if has_text && (buffer_read_only_review_text || !streamed_assistant_text) { - let text_to_emit = if buffered_assistant_text.is_empty() { - assistant_text.as_str() - } else { - buffered_assistant_text.as_str() - }; - ui.assistant_text(text_to_emit); - ui.assistant_end(); - } - if unusable { - self.messages - .push_assistant_text_only(std::mem::take(&mut completion.content)); - stalled_unfinished = true; - progress_tracker.record( - ProgressKind::None, - "forced final-answer attempt was unusable", - None, - ); - ui.status(INCOMPLETE_STATUS); - break false; - } - self.messages - .push_assistant(std::mem::take(&mut completion.content)); - progress_tracker.record_final_answer(); - break false; - } - - // Auto-recover from a content-less response — no tool calls and no - // text, i.e. a flaky provider returning only reasoning or an empty - // message. Silently re-run a few times before giving up, each - // retry resampling hotter (see the temperature bump above). The - // dead round isn't recorded, so each retry re-runs with the - // original context. - if calls.is_empty() && !has_text { - if empty_retries < self.config.loop_limits.max_empty_retries { - empty_retries += 1; - if made_tool_call { - self.nudge_after_post_tool_empty_response( - &mut force_tools_next, - implementation_intent.is_some(), - ); - } - ui.status(&format!( - "⚠ the model returned no response — retrying ({empty_retries}/{})", - self.config.loop_limits.max_empty_retries - )); - continue; - } - ui.status("⚠ the model returned no response after retrying — try /retry."); - break false; - } - // Real output this round — clear the retry counter so the - // temperature bump is transient: a later, unrelated stall gets - // its own budget rather than inheriting this one's elevation. - empty_retries = 0; - retry_state.protocol_retries = 0; - truncation_retries = 0; - - if calls.is_empty() { - match self.steer_without_tools( - &assistant_text, - &mut completion.content, + max_parallel_tools, + &task_contract, + &mut implementation_tracker, + &mut evidence, + &mut tool_guardrail, + &mut progress_tracker, + &mut tool_timeline, + &mut sched_tool_calls, + &mut sched_max_concurrent, + &mut sched_serial_runs, + &mut plan_updated_goal, + &mut proposed_goal, + &mut turn_snapshot, + &mut turn_checkpoint_allowed, + &mut turn_checkpoint_created, + &mut fast_feedback, + ui, + ) + .await?; + match self.steer_after_tools( + &calls, + &batch, + expected_mutation, read_only_intent, implementation_intent, &mut implementation_tracker, &mut evidence, - &mut review_repair, + &mut mutation_recovery, &mut progress_tracker, - &mut silent_continues, - &mut continue_total_nudges, + &mut repeat_nudges, &mut force_tools_next, - &mut force_text_answer_next, &mut text_tool_fallback_next, + &mut force_no_progress_final_answer_next, + &mut prev_added_no_evidence, + &mut stalled_repeating, &mut stalled_unfinished, - &mut buffered_assistant_text, - buffer_read_only_review_text, - steps, ui, ) { - super::steer::RoundControl::Continue => continue, + super::steer::RoundControl::Continue => {} super::steer::RoundControl::BreakInner(hit) => break hit, } - } - // The model requested tool calls — it's actively working. - made_tool_call = true; - // Real progress this round, so clear the silent-continue counter: - // the budget bounds *consecutive* narrate-without-acting stalls, - // not their total across the turn. A long, productive turn that - // reads many files but occasionally narrates a step without the - // tool call (a quirk of some models) recovers each time via the - // nudge — without this reset the counter would creep up across - // the whole turn and kill the turn mid-progress on the Nth stall - // even though the model acted between every one. Mirrors the - // `empty_retries = 0` reset above (a later stall gets its own - // budget rather than inheriting an earlier one's). - silent_continues = 0; - // The model acted, so drop the forced-tool-choice we may have set - // after a nudge — the next round is free to narrate or finish. - force_tools_next = false; - self.set_turn_phase(TurnPhase::Tools); - let batch = self - .execute_tool_batch( - &calls, - &mut completion.content, - read_only_intent, - max_parallel_tools, - &task_contract, - &mut implementation_tracker, - &mut evidence, - &mut tool_guardrail, - &mut progress_tracker, - &mut tool_timeline, - &mut sched_tool_calls, - &mut sched_max_concurrent, - &mut sched_serial_runs, - &mut plan_updated_goal, - &mut proposed_goal, - &mut turn_snapshot, - &mut turn_checkpoint_allowed, - &mut turn_checkpoint_created, - &mut fast_feedback, - ui, - ) - .await?; - match self.steer_after_tools( - &calls, - &batch, - expected_mutation, - read_only_intent, - implementation_intent, - &mut implementation_tracker, - &mut evidence, - &mut mutation_recovery, - &mut progress_tracker, - &mut repeat_nudges, - &mut force_tools_next, - &mut text_tool_fallback_next, - &mut force_no_progress_final_answer_next, - &mut prev_added_no_evidence, - &mut stalled_repeating, - &mut stalled_unfinished, - ui, - ) { - super::steer::RoundControl::Continue => {} - super::steer::RoundControl::BreakInner(hit) => break hit, + } } }; @@ -2172,7 +914,7 @@ If the task is already complete, stop and give your final recap." if self.config.subagents.long_horizon && let Some(previous) = goal_before_final_settlement { - self.goals.structured = Some(previous); + self.goals.set_structured(Some(previous)); self.refresh_system_message(); // The earlier persist may contain tentatively advanced goal // state. Rewrite the goal record itself (message persistence diff --git a/crates/hi-agent/src/agent/turn/mod.rs b/crates/hi-agent/src/agent/turn/mod.rs index 8e227ca..a29f01e 100644 --- a/crates/hi-agent/src/agent/turn/mod.rs +++ b/crates/hi-agent/src/agent/turn/mod.rs @@ -17,12 +17,14 @@ //! - [`settlement`] — keep/invalidate a green verify when the tree moves after //! - [`tools`] — one-round tool-batch scheduler (TurnPhase::Tools) //! - [`steer`] — post-model / post-tool policy (TurnPhase::Steer) +//! - [`model_round`] — Model phase stream/retries/guards/text-steer //! - [`loop_`] — `run_turn` orchestration (phase stamps; outcome classification in [`finalize`]) mod fast_feedback; mod finalize; mod helpers; mod loop_; +mod model_round; mod obligation; pub mod phase; mod progress; diff --git a/crates/hi-agent/src/agent/turn/model_round.rs b/crates/hi-agent/src/agent/turn/model_round.rs new file mode 100644 index 0000000..2ff7bed --- /dev/null +++ b/crates/hi-agent/src/agent/turn/model_round.rs @@ -0,0 +1,1509 @@ +//! One Model→(text)Steer iteration of the inner turn loop. + +use std::collections::BTreeSet; + +use anyhow::Result; +use hi_ai::{ + ChatRequest, Content, ProviderErrorKind, RequestProfile, Role, StreamEvent, ToolMode, + provider_error_kind, +}; +use hi_tools::PlanStatus; + +use crate::heuristics::{ + RECOVERY_SAMPLING, StallMode, looks_like_unfinished_step, parse_text_tool_calls, + recovery_sampling, recovery_telemetry, textcall_id_offset, +}; +use crate::snapshot::changed_files_between; +use crate::steering::{ + BOOKKEEPING_REPOST_NUDGE, EvidenceTracker, IMPLEMENTATION_NO_CHANGES_NUDGE, ImplementationIntent, + ImplementationTracker, PLAN_REPOST_NUDGE, READ_AFTER_SEARCH_NUDGE, READ_ONLY_SAFE_CONTEXT_WINDOW, + REPEAT_NUDGE, REREAD_NUDGE, ReviewIntent, SKIPPED_BOOKKEEPING_REPOST_RESULT, + SKIPPED_PLAN_REPOST_RESULT, SKIPPED_REPEATED_CALL_RESULT, TOOL_PROTOCOL_RETRY_NUDGE, + TOOL_PROTOCOL_TEXT_FALLBACK_NUDGE, ToolLoopGuardrail, bash_call_waits, bash_no_progress_signature, + implementation_text_tool_nudge, inspected_paths_for_prompt, inspection_sprawl_exhausted, + inspection_sprawl_nudge, should_nudge_inspection_sprawl, should_nudge_read_after_repeated_search, +}; +use crate::transcript::NudgeKind; +use crate::verify::WorkspaceRepairVerifier; +use crate::{ + MAX_TOOL_PROTOCOL_RETRIES, TRUNCATED_TOOL_CALL_NUDGE, TRUNCATION_NUDGE, + Ui, +}; + +use super::helpers::{build_turn_telemetry, effective_model_route}; +use super::phase::TurnPhase; +use super::progress::{ + NO_PROGRESS_FINAL_ANSWER_NUDGE, ProgressKind, ProgressTracker, forced_final_answer_is_unusable, + no_progress_signature_for_calls, +}; +use super::retry::{ + INCOMPLETE_STATUS, MAX_PROVIDER_OVERLOAD_RETRIES, MAX_TRANSIENT_ROUTE_RETRIES, + ReviewRepairState, TurnRetryState, delay_label, estimate_tool_schema_tokens, + output_cap_retry_tokens, provider_error_is_backoff_retryable, provider_overload_retry_delay, + transient_route_retry_delay, +}; + +pub(super) enum ModelRoundControl { + Continue, + BreakInner(bool), + RunTools { + calls: Vec<(String, String, String)>, + completion_content: Vec, + }, +} + +pub(super) struct ModelRoundState<'a> { + pub steps: &'a mut u32, + pub empty_retries: &'a mut u32, + pub truncation_retries: &'a mut u32, + pub truncation_total_retries: &'a mut u32, + pub silent_continues: &'a mut u32, + pub continue_total_nudges: &'a mut u32, + pub repeat_nudges: &'a mut u32, + pub repeat_sampling_rounds: &'a mut u32, + pub force_tools_next: &'a mut bool, + pub text_tool_fallback_next: &'a mut bool, + pub force_text_answer_next: &'a mut bool, + pub force_no_progress_final_answer_next: &'a mut bool, + pub suppress_bookkeeping_tools_next: &'a mut bool, + pub prev_added_no_evidence: &'a mut bool, + pub made_tool_call: &'a mut bool, + pub turn_start: &'a mut usize, + pub stalled_repeating: &'a mut bool, + pub stalled_unfinished: &'a mut bool, + pub context_generation_seen: &'a mut u64, + pub indexed_ledger_revision: &'a mut u64, + pub sched_tool_calls: &'a mut u32, + pub sched_max_concurrent: &'a mut u32, + pub sched_serial_runs: &'a mut u32, + pub tool_schema_tokens: &'a mut u64, + pub ended_at_cap: &'a mut bool, + pub prev_call_sig: &'a mut Option>, + pub retry_state: &'a mut TurnRetryState, + pub request_max_tokens_override: &'a mut Option, + pub compat_fallbacks: &'a mut Vec, + pub effective_fallback_route: &'a mut Option, + pub ranked_context_paths: &'a mut BTreeSet, + pub progress_tracker: &'a mut ProgressTracker, + pub evidence: &'a mut EvidenceTracker, + pub implementation_tracker: &'a mut ImplementationTracker, + pub review_repair: &'a mut ReviewRepairState, + pub tool_guardrail: &'a mut ToolLoopGuardrail, + pub last_verify_attributions: &'a mut Vec, + pub tool_timeline: &'a mut Vec, + pub advertised_tool_names: &'a mut BTreeSet, + pub turn_snapshot: &'a mut Option, + pub max_steps: u32, + pub context_task: &'a str, + pub repository_context_enabled: bool, + pub turn_ledger_revision: u64, + pub read_only_intent: Option, + pub implementation_intent: Option, + pub read_only_inspection_cap: Option, + pub expected_mutation: bool, + pub input: &'a str, + pub user_prompt_tokens: u64, + pub inspection_sprawl_intent: Option, + pub verifier: &'a WorkspaceRepairVerifier, +} + +impl crate::Agent { + pub(super) async fn run_model_round( + &mut self, + state: &mut ModelRoundState<'_>, + ui: &mut dyn Ui, + ) -> Result { + let mut steps = *state.steps; + let mut empty_retries = *state.empty_retries; + let mut truncation_retries = *state.truncation_retries; + let mut truncation_total_retries = *state.truncation_total_retries; + let mut silent_continues = *state.silent_continues; + let mut continue_total_nudges = *state.continue_total_nudges; + let mut repeat_nudges = *state.repeat_nudges; + let mut repeat_sampling_rounds = *state.repeat_sampling_rounds; + let mut force_tools_next = *state.force_tools_next; + let mut text_tool_fallback_next = *state.text_tool_fallback_next; + let mut force_text_answer_next = *state.force_text_answer_next; + let mut force_no_progress_final_answer_next = *state.force_no_progress_final_answer_next; + let mut suppress_bookkeeping_tools_next = *state.suppress_bookkeeping_tools_next; + let mut prev_added_no_evidence = *state.prev_added_no_evidence; + let mut made_tool_call = *state.made_tool_call; + let mut turn_start = *state.turn_start; + let mut stalled_repeating = *state.stalled_repeating; + let mut stalled_unfinished = *state.stalled_unfinished; + let mut context_generation_seen = *state.context_generation_seen; + let mut indexed_ledger_revision = *state.indexed_ledger_revision; + let mut sched_tool_calls = *state.sched_tool_calls; + let mut sched_max_concurrent = *state.sched_max_concurrent; + let mut sched_serial_runs = *state.sched_serial_runs; + let mut tool_schema_tokens = *state.tool_schema_tokens; + let mut ended_at_cap = *state.ended_at_cap; + let mut prev_call_sig = std::mem::take(state.prev_call_sig); + let mut retry_state = std::mem::take(state.retry_state); + let mut request_max_tokens_override = std::mem::take(state.request_max_tokens_override); + let mut compat_fallbacks = std::mem::take(state.compat_fallbacks); + let mut effective_fallback_route = std::mem::take(state.effective_fallback_route); + let mut ranked_context_paths = std::mem::take(state.ranked_context_paths); + let mut progress_tracker = std::mem::take(state.progress_tracker); + let mut evidence = std::mem::take(state.evidence); + let mut implementation_tracker = std::mem::take(state.implementation_tracker); + let mut review_repair = std::mem::take(state.review_repair); + let mut tool_guardrail = std::mem::take(state.tool_guardrail); + let mut last_verify_attributions = std::mem::take(state.last_verify_attributions); + let mut tool_timeline = std::mem::take(state.tool_timeline); + let mut advertised_tool_names = std::mem::take(state.advertised_tool_names); + let mut turn_snapshot = std::mem::take(state.turn_snapshot); + let max_steps = state.max_steps; + let context_task = state.context_task; + let repository_context_enabled = state.repository_context_enabled; + let turn_ledger_revision = state.turn_ledger_revision; + let read_only_intent = state.read_only_intent; + let implementation_intent = state.implementation_intent; + let read_only_inspection_cap = state.read_only_inspection_cap; + let expected_mutation = state.expected_mutation; + let input = state.input; + let _user_prompt_tokens = state.user_prompt_tokens; + let inspection_sprawl_intent = state.inspection_sprawl_intent; + let verifier = state.verifier; + + let result = async { + self.set_turn_phase(TurnPhase::Model); + if steps >= max_steps { + return Ok(ModelRoundControl::BreakInner(true)); + } + steps += 1; + + // Mid-turn steering: inject any messages the user typed while + // the turn was running, as genuine user messages, before the + // next model round. This is a safe transcript boundary — the + // prior round's tool calls are all resolved — so the folding + // nudge push keeps provider alternation valid. The model + // decides how to weigh them; we add no deferral directive. + let interjected = self.interjections.drain(); + if !interjected.is_empty() { + for message in &interjected { + self.messages.push_nudge_or_fold( + NudgeKind::Interjection, + format!( + "The user sent this message while you were working — take it into account now:\n{message}" + ), + ); + } + ui.status(&format!( + "✉ received {} message(s) from you mid-turn — factoring them in", + interjected.len() + )); + } + + // After a content-less/garbled round, resample hotter and with + // nucleus + frequency penalty on the retry to break out of the + // low-entropy attractor that produced it (cf. minion's recovery + // sampling). Bounded, and only while consecutively stalling — + // `empty_retries` resets on real output, so a normal round runs at + // the configured sampling. Toggleable via HI_RECOVERY_SAMPLING for + // A/B-ing on the eval harness. + let sampling_retries = empty_retries + .max(retry_state.protocol_retries) + .max(repeat_sampling_rounds); + let (sampling_mode, sampling_budget) = if repeat_sampling_rounds > 0 + && repeat_sampling_rounds >= empty_retries + && repeat_sampling_rounds >= retry_state.protocol_retries + { + // The model is deterministically re-emitting the same tool + // call round after round (observed live: four byte-identical + // `update_plan` calls despite nudges and withheld tools). + // Hotter sampling + a frequency penalty is what actually + // breaks a token-level loop; nudge text alone doesn't. + (StallMode::Repeat, self.config.loop_limits.max_repeat_nudges) + } else if retry_state.protocol_retries > empty_retries { + (StallMode::Empty, MAX_TOOL_PROTOCOL_RETRIES) + } else { + (StallMode::Empty, self.config.loop_limits.max_empty_retries) + }; + let (temperature, top_p, frequency_penalty) = recovery_sampling( + sampling_retries, + self.config.routing.temperature, + *RECOVERY_SAMPLING, + ); + + // Telemetry for the recovery-sampling A/B: emit a concise debug + // line only when sampling is actually being changed (recovery on + // and this is a retry), so ordinary runs stay quiet. + if let Some(line) = recovery_telemetry( + sampling_mode, + sampling_retries, + sampling_budget, + temperature, + top_p, + frequency_penalty, + *RECOVERY_SAMPLING, + ) { + ui.nudge(&line); + } + + let context_safety_window = read_only_intent + .is_some() + .then_some(READ_ONLY_SAFE_CONTEXT_WINDOW); + self.elide_in_turn_context_if_needed(ui, context_safety_window); + + self.refresh_active_task_context( + &context_task, + repository_context_enabled, + turn_ledger_revision, + &mut ranked_context_paths, + &mut context_generation_seen, + &mut indexed_ledger_revision, + ); + + self.messages.repair_invalid_tool_call_arguments(); + + // Debug-mode invariant check: the transcript we're about to send + // must be provider-safe (every tool_use answered, no consecutive + // user messages). Cheap in release builds; in debug it catches + // the orphan-tool_use class of bug at the source. + debug_assert!( + self.messages.validate_for_provider().is_ok(), + "transcript invariant violated before provider send" + ); + + let request_text_tool_fallback = text_tool_fallback_next; + text_tool_fallback_next = false; + let request_text_answer = force_text_answer_next; + force_text_answer_next = false; + let request_no_progress_final_answer = force_no_progress_final_answer_next; + if request_no_progress_final_answer { + progress_tracker.record_forced_final_answer_attempt(); + } + force_no_progress_final_answer_next = false; + + // After a continue-nudge, force this round to call a tool rather + // than narrate again or come back empty. Only when tools are + // freely available (Auto): never override an intentional + // ChatOnly/ReadOnly restriction, and Required already forces. + let tool_mode = if request_text_tool_fallback + || request_text_answer + || request_no_progress_final_answer + { + ToolMode::ChatOnly + } else if force_tools_next && self.config.routing.tool_mode == ToolMode::Auto { + ToolMode::Required + } else { + self.config.routing.tool_mode + }; + let tool_availability_mode = if request_text_tool_fallback + || request_text_answer + || request_no_progress_final_answer + { + ToolMode::ChatOnly + } else if read_only_intent.is_some() + && !matches!(self.config.routing.tool_mode, ToolMode::ChatOnly) + { + ToolMode::ReadOnly + } else { + self.config.routing.tool_mode + }; + let requested_request_max_tokens = + request_max_tokens_override.unwrap_or(self.config.routing.max_tokens); + let mut request_tools = self.request_tools_for(tool_availability_mode); + if suppress_bookkeeping_tools_next { + suppress_bookkeeping_tools_next = false; + // Only withhold when other tools remain — an empty tool + // list with tool_choice=required would be a provider error. + if request_tools + .iter() + .any(|tool| !hi_tools::is_coordination(&tool.name)) + { + request_tools = request_tools + .iter() + .filter(|tool| !hi_tools::is_coordination(&tool.name)) + .cloned() + .collect(); + } + } + advertised_tool_names.extend(request_tools.iter().map(|tool| tool.name.clone())); + let request_tool_schema_tokens = estimate_tool_schema_tokens(&request_tools); + tool_schema_tokens = tool_schema_tokens.max(request_tool_schema_tokens); + let context_preflight = match self.ensure_request_fits_context( + input, + turn_start, + requested_request_max_tokens, + request_tool_schema_tokens, + context_safety_window, + ui, + ) { + Ok(context_preflight) => context_preflight, + Err(err) => { + self.reconcile_error_turn_changes(turn_ledger_revision)?; + self.truncate_messages(turn_start); + self.add_error_usage(&err); + self.emit_usage(ui); + self.last_compat_fallbacks = compat_fallbacks.clone(); + self.last_turn_telemetry = build_turn_telemetry( + max_steps, + verifier.round(), + empty_retries, + repeat_nudges, + continue_total_nudges, + truncation_total_retries, + &progress_tracker, + ended_at_cap, + stalled_unfinished, + stalled_repeating, + &last_verify_attributions, + verifier.executions(), + sched_tool_calls, + sched_max_concurrent, + sched_serial_runs, + &tool_timeline, + &evidence, + &review_repair, + ); + let _ = self.persist(); + let (kind, guidance) = crate::ui::classify_error(&err); + ui.turn_error(kind, &err.to_string(), guidance); + self.last_effective_route = effective_model_route( + &self.config, + effective_fallback_route.as_deref(), + ); + return Err(err); + } + }; + if context_preflight.dropped_prior_context { + turn_start = self.messages.len().saturating_sub(1); + } + // Context fitting may itself compact or elide the transcript. + // Consume that generation before constructing the request. + self.refresh_active_task_context( + &context_task, + repository_context_enabled, + turn_ledger_revision, + &mut ranked_context_paths, + &mut context_generation_seen, + &mut indexed_ledger_revision, + ); + let request_max_tokens = context_preflight.max_tokens; + if request_max_tokens != requested_request_max_tokens { + request_max_tokens_override = Some(request_max_tokens); + } + let request = ChatRequest { + model: self.config.routing.model.clone(), + user_turn: true, + canonical_objective: Some(context_task.to_string()), + messages: self.messages.arc(), + tools: request_tools, + max_tokens: request_max_tokens, + temperature, + top_p, + frequency_penalty, + thinking_budget: self.config.routing.thinking_budget, + reasoning_effort: self.config.routing.reasoning_effort, + profile: RequestProfile { + compat: self.config.routing.compat, + tool_mode, + stream_usage: None, + }, + }; + + let buffer_read_only_review_text = + read_only_intent.is_some() || implementation_intent.is_some(); + let mut buffered_assistant_text = String::new(); + let mut streamed_assistant_text = false; + let mut sink = |event: StreamEvent| match event { + StreamEvent::Text(text) => { + if buffer_read_only_review_text { + buffered_assistant_text.push_str(&text); + } else { + streamed_assistant_text = true; + ui.assistant_text(&text); + } + } + StreamEvent::Reasoning(text) => ui.assistant_reasoning(&text), + StreamEvent::Status(text) => { + if let Some(fallback) = text.strip_prefix("compat: ") { + compat_fallbacks.push(fallback.to_string()); + } + if let Some(route) = text.rsplit_once("falling back to ").map(|(_, r)| r) { + effective_fallback_route = Some(route.trim().to_string()); + } + ui.status(&text); + } + }; + let mut completion = match self.provider.stream(request, &mut sink).await { + Ok(completion) => { + retry_state.record_provider_success(); + completion + } + Err(err) + if !retry_state.output_cap_retry_attempted + && hi_ai::provider_output_cap_error(&err) + .and_then(|cap| output_cap_retry_tokens(request_max_tokens, cap)) + .is_some() => + { + ui.assistant_end(); + self.add_error_usage(&err); + self.emit_usage(ui); + retry_state.output_cap_retry_attempted = true; + let new_max = hi_ai::provider_output_cap_error(&err) + .and_then(|cap| output_cap_retry_tokens(request_max_tokens, cap)) + .expect("guard checked retry tokens"); + request_max_tokens_override = Some(new_max); + ui.nudge(&format!( + "provider rejected the output budget; retrying this turn with max_tokens={new_max}" + )); + return Ok(ModelRoundControl::Continue); + } + Err(err) + if retry_state.provider_overload_retries + < MAX_PROVIDER_OVERLOAD_RETRIES + && provider_error_is_backoff_retryable(&err) => + { + ui.assistant_end(); + self.add_error_usage(&err); + self.emit_usage(ui); + retry_state.provider_overload_retries += 1; + let retry = retry_state.provider_overload_retries; + let delay = provider_overload_retry_delay(retry, &err); + let reason = if provider_error_kind(&err) + == Some(ProviderErrorKind::RateLimit) + { + "rate limited" + } else { + "request did not complete" + }; + ui.nudge(&format!( + "{reason}; retrying {} ({retry}/{MAX_PROVIDER_OVERLOAD_RETRIES})", + delay_label(delay) + )); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + return Ok(ModelRoundControl::Continue); + } + Err(err) + if retry_state.transient_route_retries < MAX_TRANSIENT_ROUTE_RETRIES + && hi_ai::provider_route_error_is_retryable(&err) => + { + ui.assistant_end(); + self.add_error_usage(&err); + self.emit_usage(ui); + retry_state.transient_route_retries += 1; + let retry = retry_state.transient_route_retries; + let delay = transient_route_retry_delay(retry, &err); + ui.nudge(&format!( + "request did not complete; retrying {} ({retry}/{MAX_TRANSIENT_ROUTE_RETRIES})", + delay_label(delay) + )); + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + return Ok(ModelRoundControl::Continue); + } + Err(err) + if provider_error_kind(&err) + == Some(ProviderErrorKind::RequestTooLarge) => + { + let mut context_drop_persistence_failed = false; + if !retry_state.request_too_large_retried { + match self.retry_after_request_too_large(input, turn_start, ui) { + Ok(true) => { + retry_state.request_too_large_retried = true; + turn_start = self.messages.len().saturating_sub(1); + return Ok(ModelRoundControl::Continue); + } + Ok(false) => {} + Err(persist_err) => { + ui.status(&format!( + "couldn't persist dropped-context retry state: {persist_err}" + )); + context_drop_persistence_failed = true; + } + } + } + self.truncate_messages(turn_start); + if context_drop_persistence_failed { + ui.status( + "request exceeds the provider limit, and prior context could not be \ + safely dropped because the session boundary was not persisted; fix \ + session storage or start a fresh/cleared session, then retry", + ); + } else { + ui.status( + "request still exceeds the provider limit with prior context removed; \ + shorten the prompt or attached input, then retry", + ); + } + self.add_error_usage(&err); + self.reconcile_error_turn_changes(turn_ledger_revision)?; + self.emit_usage(ui); + self.last_compat_fallbacks = compat_fallbacks.clone(); + self.last_turn_telemetry = build_turn_telemetry( + max_steps, + verifier.round(), + empty_retries, + repeat_nudges, + continue_total_nudges, + truncation_total_retries, + &progress_tracker, + ended_at_cap, + stalled_unfinished, + stalled_repeating, + &last_verify_attributions, + verifier.executions(), + sched_tool_calls, + sched_max_concurrent, + sched_serial_runs, + &tool_timeline, + &evidence, + &review_repair, + ); + let _ = self.persist(); + let (kind, guidance) = crate::ui::classify_error(&err); + ui.turn_error(kind, &err.to_string(), guidance); + self.last_effective_route = effective_model_route( + &self.config, + effective_fallback_route.as_deref(), + ); + return Err(err); + } + Err(err) + if provider_error_kind(&err) == Some(ProviderErrorKind::ToolProtocol) + && retry_state.protocol_retries < MAX_TOOL_PROTOCOL_RETRIES + && retry_state.protocol_failures_total + < crate::MAX_TOOL_PROTOCOL_FAILURES => + { + ui.assistant_end(); + self.add_error_usage(&err); + self.emit_usage(ui); + retry_state.protocol_retries += 1; + retry_state.protocol_failures_total += 1; + let protocol_retries = retry_state.protocol_retries; + if implementation_intent.is_some() || made_tool_call { + force_tools_next = true; + } + ui.nudge(&format!( + "⚠ the model emitted an invalid tool turn — retrying with tool-format guidance ({protocol_retries}/{MAX_TOOL_PROTOCOL_RETRIES})" + )); + if self + .messages + .as_slice() + .last() + .is_some_and(|message| message.role == Role::User) + { + self.messages.push_user_or_fold(TOOL_PROTOCOL_RETRY_NUDGE); + } else { + self.messages + .push_nudge(NudgeKind::Continue, TOOL_PROTOCOL_RETRY_NUDGE); + } + return Ok(ModelRoundControl::Continue); + } + Err(err) + if provider_error_kind(&err) == Some(ProviderErrorKind::ToolProtocol) + && implementation_intent.is_some() + && retry_state.protocol_text_fallbacks < 1 => + { + ui.assistant_end(); + self.add_error_usage(&err); + self.emit_usage(ui); + retry_state.protocol_text_fallbacks += 1; + text_tool_fallback_next = true; + force_tools_next = false; + ui.status( + "structured tool calls kept failing; falling back to plain-text tool-call parsing", + ); + if self + .messages + .as_slice() + .last() + .is_some_and(|message| message.role == Role::User) + { + self.messages + .push_user_or_fold(TOOL_PROTOCOL_TEXT_FALLBACK_NUDGE); + } else { + self.messages + .push_nudge(NudgeKind::Continue, TOOL_PROTOCOL_TEXT_FALLBACK_NUDGE); + } + return Ok(ModelRoundControl::Continue); + } + Err(err) + if provider_error_kind(&err) == Some(ProviderErrorKind::ToolProtocol) => + { + // Both the consecutive and cumulative invalid-tool-turn + // budgets are spent. A model that alternates a valid tool + // call with an invalid turn keeps resetting the consecutive + // counter, so without the cumulative cap this nudge-and-retry + // loop runs forever (spinning CPU, burning tokens). End the + // turn instead so the driver/user regains control; on a + // long-horizon drive the next turn resumes with a fresh budget. + ui.assistant_end(); + self.add_error_usage(&err); + self.emit_usage(ui); + ui.status( + "⚠ the model kept emitting invalid tool turns — ending the turn; /retry or continue to resume", + ); + return Ok(ModelRoundControl::BreakInner(false)); + } + // A transient generation flake — a malformed/garbled stream or + // an empty completion. Treat it like a content-less response: + // flush, then silently re-run with hotter recovery sampling (a + // fresh request, with its own transport retries) up to the same + // budget, instead of failing the turn. Terminal errors (auth, + // rate limits, ...) fall through to the abort below. Invalid tool turns + // use the protocol-specific nudge path above. + Err(err) + if empty_retries < self.config.loop_limits.max_empty_retries + && matches!( + provider_error_kind(&err), + Some( + ProviderErrorKind::MalformedStream + | ProviderErrorKind::EmptyCompletion + ) + ) => + { + ui.assistant_end(); + self.add_error_usage(&err); + self.emit_usage(ui); + empty_retries += 1; + if made_tool_call { + self.nudge_after_post_tool_empty_response( + &mut force_tools_next, + implementation_intent.is_some(), + ); + } + ui.nudge(&format!( + "⚠ the model's response didn't come through cleanly — \ + retrying ({empty_retries}/{})", + self.config.loop_limits.max_empty_retries + )); + return Ok(ModelRoundControl::Continue); + } + Err(err) => { + self.add_error_usage(&err); + self.reconcile_error_turn_changes(turn_ledger_revision)?; + self.emit_usage(ui); + if self.last_changed_files.is_empty() + && let Some(turn_snapshot) = turn_snapshot.as_ref() + { + self.messages.strip_trailing_nudges(); + if let Ok(end_snapshot) = self.snapshot_cached().await { + self.last_changed_files = + changed_files_between(turn_snapshot, &end_snapshot); + } + } + // With no model tool call, any concurrent workspace + // change was external to this failed attempt. Preserve + // it in the report, but never retain the failed user + // prompt or retry guidance in conversation history. + if !made_tool_call { + self.truncate_messages(turn_start); + } + self.last_compat_fallbacks = compat_fallbacks.clone(); + self.last_turn_telemetry = build_turn_telemetry( + max_steps, + verifier.round(), + empty_retries, + repeat_nudges, + continue_total_nudges, + truncation_total_retries, + &progress_tracker, + ended_at_cap, + stalled_unfinished, + stalled_repeating, + &last_verify_attributions, + verifier.executions(), + sched_tool_calls, + sched_max_concurrent, + sched_serial_runs, + &tool_timeline, + &evidence, + &review_repair, + ); + let _ = self.persist(); + let (kind, guidance) = crate::ui::classify_error(&err); + ui.turn_error(kind, &err.to_string(), guidance); + self.last_effective_route = effective_model_route( + &self.config, + effective_fallback_route.as_deref(), + ); + return Err(err); + } + }; + if !buffer_read_only_review_text { + ui.assistant_end(); + } + + self.add_usage(completion.usage); + // Let the frontend show the running total climb mid-turn. + self.emit_usage(ui); + + // Truncation recovery: the model hit the output token cap + // (`stop_reason: "length"` / `"max_tokens"`) mid-generation. + // The response was cut off, not finished — record what it + // produced and nudge it to continue from the cutoff, instead + // of treating the truncation as a natural stop (which would + // end the turn on a half-finished output and leave the model + // "picking up where it stalled" on the next prompt). Bounded + // by a *dedicated* truncation budget (separate from + // `empty_retries`) so a big task that legitimately hits the + // cap several times can still finish without the user typing + // "continue". + let truncated = matches!( + completion.stop_reason.as_deref(), + Some("length" | "max_tokens") + ); + if truncated && truncation_retries < self.config.loop_limits.max_truncation_retries { + truncation_retries += 1; + truncation_total_retries += 1; + ui.nudge(&format!( + "⚠ the model hit the output token limit — continuing ({truncation_retries}/{})", + self.config.loop_limits.max_truncation_retries + )); + // Clean text-embedded tool-call JSON (local models) from the + // truncated content before recording. Complete tool calls are + // extracted and stripped; partial JSON (cut off mid-generation) + // stays as text so the model can continue from the cutoff. + // Structured ToolCall blocks are stripped: a truncated tool call + // has partial/malformed arguments and was never executed, so it + // has no matching tool_result. Leaving it in would create an + // orphan tool_use that providers reject on the next request. + let partial_tool_call = + self.clean_text_tool_calls_from_content(&mut completion.content); + let truncated_text = completion + .content + .iter() + .filter_map(|c| match c { + Content::Text(t) => Some(t.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let active_tool_work = read_only_intent.is_none() + && (implementation_intent.is_some() + || made_tool_call + || implementation_tracker.mutation_seen + || self.goals.plan_incomplete() + || looks_like_unfinished_step(&truncated_text)); + if (partial_tool_call || active_tool_work) + && self.config.routing.tool_mode == ToolMode::Auto + { + force_tools_next = true; + } + self.messages + .push_assistant_text_only(std::mem::take(&mut completion.content)); + self.messages.push_nudge( + NudgeKind::Truncation, + if partial_tool_call || active_tool_work { + TRUNCATED_TOOL_CALL_NUDGE + } else { + TRUNCATION_NUDGE + }, + ); + return Ok(ModelRoundControl::Continue); + } + // Truncation budget exhausted: the model kept hitting the output + // token cap through the whole retry budget. Record the truncated + // output (stripping partial tool calls, as above) and warn the + // user — the task may be incomplete. Don't silently end the turn + // on a half-finished output without surfacing what happened. + if truncated { + self.clean_text_tool_calls_from_content(&mut completion.content); + self.messages + .push_assistant_text_only(std::mem::take(&mut completion.content)); + stalled_unfinished = true; + ui.nudge(&format!( + "⚠ the model hit the output token limit {max} times — the task may be \ + incomplete. /retry, or send 'continue'.", + max = self.config.loop_limits.max_truncation_retries, + )); + return Ok(ModelRoundControl::BreakInner(false)); + } + // A public RSI response is terminal, not a local planning round to nudge. + if completion.stop_reason.as_deref() == Some("rsi_remote_completed") { + let answer = completion + .content + .iter() + .filter_map(|content| match content { + Content::Text(text) => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + if !answer.trim().is_empty() + && (buffer_read_only_review_text || !streamed_assistant_text) + { + ui.assistant_text(&answer); + ui.assistant_end(); + } + self.messages + .push_assistant(std::mem::take(&mut completion.content)); + progress_tracker.record_final_answer(); + return Ok(ModelRoundControl::BreakInner(false)); + } + + let calls: Vec<(String, String, String)> = + if request_text_answer || request_no_progress_final_answer { + Vec::new() + } else { + completion + .tool_calls() + .into_iter() + .map(|c| { + ( + c.id.to_string(), + c.name.to_string(), + c.arguments.to_string(), + ) + }) + .collect() + }; + + // Fallback for local models (Ollama, llama.cpp, etc.) that emit + // tool calls as text — raw JSON like {"name":"bash","arguments":…} + // — instead of using the structured `tool_calls` API field. When + // the API returned no structured calls, scan the assistant text + // for tool-call JSON and promote any matches to real ToolCall + // blocks so they actually execute. The raw JSON is stripped from + // the recorded text so history stays clean. + let calls = if calls.is_empty() + && !request_text_answer + && !request_no_progress_final_answer + { + let full_text: String = completion + .content + .iter() + .filter_map(|c| match c { + Content::Text(t) => Some(t.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let parsed = + parse_text_tool_calls(&full_text, textcall_id_offset(&self.messages)); + if parsed.iter().any(|c| matches!(c, Content::ToolCall { .. })) { + // Replace text blocks with the interleaved content + // (prose segments + ToolCall blocks in emission order), + // preserving any Thinking blocks from the original. + let mut new_content = Vec::new(); + let mut parsed_iter = parsed.into_iter().peekable(); + for c in completion.content.iter() { + match c { + Content::Text(_) => { + // Drain the parsed content that corresponds to + // this text block (all of it — the original had + // one Text block with the full raw text). + for p in parsed_iter.by_ref() { + new_content.push(p); + } + } + Content::Thinking { .. } => new_content.push(c.clone()), + _ => {} + } + } + // If the original had no Text block (shouldn't happen for + // the local-model path, but be safe), drain remaining. + for p in parsed_iter { + new_content.push(p); + } + completion.content = new_content; + completion + .tool_calls() + .into_iter() + .map(|c| { + ( + c.id.to_string(), + c.name.to_string(), + c.arguments.to_string(), + ) + }) + .collect() + } else { + Vec::new() + } + } else { + calls + }; + + // Repetition guard: the model re-issued the exact same tool + // calls (same names, same arguments, same order) as the previous + // round. Re-running most tools can only reproduce the same + // output, so don't execute — nudge the model to act on the output + // it already has. `bash_output` is intentionally excluded from + // this exact-match shortcut because a live background process is + // time-dependent and can emit new output between identical polls; + // completed/missing/pruned handles are caught below by the + // stale-background no-new-evidence path. Bounded; past the + // budget the turn ends with an honest "stuck repeating" notice + // rather than looping until `max_steps`. + let call_sig: Vec<(String, String)> = calls + .iter() + .map(|(_, name, args)| (name.clone(), args.clone())) + .collect(); + let has_background_output_poll = calls + .iter() + .any(|(_, name, _)| name.as_str() == "bash_output"); + let has_background_handle_call = calls + .iter() + .any(|(_, name, _)| matches!(name.as_str(), "bash_output" | "bash_kill")); + let has_no_progress_bash = calls.iter().any(|(_, name, args)| { + name == "bash" && bash_no_progress_signature(args).is_some() + }); + // A bash command that deliberately waits before sampling state + // ("sleep 300 && du -sh models/") is time-dependent the same + // way a `bash_output` poll is: re-running it verbatim is how + // the model watches a slow external process (a download, a + // long build, a warming server), and each run can return new + // output. Exempt such rounds from the signature-based repeat + // guards; the result-hash guard below still catches the + // static case (the same poll returning byte-identical output), + // so a wait loop stays bounded without punishing legitimate + // progress-watching. + let has_wait_poll_bash = calls + .iter() + .any(|(_, name, args)| name == "bash" && bash_call_waits(args)); + let exact_repeat = !calls.is_empty() + && !has_background_output_poll + && !has_wait_poll_bash + && prev_call_sig.as_ref() == Some(&call_sig); + // No-new-evidence cycle guard: a round whose every call is a + // read-only inspection (read/list/grep/glob) or stale background + // handle operation already performed earlier this turn. This + // catches multi-step cycles like + // A→B→C→A→B→C — including grep/list cycles, not just re-reads — + // that evade the exact-match check because each round differs + // from the one right before it. On large workspaces such a cycle + // can otherwise loop until `max_steps` without ever re-issuing an + // identical round. `EvidenceTracker::round_adds_evidence` keys on + // a stable per-inspection signature (read path/page, list path, + // grep pattern/glob/path/context, stale background handle id), so + // any re-inspection is caught regardless of cycle length or tool + // mix. Shares the same + // `repeat_nudges` budget as the exact-match guard so it stays + // bounded. + // + // Fires only on the *second* consecutive no-new-evidence round + // (`prev_added_no_evidence`): a single re-inspection right after + // new evidence is allowed through (e.g. re-reading a file once a + // broader search has surfaced something to re-examine, or paging + // further into a file). Once the turn has made a successful + // mutation, this guard is advisory only: after the nudge budget + // is spent, execute the inspection rather than hard-stalling a + // long implementation harness in the middle of a later plan step. + let no_new_evidence = !calls.is_empty() && !evidence.round_adds_evidence(&calls); + let stale_background_handle_call = no_new_evidence && has_background_handle_call; + // A wait-poll round re-runs a seen inspection signature by + // design, so it must not trip the no-new-evidence cycle guard + // either — its staleness is judged by output, below. + let is_repeat = exact_repeat + || (no_new_evidence + && !has_wait_poll_bash + && (prev_added_no_evidence || stale_background_handle_call)); + let no_new_after_mutation = is_repeat + && no_new_evidence + && implementation_tracker.mutation_seen + && !stale_background_handle_call; + let repeat_budget_available = repeat_nudges < self.config.loop_limits.max_repeat_nudges; + let should_skip_for_repeat = + is_repeat && (!no_new_after_mutation || repeat_budget_available); + if should_skip_for_repeat { + // We deliberately do NOT execute the repeated tool calls, + // but the calls stay in the transcript, each paired with a + // synthetic result that says why it was skipped. Stripping + // them (as this path once did) left the model's turn as a + // bare placeholder with no result for the call it just + // made — weak models concluded the tool layer was broken + // ("my tool calls aren't producing visible output") and + // gave up instead of correcting course. Pairing every + // skipped `tool_use` with a `tool_result` also keeps the + // transcript in the shape providers require. + let all_plan_reposts = calls.iter().all(|(_, name, _)| name == "update_plan"); + let all_bookkeeping_reposts = calls + .iter() + .all(|(_, name, _)| hi_tools::is_coordination(name)); + let skip_results: Vec<(String, String)> = calls + .iter() + .map(|(id, name, _)| { + let note = if name == "update_plan" { + SKIPPED_PLAN_REPOST_RESULT + } else if hi_tools::is_coordination(name) { + SKIPPED_BOOKKEEPING_REPOST_RESULT + } else { + SKIPPED_REPEATED_CALL_RESULT + }; + (id.clone(), note.to_string()) + }) + .collect(); + self.messages.push_assistant_with_results( + std::mem::take(&mut completion.content), + skip_results, + ); + if repeat_budget_available { + repeat_nudges += 1; + repeat_sampling_rounds += 1; + stalled_repeating = true; + let stall_reason = if all_plan_reposts { + "unchanged plan repost" + } else if all_bookkeeping_reposts { + "repeated bookkeeping call" + } else if stale_background_handle_call { + "stale background handle" + } else if has_no_progress_bash { + "semantic no-op bash command" + } else if no_new_evidence { + "repeated inspection signature" + } else { + "skipped repeated calls" + }; + // Never force a chat-only "final answer" after bookkeeping + // loops on a mutation turn. That path exists for inspection + // stalls where the model already has evidence to summarize; + // on an edit request it just ends the turn incomplete with + // zero file changes (live: "I started the fix but didn't + // land the edit"). Keep tools required and let the + // budget-exhausted branch hand off to implementation repair. + let force_final_after_nudge = progress_tracker.record_no_progress_nudge( + stall_reason, + no_progress_signature_for_calls(&calls), + ) && !no_new_after_mutation + && implementation_intent.is_none() + && !(expected_mutation && all_bookkeeping_reposts); + let nudge = if all_bookkeeping_reposts { + if all_plan_reposts { + ui.nudge(&format!( + "the model re-posted an unchanged plan — withholding \ + bookkeeping tools for a round and nudging it to execute \ + the next step ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + } else { + ui.nudge(&format!( + "the model repeated bookkeeping calls without real work — \ + withholding bookkeeping tools for a round \ + ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + } + suppress_bookkeeping_tools_next = true; + force_tools_next = true; + // Cancel any prior force-final from a mixed stall so the + // bookkeeping withhold round still has real tools. + force_no_progress_final_answer_next = false; + if all_plan_reposts { + PLAN_REPOST_NUDGE.to_string() + } else { + BOOKKEEPING_REPOST_NUDGE.to_string() + } + } else if stale_background_handle_call { + if has_background_output_poll { + ui.nudge(&format!( + "the model kept polling stale background process handles — \ + nudging it to stop polling them ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + "The background process handle you just polled is completed, missing, or pruned, so polling it again cannot produce new output. Do not call bash_output for that handle again. Continue from the available output, restart the command if you still need it, or finish with the current result.".to_string() + } else { + ui.nudge(&format!( + "the model kept using stale background process handles — \ + nudging it to stop using them ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + "The background process handle you just used is already killed, already exited, missing, or pruned, so calling bash_kill for it again cannot change anything. Do not call bash_kill for that handle again. Continue from the available output, restart the command if you still need it, or finish with the current result.".to_string() + } + } else if should_nudge_read_after_repeated_search( + read_only_intent, + &evidence, + ) { + ui.nudge(&format!( + "the model re-ran the same search — nudging it to read a matching file ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + READ_AFTER_SEARCH_NUDGE.to_string() + } else if implementation_intent.is_some() + && no_new_evidence + && (evidence.saw_read || evidence.saw_search) + { + // Concrete, actionable nudge for implementation tasks: + // name the inspected files and the next plan step (if + // any) so the model has a specific action to take + // instead of a generic "start editing." A strong model + // responds to one concrete nudge; a weak one won't + // respond to any number, so the budget stays tight (2). + // Only fires for no-new-evidence cycles (re-reading + // already-inspected files); exact repeats of non-read + // tools (e.g. re-running a bash command) fall through + // to the generic REPEAT_NUDGE below, which says "don't + // re-run that command" — the right message for that case. + ui.nudge(&format!( + "the model re-read files it already inspected — their contents are \ + already above; nudging it to act on them ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + let paths = inspected_paths_for_prompt(&evidence); + let plan_step = self + .goals.last_plan + .iter() + .find(|s| { + s.status == PlanStatus::Pending + || s.status == PlanStatus::Active + }) + .map(|s| s.title.as_str()); + if let Some(step) = plan_step { + format!( + "You already inspected these files: {paths}. Their contents are in the conversation above — do not re-read them. \ +Your plan's next step is: \"{step}\". Execute it now with write/edit/multi_edit/apply_patch. \ +Do not read more files first — you have enough context. Act on the next plan step immediately." + ) + } else { + format!( + "You already inspected these files: {paths}. Their contents are in the conversation above — do not re-read them. \ +You have enough context to make progress. Edit one of the inspected files now with write/edit/multi_edit/apply_patch. \ +If the task is already complete, stop and give your final recap." + ) + } + } else if has_no_progress_bash { + ui.nudge(&format!( + "the model kept running no-op shell commands — nudging it to finish without more bash calls ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + "The bash command you just called only says stop/quit/done or otherwise does no work. Do not call bash for that. If the task is complete, finish with a text answer; otherwise use a tool that inspects or changes the workspace.".to_string() + } else if no_new_evidence && !exact_repeat { + ui.nudge(&format!( + "the model re-read files it already inspected — their contents are \ + already above; nudging it to act on them ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + REREAD_NUDGE.to_string() + } else { + ui.nudge(&format!( + "the model re-ran the same command — its output is already above; \ + nudging it to act on it ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + REPEAT_NUDGE.to_string() + }; + let nudge = if force_final_after_nudge { + force_no_progress_final_answer_next = true; + force_tools_next = false; + format!("{nudge}\n\n{NO_PROGRESS_FINAL_ANSWER_NUDGE}") + } else { + nudge + }; + self.messages.push_nudge(NudgeKind::Repeat, nudge); + // Keep prev_call_sig as-is so a further repeat is still + // detected against the same signature. + return Ok(ModelRoundControl::Continue); + } + if stale_background_handle_call { + ui.status( + "background process handles were completed, missing, or pruned (or already killed) and the model kept using them — the task may be incomplete. /retry, or send 'continue'.", + ); + return Ok(ModelRoundControl::BreakInner(false)); + } + if has_no_progress_bash { + stalled_unfinished = true; + ui.nudge("model repeated no-op shell commands; stopping incomplete"); + ui.status(INCOMPLETE_STATUS); + return Ok(ModelRoundControl::BreakInner(false)); + } + if read_only_intent.is_some() && evidence.saw_search && !evidence.saw_read { + stalled_unfinished = true; + ui.nudge( + "review repeated the same search without reading files; stopping incomplete", + ); + ui.status(INCOMPLETE_STATUS); + return Ok(ModelRoundControl::BreakInner(false)); + } + if let Some(intent) = read_only_intent + && (evidence.saw_read || evidence.saw_search) + { + stalled_unfinished = true; + ui.nudge( + "review repeated the same command after inspection; stopping incomplete", + ); + let _ = (intent, &evidence); + ui.status(INCOMPLETE_STATUS); + return Ok(ModelRoundControl::BreakInner(false)); + } + // Implementation / explicit-mutation turns that burned the + // repeat budget on non-mutating work must not hard-stop yet. + // Two live failure modes share this path: + // 1. re-reading already-inspected files without editing + // 2. pure bookkeeping loops (identical update_plan / + // record_decision) that never even inspected the tree + // Case (2) used to fall through to the generic "kept + // re-running the same command" stop because the old gate + // required saw_read/saw_search. That branded turns as + // `incomplete · stalled` after two plan re-posts even when + // the model still had the implementation repair budget — + // exactly the "I started that fix but didn't land the + // edit" stall. Bookkeeping is zero-progress meta-work, not + // a dangerous inspection loop; hand it the same edit nudge. + let bookkeeping_only_stall = calls + .iter() + .all(|(_, name, _)| hi_tools::is_coordination(name)); + let implementation_needs_mutation = !implementation_tracker.mutation_seen + && (implementation_intent.is_some() || expected_mutation) + && ((evidence.saw_read || evidence.saw_search) || bookkeeping_only_stall); + if implementation_needs_mutation { + if implementation_tracker.no_change_nudges < 2 { + implementation_tracker.no_change_nudges += 1; + evidence.quality_repair_nudges = + evidence.quality_repair_nudges.saturating_add(1); + let use_text_fallback = implementation_tracker.no_change_nudges >= 2; + force_tools_next = !use_text_fallback; + text_tool_fallback_next = use_text_fallback; + // Clear the sticky repeat stall: we are converting it + // into an implementation-repair continue, not ending + // the turn as stalled_repeating. + stalled_repeating = false; + // Drop the sticky prev signature so the next real + // tool call isn't immediately compared against the + // bookkeeping-only round that just exhausted the + // repeat budget. + prev_call_sig = None; + prev_added_no_evidence = false; + if bookkeeping_only_stall { + // Keep bookkeeping withheld while we demand real + // work — otherwise the model just re-posts the + // plan again on the repair round. + suppress_bookkeeping_tools_next = true; + ui.nudge( + "implementation burned the bookkeeping-repeat budget without editing; nudging the model to edit or scaffold", + ); + } else { + ui.nudge( + "implementation kept repeating without editing; nudging the model to edit or scaffold", + ); + } + let nudge = if use_text_fallback { + implementation_text_tool_nudge(IMPLEMENTATION_NO_CHANGES_NUDGE) + } else { + IMPLEMENTATION_NO_CHANGES_NUDGE.to_string() + }; + self.messages.push_nudge(NudgeKind::Continue, nudge); + return Ok(ModelRoundControl::Continue); + } + + stalled_unfinished = true; + ui.nudge( + "implementation kept repeating without editing; no file changes were made", + ); + ui.status(INCOMPLETE_STATUS); + return Ok(ModelRoundControl::BreakInner(false)); + } + ui.status( + "⚠ the model kept re-running the same command without acting on the \ + result — the task may be incomplete. /retry, or send 'continue'.", + ); + return Ok(ModelRoundControl::BreakInner(false)); + } + // A different set of calls (or none) this round — the model moved + // on, so clear any pending repeat-stall state. A wait-poll + // round is not counted as the first wasted round of a cycle: + // waiting on external state is progress-neutral, not evidence + // of a loop. + stalled_repeating = false; + repeat_sampling_rounds = 0; + prev_call_sig = Some(call_sig); + prev_added_no_evidence = no_new_evidence && !has_wait_poll_bash; + + // Inspection-sprawl guard: a read-only review turn that keeps + // reading *distinct* files (each a new inspection signature, so + // the repeat/cycle guard above never fires) without ever + // producing findings. Once enough evidence has accumulated, + // nudge the model to answer; if it keeps sprawling past the + // budget, stop incomplete rather than fabricate an answer. This is + // the only guard that catches the "read 100 files, never + // answer" failure mode — all review-quality guards fire only + // on a final text answer, which never comes while the model + // keeps issuing tool calls. + if inspection_sprawl_exhausted( + inspection_sprawl_intent, + &evidence, + &calls, + read_only_inspection_cap, + ) { + stalled_unfinished = true; + ui.nudge( + "review kept inspecting new files without producing findings; stopping incomplete", + ); + ui.status(INCOMPLETE_STATUS); + return Ok(ModelRoundControl::BreakInner(false)); + } + if should_nudge_inspection_sprawl( + inspection_sprawl_intent, + &evidence, + &calls, + read_only_inspection_cap, + ) { + evidence.inspection_sprawl_nudges = + evidence.inspection_sprawl_nudges.saturating_add(1); + force_text_answer_next = true; + let cap = read_only_inspection_cap + .unwrap_or_else(|| evidence.inspection_attempt_count()); + ui.nudge(&format!( + "review inspected {} files/searches without answering; nudging it to produce findings", + evidence.inspection_attempt_count() + )); + self.messages + .push_assistant_text_only(std::mem::take(&mut completion.content)); + self.messages.push_nudge( + NudgeKind::Continue, + inspection_sprawl_nudge(cap, evidence.inspection_attempt_count()), + ); + return Ok(ModelRoundControl::Continue); + } + + // This round's assistant text, joined and captured before the + // content is moved into history. Used both to detect a content-less + // response (a reasoning model can return only reasoning tokens or + // whitespace) and to spot an announced-but-unperformed next step. + let assistant_text: String = completion + .content + .iter() + .filter_map(|c| match c { + Content::Text(t) => Some(t.as_str()), + _ => None, + }) + .collect::>() + .join("\n"); + let has_text = !assistant_text.trim().is_empty(); + + if request_no_progress_final_answer { + let unusable = forced_final_answer_is_unusable( + &assistant_text, + self.goals.plan_incomplete(), + ); + if has_text && (buffer_read_only_review_text || !streamed_assistant_text) { + let text_to_emit = if buffered_assistant_text.is_empty() { + assistant_text.as_str() + } else { + buffered_assistant_text.as_str() + }; + ui.assistant_text(text_to_emit); + ui.assistant_end(); + } + if unusable { + self.messages + .push_assistant_text_only(std::mem::take(&mut completion.content)); + stalled_unfinished = true; + progress_tracker.record( + ProgressKind::None, + "forced final-answer attempt was unusable", + None, + ); + ui.status(INCOMPLETE_STATUS); + return Ok(ModelRoundControl::BreakInner(false)); + } + self.messages + .push_assistant(std::mem::take(&mut completion.content)); + progress_tracker.record_final_answer(); + return Ok(ModelRoundControl::BreakInner(false)); + } + + // Auto-recover from a content-less response — no tool calls and no + // text, i.e. a flaky provider returning only reasoning or an empty + // message. Silently re-run a few times before giving up, each + // retry resampling hotter (see the temperature bump above). The + // dead round isn't recorded, so each retry re-runs with the + // original context. + if calls.is_empty() && !has_text { + if empty_retries < self.config.loop_limits.max_empty_retries { + empty_retries += 1; + if made_tool_call { + self.nudge_after_post_tool_empty_response( + &mut force_tools_next, + implementation_intent.is_some(), + ); + } + ui.status(&format!( + "⚠ the model returned no response — retrying ({empty_retries}/{})", + self.config.loop_limits.max_empty_retries + )); + return Ok(ModelRoundControl::Continue); + } + ui.status("⚠ the model returned no response after retrying — try /retry."); + return Ok(ModelRoundControl::BreakInner(false)); + } + // Real output this round — clear the retry counter so the + // temperature bump is transient: a later, unrelated stall gets + // its own budget rather than inheriting this one's elevation. + empty_retries = 0; + retry_state.protocol_retries = 0; + truncation_retries = 0; + + if calls.is_empty() { + match self.steer_without_tools( + &assistant_text, + &mut completion.content, + read_only_intent, + implementation_intent, + &mut implementation_tracker, + &mut evidence, + &mut review_repair, + &mut progress_tracker, + &mut silent_continues, + &mut continue_total_nudges, + &mut force_tools_next, + &mut force_text_answer_next, + &mut text_tool_fallback_next, + &mut stalled_unfinished, + &mut buffered_assistant_text, + buffer_read_only_review_text, + steps, + ui, + ) { + super::steer::RoundControl::Continue => return Ok(ModelRoundControl::Continue), + super::steer::RoundControl::BreakInner(hit) => return Ok(ModelRoundControl::BreakInner(hit)), + } + } + + Ok(ModelRoundControl::RunTools { + calls, + completion_content: completion.content, + }) + + }.await; + + *state.steps = steps; + *state.empty_retries = empty_retries; + *state.truncation_retries = truncation_retries; + *state.truncation_total_retries = truncation_total_retries; + *state.silent_continues = silent_continues; + *state.continue_total_nudges = continue_total_nudges; + *state.repeat_nudges = repeat_nudges; + *state.repeat_sampling_rounds = repeat_sampling_rounds; + *state.force_tools_next = force_tools_next; + *state.text_tool_fallback_next = text_tool_fallback_next; + *state.force_text_answer_next = force_text_answer_next; + *state.force_no_progress_final_answer_next = force_no_progress_final_answer_next; + *state.suppress_bookkeeping_tools_next = suppress_bookkeeping_tools_next; + *state.prev_added_no_evidence = prev_added_no_evidence; + *state.made_tool_call = made_tool_call; + *state.turn_start = turn_start; + *state.stalled_repeating = stalled_repeating; + *state.stalled_unfinished = stalled_unfinished; + *state.context_generation_seen = context_generation_seen; + *state.indexed_ledger_revision = indexed_ledger_revision; + *state.sched_tool_calls = sched_tool_calls; + *state.sched_max_concurrent = sched_max_concurrent; + *state.sched_serial_runs = sched_serial_runs; + *state.tool_schema_tokens = tool_schema_tokens; + *state.ended_at_cap = ended_at_cap; + *state.prev_call_sig = prev_call_sig; + *state.retry_state = retry_state; + *state.request_max_tokens_override = request_max_tokens_override; + *state.compat_fallbacks = compat_fallbacks; + *state.effective_fallback_route = effective_fallback_route; + *state.ranked_context_paths = ranked_context_paths; + *state.progress_tracker = progress_tracker; + *state.evidence = evidence; + *state.implementation_tracker = implementation_tracker; + *state.review_repair = review_repair; + *state.tool_guardrail = tool_guardrail; + *state.last_verify_attributions = last_verify_attributions; + *state.tool_timeline = tool_timeline; + *state.advertised_tool_names = advertised_tool_names; + *state.turn_snapshot = turn_snapshot; + + result + } +} diff --git a/crates/hi-agent/src/agent/turn/steer/implementation.rs b/crates/hi-agent/src/agent/turn/steer/implementation.rs new file mode 100644 index 0000000..d921b00 --- /dev/null +++ b/crates/hi-agent/src/agent/turn/steer/implementation.rs @@ -0,0 +1,166 @@ +//! Post-tool Steer: mutation recovery, repeat/no-progress, implementation stalls. + +use crate::agent::mutation_recovery_turn::MutationRecoveryControl; +use crate::steering::{ + EvidenceTracker, IMPLEMENTATION_NO_CHANGES_NUDGE, ImplementationIntent, ImplementationTracker, + MutationRecovery, REREAD_NUDGE, ReviewIntent, WAIT_POLL_STATIC_NUDGE, bash_call_waits, + implementation_text_tool_nudge, +}; +use crate::transcript::NudgeKind; +use crate::ui::Ui; + +use super::super::phase::TurnPhase; +use super::super::progress::{ + NO_PROGRESS_FINAL_ANSWER_NUDGE, ProgressKind, ProgressTracker, no_progress_signature_for_calls, +}; +use super::super::retry::INCOMPLETE_STATUS; +use super::super::tools::ToolBatchOutcome; +use super::RoundControl; + +impl crate::Agent { + /// Post-tool Steer: mutation recovery, repeat/idempotent guards, sprawl. + #[allow(clippy::too_many_arguments)] + pub(in crate::agent::turn) fn steer_after_tools( + &mut self, + calls: &[(String, String, String)], + batch: &ToolBatchOutcome, + expected_mutation: bool, + read_only_intent: Option, + implementation_intent: Option, + implementation_tracker: &mut ImplementationTracker, + evidence: &mut EvidenceTracker, + mutation_recovery: &mut MutationRecovery, + progress_tracker: &mut ProgressTracker, + repeat_nudges: &mut u32, + force_tools_next: &mut bool, + text_tool_fallback_next: &mut bool, + force_no_progress_final_answer_next: &mut bool, + prev_added_no_evidence: &mut bool, + stalled_repeating: &mut bool, + stalled_unfinished: &mut bool, + ui: &mut dyn Ui, + ) -> RoundControl { + let ToolBatchOutcome { + hash_guard_applies, + hashable_idempotent_results, + repeated_idempotent_results, + ref tool_progress_labels, + plan_changed_this_batch, + } = *batch; + let plan_changed_this_batch = plan_changed_this_batch; + let hashable_idempotent_results = hashable_idempotent_results; + let repeated_idempotent_results = repeated_idempotent_results; + let hash_guard_applies = hash_guard_applies; +// Post-tool policy (mutation recovery, inspection sprawl, …) is Steer. +self.set_turn_phase(TurnPhase::Steer); +match self.handle_mutation_recovery( + mutation_recovery, + expected_mutation, + implementation_tracker, + evidence, + plan_changed_this_batch, force_tools_next, + ui, +) { + MutationRecoveryControl::None => {} + MutationRecoveryControl::Continue => return RoundControl::Continue, +} +let repeated_result_no_progress = hash_guard_applies + && hashable_idempotent_results == calls.len() + && repeated_idempotent_results == calls.len(); +if repeated_result_no_progress { + *prev_added_no_evidence = true; + let repeat_budget_available = *repeat_nudges < self.config.loop_limits.max_repeat_nudges; + let no_new_after_mutation = implementation_tracker.mutation_seen; + if repeat_budget_available { + *repeat_nudges += 1; + *stalled_repeating = true; + let waiting_round = calls + .iter() + .any(|(_, name, args)| name == "bash" && bash_call_waits(args)); + let force_final_after_nudge = progress_tracker.record_no_progress_nudge( + if waiting_round { + "wait poll returned static output" + } else { + "repeated idempotent tool output" + }, + no_progress_signature_for_calls(&calls), + ) && implementation_intent.is_none(); + if waiting_round { + ui.nudge(&format!( + "the wait-and-check poll returned the same output — nudging the model to diagnose the stalled process ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + } else { + ui.nudge(&format!( + "the model got the same inspection output again — nudging it to act on already-returned evidence ({repeat_nudges}/{})", + self.config.loop_limits.max_repeat_nudges + )); + } + let base_nudge = if waiting_round { + WAIT_POLL_STATIC_NUDGE + } else { + REREAD_NUDGE + }; + let nudge = if force_final_after_nudge { + *force_no_progress_final_answer_next = true; + *force_tools_next = false; + format!("{base_nudge}\n\n{NO_PROGRESS_FINAL_ANSWER_NUDGE}") + } else { + base_nudge.to_string() + }; + self.messages.push_nudge(NudgeKind::Repeat, nudge); + return RoundControl::Continue; + } + progress_tracker.record( + ProgressKind::None, + "repeated idempotent tool output", + no_progress_signature_for_calls(&calls), + ); + if !no_new_after_mutation { + if let Some(intent) = read_only_intent { + *stalled_unfinished = true; + ui.nudge( + "review kept getting the same inspection output; stopping incomplete", + ); + let _ = intent; + ui.status(INCOMPLETE_STATUS); + return RoundControl::BreakInner(false); + } + if implementation_intent.is_some() && !implementation_tracker.mutation_seen + { + if implementation_tracker.no_change_nudges < 2 { + implementation_tracker.no_change_nudges += 1; + evidence.quality_repair_nudges = + evidence.quality_repair_nudges.saturating_add(1); + let use_text_fallback = + implementation_tracker.no_change_nudges >= 2; + *force_tools_next = !use_text_fallback; + *text_tool_fallback_next = use_text_fallback; + ui.nudge( + "implementation repeated equivalent inspection output without editing; nudging the model to edit or scaffold", + ); + let nudge = if use_text_fallback { + implementation_text_tool_nudge(IMPLEMENTATION_NO_CHANGES_NUDGE) + } else { + IMPLEMENTATION_NO_CHANGES_NUDGE.to_string() + }; + self.messages.push_nudge(NudgeKind::Continue, nudge); + return RoundControl::Continue; + } + + *stalled_unfinished = true; + ui.nudge( + "implementation repeated equivalent inspection output without editing", + ); + ui.status(INCOMPLETE_STATUS); + return RoundControl::BreakInner(false); + } + } +} else if !tool_progress_labels.is_empty() { + progress_tracker.record_round_from_tools(&tool_progress_labels); +} + + RoundControl::Continue + } +} + diff --git a/crates/hi-agent/src/agent/turn/steer/mod.rs b/crates/hi-agent/src/agent/turn/steer/mod.rs new file mode 100644 index 0000000..fa09e50 --- /dev/null +++ b/crates/hi-agent/src/agent/turn/steer/mod.rs @@ -0,0 +1,18 @@ +//! Post-model / post-tool Steer policy ([`super::phase::TurnPhase::Steer`]). +//! +//! - [`review`] — text-only path (unfinished continues, review-answer repairs, +//! implementation completeness when no tools were called) +//! - [`implementation`] — post-tool path (mutation recovery, repeat/no-progress) +//! +//! Workspace compile/lint/test repair stays in [`super::verify_run`]. + +mod implementation; +mod review; + +/// Whether the inner Model→Tools→Steer loop should continue or stop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RoundControl { + Continue, + /// `true` means step-cap; `false` means natural end / stalled end of tools loop. + BreakInner(bool), +} diff --git a/crates/hi-agent/src/agent/turn/steer.rs b/crates/hi-agent/src/agent/turn/steer/review.rs similarity index 71% rename from crates/hi-agent/src/agent/turn/steer.rs rename to crates/hi-agent/src/agent/turn/steer/review.rs index f2bb085..6c0a633 100644 --- a/crates/hi-agent/src/agent/turn/steer.rs +++ b/crates/hi-agent/src/agent/turn/steer/review.rs @@ -1,72 +1,33 @@ -//! Steer-phase policy after a model round (no tools or post-tools). -//! -//! Review-answer repair, implementation completeness, silent continues, and -//! post-tool mutation recovery / repeat guards. Workspace compile/lint/test -//! repair stays in [`super::verify_run`] under WorkspaceRepair. +//! Text-only Steer path: unfinished continues, review-answer repairs, +//! and implementation completeness gates when no tools were called. use hi_ai::Content; -use crate::agent::mutation_recovery_turn::MutationRecoveryControl; use crate::heuristics::looks_like_unfinished_step; -use crate::heuristics::plan_has_pending_steps; use crate::steering::{ - CONCRETE_REVIEW_NUDGE, - EvidenceTracker, - GAP_SEARCH_OVERCLAIM_NUDGE, - IMPLEMENTATION_NO_CHANGES_NUDGE, - IMPLEMENTATION_SCAFFOLD_ONLY_NUDGE, - ImplementationIntent, - ImplementationTracker, - MutationRecovery, - READ_AFTER_SEARCH_NUDGE, - REREAD_NUDGE, - ReviewIntent, - ReviewRepairMode, - SECURITY_BROAD_SEARCH_NUDGE, - SECURITY_SCOPE_NUDGE, - WAIT_POLL_STATIC_NUDGE, - answer_says_insufficient_evidence, - bash_call_waits, - concrete_review_answer_problem, - deepen_review_nudge, - implementation_missing_validation_nudge, - implementation_text_tool_nudge, - no_evidence_review_nudge, - repair_nudge_with_required_next, - should_deepen_review, - should_nudge_gap_search_overclaim, - should_nudge_no_evidence_review, - should_nudge_read_after_search_final, - should_nudge_security_broad_search, - should_nudge_security_scope, - should_reject_review_repair_template, + CONCRETE_REVIEW_NUDGE, EvidenceTracker, GAP_SEARCH_OVERCLAIM_NUDGE, + IMPLEMENTATION_NO_CHANGES_NUDGE, IMPLEMENTATION_SCAFFOLD_ONLY_NUDGE, ImplementationIntent, + ImplementationTracker, READ_AFTER_SEARCH_NUDGE, ReviewIntent, ReviewRepairMode, + SECURITY_BROAD_SEARCH_NUDGE, SECURITY_SCOPE_NUDGE, answer_says_insufficient_evidence, + concrete_review_answer_problem, deepen_review_nudge, implementation_missing_validation_nudge, + implementation_text_tool_nudge, no_evidence_review_nudge, repair_nudge_with_required_next, + should_deepen_review, should_nudge_gap_search_overclaim, should_nudge_no_evidence_review, + should_nudge_read_after_search_final, should_nudge_security_broad_search, + should_nudge_security_scope, should_reject_review_repair_template, summarize_inspected_evidence_nudge, }; use crate::transcript::NudgeKind; use crate::{PLAN_CONTINUE_NUDGE, SILENT_CONTINUE_NUDGE, Ui}; -use super::phase::TurnPhase; -use super::progress::{ - NO_PROGRESS_FINAL_ANSWER_NUDGE, - ProgressKind, - ProgressTracker, - no_progress_signature_for_calls, -}; -use super::retry::{INCOMPLETE_STATUS, ReviewRepairState}; -use super::tools::ToolBatchOutcome; - -/// Whether the inner Model→Tools→Steer loop should continue or stop. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum RoundControl { - Continue, - /// `true` means step-cap; `false` means natural end / stalled end of tools loop. - BreakInner(bool), -} +use super::super::phase::TurnPhase; +use super::super::progress::{ProgressKind, ProgressTracker}; +use super::super::retry::{INCOMPLETE_STATUS, ReviewRepairState}; +use super::RoundControl; impl crate::Agent { /// Post-model Steer when the model returned text and no tool calls. #[allow(clippy::too_many_arguments)] - pub(super) fn steer_without_tools( + pub(in crate::agent::turn) fn steer_without_tools( &mut self, assistant_text: &str, completion_content: &mut Vec, @@ -107,7 +68,7 @@ impl crate::Agent { // after a multi-step task with a complete plan, or a plain // Q&A answer. Bounded so it can't loop forever. let looks_unfinished = looks_like_unfinished_step(assistant_text); -let plan_incomplete = plan_has_pending_steps(&self.goals.last_plan); +let plan_incomplete = self.goals.plan_incomplete(); if let Some(intent) = read_only_intent && (looks_unfinished || plan_incomplete) { @@ -581,149 +542,5 @@ if looks_unfinished || plan_incomplete { } RoundControl::BreakInner(false) } - - /// Post-tool Steer: mutation recovery, repeat/idempotent guards, sprawl. - #[allow(clippy::too_many_arguments)] - pub(super) fn steer_after_tools( - &mut self, - calls: &[(String, String, String)], - batch: &ToolBatchOutcome, - expected_mutation: bool, - read_only_intent: Option, - implementation_intent: Option, - implementation_tracker: &mut ImplementationTracker, - evidence: &mut EvidenceTracker, - mutation_recovery: &mut MutationRecovery, - progress_tracker: &mut ProgressTracker, - repeat_nudges: &mut u32, - force_tools_next: &mut bool, - text_tool_fallback_next: &mut bool, - force_no_progress_final_answer_next: &mut bool, - prev_added_no_evidence: &mut bool, - stalled_repeating: &mut bool, - stalled_unfinished: &mut bool, - ui: &mut dyn Ui, - ) -> RoundControl { - let ToolBatchOutcome { - hash_guard_applies, - hashable_idempotent_results, - repeated_idempotent_results, - ref tool_progress_labels, - plan_changed_this_batch, - } = *batch; - let plan_changed_this_batch = plan_changed_this_batch; - let hashable_idempotent_results = hashable_idempotent_results; - let repeated_idempotent_results = repeated_idempotent_results; - let hash_guard_applies = hash_guard_applies; -// Post-tool policy (mutation recovery, inspection sprawl, …) is Steer. -self.set_turn_phase(TurnPhase::Steer); -match self.handle_mutation_recovery( - mutation_recovery, - expected_mutation, - implementation_tracker, - evidence, - plan_changed_this_batch, force_tools_next, - ui, -) { - MutationRecoveryControl::None => {} - MutationRecoveryControl::Continue => return RoundControl::Continue, -} -let repeated_result_no_progress = hash_guard_applies - && hashable_idempotent_results == calls.len() - && repeated_idempotent_results == calls.len(); -if repeated_result_no_progress { - *prev_added_no_evidence = true; - let repeat_budget_available = *repeat_nudges < self.config.loop_limits.max_repeat_nudges; - let no_new_after_mutation = implementation_tracker.mutation_seen; - if repeat_budget_available { - *repeat_nudges += 1; - *stalled_repeating = true; - let waiting_round = calls - .iter() - .any(|(_, name, args)| name == "bash" && bash_call_waits(args)); - let force_final_after_nudge = progress_tracker.record_no_progress_nudge( - if waiting_round { - "wait poll returned static output" - } else { - "repeated idempotent tool output" - }, - no_progress_signature_for_calls(&calls), - ) && implementation_intent.is_none(); - if waiting_round { - ui.nudge(&format!( - "the wait-and-check poll returned the same output — nudging the model to diagnose the stalled process ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - } else { - ui.nudge(&format!( - "the model got the same inspection output again — nudging it to act on already-returned evidence ({repeat_nudges}/{})", - self.config.loop_limits.max_repeat_nudges - )); - } - let base_nudge = if waiting_round { - WAIT_POLL_STATIC_NUDGE - } else { - REREAD_NUDGE - }; - let nudge = if force_final_after_nudge { - *force_no_progress_final_answer_next = true; - *force_tools_next = false; - format!("{base_nudge}\n\n{NO_PROGRESS_FINAL_ANSWER_NUDGE}") - } else { - base_nudge.to_string() - }; - self.messages.push_nudge(NudgeKind::Repeat, nudge); - return RoundControl::Continue; - } - progress_tracker.record( - ProgressKind::None, - "repeated idempotent tool output", - no_progress_signature_for_calls(&calls), - ); - if !no_new_after_mutation { - if let Some(intent) = read_only_intent { - *stalled_unfinished = true; - ui.nudge( - "review kept getting the same inspection output; stopping incomplete", - ); - let _ = intent; - ui.status(INCOMPLETE_STATUS); - return RoundControl::BreakInner(false); - } - if implementation_intent.is_some() && !implementation_tracker.mutation_seen - { - if implementation_tracker.no_change_nudges < 2 { - implementation_tracker.no_change_nudges += 1; - evidence.quality_repair_nudges = - evidence.quality_repair_nudges.saturating_add(1); - let use_text_fallback = - implementation_tracker.no_change_nudges >= 2; - *force_tools_next = !use_text_fallback; - *text_tool_fallback_next = use_text_fallback; - ui.nudge( - "implementation repeated equivalent inspection output without editing; nudging the model to edit or scaffold", - ); - let nudge = if use_text_fallback { - implementation_text_tool_nudge(IMPLEMENTATION_NO_CHANGES_NUDGE) - } else { - IMPLEMENTATION_NO_CHANGES_NUDGE.to_string() - }; - self.messages.push_nudge(NudgeKind::Continue, nudge); - return RoundControl::Continue; - } - - *stalled_unfinished = true; - ui.nudge( - "implementation repeated equivalent inspection output without editing", - ); - ui.status(INCOMPLETE_STATUS); - return RoundControl::BreakInner(false); - } - } -} else if !tool_progress_labels.is_empty() { - progress_tracker.record_round_from_tools(&tool_progress_labels); } - RoundControl::Continue - } -} diff --git a/crates/hi-agent/src/agent/turn/tools.rs b/crates/hi-agent/src/agent/turn/tools.rs index 985239b..bfea11a 100644 --- a/crates/hi-agent/src/agent/turn/tools.rs +++ b/crates/hi-agent/src/agent/turn/tools.rs @@ -901,7 +901,7 @@ while done < calls.len() { && output .plan .as_deref() - .is_some_and(|plan| self.goals.last_plan.as_slice() != plan); + .is_some_and(|plan| self.goals.plan() != plan); plan_changed_this_batch |= plan_changed; evidence.record_success(name, &calls[i].2, &semantic_output); implementation_tracker.record_tool_result( @@ -948,7 +948,7 @@ while done < calls.len() { if calls[i].1 == "update_plan" && let Some(plan) = output.plan.as_deref() { - self.goals.last_plan = plan.to_vec(); + let _ = self.goals.replace_plan(plan); if let Some(session) = self.session.as_mut() { if plan_has_pending_steps(plan) { session.record_plan(plan)?; diff --git a/crates/hi-agent/src/domain.rs b/crates/hi-agent/src/domain.rs index 4bb13a0..2f158dd 100644 --- a/crates/hi-agent/src/domain.rs +++ b/crates/hi-agent/src/domain.rs @@ -2,12 +2,13 @@ //! //! These keep goals/plans and RSI observe-only fields grouped so the root //! `Agent` surface reads as composition rather than a flat bag of peers. -//! Access remains `pub(crate)` field projection (`agent.goals.last_plan`) so -//! existing call sites stay direct without a large accessor layer. +//! Plan/snapshot mutations go through [`GoalState`] methods; field projection +//! remains available for read-heavy call sites. -use hi_tools::PlanStep; +use hi_tools::{PlanStatus, PlanStep}; use crate::goal::Goal; +use crate::heuristics::plan_has_pending_steps; /// Session goal + plan state owned by the interactive agent. #[derive(Clone, Debug, Default)] @@ -20,6 +21,91 @@ pub(crate) struct GoalState { pub(crate) last_plan: Vec, } +impl GoalState { + /// Current plan steps (possibly empty). + pub(crate) fn plan(&self) -> &[PlanStep] { + &self.last_plan + } + + /// Whether any plan step is still pending/active. + pub(crate) fn plan_incomplete(&self) -> bool { + plan_has_pending_steps(&self.last_plan) + } + + /// Drop the in-memory plan. + pub(crate) fn clear_plan(&mut self) { + self.last_plan.clear(); + } + + /// Clear the plan unless `preserve` is set (e.g. goal-drive / "continue"). + /// Returns whether the plan was cleared. + pub(crate) fn clear_plan_unless(&mut self, preserve: bool) -> bool { + if preserve || self.last_plan.is_empty() { + return false; + } + self.clear_plan(); + true + } + + /// Install a plan only when it still has unfinished work; completed-only + /// plans are dropped so they don't re-trigger incomplete-plan steering. + pub(crate) fn set_plan_if_pending(&mut self, plan: Vec) { + self.last_plan = if plan.iter().any(|step| step.status != PlanStatus::Done) { + plan + } else { + Vec::new() + }; + } + + /// Replace the plan from an `update_plan` tool result. Returns whether the + /// steps actually changed. + pub(crate) fn replace_plan(&mut self, plan: &[PlanStep]) -> bool { + let changed = self.last_plan.as_slice() != plan; + self.last_plan = plan.to_vec(); + changed + } + + /// Snapshot the triple stored on [`crate::AgentStateSnapshot`] (decisions + /// stay outside this holder). + pub(crate) fn snapshot_triple(&self) -> (Option, Option, Vec) { + ( + self.free_text.clone(), + self.structured.clone(), + self.last_plan.clone(), + ) + } + + /// Restore free-text, structured goal, and plan from a prior snapshot triple. + pub(crate) fn restore_triple( + &mut self, + free_text: Option, + structured: Option, + last_plan: Vec, + ) { + self.free_text = free_text; + self.structured = structured; + self.last_plan = last_plan; + } + + /// Set or clear the transient free-text goal (trim; empty → `None`). + pub(crate) fn set_free_text(&mut self, goal: Option) { + self.free_text = goal.and_then(|g| { + let g = g.trim().to_string(); + (!g.is_empty()).then_some(g) + }); + } + + /// Clone the durable structured goal (turn-start baseline for revert). + pub(crate) fn clone_structured(&self) -> Option { + self.structured.clone() + } + + /// Replace the durable structured goal. + pub(crate) fn set_structured(&mut self, goal: Option) { + self.structured = goal; + } +} + /// Live RSI observation state that is *not* config (`AgentRsi`). /// /// Interactive code may observe RSI; it must not drive the RSI workflow SM. diff --git a/crates/hi-cli/src/agent_build.rs b/crates/hi-cli/src/agent_build.rs new file mode 100644 index 0000000..bbc556b --- /dev/null +++ b/crates/hi-cli/src/agent_build.rs @@ -0,0 +1,151 @@ +//! Build the interactive [`Agent`] from CLI settings, quality, and session state. + +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use hi_agent::{Agent, AgentConfig, CompactionKind}; +use hi_ai::Provider; + +use crate::config::{Cli, QualitySettings, RsiRequested, Settings, permits_missing_checkpoint}; +use crate::goal_drive; +use crate::landing::LoadedAgentSession; +use crate::project_context::load_project_context; +use crate::provider::{LiveModelMetadata, provider_label}; + +pub(crate) struct BuiltAgent { + pub agent: Agent, + pub resume_summary: Option, +} + +/// Construct [`AgentConfig`] and resume or create the session agent. +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_agent( + cli: &Cli, + settings: &Settings, + quality: &QualitySettings, + workspace_root: PathBuf, + state_root: PathBuf, + provider: Arc, + live_metadata: &LiveModelMetadata, + max_tokens: u32, + planner_model: Option, + skeptic_model: Option, + rsi_requested: RsiRequested, + rsi_control: Option>, + rsi_remote_switch: Option>, + loaded: Option, + ledger_scan: Option, +) -> Result { +let agent_config = AgentConfig { + paths: hi_agent::AgentPaths { + workspace_root: workspace_root.clone(), + state_root: state_root.clone(), + ..hi_agent::AgentPaths::default() + }, + routing: hi_agent::AgentRouting { + model: settings.model.clone(), + provider_route: Some(provider_label(settings.provider).to_string()), + requested_max_tokens: settings.max_tokens, + max_tokens, + max_tokens_explicit: settings.max_tokens_explicit, + temperature: cli.temperature, + thinking_budget: settings.thinking_budget, + reasoning_effort: settings.reasoning_effort, + tool_mode: settings.tool_mode, + compat: settings.compat, + context_window: live_metadata.context_window, + ..hi_agent::AgentRouting::default() + }, + gates: hi_agent::AgentGates { + verification: quality.verification.clone(), + max_verify_repairs: quality.max_verify_repairs, + review: quality.review, + allow_unverified: cli.allow_unverified, + allow_no_checkpoint: permits_missing_checkpoint(cli), + lsp_mode: quality.lsp_mode, + confirm_edits: cli.confirm_edits, + ..hi_agent::AgentGates::default() + }, + loop_limits: hi_agent::AgentLoopLimits { + max_steps: cli.max_steps.unwrap_or(u32::MAX), + max_steps_explicit: cli.max_steps.is_some(), + max_tool_calls: cli.max_tool_calls.unwrap_or(u32::MAX), + ..hi_agent::AgentLoopLimits::default() + }, + memory: hi_agent::AgentMemory { + tool_set: quality.tool_set, + // Env override lets you flip on skill auto-curation without editing a profile. + curate_skills: settings.curate_skills || std::env::var_os("HI_CURATE_SKILLS").is_some(), + project_context: load_project_context(), + context_exclusions: quality.context_exclusions.clone(), + auto_compact: !cli.no_auto_compact, + compaction: cli + .compaction + .as_deref() + .and_then(CompactionKind::from_arg) + .unwrap_or(CompactionKind::Hybrid { + keep_recent: hi_agent::DEFAULT_KEEP_RECENT, + }), + finalize: !cli.no_finalize, + ..hi_agent::AgentMemory::default() + }, + subagents: hi_agent::AgentSubagents { + explore_subagents: settings.explore_subagents + || std::env::var_os("HI_EXPLORE_SUBAGENTS").is_some(), + // Profile/settings choose Off/Risk/On; HI_WRITE_SUBAGENTS forces On. + write_subagents: if std::env::var_os("HI_WRITE_SUBAGENTS").is_some() { + hi_agent::WriteSubagentPolicy::On + } else { + settings.write_subagents + }, + // `--subagent` marks a delegate child: no explore/delegate offered (depth ≤ 1). + is_subagent: cli.subagent, + planner_model: planner_model.clone(), + skeptic_model, + // Opt-in: route the `/goal` skeptic review to a local (or any + // OpenAI-compatible) endpoint via HI_SKEPTIC_ENDPOINT — e.g. a running + // hi-local MLX/CUDA server. Requires HI_SKEPTIC_MODEL to name a model it + // serves. Off unless the env var is set. + skeptic_endpoint: std::env::var("HI_SKEPTIC_ENDPOINT") + .ok() + .filter(|s| !s.trim().is_empty()), + skeptic_endpoint_key: std::env::var("HI_SKEPTIC_ENDPOINT_KEY") + .ok() + .filter(|s| !s.trim().is_empty()), + // `/goal` is a core CLI contract, not a provider-specific feature. + // Delegate children receive bounded tasks and therefore keep it off. + long_horizon: goal_drive::long_horizon_enabled(cli.subagent), + ..hi_agent::AgentSubagents::default() + }, + rsi: hi_agent::AgentRsi { + enabled: rsi_requested != RsiRequested::Off, + managed: rsi_requested == RsiRequested::Managed, + remote_switch: rsi_remote_switch.clone(), + control: rsi_control, + ..hi_agent::AgentRsi::default() + }, + ..AgentConfig::default() + }; + let resume_summary = loaded.as_ref().and_then(|l| l.resume_summary.clone()); + let restored_plan = loaded.as_ref().map(|l| l.plan.clone()).unwrap_or_default(); + let agent_result = match loaded { + Some(loaded) => Agent::resume( + provider, + agent_config, + loaded.messages, + loaded.usage, + loaded.checkpoint_refs, + loaded.structured_goal, + loaded.decisions, + ), + None => Agent::with_background_scan(provider, agent_config, ledger_scan), + }; + let mut agent = agent_result.context("initializing workspace runtime")?; + agent.restore_plan(restored_plan); + + Ok(BuiltAgent { + agent, + resume_summary, + }) +} diff --git a/crates/hi-cli/src/main.rs b/crates/hi-cli/src/main.rs index 6fab842..32aeb70 100644 --- a/crates/hi-cli/src/main.rs +++ b/crates/hi-cli/src/main.rs @@ -1,4 +1,5 @@ mod bestof; +mod agent_build; mod bootstrap; mod candidate_gate; mod candidate_merge; @@ -34,12 +35,12 @@ mod delegate_tests; use std::io::IsTerminal; use std::path::PathBuf; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; -use hi_agent::{Agent, AgentConfig, CompactionKind, ObservationSink, VerificationMode}; +use hi_agent::{ObservationSink, VerificationMode}; use hi_ai::Provider; -use config::{ProviderName, RsiRequested, permits_missing_checkpoint}; +use config::{ProviderName, RsiRequested}; use landing::{effective_prompt, print_landing, profile_infos, resolve_session}; use orchestration::{build_sync_config, run_best_of, run_hf_cli, run_mcp_command}; use project_context::{auto_memory_enabled, load_project_context}; @@ -256,135 +257,33 @@ async fn run() -> Result<()> { if cli.skeptic_review { return run_skeptic_review(provider, &settings, skeptic_model).await; } - let agent_config = AgentConfig { - paths: hi_agent::AgentPaths { - workspace_root: workspace_root.clone(), - state_root: state_root.clone(), - ..hi_agent::AgentPaths::default() - }, - routing: hi_agent::AgentRouting { - model: settings.model.clone(), - provider_route: Some(provider_label(settings.provider).to_string()), - requested_max_tokens: settings.max_tokens, - max_tokens, - max_tokens_explicit: settings.max_tokens_explicit, - temperature: cli.temperature, - thinking_budget: settings.thinking_budget, - reasoning_effort: settings.reasoning_effort, - tool_mode: settings.tool_mode, - compat: settings.compat, - context_window: live_metadata.context_window, - ..hi_agent::AgentRouting::default() - }, - gates: hi_agent::AgentGates { - verification: quality.verification.clone(), - max_verify_repairs: quality.max_verify_repairs, - review: quality.review, - allow_unverified: cli.allow_unverified, - allow_no_checkpoint: permits_missing_checkpoint(&cli), - lsp_mode: quality.lsp_mode, - confirm_edits: cli.confirm_edits, - ..hi_agent::AgentGates::default() - }, - loop_limits: hi_agent::AgentLoopLimits { - max_steps: cli.max_steps.unwrap_or(u32::MAX), - max_steps_explicit: cli.max_steps.is_some(), - max_tool_calls: cli.max_tool_calls.unwrap_or(u32::MAX), - ..hi_agent::AgentLoopLimits::default() - }, - memory: hi_agent::AgentMemory { - tool_set: quality.tool_set, - // Env override lets you flip on skill auto-curation without editing a profile. - curate_skills: settings.curate_skills || std::env::var_os("HI_CURATE_SKILLS").is_some(), - project_context: load_project_context(), - context_exclusions: quality.context_exclusions.clone(), - auto_compact: !cli.no_auto_compact, - compaction: cli - .compaction - .as_deref() - .and_then(CompactionKind::from_arg) - .unwrap_or(CompactionKind::Hybrid { - keep_recent: hi_agent::DEFAULT_KEEP_RECENT, - }), - finalize: !cli.no_finalize, - ..hi_agent::AgentMemory::default() - }, - subagents: hi_agent::AgentSubagents { - explore_subagents: settings.explore_subagents - || std::env::var_os("HI_EXPLORE_SUBAGENTS").is_some(), - // Profile/settings choose Off/Risk/On; HI_WRITE_SUBAGENTS forces On. - write_subagents: if std::env::var_os("HI_WRITE_SUBAGENTS").is_some() { - hi_agent::WriteSubagentPolicy::On - } else { - settings.write_subagents - }, - // `--subagent` marks a delegate child: no explore/delegate offered (depth ≤ 1). - is_subagent: cli.subagent, - planner_model: planner_model.clone(), - skeptic_model, - // Opt-in: route the `/goal` skeptic review to a local (or any - // OpenAI-compatible) endpoint via HI_SKEPTIC_ENDPOINT — e.g. a running - // hi-local MLX/CUDA server. Requires HI_SKEPTIC_MODEL to name a model it - // serves. Off unless the env var is set. - skeptic_endpoint: std::env::var("HI_SKEPTIC_ENDPOINT") - .ok() - .filter(|s| !s.trim().is_empty()), - skeptic_endpoint_key: std::env::var("HI_SKEPTIC_ENDPOINT_KEY") - .ok() - .filter(|s| !s.trim().is_empty()), - // `/goal` is a core CLI contract, not a provider-specific feature. - // Delegate children receive bounded tasks and therefore keep it off. - long_horizon: goal_drive::long_horizon_enabled(cli.subagent), - ..hi_agent::AgentSubagents::default() - }, - rsi: hi_agent::AgentRsi { - enabled: rsi_requested != RsiRequested::Off, - managed: rsi_requested == RsiRequested::Managed, - remote_switch: rsi_remote_switch.clone(), - control: rsi_control, - ..hi_agent::AgentRsi::default() - }, - ..AgentConfig::default() - }; - let resume_summary = loaded.as_ref().and_then(|l| l.resume_summary.clone()); - let restored_plan = loaded.as_ref().map(|l| l.plan.clone()).unwrap_or_default(); - let agent_result = match loaded { - Some(loaded) => Agent::resume( - provider, - agent_config, - loaded.messages, - loaded.usage, - loaded.checkpoint_refs, - loaded.structured_goal, - loaded.decisions, - ), - None => Agent::with_background_scan(provider, agent_config, ledger_scan), - }; - let mut agent = match agent_result { - Ok(agent) => agent, - Err(error) => { - if let Some(path) = &report_path { - let rsi_summary = - finish_initialization_trace(rsi.observer.as_ref(), &error)?; - write_initialization_failure_report( - path, - &settings.model, - provider_label(settings.provider), - &error, - rsi_summary.as_ref(), - cli.max_tool_calls.unwrap_or(u32::MAX), - )?; - } - return Err(error).context("initializing workspace runtime"); - } - }; + let agent_build::BuiltAgent { + mut agent, + resume_summary, + } = agent_build::build_agent( + &cli, + &settings, + &quality, + workspace_root.clone(), + state_root.clone(), + provider.clone(), + &live_metadata, + max_tokens, + planner_model, + skeptic_model.clone(), + rsi_requested, + rsi_control.clone(), + rsi_remote_switch.clone(), + loaded, + ledger_scan, + )?; + let managed_context = cli .rsi_context_json .as_deref() .map(rsi_remote::load_managed_context) .transpose()?; agent.set_managed_rsi_context(managed_context); - agent.restore_plan(restored_plan); // Attach the write-`delegate` subagent runner for any top-level agent (a // subagent can't delegate), regardless of whether write subagents start on — // so `/delegate on` can enable it at runtime. The tool stays gated by the diff --git a/crates/hi-tools/src/catalog.rs b/crates/hi-tools/src/catalog.rs new file mode 100644 index 0000000..6668425 --- /dev/null +++ b/crates/hi-tools/src/catalog.rs @@ -0,0 +1,818 @@ +//! Tool catalog: advertised specs, capability metadata, and classifiers. +//! +//! Pure data + classification — no I/O. Execute dispatch stays in [`crate::tools`]. + +use hi_ai::ToolSpec; +use serde_json::json; +use std::sync::LazyLock; + +/// The tools advertised to the model each turn. +fn build_tool_specs() -> Vec { + vec![ + ToolSpec { + name: "update_plan".into(), + description: "Record or update a short task plan, shown to the user as a live checklist. Call it when starting a task that takes several steps — pass the full ordered list of steps — then call it again as you progress, ALWAYS passing the complete list with updated statuses (mark the step you're on `active`, finished steps `done`). Keep titles to a few words. Skip it for trivial one-step tasks.".into(), + parameters: json!({ + "type": "object", + "properties": { + "steps": { + "type": "array", + "description": "The full ordered list of plan steps, resubmitted in its entirety on every call.", + "items": { + "type": "object", + "properties": { + "title": { "type": "string", "description": "Short description of the step (a few words)." }, + "status": { "type": "string", "enum": ["pending", "active", "done"], "description": "pending (not started), active (in progress now), or done." } + }, + "required": ["title", "status"] + } + } + }, + "required": ["steps"] + }), + }, + ToolSpec { + name: "record_decision".into(), + description: "Record a key design decision so it persists across context compaction and keeps later turns consistent. Call this when you commit to an approach, a convention, or a non-obvious tradeoff (e.g. 'using a BTreeMap for ordered iteration', 'skipping Windows support for now'). Kept verbatim in the system prompt — NOT summarized away — so a long refactor doesn't drift from its own rationale. Use sparingly: only for decisions that matter later.".into(), + parameters: json!({ + "type": "object", + "properties": { + "summary": { "type": "string", "description": "A short title of the decision (one line)." }, + "rationale": { "type": "string", "description": "Why this choice — the constraint or tradeoff that drove it." }, + "files": { + "type": "array", + "description": "Files the decision most affects (may be empty).", + "items": { "type": "string" } + } + }, + "required": ["summary", "rationale"] + }), + }, + ToolSpec { + name: "read".into(), + description: "Read a UTF-8 text file. Lines are returned numbered (`\\t`). Returns at most 2000 lines by default (the whole file for most source files); page with offset/limit instead of assuming you saw everything.".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file to read." }, + "offset": { "type": "integer", "description": "1-based line to start at (default: first line)." }, + "limit": { "type": "integer", "description": "Maximum number of lines to return (default: 2000)." } + }, + "required": ["path"] + }), + }, + ToolSpec { + name: "write".into(), + description: "Create a new file, or overwrite a small existing file, with the given content. Parent directories are created as needed. Do not use write to rewrite a large existing source file — use `edit` / `multi_edit` / `apply_patch` for in-place changes (large overwrites are rejected).".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file to write." }, + "content": { "type": "string", "description": "Full content to write." } + }, + "required": ["path", "content"] + }), + }, + ToolSpec { + name: "edit".into(), + description: "Replace a unique block of text in a file (preferred for ≤1 hunk on a known file). old_string must occur once and be the file's literal text WITHOUT the `read` line-number gutter; whitespace and indentation differences are tolerated. Set replace_all=true to replace every occurrence (use with care). On a miss, the tool re-reads once if the file changed underfoot.".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file to edit." }, + "old_string": { "type": "string", "description": "Exact text to replace; must be unique in the file unless replace_all is set. Do not include line numbers." }, + "new_string": { "type": "string", "description": "Replacement text." }, + "replace_all": { "type": "boolean", "description": "If true, replace every occurrence of old_string (default: false, requires uniqueness)." } + }, + "required": ["path", "old_string", "new_string"] + }), + }, + ToolSpec { + name: "multi_edit".into(), + description: "Apply several edits to one file atomically, in order. Each edit replaces a unique block (same rules as `edit`); if any fails, none are applied. Prefer this over multiple `edit` calls on the same file.".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file to edit." }, + "edits": { + "type": "array", + "description": "Edits applied in sequence to the file's evolving content.", + "items": { + "type": "object", + "properties": { + "old_string": { "type": "string", "description": "Exact text to replace; unique at the time this edit applies. No line numbers." }, + "new_string": { "type": "string", "description": "Replacement text." } + }, + "required": ["old_string", "new_string"] + } + } + }, + "required": ["path", "edits"] + }), + }, + ToolSpec { + name: "bash".into(), + description: "Run a shell command via `sh -c` in the current working directory and return combined stdout/stderr. stdin is closed, so commands never block on input. A foreground command still running at its timeout is moved to the background (kept running, not killed) and returns a handle id — read its output with bash_output and stop it with bash_kill. For a process you know upfront is long-lived or blocking (a dev server, a file watcher, `tail -f`), set run_in_background:true to get the handle immediately. For a slow but finite build or test suite, raise `timeout` so it finishes in the foreground.".into(), + parameters: json!({ + "type": "object", + "properties": { + "command": { "type": "string", "description": "The command to run." }, + "timeout": { "type": "integer", "description": "Optional wall-clock limit in seconds (default 600, max 3600). Raise it for a slow test/build suite. Ignored when run_in_background is true." }, + "run_in_background": { "type": "boolean", "description": "Run detached and return a handle id immediately instead of waiting for the command to exit. Use for servers/watchers/long-lived processes." } + }, + "required": ["command"] + }), + }, + ToolSpec { + name: "bash_output".into(), + description: "Read new output (stdout+stderr) from a background process started by `bash` with run_in_background, since the last read. Also reports whether it is still running, exited (with code), or was killed. Returns immediately.".into(), + parameters: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "The background process handle returned by bash (e.g. `bg_1`)." } + }, + "required": ["id"] + }), + }, + ToolSpec { + name: "bash_kill".into(), + description: "Stop a background process (and its whole process tree) started by `bash` with run_in_background. Idempotent.".into(), + parameters: json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "The background process handle to kill (e.g. `bg_1`)." } + }, + "required": ["id"] + }), + }, + ToolSpec { + name: "list".into(), + description: "List the project's files (respecting .gitignore), optionally under a subpath. Use this first to get the lay of the codebase before reading files.".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Directory to list, relative to the project root (default: the whole project)." } + } + }), + }, + ToolSpec { + name: "diff".into(), + description: "Show what's changed in the working tree versus the last commit (tracked changes as a diff, plus a list of new untracked files). Use this to review your own edits before finishing.".into(), + parameters: json!({ + "type": "object", + "properties": {} + }), + }, + ToolSpec { + name: "grep".into(), + description: "Search file contents for a regular expression (ripgrep if available, else grep), respecting .gitignore. Returns matching `path:line: text`. Use this to find where something is defined or used. Pass `context` to see surrounding lines. Pass `glob` to filter by file name pattern (e.g. `*.rs`).".into(), + parameters: json!({ + "type": "object", + "properties": { + "pattern": { "type": "string", "description": "Regular expression to search for." }, + "path": { "type": "string", "description": "File or directory to search (default: the whole project)." }, + "context": { "type": "integer", "description": "Lines of context to show around each match (default: 0)." }, + "glob": { "type": "string", "description": "File name glob to filter (e.g. `*.rs`, `*.py`). Only files whose name matches are searched." } + }, + "required": ["pattern"] + }), + }, + ToolSpec { + name: "glob".into(), + description: "Find files by name pattern (e.g. `**/*.rs`, `src/*.py`). Respects .gitignore. Returns matching paths, up to 500 results.".into(), + parameters: json!({ + "type": "object", + "properties": { + "pattern": { "type": "string", "description": "Glob pattern to match file paths (e.g. `**/*.rs`, `*.py`)." }, + "path": { "type": "string", "description": "Directory to search in (default: the whole project)." } + }, + "required": ["pattern"] + }), + }, + ToolSpec { + name: "repo_map".into(), + description: "Ranked repository map of important source files and their top-level declarations. Prefer this over blind `list` when orienting on a coding task. Optional `task` boosts path/symbol word hits; optional `path` scopes under a subdirectory.".into(), + parameters: json!({ + "type": "object", + "properties": { + "task": { "type": "string", "description": "Optional task text used to rank relevant files (identifiers and path words help)." }, + "path": { "type": "string", "description": "Optional subdirectory to scope the map (project-relative)." }, + "limit": { "type": "integer", "description": "Max files to return (default 40, max 100)." } + } + }), + }, + ToolSpec { + name: "find_symbol".into(), + description: "Find definitions of a symbol by name across the repo (case-insensitive substring over fn/class/struct/trait/type/etc.). Prefer this over `grep` when you know the identifier. Returns `path` + line + kind.".into(), + parameters: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Symbol name or fragment (e.g. WorkspaceRuntime, verify_password)." }, + "path": { "type": "string", "description": "Optional subdirectory to scope the search (project-relative)." }, + "limit": { "type": "integer", "description": "Max hits to return (default 24, max 100)." } + }, + "required": ["query"] + }), + }, + ToolSpec { + name: "apply_patch".into(), + description: "Apply a multi-file (or multi-hunk) patch. Prefer `edit` for a single unique hunk in one file; use this for coordinated edits across several files. Format: '*** Begin Patch\\n*** Update File: path\\n@@ context @\\n-old\\n+new\\n unchanged\\n*** End Patch'. Also supports '*** Add File: path' and '*** Delete File: path'.".into(), + parameters: json!({ + "type": "object", + "properties": { + "patch": { "type": "string", "description": "The patch text in Begin/End Patch format." } + }, + "required": ["patch"] + }), + }, + ToolSpec { + name: "diagnostics".into(), + description: "Get LSP diagnostics (errors/warnings) for a file. Requires `/lsp on`. Returns line-level errors — cheaper and more precise than running a full build. Empty path returns diagnostics for all open files.".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file (relative to cwd)." } + }, + "required": [] + }), + }, + ToolSpec { + name: "definition".into(), + description: "Goto definition of the symbol at a position. Requires `/lsp on`. Returns file:line:col locations. More precise than grep — respects scopes and types.".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file." }, + "line": { "type": "integer", "description": "0-based line number." }, + "column": { "type": "integer", "description": "0-based character offset." } + }, + "required": ["path", "line", "column"] + }), + }, + ToolSpec { + name: "references".into(), + description: "Find all references to the symbol at a position. Requires `/lsp on`. Returns call sites as file:line:col. Semantically correct — no false matches from comments or strings.".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file." }, + "line": { "type": "integer", "description": "0-based line number." }, + "column": { "type": "integer", "description": "0-based character offset." } + }, + "required": ["path", "line", "column"] + }), + }, + ToolSpec { + name: "hover".into(), + description: "Get type and documentation for the symbol at a position. Requires `/lsp on`. Returns the hover text (type signature, docs).".into(), + parameters: json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Path to the file." }, + "line": { "type": "integer", "description": "0-based line number." }, + "column": { "type": "integer", "description": "0-based character offset." } + }, + "required": ["path", "line", "column"] + }), + }, + ToolSpec { + name: "web_search".into(), + description: "Search the web for current information outside the repo — library docs, API specs, current events, model catalogs, recent release notes. Returns cited results (title, URL, snippet). Don't use this for things `read`/`grep`/`list` can answer locally.".into(), + parameters: json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "The search query." }, + "max_results": { "type": "integer", "description": "Maximum results to return (default 5, cap 10)." } + }, + "required": ["query"] + }), + }, + ToolSpec { + name: "web_fetch".into(), + description: "Fetch a public URL and return its content (JSON pretty-printed, HTML stripped to text, truncated). No API key needed. Use this for documentation pages, public API URLs, or any direct URL the model needs to read. For search-engine results use `web_search`; for Hugging Face model discovery use `/hf` or Hub API URLs explicitly.".into(), + parameters: json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "The http:// or https:// URL to fetch." } + }, + "required": ["url"] + }), + }, + ToolSpec { + name: "web_download".into(), + description: "Download a file from Hugging Face Hub or any direct public URL. Runs in the background — returns a handle to poll with `bash_output` and stop with `bash_kill`. For a Hugging Face repo, pass `source` as `org/model`, `org/model@revision`, or `org/model@revision:filename`; if no filename is given, lists the repo's files first. Full HTTP(S) URLs are direct downloads, not Hub discovery. The `output` path defaults to the file's basename and must be within the workspace.".into(), + parameters: json!({ + "type": "object", + "properties": { + "source": { "type": "string", "description": "Hugging Face repo ref (`org/model`, `org/model@revision`, `org/model:filename`) or full URL." }, + "filename": { "type": "string", "description": "Filename within the repo (optional — if omitted, lists available files)." }, + "output": { "type": "string", "description": "Local path to save the file (defaults to basename, must be in workspace)." } + }, + "required": ["source"] + }), + }, + ] +} + +/// The tool specifications advertised to the model, cached once. +pub static TOOL_SPECS: LazyLock> = LazyLock::new(build_tool_specs); + +/// Capability family used for task-aware tool advertisement. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolCapability { + Coordination, + Repository, + Mutation, + Process, + Background, + Lsp, + Web, + Subagent, +} + +/// Authoritative behavioral metadata for every built-in and injected tool. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ToolMetadata { + pub name: &'static str, + pub capability: ToolCapability, + pub read_only: bool, + pub filesystem_mutating: bool, + pub minimal: bool, +} + +macro_rules! tool_metadata { + ($name:literal, $capability:ident, $read_only:literal, $mutating:literal, $minimal:literal) => { + ToolMetadata { + name: $name, + capability: ToolCapability::$capability, + read_only: $read_only, + filesystem_mutating: $mutating, + minimal: $minimal, + } + }; +} + +pub const TOOL_CATALOG: &[ToolMetadata] = &[ + tool_metadata!("update_plan", Coordination, true, false, true), + tool_metadata!("record_decision", Coordination, true, false, false), + tool_metadata!("read", Repository, true, false, true), + tool_metadata!("write", Mutation, false, true, true), + tool_metadata!("edit", Mutation, false, true, true), + tool_metadata!("multi_edit", Mutation, false, true, false), + tool_metadata!("bash", Process, false, false, true), + tool_metadata!("bash_output", Background, true, false, false), + tool_metadata!("bash_kill", Background, false, false, false), + tool_metadata!("list", Repository, true, false, true), + tool_metadata!("diff", Repository, true, false, false), + tool_metadata!("grep", Repository, true, false, true), + tool_metadata!("glob", Repository, true, false, true), + tool_metadata!("repo_map", Repository, true, false, true), + tool_metadata!("find_symbol", Repository, true, false, true), + tool_metadata!("apply_patch", Mutation, false, true, false), + tool_metadata!("diagnostics", Lsp, true, false, false), + tool_metadata!("definition", Lsp, true, false, false), + tool_metadata!("references", Lsp, true, false, false), + tool_metadata!("hover", Lsp, true, false, false), + tool_metadata!("web_search", Web, true, false, false), + tool_metadata!("web_fetch", Web, true, false, false), + tool_metadata!("web_download", Web, false, true, false), + tool_metadata!("explore", Subagent, false, false, false), + tool_metadata!("delegate", Subagent, false, false, false), +]; + +pub fn tool_metadata(name: &str) -> Option<&'static ToolMetadata> { + TOOL_CATALOG.iter().find(|metadata| metadata.name == name) +} + +pub fn is_known_tool(name: &str) -> bool { + tool_metadata(name).is_some() +} + +/// Essential tools kept for small models. A model around 3B can't reliably plan +/// over the full ~20-tool set — the large, detailed tool schema degrades its +/// structured-output quality and latency sharply (empirically, tool-calling +/// slowed ~15x from 6 tools to 21 and eventually produced malformed calls). This +/// lean file-navigation + edit + shell set keeps such models usable. +pub static MINIMAL_TOOL_SPECS: LazyLock> = LazyLock::new(|| { + TOOL_SPECS + .iter() + .filter(|spec| tool_metadata(&spec.name).is_some_and(|metadata| metadata.minimal)) + .cloned() + .collect() +}); + +/// The `explore` read-only subagent tool. Deliberately kept OUT of [`TOOL_SPECS`] +/// and out of [`is_read_only`]: it's only advertised when the agent explicitly +/// injects it (for a capable parent via `explore_subagents`), and because it's not +/// read-only it never survives into a `ReadOnly` child's tool set — so a subagent +/// cannot spawn another (depth is capped at 1 structurally). +pub fn explore_tool_spec() -> ToolSpec { + ToolSpec { + name: "explore".into(), + description: "Delegate a focused, READ-ONLY investigation to a subagent that runs in its own fresh context and returns just a concise answer. Use it to keep your own context clean when a question needs reading or searching across many files — e.g. \"where is X configured and how is it used?\", \"summarize how module Y works\", \"find every call site of Z and what each passes\". The subagent can only read/list/grep/glob and inspect code (no edits, no shell, no spawning). Give it ONE self-contained task with enough detail to answer standalone. Prefer it over reading many files yourself when you only need the conclusion; don't use it for trivial single-file lookups or anything that must change files.".into(), + parameters: json!({ + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "A single, self-contained read-only investigation to carry out, with enough context to answer on its own. Be specific about what to find and what to report back." + } + }, + "required": ["task"] + }), + } +} + +/// The `delegate` write-capable subagent tool. Like [`explore_tool_spec`] it's kept +/// OUT of [`TOOL_SPECS`] and [`is_read_only`], and is only injected for a top-level +/// agent (via `write_subagents`) — never for a subagent, so it can't recurse. +pub fn delegate_tool_spec() -> ToolSpec { + ToolSpec { + name: "delegate".into(), + description: "Delegate a self-contained IMPLEMENTATION subtask to a subagent that runs in its own fresh context, can edit files and run commands, and verifies its own work. Its changes are merged back into your working tree ONLY if verification passes — otherwise they're rolled back automatically. Use it to hand off a well-scoped, independent chunk of work (e.g. \"implement the FooBar parser in src/foo.rs so `cargo test foo` passes\", \"add input validation to the signup handler and update its tests\") so it stays out of your context. Give ONE self-contained task with enough detail to complete standalone, and include how success is checked. Prefer doing small edits yourself; use this for a substantial, independently-verifiable subtask. The subagent cannot itself delegate or explore.".into(), + parameters: json!({ + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "A single, self-contained implementation subtask with enough detail to complete standalone, including what 'done' looks like." + }, + "verify": { + "type": "string", + "description": "Optional shell command that must pass for the subagent's changes to be kept (e.g. `cargo test foo`). If omitted, the session's verify command is used." + } + }, + "required": ["task"] + }), + } +} + +/// Whether a tool only observes state, with no side effects — so several can +/// run concurrently within one round, and it's safe to offer in `ReadOnly` +/// tool mode. Tools that mutate the filesystem (`write`, `edit`, `multi_edit`, +/// `apply_patch`) or have ordering-sensitive external effects (`bash`, +/// `bash_kill`) are excluded. `update_plan` and `record_decision` have no +/// side effects beyond in-memory state, so they're read-only here. +/// `bash_output` is a pure poll of an existing buffer. +pub fn is_read_only(name: &str) -> bool { + tool_metadata(name).is_some_and(|metadata| metadata.read_only) +} + +/// Whether a tool mutates the working tree — so the agent should invalidate its +/// snapshot cache and kick off a proactive fast-check after it runs. This is a +/// narrower set than `!is_read_only`: `bash` can mutate files but is handled +/// separately (it always runs alone), and `bash_kill`/`update_plan`/ +/// `record_decision` have no filesystem effect even though they're not +/// read-only for parallelization purposes. +pub fn is_filesystem_mutating(name: &str) -> bool { + tool_metadata(name).is_some_and(|metadata| metadata.filesystem_mutating) +} + +/// Whether a tool is pure bookkeeping (`update_plan`, `record_decision`): +/// it records agent-side coordination state and does no work on the task +/// itself. The agent's steering uses this to spot rounds that only shuffle +/// bookkeeping — a weak-model stall pattern — and to withhold these tools for +/// a round when the model fixates on them. +pub fn is_coordination(name: &str) -> bool { + tool_metadata(name).is_some_and(|metadata| metadata.capability == ToolCapability::Coordination) +} + +/// Best-effort extraction of the primary target path from a tool call's JSON +/// arguments — the `path` field for read/write/edit/list, the `path`/`glob` for +/// grep. Returns `None` for tools without a meaningful single path (e.g. +/// `bash`, or a `grep` with only a pattern). Used by the agent to infer +/// within-batch dependencies: a read of a file a mutating call earlier in the +/// same batch targeted should observe that mutation, so it's serialized after. +/// Tolerant — a failed parse yields `None`, which the caller treats as "no +/// dependency inferred" (safe fallback to emission order). +pub fn target_path(name: &str, arguments: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(arguments).ok()?; + match name { + // read/write/edit/multi_edit carry an explicit `path`. `read` may also + // use `paths` (an array): a one-element array is that single path; a + // multi-element array has no single target, so return None and let + // dependency inference treat it conservatively. + "read" => value + .get("path") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + value.get("paths").and_then(|v| v.as_array()).and_then(|a| { + if a.len() == 1 { + a[0].as_str().map(str::to_string) + } else { + None + } + }) + }), + "write" | "edit" | "multi_edit" => value.get("path")?.as_str().map(str::to_string), + // list's path is optional (defaults to "."). + "list" => value.get("path")?.as_str().map(str::to_string), + // Optional scope path for orientation tools (directory, not a single file). + "repo_map" | "find_symbol" => value.get("path")?.as_str().map(str::to_string), + // grep: prefer an explicit `path`; fall back to `glob` only as a hint + // (a glob isn't a single file, so return None to avoid over-serializing). + "grep" => value.get("path")?.as_str().map(str::to_string), + // apply_patch: the patch text contains `*** Update File: ` (or + // `*** Add File:`/`*** Delete File:`) directives. Return the path only + // when the patch targets exactly one file. Multi-file patches have no + // single target, so return None and let dependency inference treat the + // mutation as unknown-path, serializing later reads conservatively. + "apply_patch" => { + let patch = value.get("patch")?.as_str()?; + let mut paths: Vec = patch + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("*** Update File: ") + .or_else(|| line.trim().strip_prefix("*** Add File: ")) + .or_else(|| line.trim().strip_prefix("*** Delete File: ")) + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(str::to_string) + }) + .collect(); + paths.sort(); + paths.dedup(); + if paths.len() == 1 { paths.pop() } else { None } + } + // diff/glob/bash: no single meaningful target path for dep inference. + _ => None, + } +} + + + +#[cfg(test)] +mod tests { + use super::*; + + /// Canonical capability → side-effect class matrix for interactive tools. + /// + /// RSI `hi-tool-host::SideEffect` uses the same vocabulary (None / WorkspaceRead / + /// WorkspaceWrite / Process / Network). Keep these mappings aligned when either + /// catalog changes — see `hi-tool-host` tests for the host-side mirror. + fn expected_side_effect_class(meta: &ToolMetadata) -> &'static str { + if meta.filesystem_mutating { + return "workspace_write"; + } + match meta.capability { + ToolCapability::Coordination => "none", + ToolCapability::Repository | ToolCapability::Lsp => "workspace_read", + ToolCapability::Mutation => "workspace_write", + ToolCapability::Process | ToolCapability::Background | ToolCapability::Subagent => { + "process" + } + ToolCapability::Web => { + // web_download mutates via filesystem_mutating; search/fetch are network. + if meta.read_only { + "network" + } else { + "workspace_write" + } + } + } + } + #[test] + fn read_only_tools_are_classified() { + assert!(is_read_only("read")); + assert!(is_read_only("list")); + assert!(is_read_only("grep")); + assert!(is_read_only("diff")); + assert!(is_read_only("glob")); + // No filesystem side effects — safe to parallelize and offer in + // read-only mode. + assert!(is_read_only("update_plan")); + assert!(is_read_only("record_decision")); + assert!(is_read_only("bash_output")); + // Mutating / effecting tools are not safe to run concurrently. + assert!(!is_read_only("write")); + assert!(!is_read_only("edit")); + assert!(!is_read_only("multi_edit")); + assert!(!is_read_only("apply_patch")); + assert!(!is_read_only("bash")); + assert!(!is_read_only("bash_kill")); + } + #[test] + fn filesystem_mutating_tools_are_classified() { + // Only tools that write to the working tree. + assert!(is_filesystem_mutating("write")); + assert!(is_filesystem_mutating("edit")); + assert!(is_filesystem_mutating("multi_edit")); + assert!(is_filesystem_mutating("apply_patch")); + // Everything else — including non-read-only tools like bash — does not + // directly mutate via the tool layer (bash runs alone; bash_kill stops + // a process; update_plan/record_decision are in-memory only). + assert!(!is_filesystem_mutating("bash")); + assert!(!is_filesystem_mutating("bash_kill")); + assert!(!is_filesystem_mutating("bash_output")); + assert!(!is_filesystem_mutating("update_plan")); + assert!(!is_filesystem_mutating("record_decision")); + assert!(!is_filesystem_mutating("read")); + assert!(!is_filesystem_mutating("diff")); + } + #[test] + fn metadata_catalog_covers_every_schema_once() { + let mut names = std::collections::BTreeSet::new(); + for metadata in TOOL_CATALOG { + assert!(names.insert(metadata.name), "duplicate {}", metadata.name); + } + for spec in TOOL_SPECS.iter() { + assert!(tool_metadata(&spec.name).is_some(), "missing {}", spec.name); + } + for spec in MINIMAL_TOOL_SPECS.iter() { + assert!( + tool_metadata(&spec.name).is_some_and(|metadata| metadata.minimal), + "{} is not marked minimal", + spec.name + ); + } + assert!(is_known_tool("explore")); + assert!(is_known_tool("delegate")); + assert!(!is_known_tool("hallucinated_tool")); + } + #[test] + fn target_path_extracts_path_field() { + assert_eq!( + target_path("read", r#"{"path":"src/a.rs"}"#), + Some("src/a.rs".into()) + ); + assert_eq!( + target_path("write", r#"{"path":"b.rs","content":"x"}"#), + Some("b.rs".into()) + ); + // list's path is optional → None when absent. + assert_eq!(target_path("list", r#"{}"#), None); + assert_eq!(target_path("list", r#"{"path":"sub"}"#), Some("sub".into())); + // bash has no path → None (the safe-fallback case for dep inference). + assert_eq!(target_path("bash", r#"{"command":"echo hi"}"#), None); + // Malformed JSON → None (tolerant). + assert_eq!(target_path("read", "not json"), None); + // `read` with `paths`: a one-element array yields that path. + assert_eq!( + target_path("read", r#"{"paths":["src/a.rs"]}"#), + Some("src/a.rs".into()) + ); + // A multi-element array has no single target → None. + assert_eq!( + target_path("read", r#"{"paths":["src/a.rs","src/b.rs"]}"#), + None + ); + // apply_patch: a single file directive's path is extracted. + let patch = + r#"{"patch":"*** Begin Patch\n*** Update File: src/a.rs\n-old\n+new\n*** End Patch"}"#; + assert_eq!(target_path("apply_patch", patch), Some("src/a.rs".into())); + let add_patch = + r#"{"patch":"*** Begin Patch\n*** Add File: new.txt\nhello\n*** End Patch"}"#; + assert_eq!( + target_path("apply_patch", add_patch), + Some("new.txt".into()) + ); + let delete_patch = + r#"{"patch":"*** Begin Patch\n*** Delete File: old.txt\n*** End Patch"}"#; + assert_eq!( + target_path("apply_patch", delete_patch), + Some("old.txt".into()) + ); + // Multi-file patches have no single target path. Returning None makes + // dependency inference serialize later reads conservatively. + let multi_patch = r#"{"patch":"*** Begin Patch\n*** Update File: src/a.rs\n-old\n+new\n*** Update File: src/b.rs\n-old\n+new\n*** End Patch"}"#; + assert_eq!(target_path("apply_patch", multi_patch), None); + // No file directives → None. + assert_eq!( + target_path( + "apply_patch", + r#"{"patch":"*** Begin Patch\n*** End Patch"}"# + ), + None + ); + } + #[test] + fn minimal_tool_specs_is_a_lean_subset() { + let full: Vec<&str> = TOOL_SPECS.iter().map(|s| s.name.as_str()).collect(); + let minimal: Vec<&str> = MINIMAL_TOOL_SPECS.iter().map(|s| s.name.as_str()).collect(); + assert!(minimal.len() < full.len()); + // Every minimal tool exists in the full set, in the same order. + for name in &minimal { + assert!(full.contains(name), "{name} missing from full specs"); + } + // The essentials a small coding agent needs are present. + for essential in [ + "read", + "list", + "grep", + "repo_map", + "find_symbol", + "bash", + "write", + "edit", + ] { + assert!( + minimal.contains(&essential), + "{essential} missing from minimal" + ); + } + } + #[test] + fn capability_matrix_covers_every_catalog_entry() { + assert!(!TOOL_CATALOG.is_empty()); + let mut names = std::collections::BTreeSet::new(); + for meta in TOOL_CATALOG { + assert!(names.insert(meta.name), "duplicate tool {}", meta.name); + let side = expected_side_effect_class(meta); + // Invariants tying flags to side-effect class. + match side { + "none" => { + assert!(meta.read_only, "{} none must be read_only", meta.name); + assert!(!meta.filesystem_mutating); + } + "workspace_read" => { + assert!(meta.read_only, "{} workspace_read must be read_only", meta.name); + assert!(!meta.filesystem_mutating); + } + "workspace_write" => { + assert!( + meta.filesystem_mutating || matches!(meta.capability, ToolCapability::Mutation | ToolCapability::Web), + "{} workspace_write should mutate fs or be Mutation/Web", + meta.name + ); + assert!(!meta.read_only, "{} workspace_write must not be read_only", meta.name); + } + "process" => { + assert!( + matches!( + meta.capability, + ToolCapability::Process + | ToolCapability::Background + | ToolCapability::Subagent + ), + "{} process class capability", + meta.name + ); + } + "network" => { + assert_eq!(meta.capability, ToolCapability::Web); + assert!(meta.read_only); + } + other => panic!("unknown side effect class {other}"), + } + // Classifier helpers stay consistent with catalog flags. + assert_eq!(is_read_only(meta.name), meta.read_only); + assert_eq!(is_filesystem_mutating(meta.name), meta.filesystem_mutating); + assert_eq!( + is_coordination(meta.name), + meta.capability == ToolCapability::Coordination + ); + assert!(is_known_tool(meta.name)); + } + } + #[test] + fn capability_matrix_known_tool_side_effects() { + // Explicit pins so a casual catalog edit fails loudly. + let pins = [ + ("update_plan", "none"), + ("record_decision", "none"), + ("read", "workspace_read"), + ("list", "workspace_read"), + ("grep", "workspace_read"), + ("glob", "workspace_read"), + ("repo_map", "workspace_read"), + ("find_symbol", "workspace_read"), + ("diff", "workspace_read"), + ("diagnostics", "workspace_read"), + ("definition", "workspace_read"), + ("references", "workspace_read"), + ("hover", "workspace_read"), + ("write", "workspace_write"), + ("edit", "workspace_write"), + ("multi_edit", "workspace_write"), + ("apply_patch", "workspace_write"), + ("web_download", "workspace_write"), + ("bash", "process"), + ("bash_output", "process"), + ("bash_kill", "process"), + ("explore", "process"), + ("delegate", "process"), + ("web_search", "network"), + ("web_fetch", "network"), + ]; + for (name, want) in pins { + let meta = tool_metadata(name).unwrap_or_else(|| panic!("missing {name}")); + assert_eq!( + expected_side_effect_class(meta), + want, + "{name} side-effect class drifted" + ); + } + // Every catalog entry is pinned (no silent additions). + let pinned: std::collections::BTreeSet<_> = pins.iter().map(|(n, _)| *n).collect(); + for meta in TOOL_CATALOG { + assert!( + pinned.contains(meta.name), + "add an explicit side-effect pin for new tool `{}`", + meta.name + ); + } + } + +} diff --git a/crates/hi-tools/src/lib.rs b/crates/hi-tools/src/lib.rs index b29773b..7d3dcc1 100644 --- a/crates/hi-tools/src/lib.rs +++ b/crates/hi-tools/src/lib.rs @@ -99,6 +99,7 @@ mod process; mod read; mod repo_map; mod structured_failure; +mod catalog; mod tools; mod transaction; mod web; diff --git a/crates/hi-tools/src/tools.rs b/crates/hi-tools/src/tools.rs index 3d460e0..c537782 100644 --- a/crates/hi-tools/src/tools.rs +++ b/crates/hi-tools/src/tools.rs @@ -1,14 +1,11 @@ use std::io::Read; use std::path::Path; -use std::sync::LazyLock; use std::time::Duration; use anyhow::{Context, Result, bail}; use serde::Deserialize; -use serde_json::json; use tokio::process::Command; -use hi_ai::ToolSpec; use crate::condense::condense; use crate::edit::{apply_edit, plan_multi_patch}; @@ -602,540 +599,11 @@ pub async fn commit_in(root: &Path) -> String { ) } -/// The tools advertised to the model each turn. -fn build_tool_specs() -> Vec { - vec![ - ToolSpec { - name: "update_plan".into(), - description: "Record or update a short task plan, shown to the user as a live checklist. Call it when starting a task that takes several steps — pass the full ordered list of steps — then call it again as you progress, ALWAYS passing the complete list with updated statuses (mark the step you're on `active`, finished steps `done`). Keep titles to a few words. Skip it for trivial one-step tasks.".into(), - parameters: json!({ - "type": "object", - "properties": { - "steps": { - "type": "array", - "description": "The full ordered list of plan steps, resubmitted in its entirety on every call.", - "items": { - "type": "object", - "properties": { - "title": { "type": "string", "description": "Short description of the step (a few words)." }, - "status": { "type": "string", "enum": ["pending", "active", "done"], "description": "pending (not started), active (in progress now), or done." } - }, - "required": ["title", "status"] - } - } - }, - "required": ["steps"] - }), - }, - ToolSpec { - name: "record_decision".into(), - description: "Record a key design decision so it persists across context compaction and keeps later turns consistent. Call this when you commit to an approach, a convention, or a non-obvious tradeoff (e.g. 'using a BTreeMap for ordered iteration', 'skipping Windows support for now'). Kept verbatim in the system prompt — NOT summarized away — so a long refactor doesn't drift from its own rationale. Use sparingly: only for decisions that matter later.".into(), - parameters: json!({ - "type": "object", - "properties": { - "summary": { "type": "string", "description": "A short title of the decision (one line)." }, - "rationale": { "type": "string", "description": "Why this choice — the constraint or tradeoff that drove it." }, - "files": { - "type": "array", - "description": "Files the decision most affects (may be empty).", - "items": { "type": "string" } - } - }, - "required": ["summary", "rationale"] - }), - }, - ToolSpec { - name: "read".into(), - description: "Read a UTF-8 text file. Lines are returned numbered (`\\t`). Returns at most 2000 lines by default (the whole file for most source files); page with offset/limit instead of assuming you saw everything.".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file to read." }, - "offset": { "type": "integer", "description": "1-based line to start at (default: first line)." }, - "limit": { "type": "integer", "description": "Maximum number of lines to return (default: 2000)." } - }, - "required": ["path"] - }), - }, - ToolSpec { - name: "write".into(), - description: "Create a new file, or overwrite a small existing file, with the given content. Parent directories are created as needed. Do not use write to rewrite a large existing source file — use `edit` / `multi_edit` / `apply_patch` for in-place changes (large overwrites are rejected).".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file to write." }, - "content": { "type": "string", "description": "Full content to write." } - }, - "required": ["path", "content"] - }), - }, - ToolSpec { - name: "edit".into(), - description: "Replace a unique block of text in a file (preferred for ≤1 hunk on a known file). old_string must occur once and be the file's literal text WITHOUT the `read` line-number gutter; whitespace and indentation differences are tolerated. Set replace_all=true to replace every occurrence (use with care). On a miss, the tool re-reads once if the file changed underfoot.".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file to edit." }, - "old_string": { "type": "string", "description": "Exact text to replace; must be unique in the file unless replace_all is set. Do not include line numbers." }, - "new_string": { "type": "string", "description": "Replacement text." }, - "replace_all": { "type": "boolean", "description": "If true, replace every occurrence of old_string (default: false, requires uniqueness)." } - }, - "required": ["path", "old_string", "new_string"] - }), - }, - ToolSpec { - name: "multi_edit".into(), - description: "Apply several edits to one file atomically, in order. Each edit replaces a unique block (same rules as `edit`); if any fails, none are applied. Prefer this over multiple `edit` calls on the same file.".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file to edit." }, - "edits": { - "type": "array", - "description": "Edits applied in sequence to the file's evolving content.", - "items": { - "type": "object", - "properties": { - "old_string": { "type": "string", "description": "Exact text to replace; unique at the time this edit applies. No line numbers." }, - "new_string": { "type": "string", "description": "Replacement text." } - }, - "required": ["old_string", "new_string"] - } - } - }, - "required": ["path", "edits"] - }), - }, - ToolSpec { - name: "bash".into(), - description: "Run a shell command via `sh -c` in the current working directory and return combined stdout/stderr. stdin is closed, so commands never block on input. A foreground command still running at its timeout is moved to the background (kept running, not killed) and returns a handle id — read its output with bash_output and stop it with bash_kill. For a process you know upfront is long-lived or blocking (a dev server, a file watcher, `tail -f`), set run_in_background:true to get the handle immediately. For a slow but finite build or test suite, raise `timeout` so it finishes in the foreground.".into(), - parameters: json!({ - "type": "object", - "properties": { - "command": { "type": "string", "description": "The command to run." }, - "timeout": { "type": "integer", "description": "Optional wall-clock limit in seconds (default 600, max 3600). Raise it for a slow test/build suite. Ignored when run_in_background is true." }, - "run_in_background": { "type": "boolean", "description": "Run detached and return a handle id immediately instead of waiting for the command to exit. Use for servers/watchers/long-lived processes." } - }, - "required": ["command"] - }), - }, - ToolSpec { - name: "bash_output".into(), - description: "Read new output (stdout+stderr) from a background process started by `bash` with run_in_background, since the last read. Also reports whether it is still running, exited (with code), or was killed. Returns immediately.".into(), - parameters: json!({ - "type": "object", - "properties": { - "id": { "type": "string", "description": "The background process handle returned by bash (e.g. `bg_1`)." } - }, - "required": ["id"] - }), - }, - ToolSpec { - name: "bash_kill".into(), - description: "Stop a background process (and its whole process tree) started by `bash` with run_in_background. Idempotent.".into(), - parameters: json!({ - "type": "object", - "properties": { - "id": { "type": "string", "description": "The background process handle to kill (e.g. `bg_1`)." } - }, - "required": ["id"] - }), - }, - ToolSpec { - name: "list".into(), - description: "List the project's files (respecting .gitignore), optionally under a subpath. Use this first to get the lay of the codebase before reading files.".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Directory to list, relative to the project root (default: the whole project)." } - } - }), - }, - ToolSpec { - name: "diff".into(), - description: "Show what's changed in the working tree versus the last commit (tracked changes as a diff, plus a list of new untracked files). Use this to review your own edits before finishing.".into(), - parameters: json!({ - "type": "object", - "properties": {} - }), - }, - ToolSpec { - name: "grep".into(), - description: "Search file contents for a regular expression (ripgrep if available, else grep), respecting .gitignore. Returns matching `path:line: text`. Use this to find where something is defined or used. Pass `context` to see surrounding lines. Pass `glob` to filter by file name pattern (e.g. `*.rs`).".into(), - parameters: json!({ - "type": "object", - "properties": { - "pattern": { "type": "string", "description": "Regular expression to search for." }, - "path": { "type": "string", "description": "File or directory to search (default: the whole project)." }, - "context": { "type": "integer", "description": "Lines of context to show around each match (default: 0)." }, - "glob": { "type": "string", "description": "File name glob to filter (e.g. `*.rs`, `*.py`). Only files whose name matches are searched." } - }, - "required": ["pattern"] - }), - }, - ToolSpec { - name: "glob".into(), - description: "Find files by name pattern (e.g. `**/*.rs`, `src/*.py`). Respects .gitignore. Returns matching paths, up to 500 results.".into(), - parameters: json!({ - "type": "object", - "properties": { - "pattern": { "type": "string", "description": "Glob pattern to match file paths (e.g. `**/*.rs`, `*.py`)." }, - "path": { "type": "string", "description": "Directory to search in (default: the whole project)." } - }, - "required": ["pattern"] - }), - }, - ToolSpec { - name: "repo_map".into(), - description: "Ranked repository map of important source files and their top-level declarations. Prefer this over blind `list` when orienting on a coding task. Optional `task` boosts path/symbol word hits; optional `path` scopes under a subdirectory.".into(), - parameters: json!({ - "type": "object", - "properties": { - "task": { "type": "string", "description": "Optional task text used to rank relevant files (identifiers and path words help)." }, - "path": { "type": "string", "description": "Optional subdirectory to scope the map (project-relative)." }, - "limit": { "type": "integer", "description": "Max files to return (default 40, max 100)." } - } - }), - }, - ToolSpec { - name: "find_symbol".into(), - description: "Find definitions of a symbol by name across the repo (case-insensitive substring over fn/class/struct/trait/type/etc.). Prefer this over `grep` when you know the identifier. Returns `path` + line + kind.".into(), - parameters: json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "Symbol name or fragment (e.g. WorkspaceRuntime, verify_password)." }, - "path": { "type": "string", "description": "Optional subdirectory to scope the search (project-relative)." }, - "limit": { "type": "integer", "description": "Max hits to return (default 24, max 100)." } - }, - "required": ["query"] - }), - }, - ToolSpec { - name: "apply_patch".into(), - description: "Apply a multi-file (or multi-hunk) patch. Prefer `edit` for a single unique hunk in one file; use this for coordinated edits across several files. Format: '*** Begin Patch\\n*** Update File: path\\n@@ context @\\n-old\\n+new\\n unchanged\\n*** End Patch'. Also supports '*** Add File: path' and '*** Delete File: path'.".into(), - parameters: json!({ - "type": "object", - "properties": { - "patch": { "type": "string", "description": "The patch text in Begin/End Patch format." } - }, - "required": ["patch"] - }), - }, - ToolSpec { - name: "diagnostics".into(), - description: "Get LSP diagnostics (errors/warnings) for a file. Requires `/lsp on`. Returns line-level errors — cheaper and more precise than running a full build. Empty path returns diagnostics for all open files.".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file (relative to cwd)." } - }, - "required": [] - }), - }, - ToolSpec { - name: "definition".into(), - description: "Goto definition of the symbol at a position. Requires `/lsp on`. Returns file:line:col locations. More precise than grep — respects scopes and types.".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file." }, - "line": { "type": "integer", "description": "0-based line number." }, - "column": { "type": "integer", "description": "0-based character offset." } - }, - "required": ["path", "line", "column"] - }), - }, - ToolSpec { - name: "references".into(), - description: "Find all references to the symbol at a position. Requires `/lsp on`. Returns call sites as file:line:col. Semantically correct — no false matches from comments or strings.".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file." }, - "line": { "type": "integer", "description": "0-based line number." }, - "column": { "type": "integer", "description": "0-based character offset." } - }, - "required": ["path", "line", "column"] - }), - }, - ToolSpec { - name: "hover".into(), - description: "Get type and documentation for the symbol at a position. Requires `/lsp on`. Returns the hover text (type signature, docs).".into(), - parameters: json!({ - "type": "object", - "properties": { - "path": { "type": "string", "description": "Path to the file." }, - "line": { "type": "integer", "description": "0-based line number." }, - "column": { "type": "integer", "description": "0-based character offset." } - }, - "required": ["path", "line", "column"] - }), - }, - ToolSpec { - name: "web_search".into(), - description: "Search the web for current information outside the repo — library docs, API specs, current events, model catalogs, recent release notes. Returns cited results (title, URL, snippet). Don't use this for things `read`/`grep`/`list` can answer locally.".into(), - parameters: json!({ - "type": "object", - "properties": { - "query": { "type": "string", "description": "The search query." }, - "max_results": { "type": "integer", "description": "Maximum results to return (default 5, cap 10)." } - }, - "required": ["query"] - }), - }, - ToolSpec { - name: "web_fetch".into(), - description: "Fetch a public URL and return its content (JSON pretty-printed, HTML stripped to text, truncated). No API key needed. Use this for documentation pages, public API URLs, or any direct URL the model needs to read. For search-engine results use `web_search`; for Hugging Face model discovery use `/hf` or Hub API URLs explicitly.".into(), - parameters: json!({ - "type": "object", - "properties": { - "url": { "type": "string", "description": "The http:// or https:// URL to fetch." } - }, - "required": ["url"] - }), - }, - ToolSpec { - name: "web_download".into(), - description: "Download a file from Hugging Face Hub or any direct public URL. Runs in the background — returns a handle to poll with `bash_output` and stop with `bash_kill`. For a Hugging Face repo, pass `source` as `org/model`, `org/model@revision`, or `org/model@revision:filename`; if no filename is given, lists the repo's files first. Full HTTP(S) URLs are direct downloads, not Hub discovery. The `output` path defaults to the file's basename and must be within the workspace.".into(), - parameters: json!({ - "type": "object", - "properties": { - "source": { "type": "string", "description": "Hugging Face repo ref (`org/model`, `org/model@revision`, `org/model:filename`) or full URL." }, - "filename": { "type": "string", "description": "Filename within the repo (optional — if omitted, lists available files)." }, - "output": { "type": "string", "description": "Local path to save the file (defaults to basename, must be in workspace)." } - }, - "required": ["source"] - }), - }, - ] -} - -/// The tool specifications advertised to the model, cached once. -pub static TOOL_SPECS: LazyLock> = LazyLock::new(build_tool_specs); - -/// Capability family used for task-aware tool advertisement. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ToolCapability { - Coordination, - Repository, - Mutation, - Process, - Background, - Lsp, - Web, - Subagent, -} - -/// Authoritative behavioral metadata for every built-in and injected tool. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct ToolMetadata { - pub name: &'static str, - pub capability: ToolCapability, - pub read_only: bool, - pub filesystem_mutating: bool, - pub minimal: bool, -} - -macro_rules! tool_metadata { - ($name:literal, $capability:ident, $read_only:literal, $mutating:literal, $minimal:literal) => { - ToolMetadata { - name: $name, - capability: ToolCapability::$capability, - read_only: $read_only, - filesystem_mutating: $mutating, - minimal: $minimal, - } - }; -} - -pub const TOOL_CATALOG: &[ToolMetadata] = &[ - tool_metadata!("update_plan", Coordination, true, false, true), - tool_metadata!("record_decision", Coordination, true, false, false), - tool_metadata!("read", Repository, true, false, true), - tool_metadata!("write", Mutation, false, true, true), - tool_metadata!("edit", Mutation, false, true, true), - tool_metadata!("multi_edit", Mutation, false, true, false), - tool_metadata!("bash", Process, false, false, true), - tool_metadata!("bash_output", Background, true, false, false), - tool_metadata!("bash_kill", Background, false, false, false), - tool_metadata!("list", Repository, true, false, true), - tool_metadata!("diff", Repository, true, false, false), - tool_metadata!("grep", Repository, true, false, true), - tool_metadata!("glob", Repository, true, false, true), - tool_metadata!("repo_map", Repository, true, false, true), - tool_metadata!("find_symbol", Repository, true, false, true), - tool_metadata!("apply_patch", Mutation, false, true, false), - tool_metadata!("diagnostics", Lsp, true, false, false), - tool_metadata!("definition", Lsp, true, false, false), - tool_metadata!("references", Lsp, true, false, false), - tool_metadata!("hover", Lsp, true, false, false), - tool_metadata!("web_search", Web, true, false, false), - tool_metadata!("web_fetch", Web, true, false, false), - tool_metadata!("web_download", Web, false, true, false), - tool_metadata!("explore", Subagent, false, false, false), - tool_metadata!("delegate", Subagent, false, false, false), -]; - -pub fn tool_metadata(name: &str) -> Option<&'static ToolMetadata> { - TOOL_CATALOG.iter().find(|metadata| metadata.name == name) -} - -pub fn is_known_tool(name: &str) -> bool { - tool_metadata(name).is_some() -} - -/// Essential tools kept for small models. A model around 3B can't reliably plan -/// over the full ~20-tool set — the large, detailed tool schema degrades its -/// structured-output quality and latency sharply (empirically, tool-calling -/// slowed ~15x from 6 tools to 21 and eventually produced malformed calls). This -/// lean file-navigation + edit + shell set keeps such models usable. -pub static MINIMAL_TOOL_SPECS: LazyLock> = LazyLock::new(|| { - TOOL_SPECS - .iter() - .filter(|spec| tool_metadata(&spec.name).is_some_and(|metadata| metadata.minimal)) - .cloned() - .collect() -}); - -/// The `explore` read-only subagent tool. Deliberately kept OUT of [`TOOL_SPECS`] -/// and out of [`is_read_only`]: it's only advertised when the agent explicitly -/// injects it (for a capable parent via `explore_subagents`), and because it's not -/// read-only it never survives into a `ReadOnly` child's tool set — so a subagent -/// cannot spawn another (depth is capped at 1 structurally). -pub fn explore_tool_spec() -> ToolSpec { - ToolSpec { - name: "explore".into(), - description: "Delegate a focused, READ-ONLY investigation to a subagent that runs in its own fresh context and returns just a concise answer. Use it to keep your own context clean when a question needs reading or searching across many files — e.g. \"where is X configured and how is it used?\", \"summarize how module Y works\", \"find every call site of Z and what each passes\". The subagent can only read/list/grep/glob and inspect code (no edits, no shell, no spawning). Give it ONE self-contained task with enough detail to answer standalone. Prefer it over reading many files yourself when you only need the conclusion; don't use it for trivial single-file lookups or anything that must change files.".into(), - parameters: json!({ - "type": "object", - "properties": { - "task": { - "type": "string", - "description": "A single, self-contained read-only investigation to carry out, with enough context to answer on its own. Be specific about what to find and what to report back." - } - }, - "required": ["task"] - }), - } -} - -/// The `delegate` write-capable subagent tool. Like [`explore_tool_spec`] it's kept -/// OUT of [`TOOL_SPECS`] and [`is_read_only`], and is only injected for a top-level -/// agent (via `write_subagents`) — never for a subagent, so it can't recurse. -pub fn delegate_tool_spec() -> ToolSpec { - ToolSpec { - name: "delegate".into(), - description: "Delegate a self-contained IMPLEMENTATION subtask to a subagent that runs in its own fresh context, can edit files and run commands, and verifies its own work. Its changes are merged back into your working tree ONLY if verification passes — otherwise they're rolled back automatically. Use it to hand off a well-scoped, independent chunk of work (e.g. \"implement the FooBar parser in src/foo.rs so `cargo test foo` passes\", \"add input validation to the signup handler and update its tests\") so it stays out of your context. Give ONE self-contained task with enough detail to complete standalone, and include how success is checked. Prefer doing small edits yourself; use this for a substantial, independently-verifiable subtask. The subagent cannot itself delegate or explore.".into(), - parameters: json!({ - "type": "object", - "properties": { - "task": { - "type": "string", - "description": "A single, self-contained implementation subtask with enough detail to complete standalone, including what 'done' looks like." - }, - "verify": { - "type": "string", - "description": "Optional shell command that must pass for the subagent's changes to be kept (e.g. `cargo test foo`). If omitted, the session's verify command is used." - } - }, - "required": ["task"] - }), - } -} - -/// Whether a tool only observes state, with no side effects — so several can -/// run concurrently within one round, and it's safe to offer in `ReadOnly` -/// tool mode. Tools that mutate the filesystem (`write`, `edit`, `multi_edit`, -/// `apply_patch`) or have ordering-sensitive external effects (`bash`, -/// `bash_kill`) are excluded. `update_plan` and `record_decision` have no -/// side effects beyond in-memory state, so they're read-only here. -/// `bash_output` is a pure poll of an existing buffer. -pub fn is_read_only(name: &str) -> bool { - tool_metadata(name).is_some_and(|metadata| metadata.read_only) -} - -/// Whether a tool mutates the working tree — so the agent should invalidate its -/// snapshot cache and kick off a proactive fast-check after it runs. This is a -/// narrower set than `!is_read_only`: `bash` can mutate files but is handled -/// separately (it always runs alone), and `bash_kill`/`update_plan`/ -/// `record_decision` have no filesystem effect even though they're not -/// read-only for parallelization purposes. -pub fn is_filesystem_mutating(name: &str) -> bool { - tool_metadata(name).is_some_and(|metadata| metadata.filesystem_mutating) -} - -/// Whether a tool is pure bookkeeping (`update_plan`, `record_decision`): -/// it records agent-side coordination state and does no work on the task -/// itself. The agent's steering uses this to spot rounds that only shuffle -/// bookkeeping — a weak-model stall pattern — and to withhold these tools for -/// a round when the model fixates on them. -pub fn is_coordination(name: &str) -> bool { - tool_metadata(name).is_some_and(|metadata| metadata.capability == ToolCapability::Coordination) -} - -/// Best-effort extraction of the primary target path from a tool call's JSON -/// arguments — the `path` field for read/write/edit/list, the `path`/`glob` for -/// grep. Returns `None` for tools without a meaningful single path (e.g. -/// `bash`, or a `grep` with only a pattern). Used by the agent to infer -/// within-batch dependencies: a read of a file a mutating call earlier in the -/// same batch targeted should observe that mutation, so it's serialized after. -/// Tolerant — a failed parse yields `None`, which the caller treats as "no -/// dependency inferred" (safe fallback to emission order). -pub fn target_path(name: &str, arguments: &str) -> Option { - let value: serde_json::Value = serde_json::from_str(arguments).ok()?; - match name { - // read/write/edit/multi_edit carry an explicit `path`. `read` may also - // use `paths` (an array): a one-element array is that single path; a - // multi-element array has no single target, so return None and let - // dependency inference treat it conservatively. - "read" => value - .get("path") - .and_then(|v| v.as_str()) - .map(str::to_string) - .or_else(|| { - value.get("paths").and_then(|v| v.as_array()).and_then(|a| { - if a.len() == 1 { - a[0].as_str().map(str::to_string) - } else { - None - } - }) - }), - "write" | "edit" | "multi_edit" => value.get("path")?.as_str().map(str::to_string), - // list's path is optional (defaults to "."). - "list" => value.get("path")?.as_str().map(str::to_string), - // Optional scope path for orientation tools (directory, not a single file). - "repo_map" | "find_symbol" => value.get("path")?.as_str().map(str::to_string), - // grep: prefer an explicit `path`; fall back to `glob` only as a hint - // (a glob isn't a single file, so return None to avoid over-serializing). - "grep" => value.get("path")?.as_str().map(str::to_string), - // apply_patch: the patch text contains `*** Update File: ` (or - // `*** Add File:`/`*** Delete File:`) directives. Return the path only - // when the patch targets exactly one file. Multi-file patches have no - // single target, so return None and let dependency inference treat the - // mutation as unknown-path, serializing later reads conservatively. - "apply_patch" => { - let patch = value.get("patch")?.as_str()?; - let mut paths: Vec = patch - .lines() - .filter_map(|line| { - line.trim() - .strip_prefix("*** Update File: ") - .or_else(|| line.trim().strip_prefix("*** Add File: ")) - .or_else(|| line.trim().strip_prefix("*** Delete File: ")) - .map(str::trim) - .filter(|path| !path.is_empty()) - .map(str::to_string) - }) - .collect(); - paths.sort(); - paths.dedup(); - if paths.len() == 1 { paths.pop() } else { None } - } - // diff/glob/bash: no single meaningful target path for dep inference. - _ => None, - } -} +pub use crate::catalog::{ + MINIMAL_TOOL_SPECS, TOOL_CATALOG, TOOL_SPECS, ToolCapability, ToolMetadata, + delegate_tool_spec, explore_tool_spec, is_coordination, is_filesystem_mutating, is_known_tool, + is_read_only, target_path, tool_metadata, +}; /// Execute a tool by name. Tool failures are returned as content (not errors) /// so the model sees them and can recover, rather than aborting the turn. @@ -2427,12 +1895,10 @@ fn is_env_assignment(tok: &str) -> bool { #[cfg(test)] mod tests { use super::{ - MAX_WRITE_OVERWRITE_BYTES, MINIMAL_TOOL_SPECS, TOOL_CATALOG, TOOL_SPECS, ToolCapability, - ToolMetadata, fast_check_for, foreground_interactive_command_reason, - foreground_interactive_command_reason_at, is_coordination, is_filesystem_mutating, - is_known_tool, is_read_only, is_retryable_edit_miss, render_untracked_files, - render_untracked_files_with_contents, run_bash_streaming_with_timeout, run_check_in, - target_path, tool_metadata, working_tree_diff_plain_in, + MAX_WRITE_OVERWRITE_BYTES, TOOL_SPECS, fast_check_for, + foreground_interactive_command_reason, foreground_interactive_command_reason_at, + is_retryable_edit_miss, render_untracked_files, render_untracked_files_with_contents, + run_bash_streaming_with_timeout, run_check_in, working_tree_diff_plain_in, }; use crate::edit::{apply_edit, sh_quote}; use std::time::Duration; @@ -3229,66 +2695,8 @@ mod tests { ); } - #[test] - fn read_only_tools_are_classified() { - assert!(is_read_only("read")); - assert!(is_read_only("list")); - assert!(is_read_only("grep")); - assert!(is_read_only("diff")); - assert!(is_read_only("glob")); - // No filesystem side effects — safe to parallelize and offer in - // read-only mode. - assert!(is_read_only("update_plan")); - assert!(is_read_only("record_decision")); - assert!(is_read_only("bash_output")); - // Mutating / effecting tools are not safe to run concurrently. - assert!(!is_read_only("write")); - assert!(!is_read_only("edit")); - assert!(!is_read_only("multi_edit")); - assert!(!is_read_only("apply_patch")); - assert!(!is_read_only("bash")); - assert!(!is_read_only("bash_kill")); - } - #[test] - fn filesystem_mutating_tools_are_classified() { - // Only tools that write to the working tree. - assert!(is_filesystem_mutating("write")); - assert!(is_filesystem_mutating("edit")); - assert!(is_filesystem_mutating("multi_edit")); - assert!(is_filesystem_mutating("apply_patch")); - // Everything else — including non-read-only tools like bash — does not - // directly mutate via the tool layer (bash runs alone; bash_kill stops - // a process; update_plan/record_decision are in-memory only). - assert!(!is_filesystem_mutating("bash")); - assert!(!is_filesystem_mutating("bash_kill")); - assert!(!is_filesystem_mutating("bash_output")); - assert!(!is_filesystem_mutating("update_plan")); - assert!(!is_filesystem_mutating("record_decision")); - assert!(!is_filesystem_mutating("read")); - assert!(!is_filesystem_mutating("diff")); - } - #[test] - fn metadata_catalog_covers_every_schema_once() { - let mut names = std::collections::BTreeSet::new(); - for metadata in TOOL_CATALOG { - assert!(names.insert(metadata.name), "duplicate {}", metadata.name); - } - for spec in TOOL_SPECS.iter() { - assert!(tool_metadata(&spec.name).is_some(), "missing {}", spec.name); - } - for spec in MINIMAL_TOOL_SPECS.iter() { - assert!( - tool_metadata(&spec.name).is_some_and(|metadata| metadata.minimal), - "{} is not marked minimal", - spec.name - ); - } - assert!(is_known_tool("explore")); - assert!(is_known_tool("delegate")); - assert!(!is_known_tool("hallucinated_tool")); - } #[test] fn sh_quote_escapes_single_quotes() { @@ -3296,62 +2704,6 @@ mod tests { assert_eq!(sh_quote("it's"), "'it'\\''s'"); } - #[test] - fn target_path_extracts_path_field() { - assert_eq!( - target_path("read", r#"{"path":"src/a.rs"}"#), - Some("src/a.rs".into()) - ); - assert_eq!( - target_path("write", r#"{"path":"b.rs","content":"x"}"#), - Some("b.rs".into()) - ); - // list's path is optional → None when absent. - assert_eq!(target_path("list", r#"{}"#), None); - assert_eq!(target_path("list", r#"{"path":"sub"}"#), Some("sub".into())); - // bash has no path → None (the safe-fallback case for dep inference). - assert_eq!(target_path("bash", r#"{"command":"echo hi"}"#), None); - // Malformed JSON → None (tolerant). - assert_eq!(target_path("read", "not json"), None); - // `read` with `paths`: a one-element array yields that path. - assert_eq!( - target_path("read", r#"{"paths":["src/a.rs"]}"#), - Some("src/a.rs".into()) - ); - // A multi-element array has no single target → None. - assert_eq!( - target_path("read", r#"{"paths":["src/a.rs","src/b.rs"]}"#), - None - ); - // apply_patch: a single file directive's path is extracted. - let patch = - r#"{"patch":"*** Begin Patch\n*** Update File: src/a.rs\n-old\n+new\n*** End Patch"}"#; - assert_eq!(target_path("apply_patch", patch), Some("src/a.rs".into())); - let add_patch = - r#"{"patch":"*** Begin Patch\n*** Add File: new.txt\nhello\n*** End Patch"}"#; - assert_eq!( - target_path("apply_patch", add_patch), - Some("new.txt".into()) - ); - let delete_patch = - r#"{"patch":"*** Begin Patch\n*** Delete File: old.txt\n*** End Patch"}"#; - assert_eq!( - target_path("apply_patch", delete_patch), - Some("old.txt".into()) - ); - // Multi-file patches have no single target path. Returning None makes - // dependency inference serialize later reads conservatively. - let multi_patch = r#"{"patch":"*** Begin Patch\n*** Update File: src/a.rs\n-old\n+new\n*** Update File: src/b.rs\n-old\n+new\n*** End Patch"}"#; - assert_eq!(target_path("apply_patch", multi_patch), None); - // No file directives → None. - assert_eq!( - target_path( - "apply_patch", - r#"{"patch":"*** Begin Patch\n*** End Patch"}"# - ), - None - ); - } #[test] fn fast_check_for_targets_per_file_languages() { @@ -3391,162 +2743,9 @@ mod tests { assert!(!props.contains_key("paths")); } - #[test] - fn minimal_tool_specs_is_a_lean_subset() { - let full: Vec<&str> = TOOL_SPECS.iter().map(|s| s.name.as_str()).collect(); - let minimal: Vec<&str> = MINIMAL_TOOL_SPECS.iter().map(|s| s.name.as_str()).collect(); - assert!(minimal.len() < full.len()); - // Every minimal tool exists in the full set, in the same order. - for name in &minimal { - assert!(full.contains(name), "{name} missing from full specs"); - } - // The essentials a small coding agent needs are present. - for essential in [ - "read", - "list", - "grep", - "repo_map", - "find_symbol", - "bash", - "write", - "edit", - ] { - assert!( - minimal.contains(&essential), - "{essential} missing from minimal" - ); - } - } - /// Canonical capability → side-effect class matrix for interactive tools. - /// - /// RSI `hi-tool-host::SideEffect` uses the same vocabulary (None / WorkspaceRead / - /// WorkspaceWrite / Process / Network). Keep these mappings aligned when either - /// catalog changes — see `hi-tool-host` tests for the host-side mirror. - fn expected_side_effect_class(meta: &ToolMetadata) -> &'static str { - if meta.filesystem_mutating { - return "workspace_write"; - } - match meta.capability { - ToolCapability::Coordination => "none", - ToolCapability::Repository | ToolCapability::Lsp => "workspace_read", - ToolCapability::Mutation => "workspace_write", - ToolCapability::Process | ToolCapability::Background | ToolCapability::Subagent => { - "process" - } - ToolCapability::Web => { - // web_download mutates via filesystem_mutating; search/fetch are network. - if meta.read_only { - "network" - } else { - "workspace_write" - } - } - } - } - #[test] - fn capability_matrix_covers_every_catalog_entry() { - assert!(!TOOL_CATALOG.is_empty()); - let mut names = std::collections::BTreeSet::new(); - for meta in TOOL_CATALOG { - assert!(names.insert(meta.name), "duplicate tool {}", meta.name); - let side = expected_side_effect_class(meta); - // Invariants tying flags to side-effect class. - match side { - "none" => { - assert!(meta.read_only, "{} none must be read_only", meta.name); - assert!(!meta.filesystem_mutating); - } - "workspace_read" => { - assert!(meta.read_only, "{} workspace_read must be read_only", meta.name); - assert!(!meta.filesystem_mutating); - } - "workspace_write" => { - assert!( - meta.filesystem_mutating || matches!(meta.capability, ToolCapability::Mutation | ToolCapability::Web), - "{} workspace_write should mutate fs or be Mutation/Web", - meta.name - ); - assert!(!meta.read_only, "{} workspace_write must not be read_only", meta.name); - } - "process" => { - assert!( - matches!( - meta.capability, - ToolCapability::Process - | ToolCapability::Background - | ToolCapability::Subagent - ), - "{} process class capability", - meta.name - ); - } - "network" => { - assert_eq!(meta.capability, ToolCapability::Web); - assert!(meta.read_only); - } - other => panic!("unknown side effect class {other}"), - } - // Classifier helpers stay consistent with catalog flags. - assert_eq!(is_read_only(meta.name), meta.read_only); - assert_eq!(is_filesystem_mutating(meta.name), meta.filesystem_mutating); - assert_eq!( - is_coordination(meta.name), - meta.capability == ToolCapability::Coordination - ); - assert!(is_known_tool(meta.name)); - } - } - #[test] - fn capability_matrix_known_tool_side_effects() { - // Explicit pins so a casual catalog edit fails loudly. - let pins = [ - ("update_plan", "none"), - ("record_decision", "none"), - ("read", "workspace_read"), - ("list", "workspace_read"), - ("grep", "workspace_read"), - ("glob", "workspace_read"), - ("repo_map", "workspace_read"), - ("find_symbol", "workspace_read"), - ("diff", "workspace_read"), - ("diagnostics", "workspace_read"), - ("definition", "workspace_read"), - ("references", "workspace_read"), - ("hover", "workspace_read"), - ("write", "workspace_write"), - ("edit", "workspace_write"), - ("multi_edit", "workspace_write"), - ("apply_patch", "workspace_write"), - ("web_download", "workspace_write"), - ("bash", "process"), - ("bash_output", "process"), - ("bash_kill", "process"), - ("explore", "process"), - ("delegate", "process"), - ("web_search", "network"), - ("web_fetch", "network"), - ]; - for (name, want) in pins { - let meta = tool_metadata(name).unwrap_or_else(|| panic!("missing {name}")); - assert_eq!( - expected_side_effect_class(meta), - want, - "{name} side-effect class drifted" - ); - } - // Every catalog entry is pinned (no silent additions). - let pinned: std::collections::BTreeSet<_> = pins.iter().map(|(n, _)| *n).collect(); - for meta in TOOL_CATALOG { - assert!( - pinned.contains(meta.name), - "add an explicit side-effect pin for new tool `{}`", - meta.name - ); - } - } } diff --git a/crates/hi-tui/src/app/run/drive.rs b/crates/hi-tui/src/app/run/drive.rs new file mode 100644 index 0000000..f9a3e31 --- /dev/null +++ b/crates/hi-tui/src/app/run/drive.rs @@ -0,0 +1,283 @@ +//! Drive an agent future while keeping the TUI live (redraw, scroll, cancel, interject). + +use std::io; +use std::time::Instant; + +use anyhow::Result; +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::backend::CrosstermBackend; +use ratatui::prelude::*; +use ratatui::Terminal; +use tokio::sync::mpsc; + +use super::{ + run_chord_pipeline, ChordPipeline, reconcile_queue_with_interjections, +}; +use crate::event::{ConfirmationControl, UiEvent}; +use hi_agent::{Command, command}; +use crate::{App, TurnState, watchdog_stuck_timeout}; + + +/// Drive a model future (a turn or a compaction) to completion while keeping +/// the UI live: redraw + spin every tick, drain the agent's events, let the +/// user scroll/queue/cancel. Successful values are preserved so typed turn +/// outcomes, rather than UI prose, can drive final presentation. +pub(crate) struct DriveCompletion { + pub(crate) cancelled: bool, + pub(crate) value: Option, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn drive( + terminal: &mut Terminal>, + input: &mut mpsc::UnboundedReceiver, + ticker: &mut tokio::time::Interval, + app: &mut App, + mut rx: mpsc::UnboundedReceiver, + mut confirmations: mpsc::UnboundedReceiver, + fut: impl std::future::Future>, + expect_turn_end: bool, + // When set, plain-text lines submitted while the turn runs are injected + // into the *current* turn (mid-turn steering) instead of queued for the + // next one. Slash-commands always queue. + interject: Option, +) -> Result> { + tokio::pin!(fut); + let mut cancelled = false; + let mut value = None; + let mut last_activity = Instant::now(); + let mut watchdog_stuck = false; + let watchdog_timeout = watchdog_stuck_timeout(); + let mut pending_confirmation: Option = None; + let mut confirmations_open = true; + loop { + terminal.draw(|f| app.render(f))?; + tokio::select! { + result = &mut fut => { + while let Ok(event) = rx.try_recv() { + if let Some(tap) = &app.remote_event_tap { + tap(&event); + } + app.apply(event); + } + match result { + Ok(result) => value = Some(result), + Err(err) => { + let (kind, guidance) = hi_agent::classify_error(&err); + if !matches!(app.last_turn_state, TurnState::Failed(_)) { + app.note_turn_failed(&format!("{err:#}"), kind, guidance); + } + if hi_agent::ui::error_counts_as_model_issue(&err) { + app.record_model_issue(); + } + } + } + break; + } + Some(event) = rx.recv() => { + last_activity = Instant::now(); + if let Some(tap) = &app.remote_event_tap { + tap(&event); + } + app.apply(event); + } + request = confirmations.recv(), if pending_confirmation.is_none() && confirmations_open => { + match request { + Some(request) => { + // Session-wide `a` or path-scoped `p` auto-approve. + if app.should_auto_approve(&request.request) { + let _ = request.response.send(hi_agent::ConfirmationResult::Approved); + } else { + app.confirmation = Some(request.request.clone()); + app.confirmation_scroll = 0; + pending_confirmation = Some(request); + } + } + None => confirmations_open = false, + } + } + _ = ticker.tick() => { + app.spinner = app.spinner.wrapping_add(1); + app.drain_loops(); + let idle = last_activity.elapsed(); + app.waiting_for = Some(idle); + // Only notify about a quiet backend while no tool is legitimately + // running. This is only a soft wait notice. + if expect_turn_end + && !watchdog_stuck + && app.current_tool.is_none() + && idle >= watchdog_timeout + { + watchdog_stuck = true; + app.note_backend_waiting(idle, watchdog_timeout); + } + }, + maybe = input.recv() => { + match maybe { + Some(Event::Mouse(mouse)) => app.handle_mouse(mouse), + Some(Event::Paste(text)) if pending_confirmation.is_none() => app.input.insert_str(&text), + Some(Event::Paste(_)) => {} + Some(Event::Key(key)) if key.kind == KeyEventKind::Press => { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if let Some(request) = pending_confirmation.take() { + match key.code { + KeyCode::Char('y') if !ctrl => { + let _ = request.response.send(hi_agent::ConfirmationResult::Approved); + app.confirmation = None; + } + KeyCode::Char('n') if !ctrl => { + let _ = request.response.send(hi_agent::ConfirmationResult::Rejected); + app.confirmation = None; + } + // "Always allow this session": approve this + // request AND auto-approve all subsequent ones + // without showing the modal. Removes the y-y-y-y + // fatigue during a heavy edit session. + KeyCode::Char('a') if !ctrl => { + let _ = request.response.send(hi_agent::ConfirmationResult::Approved); + app.auto_approve_session = true; + app.confirmation = None; + app.push(Line::styled( + "auto-approve on for this session (approvals suppressed until quit)", + Style::default().fg(crate::theme::theme().accent_success), + )); + } + // Path-scoped auto-approve for file edits (`p`). + KeyCode::Char('p') if !ctrl => { + if let hi_agent::ConfirmationRequest::FileEdit { path, .. } = + &request.request + { + let prefix = App::auto_approve_prefix_for(path); + app.add_auto_approve_path(&path); + let _ = request + .response + .send(hi_agent::ConfirmationResult::Approved); + app.confirmation = None; + app.push(Line::styled( + format!( + "auto-approve path '{prefix}/' for this session" + ), + Style::default() + .fg(crate::theme::theme().accent_success), + )); + } else { + // Not a file edit — keep the modal open. + pending_confirmation = Some(request); + } + } + KeyCode::Esc => { + let _ = request.response.send(hi_agent::ConfirmationResult::Rejected); + app.confirmation = None; + } + KeyCode::Char('c') if ctrl => { + let _ = request.response.send(hi_agent::ConfirmationResult::Cancelled); + app.confirmation = None; + cancelled = true; + break; + } + KeyCode::Up => { + app.confirmation_scroll = app.confirmation_scroll.saturating_sub(1); + pending_confirmation = Some(request); + } + KeyCode::Down => { + app.confirmation_scroll = app.confirmation_scroll.saturating_add(1); + pending_confirmation = Some(request); + } + KeyCode::PageUp => { + app.confirmation_scroll = app.confirmation_scroll.saturating_sub(10); + pending_confirmation = Some(request); + } + KeyCode::PageDown => { + app.confirmation_scroll = app.confirmation_scroll.saturating_add(10); + pending_confirmation = Some(request); + } + _ => pending_confirmation = Some(request), + } + continue; + } + match key.code { + KeyCode::Char('c') if ctrl => { cancelled = true; break; } + // Esc clears a half-typed queued command, or — when the + // input is empty — interrupts the current tool call + // (if one is running) or cancels the whole turn. + KeyCode::Esc if app.input.is_empty() => { + if app.current_tool.is_some() { + // A tool is running: signal interrupt to skip + // just this tool call, not the whole turn. + if let Some(flag) = &app.interrupt { + flag.store(true, std::sync::atomic::Ordering::Relaxed); + } + } else { + cancelled = true; + break; + } + } + KeyCode::Esc => app.input.clear(), + // A line submitted while a turn runs: `/copy` reads the + // selection synchronously; everything else joins the + // visible next-turn queue. Plain text is *also* offered + // to the in-flight turn as mid-turn steering when an + // inbox is available (slash-commands only queue). + _ => { + // Shared palette + action/mode dispatch (in-turn path). + match run_chord_pipeline(app, &key) { + Some(ChordPipeline::Continue) => continue, + Some(ChordPipeline::OpenPalette) => { + app.palette = Some(crate::palette::CommandPalette::open()); + continue; + } + Some(ChordPipeline::PaletteAccept(cmd)) => { + app.queue.push_back(cmd); + app.clamp_queue_selection(); + continue; + } + None => {} + } + if let Some(submitted) = app.edit_key(&key) { + match command::parse(&submitted) { + Some(Command::Copy(arg)) => app.copy(&arg), + other => { + // Always queue so the line shows under the + // prompt and runs after this turn if it was + // not consumed as mid-turn steering. + app.queue.push_back(submitted.clone()); + app.clamp_queue_selection(); + let plain = other.is_none(); + if plain { + if let Some(inbox) = interject.as_ref() { + inbox.push(submitted.clone()); + app.mid_turn_offered.push_back(submitted.clone()); + } + } + app.follow(); + } + } + } + } + } + } + Some(Event::FocusGained) => app.set_focus(true), + Some(Event::FocusLost) => app.set_focus(false), + None => { + cancelled = true; + break; + } + _ => {} + } + } + } + } + app.waiting_for = None; + app.confirmation = None; + // Reconcile the visible queue with mid-turn steering: drop entries the + // agent already injected, and keep anything still pending in the inbox + // (turn ended before the next Model phase) for the next turn. + if let Some(inbox) = interject.as_ref() { + reconcile_queue_with_interjections(app, inbox); + } else { + app.mid_turn_offered.clear(); + } + Ok(DriveCompletion { cancelled, value }) +} + + diff --git a/crates/hi-tui/src/app/run.rs b/crates/hi-tui/src/app/run/mod.rs similarity index 92% rename from crates/hi-tui/src/app/run.rs rename to crates/hi-tui/src/app/run/mod.rs index 25bbff8..c556706 100644 --- a/crates/hi-tui/src/app/run.rs +++ b/crates/hi-tui/src/app/run/mod.rs @@ -2,6 +2,9 @@ //! the agent turn behind a channel, and drives the render loop) and `drive` //! (the per-event state machine that routes crossterm events to `App`). +mod drive; +pub(crate) use drive::drive; + use std::io; use std::io::IsTerminal; use std::time::{Duration, Instant}; @@ -22,13 +25,13 @@ use ratatui::style::{Style}; use ratatui::text::{Line, Text}; use tokio::sync::mpsc; -use crate::event::{ChannelUi, ConfirmationControl, Restore, UiEvent}; +use crate::event::{ChannelUi, Restore}; use crate::input::HistorySearch; use crate::model_picker::ModelPicker; use crate::provider_form; use crate::provider_picker; use crate::render::dim; -use crate::{App, TICK, TurnState, apply_metadata, splash_lines, watchdog_stuck_timeout}; +use crate::{App, TICK, TurnState, apply_metadata, splash_lines}; /// Expand `@file` mentions in `prompt`: for each `@path` token (a path /// relative to `root` that exists and is a file), append the file's contents @@ -3061,269 +3064,6 @@ pub async fn run(agent: &mut Agent, options: crate::RunOptions) -> Result<()> { Ok(()) } - -/// Drive a model future (a turn or a compaction) to completion while keeping -/// the UI live: redraw + spin every tick, drain the agent's events, let the -/// user scroll/queue/cancel. Successful values are preserved so typed turn -/// outcomes, rather than UI prose, can drive final presentation. -struct DriveCompletion { - cancelled: bool, - value: Option, -} - -#[allow(clippy::too_many_arguments)] -async fn drive( - terminal: &mut Terminal>, - input: &mut mpsc::UnboundedReceiver, - ticker: &mut tokio::time::Interval, - app: &mut App, - mut rx: mpsc::UnboundedReceiver, - mut confirmations: mpsc::UnboundedReceiver, - fut: impl std::future::Future>, - expect_turn_end: bool, - // When set, plain-text lines submitted while the turn runs are injected - // into the *current* turn (mid-turn steering) instead of queued for the - // next one. Slash-commands always queue. - interject: Option, -) -> Result> { - tokio::pin!(fut); - let mut cancelled = false; - let mut value = None; - let mut last_activity = Instant::now(); - let mut watchdog_stuck = false; - let watchdog_timeout = watchdog_stuck_timeout(); - let mut pending_confirmation: Option = None; - let mut confirmations_open = true; - loop { - terminal.draw(|f| app.render(f))?; - tokio::select! { - result = &mut fut => { - while let Ok(event) = rx.try_recv() { - if let Some(tap) = &app.remote_event_tap { - tap(&event); - } - app.apply(event); - } - match result { - Ok(result) => value = Some(result), - Err(err) => { - let (kind, guidance) = hi_agent::classify_error(&err); - if !matches!(app.last_turn_state, TurnState::Failed(_)) { - app.note_turn_failed(&format!("{err:#}"), kind, guidance); - } - if hi_agent::ui::error_counts_as_model_issue(&err) { - app.record_model_issue(); - } - } - } - break; - } - Some(event) = rx.recv() => { - last_activity = Instant::now(); - if let Some(tap) = &app.remote_event_tap { - tap(&event); - } - app.apply(event); - } - request = confirmations.recv(), if pending_confirmation.is_none() && confirmations_open => { - match request { - Some(request) => { - // Session-wide `a` or path-scoped `p` auto-approve. - if app.should_auto_approve(&request.request) { - let _ = request.response.send(hi_agent::ConfirmationResult::Approved); - } else { - app.confirmation = Some(request.request.clone()); - app.confirmation_scroll = 0; - pending_confirmation = Some(request); - } - } - None => confirmations_open = false, - } - } - _ = ticker.tick() => { - app.spinner = app.spinner.wrapping_add(1); - app.drain_loops(); - let idle = last_activity.elapsed(); - app.waiting_for = Some(idle); - // Only notify about a quiet backend while no tool is legitimately - // running. This is only a soft wait notice. - if expect_turn_end - && !watchdog_stuck - && app.current_tool.is_none() - && idle >= watchdog_timeout - { - watchdog_stuck = true; - app.note_backend_waiting(idle, watchdog_timeout); - } - }, - maybe = input.recv() => { - match maybe { - Some(Event::Mouse(mouse)) => app.handle_mouse(mouse), - Some(Event::Paste(text)) if pending_confirmation.is_none() => app.input.insert_str(&text), - Some(Event::Paste(_)) => {} - Some(Event::Key(key)) if key.kind == KeyEventKind::Press => { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if let Some(request) = pending_confirmation.take() { - match key.code { - KeyCode::Char('y') if !ctrl => { - let _ = request.response.send(hi_agent::ConfirmationResult::Approved); - app.confirmation = None; - } - KeyCode::Char('n') if !ctrl => { - let _ = request.response.send(hi_agent::ConfirmationResult::Rejected); - app.confirmation = None; - } - // "Always allow this session": approve this - // request AND auto-approve all subsequent ones - // without showing the modal. Removes the y-y-y-y - // fatigue during a heavy edit session. - KeyCode::Char('a') if !ctrl => { - let _ = request.response.send(hi_agent::ConfirmationResult::Approved); - app.auto_approve_session = true; - app.confirmation = None; - app.push(Line::styled( - "auto-approve on for this session (approvals suppressed until quit)", - Style::default().fg(crate::theme::theme().accent_success), - )); - } - // Path-scoped auto-approve for file edits (`p`). - KeyCode::Char('p') if !ctrl => { - if let hi_agent::ConfirmationRequest::FileEdit { path, .. } = - &request.request - { - let prefix = App::auto_approve_prefix_for(path); - app.add_auto_approve_path(path); - let _ = request - .response - .send(hi_agent::ConfirmationResult::Approved); - app.confirmation = None; - app.push(Line::styled( - format!( - "auto-approve path '{prefix}/' for this session" - ), - Style::default() - .fg(crate::theme::theme().accent_success), - )); - } else { - // Not a file edit — keep the modal open. - pending_confirmation = Some(request); - } - } - KeyCode::Esc => { - let _ = request.response.send(hi_agent::ConfirmationResult::Rejected); - app.confirmation = None; - } - KeyCode::Char('c') if ctrl => { - let _ = request.response.send(hi_agent::ConfirmationResult::Cancelled); - app.confirmation = None; - cancelled = true; - break; - } - KeyCode::Up => { - app.confirmation_scroll = app.confirmation_scroll.saturating_sub(1); - pending_confirmation = Some(request); - } - KeyCode::Down => { - app.confirmation_scroll = app.confirmation_scroll.saturating_add(1); - pending_confirmation = Some(request); - } - KeyCode::PageUp => { - app.confirmation_scroll = app.confirmation_scroll.saturating_sub(10); - pending_confirmation = Some(request); - } - KeyCode::PageDown => { - app.confirmation_scroll = app.confirmation_scroll.saturating_add(10); - pending_confirmation = Some(request); - } - _ => pending_confirmation = Some(request), - } - continue; - } - match key.code { - KeyCode::Char('c') if ctrl => { cancelled = true; break; } - // Esc clears a half-typed queued command, or — when the - // input is empty — interrupts the current tool call - // (if one is running) or cancels the whole turn. - KeyCode::Esc if app.input.is_empty() => { - if app.current_tool.is_some() { - // A tool is running: signal interrupt to skip - // just this tool call, not the whole turn. - if let Some(flag) = &app.interrupt { - flag.store(true, std::sync::atomic::Ordering::Relaxed); - } - } else { - cancelled = true; - break; - } - } - KeyCode::Esc => app.input.clear(), - // A line submitted while a turn runs: `/copy` reads the - // selection synchronously; everything else joins the - // visible next-turn queue. Plain text is *also* offered - // to the in-flight turn as mid-turn steering when an - // inbox is available (slash-commands only queue). - _ => { - // Shared palette + action/mode dispatch (in-turn path). - match run_chord_pipeline(app, &key) { - Some(ChordPipeline::Continue) => continue, - Some(ChordPipeline::OpenPalette) => { - app.palette = Some(crate::palette::CommandPalette::open()); - continue; - } - Some(ChordPipeline::PaletteAccept(cmd)) => { - app.queue.push_back(cmd); - app.clamp_queue_selection(); - continue; - } - None => {} - } - if let Some(submitted) = app.edit_key(&key) { - match command::parse(&submitted) { - Some(Command::Copy(arg)) => app.copy(&arg), - other => { - // Always queue so the line shows under the - // prompt and runs after this turn if it was - // not consumed as mid-turn steering. - app.queue.push_back(submitted.clone()); - app.clamp_queue_selection(); - let plain = other.is_none(); - if plain { - if let Some(inbox) = interject.as_ref() { - inbox.push(submitted.clone()); - app.mid_turn_offered.push_back(submitted.clone()); - } - } - app.follow(); - } - } - } - } - } - } - Some(Event::FocusGained) => app.set_focus(true), - Some(Event::FocusLost) => app.set_focus(false), - None => { - cancelled = true; - break; - } - _ => {} - } - } - } - } - app.waiting_for = None; - app.confirmation = None; - // Reconcile the visible queue with mid-turn steering: drop entries the - // agent already injected, and keep anything still pending in the inbox - // (turn ended before the next Model phase) for the next turn. - if let Some(inbox) = interject.as_ref() { - reconcile_queue_with_interjections(app, inbox); - } else { - app.mid_turn_offered.clear(); - } - Ok(DriveCompletion { cancelled, value }) -} - /// After `drive` finishes, align `app.queue` with what the agent consumed from /// the interjection inbox. ///