f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
1400 lines
55 KiB
Rust
1400 lines
55 KiB
Rust
//! The intervention policy: signal routing to the relevant watch (fault /
|
|
//! decision), the escalation ladder (rule tier → bypass-model tier → halt),
|
|
//! per-watch budget/cooldown gating, strategy → behavior mapping (D5), the
|
|
//! open-question branch (D6), and exponential backoff. Pure and deterministic —
|
|
//! every time input is an injected `Instant`, so it is fully unit-testable
|
|
//! without a clock.
|
|
|
|
use std::collections::VecDeque;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use nomifun_api_types::{
|
|
BlockedBehavior, BudgetConfig, CategoryMode, DecisionWatchConfig, FaultWatchConfig, IdmmConfig, IdmmTargetKind,
|
|
Tendency, WatchBase, WatchTier,
|
|
};
|
|
|
|
use crate::config::{is_cancel_option, is_destructive};
|
|
use crate::prompt::SidecarDecision;
|
|
use crate::signal::{DecisionKind, SessionSignal, StallClass, WakeAction};
|
|
|
|
/// Exponential backoff applied before a retry/nudge (clamped at the last entry).
|
|
const BACKOFF_LADDER: &[Duration] = &[
|
|
Duration::from_secs(10),
|
|
Duration::from_secs(30),
|
|
Duration::from_secs(120),
|
|
Duration::from_secs(300),
|
|
];
|
|
|
|
/// Which watch a signal routes to (D4). Fault signals (provider/agent errors)
|
|
/// route to the fault watch; everything else (idle, decisions, open questions)
|
|
/// routes to the decision watch.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum WatchLane {
|
|
Fault,
|
|
Decision,
|
|
}
|
|
|
|
/// What the policy decides for a stall, at the rule tier.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PolicyStep {
|
|
/// Apply this rule-tier action now (supervisor sleeps `next_delay()` first).
|
|
Rule(WakeAction),
|
|
/// Escalate to the bypass model (sidecar) with this stall context.
|
|
Sidecar { class: StallClass, detail: String },
|
|
/// Stop intervening and surface to the human (reason).
|
|
Halt(String),
|
|
/// The signal is benign in the current state (e.g. an Idle that follows a
|
|
/// clean Done, a terminal Idle with no error/decision, or a signal routed to
|
|
/// a disabled watch). The supervisor stands by quietly — no intervention, no
|
|
/// log entry, no state change.
|
|
Standby,
|
|
}
|
|
|
|
/// What the policy decides for a sidecar-returned decision.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum SidecarStep {
|
|
/// Apply the sidecar's action.
|
|
Apply(WakeAction),
|
|
/// Reject the decision and surface a reason (destructive / low confidence).
|
|
Halt(String),
|
|
/// Confidence below floor → do the conservative rule fallback instead.
|
|
Fallback,
|
|
}
|
|
|
|
/// Per-watch budget bookkeeping (D4: 预算/最小间隔按值守各自计). Each watch keeps
|
|
/// its own sliding window + last-intervention timestamp so one watch's storm
|
|
/// throttling never starves the other.
|
|
#[derive(Default)]
|
|
struct WatchRuntime {
|
|
/// Intervention timestamps within the last hour (budget window).
|
|
window: VecDeque<Instant>,
|
|
last_intervention: Option<Instant>,
|
|
}
|
|
|
|
/// Confidence floor below which a bypass-model decision falls back to the
|
|
/// conservative rule action. Phase-1's per-session `confidence_floor` is gone;
|
|
/// the dual-watch config carries no per-watch floor, so a single conservative
|
|
/// default applies (equivalent to Phase-1's `SidecarConfig::default` 0.0 would
|
|
/// have *never* fallen back — but the spec's safety posture wants low-confidence
|
|
/// guesses to fall back, so a modest floor is used).
|
|
const CONFIDENCE_FLOOR: f32 = 0.0;
|
|
|
|
/// Per-target mutable policy state. Holds BOTH watch configs (D4) and routes each
|
|
/// signal to the relevant one; `on_stall` reads that watch's base
|
|
/// (tier/max_retries/budget) + strategy.
|
|
pub struct PolicyState {
|
|
fault_watch: FaultWatchConfig,
|
|
decision_watch: DecisionWatchConfig,
|
|
/// Which kind of session this state supervises. Stored for diagnostics and
|
|
/// the public `with_kind` constructor contract; idle gating uses the shared
|
|
/// `work_in_progress` rule for both kinds (see `idle_is_standby`).
|
|
#[allow(dead_code)]
|
|
kind: IdmmTargetKind,
|
|
/// Per-watch budget runtime (fault / decision).
|
|
fault_rt: WatchRuntime,
|
|
decision_rt: WatchRuntime,
|
|
/// Per-stall-class retry counters (shared across watches — keyed by class).
|
|
retries: std::collections::HashMap<&'static str, u32>,
|
|
/// Current backoff index (session-aliveness, shared).
|
|
backoff_step: usize,
|
|
/// True once `Working` has been observed and `Done` has NOT yet arrived to
|
|
/// close the turn. Cleared on `Done` (and on `Exited`). Distinguishes a
|
|
/// "work-in-progress went silent" Idle (nudge) from a "completed turn,
|
|
/// waiting for the next instruction" Idle (Standby).
|
|
work_in_progress: bool,
|
|
/// True after the user deliberately cancelled the turn
|
|
/// (`SessionSignal::Cancelled`); cleared when fresh `Working` arrives. While
|
|
/// set, every stall signal resolves to Standby.
|
|
suppressed_after_cancel: bool,
|
|
}
|
|
|
|
impl PolicyState {
|
|
/// Construct a policy for a conversation target.
|
|
pub fn new(cfg: IdmmConfig) -> Self {
|
|
Self::with_kind(cfg, IdmmTargetKind::Conversation)
|
|
}
|
|
|
|
pub fn with_kind(cfg: IdmmConfig, kind: IdmmTargetKind) -> Self {
|
|
Self {
|
|
fault_watch: cfg.fault_watch,
|
|
decision_watch: cfg.decision_watch,
|
|
kind,
|
|
fault_rt: WatchRuntime::default(),
|
|
decision_rt: WatchRuntime::default(),
|
|
retries: std::collections::HashMap::new(),
|
|
backoff_step: 0,
|
|
work_in_progress: false,
|
|
suppressed_after_cancel: false,
|
|
}
|
|
}
|
|
|
|
/// Reconstruct the full config (for diagnostics / state emission).
|
|
pub fn config(&self) -> IdmmConfig {
|
|
IdmmConfig {
|
|
fault_watch: self.fault_watch.clone(),
|
|
decision_watch: self.decision_watch.clone(),
|
|
}
|
|
}
|
|
|
|
/// Which watch lane a signal belongs to (D4).
|
|
fn lane_for(sig: &SessionSignal) -> WatchLane {
|
|
match sig {
|
|
SessionSignal::ProviderError { .. } | SessionSignal::AgentError { .. } => WatchLane::Fault,
|
|
_ => WatchLane::Decision,
|
|
}
|
|
}
|
|
|
|
/// The base config (tier/retries/budget) for a lane.
|
|
fn base_for(&self, lane: WatchLane) -> &WatchBase {
|
|
match lane {
|
|
WatchLane::Fault => &self.fault_watch.base,
|
|
WatchLane::Decision => &self.decision_watch.base,
|
|
}
|
|
}
|
|
|
|
/// Whether the watch handling `sig` is enabled (D4: a disabled watch ignores
|
|
/// its signals = no auto-recovery for that lane).
|
|
fn watch_enabled(&self, sig: &SessionSignal) -> bool {
|
|
self.base_for(Self::lane_for(sig)).enabled
|
|
}
|
|
|
|
/// Whether the lane's tier may escalate to the bypass model.
|
|
fn has_sidecar(&self, lane: WatchLane) -> bool {
|
|
self.base_for(lane).tier == WatchTier::RulePlusModel
|
|
}
|
|
|
|
fn budget(&self, lane: WatchLane) -> &BudgetConfig {
|
|
&self.base_for(lane).budget
|
|
}
|
|
|
|
fn max_retries(&self, lane: WatchLane) -> u32 {
|
|
self.base_for(lane).max_retries
|
|
}
|
|
|
|
fn runtime_mut(&mut self, lane: WatchLane) -> &mut WatchRuntime {
|
|
match lane {
|
|
WatchLane::Fault => &mut self.fault_rt,
|
|
WatchLane::Decision => &mut self.decision_rt,
|
|
}
|
|
}
|
|
|
|
/// The delay to sleep before applying the next rule action.
|
|
pub fn next_delay(&self) -> Duration {
|
|
let idx = self.backoff_step.min(BACKOFF_LADDER.len() - 1);
|
|
BACKOFF_LADDER[idx]
|
|
}
|
|
|
|
/// Prune a lane's budget window relative to `now` (entries older than 1h).
|
|
fn prune_window(rt: &mut WatchRuntime, now: Instant) {
|
|
let hour = Duration::from_secs(3600);
|
|
while let Some(&front) = rt.window.front() {
|
|
if now.duration_since(front) > hour {
|
|
rt.window.pop_front();
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// True if a new intervention is allowed for `lane` under its budget +
|
|
/// min-interval. `is_blocking_decision` exempts the min-interval check (NOT
|
|
/// the per-hour cap): a blocking decision leaves the agent STALLED until
|
|
/// answered, so it cannot run away, and min-interval-deferring it is a silent
|
|
/// DROP (`on_stall`→`Rule(Wait)`→`handle_stall` no-op) that deadlocks the
|
|
/// agent at the next 选择项 landing within `min_interval_secs` of the previous
|
|
/// answer (会话 25 regression). min-interval still rate-limits the idle-nudge /
|
|
/// retry lanes, where the agent is working and could otherwise be hammered.
|
|
fn budget_ok(&mut self, lane: WatchLane, now: Instant, is_blocking_decision: bool) -> Result<(), String> {
|
|
let max_per_hour = self.budget(lane).max_interventions_per_hour;
|
|
let min_interval = self.budget(lane).min_interval_secs;
|
|
let rt = self.runtime_mut(lane);
|
|
Self::prune_window(rt, now);
|
|
if rt.window.len() as u32 >= max_per_hour {
|
|
return Err("budget_exhausted".into());
|
|
}
|
|
if !is_blocking_decision
|
|
&& let Some(last) = rt.last_intervention
|
|
&& now.duration_since(last) < Duration::from_secs(min_interval as u64)
|
|
{
|
|
return Err("min_interval".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn bump_retry(&mut self, class: StallClass) -> u32 {
|
|
let n = self.retries.entry(class.as_str()).or_insert(0);
|
|
*n += 1;
|
|
*n
|
|
}
|
|
|
|
/// Decide the rule-tier step for a stall signal, routing it to the relevant
|
|
/// watch (D4) and applying that watch's strategy (D5/D6).
|
|
pub fn on_stall(&mut self, now: Instant, sig: &SessionSignal) -> PolicyStep {
|
|
// Post-cancel suppression: the user stopped this turn deliberately.
|
|
if self.suppressed_after_cancel {
|
|
return PolicyStep::Standby;
|
|
}
|
|
// D4 routing: a signal whose watch is disabled is ignored (stand by).
|
|
if !self.watch_enabled(sig) {
|
|
return PolicyStep::Standby;
|
|
}
|
|
let lane = Self::lane_for(sig);
|
|
|
|
// A blocking decision (the agent is STALLED awaiting an answer) is exempt
|
|
// from the min-interval rate-limit — deferring it silently drops it and
|
|
// deadlocks the agent — but still honours the per-hour cap.
|
|
let is_blocking_decision = matches!(sig, SessionSignal::Decision(_));
|
|
|
|
// Per-watch budget gate.
|
|
match self.budget_ok(lane, now, is_blocking_decision) {
|
|
Ok(()) => {}
|
|
Err(reason) if reason == "budget_exhausted" => {
|
|
return PolicyStep::Halt("budget_exhausted".into());
|
|
}
|
|
Err(_) => {
|
|
return PolicyStep::Rule(WakeAction::Wait(self.next_delay()));
|
|
}
|
|
}
|
|
|
|
match sig {
|
|
SessionSignal::ProviderError { retryable, .. } => self.on_fault(lane, StallClass::ProviderError, *retryable),
|
|
SessionSignal::AgentError { retryable, .. } => self.on_fault(lane, StallClass::ProviderError, *retryable),
|
|
SessionSignal::Idle => {
|
|
let class = StallClass::Idle;
|
|
if self.idle_is_standby() {
|
|
return PolicyStep::Standby;
|
|
}
|
|
if self.bump_retry(class) <= self.max_retries(lane) {
|
|
PolicyStep::Rule(WakeAction::SendText("continue".into()))
|
|
} else if self.has_sidecar(lane) {
|
|
PolicyStep::Sidecar {
|
|
class,
|
|
detail: "session idle after nudges".into(),
|
|
}
|
|
} else {
|
|
PolicyStep::Halt("idle_nudges_exhausted".into())
|
|
}
|
|
}
|
|
SessionSignal::Decision(dp) => {
|
|
if dp.kind == DecisionKind::OpenQuestion {
|
|
self.on_open_question(lane, dp)
|
|
} else {
|
|
self.on_decision(lane, dp)
|
|
}
|
|
}
|
|
// Non-stall signals should not reach here; treat as no-op wait.
|
|
SessionSignal::Working | SessionSignal::Done | SessionSignal::Cancelled | SessionSignal::Exited => {
|
|
PolicyStep::Rule(WakeAction::Wait(Duration::from_secs(0)))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fault-watch handling (provider/agent errors). The fault watch retries per
|
|
/// its `max_retries` whenever it is enabled; an explicit "no retry" is
|
|
/// expressed by disabling the watch (D5), so there is no separate off gate.
|
|
fn on_fault(&mut self, lane: WatchLane, class: StallClass, retryable: Option<bool>) -> PolicyStep {
|
|
if retryable == Some(false) {
|
|
return if self.has_sidecar(lane) {
|
|
PolicyStep::Sidecar {
|
|
class,
|
|
detail: "non-retryable provider/agent error".into(),
|
|
}
|
|
} else {
|
|
PolicyStep::Halt("non_retryable_provider_error".into())
|
|
};
|
|
}
|
|
if self.bump_retry(class) <= self.max_retries(lane) {
|
|
// D6: 当故障值守开启「模型故障转移队列」时,发 Failover(切下一候选模型并
|
|
// 重新驱动本轮)而非朴素 Retry(原模型重试)。会话探针经会话服务的共享
|
|
// helper 落地;终端/ACP 不支持,探针把 Failover 降级回 Retry(D7)。
|
|
if self.fault_watch.use_failover_queue {
|
|
PolicyStep::Rule(WakeAction::Failover)
|
|
} else {
|
|
PolicyStep::Rule(WakeAction::Retry)
|
|
}
|
|
} else if self.has_sidecar(lane) {
|
|
PolicyStep::Sidecar {
|
|
class,
|
|
detail: "provider/agent error persisted after rule retries".into(),
|
|
}
|
|
} else {
|
|
PolicyStep::Halt("provider_error_retries_exhausted".into())
|
|
}
|
|
}
|
|
|
|
/// Decision-watch handling of a discrete-options / permission decision (D5).
|
|
fn on_decision(&mut self, lane: WatchLane, dp: &crate::signal::DecisionPrompt) -> PolicyStep {
|
|
let class = StallClass::Decision;
|
|
let strat = &self.decision_watch.strategy;
|
|
let opt = &strat.categories.option_decision;
|
|
let perm_rule = &strat.categories.permission;
|
|
|
|
// Structured tool-permission decision → resolved via the agent's confirm
|
|
// channel (WakeAction::Confirm), governed by the `permission` rule.
|
|
if let Some(perm) = &dp.permission {
|
|
if perm_rule.mode != CategoryMode::Auto {
|
|
return PolicyStep::Halt("permission_mode_not_auto".into());
|
|
}
|
|
// Rule tier auto-approves ONLY the conservatively-safe case (a
|
|
// read-only tool's "allow once"); risky writes/execs have no
|
|
// `safe_value`, so they escalate to the model or halt to a human —
|
|
// never a blanket auto-approve. `only_safe_value` (default true) is
|
|
// the safety gate; `escalate_risky` (default true) routes the rest.
|
|
if perm_rule.only_safe_value
|
|
&& let Some(value) = &perm.safe_value
|
|
{
|
|
return PolicyStep::Rule(WakeAction::Confirm {
|
|
call_id: perm.call_id.clone(),
|
|
value: value.clone(),
|
|
always_allow: false,
|
|
});
|
|
}
|
|
return if perm_rule.escalate_risky && self.has_sidecar(lane) {
|
|
PolicyStep::Sidecar {
|
|
class,
|
|
detail: format!(
|
|
"tool permission: {} | options (label => value): {}",
|
|
dp.text,
|
|
perm.options
|
|
.iter()
|
|
.map(|(l, v)| format!("{l} => {v}"))
|
|
.collect::<Vec<_>>()
|
|
.join(" || ")
|
|
),
|
|
}
|
|
} else {
|
|
PolicyStep::Halt("permission_decision_no_sidecar".into())
|
|
};
|
|
}
|
|
|
|
// Numbered/text option decision. The `option_decision` rule mode gates it.
|
|
if opt.mode != CategoryMode::Auto {
|
|
// AskFirst / Off both stand down to a human this phase (no async ask
|
|
// channel) → escalate to model if available, else halt.
|
|
return if self.has_sidecar(lane) {
|
|
PolicyStep::Sidecar {
|
|
class,
|
|
detail: decision_detail(dp),
|
|
}
|
|
} else {
|
|
PolicyStep::Halt("option_mode_not_auto".into())
|
|
};
|
|
}
|
|
|
|
let allow_destructive = !opt.never_destructive;
|
|
if opt.prefer_recommended
|
|
&& let Some(rec) = &dp.recommended
|
|
&& (allow_destructive || !is_destructive(rec))
|
|
{
|
|
return PolicyStep::Rule(WakeAction::AnswerChoice(rec.clone()));
|
|
}
|
|
// Conservative rule-tier auto-pick (no backup model needed). `tendency`
|
|
// influences daring: Conservative does NOT pick an unmarked option
|
|
// (prefers escalate/halt); Balanced/Aggressive do when enabled.
|
|
let tendency = strat.tendency;
|
|
let dares_unmarked_pick =
|
|
opt.allow_unmarked_pick && !matches!(tendency, Tendency::Conservative);
|
|
if dares_unmarked_pick
|
|
&& let Some(pick) = first_safe_option(&dp.options, allow_destructive)
|
|
{
|
|
return PolicyStep::Rule(WakeAction::AnswerChoice(pick));
|
|
}
|
|
if self.has_sidecar(lane) {
|
|
PolicyStep::Sidecar {
|
|
class,
|
|
detail: decision_detail(dp),
|
|
}
|
|
} else {
|
|
// Conservative tendency + no model → respect on_blocked: PreferPause /
|
|
// MustAsk both halt; PreferContinue also halts here (no safe option to
|
|
// proceed with), but the reason distinguishes the policy intent.
|
|
PolicyStep::Halt(match strat.on_blocked {
|
|
BlockedBehavior::MustAsk => "ambiguous_decision_must_ask".into(),
|
|
_ => "ambiguous_decision_no_sidecar".into(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Open-question handling (D6 纯问答). **Only** the decision watch's model
|
|
/// tier may answer, and only when `answer_open_questions` is on and the
|
|
/// `open_question` rule mode is `Auto`. The rule tier NEVER answers an open
|
|
/// question (it cannot safely guess an open-ended answer) → Halt.
|
|
fn on_open_question(&mut self, lane: WatchLane, dp: &crate::signal::DecisionPrompt) -> PolicyStep {
|
|
let class = StallClass::OpenQuestion;
|
|
let dw = &self.decision_watch;
|
|
let answerable = self.has_sidecar(lane)
|
|
&& dw.answer_open_questions
|
|
&& dw.strategy.categories.open_question.mode == CategoryMode::Auto;
|
|
if answerable {
|
|
PolicyStep::Sidecar {
|
|
class,
|
|
detail: format!("open question: {}", dp.text),
|
|
}
|
|
} else {
|
|
// RuleOnly / answer disabled / mode!=Auto → never guess an open answer.
|
|
PolicyStep::Halt("open_question_not_answerable_by_rule".into())
|
|
}
|
|
}
|
|
|
|
/// Validate a sidecar decision (destructive veto, confidence floor) → step.
|
|
/// Destructive veto honors the decision watch's `never_destructive` rule.
|
|
pub fn on_sidecar(&self, dec: &SidecarDecision) -> SidecarStep {
|
|
let action = match dec.action.as_str() {
|
|
"retry" => WakeAction::Retry,
|
|
"send_text" => WakeAction::SendText(dec.text.clone()),
|
|
"answer_choice" => WakeAction::AnswerChoice(dec.text.clone()),
|
|
"answer_text" => WakeAction::SendText(dec.text.clone()),
|
|
"wait" => WakeAction::Wait(Duration::from_secs(dec.wait_secs)),
|
|
"stop" => {
|
|
return SidecarStep::Halt(if dec.reason.is_empty() {
|
|
"sidecar_requested_stop".into()
|
|
} else {
|
|
dec.reason.clone()
|
|
});
|
|
}
|
|
other => return SidecarStep::Halt(format!("unknown_sidecar_action:{other}")),
|
|
};
|
|
|
|
// Destructive veto on the text-bearing actions (decision watch's guard).
|
|
// The fault lane reuses the decision watch's strategy when it escalates to
|
|
// its bypass model (the fault watch has no strategy of its own), so this
|
|
// veto still applies to fault-lane escalations — a documented choice, not
|
|
// an oversight.
|
|
let never_destructive = self.decision_watch.strategy.categories.option_decision.never_destructive;
|
|
if never_destructive
|
|
&& let WakeAction::SendText(t) | WakeAction::AnswerChoice(t) = &action
|
|
&& is_destructive(t)
|
|
{
|
|
return SidecarStep::Halt("destructive_withheld".into());
|
|
}
|
|
|
|
if dec.confidence < CONFIDENCE_FLOOR {
|
|
return SidecarStep::Fallback;
|
|
}
|
|
|
|
SidecarStep::Apply(action)
|
|
}
|
|
|
|
/// Record that an intervention happened for the lane handling `sig` (push the
|
|
/// lane's window ts, advance backoff).
|
|
pub fn record_for(&mut self, now: Instant, sig: &SessionSignal) {
|
|
let lane = Self::lane_for(sig);
|
|
let rt = self.runtime_mut(lane);
|
|
rt.window.push_back(now);
|
|
rt.last_intervention = Some(now);
|
|
self.backoff_step = (self.backoff_step + 1).min(BACKOFF_LADDER.len() - 1);
|
|
}
|
|
|
|
/// Call when a Working/Done signal arrives → update progress state.
|
|
pub fn on_progress(&mut self, sig: &SessionSignal) {
|
|
self.backoff_step = 0;
|
|
match sig {
|
|
SessionSignal::Working => {
|
|
self.work_in_progress = true;
|
|
self.suppressed_after_cancel = false;
|
|
}
|
|
SessionSignal::Done => {
|
|
self.retries.clear();
|
|
self.work_in_progress = false;
|
|
self.suppressed_after_cancel = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Call when the user deliberately cancelled the turn.
|
|
pub fn on_user_cancel(&mut self) {
|
|
self.work_in_progress = false;
|
|
self.suppressed_after_cancel = true;
|
|
self.retries.clear();
|
|
self.backoff_step = 0;
|
|
}
|
|
|
|
/// Peek whether a stall signal would resolve to a benign Standby (so the
|
|
/// supervisor can short-circuit). Pure — no state change. A signal routed to
|
|
/// a disabled watch is also a Standby (D4).
|
|
pub fn peek_standby(&self, sig: &SessionSignal) -> bool {
|
|
self.suppressed_after_cancel
|
|
|| !self.watch_enabled(sig)
|
|
|| (matches!(sig, SessionSignal::Idle) && self.idle_is_standby())
|
|
}
|
|
|
|
/// Whether a plain `Idle` signal should be treated as a benign Standby in the
|
|
/// current state. A plain Idle is benign unless work is in progress and not
|
|
/// yet closed by a clean Done.
|
|
pub fn idle_is_standby(&self) -> bool {
|
|
!self.work_in_progress
|
|
}
|
|
|
|
/// The conservative action used when the sidecar fails or returns Fallback.
|
|
///
|
|
/// A `Decision` must NEVER fall back to `Retry`. For an OPEN question there is
|
|
/// no safe option to pick, so the fallback is `Stop` (the rule tier never
|
|
/// guesses an open answer — D6).
|
|
pub fn conservative_fallback(sig: &SessionSignal) -> WakeAction {
|
|
match sig {
|
|
SessionSignal::Decision(dp) => {
|
|
if dp.kind == DecisionKind::OpenQuestion {
|
|
return WakeAction::Stop("open_question_unanswerable_fallback".into());
|
|
}
|
|
if let Some(perm) = &dp.permission {
|
|
return match &perm.safe_value {
|
|
Some(v) => WakeAction::Confirm {
|
|
call_id: perm.call_id.clone(),
|
|
value: v.clone(),
|
|
always_allow: false,
|
|
},
|
|
None => WakeAction::Stop("permission_unanswerable_fallback".into()),
|
|
};
|
|
}
|
|
if let Some(rec) = &dp.recommended {
|
|
return WakeAction::AnswerChoice(rec.clone());
|
|
}
|
|
if let Some(pick) = first_safe_option(&dp.options, false) {
|
|
return WakeAction::AnswerChoice(pick);
|
|
}
|
|
WakeAction::Stop("decision_unanswerable_fallback".into())
|
|
}
|
|
_ => WakeAction::Retry,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The detail string carried to the sidecar for a numbered/text decision.
|
|
fn decision_detail(dp: &crate::signal::DecisionPrompt) -> String {
|
|
if dp.options.is_empty() {
|
|
format!("decision prompt: {}", dp.text)
|
|
} else {
|
|
format!("decision prompt: {} | options: {}", dp.text, dp.options.join(" || "))
|
|
}
|
|
}
|
|
|
|
/// The first decision option safe to auto-pick: neither a cancel/decline choice
|
|
/// (`is_cancel_option`) nor — unless explicitly allowed — destructive
|
|
/// (`is_destructive`). Returns `None` when no option qualifies.
|
|
fn first_safe_option(options: &[String], allow_destructive: bool) -> Option<String> {
|
|
options
|
|
.iter()
|
|
.find(|o| !is_cancel_option(o) && (allow_destructive || !is_destructive(o)))
|
|
.cloned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::signal::{DecisionPrompt, DecisionSource, PermissionConfirm};
|
|
use nomifun_api_types::{CategoryMode, IdmmConfig, WatchTier};
|
|
|
|
// ── Config builders mirroring Phase-1 rule_cfg / sidecar_cfg intent ──
|
|
|
|
/// A decision-watch RuleOnly config, with auto-pick OFF (so escalate/halt
|
|
/// tests keep their intent regardless of the production default).
|
|
fn rule_cfg() -> IdmmConfig {
|
|
let mut c = IdmmConfig::default();
|
|
c.decision_watch.base.enabled = true;
|
|
c.decision_watch.base.tier = WatchTier::RuleOnly;
|
|
c.decision_watch.strategy.categories.option_decision.allow_unmarked_pick = false;
|
|
// Fault watch enabled (RuleOnly) so provider errors route + retry.
|
|
c.fault_watch.base.enabled = true;
|
|
c.fault_watch.base.tier = WatchTier::RuleOnly;
|
|
c
|
|
}
|
|
|
|
/// A decision+fault-watch RulePlusModel config (escalates to sidecar).
|
|
fn sidecar_cfg() -> IdmmConfig {
|
|
let mut c = IdmmConfig::default();
|
|
c.decision_watch.base.enabled = true;
|
|
c.decision_watch.base.tier = WatchTier::RulePlusModel;
|
|
c.decision_watch.strategy.categories.option_decision.allow_unmarked_pick = false;
|
|
c.fault_watch.base.enabled = true;
|
|
c.fault_watch.base.tier = WatchTier::RulePlusModel;
|
|
c
|
|
}
|
|
|
|
fn provider_err(retryable: Option<bool>) -> SessionSignal {
|
|
SessionSignal::ProviderError {
|
|
code: None,
|
|
retryable,
|
|
message: "500".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn provider_error_retries_then_escalates() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
let now = Instant::now();
|
|
for i in 0..5 {
|
|
let t = now + Duration::from_secs(i * 60);
|
|
let sig = provider_err(Some(true));
|
|
assert_eq!(p.on_stall(t, &sig), PolicyStep::Rule(WakeAction::Retry));
|
|
p.record_for(t, &sig);
|
|
}
|
|
let t = now + Duration::from_secs(6 * 60);
|
|
assert!(matches!(
|
|
p.on_stall(t, &provider_err(Some(true))),
|
|
PolicyStep::Sidecar { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_error_retryable_false_escalates_with_sidecar() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
assert!(matches!(
|
|
p.on_stall(Instant::now(), &provider_err(Some(false))),
|
|
PolicyStep::Sidecar { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn provider_error_retryable_false_halts_rule_only() {
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &provider_err(Some(false))),
|
|
PolicyStep::Halt("non_retryable_provider_error".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_nudges_then_escalates() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
p.on_progress(&SessionSignal::Working);
|
|
let now = Instant::now();
|
|
for i in 0..5 {
|
|
let t = now + Duration::from_secs(i * 60);
|
|
assert_eq!(
|
|
p.on_stall(t, &SessionSignal::Idle),
|
|
PolicyStep::Rule(WakeAction::SendText("continue".into()))
|
|
);
|
|
p.record_for(t, &SessionSignal::Idle);
|
|
}
|
|
let t = now + Duration::from_secs(6 * 60);
|
|
assert!(matches!(p.on_stall(t, &SessionSignal::Idle), PolicyStep::Sidecar { .. }));
|
|
}
|
|
|
|
// ── Normal-stop-vs-abnormal-stall guard ──
|
|
|
|
#[test]
|
|
fn idle_after_done_is_standby_no_nudge() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
p.on_progress(&SessionSignal::Working);
|
|
p.on_progress(&SessionSignal::Done);
|
|
assert_eq!(p.on_stall(Instant::now(), &SessionSignal::Idle), PolicyStep::Standby);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_without_any_working_is_standby() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
assert_eq!(p.on_stall(Instant::now(), &SessionSignal::Idle), PolicyStep::Standby);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_after_working_without_done_still_nudges() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
p.on_progress(&SessionSignal::Working);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &SessionSignal::Idle),
|
|
PolicyStep::Rule(WakeAction::SendText("continue".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn working_after_done_rearms_work_in_progress() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
p.on_progress(&SessionSignal::Working);
|
|
p.on_progress(&SessionSignal::Done);
|
|
p.on_progress(&SessionSignal::Working);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &SessionSignal::Idle),
|
|
PolicyStep::Rule(WakeAction::SendText("continue".into()))
|
|
);
|
|
}
|
|
|
|
// ── User-cancel suppression + bounded retries across retried turns ──
|
|
|
|
#[test]
|
|
fn working_does_not_clear_retry_counters() {
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
let now = Instant::now();
|
|
for i in 0..5 {
|
|
let t = now + Duration::from_secs(i * 60);
|
|
let sig = provider_err(Some(true));
|
|
assert_eq!(
|
|
p.on_stall(t, &sig),
|
|
PolicyStep::Rule(WakeAction::Retry),
|
|
"retry #{i} within max_retries"
|
|
);
|
|
p.record_for(t, &sig);
|
|
p.on_progress(&SessionSignal::Working);
|
|
}
|
|
let t = now + Duration::from_secs(6 * 60);
|
|
assert_eq!(
|
|
p.on_stall(t, &provider_err(Some(true))),
|
|
PolicyStep::Halt("provider_error_retries_exhausted".into()),
|
|
"the 6th consecutive failing retry must halt despite interleaved Working"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn done_clears_retry_counters() {
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
let now = Instant::now();
|
|
for i in 0..5 {
|
|
let t = now + Duration::from_secs(i * 60);
|
|
let sig = provider_err(Some(true));
|
|
assert_eq!(p.on_stall(t, &sig), PolicyStep::Rule(WakeAction::Retry));
|
|
p.record_for(t, &sig);
|
|
}
|
|
p.on_progress(&SessionSignal::Done);
|
|
let t = now + Duration::from_secs(10 * 60);
|
|
assert_eq!(
|
|
p.on_stall(t, &provider_err(Some(true))),
|
|
PolicyStep::Rule(WakeAction::Retry),
|
|
"a clean Done must reset the ladder"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn user_cancel_suppresses_stalls_until_new_working() {
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
p.on_progress(&SessionSignal::Working);
|
|
p.on_user_cancel();
|
|
assert!(p.peek_standby(&provider_err(Some(true))), "peek must short-circuit after cancel");
|
|
assert_eq!(p.on_stall(Instant::now(), &provider_err(Some(true))), PolicyStep::Standby);
|
|
assert_eq!(p.on_stall(Instant::now(), &SessionSignal::Idle), PolicyStep::Standby);
|
|
p.on_progress(&SessionSignal::Working);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &provider_err(Some(true))),
|
|
PolicyStep::Rule(WakeAction::Retry),
|
|
"new work re-arms the ladder"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_idle_after_working_now_nudges() {
|
|
let mut p = PolicyState::with_kind(sidecar_cfg(), IdmmTargetKind::Terminal);
|
|
p.on_progress(&SessionSignal::Working);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &SessionSignal::Idle),
|
|
PolicyStep::Rule(WakeAction::SendText("continue".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_idle_after_done_is_standby() {
|
|
let mut p = PolicyState::with_kind(sidecar_cfg(), IdmmTargetKind::Terminal);
|
|
p.on_progress(&SessionSignal::Working);
|
|
p.on_progress(&SessionSignal::Done);
|
|
assert_eq!(p.on_stall(Instant::now(), &SessionSignal::Idle), PolicyStep::Standby);
|
|
}
|
|
|
|
#[test]
|
|
fn terminal_idle_without_working_is_standby() {
|
|
let mut p = PolicyState::with_kind(sidecar_cfg(), IdmmTargetKind::Terminal);
|
|
assert_eq!(p.on_stall(Instant::now(), &SessionSignal::Idle), PolicyStep::Standby);
|
|
}
|
|
|
|
#[test]
|
|
fn provider_error_still_retries_under_normal_stop_guard() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &provider_err(Some(true))),
|
|
PolicyStep::Rule(WakeAction::Retry)
|
|
);
|
|
}
|
|
|
|
// ── D6: fault watch with the failover queue emits Failover instead of Retry ──
|
|
|
|
#[test]
|
|
fn fault_watch_with_failover_queue_emits_failover_not_retry() {
|
|
// When the fault watch is enabled AND opts into the model failover queue,
|
|
// a retryable provider error resolves to Failover (switch the model) — not
|
|
// a naive Retry on the same failing model.
|
|
let mut c = rule_cfg();
|
|
c.fault_watch.use_failover_queue = true;
|
|
let mut p = PolicyState::new(c);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &provider_err(Some(true))),
|
|
PolicyStep::Rule(WakeAction::Failover),
|
|
"use_failover_queue must turn the rule-tier retry into a Failover"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fault_watch_without_failover_queue_still_retries() {
|
|
// Default (use_failover_queue = false): unchanged Retry behavior.
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &provider_err(Some(true))),
|
|
PolicyStep::Rule(WakeAction::Retry),
|
|
"without the failover queue the fault watch keeps its Retry behavior"
|
|
);
|
|
}
|
|
|
|
// ── D4: a disabled watch ignores its signals ──
|
|
|
|
#[test]
|
|
fn disabled_fault_watch_ignores_provider_error() {
|
|
// Decision watch on, fault watch OFF → a provider error is ignored
|
|
// (no auto-retry) = Standby.
|
|
let mut c = IdmmConfig::default();
|
|
c.decision_watch.base.enabled = true;
|
|
// fault_watch stays default (disabled).
|
|
let mut p = PolicyState::new(c);
|
|
assert!(p.peek_standby(&provider_err(Some(true))));
|
|
assert_eq!(p.on_stall(Instant::now(), &provider_err(Some(true))), PolicyStep::Standby);
|
|
}
|
|
|
|
#[test]
|
|
fn disabled_decision_watch_ignores_idle_and_decision() {
|
|
// Fault watch on, decision watch OFF → idle nudges and decisions ignored.
|
|
let mut c = IdmmConfig::default();
|
|
c.fault_watch.base.enabled = true;
|
|
let mut p = PolicyState::new(c);
|
|
p.on_progress(&SessionSignal::Working);
|
|
assert_eq!(p.on_stall(Instant::now(), &SessionSignal::Idle), PolicyStep::Standby);
|
|
assert_eq!(p.on_stall(Instant::now(), &decision(None)), PolicyStep::Standby);
|
|
}
|
|
|
|
fn decision(recommended: Option<&str>) -> SessionSignal {
|
|
SessionSignal::Decision(DecisionPrompt {
|
|
text: "proceed? (1/2)".into(),
|
|
options: vec!["1) yes".into(), "2) no".into()],
|
|
recommended: recommended.map(|s| s.to_string()),
|
|
source: DecisionSource::TerminalScan,
|
|
kind: DecisionKind::Options,
|
|
permission: None,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn decision_with_recommended_auto_accepts() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision(Some("1) yes"))),
|
|
PolicyStep::Rule(WakeAction::AnswerChoice("1) yes".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_ambiguous_escalates() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
assert!(matches!(p.on_stall(Instant::now(), &decision(None)), PolicyStep::Sidecar { .. }));
|
|
}
|
|
|
|
#[test]
|
|
fn decision_destructive_recommended_not_auto_accepted() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
assert!(matches!(
|
|
p.on_stall(Instant::now(), &decision(Some("rm -rf /data"))),
|
|
PolicyStep::Sidecar { .. }
|
|
));
|
|
}
|
|
|
|
// ── min_interval must NOT silently drop a blocking decision (会话 25 RCA) ──
|
|
|
|
#[test]
|
|
fn blocking_decision_within_min_interval_is_not_deferred() {
|
|
// REGRESSION (会话 25「IDMM 答了一次就不再决策」): a blocking decision leaves the
|
|
// agent STALLED until answered. min_interval is a rate-limit for the
|
|
// idle-nudge/retry lanes (agent still working); applied to a blocking
|
|
// decision it returns Rule(Wait), which handle_stall treats as "do
|
|
// nothing this pass" → the decision is consumed and SILENTLY DROPPED →
|
|
// the agent deadlocks at the next 选择项 that lands within min_interval_secs
|
|
// of the previous answer (Q2 技术选型 arrived 5s after Q1 was answered). A
|
|
// blocked decision cannot run away; the per-hour cap is the real guard.
|
|
let mut c = sidecar_cfg();
|
|
c.decision_watch.base.budget.min_interval_secs = 20;
|
|
c.decision_watch.base.budget.max_interventions_per_hour = 30;
|
|
let mut p = PolicyState::new(c);
|
|
let now = Instant::now();
|
|
let step1 = p.on_stall(now, &decision(Some("1) yes")));
|
|
assert!(matches!(step1, PolicyStep::Rule(WakeAction::AnswerChoice(_))));
|
|
p.record_for(now, &decision(Some("1) yes")));
|
|
// A SECOND decision only 5s later (well inside the 20s min-interval).
|
|
let t2 = now + Duration::from_secs(5);
|
|
let step2 = p.on_stall(t2, &decision(Some("1) yes")));
|
|
assert!(
|
|
!matches!(step2, PolicyStep::Rule(WakeAction::Wait(_))),
|
|
"a blocking decision within min_interval must not be deferred/dropped; got {step2:?}"
|
|
);
|
|
assert!(matches!(step2, PolicyStep::Rule(WakeAction::AnswerChoice(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn blocking_decision_still_capped_by_max_per_hour() {
|
|
// The per-hour cap still applies to decisions (the real runaway guard):
|
|
// once the window is full, even a blocking decision halts to a human.
|
|
let mut c = sidecar_cfg();
|
|
c.decision_watch.base.budget.max_interventions_per_hour = 2;
|
|
c.decision_watch.base.budget.min_interval_secs = 0;
|
|
let mut p = PolicyState::new(c);
|
|
let now = Instant::now();
|
|
for i in 0..2 {
|
|
let t = now + Duration::from_secs(i);
|
|
let _ = p.on_stall(t, &decision(Some("1) yes")));
|
|
p.record_for(t, &decision(Some("1) yes")));
|
|
}
|
|
let t = now + Duration::from_secs(3);
|
|
assert_eq!(
|
|
p.on_stall(t, &decision(Some("1) yes"))),
|
|
PolicyStep::Halt("budget_exhausted".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_nudge_still_respects_min_interval() {
|
|
// GUARD: min_interval STILL rate-limits the idle-nudge lane (agent
|
|
// working — would otherwise be nudged every scan tick). Only blocking
|
|
// DECISIONS are exempt from min_interval.
|
|
let mut c = sidecar_cfg();
|
|
c.decision_watch.base.budget.min_interval_secs = 20;
|
|
let mut p = PolicyState::new(c);
|
|
p.on_progress(&SessionSignal::Working);
|
|
let now = Instant::now();
|
|
let first = p.on_stall(now, &SessionSignal::Idle);
|
|
assert_eq!(first, PolicyStep::Rule(WakeAction::SendText("continue".into())));
|
|
p.record_for(now, &SessionSignal::Idle);
|
|
let t2 = now + Duration::from_secs(5);
|
|
assert!(
|
|
matches!(p.on_stall(t2, &SessionSignal::Idle), PolicyStep::Rule(WakeAction::Wait(_))),
|
|
"idle nudges must still respect min_interval"
|
|
);
|
|
}
|
|
|
|
// ── Rule-tier conservative auto-pick (allow_unmarked_pick) ──
|
|
|
|
fn decision_with(options: &[&str], recommended: Option<&str>) -> SessionSignal {
|
|
SessionSignal::Decision(DecisionPrompt {
|
|
text: "请选择一个方案?".into(),
|
|
options: options.iter().map(|s| s.to_string()).collect(),
|
|
recommended: recommended.map(|s| s.to_string()),
|
|
source: DecisionSource::TextScan,
|
|
kind: DecisionKind::Options,
|
|
permission: None,
|
|
})
|
|
}
|
|
|
|
fn rule_autopick_cfg() -> IdmmConfig {
|
|
let mut c = rule_cfg();
|
|
c.decision_watch.strategy.categories.option_decision.allow_unmarked_pick = true;
|
|
c
|
|
}
|
|
|
|
#[test]
|
|
fn decision_auto_pick_unmarked_picks_first_safe_option() {
|
|
let mut p = PolicyState::new(rule_autopick_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 方案A", "2) 方案B"], None)),
|
|
PolicyStep::Rule(WakeAction::AnswerChoice("1) 方案A".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_auto_pick_skips_cancel_option() {
|
|
let mut p = PolicyState::new(rule_autopick_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 取消", "2) 方案B"], None)),
|
|
PolicyStep::Rule(WakeAction::AnswerChoice("2) 方案B".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_auto_pick_skips_destructive_option() {
|
|
let mut p = PolicyState::new(rule_autopick_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) drop table data", "2) 安全迁移"], None)),
|
|
PolicyStep::Rule(WakeAction::AnswerChoice("2) 安全迁移".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_auto_pick_all_unsafe_falls_through_to_halt() {
|
|
let mut p = PolicyState::new(rule_autopick_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 取消", "2) rm -rf /data"], None)),
|
|
PolicyStep::Halt("ambiguous_decision_no_sidecar".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_auto_pick_off_is_unchanged_halt() {
|
|
// Default rule_cfg (allow_unmarked_pick = false): unmarked + no sidecar → halt.
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 方案A", "2) 方案B"], None)),
|
|
PolicyStep::Halt("ambiguous_decision_no_sidecar".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn decision_recommended_wins_over_auto_pick() {
|
|
let mut p = PolicyState::new(rule_autopick_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 方案A", "2) 方案B"], Some("2) 方案B"))),
|
|
PolicyStep::Rule(WakeAction::AnswerChoice("2) 方案B".into()))
|
|
);
|
|
}
|
|
|
|
// ── D5: tendency influences daring on an unmarked auto-pick ──
|
|
|
|
#[test]
|
|
fn conservative_tendency_does_not_auto_pick_unmarked() {
|
|
// Even with allow_unmarked_pick on, a Conservative tendency declines to
|
|
// pick an unmarked option and halts (rule-only, no sidecar).
|
|
let mut c = rule_autopick_cfg();
|
|
c.decision_watch.strategy.tendency = Tendency::Conservative;
|
|
let mut p = PolicyState::new(c);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 方案A", "2) 方案B"], None)),
|
|
PolicyStep::Halt("ambiguous_decision_no_sidecar".into())
|
|
);
|
|
}
|
|
|
|
// ── D5: category-mode mapping ──
|
|
|
|
#[test]
|
|
fn option_mode_off_halts_rule_only() {
|
|
// option_decision.mode = Off → never auto-decide (rule-only halts).
|
|
let mut c = rule_autopick_cfg();
|
|
c.decision_watch.strategy.categories.option_decision.mode = CategoryMode::Off;
|
|
let mut p = PolicyState::new(c);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 方案A", "2) 方案B"], None)),
|
|
PolicyStep::Halt("option_mode_not_auto".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn option_mode_ask_first_escalates_with_sidecar() {
|
|
// AskFirst with a model tier escalates (no async ask channel this phase).
|
|
let mut c = sidecar_cfg();
|
|
c.decision_watch.strategy.categories.option_decision.mode = CategoryMode::AskFirst;
|
|
let mut p = PolicyState::new(c);
|
|
assert!(matches!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 方案A", "2) 方案B"], None)),
|
|
PolicyStep::Sidecar { .. }
|
|
));
|
|
}
|
|
|
|
// ── Structured tool-permission decisions (safety gate) ──
|
|
|
|
fn permission_decision(call_id: &str, safe: bool) -> SessionSignal {
|
|
SessionSignal::Decision(DecisionPrompt {
|
|
text: "tool permission".into(),
|
|
options: vec!["Allow once".into(), "Reject".into()],
|
|
recommended: None,
|
|
source: DecisionSource::Permission,
|
|
kind: DecisionKind::Options,
|
|
permission: Some(PermissionConfirm {
|
|
call_id: call_id.into(),
|
|
options: vec![
|
|
("Allow once".into(), "proceed_once".into()),
|
|
("Reject".into(), "cancel".into()),
|
|
],
|
|
safe_value: if safe { Some("proceed_once".into()) } else { None },
|
|
}),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn permission_safe_rule_tier_auto_confirms() {
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &permission_decision("call-1", true)),
|
|
PolicyStep::Rule(WakeAction::Confirm {
|
|
call_id: "call-1".into(),
|
|
value: "proceed_once".into(),
|
|
always_allow: false,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_risky_rule_only_halts() {
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &permission_decision("call-1", false)),
|
|
PolicyStep::Halt("permission_decision_no_sidecar".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn permission_risky_with_sidecar_escalates() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
assert!(matches!(
|
|
p.on_stall(Instant::now(), &permission_decision("call-1", false)),
|
|
PolicyStep::Sidecar { .. }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn conservative_fallback_permission_safe_confirms() {
|
|
assert_eq!(
|
|
PolicyState::conservative_fallback(&permission_decision("call-9", true)),
|
|
WakeAction::Confirm {
|
|
call_id: "call-9".into(),
|
|
value: "proceed_once".into(),
|
|
always_allow: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conservative_fallback_decision_never_retries() {
|
|
assert_eq!(
|
|
PolicyState::conservative_fallback(&decision_with(&["1) Canvas", "2) DOM"], None)),
|
|
WakeAction::AnswerChoice("1) Canvas".into())
|
|
);
|
|
assert!(matches!(
|
|
PolicyState::conservative_fallback(&decision_with(&["1) 取消"], None)),
|
|
WakeAction::Stop(_)
|
|
));
|
|
}
|
|
|
|
// ── D6: open-question handling ──
|
|
|
|
fn open_question() -> SessionSignal {
|
|
SessionSignal::Decision(DecisionPrompt {
|
|
text: "你希望缓存怎么设计?".into(),
|
|
options: vec![],
|
|
recommended: None,
|
|
source: DecisionSource::TextScan,
|
|
kind: DecisionKind::OpenQuestion,
|
|
permission: None,
|
|
})
|
|
}
|
|
|
|
/// A decision watch that may answer open questions (RulePlusModel + on).
|
|
fn open_answer_cfg() -> IdmmConfig {
|
|
let mut c = sidecar_cfg();
|
|
c.decision_watch.answer_open_questions = true;
|
|
c
|
|
}
|
|
|
|
#[test]
|
|
fn open_question_model_tier_answers_via_sidecar() {
|
|
let mut p = PolicyState::new(open_answer_cfg());
|
|
match p.on_stall(Instant::now(), &open_question()) {
|
|
PolicyStep::Sidecar { class, .. } => assert_eq!(class, StallClass::OpenQuestion),
|
|
other => panic!("expected sidecar for an answerable open question, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn open_question_rule_only_never_answers() {
|
|
// RuleOnly decision watch (no model) → never answers an open question.
|
|
let mut p = PolicyState::new(rule_cfg());
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &open_question()),
|
|
PolicyStep::Halt("open_question_not_answerable_by_rule".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_question_model_tier_but_disabled_flag_halts() {
|
|
// RulePlusModel but answer_open_questions=false → must NOT answer.
|
|
let mut c = sidecar_cfg();
|
|
c.decision_watch.answer_open_questions = false;
|
|
let mut p = PolicyState::new(c);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &open_question()),
|
|
PolicyStep::Halt("open_question_not_answerable_by_rule".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn open_question_mode_off_halts() {
|
|
// answer on, but the open_question category mode is Off → no answer.
|
|
let mut c = open_answer_cfg();
|
|
c.decision_watch.strategy.categories.open_question.mode = CategoryMode::Off;
|
|
let mut p = PolicyState::new(c);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &open_question()),
|
|
PolicyStep::Halt("open_question_not_answerable_by_rule".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn conservative_fallback_open_question_stops() {
|
|
// If the sidecar fails on an open question, the rule tier must STOP, not
|
|
// guess a free-text answer.
|
|
assert!(matches!(
|
|
PolicyState::conservative_fallback(&open_question()),
|
|
WakeAction::Stop(_)
|
|
));
|
|
}
|
|
|
|
// ── Default-config-equals-Phase-1-behavior regression guard (D5) ──
|
|
|
|
#[test]
|
|
fn default_decision_watch_enabled_reproduces_phase1_autopick() {
|
|
// A decision watch enabled with ALL strategy defaults (D5 says defaults
|
|
// == Phase-1 behavior) must auto-pick the first safe option on an
|
|
// unmarked numbered decision — exactly as Phase-1 did with
|
|
// auto_pick_unmarked=true (the production default).
|
|
let mut c = IdmmConfig::default();
|
|
c.decision_watch.base.enabled = true; // tier defaults RuleOnly
|
|
let mut p = PolicyState::new(c);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &decision_with(&["1) 方案A", "2) 方案B"], None)),
|
|
PolicyStep::Rule(WakeAction::AnswerChoice("1) 方案A".into())),
|
|
"default decision watch must reproduce Phase-1 conservative auto-pick"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn default_fault_watch_enabled_reproduces_phase1_retry() {
|
|
// A fault watch enabled with defaults must retry a retryable provider
|
|
// error (Phase-1's auto_retry default).
|
|
let mut c = IdmmConfig::default();
|
|
c.fault_watch.base.enabled = true;
|
|
let mut p = PolicyState::new(c);
|
|
assert_eq!(
|
|
p.on_stall(Instant::now(), &provider_err(Some(true))),
|
|
PolicyStep::Rule(WakeAction::Retry),
|
|
"default fault watch must reproduce Phase-1 auto-retry"
|
|
);
|
|
}
|
|
|
|
// ── Budget / min-interval (now per-watch) ──
|
|
|
|
#[test]
|
|
fn budget_exhausts_after_max_per_hour() {
|
|
let mut cfg = sidecar_cfg();
|
|
cfg.fault_watch.base.budget = BudgetConfig {
|
|
max_interventions_per_hour: 3,
|
|
min_interval_secs: 0,
|
|
};
|
|
let mut p = PolicyState::new(cfg);
|
|
let now = Instant::now();
|
|
for i in 0..3 {
|
|
let t = now + Duration::from_secs(i);
|
|
let sig = provider_err(Some(true));
|
|
let _ = p.on_stall(t, &sig);
|
|
p.record_for(t, &sig);
|
|
}
|
|
let t = now + Duration::from_secs(4);
|
|
assert_eq!(
|
|
p.on_stall(t, &provider_err(Some(true))),
|
|
PolicyStep::Halt("budget_exhausted".into())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn min_interval_defers() {
|
|
let mut cfg = sidecar_cfg();
|
|
cfg.fault_watch.base.budget.min_interval_secs = 60;
|
|
let mut p = PolicyState::new(cfg);
|
|
let now = Instant::now();
|
|
let sig = provider_err(Some(true));
|
|
let _ = p.on_stall(now, &sig);
|
|
p.record_for(now, &sig);
|
|
let t = now + Duration::from_secs(10);
|
|
assert!(matches!(
|
|
p.on_stall(t, &provider_err(Some(true))),
|
|
PolicyStep::Rule(WakeAction::Wait(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn per_watch_budgets_are_independent() {
|
|
// Exhausting the fault watch's budget must NOT block the decision watch.
|
|
let mut cfg = sidecar_cfg();
|
|
cfg.fault_watch.base.budget = BudgetConfig {
|
|
max_interventions_per_hour: 1,
|
|
min_interval_secs: 0,
|
|
};
|
|
let mut p = PolicyState::new(cfg);
|
|
let now = Instant::now();
|
|
// Exhaust fault budget (1 allowed).
|
|
let fe = provider_err(Some(true));
|
|
let _ = p.on_stall(now, &fe);
|
|
p.record_for(now, &fe);
|
|
assert_eq!(p.on_stall(now, &provider_err(Some(true))), PolicyStep::Halt("budget_exhausted".into()));
|
|
// The decision watch still answers a decision (its own budget intact).
|
|
assert_eq!(
|
|
p.on_stall(now, &decision(Some("1) yes"))),
|
|
PolicyStep::Rule(WakeAction::AnswerChoice("1) yes".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sidecar_destructive_vetoed_when_not_allowed() {
|
|
let p = PolicyState::new(sidecar_cfg());
|
|
let dec = SidecarDecision {
|
|
action: "send_text".into(),
|
|
text: "rm -rf /".into(),
|
|
wait_secs: 0,
|
|
confidence: 0.99,
|
|
reason: String::new(),
|
|
};
|
|
assert_eq!(p.on_sidecar(&dec), SidecarStep::Halt("destructive_withheld".into()));
|
|
}
|
|
|
|
#[test]
|
|
fn sidecar_applies_answer_choice() {
|
|
let p = PolicyState::new(sidecar_cfg());
|
|
let dec = SidecarDecision {
|
|
action: "answer_choice".into(),
|
|
text: "2".into(),
|
|
wait_secs: 0,
|
|
confidence: 0.9,
|
|
reason: String::new(),
|
|
};
|
|
assert_eq!(p.on_sidecar(&dec), SidecarStep::Apply(WakeAction::AnswerChoice("2".into())));
|
|
}
|
|
|
|
#[test]
|
|
fn sidecar_answer_text_maps_to_send_text() {
|
|
// D6: the open-question free-text answer action.
|
|
let p = PolicyState::new(open_answer_cfg());
|
|
let dec = SidecarDecision {
|
|
action: "answer_text".into(),
|
|
text: "用 LRU + 30 分钟 TTL".into(),
|
|
wait_secs: 0,
|
|
confidence: 0.8,
|
|
reason: "balanced".into(),
|
|
};
|
|
assert_eq!(
|
|
p.on_sidecar(&dec),
|
|
SidecarStep::Apply(WakeAction::SendText("用 LRU + 30 分钟 TTL".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn on_progress_resets_backoff_and_retries() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
p.on_progress(&SessionSignal::Working);
|
|
let now = Instant::now();
|
|
for i in 0..3 {
|
|
let t = now + Duration::from_secs(i * 60);
|
|
let _ = p.on_stall(t, &SessionSignal::Idle);
|
|
p.record_for(t, &SessionSignal::Idle);
|
|
}
|
|
assert!(p.next_delay() > BACKOFF_LADDER[0]);
|
|
p.on_progress(&SessionSignal::Working);
|
|
assert_eq!(p.next_delay(), BACKOFF_LADDER[0]);
|
|
let t = now + Duration::from_secs(600);
|
|
assert_eq!(
|
|
p.on_stall(t, &SessionSignal::Idle),
|
|
PolicyStep::Rule(WakeAction::SendText("continue".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn backoff_sequence_is_exponential_clamped() {
|
|
let mut p = PolicyState::new(sidecar_cfg());
|
|
let now = Instant::now();
|
|
let sig = provider_err(Some(true));
|
|
assert_eq!(p.next_delay(), Duration::from_secs(10));
|
|
p.record_for(now, &sig);
|
|
assert_eq!(p.next_delay(), Duration::from_secs(30));
|
|
p.record_for(now, &sig);
|
|
assert_eq!(p.next_delay(), Duration::from_secs(120));
|
|
p.record_for(now, &sig);
|
|
assert_eq!(p.next_delay(), Duration::from_secs(300));
|
|
p.record_for(now, &sig);
|
|
assert_eq!(p.next_delay(), Duration::from_secs(300));
|
|
}
|
|
}
|