Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/hi-agent/src/agent/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,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<String>) {
self.rsi_observe.managed_context = context;
self.rsi_observe.set_managed_context(context);
}

/// A cloneable handle for a frontend to push user messages typed while a
Expand Down Expand Up @@ -1515,7 +1515,7 @@ impl crate::Agent {
}

pub fn set_last_rsi_fully_observed(&mut self, observed: Option<bool>) {
self.rsi_observe.last_fully_observed = observed;
self.rsi_observe.set_last_fully_observed(observed);
}

pub(crate) fn persist(&mut self) -> Result<()> {
Expand Down
2 changes: 1 addition & 1 deletion crates/hi-agent/src/agent/turn/loop_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ impl crate::Agent {
context_task.clone()
};
let input = turn_input.as_str();
let model_turn_input = match self.rsi_observe.managed_context.as_deref() {
let model_turn_input = match self.rsi_observe.take_managed_context() {
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}"
),
Expand Down
49 changes: 49 additions & 0 deletions crates/hi-agent/src/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,52 @@ pub(crate) struct RsiObserveState {
/// Validated worker-provided conversation reference for managed RSI.
pub(crate) managed_context: Option<String>,
}

impl RsiObserveState {
/// Record whether the latest turn was fully observed by the frontend.
pub(crate) fn set_last_fully_observed(&mut self, observed: Option<bool>) {
self.last_fully_observed = observed;
}

/// Install or clear the validated managed-RSI conversation reference.
pub(crate) fn set_managed_context(&mut self, context: Option<String>) {
self.managed_context = context.filter(|s| !s.trim().is_empty());
}

/// Take the managed context for one-shot injection (clears the slot).
pub(crate) fn take_managed_context(&mut self) -> Option<String> {
self.managed_context.take()
}
}

/// Per-turn control flags shared across Model / Tools / Steer.
///
/// Not stored on [`crate::Agent`] — constructed at turn start and passed through
/// the phase helpers so the turn loop does not grow an ever-longer local list
/// without a name. Field projection keeps call sites direct.
#[derive(Clone, Debug, Default)]
#[allow(dead_code)] // wired into loop in a follow-up
pub(crate) struct TurnControlFlags {
pub force_tools_next: bool,
pub text_tool_fallback_next: bool,
pub force_text_answer_next: bool,
pub force_no_progress_final_answer_next: bool,
pub suppress_bookkeeping_tools_next: bool,
pub made_tool_call: bool,
pub stalled_repeating: bool,
pub stalled_unfinished: bool,
pub ended_at_cap: bool,
pub obligation_nudge_fired: bool,
}

impl TurnControlFlags {
/// Clear one-shot force flags that apply only to the next Model request.
#[allow(dead_code)] // available for loop flag bag adoption
pub(crate) fn clear_one_shot_forces(&mut self) {
self.force_tools_next = false;
self.text_tool_fallback_next = false;
self.force_text_answer_next = false;
self.force_no_progress_final_answer_next = false;
self.suppress_bookkeeping_tools_next = false;
}
}
70 changes: 70 additions & 0 deletions crates/hi-agent/src/steering/intent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -634,3 +634,73 @@ pub(crate) fn implementation_turn_prompt(input: &str, intent: ImplementationInte
}
format!("{input}\n\n{}", rules.join("\n"))
}



