Files
Unlucky-Launcher/crates/agent/src/tools/session.rs
T
AskaEth 5f8382565e
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Unlucky Launcher v0.1.0
单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
2026-11-01 20:02:49 +08:00

389 lines
13 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.
//! `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("不存在"));
}
}