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

Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
This commit is contained in:
AskaEth committed 2026-10-31 21:54:10 +08:00
commit bc41dfcf7d
517 files changed
+92844

No files matched your search

+260
View File
@@ -0,0 +1,260 @@
//! 工具注册表:整个 agent 的**唯一真相源**。
//!
//! 一个 `ToolSpec`(名称 + 描述 + serde JSON Schema)投影两份:
//! - 内置客户端的 rig `DynamicTool` 视图;
//! - 对外 MCP server 的 rmcp `ToolRouter` 视图。
//!
//! 两份投影必须一致,用契约测试固定(同 name / 同 description / 同 schema)。
//! 设计依据:`DevDocs/PlanDoc/02-ai-agent-design.md` §1。
//!
//! 工具 v1 清单(M16 起,M39 扩到十个):`get_config` / `set_config` / `get_config_schema` /
//! `list_instances` / `list_versions` / `get_java_status` / `list_accounts` /
//! `list_game_sessions` / `read_game_log` / `diagnose_crash`。
//!
//! 安全策略随工具走:敏感键分级确认、YOLO 模式标记、审计留痕(02 §3.1 / §4b)。
//!
//! ## 落地形态
//!
//! ```text
//! registry(Vec<ToolSpec>)
//! ├─ definitions() → rig `DynamicTool::new` (内置 agent,`brain::rig`)
//! └─ definitions() → rmcp `ToolRoute::new_dyn` (外部 MCP,`mcp::projection`)
//! ```
//!
//! 防漂移的关键一招(research/02 §6.2 建议进 CI):**契约测试**遍历注册表,断言两侧
//! 投影拿到的 `(name, description, schema)` 逐字节相等。因为两侧都只吃
//! [`ToolDefinition`],这条约束在本 crate 里天然成立——测试只需要锁住
//! 「definition 是纯投影」+「schema 根必须是 object」+「名字不重复」。
//!
//! 注册表**不持有任何端口**:工具闭包在构造时就捕获了 `Arc<dyn …Port>`,注册表只是索引。
pub mod schema;
pub mod spec;
pub mod validate;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::sync::Arc;
pub use spec::{
Access, Confirmation, ToolCtx, ToolDefinition, ToolError, ToolFn, ToolOutput, ToolSpec,
};
/// 注册失败原因。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
/// 工具名为空或重复。
DuplicateName(String),
/// schema 不合法(根必须是 `type: object`,否则 MCP 侧直接拒收)。
InvalidSchema {
/// 工具名。
tool: String,
/// 原因。
reason: String,
},
/// 说明为空(模型靠 description 选工具,空说明等于没有这个工具)。
EmptyDescription(String),
}
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateName(name) => write!(f, "工具名重复或为空:`{name}`"),
Self::InvalidSchema { tool, reason } => {
write!(f, "工具 `{tool}` 的 schema 不合法:{reason}")
}
Self::EmptyDescription(name) => write!(f, "工具 `{name}` 缺少说明"),
}
}
}
impl std::error::Error for RegistryError {}
/// 工具注册表。
#[derive(Debug, Default, Clone)]
pub struct ToolRegistry {
specs: Vec<Arc<ToolSpec>>,
index: BTreeMap<String, usize>,
}
impl ToolRegistry {
/// 空注册表。
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// 注册一个工具。注册即校验名/schema/说明,**运行时不再做二次判断**。
///
/// # Errors
/// 名字重复或为空、schema 根不是 object、说明为空时返回 [`RegistryError`]。
pub fn register(&mut self, spec: ToolSpec) -> Result<(), RegistryError> {
let name = spec.name.trim();
if name.is_empty() || self.index.contains_key(name) {
return Err(RegistryError::DuplicateName(name.to_string()));
}
if spec.description.trim().is_empty() {
return Err(RegistryError::EmptyDescription(name.to_string()));
}
if spec.input_schema.get("type").and_then(|t| t.as_str()) != Some("object") {
return Err(RegistryError::InvalidSchema {
tool: name.to_string(),
reason: "根类型必须是 object(rig 与 rmcp 的入参都是对象 schema)".to_string(),
});
}
self.index.insert(name.to_string(), self.specs.len());
self.specs.push(Arc::new(spec));
Ok(())
}
/// 按名查找。
#[must_use]
pub fn get(&self, name: &str) -> Option<&Arc<ToolSpec>> {
self.index.get(name).and_then(|i| self.specs.get(*i))
}
/// 全部工具(按注册顺序,模型看到的顺序稳定)。
#[must_use]
pub fn specs(&self) -> &[Arc<ToolSpec>] {
&self.specs
}
/// 工具名列表(错误提示/审计用)。
#[must_use]
pub fn names(&self) -> Vec<&str> {
self.specs.iter().map(|s| s.name).collect()
}
/// 工具数量。
#[must_use]
pub fn len(&self) -> usize {
self.specs.len()
}
/// 是否没有工具。
#[must_use]
pub fn is_empty(&self) -> bool {
self.specs.is_empty()
}
/// **投影 1/2 的公共载荷**:`(name, description, input_schema)`。
#[must_use]
pub fn definitions(&self) -> Vec<ToolDefinition> {
self.specs.iter().map(|s| s.definition()).collect()
}
/// 只读子集(P1 的 `--readonly` 外部 MCP 模式用)。
#[must_use]
pub fn read_only_definitions(&self) -> Vec<ToolDefinition> {
self.specs
.iter()
.filter(|s| s.access == Access::ReadOnly)
.map(|s| s.definition())
.collect()
}
/// **只读模式**的注册表副本(外部 MCP 默认形态)。
///
/// 返回的是「物理上不含写工具」的新注册表:`tools/list` 里看不到 `set_config`,
/// 硬调它也会在编排层得到 `unknown_tool`(并留一条审计),而不是静默执行。
/// 这比「列表过滤 + 调用拦截」两套逻辑更不容易漏——只有一份子集,两侧天然一致。
#[must_use]
pub fn read_only_subset(&self) -> Self {
let mut subset = Self::new();
for spec in self.specs.iter().filter(|s| s.access == Access::ReadOnly) {
let name = spec.name.to_string();
if subset.index.insert(name, subset.specs.len()).is_none() {
subset.specs.push(Arc::clone(spec));
}
}
subset
}
/// 名 → access(装配处检查白名单/只读模式)。
#[must_use]
pub fn access_of(&self, name: &str) -> Option<Access> {
self.get(name).map(|s| s.access)
}
/// 去重后的名字集合(契约测试用)。
#[must_use]
pub fn unique_names(&self) -> BTreeSet<&str> {
self.specs.iter().map(|s| s.name).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::schema::{non_empty_string, object};
use serde_json::{json, Value};
fn echo(name: &'static str) -> ToolSpec {
ToolSpec::read_only(
name,
"回显",
object(&[("text", non_empty_string("文本"))], &["text"]),
Arc::new(|_ctx: &ToolCtx, args: &Value| Ok(ToolOutput::ok(args.clone(), "echo"))),
)
}
#[test]
fn register_and_lookup() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(reg.len(), 1);
assert_eq!(reg.names(), vec!["echo"]);
assert!(reg.get("echo").is_some());
assert!(reg.get("nope").is_none());
assert_eq!(reg.access_of("echo"), Some(Access::ReadOnly));
}
#[test]
fn rejects_duplicate_empty_and_non_object_schema() {
let mut reg = ToolRegistry::new();
reg.register(echo("echo")).unwrap();
assert_eq!(
reg.register(echo("echo")),
Err(RegistryError::DuplicateName("echo".into()))
);
let bad = ToolSpec::read_only(
"bad",
"x",
json!({ "type": "array" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(bad),
Err(RegistryError::InvalidSchema { .. })
));
let no_desc = ToolSpec::read_only(
"nodesc",
" ",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "x"))),
);
assert!(matches!(
reg.register(no_desc),
Err(RegistryError::EmptyDescription(_))
));
}
#[test]
fn definitions_are_stable_and_filterable() {
let mut reg = ToolRegistry::new();
reg.register(echo("a")).unwrap();
reg.register(ToolSpec::mutating(
"b",
"写",
json!({ "type": "object" }),
Arc::new(|_, _| Ok(ToolOutput::ok(Value::Null, "b"))),
))
.unwrap();
assert_eq!(reg.definitions().len(), 2);
assert_eq!(reg.read_only_definitions().len(), 1);
assert_eq!(reg.definitions()[0].name, "a");
assert_eq!(reg.unique_names().len(), 2);
}
}