#[cfg(test)]
mod golden_table {
use super::*;
use crate::steering::types::ReviewIntent;

/// Frozen prompt → intent pairs. Prefer `/macro` expansions and phrases already
/// proven in `tests/steering.rs` so this table tracks real classifier gates.
#[test]
fn read_only_intent_golden_table() {
let cases: &[(&str, Option<ReviewIntent>)] = &[
("status", None),
("fix the unsafe unwraps", None),
("review codebase and discuss status and state", None),
(
"review this code for auth leaks but do not edit",
Some(ReviewIntent::Security),
),
(
"Review this codebase for issues related to ipop/coder-balanced API routing or latency. Use at most 4 file inspections. Do not modify files. Return concise findings only.",
Some(ReviewIntent::Review),
),
];
for (prompt, want) in cases {
assert_eq!(
classify_read_only_intent(prompt),
*want,
"read-only classify failed for {prompt:?}"
);
}
}

#[test]
fn implementation_intent_golden_table() {
let build_macro = "Build a small helper.

Implementation requirements
Inspect the workspace before editing.
Expected to edit files and run verification.";
// Expanded /build macro shape (see expanded_build_macro_request).
let expanded = "build foo implementation requirements inspect the workspace before you edit files";
assert!(
classify_implementation_intent(expanded).is_some()
|| classify_implementation_intent(build_macro).is_some()
|| classify_implementation_intent(
"Implementation task: expected to edit files and run the verification command"
)
.is_some(),
"at least one known implementation shape should classify"
);
assert!(
classify_implementation_intent("keep building the feature").is_some(),
"natural continuation should classify"
);
for prompt in [
"what is the status?",
"review only, do not change code",
"discuss the architecture",
"status",
] {
assert_eq!(
classify_implementation_intent(prompt),
None,
"expected no implementation intent for {prompt:?}"
);
}
}
}
53 changes: 53 additions & 0 deletions crates/hi-agent/src/steering/review_repair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,56 @@ pub(crate) fn compact_review_repair_label(label: &str) -> String {
}
.to_string()
}

/// Text-only Steer quality-repair cascade order (after unfinished/plan and
/// implementation-completeness gates). Keep this list aligned with
/// `steer/review.rs` — tests freeze the order so a casual reorder fails loudly.
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) const REVIEW_QUALITY_CASCADE: &[ReviewRepairMode] = &[
ReviewRepairMode::NoEvidence,
ReviewRepairMode::InspectedDisclaimer,
ReviewRepairMode::InspectedDisclaimerChatAttempt,
ReviewRepairMode::GenericTemplate,
ReviewRepairMode::ListingOnly,
ReviewRepairMode::ReadAfterSearch,
ReviewRepairMode::SecurityBroadSearch,
ReviewRepairMode::SecurityScope,
ReviewRepairMode::GapSearchOverclaim,
ReviewRepairMode::ConcreteAnswer,
];

#[cfg(test)]
mod cascade_tests {
use super::*;

#[test]
fn quality_cascade_is_unique_and_covers_known_modes() {
let mut seen = std::collections::BTreeSet::new();
for mode in REVIEW_QUALITY_CASCADE {
assert!(seen.insert(mode.key()), "duplicate cascade entry {}", mode.key());
assert!(
ReviewRepairMode::ALL.contains(mode),
"{} missing from ReviewRepairMode::ALL",
mode.key()
);
}
// Disclaimer family shares exhaustion key but remains distinct cascade steps.
assert!(REVIEW_QUALITY_CASCADE.contains(&ReviewRepairMode::InspectedDisclaimer));
assert!(REVIEW_QUALITY_CASCADE.contains(&ReviewRepairMode::InspectedDisclaimerChatAttempt));
}

#[test]
fn cascade_runs_no_evidence_before_concrete_and_security_before_gap() {
let idx = |m: ReviewRepairMode| {
REVIEW_QUALITY_CASCADE
.iter()
.position(|x| *x == m)
.expect("mode in cascade")
};
assert!(idx(ReviewRepairMode::NoEvidence) < idx(ReviewRepairMode::ConcreteAnswer));
assert!(idx(ReviewRepairMode::ReadAfterSearch) < idx(ReviewRepairMode::ConcreteAnswer));
assert!(idx(ReviewRepairMode::SecurityBroadSearch) < idx(ReviewRepairMode::SecurityScope));
assert!(idx(ReviewRepairMode::SecurityScope) < idx(ReviewRepairMode::GapSearchOverclaim));
assert!(idx(ReviewRepairMode::ListingOnly) < idx(ReviewRepairMode::ConcreteAnswer));
}
}
11 changes: 11 additions & 0 deletions crates/hi-tools/src/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,15 @@ mod tests {
assert!(blocked_op(cmd).is_none(), "should allow: {cmd}");
}
}

#[test]
fn catastrophic_table_pins_force_push_and_allows_scoped_clean() {
assert!(catastrophic_op("git push --force origin main").is_some());
assert!(catastrophic_op("git push -f origin HEAD:main").is_some());
assert!(catastrophic_op("git push origin +main").is_some());
// Scoped build artifacts remain allowed.
assert!(catastrophic_op("rm -rf ./target").is_none());
assert!(catastrophic_op("rm -rf node_modules").is_none());
assert!(blocked_op("cargo test").is_none());
}
}
Loading
Loading