diff --git a/crates/hi-agent/src/agent/audit_goal.rs b/crates/hi-agent/src/agent/audit_goal.rs index 8856828..0bb17be 100644 --- a/crates/hi-agent/src/agent/audit_goal.rs +++ b/crates/hi-agent/src/agent/audit_goal.rs @@ -71,7 +71,7 @@ impl crate::Agent { /// continues. Fail-open on an unavailable auditor. The caller persists the /// goal afterwards. pub(crate) async fn audit_goal_completion(&mut self, ui: &mut dyn Ui) { - let Some(goal) = self.structured_goal.as_ref() else { + let Some(goal) = self.goals.structured.as_ref() else { return; }; if goal.status != GoalStatus::Done { @@ -88,7 +88,7 @@ impl crate::Agent { ui.status("🔎 completion audit passed — plan coverage confirmed"); } AuditVerdict::Missing(items) => { - let Some(goal) = self.structured_goal.as_mut() else { + let Some(goal) = self.goals.structured.as_mut() else { return; }; goal.audit_rounds = goal.audit_rounds.saturating_add(1); diff --git a/crates/hi-agent/src/agent/goal_turn.rs b/crates/hi-agent/src/agent/goal_turn.rs index 28f154e..1302b9e 100644 --- a/crates/hi-agent/src/agent/goal_turn.rs +++ b/crates/hi-agent/src/agent/goal_turn.rs @@ -24,7 +24,7 @@ impl crate::Agent { return None; } let goal = self - .structured_goal + .goals.structured .as_ref() .filter(|goal| goal.should_auto_drive())?; let active = goal.active_sub_goal()?; @@ -136,8 +136,8 @@ impl crate::Agent { SkepticVerdict::Object(items) => { let objections = items.join("\n"); // Objection: revert the turn's goal progress and record it. - self.structured_goal = goal_before; - if let Some(goal) = self.structured_goal.as_mut() { + self.goals.structured = goal_before; + if let Some(goal) = self.goals.structured.as_mut() { goal.skeptic_objections = goal.skeptic_objections.saturating_add(1); goal.last_skeptic_status = Some(SkepticStatus::Objected); goal.record_failure( @@ -159,8 +159,8 @@ impl crate::Agent { // the worse failure for an unattended run. The escalation // reasons land in the step's notes and the status line. let reasons = items.join("\n"); - self.structured_goal = goal_before; - if let Some(goal) = self.structured_goal.as_mut() { + self.goals.structured = goal_before; + if let Some(goal) = self.goals.structured.as_mut() { goal.skeptic_escalations = goal.skeptic_escalations.saturating_add(1); goal.last_skeptic_status = Some(SkepticStatus::Escalated); goal.skip_active(format!( @@ -177,14 +177,14 @@ impl crate::Agent { return false; } SkepticVerdict::Approve => { - if let Some(goal) = self.structured_goal.as_mut() { + if let Some(goal) = self.goals.structured.as_mut() { goal.last_skeptic_status = Some(SkepticStatus::Approved); } self.last_turn_telemetry.skeptic_last_status = Some(SkepticStatus::Approved); ui.status("🔍 skeptic approved — advancing"); } SkepticVerdict::Unavailable(reason) => { - if let Some(goal) = self.structured_goal.as_mut() { + if let Some(goal) = self.goals.structured.as_mut() { goal.skeptic_unavailable = goal.skeptic_unavailable.saturating_add(1); goal.last_skeptic_status = Some(SkepticStatus::Unavailable); } @@ -230,7 +230,7 @@ impl crate::Agent { self.last_verify = None; clean_success = false; verification_invalidated = true; - self.structured_goal = goal_before.clone(); + self.goals.structured = goal_before.clone(); self.refresh_system_message(); ui.status( "workspace changed while completion review was running; goal progress was not advanced", @@ -241,7 +241,7 @@ impl crate::Agent { self.last_verify = None; clean_success = false; verification_invalidated = true; - self.structured_goal = goal_before.clone(); + self.goals.structured = goal_before.clone(); self.refresh_system_message(); ui.status(&format!( "could not confirm the reviewed workspace revision; goal progress was not advanced: {error:#}" @@ -256,19 +256,19 @@ impl crate::Agent { if clean_success && let Some(mut proposal) = proposed_goal.take() { // The proposal was cloned before the asynchronous skeptic call. // Preserve review metadata accumulated on the live baseline goal. - if let Some(reviewed) = self.structured_goal.as_ref() { + if let Some(reviewed) = self.goals.structured.as_ref() { proposal.skeptic_objections = reviewed.skeptic_objections; proposal.skeptic_unavailable = reviewed.skeptic_unavailable; proposal.last_skeptic_status = reviewed.last_skeptic_status; } - self.structured_goal = Some(proposal); + self.goals.structured = Some(proposal); } if !clean_success { // Defensive restoration for future mutations of the live goal // inside a turn. Today update_plan remains entirely provisional, // but the failure path stays explicitly anchored to the pre-turn // goal before any neutral/failure return. - self.structured_goal = goal_before.clone(); + self.goals.structured = goal_before.clone(); } // A clean read-only turn (investigation, Q&A — no edits, no verify, // no stall) is neutral: neither advance nor record failure. The sub-goal @@ -284,7 +284,7 @@ impl crate::Agent { if clean_success { // Approve (or gate off): advance as today. If `update_plan` already // advanced the goal this turn, don't advance again (skips a sub-goal). - if !plan_updated_goal && let Some(goal) = self.structured_goal.as_mut() { + if !plan_updated_goal && let Some(goal) = self.goals.structured.as_mut() { let i = goal.active_index(); goal.advance(); if let Some(i) = i { @@ -301,23 +301,23 @@ impl crate::Agent { // only hold the goal open (append missing work), never advance it, // so no post-skeptic reconcile pass is needed here. Fail-open. if matches!( - self.structured_goal.as_ref().map(|g| g.status), + self.goals.structured.as_ref().map(|g| g.status), Some(GoalStatus::Done) ) { self.audit_goal_completion(ui).await; } if matches!( - self.structured_goal.as_ref().map(|g| g.status), + self.goals.structured.as_ref().map(|g| g.status), Some(GoalStatus::Done) ) { ui.status("✓ long-horizon goal complete"); } else if plan_updated_goal && let (Some(before), Some(after)) = ( start_active_index, - self.structured_goal.as_ref().and_then(Goal::active_index), + self.goals.structured.as_ref().and_then(Goal::active_index), ) && after > before - && let Some(goal) = self.structured_goal.as_ref() + && let Some(goal) = self.goals.structured.as_ref() { ui.status(&format!( "✓ sub-goal {}/{} done — advancing", @@ -343,7 +343,7 @@ impl crate::Agent { // rather than grind it out over dozens of turns. Snapshot the decision // inputs first — the planner call below borrows `self`, so it can't // overlap the goal borrow. - let split_desc = self.structured_goal.as_ref().and_then(|g| { + let split_desc = self.goals.structured.as_ref().and_then(|g| { let active = g.active_index()?; let sg = &g.sub_goals[active]; (made_progress @@ -356,7 +356,7 @@ impl crate::Agent { && let Ok(sub_steps) = self.decompose_milestone(&desc).await { let spliced = self - .structured_goal + .goals.structured .as_mut() .map(|g| g.decompose_active(&sub_steps)) .unwrap_or(0); @@ -376,7 +376,7 @@ impl crate::Agent { // turns as long as the milestone keeps making progress — only a run // of barren caps (the model can't land edits) or the generous safety // ceiling ends the continuation and hands it to the retry/skip machinery. - if let Some(goal) = self.structured_goal.as_mut() + if let Some(goal) = self.goals.structured.as_mut() && let Some(active) = goal.active_index() { let sub_goal = &mut goal.sub_goals[active]; @@ -427,7 +427,7 @@ impl crate::Agent { } else { "verification failed and the turn ended without fixing it" }; - let can_retry = match self.structured_goal.as_mut() { + let can_retry = match self.goals.structured.as_mut() { Some(goal) => goal.record_failure(reason, max_retries), None => return verification_invalidated, }; @@ -442,7 +442,7 @@ impl crate::Agent { // `Failed` and visible). Only a dead end with nothing left to // drive is terminal. let skipped = self - .structured_goal + .goals.structured .as_mut() .is_some_and(Goal::continue_past_failure); if skipped { diff --git a/crates/hi-agent/src/agent/lifecycle.rs b/crates/hi-agent/src/agent/lifecycle.rs index 521f42e..4bbccfb 100644 --- a/crates/hi-agent/src/agent/lifecycle.rs +++ b/crates/hi-agent/src/agent/lifecycle.rs @@ -66,7 +66,7 @@ impl crate::Agent { .drain(0..agent.checkpoints.len() - crate::MAX_CHECKPOINTS); } agent.decisions = decisions; - agent.structured_goal = agent + agent.goals.structured = agent .config.subagents.long_horizon .then_some(structured_goal) .flatten(); @@ -149,14 +149,11 @@ impl crate::Agent { last_turn_outcome: None, turn_phase: TurnPhase::Setup, last_effective_route, - goal: None, - structured_goal: None, + goals: crate::domain::GoalState::default(), decisions: DecisionLog::default(), snapshot_cache: SnapshotCache::default(), - last_plan: Vec::new(), interjections: crate::InterjectionInbox::default(), - last_rsi_fully_observed: None, - managed_rsi_context: None, + rsi_observe: crate::domain::RsiObserveState::default(), }) } @@ -164,7 +161,7 @@ impl crate::Agent { /// one-shot turn. This is deliberately separate from `AgentConfig` so /// ordinary agents and read-only subagents cannot inherit it accidentally. pub fn set_managed_rsi_context(&mut self, context: Option) { - self.managed_rsi_context = context; + self.rsi_observe.managed_context = context; } /// A cloneable handle for a frontend to push user messages typed while a @@ -330,14 +327,14 @@ impl crate::Agent { .drain(0..self.checkpoints.len() - crate::MAX_CHECKPOINTS); } self.decisions = decisions; - self.structured_goal = self + self.goals.structured = self .config.subagents.long_horizon .then_some(structured_goal) .flatten(); // Clear per-turn / transient state from the previous session, matching // what `with_messages` initializes to None/empty for a fresh agent. - self.goal = None; - self.last_plan = if plan + self.goals.free_text = None; + self.goals.last_plan = if plan .iter() .any(|step| step.status != hi_tools::PlanStatus::Done) { @@ -357,7 +354,7 @@ impl crate::Agent { /// Install an unfinished plan reconstructed by session storage. pub fn restore_plan(&mut self, plan: Vec) { - self.last_plan = if plan + self.goals.last_plan = if plan .iter() .any(|step| step.status != hi_tools::PlanStatus::Done) { @@ -368,7 +365,7 @@ impl crate::Agent { } pub fn current_plan(&self) -> &[hi_tools::PlanStep] { - &self.last_plan + &self.goals.last_plan } /// Attach the runner that executes write-capable `delegate` subagents. Without @@ -458,10 +455,10 @@ impl crate::Agent { /// recorded during it. pub fn state_snapshot(&self) -> crate::AgentStateSnapshot { crate::AgentStateSnapshot { - goal: self.goal.clone(), - structured_goal: self.structured_goal.clone(), + goal: self.goals.free_text.clone(), + structured_goal: self.goals.structured.clone(), decisions: self.decisions.clone(), - last_plan: self.last_plan.clone(), + last_plan: self.goals.last_plan.clone(), } } @@ -469,10 +466,10 @@ 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.goal = snapshot.goal.clone(); - self.structured_goal = snapshot.structured_goal.clone(); + self.goals.free_text = snapshot.goal.clone(); + self.goals.structured = snapshot.structured_goal.clone(); self.decisions = snapshot.decisions.clone(); - self.last_plan = snapshot.last_plan.clone(); + self.goals.last_plan = snapshot.last_plan.clone(); self.refresh_system_message(); } @@ -510,10 +507,10 @@ impl crate::Agent { } self.messages.replace_all(next); self.persisted = self.messages.len(); - self.goal = snapshot.goal.clone(); - self.structured_goal = structured_goal; + self.goals.free_text = snapshot.goal.clone(); + self.goals.structured = structured_goal; self.decisions = snapshot.decisions.clone(); - self.last_plan = snapshot.last_plan.clone(); + self.goals.last_plan = snapshot.last_plan.clone(); Ok(()) } @@ -964,8 +961,8 @@ impl crate::Agent { pub(crate) fn system_message(&self) -> Message { self.system_message_for( - self.goal.as_deref(), - self.structured_goal.as_ref(), + self.goals.free_text.as_deref(), + self.goals.structured.as_ref(), &self.decisions, ) } @@ -988,7 +985,7 @@ impl crate::Agent { /// Current transient session goal, if any. pub fn goal(&self) -> Option<&str> { - self.goal.as_deref() + self.goals.free_text.as_deref() } /// The durable intra-session decision log (recorded via `record_decision`), @@ -999,7 +996,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.goal = goal.and_then(|g| { + self.goals.free_text = goal.and_then(|g| { let g = g.trim().to_string(); (!g.is_empty()).then_some(g) }); @@ -1029,14 +1026,14 @@ impl crate::Agent { session.clear_goal()?; } } - self.structured_goal = goal; + self.goals.structured = goal; self.refresh_system_message(); Ok(true) } /// The structured long-horizon goal, if any (for persistence/observability). pub fn structured_goal(&self) -> Option<&Goal> { - self.structured_goal.as_ref() + self.goals.structured.as_ref() } /// Pause or resume the structured goal without losing progress: a paused goal @@ -1044,7 +1041,7 @@ impl crate::Agent { /// sub-goal progress is retained and persisted so `/goal resume` picks up /// exactly where it left off. Returns whether there was a goal to update. pub fn set_goal_paused(&mut self, paused: bool) -> bool { - let snapshot = match self.structured_goal.as_mut() { + let snapshot = match self.goals.structured.as_mut() { Some(goal) => { goal.paused = paused; goal.clone() @@ -1062,7 +1059,7 @@ impl crate::Agent { /// the goal (so a resumed goal remembers it) and refreshes the system message. /// Returns `false` if there's no active goal. pub fn set_goal_team(&mut self, on: bool) -> bool { - let snapshot = match self.structured_goal.as_mut() { + let snapshot = match self.goals.structured.as_mut() { Some(goal) => { goal.team = on; goal.clone() @@ -1081,7 +1078,7 @@ impl crate::Agent { /// agent discovers work. Persisted with the goal. Returns whether there was a /// goal to update. pub fn set_goal_step_limit(&mut self, limit: Option) -> bool { - let snapshot = match self.structured_goal.as_mut() { + let snapshot = match self.goals.structured.as_mut() { Some(goal) => { goal.step_limit = limit; goal.clone() @@ -1098,7 +1095,7 @@ impl crate::Agent { /// progress ("objective — 2/7 sub-goals done", with a paused marker) when one /// is set, else the transient goal string, else "off". pub fn goal_summary(&self) -> String { - if let Some(g) = &self.structured_goal { + if let Some(g) = &self.goals.structured { let done = g .sub_goals .iter() @@ -1123,7 +1120,7 @@ impl crate::Agent { g.sub_goals.len() ); } - self.goal.clone().unwrap_or_else(|| "off".to_string()) + self.goals.free_text.clone().unwrap_or_else(|| "off".to_string()) } /// Whether long-horizon agency is on (the `long_horizon` config flag), so @@ -1448,7 +1445,7 @@ impl crate::Agent { } else { "off" }; - (requested, mode, self.last_rsi_fully_observed) + (requested, mode, self.rsi_observe.last_fully_observed) } pub fn rsi_maximum_cost_microusd(&self) -> Option { @@ -1534,7 +1531,7 @@ impl crate::Agent { } pub fn set_last_rsi_fully_observed(&mut self, observed: Option) { - self.last_rsi_fully_observed = observed; + self.rsi_observe.last_fully_observed = observed; } pub(crate) fn persist(&mut self) -> Result<()> { @@ -1557,7 +1554,7 @@ impl crate::Agent { /// doesn't fail the turn (the goal still lives in-memory for this session). pub(crate) fn persist_goal(&mut self, ui: &mut dyn Ui) { if let Some(session) = self.session.as_mut() - && let Some(goal) = &self.structured_goal + && let Some(goal) = &self.goals.structured && let Err(err) = session.record_goal(goal) { ui.status(&format!("(couldn't persist goal: {err})")); diff --git a/crates/hi-agent/src/agent/mutation_recovery_turn.rs b/crates/hi-agent/src/agent/mutation_recovery_turn.rs index b33c871..f1da2ab 100644 --- a/crates/hi-agent/src/agent/mutation_recovery_turn.rs +++ b/crates/hi-agent/src/agent/mutation_recovery_turn.rs @@ -32,7 +32,7 @@ impl Agent { if !expected_mutation { return MutationRecoveryControl::None; } - let has_pending_plan = plan_has_pending_steps(&self.last_plan); + let has_pending_plan = plan_has_pending_steps(&self.goals.last_plan); 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 c511e33..22e7474 100644 --- a/crates/hi-agent/src/agent/turn/loop_.rs +++ b/crates/hi-agent/src/agent/turn/loop_.rs @@ -39,7 +39,7 @@ use crate::steering::{ should_nudge_inspection_sprawl, should_nudge_read_after_repeated_search, }; use crate::transcript::NudgeKind; -use crate::verify::{Snapshot, VerifyOutcome, WorkspaceRepairVerifier, stage_guidance}; +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, @@ -48,7 +48,7 @@ use crate::{ use super::helpers::{ build_turn_telemetry, effective_max_steps_for_turn, effective_model_route, - fallback_review_line_count, task_needs_repository_context, + task_needs_repository_context, }; use super::phase::TurnPhase; use super::progress::{ @@ -211,7 +211,7 @@ impl crate::Agent { context_task.clone() }; let input = turn_input.as_str(); - let model_turn_input = match self.managed_rsi_context.as_deref() { + let model_turn_input = match self.rsi_observe.managed_context.as_deref() { Some(context) if !context.is_empty() => format!( "{turn_input}\n\nManaged RSI prior conversation context (reference only; it does not change the current task's mutation requirements):\n{context}" ), @@ -239,9 +239,9 @@ 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.last_plan); - if !preserve_plan && !self.last_plan.is_empty() { - self.last_plan.clear(); + && plan_has_pending_steps(&self.goals.last_plan); + if !preserve_plan && !self.goals.last_plan.is_empty() { + self.goals.last_plan.clear(); if let Some(session) = self.session.as_mut() { session.clear_plan()?; } @@ -325,9 +325,9 @@ 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.last_plan); - if !preserve_plan && !self.last_plan.is_empty() { - self.last_plan.clear(); + && plan_has_pending_steps(&self.goals.last_plan); + if !preserve_plan && !self.goals.last_plan.is_empty() { + self.goals.last_plan.clear(); if let Some(session) = self.session.as_mut() { session.clear_plan()?; } @@ -390,7 +390,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.structured_goal.clone(); + let goal_before = self.goals.structured.clone(); // 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 @@ -1140,7 +1140,7 @@ impl crate::Agent { && (implementation_intent.is_some() || made_tool_call || implementation_tracker.mutation_seen - || plan_has_pending_steps(&self.last_plan) + || 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 @@ -1498,7 +1498,7 @@ impl crate::Agent { )); let paths = inspected_paths_for_prompt(&evidence); let plan_step = self - .last_plan + .goals.last_plan .iter() .find(|s| { s.status == PlanStatus::Pending @@ -1730,7 +1730,7 @@ If the task is already complete, stop and give your final recap." if request_no_progress_final_answer { let unusable = forced_final_answer_is_unusable( &assistant_text, - plan_has_pending_steps(&self.last_plan), + 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() { @@ -1905,310 +1905,36 @@ If the task is already complete, stop and give your final recap." // check, and reports for those error turns need the stages that // actually ran. self.last_turn_telemetry.verification_executions = verifier.executions().to_vec(); - match outcome { - VerifyOutcome::NotRun => { - // Phase C obligation: one re-entry when a coding turn still - // owes green evidence (failed budget or never sealed). - let (changed_now, mutation_now) = { - let ledger = self.runtime.ledger(); - ( - ledger - .changes_since(turn_ledger_revision) - .into_iter() - .map(|c| c.path) - .collect::>(), - ledger.had_mutation_since(turn_ledger_revision), - ) - }; - if !obligation_nudge_fired - && let Some(reason) = super::obligation::coding_verify_obligation( - self.last_task_contract.as_ref(), - &self.config.gates.verification, - expected_mutation, - &changed_now, - mutation_now, - self.last_verify, - verifier.executions().len(), - ) - { - match reason { - // Never sealed green after a code mutation — one more - // model round to run checks / fix. Failed-verify budget - // exhaustion already spent its repair rounds above. - super::obligation::ObligationReason::UnverifiedMutation => { - obligation_nudge_fired = true; - ui.status(reason.ui_status()); - ui.nudge(reason.ui_status()); - self.messages - .push_nudge(NudgeKind::Continue, reason.nudge_body()); - force_tools_next = true; - continue 'turn; - } - super::obligation::ObligationReason::FailedVerify => { - stalled_unfinished = true; - ui.status(reason.ui_status()); - } - } - } - if self.last_verify == Some(false) { - stalled_unfinished = true; - ui.status( - "verification still failed after the retry budget; the task may be incomplete. /retry, or send 'continue'.", - ); - } - break 'turn; - } - VerifyOutcome::SkippedNoChanges { first } => { - if first { - ui.status("verification skipped — no files changed this turn"); - } - // Mutation-shaped coding turns that somehow report no file - // delta still owe evidence when mutation_seen (e.g. restored - // bytes) or the contract expected edits — one obligation nudge. - let mutation_now = self - .runtime - .ledger() - .had_mutation_since(turn_ledger_revision); - if !obligation_nudge_fired - && let Some(reason) = super::obligation::coding_verify_obligation( - self.last_task_contract.as_ref(), - &self.config.gates.verification, - expected_mutation, - &[], - mutation_now, - self.last_verify, - verifier.executions().len(), - ) - { - if matches!( - reason, - super::obligation::ObligationReason::UnverifiedMutation - ) { - obligation_nudge_fired = true; - ui.status(reason.ui_status()); - ui.nudge(reason.ui_status()); - self.messages - .push_nudge(NudgeKind::Continue, reason.nudge_body()); - force_tools_next = true; - continue 'turn; - } - } - break 'turn; - } - VerifyOutcome::SkippedProseOnly { first } => { - if first { - ui.status("verification skipped — prose-only files changed this turn"); - } - break 'turn; - } - VerifyOutcome::Passed => { - ui.status("✓ verification passed"); - self.last_verify = Some(true); - self.reconcile_workspace_changes()?; - let (verified_revision, verified_digest, current_changes) = { - let mut ledger = self.runtime.ledger(); - ( - ledger.revision(), - ledger.workspace_revision(), - ledger.changes_since(turn_ledger_revision), - ) - }; - verified_at = Some((verified_revision, verified_digest.clone())); - let current_files = current_changes - .iter() - .map(|change| change.path.clone()) - .collect::>(); - let mut diff = self.turn_diff().await; - let diff_lines = if diff.trim().is_empty() { - fallback_review_line_count(self.runtime.root(), ¤t_changes) - } else { - diff.lines().count() - }; - let (review_required, large_diff_review) = - self.last_task_contract.as_ref().map_or((false, false), |contract| { - let required = contract.requires_review( - self.config.gates.review, - ¤t_files, - diff_lines, - self.config.subagents.long_horizon - || self.config.subagents.write_subagents.is_enabled(), - ); - let large = contract.is_large_mutation(¤t_files, diff_lines); - (required, large) - }); - if review_required { - 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, - ); - if diff.chars().count() > 50_000 { - diff = diff.chars().take(50_000).collect(); - diff.push_str("\n… (bounded review diff truncated)"); - } - let contract = self - .last_task_contract - .as_ref() - .and_then(|contract| serde_json::to_string_pretty(contract).ok()) - .unwrap_or_else(|| "(task contract unavailable)".into()); - let instructions = self.task_context.as_deref().unwrap_or("(none)"); - let stages = verifier.stages_summary().unwrap_or_else(|| "(none)".into()); - let context = format!( - "Task contract:\n{contract}\n\nScoped instructions and relevant repository context:\n{instructions}\n\nChanged files ({file_count}):\n{files}\n\nDiff size: {diff_lines} lines\nDeterministic verification: PASSED\nStages: {stages}\nVerified workspace revision: {verified_digest}\n\nComplete bounded turn diff:\n{diff}", - file_count = current_files.len(), - files = current_files.join("\n"), - ); - // Phase L: large multi-file diffs get the hole-focused - // skeptic prompt; other risk reviews keep the general one. - let review_label = if large_diff_review { - "large-diff skeptic" - } else { - "independent completion review" - }; - ui.status(&format!("running {review_label}")); - let verdict = if diff.trim().is_empty() && !current_files.is_empty() { - super::super::skeptic::SkepticVerdict::Unavailable( - "a complete turn diff was unavailable for the current changes" - .into(), - ) - } else if large_diff_review { - self.large_diff_review(&context).await - } else { - self.independent_review(&context).await - }; - match verdict { - super::super::skeptic::SkepticVerdict::Approve => { - independent_review_status = ReviewStatus::Passed; - if large_diff_review { - ui.status("✓ large-diff skeptic approved"); - } - } - super::super::skeptic::SkepticVerdict::Unavailable(reason) => { - independent_review_status = ReviewStatus::Unavailable; - ui.status(&format!( - "{review_label} unavailable after deterministic pass: {reason}" - )); - } - super::super::skeptic::SkepticVerdict::Object(objections) - if independent_review_repairs == 0 => - { - independent_review_repairs = 1; - independent_review_status = ReviewStatus::Objected; - self.last_verify = None; - verified_at = None; - verifier.allow_review_revalidation(); - let headline = if large_diff_review { - "Large-diff skeptic found concrete multi-file defects" - } else { - "Independent review found concrete completion defects" - }; - self.messages.push_nudge( - NudgeKind::Review, - format!( - "{headline}. Repair them now, then re-run deterministic validation.\n\n{}", - objections - .iter() - .map(|objection| format!("- {objection}")) - .collect::>() - .join("\n") - ), - ); - ui.nudge(&format!( - "{review_label} objected; allowing one repair cycle" - )); - continue 'turn; - } - super::super::skeptic::SkepticVerdict::Object(objections) => { - independent_review_status = ReviewStatus::Objected; - stalled_unfinished = true; - ui.status(&format!( - "{review_label} objected again after repair: {}", - objections.join("; ") - )); - } - // The independent-review prompt defines no ESCALATE - // verdict; treat a stray one as a final objection - // (no extra repair cycle — escalation means - // retrying can't fix it). - super::super::skeptic::SkepticVerdict::Escalate(objections) => { - independent_review_status = ReviewStatus::Objected; - stalled_unfinished = true; - ui.status(&format!( - "{review_label} escalated — needs your judgment: {}", - objections.join("; ") - )); - } - } - } - break 'turn; - } - VerifyOutcome::Failed { - stage, - output, - round, - } => { - ui.status(&format!("✗ {} failed; iterating", stage.name)); - self.last_verify = Some(false); - verified_at = None; - let guidance = stage_guidance(&stage); - // Structured failure: attributions + condensed output + optional - // diagnostic snippet. Enrich-only relative to the raw blob. - let structured = hi_tools::format_structured_failure( - &format!( - "Verification stage `{}` failed (`{}`).", - stage.name, stage.command - ), - &output, - Some(guidance), - ); - last_verify_attributions = structured.attributions.clone(); - // Replace the previous verify nudge instead of accumulating. - // Only the latest verification output belongs in context. - // `replace_last_nudge` pops trailing tool/assistant messages - // from the prior verify cycle and the prior nudge itself - // (located by typed kind, not string-matching), then pushes - // the new one. On the first round there's no prior nudge, so - // nothing is popped — the model's just-finished turn stays. - self.messages - .replace_last_nudge(NudgeKind::Verify { round }, structured.body); - // Re-enter Model → Tools with the verify nudge in context. - // The verifier's round counter enforces max_verify_repairs. - continue 'turn; - } - VerifyOutcome::InfrastructureError { - stage, - output, - round, - } => { - verification_infrastructure_error = true; - self.last_verify = None; - verified_at = None; - ui.status(&format!( - "verification infrastructure failed at {} (round {round}): {output}", - stage.name, - )); - break 'turn; - } - VerifyOutcome::Unstable { - stage, - changed_files, - round, - } => { - verification_unstable = true; - stalled_unfinished = true; - self.last_verify = Some(false); - verified_at = None; - ui.status(&format!( - "verification is unstable in round {round}: stage {} modified {}", - stage.name, - changed_files.join(", ") - )); - break 'turn; - } + match self + .handle_workspace_repair_outcome( + outcome, + &mut verifier, + turn_ledger_revision, + expected_mutation, + &context_task, + repository_context_enabled, + &mut super::verify_outcome::VerifyOutcomeState { + obligation_nudge_fired: &mut obligation_nudge_fired, + force_tools_next: &mut force_tools_next, + verified_at: &mut verified_at, + independent_review_status: &mut independent_review_status, + independent_review_repairs: &mut independent_review_repairs, + stalled_unfinished: &mut stalled_unfinished, + verification_infrastructure_error: &mut verification_infrastructure_error, + verification_unstable: &mut verification_unstable, + last_verify_attributions: &mut last_verify_attributions, + ranked_context_paths: &mut ranked_context_paths, + context_generation_seen: &mut context_generation_seen, + indexed_ledger_revision: &mut indexed_ledger_revision, + }, + ui, + ) + .await? + { + super::verify_outcome::VerifyOutcomeControl::BreakTurn => break 'turn, + super::verify_outcome::VerifyOutcomeControl::ReenterModel => continue 'turn, } + } // TurnPhase::Settle — seal checkpoint, then keep/wipe green verify. @@ -2446,13 +2172,13 @@ 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.structured_goal = Some(previous); + self.goals.structured = Some(previous); self.refresh_system_message(); // The earlier persist may contain tentatively advanced goal // state. Rewrite the goal record itself (message persistence // does not include side-channel goal state) before returning. if let Some(session) = self.session.as_mut() - && let Some(goal) = self.structured_goal.as_ref() + && let Some(goal) = self.goals.structured.as_ref() { session.record_goal(goal)?; } diff --git a/crates/hi-agent/src/agent/turn/mod.rs b/crates/hi-agent/src/agent/turn/mod.rs index 442393f..8e227ca 100644 --- a/crates/hi-agent/src/agent/turn/mod.rs +++ b/crates/hi-agent/src/agent/turn/mod.rs @@ -13,6 +13,7 @@ //! - [`setup`] — checkpoints, snapshots, task-context refresh //! - [`finalize`] — recap call, usage/steer lines, text-tool cleanup //! - [`verify_run`] — background teardown + [`crate::verify::WorkspaceRepairVerifier`] +//! - [`verify_outcome`] — react to one `VerifyOutcome` (re-enter Model or break to Settle) //! - [`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) @@ -30,6 +31,7 @@ mod setup; mod settlement; mod steer; mod tools; +mod verify_outcome; mod verify_run; pub use phase::TurnPhase; diff --git a/crates/hi-agent/src/agent/turn/retry.rs b/crates/hi-agent/src/agent/turn/retry.rs index 7e7de3a..561e136 100644 --- a/crates/hi-agent/src/agent/turn/retry.rs +++ b/crates/hi-agent/src/agent/turn/retry.rs @@ -179,3 +179,180 @@ pub(super) fn estimate_tool_schema_tokens(tools: &[ToolSpec]) -> u64 { }) .sum() } + +#[cfg(test)] +mod review_repair_budget_tests { + use super::*; + use crate::config::ReviewRepairBudgets; + use crate::steering::{EvidenceTracker, ReviewRepairMode}; + + fn zero_budgets() -> ReviewRepairBudgets { + ReviewRepairBudgets { + no_evidence: 0, + listing_only: 0, + generic_template: 0, + inspected_disclaimer: 0, + inspected_disclaimer_chat_attempt: 0, + concrete_answer: 0, + read_after_search: 0, + security_broad_search: 0, + security_scope: 0, + gap_search_overclaim: 0, + } + } + + #[test] + fn every_mode_has_default_budget_and_stable_keys() { + let budgets = ReviewRepairBudgets::default(); + let mut keys = std::collections::BTreeSet::new(); + for mode in ReviewRepairMode::ALL { + assert!( + mode.limit_with(&budgets) > 0, + "{} default budget must be positive", + mode.key() + ); + assert_eq!(budgets.limit_for_key(mode.key()), mode.limit_with(&budgets)); + assert!(keys.insert(mode.key()), "duplicate key {}", mode.key()); + assert!( + mode.exhaustion_key().ends_with("_exhausted") + || mode.exhaustion_key().contains("exhausted"), + "exhaustion key for {}", + mode.key() + ); + assert!(!mode.required_next().is_empty()); + assert!(!mode.required_next_instruction().is_empty()); + assert!(!mode.compact_label().is_empty()); + assert_eq!(ReviewRepairMode::from_key(mode.key()), Some(*mode)); + } + assert_eq!(keys.len(), ReviewRepairMode::ALL.len()); + } + + #[test] + fn spend_exhausts_exactly_at_budget_and_is_independent_per_mode() { + let budgets = ReviewRepairBudgets { + no_evidence: 2, + listing_only: 1, + ..ReviewRepairBudgets::default() + }; + let mut state = ReviewRepairState::default(); + let mut evidence = EvidenceTracker::default(); + + assert!(state.spend(ReviewRepairMode::NoEvidence, &mut evidence, &budgets)); + assert!(state.spend(ReviewRepairMode::NoEvidence, &mut evidence, &budgets)); + assert!(!state.spend(ReviewRepairMode::NoEvidence, &mut evidence, &budgets)); + assert_eq!(state.count(ReviewRepairMode::NoEvidence), 2); + assert_eq!(evidence.quality_repair_nudges, 2); + + // Sibling mode still has its own budget. + assert!(state.spend(ReviewRepairMode::ListingOnly, &mut evidence, &budgets)); + assert!(!state.spend(ReviewRepairMode::ListingOnly, &mut evidence, &budgets)); + assert_eq!(evidence.quality_repair_nudges, 3); + } + + #[test] + fn table_driven_mode_pairs_do_not_share_counters() { + let budgets = ReviewRepairBudgets::default(); + // Exhaustion of one mode must not zero another mode's remaining budget. + let pairs = [ + (ReviewRepairMode::NoEvidence, ReviewRepairMode::ListingOnly), + ( + ReviewRepairMode::GenericTemplate, + ReviewRepairMode::ConcreteAnswer, + ), + ( + ReviewRepairMode::SecurityBroadSearch, + ReviewRepairMode::SecurityScope, + ), + ( + ReviewRepairMode::ReadAfterSearch, + ReviewRepairMode::GapSearchOverclaim, + ), + ( + ReviewRepairMode::InspectedDisclaimer, + ReviewRepairMode::InspectedDisclaimerChatAttempt, + ), + ]; + for (a, b) in pairs { + let mut state = ReviewRepairState::default(); + let mut evidence = EvidenceTracker::default(); + let limit_a = a.limit_with(&budgets); + for _ in 0..limit_a { + assert!(state.spend(a, &mut evidence, &budgets), "{}", a.key()); + } + assert!(!state.spend(a, &mut evidence, &budgets), "{}", a.key()); + assert!( + state.has_budget(b, &budgets), + "{} should still have budget after exhausting {}", + b.key(), + a.key() + ); + assert_eq!(state.exhausted(a), a.exhaustion_key()); + assert_eq!(state.exhaustion_reason, a.exhaustion_key()); + // Later exhaustion overwrites the reason (last writer wins). + assert_eq!(state.exhausted(b), b.exhaustion_key()); + assert_eq!(state.exhaustion_reason, b.exhaustion_key()); + } + } + + #[test] + fn zero_budget_modes_never_spend() { + let budgets = zero_budgets(); + let mut state = ReviewRepairState::default(); + let mut evidence = EvidenceTracker::default(); + for mode in ReviewRepairMode::ALL { + assert!(!state.has_budget(*mode, &budgets), "{}", mode.key()); + assert!(!state.spend(*mode, &mut evidence, &budgets), "{}", mode.key()); + assert_eq!(state.count(*mode), 0); + } + assert_eq!(evidence.quality_repair_nudges, 0); + } + + #[test] + fn disclaimer_family_shares_exhaustion_key() { + assert_eq!( + ReviewRepairMode::GenericTemplate.exhaustion_key(), + ReviewRepairMode::InspectedDisclaimer.exhaustion_key() + ); + assert_eq!( + ReviewRepairMode::InspectedDisclaimer.exhaustion_key(), + ReviewRepairMode::InspectedDisclaimerChatAttempt.exhaustion_key() + ); + // But counters remain per-mode keys. + assert_ne!( + ReviewRepairMode::GenericTemplate.key(), + ReviewRepairMode::InspectedDisclaimer.key() + ); + } + + #[test] + fn property_random_spend_order_never_exceeds_budget() { + let budgets = ReviewRepairBudgets::default(); + let modes = ReviewRepairMode::ALL; + // Deterministic pseudo-shuffle over a fixed schedule. + let schedule: Vec = (0..200) + .map(|i| modes[(i * 7 + 3) % modes.len()]) + .collect(); + let mut state = ReviewRepairState::default(); + let mut evidence = EvidenceTracker::default(); + for mode in schedule { + let before = state.count(mode); + let limit = mode.limit_with(&budgets); + let spent = state.spend(mode, &mut evidence, &budgets); + let after = state.count(mode); + if spent { + assert_eq!(after, before + 1); + assert!(after <= limit); + } else { + assert_eq!(after, before); + assert!(before >= limit); + } + } + for mode in modes { + assert!( + state.count(*mode) <= mode.limit_with(&budgets), + "{} exceeded budget", + mode.key() + ); + } + } +} diff --git a/crates/hi-agent/src/agent/turn/steer.rs b/crates/hi-agent/src/agent/turn/steer.rs index ee5db66..f2bb085 100644 --- a/crates/hi-agent/src/agent/turn/steer.rs +++ b/crates/hi-agent/src/agent/turn/steer.rs @@ -107,7 +107,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.last_plan); +let plan_incomplete = plan_has_pending_steps(&self.goals.last_plan); if let Some(intent) = read_only_intent && (looks_unfinished || plan_incomplete) { diff --git a/crates/hi-agent/src/agent/turn/tools.rs b/crates/hi-agent/src/agent/turn/tools.rs index 05cfb90..985239b 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.last_plan.as_slice() != plan); + .is_some_and(|plan| self.goals.last_plan.as_slice() != 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.last_plan = plan.to_vec(); + self.goals.last_plan = plan.to_vec(); if let Some(session) = self.session.as_mut() { if plan_has_pending_steps(plan) { session.record_plan(plan)?; @@ -965,7 +965,7 @@ while done < calls.len() { // durable goal (stable across the turn), so repeated // update_plan calls can't compound past one advance. if self.config.subagents.long_horizon - && let Some(current_goal) = self.structured_goal.as_ref() + && let Some(current_goal) = self.goals.structured.as_ref() { let turn_start_active = current_goal.active_index(); let goal = diff --git a/crates/hi-agent/src/agent/turn/verify_outcome.rs b/crates/hi-agent/src/agent/turn/verify_outcome.rs new file mode 100644 index 0000000..140a44f --- /dev/null +++ b/crates/hi-agent/src/agent/turn/verify_outcome.rs @@ -0,0 +1,361 @@ +//! React to one [`VerifyOutcome`] from workspace repair. +//! +//! Extracted from the main turn loop so orchestration stays thin routing: +//! run verify → handle outcome → re-enter Model or leave the `'turn` loop. + +use std::collections::BTreeSet; + +use anyhow::Result; + +use crate::transcript::NudgeKind; +use crate::verify::{VerifyOutcome, WorkspaceRepairVerifier, stage_guidance}; +use crate::{ReviewStatus, Ui}; + +use super::helpers::fallback_review_line_count; + +/// What the outer `'turn` loop should do after handling a verify outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum VerifyOutcomeControl { + /// Leave the model/verify loop and proceed to Settle. + BreakTurn, + /// Re-enter Model → Tools (obligation, failed repair, skeptic objection, …). + ReenterModel, +} + +/// Mutable turn-locals the verify-outcome handler may update. +pub(super) struct VerifyOutcomeState<'a> { + pub(super) obligation_nudge_fired: &'a mut bool, + pub(super) force_tools_next: &'a mut bool, + pub(super) verified_at: &'a mut Option<(u64, String)>, + pub(super) independent_review_status: &'a mut ReviewStatus, + pub(super) independent_review_repairs: &'a mut u32, + pub(super) stalled_unfinished: &'a mut bool, + pub(super) verification_infrastructure_error: &'a mut bool, + pub(super) verification_unstable: &'a mut bool, + pub(super) last_verify_attributions: &'a mut Vec, + pub(super) ranked_context_paths: &'a mut BTreeSet, + pub(super) context_generation_seen: &'a mut u64, + pub(super) indexed_ledger_revision: &'a mut u64, +} + +impl crate::Agent { + /// Apply one workspace-repair [`VerifyOutcome`]. Returns whether the outer + /// loop should break to Settle or re-enter Model. + #[allow(clippy::too_many_arguments)] + pub(super) async fn handle_workspace_repair_outcome( + &mut self, + outcome: VerifyOutcome, + verifier: &mut WorkspaceRepairVerifier, + turn_ledger_revision: u64, + expected_mutation: bool, + context_task: &str, + repository_context_enabled: bool, + state: &mut VerifyOutcomeState<'_>, + ui: &mut dyn Ui, + ) -> Result { + match outcome { + VerifyOutcome::NotRun => { + // Phase C obligation: one re-entry when a coding turn still + // owes green evidence (failed budget or never sealed). + let (changed_now, mutation_now) = { + let ledger = self.runtime.ledger(); + ( + ledger + .changes_since(turn_ledger_revision) + .into_iter() + .map(|c| c.path) + .collect::>(), + ledger.had_mutation_since(turn_ledger_revision), + ) + }; + if !*state.obligation_nudge_fired + && let Some(reason) = super::obligation::coding_verify_obligation( + self.last_task_contract.as_ref(), + &self.config.gates.verification, + expected_mutation, + &changed_now, + mutation_now, + self.last_verify, + verifier.executions().len(), + ) + { + match reason { + // Never sealed green after a code mutation — one more + // model round to run checks / fix. Failed-verify budget + // exhaustion already spent its repair rounds above. + super::obligation::ObligationReason::UnverifiedMutation => { + *state.obligation_nudge_fired = true; + ui.status(reason.ui_status()); + ui.nudge(reason.ui_status()); + self.messages + .push_nudge(NudgeKind::Continue, reason.nudge_body()); + *state.force_tools_next = true; + return Ok(VerifyOutcomeControl::ReenterModel); + } + super::obligation::ObligationReason::FailedVerify => { + *state.stalled_unfinished = true; + ui.status(reason.ui_status()); + } + } + } + if self.last_verify == Some(false) { + *state.stalled_unfinished = true; + ui.status( + "verification still failed after the retry budget; the task may be incomplete. /retry, or send 'continue'.", + ); + } + return Ok(VerifyOutcomeControl::BreakTurn); + } + VerifyOutcome::SkippedNoChanges { first } => { + if first { + ui.status("verification skipped — no files changed this turn"); + } + // Mutation-shaped coding turns that somehow report no file + // delta still owe evidence when mutation_seen (e.g. restored + // bytes) or the contract expected edits — one obligation nudge. + let mutation_now = self + .runtime + .ledger() + .had_mutation_since(turn_ledger_revision); + if !*state.obligation_nudge_fired + && let Some(reason) = super::obligation::coding_verify_obligation( + self.last_task_contract.as_ref(), + &self.config.gates.verification, + expected_mutation, + &[], + mutation_now, + self.last_verify, + verifier.executions().len(), + ) + { + if matches!( + reason, + super::obligation::ObligationReason::UnverifiedMutation + ) { + *state.obligation_nudge_fired = true; + ui.status(reason.ui_status()); + ui.nudge(reason.ui_status()); + self.messages + .push_nudge(NudgeKind::Continue, reason.nudge_body()); + *state.force_tools_next = true; + return Ok(VerifyOutcomeControl::ReenterModel); + } + } + return Ok(VerifyOutcomeControl::BreakTurn); + } + VerifyOutcome::SkippedProseOnly { first } => { + if first { + ui.status("verification skipped — prose-only files changed this turn"); + } + return Ok(VerifyOutcomeControl::BreakTurn); + } + VerifyOutcome::Passed => { + ui.status("✓ verification passed"); + self.last_verify = Some(true); + self.reconcile_workspace_changes()?; + let (verified_revision, verified_digest, current_changes) = { + let mut ledger = self.runtime.ledger(); + ( + ledger.revision(), + ledger.workspace_revision(), + ledger.changes_since(turn_ledger_revision), + ) + }; + *state.verified_at = Some((verified_revision, verified_digest.clone())); + let current_files = current_changes + .iter() + .map(|change| change.path.clone()) + .collect::>(); + let mut diff = self.turn_diff().await; + let diff_lines = if diff.trim().is_empty() { + fallback_review_line_count(self.runtime.root(), ¤t_changes) + } else { + diff.lines().count() + }; + let (review_required, large_diff_review) = + self.last_task_contract.as_ref().map_or((false, false), |contract| { + let required = contract.requires_review( + self.config.gates.review, + ¤t_files, + diff_lines, + self.config.subagents.long_horizon + || self.config.subagents.write_subagents.is_enabled(), + ); + let large = contract.is_large_mutation(¤t_files, diff_lines); + (required, large) + }); + if review_required { + self.refresh_active_task_context( + &context_task, + repository_context_enabled, + turn_ledger_revision, + state.ranked_context_paths, + state.context_generation_seen, + state.indexed_ledger_revision, + ); + if diff.chars().count() > 50_000 { + diff = diff.chars().take(50_000).collect(); + diff.push_str("\n… (bounded review diff truncated)"); + } + let contract = self + .last_task_contract + .as_ref() + .and_then(|contract| serde_json::to_string_pretty(contract).ok()) + .unwrap_or_else(|| "(task contract unavailable)".into()); + let instructions = self.task_context.as_deref().unwrap_or("(none)"); + let stages = verifier.stages_summary().unwrap_or_else(|| "(none)".into()); + let context = format!( + "Task contract:\n{contract}\n\nScoped instructions and relevant repository context:\n{instructions}\n\nChanged files ({file_count}):\n{files}\n\nDiff size: {diff_lines} lines\nDeterministic verification: PASSED\nStages: {stages}\nVerified workspace revision: {verified_digest}\n\nComplete bounded turn diff:\n{diff}", + file_count = current_files.len(), + files = current_files.join("\n"), + ); + // Phase L: large multi-file diffs get the hole-focused + // skeptic prompt; other risk reviews keep the general one. + let review_label = if large_diff_review { + "large-diff skeptic" + } else { + "independent completion review" + }; + ui.status(&format!("running {review_label}")); + let verdict = if diff.trim().is_empty() && !current_files.is_empty() { + super::super::skeptic::SkepticVerdict::Unavailable( + "a complete turn diff was unavailable for the current changes" + .into(), + ) + } else if large_diff_review { + self.large_diff_review(&context).await + } else { + self.independent_review(&context).await + }; + match verdict { + super::super::skeptic::SkepticVerdict::Approve => { + *state.independent_review_status = ReviewStatus::Passed; + if large_diff_review { + ui.status("✓ large-diff skeptic approved"); + } + } + super::super::skeptic::SkepticVerdict::Unavailable(reason) => { + *state.independent_review_status = ReviewStatus::Unavailable; + ui.status(&format!( + "{review_label} unavailable after deterministic pass: {reason}" + )); + } + super::super::skeptic::SkepticVerdict::Object(objections) + if *state.independent_review_repairs == 0 => + { + *state.independent_review_repairs = 1; + *state.independent_review_status = ReviewStatus::Objected; + self.last_verify = None; + *state.verified_at = None; + verifier.allow_review_revalidation(); + let headline = if large_diff_review { + "Large-diff skeptic found concrete multi-file defects" + } else { + "Independent review found concrete completion defects" + }; + self.messages.push_nudge( + NudgeKind::Review, + format!( + "{headline}. Repair them now, then re-run deterministic validation.\n\n{}", + objections + .iter() + .map(|objection| format!("- {objection}")) + .collect::>() + .join("\n") + ), + ); + ui.nudge(&format!( + "{review_label} objected; allowing one repair cycle" + )); + return Ok(VerifyOutcomeControl::ReenterModel); + } + super::super::skeptic::SkepticVerdict::Object(objections) => { + *state.independent_review_status = ReviewStatus::Objected; + *state.stalled_unfinished = true; + ui.status(&format!( + "{review_label} objected again after repair: {}", + objections.join("; ") + )); + } + // The independent-review prompt defines no ESCALATE + // verdict; treat a stray one as a final objection + // (no extra repair cycle — escalation means + // retrying can't fix it). + super::super::skeptic::SkepticVerdict::Escalate(objections) => { + *state.independent_review_status = ReviewStatus::Objected; + *state.stalled_unfinished = true; + ui.status(&format!( + "{review_label} escalated — needs your judgment: {}", + objections.join("; ") + )); + } + } + } + return Ok(VerifyOutcomeControl::BreakTurn); + } + VerifyOutcome::Failed { + stage, + output, + round, + } => { + ui.status(&format!("✗ {} failed; iterating", stage.name)); + self.last_verify = Some(false); + *state.verified_at = None; + let guidance = stage_guidance(&stage); + // Structured failure: attributions + condensed output + optional + // diagnostic snippet. Enrich-only relative to the raw blob. + let structured = hi_tools::format_structured_failure( + &format!( + "Verification stage `{}` failed (`{}`).", + stage.name, stage.command + ), + &output, + Some(guidance), + ); + *state.last_verify_attributions = structured.attributions.clone(); + // Replace the previous verify nudge instead of accumulating. + // Only the latest verification output belongs in context. + // `replace_last_nudge` pops trailing tool/assistant messages + // from the prior verify cycle and the prior nudge itself + // (located by typed kind, not string-matching), then pushes + // the new one. On the first round there's no prior nudge, so + // nothing is popped — the model's just-finished turn stays. + self.messages + .replace_last_nudge(NudgeKind::Verify { round }, structured.body); + // Re-enter Model → Tools with the verify nudge in context. + // The verifier's round counter enforces max_verify_repairs. + return Ok(VerifyOutcomeControl::ReenterModel); + } + VerifyOutcome::InfrastructureError { + stage, + output, + round, + } => { + *state.verification_infrastructure_error = true; + self.last_verify = None; + *state.verified_at = None; + ui.status(&format!( + "verification infrastructure failed at {} (round {round}): {output}", + stage.name, + )); + return Ok(VerifyOutcomeControl::BreakTurn); + } + VerifyOutcome::Unstable { + stage, + changed_files, + round, + } => { + *state.verification_unstable = true; + *state.stalled_unfinished = true; + self.last_verify = Some(false); + *state.verified_at = None; + ui.status(&format!( + "verification is unstable in round {round}: stage {} modified {}", + stage.name, + changed_files.join(", ") + )); + return Ok(VerifyOutcomeControl::BreakTurn); + } + } + } +} diff --git a/crates/hi-agent/src/command.rs b/crates/hi-agent/src/command.rs index 42c22dc..8f0cfb2 100644 --- a/crates/hi-agent/src/command.rs +++ b/crates/hi-agent/src/command.rs @@ -639,6 +639,19 @@ pub enum ConfigArg { RsiSpendLimit(u64), /// `/config rsi channel stable|beta` — persist the candidate channel. RsiChannel(RsiChannel), + /// Nested settings that resolve to existing top-level commands. Frontends + /// should prefer [`resolve_command`] so `/config model …` shares the same + /// handlers as bare `/model …`. + Model(String), + Provider(String), + Login(String), + Logout(String), + Verify(String), + Lsp(String), + Delegate(String), + Theme(String), + Density(String), + Mouse(String), /// Unrecognized option or bad value; carries a usage/error hint. Invalid(String), } @@ -778,12 +791,89 @@ pub fn parse_config_arg(arg: &str) -> ConfigArg { )), }, "rsi" => parse_rsi_config_arg(val), + // Nested settings hub — these rewrite to the existing top-level + // commands via [`resolve_command`] so handlers stay single-sourced. + "model" | "m" => ConfigArg::Model(val.to_string()), + "provider" | "prov" | "profile" => ConfigArg::Provider(val.to_string()), + "login" | "signin" => ConfigArg::Login(val.to_string()), + "logout" | "signout" => ConfigArg::Logout(val.to_string()), + "auth" => parse_auth_config_arg(val), + "verify" | "test" => ConfigArg::Verify(val.to_string()), + "lsp" => ConfigArg::Lsp(val.to_string()), + "delegate" | "delegates" => ConfigArg::Delegate(val.to_string()), + "theme" | "themes" => ConfigArg::Theme(val.to_string()), + "density" | "dense" => ConfigArg::Density(val.to_string()), + "mouse" => ConfigArg::Mouse(val.to_string()), + "ui" => parse_ui_config_arg(val), other => ConfigArg::Invalid(format!( - "unknown /config option '{other}' — try: show, reasoning , temp , steps , moe-streaming , skeptic-local , rsi [on|off|spend-limit ]" + "unknown /config option '{other}' — try: show, model, provider, auth, \ +reasoning, temp, steps, verify, lsp, delegate, moe-streaming, skeptic-local, rsi, \ +ui theme|density|mouse" )), } } +/// `/config auth login|logout `. +fn parse_auth_config_arg(arg: &str) -> ConfigArg { + let a = arg.trim(); + if a.is_empty() { + return ConfigArg::Invalid("usage: /config auth ".into()); + } + let (action, rest) = match a.split_once(char::is_whitespace) { + Some((k, v)) => (k, v.trim()), + None => (a, ""), + }; + match action.to_ascii_lowercase().as_str() { + "login" | "signin" => ConfigArg::Login(rest.to_string()), + "logout" | "signout" => ConfigArg::Logout(rest.to_string()), + other => ConfigArg::Invalid(format!( + "unknown /config auth action '{other}' — use login or logout" + )), + } +} + +/// `/config ui theme|density|mouse …`. +fn parse_ui_config_arg(arg: &str) -> ConfigArg { + let a = arg.trim(); + if a.is_empty() { + return ConfigArg::Invalid("usage: /config ui [value]".into()); + } + let (key, rest) = match a.split_once(char::is_whitespace) { + Some((k, v)) => (k, v.trim()), + None => (a, ""), + }; + match key.to_ascii_lowercase().as_str() { + "theme" | "themes" => ConfigArg::Theme(rest.to_string()), + "density" | "dense" => ConfigArg::Density(rest.to_string()), + "mouse" => ConfigArg::Mouse(rest.to_string()), + other => ConfigArg::Invalid(format!( + "unknown /config ui option '{other}' — use theme, density, or mouse" + )), + } +} + +/// Rewrite nested `/config …` settings into the underlying top-level +/// [`Command`] so frontends keep a single handler per setting. Live knobs +/// (reasoning/temp/steps/rsi/…) stay as [`Command::Config`]. +pub fn resolve_command(command: Command) -> Command { + match command { + Command::Config(ref arg) => match parse_config_arg(arg) { + ConfigArg::Model(s) => Command::Model(s), + ConfigArg::Provider(s) => Command::Provider(s), + ConfigArg::Login(s) => Command::Login(s), + ConfigArg::Logout(s) => Command::Logout(s), + ConfigArg::Verify(s) => Command::Verify(s), + ConfigArg::Lsp(s) => Command::Lsp(s), + ConfigArg::Delegate(s) => Command::Delegate(s), + ConfigArg::Theme(s) => Command::Theme(s), + ConfigArg::Density(s) => Command::Density(s), + ConfigArg::Mouse(s) => Command::Mouse(s), + _ => command, + }, + other => other, + } +} + fn parse_rsi_config_arg(value: &str) -> ConfigArg { let mut parts = value.split_whitespace(); let Some(action) = parts.next() else { @@ -940,14 +1030,21 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "model", args: "[id]", - help: "show or set the model (no id opens the selector)", + help: "show or set the model (alias of /config model)", arg_values: &[], }, CommandSpec { name: "config", - args: "[reasoning |temp |steps ]", - help: "show or set live reasoning, temperature, and step limit", + args: "[key …]", + help: "settings hub — model, provider, auth, reasoning, verify, lsp, ui, …", arg_values: &[ + ("show", "print the current live settings"), + ("model", "show or set the model (/config model [id])"), + ( + "provider", + "list/switch profiles, or add/edit/remove (/config provider …)", + ), + ("auth", "subscription login/logout (/config auth login|logout )"), ( "reasoning", "set reasoning_effort: minimal|low|medium|high|xhigh|off", @@ -957,6 +1054,22 @@ pub const COMMANDS: &[CommandSpec] = &[ "steps", "set the turn step limit: positive integer, auto, or off", ), + ("verify", "show/set/clear the verify command"), + ("lsp", "toggle LSP or show status"), + ("delegate", "write-capable delegate policy: on|off|risk"), + ( + "moe-streaming", + "MLX MoE expert streaming: on|off|auto", + ), + ("skeptic-local", "auto-managed local skeptic model: on|off"), + ( + "rsi", + "public RSI policy: on|off|spend-limit|channel", + ), + ("ui", "TUI chrome: theme|density|mouse"), + ("theme", "TUI color theme (alias of /config ui theme)"), + ("density", "transcript density (alias of /config ui density)"), + ("mouse", "mouse capture (alias of /config ui mouse)"), ], }, CommandSpec { @@ -981,13 +1094,13 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "provider", args: "[name|add|edit|remove]", - help: "use a profile, or add/edit/remove a profile (no arg lists all)", + help: "profiles (alias of /config provider)", arg_values: &[], }, CommandSpec { name: "login", args: "", - help: "sign in with a subscription instead of an API key", + help: "subscription sign-in (alias of /config auth login)", arg_values: &[( "xai", "Grok via a grok.com SuperGrok or X Premium subscription", @@ -996,13 +1109,13 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "logout", args: "", - help: "discard a stored subscription login", + help: "discard subscription login (alias of /config auth logout)", arg_values: &[("xai", "forget the stored grok.com credential")], }, CommandSpec { name: "verify", args: "[cmd|off]", - help: "show/set/clear the test command turns iterate against", + help: "verify command (alias of /config verify); turns iterate against", arg_values: &[("off", "disable the verify command")], }, CommandSpec { @@ -1188,7 +1301,7 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "lsp", args: "[on|off|status]", - help: "toggle the LSP subsystem, or show server status", + help: "LSP toggle (alias of /config lsp)", arg_values: &[ ("on", "enable LSP"), ("off", "disable LSP"), @@ -1198,7 +1311,7 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "delegate", args: "[on|off|risk|status]", - help: "write-capable delegate subagent (default risk: multi-file / isolation tasks)", + help: "delegate policy (alias of /config delegate)", arg_values: &[ ("on", "offer delegate on every mutation turn"), ("off", "never offer delegate"), @@ -1242,7 +1355,7 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "theme", args: "[dark|light|ansi|auto]", - help: "switch the TUI color theme (empty cycles; auto follows OS light/dark)", + help: "TUI theme (alias of /config ui theme)", arg_values: &[ ("dark", "designed dark palette (truecolor)"), ("light", "designed light palette (truecolor)"), @@ -1253,7 +1366,7 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "density", args: "[compact|comfortable|verbose]", - help: "transcript density (empty cycles; compact folds tool bodies harder)", + help: "transcript density (alias of /config ui density)", arg_values: &[ ("compact", "headers only for long tool output"), ("comfortable", "default preview fold"), @@ -1263,7 +1376,7 @@ pub const COMMANDS: &[CommandSpec] = &[ CommandSpec { name: "mouse", args: "[on|off]", - help: "toggle mouse capture; off lets the terminal select text natively", + help: "mouse capture (alias of /config ui mouse)", arg_values: &[ ( "on", @@ -1356,11 +1469,31 @@ pub fn arg_matching(name: &str, prefix: &str) -> Vec<(&'static str, &'static str .unwrap_or_default() } +/// Whether a command is primarily a settings toggle whose canonical home is +/// `/config`. Kept in help under a separate section so the action list stays +/// scannable; bare tops remain parseable aliases forever. +fn is_settings_alias(name: &str) -> bool { + matches!( + name, + "model" + | "provider" + | "login" + | "logout" + | "verify" + | "lsp" + | "delegate" + | "theme" + | "density" + | "mouse" + ) +} + /// Help text, generated from [`COMMANDS`] so it always lists exactly what -/// exists. Includes a keybindings section so Ctrl- shortcuts aren't secret. +/// exists. Groups settings under `/config` and marks bare tops as aliases. +/// Includes a keybindings section so Ctrl- shortcuts aren't secret. pub fn help_text() -> String { let mut out = String::from("commands:\n"); - for c in COMMANDS { + for c in COMMANDS.iter().filter(|c| !is_settings_alias(c.name)) { let left = if c.args.is_empty() { format!("/{}", c.name) } else { @@ -1368,7 +1501,15 @@ pub fn help_text() -> String { }; out.push_str(&format!(" {left:<18} {}\n", c.help)); } - out.push_str("aliases: /m /st /cp /redo /revert /new /changes /usage /debug /h /?"); + out.push_str("\nsettings (also available as bare aliases):\n"); + out.push_str( + " /config [key …] hub for model, provider, auth, reasoning, verify, lsp, ui…\n", + ); + out.push_str(" /model /provider /login /logout /verify /lsp /delegate\n"); + out.push_str(" /theme /density /mouse (TUI; also /config ui …)\n"); + out.push_str( + "aliases: /m /st /cp /redo /revert /new /changes /usage /debug /cfg /set /h /?", + ); out.push_str("\n\nkeybindings (TUI):\n"); out.push_str(" Ctrl-T toggle reasoning (thinking) collapse\n"); out.push_str(" Ctrl-D toggle the working-tree diff panel\n"); @@ -1842,7 +1983,9 @@ mod tests { #[test] fn config_arg_parsing() { - use super::{ConfigArg, MoeStreamingMode, format_usd_micros, parse_config_arg}; + use super::{ + ConfigArg, MoeStreamingMode, format_usd_micros, parse_config_arg, resolve_command, + }; use hi_ai::ReasoningEffort; // Empty → show. assert_eq!(parse_config_arg(""), ConfigArg::Show); @@ -1998,6 +2141,74 @@ mod tests { parse("/set reasoning off"), Some(Command::Config("reasoning off".into())) ); + // Nested settings hub. + assert_eq!( + parse_config_arg("model gpt-test"), + ConfigArg::Model("gpt-test".into()) + ); + assert_eq!( + parse_config_arg("provider add"), + ConfigArg::Provider("add".into()) + ); + assert_eq!( + parse_config_arg("auth login xai"), + ConfigArg::Login("xai".into()) + ); + assert_eq!( + parse_config_arg("auth logout xai"), + ConfigArg::Logout("xai".into()) + ); + assert_eq!(parse_config_arg("lsp on"), ConfigArg::Lsp("on".into())); + assert_eq!( + parse_config_arg("delegate risk"), + ConfigArg::Delegate("risk".into()) + ); + assert_eq!( + parse_config_arg("verify cargo test"), + ConfigArg::Verify("cargo test".into()) + ); + assert_eq!( + parse_config_arg("ui theme dark"), + ConfigArg::Theme("dark".into()) + ); + assert_eq!( + parse_config_arg("ui density compact"), + ConfigArg::Density("compact".into()) + ); + assert_eq!(parse_config_arg("mouse off"), ConfigArg::Mouse("off".into())); + assert_eq!( + resolve_command(Command::Config("model gpt-test".into())), + Command::Model("gpt-test".into()) + ); + assert_eq!( + resolve_command(Command::Config("lsp on".into())), + Command::Lsp("on".into()) + ); + assert_eq!( + resolve_command(Command::Config("auth login xai".into())), + Command::Login("xai".into()) + ); + assert_eq!( + resolve_command(Command::Config("ui theme dark".into())), + Command::Theme("dark".into()) + ); + // Live knobs stay as Config. + assert_eq!( + resolve_command(Command::Config("reasoning high".into())), + Command::Config("reasoning high".into()) + ); + let help = help_text(); + assert!(help.contains("settings (also available as bare aliases)")); + assert!(help.contains("/config [key")); + // Primary command rows are ` /name …` under the commands section; the + // settings blurb lists bare aliases on a single line without a help tail. + assert!( + !help.lines().any(|line| { + line.starts_with(" /model ") && line.contains("alias of /config") + }), + "bare /model should not appear as a primary help row" + ); + assert!(help.contains("/model /provider")); } #[test] diff --git a/crates/hi-agent/src/domain.rs b/crates/hi-agent/src/domain.rs new file mode 100644 index 0000000..4bb13a0 --- /dev/null +++ b/crates/hi-agent/src/domain.rs @@ -0,0 +1,32 @@ +//! Domain-scoped holders for cross-cutting `Agent` state. +//! +//! 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. + +use hi_tools::PlanStep; + +use crate::goal::Goal; + +/// Session goal + plan state owned by the interactive agent. +#[derive(Clone, Debug, Default)] +pub(crate) struct GoalState { + /// Transient free-text goal (prompt injection; not the durable structured goal). + pub(crate) free_text: Option, + /// Durable hierarchical goal when long-horizon mode is on. + pub(crate) structured: Option, + /// Latest `update_plan` steps for incomplete-plan steering. + pub(crate) last_plan: Vec, +} + +/// Live RSI observation state that is *not* config (`AgentRsi`). +/// +/// Interactive code may observe RSI; it must not drive the RSI workflow SM. +#[derive(Clone, Debug, Default)] +pub(crate) struct RsiObserveState { + /// Frontend observation result for the latest completed turn. + pub(crate) last_fully_observed: Option, + /// Validated worker-provided conversation reference for managed RSI. + pub(crate) managed_context: Option, +} diff --git a/crates/hi-agent/src/lib.rs b/crates/hi-agent/src/lib.rs index 5c3ab93..eeccb3b 100644 --- a/crates/hi-agent/src/lib.rs +++ b/crates/hi-agent/src/lib.rs @@ -9,6 +9,7 @@ pub mod compaction; mod config; mod context_index; mod decision; +mod domain; mod goal; mod heuristics; pub mod local_skeptic; @@ -95,6 +96,7 @@ pub use ui::{ pub use verify::VerificationExecution; pub use workspace_runtime::WorkspaceRuntime; +use domain::{GoalState, RsiObserveState}; use snapshot::SnapshotCache; use transcript::Transcript; @@ -699,13 +701,8 @@ pub struct Agent { /// when the turn ends with a provider/infrastructure error before a typed /// outcome can be finalized. pub(crate) last_effective_route: EffectiveModelRoute, - /// Optional transient goal injected into the system prompt for future turns. - pub(crate) goal: Option, - /// A structured, multi-step long-horizon goal (decomposed into sub-goals) - /// used when `config.subagents.long_horizon` is on. Persisted across sessions and - /// injected into the system prompt each turn so the agent resumes the - /// active sub-goal coherently. Distinct from the transient `goal` string. - pub(crate) structured_goal: Option, + /// Session goals + plan (transient free-text, durable structured goal, last plan). + pub(crate) goals: GoalState, /// Durable intra-session decision log — recorded via the `record_decision` /// tool and injected into the system prompt each turn, so the model stays /// consistent across compaction (which would otherwise summarize away the @@ -715,12 +712,6 @@ pub struct Agent { /// verify/turn-end check when no files changed. Invalidated by any /// write/edit/bash tool call in the current turn, and by `/undo`. pub(crate) snapshot_cache: SnapshotCache, - /// The most recent plan posted via `update_plan` this turn — used to detect - /// an incomplete plan when the model stops calling tools. If the plan has - /// pending/active steps, the agent silently nudges the model to continue - /// rather than ending the turn (the model often writes a finished-looking - /// recap after one sub-task, even when the plan is only 2/9 done). - pub(crate) last_plan: Vec, /// Messages the user typed *while a turn was running*, awaiting injection at /// the next safe point in the loop (mid-turn interjection steering). A /// frontend clones a push handle via [`Agent::interjection_inbox`] before @@ -728,11 +719,8 @@ pub struct Agent { /// each as a genuine user message so the model can course-correct without /// the turn being cancelled and restarted. pub(crate) interjections: InterjectionInbox, - /// Observation result reported by the frontend for the latest completed turn. - pub(crate) last_rsi_fully_observed: Option, - /// Validated, worker-provided conversation reference for managed RSI. It is - /// appended only after the active turn's intent and contract are derived. - pub(crate) managed_rsi_context: Option, + /// Live RSI observe-only state (not config; not the RSI workflow SM). + pub(crate) rsi_observe: RsiObserveState, } /// A cloneable handle to an agent's mid-turn interjection queue. The frontend diff --git a/crates/hi-agent/src/tests/plan.rs b/crates/hi-agent/src/tests/plan.rs index 1ee7077..4a91284 100644 --- a/crates/hi-agent/src/tests/plan.rs +++ b/crates/hi-agent/src/tests/plan.rs @@ -199,7 +199,7 @@ async fn resumed_active_plan_transitions_to_mutation_instead_of_stalling() { let mut cfg = workspace.config(); cfg.gates.verification = VerificationMode::Explicit(vec![VerifyStage::new("test", "true")]); let mut agent = Agent::new(std::sync::Arc::new(provider), cfg).unwrap(); - agent.last_plan = vec![PlanStep { + agent.goals.last_plan = vec![PlanStep { title: "Resume implementation".into(), status: PlanStatus::Active, }]; @@ -359,7 +359,7 @@ async fn new_task_emits_plan_clear_for_frontends() { )], config(), ); - agent.last_plan = vec![PlanStep { + agent.goals.last_plan = vec![PlanStep { title: "old unfinished step".into(), status: PlanStatus::Pending, }]; @@ -370,7 +370,7 @@ async fn new_task_emits_plan_clear_for_frontends() { .await .unwrap(); - assert!(agent.last_plan.is_empty()); + assert!(agent.goals.last_plan.is_empty()); assert_eq!(ui.plans, vec![Vec::::new()]); } @@ -380,7 +380,7 @@ async fn continue_does_not_preserve_a_completed_plan_box() { vec![completion(vec![Content::Text("done".into())], 1, 1)], config(), ); - agent.last_plan = vec![PlanStep { + agent.goals.last_plan = vec![PlanStep { title: "old completed step".into(), status: PlanStatus::Done, }]; @@ -388,7 +388,7 @@ async fn continue_does_not_preserve_a_completed_plan_box() { agent.run_turn("continue", &mut ui).await.unwrap(); - assert!(agent.last_plan.is_empty()); + assert!(agent.goals.last_plan.is_empty()); assert_eq!(ui.plans, vec![Vec::::new()]); } @@ -738,7 +738,7 @@ async fn plan_persists_across_turns_for_continue() { // Verify the plan state persisted after turn 1 — it should still have // pending steps so the plan-aware continue can fire on "continue". - let plan_after_turn1 = &agent.last_plan; + let plan_after_turn1 = &agent.goals.last_plan; assert!( plan_has_pending_steps(plan_after_turn1), "plan should persist with pending steps after turn 1: {:?}", @@ -750,7 +750,7 @@ async fn plan_persists_across_turns_for_continue() { // We can't easily run a full turn here (Canned provider is exhausted), // but we can verify the clearing logic by checking that a non-continue // input would clear it. Simulate by calling the clearing logic directly. - let mut plan = agent.last_plan.clone(); + let mut plan = agent.goals.last_plan.clone(); // The agent clears last_plan when input doesn't look like "continue". // Verify the heuristic: "fix a different bug" is NOT a continue command. assert!( diff --git a/crates/hi-agent/src/tests/turn.rs b/crates/hi-agent/src/tests/turn.rs index c0e7bea..fa61d36 100644 --- a/crates/hi-agent/src/tests/turn.rs +++ b/crates/hi-agent/src/tests/turn.rs @@ -168,7 +168,7 @@ async fn tools_unavailable_fast_path_resets_state_and_shows_message() { tool_calls: 3, ..TurnTelemetry::default() }; - agent.last_plan = vec![PlanStep { + agent.goals.last_plan = vec![PlanStep { title: "stale step".to_string(), status: PlanStatus::Active, }]; @@ -196,7 +196,7 @@ async fn tools_unavailable_fast_path_resets_state_and_shows_message() { assert!(agent.last_changed_files().is_empty()); assert!(agent.last_compat_fallbacks().is_empty()); assert_eq!(agent.last_turn_telemetry(), &TurnTelemetry::default()); - assert!(agent.last_plan.is_empty()); + assert!(agent.goals.last_plan.is_empty()); agent.messages.validate_for_provider().unwrap(); assert!( !agent diff --git a/crates/hi-cli/src/bootstrap.rs b/crates/hi-cli/src/bootstrap.rs new file mode 100644 index 0000000..e684855 --- /dev/null +++ b/crates/hi-cli/src/bootstrap.rs @@ -0,0 +1,121 @@ +//! Early CLI bootstrap: flag validation, config load, and short-circuit commands. +//! +//! Keeps `main::run` focused on wiring the agent and choosing a run mode +//! (one-shot / TUI / REPL / daemon) rather than front-loading every preflight. + +use anyhow::Result; +use clap::Parser; + +use crate::config::{self, Cli, ProviderName, RsiRequested}; +use crate::provider::{ + LiveModelMetadata, build_provider, effective_max_tokens_for_model, provider_label, + resolve_live_model_metadata, +}; +use crate::session; + +/// Parse argv and reject incompatible flag combinations (exits process on error). +pub(crate) fn parse_and_validate_cli() -> Cli { + let cli = Cli::parse(); + if let Some(id) = cli.sync_session_id.as_deref() + && let Err(err) = crate::sync::validate_session_id(id) + { + eprintln!("{err}"); + std::process::exit(2); + } + if let Some(id) = cli.attach.as_deref() + && let Err(err) = crate::sync::validate_session_id(id) + { + eprintln!("{err}"); + std::process::exit(2); + } + if cli.resume_local && cli.attach.is_none() { + eprintln!("--resume-local requires --attach "); + std::process::exit(2); + } + if cli.attach.is_some() && cli.daemon { + eprintln!("--attach and --daemon cannot be used together"); + std::process::exit(2); + } + cli +} + +/// Handle `--show-config` / `--list-sessions` before the heavy agent path. +/// +/// Returns `Some(result)` when the process should exit after the short-circuit. +pub(crate) async fn maybe_short_circuit(cli: &Cli) -> Option> { + if cli.show_config { + return Some(print_show_config(cli).await); + } + if cli.list_sessions { + return Some(session::list_sessions()); + } + None +} + +async fn print_show_config(cli: &Cli) -> Result<()> { + let file = match config::load_config(cli.config.as_deref()) { + Ok(file) => file, + Err(err) => { + eprintln!("{err:#}"); + std::process::exit(2); + } + }; + match config::resolve(cli, &file) { + Ok(settings) => { + let live = if settings.provider == ProviderName::Pipenetwork { + let provider = build_provider(&settings); + resolve_live_model_metadata(provider.as_ref(), &settings.model).await + } else { + LiveModelMetadata { + context_window: None, + max_output_tokens: None, + } + }; + let effective_max_tokens = + effective_max_tokens_for_model(&settings, live.max_output_tokens); + println!("provider: {}", provider_label(settings.provider)); + println!("model: {}", settings.model); + println!("base_url: {}", settings.base_url); + if let Some(mcp_url) = &settings.mcp_url { + println!("mcp_url: {mcp_url}"); + } + println!("max_tokens: {}", effective_max_tokens); + if let Some(limit) = live.max_output_tokens { + println!("model_max_output_tokens: {limit}"); + } + println!( + "thinking: {}", + settings + .thinking_budget + .map(|b| b.to_string()) + .unwrap_or_else(|| "off".into()) + ); + println!( + "reasoning: {}", + settings + .reasoning_effort + .map(|e| e.as_str().to_string()) + .unwrap_or_else(|| "off".into()) + ); + println!("tool_mode: {:?}", settings.tool_mode); + let rsi = config::resolve_rsi(cli, &file)?; + println!("rsi_requested: {rsi:?}"); + println!( + "rsi_active: {}", + if rsi == RsiRequested::Off { + "off" + } else { + "on" + } + ); + println!("rsi_latest_turn_fully_observed: none"); + println!("compat: {:?}", settings.compat); + println!("api_key: {}", config::mask_key(&settings.api_key)); + Ok(()) + } + Err(err) => { + eprintln!("{err}"); + std::process::exit(2); + } + } +} diff --git a/crates/hi-cli/src/commands.rs b/crates/hi-cli/src/commands.rs index 56345b7..56e2fa1 100644 --- a/crates/hi-cli/src/commands.rs +++ b/crates/hi-cli/src/commands.rs @@ -9,6 +9,8 @@ use hi_agent::Agent; /// Act on a slash command. Returns true when the session should quit. pub(crate) fn handle_command(agent: &mut Agent, command: hi_agent::Command) -> bool { use hi_agent::Command; + // Nested `/config model|lsp|…` rewrites to the bare top-level command. + let command = hi_agent::command::resolve_command(command); match command { Command::Quit => return true, Command::Rsi(_) => { @@ -267,6 +269,18 @@ pub(crate) fn handle_command(agent: &mut Agent, command: hi_agent::Command) -> b Ok(()) => println!("\x1b[2mRSI channel → {} (saved)\x1b[0m", channel.as_str()), Err(error) => eprintln!("\x1b[33mRSI config error: {error}\x1b[0m"), }, + // Nested settings are rewritten by `resolve_command` before + // dispatch; if one still reaches here, treat it as a no-op. + ConfigArg::Model(_) + | ConfigArg::Provider(_) + | ConfigArg::Login(_) + | ConfigArg::Logout(_) + | ConfigArg::Verify(_) + | ConfigArg::Lsp(_) + | ConfigArg::Delegate(_) + | ConfigArg::Theme(_) + | ConfigArg::Density(_) + | ConfigArg::Mouse(_) => {} ConfigArg::Invalid(m) => eprintln!("\x1b[33m{m}\x1b[0m"), } } diff --git a/crates/hi-cli/src/config/session.rs b/crates/hi-cli/src/config/session.rs index a8c277b..d8d7e19 100644 --- a/crates/hi-cli/src/config/session.rs +++ b/crates/hi-cli/src/config/session.rs @@ -73,3 +73,35 @@ pub fn remember_session( }; save_last_session(root, &session) } + +/// Profile name the interactive shell should treat as active on startup. +/// +/// Order: explicit `--profile` → last-session profile (when it still exists) → +/// config `default_profile`. A last-session snapshot with no profile means the +/// user was on a provider preset (`/provider xai`); in that case we deliberately +/// return `None` instead of falling back to `default_profile`, otherwise the +/// next exit would rewrite `.hi/last_session.toml` under the default profile and +/// discard the preset routing on the following launch. +pub fn resolve_active_profile(cli: &Cli, config: &Config, root: &Path) -> Option { + if let Some(name) = cli.profile.clone() { + return Some(name); + } + // CLI provider/model overrides mean last-session routing does not apply. + if cli.model.is_some() || cli.provider.is_some() { + return config.default_profile.clone(); + } + let last = load_last_session(root); + if let Some(name) = last + .as_ref() + .and_then(|s| s.profile.clone()) + .filter(|name| config.profiles.contains_key(name)) + { + return Some(name); + } + // Preset last-session: keep active_profile unset so remember_session writes + // profile=None again on exit. + if last.is_some() { + return None; + } + config.default_profile.clone() +} diff --git a/crates/hi-cli/src/config/tests.rs b/crates/hi-cli/src/config/tests.rs index 580e5dc..b447132 100644 --- a/crates/hi-cli/src/config/tests.rs +++ b/crates/hi-cli/src/config/tests.rs @@ -4,11 +4,13 @@ PIPENETWORK_DEFAULT_MAX_TOKENS, Profile, ProviderName, RsiRequested, RsiSection, configured_max_tokens, curate_skills_default, detect_verify_pipeline, explore_subagents_default, max_tokens_is_explicit, permits_missing_checkpoint, - planner_model_default, read_config_file, resolve_named_profile, resolve_quality, - resolve_rsi, save_config_to, set_rsi_config, write_subagents_default, + planner_model_default, read_config_file, resolve, resolve_active_profile, + resolve_named_profile, resolve_quality, resolve_rsi, save_config_to, set_rsi_config, + write_subagents_default, }; use clap::Parser; use hi_agent::{LspMode, ReviewPolicy, ToolSet, VerificationMode}; + use std::path::Path; use std::sync::atomic::{AtomicU32, Ordering}; fn temp_dir_with(marker: &str) -> std::path::PathBuf { @@ -1239,3 +1241,140 @@ context_exclusions = ["generated/**"] assert!(load_last_session(&dir).is_none()); std::fs::remove_dir_all(dir).unwrap(); } + + fn config_with_default_profile() -> Config { + Config { + default_profile: Some("default".into()), + profiles: [( + "default".into(), + Profile { + provider: Some(ProviderName::Pipenetwork), + model: Some("pipe/kimi-3".into()), + api_key: Some("test-key".into()), + ..Profile::default() + }, + )] + .into_iter() + .collect(), + ..Config::default() + } + } + + #[test] + fn active_profile_skips_default_when_last_session_was_provider_preset() { + use super::{LastSession, save_last_session}; + let dir = std::env::temp_dir().join(format!( + "hi-active-profile-preset-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + save_last_session( + &dir, + &LastSession { + profile: None, + provider: Some("xai".into()), + model: Some("grok-4.5".into()), + }, + ) + .unwrap(); + let cli = Cli::try_parse_from(["hi"]).unwrap(); + let config = config_with_default_profile(); + // The bug: falling back to default_profile here made exit rewrite + // last_session under "default" and lose the xai preset next launch. + assert_eq!(resolve_active_profile(&cli, &config, &dir), None); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn active_profile_restores_named_last_session_profile() { + use super::{LastSession, save_last_session}; + let dir = std::env::temp_dir().join(format!( + "hi-active-profile-named-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let mut config = config_with_default_profile(); + config.profiles.insert( + "work".into(), + Profile { + provider: Some(ProviderName::Xai), + model: Some("grok-4.5".into()), + api_key: Some("xai-key".into()), + ..Profile::default() + }, + ); + save_last_session( + &dir, + &LastSession { + profile: Some("work".into()), + provider: Some("xai".into()), + model: Some("grok-4.5".into()), + }, + ) + .unwrap(); + let cli = Cli::try_parse_from(["hi"]).unwrap(); + assert_eq!( + resolve_active_profile(&cli, &config, &dir).as_deref(), + Some("work") + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn active_profile_falls_back_to_default_without_last_session() { + let dir = std::env::temp_dir().join(format!( + "hi-active-profile-default-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let cli = Cli::try_parse_from(["hi"]).unwrap(); + let config = config_with_default_profile(); + assert_eq!( + resolve_active_profile(&cli, &config, &dir).as_deref(), + Some("default") + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn resolve_restores_provider_preset_from_last_session() { + use super::{LastSession, save_last_session}; + let dir = std::env::temp_dir().join(format!( + "hi-resolve-preset-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(dir.join(".hi")).unwrap(); + // resolve() reads last_session from cwd (`.`), so run under `dir`. + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + save_last_session( + Path::new("."), + &LastSession { + profile: None, + provider: Some("xai".into()), + model: Some("grok-4.5".into()), + }, + ) + .unwrap(); + // Auth store / env may or may not have an xAI key; force one via CLI so + // the assertion focuses on provider/model restore. + let cli = Cli::try_parse_from(["hi", "--api-key", "xai-test-key"]).unwrap(); + let config = config_with_default_profile(); + let settings = resolve(&cli, &config).expect("resolve preset last session"); + assert_eq!(settings.provider, ProviderName::Xai); + assert_eq!(settings.model, "grok-4.5"); + std::env::set_current_dir(prev).unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } diff --git a/crates/hi-cli/src/main.rs b/crates/hi-cli/src/main.rs index 33e2b91..6fab842 100644 --- a/crates/hi-cli/src/main.rs +++ b/crates/hi-cli/src/main.rs @@ -1,4 +1,5 @@ mod bestof; +mod bootstrap; mod candidate_gate; mod candidate_merge; mod child_process; @@ -34,17 +35,16 @@ use std::io::IsTerminal; use std::path::PathBuf; use anyhow::{Context, Result, anyhow}; -use clap::Parser; use hi_agent::{Agent, AgentConfig, CompactionKind, ObservationSink, VerificationMode}; use hi_ai::Provider; -use config::{Cli, ProviderName, RsiRequested, permits_missing_checkpoint}; +use config::{ProviderName, RsiRequested, permits_missing_checkpoint}; 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}; use provider::{ - LiveModelMetadata, build_chain, build_provider, default_skeptic_model, + LiveModelMetadata, build_chain, default_skeptic_model, effective_max_tokens_for_model, provider_label, resolve_live_model_metadata, }; use repl::repl; @@ -90,98 +90,9 @@ async fn run() -> Result<()> { return run_hf_cli(&raw_args[2..]).await; } - let cli = Cli::parse(); - if let Some(id) = cli.sync_session_id.as_deref() - && let Err(err) = sync::validate_session_id(id) - { - eprintln!("{err}"); - std::process::exit(2); - } - if let Some(id) = cli.attach.as_deref() - && let Err(err) = sync::validate_session_id(id) - { - eprintln!("{err}"); - std::process::exit(2); - } - if cli.resume_local && cli.attach.is_none() { - eprintln!("--resume-local requires --attach "); - std::process::exit(2); - } - if cli.attach.is_some() && cli.daemon { - eprintln!("--attach and --daemon cannot be used together"); - std::process::exit(2); - } - - if cli.show_config { - let file = match config::load_config(cli.config.as_deref()) { - Ok(file) => file, - Err(err) => { - eprintln!("{err:#}"); - std::process::exit(2); - } - }; - match config::resolve(&cli, &file) { - Ok(settings) => { - let live = if settings.provider == ProviderName::Pipenetwork { - let provider = build_provider(&settings); - resolve_live_model_metadata(provider.as_ref(), &settings.model).await - } else { - LiveModelMetadata { - context_window: None, - max_output_tokens: None, - } - }; - let effective_max_tokens = - effective_max_tokens_for_model(&settings, live.max_output_tokens); - println!("provider: {}", provider_label(settings.provider)); - println!("model: {}", settings.model); - println!("base_url: {}", settings.base_url); - if let Some(mcp_url) = &settings.mcp_url { - println!("mcp_url: {mcp_url}"); - } - println!("max_tokens: {}", effective_max_tokens); - if let Some(limit) = live.max_output_tokens { - println!("model_max_output_tokens: {limit}"); - } - println!( - "thinking: {}", - settings - .thinking_budget - .map(|b| b.to_string()) - .unwrap_or_else(|| "off".into()) - ); - println!( - "reasoning: {}", - settings - .reasoning_effort - .map(|e| e.as_str().to_string()) - .unwrap_or_else(|| "off".into()) - ); - println!("tool_mode: {:?}", settings.tool_mode); - let rsi = config::resolve_rsi(&cli, &file)?; - println!("rsi_requested: {rsi:?}"); - println!( - "rsi_active: {}", - if rsi == RsiRequested::Off { - "off" - } else { - "on" - } - ); - println!("rsi_latest_turn_fully_observed: none"); - println!("compat: {:?}", settings.compat); - println!("api_key: {}", config::mask_key(&settings.api_key)); - return Ok(()); - } - Err(err) => { - eprintln!("{err}"); - std::process::exit(2); - } - } - } - - if cli.list_sessions { - return session::list_sessions(); + let cli = bootstrap::parse_and_validate_cli(); + if let Some(result) = bootstrap::maybe_short_circuit(&cli).await { + return result; } let mut file = match config::load_config(cli.config.as_deref()) { @@ -834,17 +745,11 @@ async fn run() -> Result<()> { let use_tui = !cli.plain && stdout_is_tty && stdin_is_tty; // Prefer the workspace last-session profile (when it still exists) so a // mid-session `/provider` switch is what the next bare `hi` resumes with. - // Explicit `--profile` still wins; otherwise fall back to config default. - let active_profile = cli.profile.clone().or_else(|| { - if cli.model.is_none() && cli.provider.is_none() { - config::load_last_session(std::path::Path::new(".")) - .and_then(|s| s.profile) - .filter(|name| file.profiles.contains_key(name)) - } else { - None - } - }) - .or_else(|| file.default_profile.clone()); + // Explicit `--profile` still wins. Provider-preset last sessions must NOT + // fall back to `default_profile` or exit would rewrite last_session under + // the default and lose the preset on the next launch. + let active_profile = + config::resolve_active_profile(&cli, &file, std::path::Path::new(".")); // Flush durable records and live events after each interactive turn. The // callback is synchronous because both frontends own their event loops; diff --git a/crates/hi-cli/src/repl.rs b/crates/hi-cli/src/repl.rs index d693c24..4812357 100644 --- a/crates/hi-cli/src/repl.rs +++ b/crates/hi-cli/src/repl.rs @@ -98,7 +98,9 @@ pub(crate) async fn repl( // Resolve the line to a prompt to run. Commands either handle // themselves (and `continue`) or yield a prompt (`/retry`). let mut restore_model_state: Option = None; - let input = if let Some(command) = hi_agent::command::parse(&line) { + let input = if let Some(command) = + hi_agent::command::parse(&line).map(hi_agent::command::resolve_command) + { match command { Command::Quit => break, Command::Prompt(prompt) => prompt, diff --git a/crates/hi-tool-host/src/lib.rs b/crates/hi-tool-host/src/lib.rs index 6d7dbaa..7d997ee 100644 --- a/crates/hi-tool-host/src/lib.rs +++ b/crates/hi-tool-host/src/lib.rs @@ -338,4 +338,122 @@ mod tests { assert!(policy.authorize(Path::new("link"), false).is_err()); assert!(policy.authorize(Path::new("src/new.rs"), true).is_ok()); } + + /// Mirror of the interactive `hi-tools` capability → side-effect matrix. + /// + /// When registering RSI host tools, descriptors must use these classes so + /// candidate capabilities stay aligned with workstation tool semantics. + /// Source of truth for *which tools exist* is `hi_tools::TOOL_CATALOG`; + /// this table documents the shared side-effect vocabulary. + fn interactive_tool_side_effect(name: &str) -> Option { + match name { + "update_plan" | "record_decision" => Some(SideEffect::None), + "read" | "list" | "grep" | "glob" | "repo_map" | "find_symbol" | "diff" + | "diagnostics" | "definition" | "references" | "hover" => { + Some(SideEffect::WorkspaceRead) + } + "write" | "edit" | "multi_edit" | "apply_patch" | "web_download" => { + Some(SideEffect::WorkspaceWrite) + } + "bash" | "bash_output" | "bash_kill" | "explore" | "delegate" => { + Some(SideEffect::Process) + } + "web_search" | "web_fetch" => Some(SideEffect::Network), + _ => None, + } + } + + #[test] + fn side_effect_matrix_covers_interactive_catalog_names() { + let expected = [ + "update_plan", + "record_decision", + "read", + "list", + "grep", + "glob", + "repo_map", + "find_symbol", + "diff", + "diagnostics", + "definition", + "references", + "hover", + "write", + "edit", + "multi_edit", + "apply_patch", + "web_download", + "bash", + "bash_output", + "bash_kill", + "explore", + "delegate", + "web_search", + "web_fetch", + ]; + for name in expected { + assert!( + interactive_tool_side_effect(name).is_some(), + "missing side-effect mapping for {name}" + ); + } + } + + #[test] + fn side_effecting_tools_cannot_be_marked_replayable() { + for side_effect in [ + SideEffect::WorkspaceWrite, + SideEffect::Process, + SideEffect::Network, + ] { + let descriptor = ToolDescriptor { + name: "sample".into(), + input_schema: serde_json::json!({}), + output_schema: serde_json::json!({}), + required_capabilities: BTreeSet::new(), + side_effect, + maximum_output_bytes: 1024, + timeout_ms: 1000, + replayable: true, + }; + assert!( + validate_descriptor(&descriptor).is_err(), + "{side_effect:?} must reject replayable" + ); + } + let ok = ToolDescriptor { + name: "sample".into(), + input_schema: serde_json::json!({}), + output_schema: serde_json::json!({}), + required_capabilities: BTreeSet::new(), + side_effect: SideEffect::WorkspaceRead, + maximum_output_bytes: 1024, + timeout_ms: 1000, + replayable: true, + }; + assert!(validate_descriptor(&ok).is_ok()); + } + + #[test] + fn write_process_network_require_non_replayable_in_host_policy() { + // Pins the shared policy: mutating/network/process host tools always + // record artifacts (mirrors interactive non-read_only tools). + assert!(matches!( + interactive_tool_side_effect("write"), + Some(SideEffect::WorkspaceWrite) + )); + assert!(matches!( + interactive_tool_side_effect("bash"), + Some(SideEffect::Process) + )); + assert!(matches!( + interactive_tool_side_effect("web_search"), + Some(SideEffect::Network) + )); + assert!(matches!( + interactive_tool_side_effect("read"), + Some(SideEffect::WorkspaceRead) + )); + } } diff --git a/crates/hi-tools/src/tools.rs b/crates/hi-tools/src/tools.rs index 198de8f..3d460e0 100644 --- a/crates/hi-tools/src/tools.rs +++ b/crates/hi-tools/src/tools.rs @@ -2427,12 +2427,12 @@ fn is_env_assignment(tok: &str) -> bool { #[cfg(test)] mod tests { use super::{ - MAX_WRITE_OVERWRITE_BYTES, MINIMAL_TOOL_SPECS, TOOL_CATALOG, TOOL_SPECS, fast_check_for, - foreground_interactive_command_reason, foreground_interactive_command_reason_at, - 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, 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, }; use crate::edit::{apply_edit, sh_quote}; use std::time::Duration; @@ -3417,4 +3417,136 @@ mod tests { ); } } + + /// 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/commands.rs b/crates/hi-tui/src/app/commands.rs index 0054b20..d7f090d 100644 --- a/crates/hi-tui/src/app/commands.rs +++ b/crates/hi-tui/src/app/commands.rs @@ -1063,6 +1063,8 @@ impl crate::App { } pub(crate) async fn handle_command(&mut self, agent: &mut Agent, command: Command) { + // Nested `/config model|lsp|…` rewrites to the bare top-level command. + let command = command::resolve_command(command); match command { Command::Quit => {} // Handled inline by the run loop (needs terminal/input/ticker). @@ -1371,6 +1373,18 @@ impl crate::App { }; self.push(Line::styled(message, dim())); } + // Nested settings are rewritten by `resolve_command` before + // dispatch; if one still reaches here, treat it as a no-op. + ConfigArg::Model(_) + | ConfigArg::Provider(_) + | ConfigArg::Login(_) + | ConfigArg::Logout(_) + | ConfigArg::Verify(_) + | ConfigArg::Lsp(_) + | ConfigArg::Delegate(_) + | ConfigArg::Theme(_) + | ConfigArg::Density(_) + | ConfigArg::Mouse(_) => {} ConfigArg::Invalid(m) => { self.push(Line::styled(m, Style::default().fg(crate::theme::theme().warning))); } diff --git a/crates/hi-tui/src/app/run.rs b/crates/hi-tui/src/app/run.rs index f186601..25bbff8 100644 --- a/crates/hi-tui/src/app/run.rs +++ b/crates/hi-tui/src/app/run.rs @@ -1210,7 +1210,7 @@ pub async fn run(agent: &mut Agent, options: crate::RunOptions) -> Result<()> { // to re-run in the turn phase below. let mut restore_model_state: Option = None; let mut restore_app_model: Option<(String, Option)> = None; - let run_line = if let Some(cmd) = command::parse(&line) { + let run_line = if let Some(cmd) = command::parse(&line).map(command::resolve_command) { match cmd { Command::Quit => break, Command::Prompt(prompt) => prompt, diff --git a/crates/hi-tui/src/completion.rs b/crates/hi-tui/src/completion.rs index 2b9fc38..7689023 100644 --- a/crates/hi-tui/src/completion.rs +++ b/crates/hi-tui/src/completion.rs @@ -100,17 +100,41 @@ pub(crate) fn completion_context(input: &str) -> Option { prefix: remainder.to_lowercase(), }); } + // Nested `/config …` keeps completing the key until a full + // key is chosen; values after that are freeform or handled by the + // rewritten bare command. + if (spec.name == "config" || spec.name == "cfg" || spec.name == "set") + && arg.contains(char::is_whitespace) + { + return None; + } if arg.contains(char::is_whitespace) { return None; // a second token — past the single argument } let prefix = arg.to_lowercase(); if !spec.arg_values.is_empty() { - // A static enumerable set (compact, copy, verify, goal). - if spec.arg_values.iter().any(|(v, _)| *v == prefix) { + // A static enumerable set (compact, copy, verify, goal, config). + // For `/config`, keep the menu open after a full key so the user + // can still Tab-accept and type a value (`/config lsp `). + let keep_open_after_key = spec.name == "config" + || spec.name == "cfg" + || spec.name == "set"; + if !keep_open_after_key && spec.arg_values.iter().any(|(v, _)| *v == prefix) { return None; // a full valid value is typed — nothing left to pick } + if keep_open_after_key && spec.arg_values.iter().any(|(v, _)| *v == prefix) { + // Full key chosen — leave a trailing space via insert path. + return Some(CompletionContext::Arg { + cmd: "config", + prefix, + }); + } return Some(CompletionContext::Arg { - cmd: spec.name, + cmd: if spec.name == "cfg" || spec.name == "set" { + "config" + } else { + spec.name + }, prefix, }); } @@ -177,23 +201,43 @@ pub(crate) fn completion_items_for(ctx: &CompletionContext) -> Vec command::arg_matching(cmd, prefix) .into_iter() - .map(|(value, hint)| CompletionItem { - label: value.to_string(), - help: hint.to_string(), - insert: if *cmd == SESSIONS_CMD + .map(|(value, hint)| { + let config_key_needs_value = *cmd == "config" && matches!( value, - "switch" | "rename" | "favorite" | "archive" | "restore" | "delete" - ) { - format!("/{cmd} {value} ") - } else { - format!("/{cmd} {value}") - }, - submit_on_enter: !(*cmd == SESSIONS_CMD + "model" + | "provider" + | "auth" + | "reasoning" + | "temp" + | "steps" + | "verify" + | "lsp" + | "delegate" + | "moe-streaming" + | "skeptic-local" + | "rsi" + | "ui" + | "theme" + | "density" + | "mouse" + ); + let sessions_needs_id = *cmd == SESSIONS_CMD && matches!( value, "switch" | "rename" | "favorite" | "archive" | "restore" | "delete" - )), + ); + let needs_more = config_key_needs_value || sessions_needs_id; + CompletionItem { + label: value.to_string(), + help: hint.to_string(), + insert: if needs_more { + format!("/{cmd} {value} ") + } else { + format!("/{cmd} {value}") + }, + submit_on_enter: !needs_more, + } }) .collect(), // Path completion is resolved against the workspace root in @@ -234,6 +278,23 @@ mod tests { prefix: "hy".to_string() }) ); + // Settings hub: first key is enumerable under /config. + assert_eq!( + completion_context("/config "), + Some(Arg { + cmd: "config", + prefix: String::new() + }) + ); + assert_eq!( + completion_context("/config ls"), + Some(Arg { + cmd: "config", + prefix: "ls".to_string() + }) + ); + // Nested value after a full key closes the static menu. + assert_eq!(completion_context("/config lsp on"), None); // The single-keyword commands and the dynamic model command, too. assert_eq!( completion_context("/verify "),