Files
Unlucky-Launcher/crates/agent/src/tools/diagnose.rs
T
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

336 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `diagnose_crash`(02 文档 §3.2 第三条)。
//!
//! 流水线是「**规则在前、模型补位**」(analyse/02 §8.3):本工具只跑 M15 的规则引擎,
//! 把**结构化结论 + 关键片段**交给模型,绝不让模型读裸日志。规则未命中
//! (`candidates` 空 / `needs_more_info`)时,模型再去用 `read_game_log` 自己找证据。
//!
//! P0 与 M15 的桥:M15 的修复动作指向 `java.set` / `memory.set` 这些 **P1 才有的**语义工具 id。
//! P0 只有 `set_config`,所以这里按 [`FixActionKind`] 生成 `p0_next_steps`
//! (`adjust_memory` → `set_config {"path":"java.default_memory_mb"}`,`change_java` →
//! `set_config {"path":"java.selected_path"}`,见 [`MEMORY_KEY`] / [`JAVA_KEY`]),
//! 剩下的动作明确标成「P0 无工具,解释给用户」。
use std::sync::Arc;
use serde_json::{json, Value};
use crate::port::{CrashDiagnosis, DiagnosePort, DiagnosisCandidate, FixAction, FixActionKind};
use crate::redact::redact_text;
use crate::registry::schema::{integer, non_empty_string, object};
use crate::registry::{
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
};
/// 诊断工具名。
pub const DIAGNOSE_CRASH: &str = "diagnose_crash";
/// 单次返回的证据行上限(防 token 爆炸)。
pub const MAX_EVIDENCE_LINES: usize = 40;
/// 证据行默认条数。
pub const DEFAULT_EVIDENCE_LINES: usize = 20;
/// 单条证据最大字符数。
pub const MAX_EVIDENCE_CHARS: usize = 400;
/// 内存建议的配置键(M12 白名单里的键名,见 `setting-接口约定.md` §8)。
pub const MEMORY_KEY: &str = "java.default_memory_mb";
/// Java 路径的配置键。
pub const JAVA_KEY: &str = "java.selected_path";
/// 注册诊断工具。
///
/// # Errors
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
pub fn register(
registry: &mut ToolRegistry,
port: Arc<dyn DiagnosePort>,
) -> Result<(), RegistryError> {
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
let session_id = args
.get("session_id")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `session_id`".to_string()))?;
let max_lines = args
.get("max_evidence_lines")
.and_then(Value::as_u64)
.map_or(DEFAULT_EVIDENCE_LINES, |n| n as usize)
.clamp(1, MAX_EVIDENCE_LINES);
let report = port.diagnose(session_id).map_err(ToolError::Port)?;
Ok(render(&report, max_lines))
});
registry.register(ToolSpec::read_only(
DIAGNOSE_CRASH,
"对某次游戏会话跑规则引擎(59 条崩溃规则)并返回结构化结论:主因、置信度、证据行、修复动作。规则未命中时会提示需要更多信息——此时再用 read_game_log 取 stderr/exit 片段。",
object(
&[
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
(
"max_evidence_lines",
integer("返回多少条证据行", 1, MAX_EVIDENCE_LINES as i64),
),
],
&["session_id"],
),
handler,
))
}
/// 把规则引擎结论压成工具结果。
fn render(report: &CrashDiagnosis, max_evidence_lines: usize) -> ToolOutput {
let candidates: Vec<Value> = report
.candidates
.iter()
.map(|c| render_candidate(c, max_evidence_lines))
.collect();
let secondary: Vec<Value> = report
.secondary
.iter()
.take(5)
.map(|c| {
json!({
"id": c.id,
"severity": c.severity,
"category": c.category,
"confidence": c.confidence,
"message": message_of(c),
})
})
.collect();
let next_steps: Vec<Value> = report
.candidates
.iter()
.flat_map(|c| c.fix_actions.iter())
.map(bridge_to_p0)
.collect();
let content = json!({
"session_id": report.session_id,
"source": report.source,
"needs_more_info": report.needs_more_info,
"exit": report.exit,
"top_confidence": report.top_confidence(),
"candidates": candidates,
"secondary": secondary,
"evidence": cap_lines(&report.evidence, max_evidence_lines),
"p0_next_steps": next_steps,
});
ToolOutput::ok(content, summarize(report))
}
fn render_candidate(candidate: &DiagnosisCandidate, max_evidence_lines: usize) -> Value {
json!({
"id": candidate.id,
"severity": candidate.severity,
"category": candidate.category,
"confidence": candidate.confidence,
"message": message_of(candidate),
"detail": candidate.detail,
"evidence": cap_lines(&candidate.evidence, max_evidence_lines),
"fix_actions": candidate.fix_actions,
})
}
/// `message` 为空时回落显示 i18n key(装配层没渲染时至少不丢信息)。
fn message_of(candidate: &DiagnosisCandidate) -> String {
let text = candidate.message.trim();
if text.is_empty() {
candidate.message_key.clone()
} else {
text.to_string()
}
}
fn summarize(report: &CrashDiagnosis) -> String {
match report.top() {
Some(top) => {
let label = top
.fix_actions
.first()
.map(action_label)
.unwrap_or_else(|| "没有可执行的修复动作".to_string());
format!(
"规则引擎命中 {} 条候选,主因 `{}`(置信度 {:.2});建议:{label}",
report.candidates.len(),
top.id,
top.confidence
)
}
None if report.needs_more_info => {
"规则引擎未命中,且需要更多信息(用 read_game_log 取 stderr/exit 片段后再判断)"
.to_string()
}
None => "规则引擎未命中(59 条规则都不匹配),需要结合日志片段人工/AI 判断".to_string(),
}
}
/// M15 的语义动作 → P0 现有工具。返回给模型看的"下一步"。
fn bridge_to_p0(action: &FixAction) -> Value {
let label = action_label(action);
match action.kind {
FixActionKind::AdjustMemory => {
let value = pick_memory_mb(action).map_or(Value::Null, |mb| json!(mb));
json!({
"action": action.tool,
"kind": action.kind,
"label": label,
"suggested_tool": "set_config",
"suggested_args": { "path": MEMORY_KEY, "value": value },
})
}
FixActionKind::ChangeJava => {
let value = pick_arg(action, &["path", "java_path", "binary", "value"])
.map_or(Value::Null, |p| json!(p));
json!({
"action": action.tool,
"kind": action.kind,
"label": label,
"suggested_tool": "set_config",
"suggested_args": { "path": JAVA_KEY, "value": value },
})
}
other => json!({
"action": action.tool,
"kind": other,
"label": label,
"suggested_tool": Value::Null,
"unavailable_reason": "P0 没有对应工具(P1 落地),请把这个建议解释给用户",
}),
}
}
/// 动作的人话:优先 M15 给的 `label`,否则按 kind 兜底。
fn action_label(action: &FixAction) -> String {
let label = action.label.trim();
if !label.is_empty() {
return label.to_string();
}
match action.kind {
FixActionKind::ChangeJava => "换一个匹配的 Java 版本".to_string(),
FixActionKind::AdjustMemory => "调整最大内存".to_string(),
FixActionKind::ChangeGraphics => "调整图形设置/驱动".to_string(),
FixActionKind::RemoveMod => "移除或替换冲突的 Mod".to_string(),
FixActionKind::Reinstall => "重装/修复对应组件".to_string(),
FixActionKind::SeekHelp => "导出诊断包并反馈".to_string(),
}
}
/// 从动作参数里挑内存上限:先看常见键,再退化成"第一个带数字的值"。
fn pick_memory_mb(action: &FixAction) -> Option<i64> {
if let Some(value) = pick_arg(
action,
&["value", "suggest", "mb", "max_mb", "xmx", "xmx_mb"],
) {
if let Some(mb) = parse_mb(&value) {
return Some(mb);
}
}
action.args.values().find_map(|v| parse_mb(v))
}
fn pick_arg(action: &FixAction, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| action.args.get(*key).cloned())
.filter(|v| !v.trim().is_empty())
}
/// `"4096"` / `"4G"` / `"6144M"` → MB。
fn parse_mb(raw: &str) -> Option<i64> {
let text = raw.trim().to_ascii_lowercase();
let (number, scale) = if let Some(rest) = text.strip_suffix('g') {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix("gb") {
(rest, 1024)
} else if let Some(rest) = text.strip_suffix('m') {
(rest, 1)
} else if let Some(rest) = text.strip_suffix("mb") {
(rest, 1)
} else {
(text.as_str(), 1)
};
number.trim().parse::<i64>().ok().map(|n| n * scale)
}
fn cap_lines(lines: &[String], max_lines: usize) -> Vec<String> {
lines
.iter()
.take(max_lines)
.map(|line| {
let masked = redact_text(line);
if masked.chars().count() <= MAX_EVIDENCE_CHARS {
masked
} else {
let head: String = masked.chars().take(MAX_EVIDENCE_CHARS).collect();
format!("{head}…")
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::MockDiagnose;
fn call(port: &MockDiagnose, session_id: &str) -> ToolOutput {
let report = port.diagnose(session_id).expect("mock 应返回结论");
render(&report, DEFAULT_EVIDENCE_LINES)
}
#[test]
fn renders_candidates_with_p0_bridge_for_reinstall() {
let out = call(&MockDiagnose::new(), "s-1");
assert_eq!(out.content["candidates"].as_array().unwrap().len(), 1);
let confidence = out.content["top_confidence"].as_f64().unwrap();
assert!((confidence - 0.86).abs() < 1e-6, "{confidence}");
let steps = out.content["p0_next_steps"].as_array().unwrap();
assert_eq!(steps[0]["kind"], json!("reinstall"));
assert_eq!(steps[0]["suggested_tool"], Value::Null);
assert!(out.summary.contains("建议:重装该版本的 Forge 组件"));
}
#[test]
fn bridges_memory_action_to_set_config() {
let mut report = MockDiagnose::forge_missing_library();
if let Some(candidate) = report.candidates.first_mut() {
candidate.fix_actions = vec![FixAction {
kind: FixActionKind::AdjustMemory,
tool: "memory.set".to_string(),
label: String::new(),
args: [("suggest".to_string(), "4G".to_string())]
.into_iter()
.collect(),
}];
}
let out = render(&report, 5);
let steps = out.content["p0_next_steps"].as_array().unwrap();
assert_eq!(steps[0]["suggested_tool"], json!("set_config"));
assert_eq!(steps[0]["suggested_args"]["path"], json!(MEMORY_KEY));
assert_eq!(steps[0]["suggested_args"]["value"], json!(4096));
assert_eq!(steps[0]["label"], json!("调整最大内存"));
}
#[test]
fn parses_memory_units() {
assert_eq!(parse_mb("4096"), Some(4096));
assert_eq!(parse_mb("4G"), Some(4096));
assert_eq!(parse_mb("6144mb"), Some(6144));
assert_eq!(parse_mb("abc"), None);
}
#[test]
fn empty_candidates_says_need_more_info() {
let report = CrashDiagnosis {
session_id: "s-2".into(),
source: "rules".into(),
needs_more_info: true,
candidates: vec![],
secondary: vec![],
evidence: vec![],
exit: crate::port::ExitSummary::default(),
};
let out = render(&report, 5);
assert!(out.summary.contains("需要更多信息"));
}
}