Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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' | '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() || ('0'..='9').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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user