Unlucky Launcher v0.0.1-alpha
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
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
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent), MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
commit
bc41dfcf7d
517 files changed
+92844
No files matched your search
@@ -0,0 +1,110 @@
|
||||
//! `list_accounts`:账户清单(**凭据永不出口**)。
|
||||
//!
|
||||
//! 这个工具存在的理由很具体:用户常问「我有哪几个号 / 这局用的是哪个号」。
|
||||
//! 但 D4/D9 的凭据红线是硬的——工具结果会回灌模型、也可能被发往远端 LLM,
|
||||
//! 所以:
|
||||
//! 1. 端口只回 [`crate::port::AccountSummary`](`AccountMeta` 的公开字段);
|
||||
//! 2. 本文件里**没有任何**能表达 token / 密码的类型(连字段名都不出现);
|
||||
//! 3. 工具结果再过一遍 [`crate::redact`],兜住实现方把凭据塞进 `profile_name` 的意外。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::AccountPort;
|
||||
use crate::registry::schema::object;
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 工具名。
|
||||
pub const LIST_ACCOUNTS: &str = "list_accounts";
|
||||
|
||||
/// 注册 `list_accounts`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn AccountPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
|
||||
let accounts = port.list_accounts().map_err(ToolError::Port)?;
|
||||
let count = accounts.len();
|
||||
let display: Vec<String> = accounts
|
||||
.iter()
|
||||
.map(|a| {
|
||||
if a.profile_name.trim().is_empty() {
|
||||
format!("{}({})", a.id, a.kind)
|
||||
} else {
|
||||
format!("{}({})", a.profile_name, a.kind)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut content = json!({
|
||||
"count": count,
|
||||
"accounts": accounts,
|
||||
"credential_policy": "账户凭据(access token / 刷新令牌 / authlib 密码)永不经由 AI 工具出口",
|
||||
});
|
||||
// 兜底脱敏:端口实现若把敏感串塞进了公开字段,这里也不会带出去。
|
||||
content = crate::redact::redact_texts(&content);
|
||||
Ok(ToolOutput::ok(
|
||||
content,
|
||||
if count == 0 {
|
||||
"还没有任何账户".to_string()
|
||||
} else {
|
||||
format!("账户 {count} 个:{}", display.join("、"))
|
||||
},
|
||||
))
|
||||
});
|
||||
|
||||
registry.register(ToolSpec::read_only(
|
||||
LIST_ACCOUNTS,
|
||||
"列出启动器里已登录的账户(展示名 / 轨道 offline|microsoft|authlib / 档案 UUID / 创建时间)。**永不返回任何凭据**:令牌与密码不在工具结果的类型里,需要登录态请让用户在启动器界面查看。",
|
||||
object(&[], &[]),
|
||||
handler,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockAccounts;
|
||||
|
||||
fn registry(port: Arc<dyn AccountPort>) -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, port).expect("注册应成功");
|
||||
registry
|
||||
}
|
||||
|
||||
fn call(registry: &ToolRegistry) -> Value {
|
||||
let spec = registry.get(LIST_ACCOUNTS).expect("工具存在").clone();
|
||||
let ctx = ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
};
|
||||
(spec.handler)(&ctx, &json!({})).expect("应成功").content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_accounts_without_credentials() {
|
||||
let content = call(®istry(Arc::new(MockAccounts::new())));
|
||||
assert_eq!(content["count"], json!(1));
|
||||
assert_eq!(content["accounts"][0]["profile_name"], json!("Steve"));
|
||||
// 只扫账户数据本身:`credential_policy` 那句说明文字里本来就写着 "token"。
|
||||
let fields = content["accounts"].to_string().to_lowercase();
|
||||
for forbidden in ["token", "password", "refresh", "secret"] {
|
||||
assert!(
|
||||
!fields.contains(forbidden),
|
||||
"账户字段含 `{forbidden}`:{fields}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_list_is_not_an_error() {
|
||||
let content = call(®istry(Arc::new(MockAccounts::empty())));
|
||||
assert_eq!(content["count"], json!(0));
|
||||
assert_eq!(content["accounts"], json!([]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! `get_config` / `set_config`(02 文档 §3.1「有权限修改 ULL 各项配置」)。
|
||||
//!
|
||||
//! 安全边界都落在这里,但**分级确认的"要不要问"不在这里**:
|
||||
//! 工具只负责「白名单过滤 → 生成 diff → 需要确认时只报价不执行」,
|
||||
//! 问不问由编排层按 [`crate::confirm::ConfirmPolicy`] 决定(YOLO 也只改那一处)。
|
||||
//!
|
||||
//! 值校验(类型/范围/schema)归 M12 的配置 schema 所有:agent 不复制一份,
|
||||
//! 否则两处规则迟早漂移(02 文档 §3.1「schema 强校验」)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::confirm::Sensitivity;
|
||||
use crate::port::{SettingKey, SettingPort};
|
||||
use crate::registry::schema::{any, non_empty_string, object, string};
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 读配置。
|
||||
pub const GET_CONFIG: &str = "get_config";
|
||||
/// 写配置。
|
||||
pub const SET_CONFIG: &str = "set_config";
|
||||
/// 错误提示里最多列多少个可写键(防"把白名单灌给模型")。
|
||||
pub const MAX_LISTED_KEYS: usize = 16;
|
||||
/// 摘要里值的最大展示长度。
|
||||
const BRIEF_CHARS: usize = 60;
|
||||
|
||||
/// 注册两个配置工具。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn SettingPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
registry.register(get_config_spec(Arc::clone(&port)))?;
|
||||
registry.register(set_config_spec(port))
|
||||
}
|
||||
|
||||
fn get_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty());
|
||||
match path {
|
||||
None => {
|
||||
let summary = port.summary();
|
||||
let writable = port.writable_keys();
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"scope": "all",
|
||||
"values": summary,
|
||||
"writable_keys": writable.iter().map(describe_key).collect::<Vec<_>>(),
|
||||
}),
|
||||
format!(
|
||||
"读取全部配置概要({} 个键,其中 {} 个 AI 可写)",
|
||||
summary.as_object().map_or(0, serde_json::Map::len),
|
||||
writable.len()
|
||||
),
|
||||
))
|
||||
}
|
||||
Some(path) => {
|
||||
// 凭据类键一律不读:值会进 ToolOutput.content → 回灌模型 → 发往远端 LLM,
|
||||
// 违反本 crate 的硬边界(AI 永不接触账户凭据,见 `lib.rs`)。
|
||||
// 这是 agent 侧的第二道防线:第一道是 M12 不把凭据放白名单/概要。
|
||||
if crate::redact::is_sensitive_key(path) {
|
||||
return Err(ToolError::NotAllowed(format!(
|
||||
"`{path}` 是凭据类配置,AI 不可读取也不可写入;请让用户自己在设置页查看。"
|
||||
)));
|
||||
}
|
||||
let value = port.get(path).map_err(ToolError::Port)?;
|
||||
Ok(ToolOutput::ok(
|
||||
json!({ "scope": "key", "path": path, "value": value }),
|
||||
format!("读取配置 `{path}` = {}", brief(&value)),
|
||||
))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
GET_CONFIG,
|
||||
"读取启动器配置。不带 path 返回全部配置概要(含 AI 可写键及其敏感等级);带 path 返回单个键的值。",
|
||||
object(
|
||||
&[(
|
||||
"path",
|
||||
string("配置键点分路径,如 `download.concurrency`;省略则返回全部概要"),
|
||||
)],
|
||||
&[],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn set_config_spec(port: Arc<dyn SettingPort>) -> ToolSpec {
|
||||
let handler: ToolFn =
|
||||
Arc::new(move |ctx: &ToolCtx, args: &Value| apply_set_config(port.as_ref(), ctx, args));
|
||||
|
||||
ToolSpec::mutating(
|
||||
SET_CONFIG,
|
||||
"修改启动器配置(仅限白名单内的键)。普通键立即生效;敏感键(Java 路径/数据目录/代理/AI 端点)会先返回 needs_confirmation,等用户确认后才会真正写入。值必须符合该键的类型与范围。",
|
||||
object(
|
||||
&[
|
||||
("path", non_empty_string("配置键点分路径,必须是 get_config 列出的可写键")),
|
||||
("value", any()),
|
||||
],
|
||||
&["path", "value"],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
/// `set_config` 的实现(独立成函数便于单测直接调用)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 键不在白名单、值缺失、端口写入失败时返回 [`ToolError`]。
|
||||
pub fn apply_set_config(
|
||||
port: &dyn SettingPort,
|
||||
ctx: &ToolCtx,
|
||||
args: &Value,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let path = args
|
||||
.get("path")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|p| !p.is_empty())
|
||||
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `path`".to_string()))?
|
||||
.to_string();
|
||||
let value = args
|
||||
.get("value")
|
||||
.cloned()
|
||||
.ok_or_else(|| ToolError::NotAllowed("缺少参数 `value`".to_string()))?;
|
||||
|
||||
let keys = port.writable_keys();
|
||||
let key = keys
|
||||
.iter()
|
||||
.find(|k| k.path == path)
|
||||
.ok_or_else(|| ToolError::NotAllowed(not_writable_message(&keys, &path)))?;
|
||||
|
||||
// 旧值读不到不影响写(首次设置),只是 diff 少一半。
|
||||
let old = port.get(&path).ok();
|
||||
|
||||
if !ctx.confirmed && key.sensitivity == Sensitivity::High {
|
||||
let diff = json!({
|
||||
"path": path,
|
||||
"old": old,
|
||||
"new": value,
|
||||
"sensitivity": key.sensitivity,
|
||||
"description": key.description,
|
||||
});
|
||||
return Ok(ToolOutput::needs_confirmation(
|
||||
format!(
|
||||
"AI 请求把 `{path}` 从 {} 改为 {}({})",
|
||||
brief_opt(&old),
|
||||
brief(&value),
|
||||
sensitivity_label(key.sensitivity)
|
||||
),
|
||||
diff,
|
||||
key.sensitivity,
|
||||
));
|
||||
}
|
||||
|
||||
let change = port.set(&path, &value).map_err(ToolError::Port)?;
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"status": "written",
|
||||
"path": change.path,
|
||||
"old": change.old,
|
||||
"new": change.new,
|
||||
"sensitivity": key.sensitivity,
|
||||
}),
|
||||
format!(
|
||||
"已把 `{}` 从 {} 改为 {}",
|
||||
change.path,
|
||||
brief_opt(&change.old),
|
||||
brief(&change.new)
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
/// 白名单拒绝的文案:说清"不能写"并给出可写键(模型会据此改用正确路径)。
|
||||
fn not_writable_message(keys: &[SettingKey], path: &str) -> String {
|
||||
let listed: Vec<&str> = keys
|
||||
.iter()
|
||||
.take(MAX_LISTED_KEYS)
|
||||
.map(|k| k.path.as_str())
|
||||
.collect();
|
||||
let rest = keys.len().saturating_sub(listed.len());
|
||||
let tail = if rest > 0 {
|
||||
format!(",另有 {rest} 个键见 get_config 概要")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!(
|
||||
"配置键 `{path}` 不在 AI 可写白名单内。可写键({} 个):{}{tail}",
|
||||
keys.len(),
|
||||
listed.join("、")
|
||||
)
|
||||
}
|
||||
|
||||
fn describe_key(key: &SettingKey) -> Value {
|
||||
json!({
|
||||
"path": key.path,
|
||||
"sensitivity": key.sensitivity,
|
||||
"value_type": key.value_type,
|
||||
"description": key.description,
|
||||
})
|
||||
}
|
||||
|
||||
fn sensitivity_label(sensitivity: Sensitivity) -> &'static str {
|
||||
match sensitivity {
|
||||
Sensitivity::Low => "普通",
|
||||
Sensitivity::Medium => "中等(静默但审计)",
|
||||
Sensitivity::High => "敏感,需确认",
|
||||
}
|
||||
}
|
||||
|
||||
fn brief(value: &Value) -> String {
|
||||
let rendered = match value {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
truncate(&rendered)
|
||||
}
|
||||
|
||||
fn brief_opt(value: &Option<Value>) -> String {
|
||||
value
|
||||
.as_ref()
|
||||
.map_or_else(|| "(未设置)".to_string(), brief)
|
||||
}
|
||||
|
||||
fn truncate(text: &str) -> String {
|
||||
if text.chars().count() <= BRIEF_CHARS {
|
||||
return text.to_string();
|
||||
}
|
||||
let head: String = text.chars().take(BRIEF_CHARS).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockSettings;
|
||||
|
||||
fn ctx(confirmed: bool) -> ToolCtx {
|
||||
ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_keys_outside_whitelist() {
|
||||
let port = MockSettings::new();
|
||||
let err = apply_set_config(
|
||||
&port,
|
||||
&ctx(true),
|
||||
&json!({ "path": "java.random.thing", "value": 1 }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::NotAllowed(_)));
|
||||
assert!(err.message().contains("不在 AI 可写白名单内"));
|
||||
assert!(err.message().contains("download.concurrency"));
|
||||
assert_eq!(port.set_calls(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_key_writes_without_confirmation() {
|
||||
let port = MockSettings::new();
|
||||
let out = apply_set_config(
|
||||
&port,
|
||||
&ctx(false),
|
||||
&json!({ "path": "download.concurrency", "value": 16 }),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.confirmation.is_none());
|
||||
assert_eq!(out.content["status"], json!("written"));
|
||||
assert_eq!(port.value_of("download.concurrency"), Some(json!(16)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn medium_key_writes_silently_too() {
|
||||
// M12:Medium = 静默生效但审计标记,不弹窗
|
||||
let port = MockSettings::new();
|
||||
let out = apply_set_config(
|
||||
&port,
|
||||
&ctx(false),
|
||||
&json!({ "path": "download.region", "value": "cn" }),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.confirmation.is_none());
|
||||
assert_eq!(port.set_calls(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_key_quotes_first_then_writes_after_confirm() {
|
||||
let port = MockSettings::new();
|
||||
let args = json!({ "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" });
|
||||
|
||||
let quoted = apply_set_config(&port, &ctx(false), &args).unwrap();
|
||||
let confirmation = quoted.confirmation.expect("High 键应要求确认");
|
||||
assert_eq!(confirmation.sensitivity, Sensitivity::High);
|
||||
assert_eq!(confirmation.details["new"], json!("D:/jdk17/bin/java.exe"));
|
||||
assert_eq!(port.set_calls(), 0, "未确认前不得写入");
|
||||
|
||||
let written = apply_set_config(&port, &ctx(true), &args).unwrap();
|
||||
assert!(written.confirmation.is_none());
|
||||
assert_eq!(port.set_calls(), 1);
|
||||
assert_eq!(
|
||||
port.value_of("java.selected_path"),
|
||||
Some(json!("D:/jdk17/bin/java.exe"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_credential_key_is_not_writable() {
|
||||
// M12:ai.api_key 是 writable=false,不在白名单里 → 任何情况下都拒绝
|
||||
let port = MockSettings::new();
|
||||
let err = apply_set_config(
|
||||
&port,
|
||||
&ctx(true),
|
||||
&json!({ "path": "ai.api_key", "value": "sk-x" }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ToolError::NotAllowed(_)));
|
||||
assert_eq!(port.set_calls(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_error_is_passed_through() {
|
||||
let port = MockSettings::new();
|
||||
port.fail_set_with("磁盘只读");
|
||||
let err = apply_set_config(
|
||||
&port,
|
||||
&ctx(true),
|
||||
&json!({ "path": "appearance.theme_mode", "value": "metro_light" }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "port_error");
|
||||
assert!(err.message().contains("磁盘只读"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! `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("需要更多信息"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 实例 / 版本 / Java 三个只读工具(M39 任务 3 的读侧)。
|
||||
//!
|
||||
//! 全部只读、全部走 [`crate::port::GamePort`]:agent 不自己去翻 `versions/` 目录,
|
||||
//! 也不自己扫 JDK——那两件事的真相源在 M14(game)与 M33(java),重复一份必然漂。
|
||||
//!
|
||||
//! 输出形状刻意做成「摘要 + 上限」:实例可能几十个、Java 候选可能十几个,
|
||||
//! 一次全塞给模型等于烧 token(02 §3.2 的「单次返回上限防 token 爆炸」同理)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::GamePort;
|
||||
use crate::registry::schema::{integer, object};
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 实例列表工具名。
|
||||
pub const LIST_INSTANCES: &str = "list_instances";
|
||||
/// 版本列表工具名。
|
||||
pub const LIST_VERSIONS: &str = "list_versions";
|
||||
/// Java 现状工具名。
|
||||
pub const GET_JAVA_STATUS: &str = "get_java_status";
|
||||
/// 单次最多返回多少个实例/版本。
|
||||
pub const MAX_ROWS: usize = 50;
|
||||
|
||||
/// 注册三个只读工具。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(registry: &mut ToolRegistry, port: Arc<dyn GamePort>) -> Result<(), RegistryError> {
|
||||
registry.register(list_instances_spec(Arc::clone(&port)))?;
|
||||
registry.register(list_versions_spec(Arc::clone(&port)))?;
|
||||
registry.register(java_status_spec(port))
|
||||
}
|
||||
|
||||
fn limit_arg(args: &Value) -> usize {
|
||||
args.get("limit")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(MAX_ROWS, |n| usize::try_from(n).unwrap_or(MAX_ROWS))
|
||||
.clamp(1, MAX_ROWS)
|
||||
}
|
||||
|
||||
fn limit_schema() -> Value {
|
||||
integer(
|
||||
&format!("最多返回多少条(1~{MAX_ROWS},默认 {MAX_ROWS})"),
|
||||
1,
|
||||
MAX_ROWS as i64,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_instances_spec(port: Arc<dyn GamePort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let limit = limit_arg(args);
|
||||
let mut instances = port.list_instances().map_err(ToolError::Port)?;
|
||||
let total = instances.len();
|
||||
instances.truncate(limit);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"total": total,
|
||||
"shown": instances.len(),
|
||||
"truncated": total > instances.len(),
|
||||
"instances": instances,
|
||||
}),
|
||||
format!("列出 {}/{} 个实例", instances.len(), total),
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
LIST_INSTANCES,
|
||||
"列出启动器里的游戏实例(id / 名称 / 版本 id / 载荷是否已安装 / 独立游戏目录 / 实例级 Java)。要看某个实例的日志先用 list_game_sessions。",
|
||||
object(&[("limit", limit_schema())], &[]),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn list_versions_spec(port: Arc<dyn GamePort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let limit = limit_arg(args);
|
||||
let mut versions = port.list_versions().map_err(ToolError::Port)?;
|
||||
let total = versions.len();
|
||||
versions.truncate(limit);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"total": total,
|
||||
"shown": versions.len(),
|
||||
"truncated": total > versions.len(),
|
||||
"versions": versions,
|
||||
}),
|
||||
format!("列出 {}/{} 个已安装版本", versions.len(), total),
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
LIST_VERSIONS,
|
||||
"列出已安装的游戏版本(`versions/<id>/` 下真实存在的载荷),带类型与被几个实例引用。判断「实例的载荷缺没缺」时配合 list_instances 的 version_present 一起看。",
|
||||
object(&[("limit", limit_schema())], &[]),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn java_status_spec(port: Arc<dyn GamePort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
|
||||
let status = port.java_status().map_err(ToolError::Port)?;
|
||||
let majors: Vec<String> = status
|
||||
.candidates
|
||||
.iter()
|
||||
.filter_map(|c| c.major.map(|m| m.to_string()))
|
||||
.collect();
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"default_memory_mb": status.default_memory_mb,
|
||||
"managed_roots": status.managed_roots,
|
||||
"candidate_count": status.candidates.len(),
|
||||
"majors": majors,
|
||||
"candidates": status.candidates,
|
||||
}),
|
||||
format!(
|
||||
"Java 候选 {} 个(主版本 {})",
|
||||
status.candidates.len(),
|
||||
if majors.is_empty() {
|
||||
"无".to_string()
|
||||
} else {
|
||||
majors.join("/")
|
||||
}
|
||||
),
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
GET_JAVA_STATUS,
|
||||
"读取 Java 现状:托管扫描根、默认堆内存、探测到的全部运行时(路径 / 版本 / 主版本 / 发行方 / 来源)。启动报「不支持的 class 版本」「找不到 Java」时先看这里。",
|
||||
object(&[], &[]),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockGame;
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, Arc::new(MockGame::new())).expect("注册应成功");
|
||||
registry
|
||||
}
|
||||
|
||||
fn call(registry: &ToolRegistry, name: &str, args: Value) -> Value {
|
||||
let spec = registry.get(name).expect("工具存在").clone();
|
||||
let ctx = ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
};
|
||||
(spec.handler)(&ctx, &args).expect("应成功").content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_instances_with_limit() {
|
||||
let content = call(®istry(), LIST_INSTANCES, json!({ "limit": 1 }));
|
||||
assert_eq!(content["total"], json!(1));
|
||||
assert_eq!(content["instances"][0]["id"], json!("inst-1"));
|
||||
assert_eq!(content["truncated"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_versions() {
|
||||
let content = call(®istry(), LIST_VERSIONS, json!({}));
|
||||
assert_eq!(content["total"], json!(2));
|
||||
assert_eq!(content["versions"][0]["id"], json!("1.20.1-forge-47.2.20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_status_summarises_majors() {
|
||||
let content = call(®istry(), GET_JAVA_STATUS, json!({}));
|
||||
assert_eq!(content["candidate_count"], json!(2));
|
||||
assert_eq!(content["majors"], json!(["17", "21"]));
|
||||
assert_eq!(content["default_memory_mb"], json!(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_errors_are_passed_through() {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(
|
||||
&mut registry,
|
||||
Arc::new(MockGame::failing("versions 目录不可读")),
|
||||
)
|
||||
.expect("注册应成功");
|
||||
let spec = registry.get(LIST_VERSIONS).expect("工具存在").clone();
|
||||
let ctx = ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
};
|
||||
let err = (spec.handler)(&ctx, &json!({})).unwrap_err();
|
||||
assert_eq!(err.code(), "port_error");
|
||||
assert!(err.message().contains("versions 目录不可读"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! 工具集 v1(02 文档 §3):配置读写 / 会话与日志 / 崩溃诊断 / 实例与 Java / 账户。
|
||||
//!
|
||||
//! 分工边界(重要,评审时按这条看):
|
||||
//! - **工具**:参数语义 → 端口调用 → 结构化结果;需要确认时**只报价不执行**。
|
||||
//! - **编排层**([`crate::brain::exec`]):schema 校验、确认闸门、YOLO 判定、审计、事件。
|
||||
//! - **端口**([`crate::port`]):真正的 IO 与领域校验(M12/M14/M15/M18/M31/M33 拥有)。
|
||||
//!
|
||||
//! 这样「YOLO」只在一个地方(编排层),工具的测试不需要关心 policy。
|
||||
//!
|
||||
//! 双投影:这里的注册表是**唯一真相源**,内置客户端投成 rig `DynamicTool`,
|
||||
//! 对外 MCP server 投成 rmcp `ToolRoute`([`crate::mcp`])。工具数量与顺序对外一致。
|
||||
|
||||
pub mod accounts;
|
||||
pub mod config;
|
||||
pub mod diagnose;
|
||||
pub mod game;
|
||||
pub mod schema_tool;
|
||||
pub mod session;
|
||||
|
||||
use crate::port::Ports;
|
||||
use crate::registry::{RegistryError, ToolRegistry};
|
||||
|
||||
/// 组装 v1 的全部工具。注册顺序 = 模型看到的顺序(只读在前,写在后)。
|
||||
///
|
||||
/// 顺序不是随便排的:模型在长工具表里更容易选中靠前的项,所以「先看再改」
|
||||
/// (`get_config*` 在 `set_config` 之前)是刻意的提示。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复 / schema 非法时返回 [`RegistryError`](属于编码错误,装配期就该炸)。
|
||||
pub fn build_v1_registry(ports: &Ports) -> Result<ToolRegistry, RegistryError> {
|
||||
let mut registry = ToolRegistry::new();
|
||||
config::register(&mut registry, ports.setting.clone())?;
|
||||
schema_tool::register(&mut registry, ports.setting.clone())?;
|
||||
game::register(&mut registry, ports.game.clone())?;
|
||||
accounts::register(&mut registry, ports.accounts.clone())?;
|
||||
session::register(&mut registry, ports.sessions.clone())?;
|
||||
diagnose::register(&mut registry, ports.diagnose.clone())?;
|
||||
Ok(registry)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! `get_config_schema`:把 M12 的配置 schema(**只含 AI 可写键**)投给模型。
|
||||
//!
|
||||
//! 为什么单独一个工具而不是塞进 `get_config`:模型要能「先看有哪些键、类型是什么、
|
||||
//! 哪些是敏感键」,再决定要不要写——这是一次性的静态信息,和读当前值不是同一件事。
|
||||
//! 拆开还能让 token 预算更可控(schema 比概要大一个量级)。
|
||||
//!
|
||||
//! 凭据边界:schema 由 M12 的 `SettingSchema::to_json_schema()` 生成,只遍历
|
||||
//! `ai.writable && !deprecated` 的键,`ai.api_key`(`locked`)天然不在其中。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::port::SettingPort;
|
||||
use crate::registry::schema::object;
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 工具名。
|
||||
pub const GET_CONFIG_SCHEMA: &str = "get_config_schema";
|
||||
|
||||
/// 注册 `get_config_schema`。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn SettingPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, _args: &Value| {
|
||||
let schema = port.schema();
|
||||
if schema.get("properties").is_none() {
|
||||
return Err(ToolError::Port(
|
||||
"配置 schema 不可用:装配层没有提供 SettingPort::schema()".to_string(),
|
||||
));
|
||||
}
|
||||
let writable = schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.map_or(0, serde_json::Map::len);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({
|
||||
"scope": "ai_writable_schema",
|
||||
"writable_key_count": writable,
|
||||
"schema": schema,
|
||||
}),
|
||||
format!("读取配置 schema({writable} 个 AI 可写键)"),
|
||||
))
|
||||
});
|
||||
|
||||
registry.register(ToolSpec::read_only(
|
||||
GET_CONFIG_SCHEMA,
|
||||
"读取启动器配置的 JSON Schema(只含 AI 可写键)。每个键带类型、取值范围、说明与 `x-ai-sensitivity` 敏感等级;凭据类键不会出现。写配置前先看它。",
|
||||
object(&[], &[]),
|
||||
handler,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockSettings;
|
||||
|
||||
fn ctx() -> ToolCtx {
|
||||
ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_schema_without_credentials() {
|
||||
let port: Arc<dyn SettingPort> = Arc::new(MockSettings::new());
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, port).expect("注册应成功");
|
||||
let spec = registry.get(GET_CONFIG_SCHEMA).expect("工具存在").clone();
|
||||
let out = (spec.handler)(&ctx(), &json!({})).expect("应返回 schema");
|
||||
let text = out.content.to_string();
|
||||
assert!(out.content["writable_key_count"].as_u64().unwrap_or(0) > 0);
|
||||
assert!(text.contains("x-ai-sensitivity"));
|
||||
assert!(!text.contains("ai.api_key"), "凭据键不得进 schema:{text}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
//! `list_game_sessions` / `read_game_log`(02 文档 §3.2「能读取某次游戏崩溃日志」)。
|
||||
//!
|
||||
//! 数据源是 M18 的会话事件流(`DevDocs/SubAgentsTalk/launch-接口约定.md`):
|
||||
//! - 列表读 `index.jsonl`(每会话一行摘要,同 id 取最后一行);
|
||||
//! - 日志读 `<session-id>.jsonl`,**按 `seq` 切片 + 按 `kind` 过滤**,不是按行号。
|
||||
//!
|
||||
//! 两条防 token 爆炸的硬约束:
|
||||
//! 1. 单次最多 [`MAX_LOG_EVENTS`] 条事件(默认 [`DEFAULT_LOG_EVENTS`]);
|
||||
//! 2. 单条 payload 里的字符串超过 [`MAX_PAYLOAD_CHARS`] 截断。
|
||||
//!
|
||||
//! 脱敏:launch 落盘前已经做过一遍(`<home>` / `<redacted:token>`),本工具再跑一遍
|
||||
//! [`crate::redact::redact_text`] 作为第二道防线(幂等,旧会话/第三方 mod 输出可能漏网)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::port::{GameSessionPort, LogEvent, LogPage, LogQuery};
|
||||
use crate::redact::redact_text;
|
||||
use crate::registry::schema::{enum_string_array, integer, non_empty_string, object, string};
|
||||
use crate::registry::{
|
||||
RegistryError, ToolCtx, ToolError, ToolFn, ToolOutput, ToolRegistry, ToolSpec,
|
||||
};
|
||||
|
||||
/// 列出游戏会话。
|
||||
pub const LIST_GAME_SESSIONS: &str = "list_game_sessions";
|
||||
/// 读一个会话的日志。
|
||||
pub const READ_GAME_LOG: &str = "read_game_log";
|
||||
|
||||
/// 单次返回的事件上限(防 token 爆炸的硬闸)。
|
||||
pub const MAX_LOG_EVENTS: usize = 500;
|
||||
/// 默认返回条数。
|
||||
pub const DEFAULT_LOG_EVENTS: usize = 100;
|
||||
/// 单条 payload 字符串的展示上限(字符)。
|
||||
pub const MAX_PAYLOAD_CHARS: usize = 800;
|
||||
/// 列表默认/最大条数。
|
||||
pub const DEFAULT_SESSION_LIMIT: usize = 10;
|
||||
/// 列表最大条数。
|
||||
pub const MAX_SESSION_LIMIT: usize = 50;
|
||||
|
||||
/// `launch-接口约定.md` §3 的事件类型(schema 里给模型做枚举;未知 kind 仍可透传)。
|
||||
pub const KNOWN_KINDS: [&str; 13] = [
|
||||
"session_start",
|
||||
"java_selected",
|
||||
"cmd_built",
|
||||
"natives_extracted",
|
||||
"proc_start",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"window_detected",
|
||||
"exit",
|
||||
"crash_found",
|
||||
"diagnosis",
|
||||
"killed",
|
||||
"session_end",
|
||||
];
|
||||
|
||||
/// 注册两个日志工具。
|
||||
///
|
||||
/// # Errors
|
||||
/// 工具名重复/schema 非法时返回 [`RegistryError`]。
|
||||
pub fn register(
|
||||
registry: &mut ToolRegistry,
|
||||
port: Arc<dyn GameSessionPort>,
|
||||
) -> Result<(), RegistryError> {
|
||||
registry.register(list_sessions_spec(Arc::clone(&port)))?;
|
||||
registry.register(read_log_spec(port))
|
||||
}
|
||||
|
||||
fn list_sessions_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
|
||||
let handler: ToolFn = Arc::new(move |_ctx: &ToolCtx, args: &Value| {
|
||||
let limit = args
|
||||
.get("limit")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(DEFAULT_SESSION_LIMIT, |n| n as usize)
|
||||
.clamp(1, MAX_SESSION_LIMIT);
|
||||
let instance_id = args.get("instance_id").and_then(Value::as_str);
|
||||
let sessions = port
|
||||
.list_sessions(limit, instance_id)
|
||||
.map_err(ToolError::Port)?;
|
||||
|
||||
if sessions.is_empty() {
|
||||
return Ok(ToolOutput::ok(
|
||||
json!({ "sessions": [], "count": 0 }),
|
||||
"没有游戏会话记录(还没启动过游戏,或数据目录不对)".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let crashed = sessions
|
||||
.iter()
|
||||
.filter(|s| s.crash_found || s.silent_failure || s.exit_code.is_some_and(|c| c != 0))
|
||||
.count();
|
||||
let summary = format!(
|
||||
"最近 {} 个游戏会话,其中 {crashed} 个崩溃/异常退出;最近一次 {}({})",
|
||||
sessions.len(),
|
||||
sessions[0].session_id,
|
||||
sessions[0].started_at.as_deref().unwrap_or("启动时间未知")
|
||||
);
|
||||
Ok(ToolOutput::ok(
|
||||
json!({ "sessions": sessions, "count": sessions.len(), "problem_count": crashed }),
|
||||
summary,
|
||||
))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
LIST_GAME_SESSIONS,
|
||||
"列出最近的游戏会话(启动时间、退出码、是否崩溃、规则引擎首选结论)。用于定位要读哪一次会话的日志。",
|
||||
object(
|
||||
&[
|
||||
("limit", integer("返回条数", 1, MAX_SESSION_LIMIT as i64)),
|
||||
("instance_id", string("只看某个实例的会话;省略则全部实例")),
|
||||
],
|
||||
&[],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
fn read_log_spec(port: Arc<dyn GameSessionPort>) -> ToolSpec {
|
||||
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()))?
|
||||
.to_string();
|
||||
|
||||
let kinds: Vec<String> = args
|
||||
.get("kinds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let max_events = args
|
||||
.get("max_events")
|
||||
.and_then(Value::as_u64)
|
||||
.map_or(DEFAULT_LOG_EVENTS, |n| n as usize)
|
||||
.clamp(1, MAX_LOG_EVENTS);
|
||||
|
||||
let query = LogQuery {
|
||||
from_seq: args.get("from_seq").and_then(Value::as_u64),
|
||||
to_seq: args.get("to_seq").and_then(Value::as_u64),
|
||||
kinds,
|
||||
max_events,
|
||||
};
|
||||
let page = port
|
||||
.read_log(&session_id, &query)
|
||||
.map_err(ToolError::Port)?;
|
||||
Ok(render_page(&page))
|
||||
});
|
||||
|
||||
ToolSpec::read_only(
|
||||
READ_GAME_LOG,
|
||||
"读取某次游戏会话的事件流(stdout/stderr 按行、退出事件、crash-report 摘录)。可按 seq 区间和事件类型过滤;单次最多 500 条、单条最长 800 字符,优先用 kinds 过滤 stderr/exit/crash_found 而不是拉全量。",
|
||||
object(
|
||||
&[
|
||||
("session_id", non_empty_string("会话 id,来自 list_game_sessions")),
|
||||
("from_seq", integer("起始 seq(含)", 0, 9_007_199_254_740_991)),
|
||||
("to_seq", integer("结束 seq(含)", 0, 9_007_199_254_740_991)),
|
||||
(
|
||||
"kinds",
|
||||
enum_string_array(&KNOWN_KINDS, "只要这些事件类型;省略则全部", 13),
|
||||
),
|
||||
("max_events", integer("最多返回多少条", 1, MAX_LOG_EVENTS as i64)),
|
||||
],
|
||||
&["session_id"],
|
||||
),
|
||||
handler,
|
||||
)
|
||||
}
|
||||
|
||||
/// 把端口返回的一页日志压成工具结果(含摘要,模型据此决定要不要继续翻页)。
|
||||
fn render_page(page: &LogPage) -> ToolOutput {
|
||||
let events: Vec<Value> = page.events.iter().map(render_event).collect();
|
||||
let kind_summary = page
|
||||
.kind_counts
|
||||
.iter()
|
||||
.map(|(kind, count)| format!("{kind}×{count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
|
||||
let (content, summary) = if events.is_empty() {
|
||||
(
|
||||
json!({
|
||||
"session_id": page.session_id,
|
||||
"total_events": page.total_events,
|
||||
"matched_events": 0,
|
||||
"truncated": false,
|
||||
"kind_counts": page.kind_counts,
|
||||
"first_ts": page.first_ts,
|
||||
"last_ts": page.last_ts,
|
||||
"events": [],
|
||||
"hint": "没有符合过滤条件的事件;把 kinds/from_seq/to_seq 放宽再试",
|
||||
}),
|
||||
format!(
|
||||
"会话 {} 没有匹配的事件(共 {} 条)",
|
||||
page.session_id, page.total_events
|
||||
),
|
||||
)
|
||||
} else {
|
||||
let truncated_note = if page.truncated {
|
||||
"(已截断)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
(
|
||||
json!({
|
||||
"session_id": page.session_id,
|
||||
"total_events": page.total_events,
|
||||
"matched_events": page.matched_events,
|
||||
"truncated": page.truncated,
|
||||
"kind_counts": page.kind_counts,
|
||||
"first_ts": page.first_ts,
|
||||
"last_ts": page.last_ts,
|
||||
"events": events,
|
||||
}),
|
||||
format!(
|
||||
"会话 {}:共 {} 条事件,返回 {} 条{truncated_note};类型分布 {kind_summary}",
|
||||
page.session_id,
|
||||
page.total_events,
|
||||
events.len(),
|
||||
),
|
||||
)
|
||||
};
|
||||
ToolOutput::ok(content, summary)
|
||||
}
|
||||
|
||||
fn render_event(event: &LogEvent) -> Value {
|
||||
json!({
|
||||
"seq": event.seq,
|
||||
"ts": event.ts,
|
||||
"t_ms": event.t_ms,
|
||||
"kind": event.kind,
|
||||
"payload": shrink(&event.payload),
|
||||
})
|
||||
}
|
||||
|
||||
/// 递归脱敏 + 截断(第二道脱敏防线)。
|
||||
fn shrink(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::String(text) => Value::String(cap(&redact_text(text))),
|
||||
Value::Array(items) => Value::Array(items.iter().map(shrink).collect()),
|
||||
Value::Object(map) => {
|
||||
let mut out = Map::with_capacity(map.len());
|
||||
for (key, item) in map {
|
||||
out.insert(key.clone(), shrink(item));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cap(text: &str) -> String {
|
||||
let length = text.chars().count();
|
||||
if length <= MAX_PAYLOAD_CHARS {
|
||||
return text.to_string();
|
||||
}
|
||||
let head: String = text.chars().take(MAX_PAYLOAD_CHARS).collect();
|
||||
format!("{head}…(已截断 {} 字符)", length - MAX_PAYLOAD_CHARS)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testing::MockSessions;
|
||||
use serde_json::json;
|
||||
|
||||
const SESSION: &str = "20261029T124501-9f3c2a";
|
||||
|
||||
fn mock() -> MockSessions {
|
||||
let (summary, events) = MockSessions::crashing_session(SESSION);
|
||||
let (older, older_events) = MockSessions::crashing_session("20261028T100000-aaaaaa");
|
||||
let mut sessions = MockSessions::new();
|
||||
sessions.add(summary, events);
|
||||
sessions.add(older, older_events);
|
||||
sessions
|
||||
}
|
||||
|
||||
fn registry() -> ToolRegistry {
|
||||
let mut registry = ToolRegistry::new();
|
||||
register(&mut registry, Arc::new(mock())).expect("注册应成功");
|
||||
registry
|
||||
}
|
||||
|
||||
/// 走**真实注册表 → 真实 handler**,不复制实现(否则测的是复制品)。
|
||||
fn call(tool: &str, args: Value) -> ToolOutput {
|
||||
let registry = registry();
|
||||
let spec = registry.get(tool).expect("工具应存在").clone();
|
||||
(spec.handler)(
|
||||
&ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
},
|
||||
&args,
|
||||
)
|
||||
.expect("工具应成功")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_sessions_newest_first() {
|
||||
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 5 }));
|
||||
assert_eq!(out.content["count"], json!(2));
|
||||
assert_eq!(out.content["sessions"][0]["session_id"], json!(SESSION));
|
||||
assert_eq!(out.content["problem_count"], json!(2));
|
||||
assert!(out.summary.contains("2 个崩溃/异常退出"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_respects_limit() {
|
||||
let out = call(LIST_GAME_SESSIONS, json!({ "limit": 1 }));
|
||||
assert_eq!(out.content["count"], json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_by_kind_and_keeps_summary() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "kinds": ["stderr"] }),
|
||||
);
|
||||
assert_eq!(out.content["events"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(out.content["kind_counts"]["stdout"], json!(1));
|
||||
assert!(out.summary.contains("共 5 条事件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_when_more_events_than_cap() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "max_events": 2 }),
|
||||
);
|
||||
assert_eq!(out.content["events"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(out.content["truncated"], json!(true));
|
||||
assert_eq!(out.content["matched_events"], json!(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_user_dir_in_payload() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "kinds": ["crash_found"] }),
|
||||
);
|
||||
let payload = &out.content["events"][0]["payload"];
|
||||
assert_eq!(
|
||||
payload["path"],
|
||||
json!(r"C:\Users\***\.minecraft\crash-reports\crash-1.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_empty_result_with_hint() {
|
||||
let out = call(
|
||||
READ_GAME_LOG,
|
||||
json!({ "session_id": SESSION, "kinds": ["window_detected"] }),
|
||||
);
|
||||
assert_eq!(out.content["events"], json!([]));
|
||||
assert!(out.content["hint"].is_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_long_payload_text() {
|
||||
let long = "x".repeat(MAX_PAYLOAD_CHARS + 10);
|
||||
assert!(cap(&long).contains("已截断 10 字符"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_session_is_a_port_error() {
|
||||
let registry = registry();
|
||||
let spec = registry.get(READ_GAME_LOG).expect("工具应存在").clone();
|
||||
let err = (spec.handler)(
|
||||
&ToolCtx {
|
||||
agent_session_id: "s1".to_string(),
|
||||
confirmed: false,
|
||||
},
|
||||
&json!({ "session_id": "nope" }),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "port_error");
|
||||
assert!(err.message().contains("不存在"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user