Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,26 @@
[package]
name = "nomifun-idmm"
version.workspace = true
edition.workspace = true
[dependencies]
nomifun-common.workspace = true
nomifun-db.workspace = true
nomifun-api-types.workspace = true
nomifun-realtime.workspace = true
nomifun-conversation.workspace = true
nomifun-ai-agent.workspace = true
nomifun-terminal.workspace = true
nomifun-requirement.workspace = true
nomifun-auth.workspace = true
axum.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
dashmap.workspace = true
async-trait.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
sqlx = { workspace = true }
@@ -0,0 +1,234 @@
//! Config-derived classification helpers + validation. The persisted config DTOs
//! themselves live in `nomifun_api_types::idmm`; this module adds runtime logic
//! over them.
use nomifun_api_types::{AgentErrorCode, IdmmConfig, WatchTier};
/// Classify an `AgentErrorCode` as a provider fault IDMM should supervise
/// (i.e. a single-vendor failure a backup model or a retry might overcome).
pub fn is_provider_fault(code: AgentErrorCode) -> bool {
use AgentErrorCode::*;
matches!(
code,
UserLlmProviderAuthFailed
| UserLlmProviderPermissionDenied
| UserLlmProviderBillingRequired
| UserLlmProviderConfigError
| UserLlmProviderModelNotFound
| UserLlmProviderUnsupportedModel
| UserLlmProviderEndpointNotFound
| UserLlmProviderInvalidRequest
| UserLlmProviderInvalidToolSchema
| UserLlmProviderContextTooLarge
| UserLlmProviderRateLimited
| UserLlmProviderTimeout
| UserLlmProviderNetworkError
| UserLlmProviderEmptyResponse
| UserLlmProviderGatewayError
| UnknownUpstreamError
)
}
/// Validate a config for the given backup resolvability. Returns `Err(reason)`
/// to map to a 400 / inline UI error.
///
/// Phase 2 (plan D4 / config.rs validate): validation is **per-watch**. A watch
/// only carries operational requirements when it is enabled. The single hard
/// prerequisite is the `RulePlusModel` tier's resolvable backup model — and the
/// caller computes `backup_resolvable` inclusively (per-watch override → global
/// default → the conversation's own model), so a plain desktop chat satisfies it
/// with zero extra config ("智能值守、全托管" is one click). The strategy's
/// steering / freeform text is OPTIONAL: when empty, the sidecar prompt falls
/// back to a conservative built-in policy ([`crate::prompt::build_user_prompt`]),
/// so requiring it only added friction. A disabled watch never runs and so
/// carries no requirements (users must always be able to turn a watch off, even
/// from a half-filled model form).
pub fn validate(cfg: &IdmmConfig, backup_resolvable: bool) -> Result<(), String> {
let fault_needs_backup =
cfg.fault_watch.base.enabled && cfg.fault_watch.base.tier == WatchTier::RulePlusModel;
let decision_needs_backup =
cfg.decision_watch.base.enabled && cfg.decision_watch.base.tier == WatchTier::RulePlusModel;
if (fault_needs_backup || decision_needs_backup) && !backup_resolvable {
return Err(
"no backup model resolvable for the 旁路模型 (RulePlusModel) tier — pick a per-watch bypass model, set a \
global default (设置 → 智能决策), or enable it on a conversation that already has a model selected"
.into(),
);
}
Ok(())
}
/// Whether an answer/action text looks destructive (vetoed unless explicitly
/// allowed). Case-insensitive substring match on common irreversible operations.
pub fn is_destructive(text: &str) -> bool {
let low = text.to_lowercase();
const SIGS: &[&str] = &[
"rm -rf",
"rm -fr",
"drop table",
"drop database",
"truncate",
"delete from",
"force push",
"push --force",
"push -f",
"reset --hard",
"git clean -",
"mkfs",
"dd if=",
"> /dev/",
];
SIGS.iter().any(|s| low.contains(s))
}
/// Whether a decision option looks like a cancel / decline / skip choice.
/// `auto_pick_unmarked` must never auto-select one of these — the point of the
/// conservative auto-pick is to PROCEED with a real option, not to bail. When
/// every option is a cancel (or destructive) one, the policy falls through to
/// the sidecar / halt instead. Case-insensitive substring match.
pub fn is_cancel_option(text: &str) -> bool {
let low = text.to_lowercase();
const SIGS: &[&str] = &[
"取消",
"放弃",
"跳过",
"稍后",
"暂不",
"退出",
"以后再",
"什么都不",
"都不选",
"不需要",
"cancel",
"skip",
"abort",
"quit",
"go back",
"none of",
"do nothing",
"nevermind",
"never mind",
];
SIGS.iter().any(|s| low.contains(s))
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::{DecisionWatchConfig, FaultWatchConfig, IdmmConfig, WatchBase, WatchTier};
fn decision_model_watch(enabled: bool) -> DecisionWatchConfig {
DecisionWatchConfig {
base: WatchBase {
enabled,
tier: WatchTier::RulePlusModel,
..WatchBase::default()
},
..Default::default()
}
}
fn fault_model_watch(enabled: bool) -> FaultWatchConfig {
FaultWatchConfig {
base: WatchBase {
enabled,
tier: WatchTier::RulePlusModel,
..WatchBase::default()
},
..Default::default()
}
}
#[test]
fn validate_freeform_optional_when_model_tier() {
// The strategy's freeform policy is no longer mandatory for the model
// tier — an empty policy falls back to the conservative built-in. Only a
// resolvable backup model is required.
let mut cfg = IdmmConfig {
decision_watch: decision_model_watch(true),
..Default::default()
};
assert!(validate(&cfg, true).is_ok(), "empty freeform must be allowed when backup resolves");
cfg.decision_watch.strategy.freeform_policy = Some("prefer recommended".into());
assert!(validate(&cfg, true).is_ok());
}
#[test]
fn validate_requires_backup_when_decision_model_tier() {
let cfg = IdmmConfig {
decision_watch: decision_model_watch(true),
..Default::default()
};
assert!(validate(&cfg, false).is_err());
assert!(validate(&cfg, true).is_ok());
}
#[test]
fn validate_requires_backup_when_fault_model_tier() {
let cfg = IdmmConfig {
fault_watch: fault_model_watch(true),
..Default::default()
};
assert!(validate(&cfg, false).is_err());
assert!(validate(&cfg, true).is_ok());
}
#[test]
fn validate_ok_rule_only_regardless_of_backup() {
// Both watches RuleOnly (default tier) + enabled → no backup required.
let cfg = IdmmConfig {
fault_watch: FaultWatchConfig {
base: WatchBase { enabled: true, ..WatchBase::default() },
..Default::default()
},
decision_watch: DecisionWatchConfig {
base: WatchBase { enabled: true, ..WatchBase::default() },
..Default::default()
},
};
assert!(validate(&cfg, false).is_ok());
}
// ── A disabled watch must always be allowed through (turn-off must work) ──
#[test]
fn validate_allows_disable_without_backup() {
// The user picked the model tier for the decision watch but left it
// disabled (toggled off). This MUST succeed — an inactive watch carries
// no operational requirements.
let cfg = IdmmConfig {
decision_watch: decision_model_watch(false),
..Default::default()
};
assert!(validate(&cfg, false).is_ok());
assert!(validate(&cfg, true).is_ok());
}
#[test]
fn is_provider_fault_covers_known_codes() {
assert!(is_provider_fault(AgentErrorCode::UserLlmProviderEndpointNotFound));
assert!(is_provider_fault(AgentErrorCode::UserLlmProviderGatewayError));
assert!(is_provider_fault(AgentErrorCode::UserLlmProviderRateLimited));
assert!(!is_provider_fault(AgentErrorCode::UserAgentNotInstalled));
assert!(!is_provider_fault(AgentErrorCode::NomifunConversationBusy));
}
#[test]
fn is_destructive_flags_dangerous_text() {
assert!(is_destructive("run rm -rf /tmp/x"));
assert!(is_destructive("git reset --hard origin/main"));
assert!(is_destructive("DROP TABLE users"));
assert!(!is_destructive("yes, continue"));
assert!(!is_destructive("option 1"));
}
#[test]
fn is_cancel_option_flags_decline_choices() {
assert!(is_cancel_option("1) 取消"));
assert!(is_cancel_option("3) 跳过此步"));
assert!(is_cancel_option("2) Cancel and try later"));
assert!(is_cancel_option("None of the above"));
assert!(!is_cancel_option("1) Canvas 渲染"));
assert!(!is_cancel_option("2) 方案B:双写过渡"));
}
}
@@ -0,0 +1,832 @@
//! The no-LLM stall detector. Two entry points:
//! * `signal_from_agent_error` — maps an agent error payload to a signal
//! (conversation path), plus `map_agent_event` for the full event stream.
//! * `TerminalDetector` — feeds raw PTY bytes through the shared
//! `AnsiLineScanner` and classifies completed lines via built-in pattern
//! sets (provider-error signatures / decision prompts / recommended option).
//!
//! Self-echo guard: injected wake/answer text is tagged by the probe with a
//! zero-width marker prefix; lines bearing it are skipped so an injection's own
//! echo cannot be re-detected as a fresh stall.
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use nomifun_api_types::{AgentErrorOwnership, AgentStreamErrorData};
use nomifun_terminal::AnsiLineScanner;
use crate::signal::{DecisionKind, DecisionPrompt, DecisionSource, SessionSignal};
/// Map an agent error payload to a signal.
pub fn signal_from_agent_error(d: &AgentStreamErrorData) -> SessionSignal {
let is_provider = d.ownership == Some(AgentErrorOwnership::UserLlmProvider)
|| d.code.map(crate::config::is_provider_fault).unwrap_or(false);
if is_provider {
SessionSignal::ProviderError {
code: d.code,
retryable: d.retryable,
message: d.message.clone(),
}
} else {
SessionSignal::AgentError {
retryable: d.retryable,
message: d.message.clone(),
}
}
}
/// Built-in provider-error line signatures (lowercased contains-match).
const PROVIDER_ERROR_SIGS: &[&str] = &[
"http 404",
"http 424",
"http 429",
"http 500",
"http 502",
"http 503",
"http 529",
"status 500",
"status 502",
"status 503",
"rate limit",
"rate_limit",
"overloaded",
"request timed out",
"request timeout",
"gateway timeout",
"connection refused",
"invalid api key",
"invalid_api_key",
"econnreset",
"socket hang up",
"internal server error",
"bad gateway",
"service unavailable",
"upstream error",
];
/// Decision-prompt trailing markers.
const YES_NO_MARKERS: &[&str] = &["(y/n)", "(yes/no)", "[y/n]", "[yes/no]", "y/n?"];
const PROCEED_MARKERS: &[&str] = &[
"do you want to proceed",
"do you want to continue",
"press enter to continue",
"continue? (",
"proceed? (",
"are you sure",
"confirm? (",
"[y/n]",
];
/// Returns true if a (lowercased) line looks like a recommended/default option.
fn recommended_marker(line: &str) -> bool {
let low = line.to_lowercase();
line.contains('\u{276f}') //
|| line.contains('▶')
|| low.contains("(default)")
|| low.contains("(recommended)")
|| low.contains("[default]")
|| line.contains("(推荐)")
|| line.contains("(默认)")
|| line.contains("(推荐)")
|| line.contains("(默认)")
|| line.contains("[推荐]")
|| line.contains("[默认]")
|| low.trim_start().starts_with("> ")
}
/// Parse a single ANSI-stripped line plus recent context into a decision prompt.
/// `recent` is the bounded scrollback (oldest→newest), used to gather the
/// numbered options preceding a trailing prompt and to find a recommended line.
fn detect_decision(line: &str, recent: &VecDeque<String>) -> Option<DecisionPrompt> {
let low = line.to_lowercase();
let trimmed = low.trim_end();
let is_yes_no = YES_NO_MARKERS
.iter()
.any(|m| trimmed.ends_with(m) || trimmed.contains(m));
let is_proceed = PROCEED_MARKERS.iter().any(|m| low.contains(m));
let is_menu_line = is_numbered_option(line);
let has_recent_options = recent.iter().any(|l| is_numbered_option(l));
// A question (or an inline numeric-choice token), especially when a numbered
// menu preceded it, is a decision prompt awaiting selection.
let is_question = (trimmed.contains('?') && (has_numeric_choice(trimmed) || has_recent_options))
|| (has_numeric_choice(trimmed) && has_recent_options);
if !is_yes_no && !is_proceed && !is_menu_line && !is_question {
return None;
}
// Gather contiguous numbered options from recent lines (+ this one).
let mut options: Vec<String> = recent.iter().filter(|l| is_numbered_option(l)).cloned().collect();
if is_menu_line {
options.push(line.to_string());
}
options.dedup();
// Recommended: a recent (or current) line carrying a recommended marker.
let recommended = recent
.iter()
.rev()
.chain(std::iter::once(&line.to_string()))
.find(|l| recommended_marker(l))
.map(|l| clean_option(l));
Some(DecisionPrompt {
text: line.trim().to_string(),
options: options.iter().map(|o| clean_option(o)).collect(),
recommended,
source: DecisionSource::TerminalScan,
kind: DecisionKind::Options,
permission: None,
})
}
/// Detects an inline numeric-choice token like `(1/2)`, `[1-3]`, `(1/2/3)`, and
/// the fullwidth-bracket forms Chinese output uses — `1/2`, `1-3`. Scans
/// by `char` (not bytes) so multi-byte fullwidth brackets/digits/separators are
/// recognized; an ASCII-only byte scan silently skipped every fullwidth menu.
fn has_numeric_choice(line: &str) -> bool {
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
let open = chars[i];
if open == '(' || open == '[' || open == '' || open == '' {
// Scan until the matching close, requiring a digit and a separator.
let mut j = i + 1;
let mut saw_digit = false;
let mut saw_sep = false;
while j < chars.len() {
let c = chars[j];
if matches!(c, ')' | ']' | '' | '') {
break;
}
match c {
'0'..='9' | ''..='' => saw_digit = true,
'/' | '-' | ',' | '' | '、' | '' => saw_sep = true,
' ' | '\u{3000}' => {}
_ => {
saw_digit = false;
break;
}
}
j += 1;
}
if saw_digit && saw_sep {
return true;
}
}
i += 1;
}
false
}
/// A numbered menu option like `1) yes`, `2. no`, ` 1) yes`, plus the Chinese
/// enumeration forms `1、是` (顿号) and the fullwidth `1)是` / `1.是`. Chinese
/// LLM output overwhelmingly uses `1、` and fullwidth punctuation, so an
/// ASCII-`.`/`)`-only check scored those menus as zero options and IDMM never
/// saw the decision. The selection-intent guard in `detect_chat_decision` still
/// prevents a plain `1、…` step list from being treated as a menu.
fn is_numbered_option(line: &str) -> bool {
let s = line
.trim_start_matches(['\u{276f}', '▶', '>', ' ', '\t'])
.trim_start();
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_digit() || (''..='').contains(&c) => {
matches!(chars.next(), Some('.') | Some(')') | Some('、') | Some('') | Some(''))
}
_ => false,
}
}
/// Strip leading selection markers/whitespace from an option line.
fn clean_option(line: &str) -> String {
line.trim_start_matches(['\u{276f}', '▶', '>', ' ', '\t'])
.trim()
.to_string()
}
/// Explicit "reply with the option number" phrasing — the strongest signal that
/// the agent ended its turn waiting for the user to pick a numbered option.
fn has_reply_number_phrase(low: &str) -> bool {
const SIGS: &[&str] = &[
"回复编号",
"回复对应",
"回复数字",
"回复序号",
"回复选项",
"输入编号",
"选择编号",
"告诉我编号",
"reply with the number",
"reply with a number",
"reply with the option",
"respond with the number",
];
SIGS.iter().any(|s| low.contains(s))
}
/// Selection-intent wording: the agent is asking the user to CHOOSE among
/// options (vs. listing steps or announcing its own pick). Kept specific to
/// avoid matching "我选择了…/I'll use…" (the agent stating its decision).
fn has_select_word(low: &str) -> bool {
const SIGS: &[&str] = &[
"请选择",
"选择哪",
"选哪",
"哪一个",
"哪个方案",
"哪个选项",
"你想选",
"你想用哪",
"你倾向",
"你更倾向",
"你希望用哪",
"你希望选",
"你的选择",
"which option",
"which approach",
"which one",
"which do you",
"please choose",
"please select",
"choose an option",
"select an option",
"let me know which",
"do you prefer",
"your choice",
"your preference",
];
SIGS.iter().any(|s| low.contains(s))
}
/// Detect a chat-style decision prompt in an assistant turn's full text.
///
/// Conservative by design — requires BOTH discrete choices (≥2 numbered options
/// or an inline `(1/2/3)` token) AND an intent to have the user choose (an
/// explicit "回复编号"/"reply with the number" phrase, OR a selection word paired
/// with a question / inline token / numbered menu). Plain numbered
/// implementation steps, prose with no options, a single option, and the agent
/// announcing its own pick all return `None` (false-positive guards).
///
/// Used by `ConversationProbe` on turn-end for PLAIN DESKTOP conversations only:
/// channel/companion conversations route such menus to a remote human via the
/// channel `PendingDecisionStore` and must NOT be auto-answered by IDMM.
pub fn detect_chat_decision(text: &str) -> Option<DecisionPrompt> {
let options: Vec<String> = text
.lines()
.filter(|l| is_numbered_option(l))
.map(clean_option)
.collect();
let low = text.to_lowercase();
let inline_token = has_numeric_choice(&low);
let has_question = low.contains('?') || low.contains('');
let has_menu = options.len() >= 2 || inline_token;
let has_intent = has_reply_number_phrase(&low)
|| (has_select_word(&low) && (has_question || inline_token || options.len() >= 2));
if !(has_menu && has_intent) {
return None;
}
let recommended = text.lines().rev().find(|l| recommended_marker(l)).map(clean_option);
let prompt_line = text
.lines()
.rev()
.map(|l| l.trim())
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string();
Some(DecisionPrompt {
text: prompt_line,
options,
recommended,
source: DecisionSource::TextScan,
kind: DecisionKind::Options,
permission: None,
})
}
/// Detect an open-ended question (纯问答, D6) in an assistant turn's full text:
/// the turn ends on an interrogative but has NO enumerable options (so
/// [`detect_chat_decision`] would return `None`). Returns a `DecisionPrompt`
/// with `kind = OpenQuestion`, empty `options`, and no `permission` — the
/// decision watch's model tier answers it with free text; the rule tier never
/// guesses an open answer (spec §5.4).
///
/// Conservative by design: requires an interrogative cue (`?`/`` or a
/// question/select intent word) AND the absence of an INLINE discrete-choice
/// token (`(1/2)` / `1/2`). Plain prose with no question, a genuine pick-one
/// numbered menu (that's an `Options` decision — already caught by
/// [`detect_chat_decision`] above), and the agent announcing its own next step
/// all return `None`. A multi-part question prompt whose numbered lines are
/// TOPICS rather than mutually-exclusive options IS an open question (the model
/// answers all parts in free text). The caller gates on `work_in_progress` (only
/// an open question DURING an unfinished turn is a stall worth answering).
pub fn detect_chat_open_question(text: &str) -> Option<DecisionPrompt> {
// If it parses as a discrete-options decision, it is NOT an open question.
if detect_chat_decision(text).is_some() {
return None;
}
let low = text.to_lowercase();
// An INLINE discrete-choice token like `(1/2)` / `1/2` is an unambiguous
// pick-one menu marker → not an open question.
//
// Do NOT additionally disqualify on the COUNT of numbered lines. A multi-part
// design prompt — "先问你几个基础设计问题:1. 技术栈偏好… 2. 界面风格… 请告诉我你的
// 偏好。" — has several numbered TOPICS (each its own question), NOT mutually-
// exclusive options. The old `numbered_lines >= 2` guard mis-read those as a
// menu, so the turn matched NEITHER detector: `detect_chat_decision` declined
// it (no pick-one selection intent) and this returned `None` on the count, so
// IDMM never saw the pending question and stayed silent (会话 27「中途开启
// 智能决策不生效」, no decision record at all). A genuine pick-one numbered menu
// is already excluded by the `detect_chat_decision` check above (it carries the
// selection intent this multi-question prompt lacks).
if has_numeric_choice(&low) {
return None;
}
let has_question = low.contains('?') || low.contains('');
// An interrogative cue: an explicit question mark, OR an asking-intent word
// (covers "你希望…", "需要我…", "should I…" phrasings that may omit the mark).
if !(has_question || has_open_intent(&low)) {
return None;
}
// The trailing non-empty line is the question prompt.
let prompt_line = text
.lines()
.rev()
.map(|l| l.trim())
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string();
if prompt_line.is_empty() {
return None;
}
Some(DecisionPrompt {
text: prompt_line,
options: vec![],
recommended: None,
source: DecisionSource::TextScan,
kind: DecisionKind::OpenQuestion,
permission: None,
})
}
/// Asking-intent wording for an OPEN question (no enumerable options): the agent
/// is asking the user something, not announcing its own plan. Kept specific to
/// avoid matching "我先去实现…/I'll now…" (the agent stating its next step).
///
/// `pub(crate)` so the terminal turn-end helper (`probe.rs`) can reuse the exact
/// same open-intent word set when gating on whether the TRAILING content line is
/// interrogative — one source of truth (a mark-less "你希望…" trailing line must
/// gate the same way the chat detector treats it).
pub(crate) fn has_open_intent(low: &str) -> bool {
const SIGS: &[&str] = &[
"请问",
"你希望",
"你想要",
"你想让",
"你打算",
"需要我",
"要我",
"你倾向",
"你觉得",
"你能否告诉",
"能否告诉我",
"告诉我你",
"想确认一下",
"想跟你确认",
"what would you like",
"what do you want",
"how would you like",
"should i",
"do you want me to",
"could you tell me",
"can you tell me",
"let me know what",
"what should",
];
SIGS.iter().any(|s| low.contains(s))
}
/// Terminal byte → signal scanner. Holds a bounded scrollback for context.
pub struct TerminalDetector {
scanner: AnsiLineScanner,
recent: VecDeque<String>,
/// Text IDMM recently injected into this PTY, shared with `TerminalProbe`.
/// A completed output line equal to a pending entry is the echo of our own
/// injection (the CLI echoing the keystrokes we sent) — skip it and pop the
/// entry so it cannot be re-detected as a fresh stall. Replaces the old
/// zero-width-tag scheme, which corrupted the bytes the CLI actually read.
recent_injections: Arc<Mutex<VecDeque<String>>>,
}
impl Default for TerminalDetector {
fn default() -> Self {
Self::new()
}
}
impl TerminalDetector {
const MAX_RECENT: usize = 400;
pub fn new() -> Self {
Self {
scanner: AnsiLineScanner::new(),
recent: VecDeque::new(),
recent_injections: Arc::new(Mutex::new(VecDeque::new())),
}
}
/// Construct sharing a `recent_injections` queue with the probe, so lines
/// echoing IDMM's own injected answers/nudges are skipped.
pub fn with_echo_guard(recent_injections: Arc<Mutex<VecDeque<String>>>) -> Self {
Self {
scanner: AnsiLineScanner::new(),
recent: VecDeque::new(),
recent_injections,
}
}
/// Whether a completed line is the echo of a recently-injected answer/nudge.
/// Pops the matched entry so each injection only suppresses one echo line.
fn is_injection_echo(&self, line: &str) -> bool {
let trimmed = line.trim();
if trimmed.is_empty() {
return false;
}
let Ok(mut pending) = self.recent_injections.lock() else {
return false;
};
if let Some(pos) = pending.iter().position(|e| e == trimmed) {
pending.remove(pos);
true
} else {
false
}
}
/// Feed a raw PTY chunk; return signals derived from completed lines.
pub fn feed(&mut self, bytes: &[u8]) -> Vec<SessionSignal> {
let mut out = Vec::new();
for line in self.scanner.feed(bytes) {
// Self-echo guard: skip lines that echo our own injection.
if self.is_injection_echo(&line) {
self.push_recent(line);
continue;
}
let low = line.to_lowercase();
if PROVIDER_ERROR_SIGS.iter().any(|s| low.contains(s)) {
out.push(SessionSignal::ProviderError {
code: None,
retryable: None,
message: line.clone(),
});
} else if let Some(dp) = detect_decision(&line, &self.recent) {
out.push(SessionSignal::Decision(dp));
}
self.push_recent(line);
}
out
}
fn push_recent(&mut self, line: String) {
if self.recent.len() >= Self::MAX_RECENT {
self.recent.pop_front();
}
self.recent.push_back(line);
}
/// The recent scrollback joined newest-last, truncated to `max_chars` from
/// the tail (keeps the most recent output for sidecar context).
pub fn scrollback(&self, max_chars: usize) -> String {
let joined = self.recent.iter().cloned().collect::<Vec<_>>().join("\n");
crate::util::tail_chars(&joined, max_chars)
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::{AgentErrorCode, AgentErrorOwnership};
fn err(code: AgentErrorCode, ownership: AgentErrorOwnership) -> AgentStreamErrorData {
AgentStreamErrorData::classified("boom", code, ownership, None, true, false, None)
}
#[test]
fn agent_error_provider_vs_other() {
let p = signal_from_agent_error(&err(
AgentErrorCode::UserLlmProviderGatewayError,
AgentErrorOwnership::UserLlmProvider,
));
assert!(matches!(p, SessionSignal::ProviderError { .. }));
let a = signal_from_agent_error(&err(
AgentErrorCode::UserAgentNotInstalled,
AgentErrorOwnership::UserAgent,
));
assert!(matches!(a, SessionSignal::AgentError { .. }));
}
#[test]
fn provider_500_line_classified() {
let mut d = TerminalDetector::new();
let sigs = d.feed(b"Error: HTTP 500 Internal Server Error from provider\n");
assert_eq!(sigs.len(), 1);
assert!(matches!(sigs[0], SessionSignal::ProviderError { .. }));
}
#[test]
fn rate_limit_and_424_lines_classified() {
let mut d = TerminalDetector::new();
assert!(matches!(
d.feed(b"429 rate limit exceeded\n")[0],
SessionSignal::ProviderError { .. }
));
let mut d2 = TerminalDetector::new();
assert!(matches!(
d2.feed(b"received HTTP 424 from upstream\n")[0],
SessionSignal::ProviderError { .. }
));
}
#[test]
fn yes_no_prompt_detected() {
let mut d = TerminalDetector::new();
let sigs = d.feed(b"Do you want to proceed? (y/n)\n");
assert_eq!(sigs.len(), 1);
assert!(matches!(sigs[0], SessionSignal::Decision(_)));
}
#[test]
fn numbered_menu_parsed_with_recommended() {
let mut d = TerminalDetector::new();
// Options arrive, then a trailing prompt; the marks the recommended one.
d.feed(b"Select an option:\n");
d.feed("\u{276f} 1) yes\n".as_bytes());
d.feed(b" 2) no\n");
let sigs = d.feed(b"Your choice? (1/2)\n");
let decision = sigs
.iter()
.find_map(|s| match s {
SessionSignal::Decision(dp) => Some(dp.clone()),
_ => None,
})
.expect("a decision signal");
assert!(decision.options.iter().any(|o| o.contains("1) yes")));
assert!(decision.options.iter().any(|o| o.contains("2) no")));
assert_eq!(decision.recommended.as_deref(), Some("1) yes"));
}
#[test]
fn plain_output_no_signal() {
let mut d = TerminalDetector::new();
assert!(d.feed(b"compiling module foo\nok\n").is_empty());
}
#[test]
fn self_echo_guard_skips_injected_lines() {
let recent = Arc::new(Mutex::new(VecDeque::new()));
recent.lock().unwrap().push_back("do you want to proceed? (y/n)".to_string());
let mut d = TerminalDetector::with_echo_guard(recent);
// The echoed injection (equal to a pending entry) is skipped, not detected.
assert!(d.feed(b"do you want to proceed? (y/n)\n").is_empty());
// The entry was consumed, so a genuine later prompt IS detected.
let sigs = d.feed(b"do you want to proceed? (y/n)\n");
assert_eq!(sigs.len(), 1);
assert!(matches!(sigs[0], SessionSignal::Decision(_)));
}
#[test]
fn scrollback_truncates_to_tail() {
let mut d = TerminalDetector::new();
d.feed(b"line-aaaa\nline-bbbb\nline-cccc\n");
let tail = d.scrollback(9);
assert_eq!(tail.len(), 9);
assert!(tail.ends_with("cccc"));
}
// ── detect_chat_decision: prose/markdown decision prompts in chat turns ──
#[test]
fn chat_decision_numbered_with_reply_number_phrase() {
// The canonical "方案 1/2/3、请回复编号" desktop decision.
let text = "我设计了两套方案:\n\
1) Canvas 渲染:性能好,开发量大\n\
2) DOM + CSS:开发快,性能一般\n\
请回复编号告诉我你的选择。";
let dp = detect_chat_decision(text).expect("a chat decision");
assert_eq!(dp.source, DecisionSource::TextScan);
assert!(dp.options.iter().any(|o| o.contains("Canvas")));
assert!(dp.options.iter().any(|o| o.contains("DOM")));
assert_eq!(dp.options.len(), 2);
}
#[test]
fn chat_decision_question_plus_select_word() {
let text = "1. 用 React\n2. 用原生 JS\n你想用哪个?";
let dp = detect_chat_decision(text).expect("a chat decision");
assert_eq!(dp.options.len(), 2);
}
#[test]
fn chat_decision_inline_numeric_token_with_select_word() {
// No newline-numbered options, but an inline (1/2/3) token + 请选择.
let text = "请选择构建方式 (1/2/3)。";
assert!(detect_chat_decision(text).is_some());
}
#[test]
fn chat_decision_recommended_marker_chinese() {
let text = "1) 方案A(推荐):稳妥\n2) 方案B:激进\n请选择哪个?";
let dp = detect_chat_decision(text).expect("a chat decision");
assert!(
dp.recommended.as_deref().unwrap_or("").contains("方案A"),
"recommended should be the marked option A; got {:?}",
dp.recommended
);
}
#[test]
fn chat_decision_english_which_option() {
let text = "1) Server-side render\n2) Client-side render\nWhich option do you prefer?";
assert!(detect_chat_decision(text).is_some());
}
// ── false-positive guards (must return None) ──
#[test]
fn chat_decision_plain_numbered_steps_is_none() {
// An implementation plan with numbered steps is NOT a decision: no
// selection intent. This is the highest-risk false positive.
let text = "实现步骤:\n1) 初始化画布\n2) 渲染西瓜\n3) 处理切割手势\n我现在开始实现。";
assert!(
detect_chat_decision(text).is_none(),
"numbered implementation steps must not be a decision"
);
}
#[test]
fn chat_decision_prose_no_options_is_none() {
assert!(detect_chat_decision("好的,我先去实现这个功能。").is_none());
}
#[test]
fn chat_decision_agent_announcing_its_own_choice_is_none() {
// Agent stating what it picked (no question, no reply-number phrase, no
// select cue) must not be hijacked.
let text = "我会用方案 1) Canvas 来实现,因为性能更好。现在开始。";
assert!(detect_chat_decision(text).is_none());
}
#[test]
fn chat_decision_single_option_question_is_none() {
// A single numbered line + a question is too weak to be a menu.
let text = "1) 继续\n要继续吗?";
assert!(detect_chat_decision(text).is_none());
}
// ── detect_chat_open_question: D6 纯问答(interrogative, no options) ──
#[test]
fn open_question_interrogative_no_options_detected() {
// An open-ended question with no enumerable options → OpenQuestion.
let text = "我看了下你的需求。你希望这个导出功能支持哪些文件格式?";
let dp = detect_chat_open_question(text).expect("an open question");
assert_eq!(dp.kind, DecisionKind::OpenQuestion);
assert!(dp.options.is_empty());
assert!(dp.permission.is_none());
assert_eq!(dp.source, DecisionSource::TextScan);
}
#[test]
fn open_question_english_should_i_detected() {
let text = "I finished the migration script. What naming convention should I use for the new columns?";
assert!(detect_chat_open_question(text).is_some());
}
#[test]
fn open_question_with_options_is_none() {
// A numbered menu is an Options decision, NOT an open question.
let text = "1) 用 React\n2) 用原生 JS\n你想用哪个?";
assert!(
detect_chat_open_question(text).is_none(),
"an enumerable-options decision must not be classified as an open question"
);
// And it IS a normal options decision.
assert!(detect_chat_decision(text).is_some());
}
#[test]
fn open_question_non_question_prose_is_none() {
// The agent announcing its own next step is not an open question.
assert!(detect_chat_open_question("好的,我先去实现这个功能。").is_none());
assert!(detect_chat_open_question("我会用 Canvas 来实现,现在开始。").is_none());
}
#[test]
fn open_question_multi_part_design_prompt_is_open_question() {
// REGRESSION (会话 27「中途开启智能决策不生效 / 完全没有决策记录」): a multi-part
// design questionnaire — several NUMBERED TOPICS (each a question, some with
// suggested bullet sub-options) ending on an open-intent statement — is an
// OPEN QUESTION the model tier should answer in free text. It is NOT a
// pick-one menu: there is no "回复编号"/选择 intent, so detect_chat_decision
// declines it. The old `numbered_lines >= 2` guard here mis-read the topic
// numbers as a menu and returned None, so the turn matched NEITHER detector
// and IDMM stayed silent. It must now classify as OpenQuestion.
let text = "好的!我会一步步和你确认设计,然后写出贪吃蛇游戏。\n\n\
先问你几个基础设计问题,我们再往下细化:\n\n\
1. **技术栈偏好**:你想用什么来写?\n\
\u{20} - 推荐:**HTML5 + JavaScript**\n\
\u{20} - 或 **Python + Pygame**\n\n\
2. **界面风格**\n\
\u{20} - 复古像素风\n\
\u{20} - 现代简约风\n\n\
3. **核心规则**\n\
\u{20} - 撞墙死,还是穿墙继续?\n\
\u{20} - 是否显示分数和最高分?\n\n\
请告诉我你的偏好,我们一个一个敲定,然后我再开始写代码。";
assert!(
detect_chat_decision(text).is_none(),
"a multi-question design prompt has no pick-one selection intent → not an Options decision"
);
let dp = detect_chat_open_question(text).expect("a multi-part design prompt is an open question");
assert_eq!(dp.kind, DecisionKind::OpenQuestion);
assert!(dp.options.is_empty(), "an open question carries no enumerable options");
assert!(dp.permission.is_none());
assert_eq!(dp.source, DecisionSource::TextScan);
}
#[test]
fn open_question_multi_part_without_question_mark_via_open_intent() {
// The same shape but with NO `` anywhere — the trailing "请告诉我你…" /
// "你希望…" open-intent cue must still classify it as an open question
// (numbered topics must not disqualify it).
let text = "我们先定几个方向:\n\
1. 配色\n\
2. 字体\n\
3. 布局\n\
告诉我你的偏好,我再继续。";
assert!(detect_chat_decision(text).is_none());
assert!(
detect_chat_open_question(text).is_some(),
"numbered TOPICS + an open-intent trailing line is an open question, not a menu"
);
}
// ── Chinese-convention menu formats (REGRESSION GUARD) ──
// Chinese LLM output overwhelmingly uses the enumeration comma "1、" and
// fullwidth punctuation ("1", "1/2") rather than the ASCII "1." / "1)" /
// "(1/2)" the detector was originally written for. These turns are real
// "选择项" the user sees, but the strict ASCII-only detector scored them as
// zero options → SessionSignal::Done → IDMM never intervened. This is the
// "选择项出现了但不介入" gap for Chinese desktop chats.
#[test]
fn chat_decision_chinese_dunhao_numbered_menu() {
// 顿号编号 "1、" + "请回复编号"(no question mark) — the canonical Chinese
// numbered menu. Must be an Options decision with both options parsed.
let text = "我们先确定渲染方案:\n\
1、Canvas 渲染\n\
2、DOM + CSS\n\
请回复编号告诉我你的选择。";
let dp = detect_chat_decision(text).expect("a 顿号-separated chat decision");
assert_eq!(dp.options.len(), 2, "顿号 '1、/2、' lines must count as numbered options");
assert!(dp.options.iter().any(|o| o.contains("Canvas")));
assert!(dp.options.iter().any(|o| o.contains("DOM")));
}
#[test]
fn chat_decision_fullwidth_paren_numbered_menu() {
// 全角右括号编号 "1".
let text = "1)用 React\n2)用原生 JS\n你想用哪个?";
let dp = detect_chat_decision(text).expect("a fullwidth-paren chat decision");
assert_eq!(dp.options.len(), 2);
}
#[test]
fn chat_decision_fullwidth_inline_token() {
// 全角括号内联选项 "1/2" + 请选择 — has_numeric_choice must accept the
// fullwidth bracket.
let text = "请选择构建方式(1/2)。";
assert!(
detect_chat_decision(text).is_some(),
"fullwidth 1/2 inline token must be recognized as a menu"
);
}
#[test]
fn chat_decision_dunhao_steps_without_intent_is_none() {
// FALSE-POSITIVE GUARD: 顿号 enumeration is now a numbered option, but a
// plain step list with NO selection intent must still NOT be a decision.
let text = "实现步骤:\n1、初始化画布\n2、渲染蛇身\n3、处理键盘\n我现在开始实现。";
assert!(
detect_chat_decision(text).is_none(),
"顿号 step list with no selection intent must not be a decision"
);
}
}
@@ -0,0 +1,109 @@
//! WebSocket event emission for IDMM. Mirrors `nomifun_requirement::events`.
//! Event names follow the `domain.camelCaseAction` convention.
use std::sync::Arc;
use nomifun_api_types::{IdmmState, InterventionRecord, WebSocketMessage};
use nomifun_realtime::EventBroadcaster;
use tracing::error;
/// Emits IDMM status + intervention events through the shared broadcaster.
#[derive(Clone)]
pub struct IdmmEventEmitter {
broadcaster: Arc<dyn EventBroadcaster>,
}
impl IdmmEventEmitter {
pub fn new(broadcaster: Arc<dyn EventBroadcaster>) -> Self {
Self { broadcaster }
}
/// `idmm.statusChanged` — armed/disabled/intervening transitions.
pub fn emit_status_changed(&self, state: &IdmmState) {
self.broadcast("idmm.statusChanged", state);
}
/// `idmm.intervention` — one intervention happened (detected → action → outcome).
pub fn emit_intervention(&self, record: &InterventionRecord) {
self.broadcast("idmm.intervention", record);
}
fn broadcast<T: serde::Serialize>(&self, event_name: &str, payload: &T) {
let value = match serde_json::to_value(payload) {
Ok(v) => v,
Err(e) => {
error!(event = event_name, error = %e, "IDMM event serialize failed");
return;
}
};
self.broadcaster.broadcast(WebSocketMessage::new(event_name, value));
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::{IdmmRunState, IdmmTargetKind};
use std::sync::Mutex;
#[derive(Default)]
struct CapturingBroadcaster {
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
}
impl EventBroadcaster for CapturingBroadcaster {
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
self.events.lock().unwrap().push(event);
}
}
#[test]
fn emits_status_changed_with_payload() {
let bc = Arc::new(CapturingBroadcaster::default());
let emitter = IdmmEventEmitter::new(bc.clone());
let st = IdmmState {
kind: IdmmTargetKind::Conversation,
target_id: "c1".into(),
enabled: true,
fault_enabled: false,
decision_enabled: true,
run_state: IdmmRunState::Armed,
interventions_count: 0,
last_signal: None,
last_intervention_at: None,
sidecar_provider_resolved: false,
config: None,
};
emitter.emit_status_changed(&st);
let evs = bc.events.lock().unwrap();
assert_eq!(evs.len(), 1);
assert_eq!(evs[0].name, "idmm.statusChanged");
assert_eq!(evs[0].data["target_id"], "c1");
assert_eq!(evs[0].data["run_state"], "armed");
}
#[test]
fn emits_intervention_with_payload() {
let bc = Arc::new(CapturingBroadcaster::default());
let emitter = IdmmEventEmitter::new(bc.clone());
let rec = InterventionRecord {
id: "idmmrec_x".into(),
target_kind: "conversation".into(),
target_id: "t1".into(),
watch: "fault".into(),
at: 123,
stall_class: "provider_error".into(),
tier_used: "rule".into(),
category: None,
action: "retry".into(),
detail: None,
outcome: "applied".into(),
reason: Some("transient 500".into()),
confidence: None,
bypass_model: None,
};
emitter.emit_intervention(&rec);
let evs = bc.events.lock().unwrap();
assert_eq!(evs[0].name, "idmm.intervention");
assert_eq!(evs[0].data["action"], "retry");
}
}
@@ -0,0 +1,30 @@
//! IDMM (Intelligent Decision-Making Mode): per-session supervision that keeps
//! agent/terminal sessions alive through provider faults and decision stalls.
//! Rule tier (no LLM) + sidecar backup-model tier, stacking on AutoWork.
//!
//! Layering: `signal`/`config`/`detector`/`prompt`/`util` are pure; `probe`
//! abstracts the target; `sidecar` calls the backup model; `policy` is the
//! escalation ladder; `supervisor` runs the per-session loop + `IdmmManager`
//! (which implements `nomifun_requirement::IdmmHandle`); `service`/`state`/
//! `routes` are the domain API surface.
pub mod config;
pub mod detector;
pub mod events;
pub mod policy;
pub mod probe;
pub mod prompt;
pub mod routes;
pub mod service;
pub mod sidecar;
pub mod signal;
pub mod state;
pub mod supervisor;
pub mod util;
pub use events::IdmmEventEmitter;
pub use routes::idmm_routes;
pub use service::{IdmmService, ProbeDeps};
pub use sidecar::{Completer, LiveCompleter, SidecarClient};
pub use state::IdmmRouterState;
pub use supervisor::{IdmmManager, LoopDeps};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,180 @@
//! Sidecar prompt assembly and strict-JSON decision parsing. Kept pure so it can
//! be unit-tested without a provider. NEVER log the assembled prompt or context
//! in production-visible logs (it contains user data).
use nomifun_api_types::{DecisionStrategy, Tendency};
use crate::signal::StallClass;
/// System prompt establishing the sidecar's role and strict output contract.
/// `action` covers both the option/permission path (`answer_choice`) and the
/// open-question free-text path (`answer_text`).
pub const SIDECAR_SYSTEM: &str = "You are a supervisory co-pilot. Your ONLY job is to unblock and steer a stalled \
agent session. Respond with STRICT JSON only — no prose, no code fences:\n\
{\"action\":\"retry|send_text|answer_choice|answer_text|wait|stop\",\"text\":\"\",\"wait_secs\":0,\"confidence\":0.0,\"reason\":\"\"}\n\
Field meanings: retry = re-run/continue the current step; send_text = inject the given text as a nudge or \
instruction; answer_choice = answer a pending option/permission decision with the given option/value; answer_text = \
answer an OPEN-ENDED question with a concise free-text reply; wait = do nothing for wait_secs; stop = give up and ask \
the human (reason required). confidence is 0..1.\n\
Hard rules: obey the DECISION POLICY exactly. Never propose destructive actions unless the SAFETY block allows them. \
Prefer the smallest action that unblocks the session.";
/// Render the human-readable decision-policy block from a [`DecisionStrategy`]:
/// the tendency, the on-blocked behavior, the never-destructive guard, and the
/// optional user freeform policy. Threaded into both the option and open-question
/// prompts (plan D5/D6) so the model is bound by the same structured guardrails
/// that drive the rule tier.
fn policy_block(strat: &DecisionStrategy) -> String {
let tendency = match strat.tendency {
Tendency::Conservative => "conservative (prefer the safest option; ask/halt when unsure)",
Tendency::Balanced => "balanced",
Tendency::Aggressive => "aggressive (keep work moving; decide decisively when safe)",
};
let freeform = strat.freeform_policy.as_deref().map(str::trim).filter(|s| !s.is_empty());
let freeform = freeform.unwrap_or(
"(none provided — act conservatively; prefer recommended/default options and avoid irreversible actions)",
);
format!(
"tendency={tendency}\non_blocked={:?}\nfreeform_policy:\n{freeform}",
strat.on_blocked,
)
}
/// Build the user message for an OPTION / PERMISSION decision or a fault/idle
/// stall: policy + safety + stall + context blocks.
pub fn build_user_prompt(strat: &DecisionStrategy, class: StallClass, detail: &str, context: &str) -> String {
let never_destructive = strat.categories.option_decision.never_destructive;
format!(
"DECISION POLICY (obey strictly):\n{}\n\n\
SAFETY: allow_destructive={}\n\n\
STALL: class={} detail={}\n\n\
RECENT CONTEXT (most recent last; may be truncated):\n{}",
policy_block(strat),
!never_destructive,
class.as_str(),
detail,
context,
)
}
/// Build the user message for an OPEN-ended question (纯问答, D6). Asks for a
/// concise free-text answer bounded by `max_answer_chars`, constrained by the
/// strategy's tendency / freeform policy. The model must reply with
/// `action=answer_text`.
pub fn build_open_question_prompt(
strat: &DecisionStrategy,
question: &str,
context: &str,
max_answer_chars: u32,
) -> String {
format!(
"DECISION POLICY (obey strictly):\n{}\n\n\
TASK: The agent asked the user an OPEN-ENDED question and is blocked waiting for a reply. \
Answer it on the user's behalf so the work continues. Be concise (≤{max_answer_chars} characters), \
decisive per the tendency above, and never commit to irreversible/destructive actions. \
Reply with action=answer_text and put your answer in `text`. If you cannot answer safely, use action=stop.\n\n\
OPEN QUESTION:\n{question}\n\n\
RECENT CONTEXT (most recent last; may be truncated):\n{context}",
policy_block(strat),
)
}
/// The strict JSON decision the sidecar must return.
#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
pub struct SidecarDecision {
pub action: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub wait_secs: u64,
#[serde(default)]
pub confidence: f32,
#[serde(default)]
pub reason: String,
}
/// Parse the model's reply into a decision, tolerating ```json fences and
/// surrounding prose by extracting the outermost `{ … }` span.
pub fn parse_decision(raw: &str) -> Option<SidecarDecision> {
let start = raw.find('{')?;
let end = raw.rfind('}')?;
if end < start {
return None;
}
serde_json::from_str(&raw[start..=end]).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::{DecisionStrategy, Tendency};
#[test]
fn parse_decision_plain() {
let d = parse_decision(r#"{"action":"retry","confidence":0.9,"reason":"transient 500"}"#).unwrap();
assert_eq!(d.action, "retry");
assert_eq!(d.confidence, 0.9);
assert_eq!(d.reason, "transient 500");
}
#[test]
fn parse_decision_with_fence_and_prose() {
let raw = "Here is my decision:\n```json\n{\"action\":\"answer_choice\",\"text\":\"1\"}\n```\nDone.";
let d = parse_decision(raw).unwrap();
assert_eq!(d.action, "answer_choice");
assert_eq!(d.text, "1");
}
#[test]
fn parse_decision_answer_text_open_question() {
let d = parse_decision(r#"{"action":"answer_text","text":"用 LRU 缓存","confidence":0.8}"#).unwrap();
assert_eq!(d.action, "answer_text");
assert_eq!(d.text, "用 LRU 缓存");
}
#[test]
fn parse_decision_garbage_is_none() {
assert!(parse_decision("I cannot help with that.").is_none());
assert!(parse_decision("").is_none());
}
#[test]
fn build_user_prompt_includes_policy_and_stall() {
let strat = DecisionStrategy {
freeform_policy: Some("never delete data".into()),
..Default::default()
};
let p = build_user_prompt(&strat, StallClass::ProviderError, "http 500", "...");
assert!(p.contains("never delete data"));
assert!(p.contains("class=provider_error"));
// never_destructive defaults true → allow_destructive=false.
assert!(p.contains("allow_destructive=false"));
}
#[test]
fn build_user_prompt_handles_empty_freeform() {
let strat = DecisionStrategy::default();
let p = build_user_prompt(&strat, StallClass::Idle, "no output 90s", "ctx");
assert!(p.contains("act conservatively"));
}
#[test]
fn build_user_prompt_tendency_aggressive_reflected() {
let strat = DecisionStrategy {
tendency: Tendency::Aggressive,
..Default::default()
};
let p = build_user_prompt(&strat, StallClass::Decision, "pick one", "ctx");
assert!(p.contains("aggressive"));
}
#[test]
fn build_open_question_prompt_bounds_and_instructs() {
let strat = DecisionStrategy::default();
let p = build_open_question_prompt(&strat, "你希望缓存怎么设计?", "ctx", 600);
assert!(p.contains("OPEN QUESTION"));
assert!(p.contains("你希望缓存怎么设计"));
assert!(p.contains("answer_text"));
assert!(p.contains("600"));
}
}
@@ -0,0 +1,158 @@
//! IDMM HTTP routes. Handlers do request/response transformation only; all
//! logic lives in `IdmmService`. Auth is layered externally in nomifun-app
//! (mirrors the requirement routes).
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Extension, Json, Path, Query, State};
use axum::routing::{get, post};
use nomifun_api_types::{
ApiResponse, IdmmConfig, IdmmSettings, IdmmState, IdmmTargetKind, InterventionRecord, SetIdmmRequest,
};
use nomifun_auth::CurrentUser;
use nomifun_common::AppError;
use serde::Deserialize;
use crate::state::IdmmRouterState;
/// Default `?limit` for `GET .../log` — matches the per-target eviction cap, so
/// the timeline shows every record the aggressive pruning keeps.
const DEFAULT_LOG_LIMIT: i64 = 30;
/// Default `?limit` for the cross-session activity feed (`GET /api/idmm/activity`).
const DEFAULT_ACTIVITY_LIMIT: i64 = 50;
/// Query string for `GET .../log`.
#[derive(Debug, Deserialize)]
struct LogQuery {
/// Max rows to return (most-recent-first). Defaults to [`DEFAULT_LOG_LIMIT`].
limit: Option<i64>,
}
/// Query string for `GET /api/idmm/activity`.
#[derive(Debug, Deserialize)]
struct ActivityQuery {
/// Max rows to return (most-recent-first). Defaults to [`DEFAULT_ACTIVITY_LIMIT`].
limit: Option<i64>,
}
pub fn idmm_routes(state: IdmmRouterState) -> Router {
Router::new()
.route("/api/idmm", post(set_idmm))
.route("/api/idmm/settings", get(get_settings).put(set_settings))
.route("/api/idmm/activity", get(get_activity).delete(clear_activity))
.route("/api/idmm/{kind}/{target_id}", get(get_idmm))
.route("/api/idmm/{kind}/{target_id}/intervene", post(intervene))
.route("/api/idmm/{kind}/{target_id}/log", get(get_log).delete(clear_log))
.with_state(state)
}
/// Resolve + ownership-check a `{kind}/{target_id}` pair.
async fn resolve_owned(
state: &IdmmRouterState,
kind: &str,
target_id: &str,
user_id: &str,
) -> Result<IdmmTargetKind, AppError> {
let kind =
IdmmTargetKind::parse(kind).ok_or_else(|| AppError::BadRequest(format!("unknown idmm target kind: {kind}")))?;
if kind == IdmmTargetKind::Terminal {
state.service.verify_terminal_owner(target_id, user_id).await?;
}
Ok(kind)
}
async fn set_idmm(
State(state): State<IdmmRouterState>,
Extension(user): Extension<CurrentUser>,
body: Result<Json<SetIdmmRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<IdmmState>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if req.kind == IdmmTargetKind::Terminal {
state.service.verify_terminal_owner(&req.target_id, &user.id).await?;
}
let cfg: IdmmConfig = req.config;
state.service.save_config(req.kind, &req.target_id, &cfg).await?;
let st = state.service.build_state(req.kind, &req.target_id).await?;
Ok(Json(ApiResponse::ok(st)))
}
async fn get_idmm(
State(state): State<IdmmRouterState>,
Extension(user): Extension<CurrentUser>,
Path((kind, target_id)): Path<(String, String)>,
) -> Result<Json<ApiResponse<IdmmState>>, AppError> {
let kind = resolve_owned(&state, &kind, &target_id, &user.id).await?;
let st = state.service.build_state(kind, &target_id).await?;
Ok(Json(ApiResponse::ok(st)))
}
async fn intervene(
State(state): State<IdmmRouterState>,
Extension(user): Extension<CurrentUser>,
Path((kind, target_id)): Path<(String, String)>,
) -> Result<Json<ApiResponse<IdmmState>>, AppError> {
let kind = resolve_owned(&state, &kind, &target_id, &user.id).await?;
state.service.intervene_now(kind, &target_id).await?;
let st = state.service.build_state(kind, &target_id).await?;
Ok(Json(ApiResponse::ok(st)))
}
async fn get_log(
State(state): State<IdmmRouterState>,
Extension(user): Extension<CurrentUser>,
Path((kind, target_id)): Path<(String, String)>,
Query(q): Query<LogQuery>,
) -> Result<Json<ApiResponse<Vec<InterventionRecord>>>, AppError> {
let kind = resolve_owned(&state, &kind, &target_id, &user.id).await?;
let limit = q.limit.unwrap_or(DEFAULT_LOG_LIMIT);
let log = state.service.log(kind, &target_id, limit).await?;
Ok(Json(ApiResponse::ok(log)))
}
async fn clear_log(
State(state): State<IdmmRouterState>,
Extension(user): Extension<CurrentUser>,
Path((kind, target_id)): Path<(String, String)>,
) -> Result<Json<ApiResponse<u64>>, AppError> {
let kind = resolve_owned(&state, &kind, &target_id, &user.id).await?;
let removed = state.service.clear_log(kind, &target_id).await?;
Ok(Json(ApiResponse::ok(removed)))
}
async fn get_activity(
State(state): State<IdmmRouterState>,
Extension(_user): Extension<CurrentUser>,
Query(q): Query<ActivityQuery>,
) -> Result<Json<ApiResponse<Vec<InterventionRecord>>>, AppError> {
let limit = q.limit.unwrap_or(DEFAULT_ACTIVITY_LIMIT);
let activity = state.service.recent_activity(limit).await?;
Ok(Json(ApiResponse::ok(activity)))
}
async fn clear_activity(
State(state): State<IdmmRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<u64>>, AppError> {
let removed = state.service.clear_all_activity().await?;
Ok(Json(ApiResponse::ok(removed)))
}
async fn get_settings(
State(state): State<IdmmRouterState>,
Extension(_user): Extension<CurrentUser>,
) -> Result<Json<ApiResponse<IdmmSettings>>, AppError> {
let s = state.service.get_settings().await?;
Ok(Json(ApiResponse::ok(s)))
}
async fn set_settings(
State(state): State<IdmmRouterState>,
Extension(_user): Extension<CurrentUser>,
body: Result<Json<IdmmSettings>, JsonRejection>,
) -> Result<Json<ApiResponse<IdmmSettings>>, AppError> {
let Json(settings) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.service.set_settings(&settings).await?;
Ok(Json(ApiResponse::ok(state.service.get_settings().await?)))
}
@@ -0,0 +1,356 @@
//! IDMM business logic: config persistence (conversation `extra.idmm` /
//! `terminal_sessions.idmm`), state assembly, global settings, and the
//! `ConfigReader` + `ProbeFactory` impls that let `IdmmManager` (re)build probes
//! and read config lazily. No axum here.
//!
//! Construction is layered to avoid a cycle: `ProbeDeps` (probe build + config
//! read) needs no manager → it backs the factory/config-reader → those back the
//! `IdmmManager` → the `IdmmService` composes `ProbeDeps` + sidecar + manager.
use std::sync::Arc;
use async_trait::async_trait;
use nomifun_ai_agent::task_manager::IWorkerTaskManager;
use nomifun_api_types::{IdmmConfig, IdmmSettings, IdmmState, IdmmTargetKind, InterventionRecord};
use nomifun_common::AppError;
use nomifun_conversation::ConversationService;
use nomifun_db::models::IdmmInterventionRow;
use nomifun_db::{IClientPreferenceRepository, IConversationRepository, IIdmmInterventionRepository};
use nomifun_terminal::TerminalDriver;
use crate::probe::{ConversationProbe, SessionProbe, TerminalProbe};
use crate::sidecar::{PREF_BACKUP_MODEL, PREF_BACKUP_PROVIDER, PREF_DEFAULT_STEERING, SidecarClient};
use crate::supervisor::{ConfigReader, IdmmManager, ProbeFactory, build_state};
const SYSTEM_DEFAULT_USER_ID: &str = "system_default_user";
/// Parse an IDMM string `target_id` (the kind-agnostic target handle on the
/// IDMM DTO) into the integer key the conversation repo / terminal driver now
/// use. A non-numeric id yields an explicit NotFound (spec §2.5/§7.4).
fn parse_target_id(target_id: &str) -> Result<i64, AppError> {
target_id
.parse::<i64>()
.map_err(|_| AppError::NotFound(format!("session {target_id}")))
}
/// Map a persisted row to the API/WS `InterventionRecord` DTO.
fn row_to_record(row: IdmmInterventionRow) -> InterventionRecord {
InterventionRecord {
id: row.id,
target_kind: row.target_kind,
target_id: row.target_id,
watch: row.watch,
at: row.at,
stall_class: row.signal,
tier_used: row.tier_used,
category: row.category,
action: row.action,
detail: row.detail,
outcome: row.outcome,
reason: row.reason,
confidence: row.confidence.map(|c| c as f32),
bypass_model: row.bypass_model,
}
}
/// Collaborators needed to build probes + read config (NO manager → breaks the
/// construction cycle). Shared by the factory, config-reader, and service.
pub struct ProbeDeps {
pub conversation_service: ConversationService,
pub conversation_repo: Arc<dyn IConversationRepository>,
pub terminal_driver: Arc<dyn TerminalDriver>,
pub task_manager: Arc<dyn IWorkerTaskManager>,
}
impl ProbeDeps {
/// Read the persisted per-session config (default when none / store absent).
pub async fn read_config(&self, kind: IdmmTargetKind, target_id: &str) -> Result<IdmmConfig, AppError> {
let raw: Option<serde_json::Value> = match kind {
IdmmTargetKind::Conversation => {
let Some(row) = self.conversation_repo.get(parse_target_id(target_id)?).await? else {
return Ok(IdmmConfig::default());
};
let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or_default();
extra.get("idmm").cloned()
}
IdmmTargetKind::Terminal => match self
.terminal_driver
.read_idmm(parse_target_id(target_id)?)
.await
.map_err(|e| AppError::Internal(format!("read_idmm failed: {e}")))?
{
Some(s) => serde_json::from_str(&s).ok(),
None => None,
},
};
Ok(raw.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default())
}
fn build_probe(&self, kind: IdmmTargetKind, target_id: &str) -> Option<Arc<dyn SessionProbe>> {
match kind {
IdmmTargetKind::Conversation => Some(Arc::new(ConversationProbe {
task_manager: self.task_manager.clone(),
conversation_service: self.conversation_service.clone(),
conversation_repo: self.conversation_repo.clone(),
conversation_id: target_id.to_string(),
user_id: SYSTEM_DEFAULT_USER_ID.to_string(),
})),
IdmmTargetKind::Terminal => {
// A non-numeric terminal target cannot map to a PTY → no probe.
let id = target_id.parse::<i64>().ok()?;
Some(Arc::new(TerminalProbe::new(self.terminal_driver.clone(), id)))
}
}
}
}
impl ProbeFactory for ProbeDeps {
fn build(&self, kind: IdmmTargetKind, target_id: &str) -> Option<Arc<dyn SessionProbe>> {
self.build_probe(kind, target_id)
}
}
#[async_trait]
impl ConfigReader for ProbeDeps {
async fn read(&self, kind: IdmmTargetKind, target_id: &str) -> IdmmConfig {
self.read_config(kind, target_id).await.unwrap_or_default()
}
}
/// IDMM's API-facing service (config persistence, state, settings, log).
#[derive(Clone)]
pub struct IdmmService {
probe_deps: Arc<ProbeDeps>,
client_prefs: Arc<dyn IClientPreferenceRepository>,
sidecar: Arc<SidecarClient>,
manager: IdmmManager,
records: Arc<dyn IIdmmInterventionRepository>,
}
impl IdmmService {
pub fn new(
probe_deps: Arc<ProbeDeps>,
client_prefs: Arc<dyn IClientPreferenceRepository>,
sidecar: Arc<SidecarClient>,
manager: IdmmManager,
records: Arc<dyn IIdmmInterventionRepository>,
) -> Self {
Self {
probe_deps,
client_prefs,
sidecar,
manager,
records,
}
}
pub fn manager(&self) -> &IdmmManager {
&self.manager
}
/// Whether the RulePlusModel tier has a resolvable bypass model: a per-watch
/// override / global default, OR — for a conversation target — the
/// conversation's own selected model (which becomes the bypass model, so the
/// model tier works with zero extra config on a plain chat). Terminals have
/// no own callable model (their agent CLI owns the model), so they still need
/// an explicit backup. Feeds both `validate` and the
/// `sidecar_provider_resolved` state flag the frontend gates its toggle on.
///
/// Checks both watches' bypass models (either resolving satisfies the
/// requirement — `validate` only demands a backup when an enabled watch is on
/// the model tier, and both watches resolve through the same global default).
async fn sidecar_backup_resolvable(&self, kind: IdmmTargetKind, target_id: &str, cfg: &IdmmConfig) -> bool {
if self.sidecar.backup_resolvable(&cfg.decision_watch.base.bypass_model).await
|| self.sidecar.backup_resolvable(&cfg.fault_watch.base.bypass_model).await
{
return true;
}
if kind == IdmmTargetKind::Conversation
&& let Ok(id) = parse_target_id(target_id)
&& let Ok(Some(row)) = self.probe_deps.conversation_repo.get(id).await
{
let pm = nomifun_conversation::task_options::provider_model_from_conversation_row(&row);
return !pm.provider_id.trim().is_empty();
}
false
}
// ── Config persistence ──
/// Validate + persist a per-session config, then arm/stop supervision.
pub async fn save_config(&self, kind: IdmmTargetKind, target_id: &str, cfg: &IdmmConfig) -> Result<(), AppError> {
let backup_resolvable = self.sidecar_backup_resolvable(kind, target_id, cfg).await;
crate::config::validate(cfg, backup_resolvable).map_err(AppError::BadRequest)?;
match kind {
IdmmTargetKind::Conversation => {
let blob = serde_json::to_value(cfg).map_err(|e| AppError::Internal(e.to_string()))?;
self.probe_deps
.conversation_service
.update_extra(target_id, serde_json::json!({ "idmm": blob }))
.await?;
}
IdmmTargetKind::Terminal => {
let s = serde_json::to_string(cfg).map_err(|e| AppError::Internal(e.to_string()))?;
self.probe_deps
.terminal_driver
.write_idmm(parse_target_id(target_id)?, Some(&s))
.await
.map_err(|e| AppError::Internal(format!("write_idmm failed: {e}")))?;
}
}
if cfg.any_enabled() {
self.manager.ensure(kind, target_id).await;
} else {
self.manager.stop(kind, target_id);
}
Ok(())
}
/// Read the persisted per-session config. Returns `Ok(None)` when no
/// config has been saved for this target (the frontend should then seed
/// the form from `IdmmSettings.default_steering_prompt` instead of from a
/// blank `IdmmConfig::default()`).
pub async fn read_config_persisted(
&self,
kind: IdmmTargetKind,
target_id: &str,
) -> Result<Option<IdmmConfig>, AppError> {
let raw: Option<serde_json::Value> = match kind {
IdmmTargetKind::Conversation => {
let Some(row) = self.probe_deps.conversation_repo.get(parse_target_id(target_id)?).await? else {
return Ok(None);
};
let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or_default();
extra.get("idmm").cloned()
}
IdmmTargetKind::Terminal => match self
.probe_deps
.terminal_driver
.read_idmm(parse_target_id(target_id)?)
.await
.map_err(|e| AppError::Internal(format!("read_idmm failed: {e}")))?
{
Some(s) => serde_json::from_str(&s).ok(),
None => None,
},
};
Ok(raw.and_then(|v| serde_json::from_value(v).ok()))
}
pub async fn read_config(&self, kind: IdmmTargetKind, target_id: &str) -> Result<IdmmConfig, AppError> {
self.probe_deps.read_config(kind, target_id).await
}
/// Assemble the live state (config + manager runtime + backup resolvability).
/// Includes the persisted config (when one exists) so the frontend can
/// rehydrate its form without losing user input on remount (Req4).
pub async fn build_state(&self, kind: IdmmTargetKind, target_id: &str) -> Result<IdmmState, AppError> {
let persisted = self.read_config_persisted(kind, target_id).await?;
let cfg = persisted.clone().unwrap_or_default();
let shared = self.manager.shared_for(kind, target_id);
let resolved = self.sidecar_backup_resolvable(kind, target_id, &cfg).await;
Ok(build_state(
&shared,
kind,
target_id,
&cfg,
resolved,
persisted.as_ref(),
))
}
/// Recent intervention log for a target (most-recent-first), read from the
/// persisted audit table — the DB is the sole source of truth (the supervisor
/// itself keeps only live counters, not a record ring). `limit` caps the rows.
pub async fn log(&self, kind: IdmmTargetKind, target_id: &str, limit: i64) -> Result<Vec<InterventionRecord>, AppError> {
let rows = self
.records
.list_for_target(kind.as_str(), target_id, limit)
.await?;
Ok(rows.into_iter().map(row_to_record).collect())
}
/// Clear all persisted intervention records for a target. Returns the count
/// removed. (Manual "清空记录" + the session-delete cascade both route here.)
pub async fn clear_log(&self, kind: IdmmTargetKind, target_id: &str) -> Result<u64, AppError> {
Ok(self
.records
.delete_for_target(kind.as_str(), target_id)
.await?)
}
/// Cross-session recent intervention feed (most-recent-first across ALL
/// targets), read from the persisted audit table. `limit` caps the rows.
pub async fn recent_activity(&self, limit: i64) -> Result<Vec<InterventionRecord>, AppError> {
let rows = self.records.list_recent(limit).await?;
Ok(rows.into_iter().map(row_to_record).collect())
}
/// Clear EVERY persisted intervention record across all targets. Returns the
/// count removed (manual "清空全部记录").
pub async fn clear_all_activity(&self) -> Result<u64, AppError> {
Ok(self.records.clear_all().await?)
}
/// Force one ladder pass now (manual "act now"): ensures supervision is
/// running; the actual pass happens on the next observed signal.
pub async fn intervene_now(&self, kind: IdmmTargetKind, target_id: &str) -> Result<(), AppError> {
self.manager.ensure(kind, target_id).await;
Ok(())
}
// ── Global settings (client_preferences) ──
pub async fn get_settings(&self) -> Result<IdmmSettings, AppError> {
let rows = self
.client_prefs
.get_by_keys(&[PREF_BACKUP_PROVIDER, PREF_BACKUP_MODEL, PREF_DEFAULT_STEERING])
.await?;
let mut s = IdmmSettings::default();
for r in rows {
match r.key.as_str() {
PREF_BACKUP_PROVIDER => s.backup_provider_id = Some(r.value),
PREF_BACKUP_MODEL => s.backup_model = Some(r.value),
PREF_DEFAULT_STEERING => s.default_steering_prompt = r.value,
_ => {}
}
}
Ok(s)
}
pub async fn set_settings(&self, settings: &IdmmSettings) -> Result<(), AppError> {
let mut entries: Vec<(&str, &str)> = Vec::new();
if let Some(p) = &settings.backup_provider_id {
entries.push((PREF_BACKUP_PROVIDER, p.as_str()));
}
if let Some(m) = &settings.backup_model {
entries.push((PREF_BACKUP_MODEL, m.as_str()));
}
entries.push((PREF_DEFAULT_STEERING, settings.default_steering_prompt.as_str()));
self.client_prefs.upsert_batch(&entries).await?;
Ok(())
}
/// Verify a terminal target belongs to `user_id` (data isolation).
pub async fn verify_terminal_owner(&self, terminal_id: &str, user_id: &str) -> Result<(), AppError> {
let desc = self
.probe_deps
.terminal_driver
.describe(parse_target_id(terminal_id)?)
.await
.map_err(|e| AppError::Internal(format!("describe failed: {e}")))?
.ok_or_else(|| AppError::NotFound(format!("terminal {terminal_id} not found")))?;
if desc.user_id != user_id {
return Err(AppError::Forbidden("not your terminal".into()));
}
Ok(())
}
}
// Service-level persistence + validation + settings are covered end-to-end by
// `nomifun-app/tests/idmm_e2e.rs` against a real in-memory database (per
// AGENTS.md: prefer a real DB over brittle stubs of the agent/conversation
// stack). The pure pieces (config validation, policy, detector, sidecar) are
// unit-tested in their own modules.
@@ -0,0 +1,419 @@
//! Sidecar backup-model caller. Resolves the effective bypass provider/model
//! (per-watch override → global default in `client_preferences` → the session's
//! own model), then runs a one-shot completion and parses the strict-JSON
//! decision (with one retry).
//!
//! The provider call is behind the `Completer` trait so the supervisor tests can
//! inject canned responses without a live provider; the production impl wraps
//! `nomifun_ai_agent::{resolve_provider_config, one_shot_completion}`.
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use nomifun_ai_agent::{one_shot_completion, resolve_provider_config, user_message};
use nomifun_api_types::{BypassModelRef, DecisionStrategy};
use nomifun_db::{IClientPreferenceRepository, IProviderRepository};
use crate::prompt::{SIDECAR_SYSTEM, SidecarDecision, build_open_question_prompt, build_user_prompt, parse_decision};
use crate::signal::StallClass;
/// Global-default preference keys (stored in `client_preferences`).
pub const PREF_BACKUP_PROVIDER: &str = "idmm_backup_provider_id";
pub const PREF_BACKUP_MODEL: &str = "idmm_backup_model";
pub const PREF_DEFAULT_STEERING: &str = "idmm_default_steering_prompt";
const SIDECAR_MAX_TOKENS: u32 = 1024;
/// The provider call seam. Production wraps the real provider; tests inject.
#[async_trait]
pub trait Completer: Send + Sync {
/// Run a system+user completion against `provider_id`/`model`. Returns the
/// assembled text, or `Err(())` on any provider failure (→ rule fallback).
async fn complete(&self, provider_id: &str, model: &str, system: &str, user: &str) -> Result<String, ()>;
}
/// Production completer: provider row → nomi Config → one-shot completion.
pub struct LiveCompleter {
pub provider_repo: Arc<dyn IProviderRepository>,
pub encryption_key: [u8; 32],
pub workspace: PathBuf,
}
#[async_trait]
impl Completer for LiveCompleter {
async fn complete(&self, provider_id: &str, model: &str, system: &str, user: &str) -> Result<String, ()> {
let cfg = resolve_provider_config(
&self.provider_repo,
&self.encryption_key,
provider_id,
model,
&self.workspace,
)
.await
.map_err(|e| {
tracing::warn!(error = %e, "IDMM sidecar provider config resolution failed");
})?;
one_shot_completion(&cfg, system, vec![user_message(user)], SIDECAR_MAX_TOKENS)
.await
.map_err(|e| {
tracing::warn!(error = %e, "IDMM sidecar completion failed");
})
}
}
/// Outcome of a sidecar decision attempt.
#[derive(Debug, Clone)]
pub struct SidecarOutcome {
/// The parsed decision, if the model produced valid JSON.
pub decision: Option<SidecarDecision>,
/// True if the provider call itself failed (vs. produced unparseable text).
pub provider_failed: bool,
/// The `(provider_id, model)` the sidecar resolved and used (or attempted,
/// on a provider failure). `None` only when no backup was resolvable at all.
/// Lets the caller record the audit `bypass_model` without re-resolving.
pub resolved: Option<(String, String)>,
}
/// An open-question answer request (D6): the question text + its char cap. When
/// present, [`SidecarClient::decide`] uses the free-text answer prompt instead
/// of the option/permission prompt.
pub struct OpenQuestionAsk<'a> {
pub question: &'a str,
pub max_answer_chars: u32,
}
/// Resolves the bypass model and runs sidecar decisions.
pub struct SidecarClient {
completer: Arc<dyn Completer>,
client_prefs: Arc<dyn IClientPreferenceRepository>,
}
impl SidecarClient {
pub fn new(completer: Arc<dyn Completer>, client_prefs: Arc<dyn IClientPreferenceRepository>) -> Self {
Self {
completer,
client_prefs,
}
}
/// Read a single global-default preference value.
async fn pref(&self, key: &str) -> Option<String> {
self.client_prefs
.get_by_keys(&[key])
.await
.ok()
.and_then(|rows| rows.into_iter().next())
.map(|p| p.value)
}
/// Resolve effective `(provider_id, model)` from the watch's `bypass_model` +
/// global defaults. `model` falls back to the global default, then to empty
/// (the provider's default).
pub async fn resolve_backup(&self, bypass: &BypassModelRef) -> Option<(String, String)> {
let provider_id = match &bypass.provider_id {
Some(p) if !p.is_empty() => p.clone(),
_ => self.pref(PREF_BACKUP_PROVIDER).await?,
};
let model = match &bypass.model {
Some(m) if !m.is_empty() => m.clone(),
_ => self.pref(PREF_BACKUP_MODEL).await.unwrap_or_default(),
};
Some((provider_id, model))
}
/// Whether a backup provider is resolvable for this watch's bypass model —
/// used by validation + the `sidecar_provider_resolved` state flag.
pub async fn backup_resolvable(&self, bypass: &BypassModelRef) -> bool {
self.resolve_backup(bypass).await.is_some()
}
/// Run one sidecar decision pass.
///
/// `bypass` is the active watch's bypass-model selection (per-watch override →
/// global default). `strategy` drives the prompt's policy block (tendency /
/// freeform / never-destructive). `fallback` is the supervised session's own
/// `(provider_id, model)` — used when no per-watch/global backup is
/// configured, so the model tier works out-of-the-box on a plain desktop chat
/// (the session's own model becomes the bypass model). `open_question`, when
/// `Some`, switches to the free-text answer prompt (D6).
#[allow(clippy::too_many_arguments)]
pub async fn decide(
&self,
bypass: &BypassModelRef,
strategy: &DecisionStrategy,
class: StallClass,
detail: &str,
context: &str,
fallback: Option<(String, String)>,
open_question: Option<OpenQuestionAsk<'_>>,
) -> SidecarOutcome {
let resolved = match self.resolve_backup(bypass).await {
Some(pm) => Some(pm),
None => fallback.filter(|(p, _)| !p.trim().is_empty()),
};
let Some((provider_id, model)) = resolved else {
return SidecarOutcome {
decision: None,
provider_failed: true,
resolved: None,
};
};
let used = (provider_id.clone(), model.clone());
let user = match &open_question {
Some(oq) => build_open_question_prompt(strategy, oq.question, context, oq.max_answer_chars),
None => build_user_prompt(strategy, class, detail, context),
};
// First attempt.
let raw = match self
.completer
.complete(&provider_id, &model, SIDECAR_SYSTEM, &user)
.await
{
Ok(r) => r,
Err(()) => {
return SidecarOutcome {
decision: None,
provider_failed: true,
resolved: Some(used),
};
}
};
if let Some(d) = parse_decision(&raw) {
return SidecarOutcome {
decision: Some(d),
provider_failed: false,
resolved: Some(used),
};
}
// One retry, nudging for strict JSON.
let retry_user = format!("{user}\n\nReturn ONLY the JSON object, nothing else.");
match self
.completer
.complete(&provider_id, &model, SIDECAR_SYSTEM, &retry_user)
.await
{
Ok(r2) => SidecarOutcome {
decision: parse_decision(&r2),
provider_failed: false,
resolved: Some(used),
},
Err(()) => SidecarOutcome {
decision: None,
provider_failed: true,
resolved: Some(used),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomifun_api_types::{BypassModelRef, DecisionStrategy};
use nomifun_db::DbError;
use nomifun_db::models::ClientPreference;
use std::sync::Mutex;
// ── Mock client-preferences repo ──
#[derive(Default)]
struct MockPrefs {
map: Mutex<std::collections::HashMap<String, String>>,
}
impl MockPrefs {
fn with(pairs: &[(&str, &str)]) -> Self {
let m = Self::default();
for (k, v) in pairs {
m.map.lock().unwrap().insert(k.to_string(), v.to_string());
}
m
}
}
#[async_trait]
impl IClientPreferenceRepository for MockPrefs {
async fn get_all(&self) -> Result<Vec<ClientPreference>, DbError> {
Ok(vec![])
}
async fn get_by_keys(&self, keys: &[&str]) -> Result<Vec<ClientPreference>, DbError> {
let map = self.map.lock().unwrap();
Ok(keys
.iter()
.filter_map(|k| {
map.get(*k).map(|v| ClientPreference {
key: k.to_string(),
value: v.clone(),
updated_at: 0,
})
})
.collect())
}
async fn upsert_batch(&self, entries: &[(&str, &str)]) -> Result<(), DbError> {
let mut map = self.map.lock().unwrap();
for (k, v) in entries {
map.insert(k.to_string(), v.to_string());
}
Ok(())
}
async fn delete_keys(&self, keys: &[&str]) -> Result<(), DbError> {
let mut map = self.map.lock().unwrap();
for k in keys {
map.remove(*k);
}
Ok(())
}
}
// ── Mock completer: scripted responses ──
struct ScriptedCompleter {
responses: Mutex<Vec<Result<String, ()>>>,
calls: Mutex<u32>,
}
impl ScriptedCompleter {
fn new(responses: Vec<Result<String, ()>>) -> Self {
Self {
responses: Mutex::new(responses),
calls: Mutex::new(0),
}
}
}
#[async_trait]
impl Completer for ScriptedCompleter {
async fn complete(&self, _p: &str, _m: &str, _s: &str, _u: &str) -> Result<String, ()> {
*self.calls.lock().unwrap() += 1;
let mut r = self.responses.lock().unwrap();
if r.is_empty() { Err(()) } else { r.remove(0) }
}
}
fn bypass() -> BypassModelRef {
BypassModelRef {
provider_id: Some("prov1".into()),
model: Some("m1".into()),
}
}
fn strat() -> DecisionStrategy {
DecisionStrategy::default()
}
#[tokio::test]
async fn resolve_backup_prefers_watch_then_global() {
let prefs = Arc::new(MockPrefs::with(&[
(PREF_BACKUP_PROVIDER, "global_prov"),
(PREF_BACKUP_MODEL, "global_model"),
]));
let comp = Arc::new(ScriptedCompleter::new(vec![]));
let client = SidecarClient::new(comp, prefs);
// Per-watch override wins.
let watch = BypassModelRef {
provider_id: Some("watch_prov".into()),
model: Some("watch_model".into()),
};
assert_eq!(
client.resolve_backup(&watch).await,
Some(("watch_prov".into(), "watch_model".into()))
);
// Empty → global default.
let empty = BypassModelRef::default();
assert_eq!(
client.resolve_backup(&empty).await,
Some(("global_prov".into(), "global_model".into()))
);
}
#[tokio::test]
async fn resolve_backup_none_when_no_provider_anywhere() {
let prefs = Arc::new(MockPrefs::default());
let comp = Arc::new(ScriptedCompleter::new(vec![]));
let client = SidecarClient::new(comp, prefs);
assert!(client.resolve_backup(&BypassModelRef::default()).await.is_none());
assert!(!client.backup_resolvable(&BypassModelRef::default()).await);
}
#[tokio::test]
async fn sidecar_returns_parsed_decision() {
let prefs = Arc::new(MockPrefs::default());
let comp = Arc::new(ScriptedCompleter::new(vec![Ok(
r#"{"action":"retry","confidence":0.9,"reason":"transient"}"#.into(),
)]));
let client = SidecarClient::new(comp, prefs);
let out = client
.decide(&bypass(), &strat(), StallClass::ProviderError, "500", "ctx", None, None)
.await;
assert!(!out.provider_failed);
assert_eq!(out.decision.unwrap().action, "retry");
}
#[tokio::test]
async fn sidecar_retries_once_on_garbage_then_parses() {
let prefs = Arc::new(MockPrefs::default());
let comp = Arc::new(ScriptedCompleter::new(vec![
Ok("sorry, I cannot".into()),
Ok(r#"{"action":"send_text","text":"continue"}"#.into()),
]));
let client = SidecarClient::new(comp.clone(), prefs);
let out = client
.decide(&bypass(), &strat(), StallClass::Idle, "idle", "ctx", None, None)
.await;
assert!(!out.provider_failed);
assert_eq!(out.decision.unwrap().action, "send_text");
assert_eq!(*comp.calls.lock().unwrap(), 2);
}
#[tokio::test]
async fn sidecar_garbage_twice_yields_no_decision() {
let prefs = Arc::new(MockPrefs::default());
let comp = Arc::new(ScriptedCompleter::new(vec![Ok("nope".into()), Ok("still nope".into())]));
let client = SidecarClient::new(comp, prefs);
let out = client
.decide(&bypass(), &strat(), StallClass::Idle, "idle", "ctx", None, None)
.await;
assert!(!out.provider_failed);
assert!(out.decision.is_none());
}
#[tokio::test]
async fn sidecar_provider_error_sets_provider_failed() {
let prefs = Arc::new(MockPrefs::default());
let comp = Arc::new(ScriptedCompleter::new(vec![Err(())]));
let client = SidecarClient::new(comp, prefs);
let out = client
.decide(&bypass(), &strat(), StallClass::ProviderError, "500", "ctx", None, None)
.await;
assert!(out.provider_failed);
assert!(out.decision.is_none());
}
#[tokio::test]
async fn sidecar_open_question_returns_answer_text() {
// D6: an open-question ask uses the free-text prompt and the model
// replies with answer_text.
let prefs = Arc::new(MockPrefs::default());
let comp = Arc::new(ScriptedCompleter::new(vec![Ok(
r#"{"action":"answer_text","text":"用 LRU + 30 分钟 TTL","confidence":0.8}"#.into(),
)]));
let client = SidecarClient::new(comp, prefs);
let out = client
.decide(
&bypass(),
&strat(),
StallClass::OpenQuestion,
"open question: 缓存怎么设计",
"ctx",
None,
Some(OpenQuestionAsk {
question: "你希望缓存怎么设计?",
max_answer_chars: 600,
}),
)
.await;
assert!(!out.provider_failed);
let d = out.decision.unwrap();
assert_eq!(d.action, "answer_text");
assert_eq!(d.text, "用 LRU + 30 分钟 TTL");
}
}
@@ -0,0 +1,173 @@
//! Normalized supervision signals, stall classes, and wake actions — the
//! vocabulary the detector emits and the policy/supervisor consume. Independent
//! of how signals are sourced (agent events vs PTY bytes).
use nomifun_api_types::AgentErrorCode;
/// Where a detected decision prompt came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecisionSource {
/// Parsed from terminal output bytes.
TerminalScan,
/// Parsed from a chat conversation's assistant turn text (a "方案 1/2/3、
/// 请回复编号" style prompt the agent ended its turn on). Plain-desktop
/// conversations only — channel/companion conversations route such menus to
/// a remote human and must NOT be auto-answered (see `ConversationProbe`).
TextScan,
/// An agent `Permission`/`AcpPermission` event.
Permission,
}
/// Whether a detected decision is a discrete option/permission choice or an
/// open-ended question with no enumerable options (纯问答, D6). `Options` is the
/// default (back-compat with the existing numbered-choice / permission path);
/// `OpenQuestion` marks an interrogative end-of-turn that has NO selectable
/// options, which only the model tier may answer (rule tier never guesses an
/// open answer — spec §5.4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DecisionKind {
/// A discrete option / permission decision (numbered choice, y/n, tool
/// permission). The existing auto-pick / confirm path handles it.
#[default]
Options,
/// An open-ended question with no enumerable options. Answered only by the
/// decision watch's model tier (free-text), never by the rule tier.
OpenQuestion,
}
/// A parsed decision prompt awaiting a choice.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecisionPrompt {
/// The raw (ANSI-stripped) prompt text.
pub text: String,
/// Parsed selectable options in order, if any (e.g. `["1) yes", "2) no"]`).
pub options: Vec<String>,
/// The option the CLI marks recommended/default, if detectable.
pub recommended: Option<String>,
pub source: DecisionSource,
/// Whether this is a discrete-options decision (default) or an open-ended
/// question (D6). An `OpenQuestion` carries no `options`/`permission` and is
/// answered with free text only by the model tier.
pub kind: DecisionKind,
/// Set when this is a STRUCTURED tool-permission decision
/// (`Permission`/`AcpPermission`): it is answered by resolving the agent's
/// pending approval via `ConversationService::confirm(call_id, …)`, NOT by
/// injecting a chat message. `None` for text/terminal numbered-choice
/// prompts (answered with their option text). See [`PermissionConfirm`].
pub permission: Option<PermissionConfirm>,
}
/// Structured data needed to resolve a tool-permission decision via the agent's
/// confirmation channel (instead of a free-text chat reply, which never clears
/// the pending approval).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermissionConfirm {
/// Tool-call id the approval is keyed by (`ConversationService::confirm`).
pub call_id: String,
/// `(label, submit-value)` per option, in order. The submit-value is the
/// per-backend token (`option_id` for ACP, `proceed_once`/`cancel`/… for
/// nomi) — IDMM submits it as both `option_id` and `value` so either backend
/// resolves it.
pub options: Vec<(String, String)>,
/// The conservatively-safe "approve once" option's submit-value, set ONLY
/// when it is safe to auto-approve WITHOUT a model (read-only / benign tool).
/// `None` for risky tools (edit/execute): the rule tier must escalate to the
/// sidecar (model judges with the tool details) or halt — never blanket
/// auto-approve a write/exec.
pub safe_value: Option<String>,
}
/// A normalized signal emitted by a `SessionProbe`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SessionSignal {
/// Activity observed; resets the idle timer.
Working,
/// Provider fault. `retryable` mirrors `AgentStreamErrorData.retryable` when known.
ProviderError {
code: Option<AgentErrorCode>,
retryable: Option<bool>,
message: String,
},
/// Non-provider agent error.
AgentError { retryable: Option<bool>, message: String },
/// Quiescent beyond the idle threshold.
Idle,
/// A decision prompt is awaiting input.
Decision(DecisionPrompt),
/// The turn finished normally.
Done,
/// The turn was deliberately cancelled by the user (engines emit
/// `Finish(stop_reason=Cancelled)` only on the user-stop path). NOT a
/// stall: the supervisor must stand down instead of "recovering" work the
/// user just stopped — nudging here was the "I paused it and it started
/// running again" bug.
Cancelled,
/// The session/PTY ended.
Exited,
}
/// Stall classification (drives the ladder + `InterventionRecord.stall_class`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StallClass {
ProviderError,
Idle,
Decision,
/// 纯问答(open-ended question, no enumerable options) — D6.
OpenQuestion,
}
impl StallClass {
pub fn as_str(self) -> &'static str {
match self {
StallClass::ProviderError => "provider_error",
StallClass::Idle => "idle",
StallClass::Decision => "decision",
StallClass::OpenQuestion => "open_question",
}
}
}
/// The concrete action injected into a session to unblock it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WakeAction {
/// Re-submit / "continue" the turn (backoff already applied by the policy).
Retry,
/// Send a free-text nudge or instruction.
SendText(String),
/// Answer a decision prompt (option text / "y" / a value).
AnswerChoice(String),
/// Resolve a STRUCTURED tool-permission approval via the agent's confirm
/// channel (`call_id` + the chosen option's submit-`value`). Distinct from
/// `AnswerChoice` (a chat-text reply) — a permission is a structured oneshot
/// that a chat message would never clear.
Confirm {
call_id: String,
value: String,
always_allow: bool,
},
/// Switch to the next model in the failover queue and re-drive the turn
/// (D6). Resolved by the conversation probe via the conversation service's
/// shared failover helper — the SAME implementation the send-loop uses
/// (`ConversationService::perform_model_failover`), so there is one source
/// of truth for the swap. Terminal/ACP sessions self-manage their model and
/// do NOT support this (the terminal probe degrades it to Retry; see D7).
Failover,
/// Back off for a duration before re-evaluating.
Wait(std::time::Duration),
/// Give up; surface to the user. Carries a reason.
Stop(String),
}
impl WakeAction {
pub fn as_str(&self) -> &'static str {
match self {
WakeAction::Retry => "retry",
WakeAction::SendText(_) => "send_text",
WakeAction::AnswerChoice(_) => "answer_choice",
WakeAction::Confirm { .. } => "confirm",
WakeAction::Failover => "failover",
WakeAction::Wait(_) => "wait",
WakeAction::Stop(_) => "stop",
}
}
}
@@ -0,0 +1,16 @@
//! Router state for the IDMM domain. Holds the `Arc`-wrapped service.
use std::sync::Arc;
use crate::service::IdmmService;
#[derive(Clone)]
pub struct IdmmRouterState {
pub service: Arc<IdmmService>,
}
impl IdmmRouterState {
pub fn new(service: Arc<IdmmService>) -> Self {
Self { service }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,41 @@
//! Small shared helpers.
/// Return the last `max_chars` bytes of `s`, snapped to a char boundary so the
/// result is always valid UTF-8 (never panics on multibyte content). When `s`
/// fits, returns it unchanged.
pub fn tail_chars(s: &str, max_chars: usize) -> String {
if s.len() <= max_chars {
return s.to_string();
}
let mut start = s.len() - max_chars;
while start < s.len() && !s.is_char_boundary(start) {
start += 1;
}
s[start..].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tail_shorter_than_limit_unchanged() {
assert_eq!(tail_chars("abc", 10), "abc");
}
#[test]
fn tail_truncates_to_limit() {
assert_eq!(tail_chars("abcdefgh", 3), "fgh");
}
#[test]
fn tail_never_splits_multibyte() {
// "你好世界" is 12 bytes (3 each). Asking for 7 bytes must snap forward
// to a char boundary, never panic.
let s = "你好世界";
let out = tail_chars(s, 7);
assert!(s.ends_with(&out));
// valid UTF-8 by construction (String); length is a whole number of chars
assert_eq!(out.chars().count(), 2);
}
}