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

+267
View File
@@ -0,0 +1,267 @@
//! 对话协议类型 + [`ChatProvider`] trait(四态端点配置在 [`super::provider`])。
//!
//! 这里**没有协议方言**:OpenAI/Ollama/Anthropic 的报文差异全部由实现方
//! (阶段 B 的 [`super::rig`])吃掉。本模块只定义循环需要的三样东西:
//! 消息、请求/回复、流式增量回调。
//!
//! 一次工具调用的完整往返:
//! `Assistant { tool_calls }` → 编排层执行 → `Tool { call_id, name, content }` → 再次补全。
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::provider::{ProviderConfig, ProviderError};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::ToolDefinition;
/// 一条工具调用请求(模型产出)。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
/// 调用 id(OpenAI 方言的 `tool_call_id`;Ollama 原生没有 id,由适配层生成)。
pub id: String,
/// 工具名。
pub name: String,
/// 参数(JSON 对象)。
pub arguments: Value,
}
/// 对话消息。
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "role", rename_all = "snake_case")]
pub enum ChatMessage {
/// 系统提示词(每轮由 [`super::BrainConfig::system_prompt`] 重新注入,不进历史)。
System {
/// 提示词正文。
content: String,
},
/// 用户输入。
User {
/// 正文。
content: String,
},
/// 模型回复(可能带工具调用)。
Assistant {
/// 文本正文(工具调用轮可能为空)。
text: String,
/// 工具调用。
tool_calls: Vec<ToolCall>,
},
/// 工具结果回灌。
Tool {
/// 对应调用 id。
call_id: String,
/// 工具名(Ollama 原生靠 `tool_name` 配对,不是 id)。
name: String,
/// 结果 JSON 字符串。
content: String,
},
}
impl ChatMessage {
/// 便捷:系统消息。
#[must_use]
pub fn system(content: impl Into<String>) -> Self {
Self::System {
content: content.into(),
}
}
/// 便捷:用户消息。
#[must_use]
pub fn user(content: impl Into<String>) -> Self {
Self::User {
content: content.into(),
}
}
/// 便捷:工具结果消息。
#[must_use]
pub fn tool(
call_id: impl Into<String>,
name: impl Into<String>,
content: impl Into<String>,
) -> Self {
Self::Tool {
call_id: call_id.into(),
name: name.into(),
content: content.into(),
}
}
}
/// 一次补全请求。
#[derive(Debug, Clone, PartialEq)]
pub struct ChatRequest {
/// 模型名。
pub model: String,
/// 消息(system 在首位)。
pub messages: Vec<ChatMessage>,
/// 工具定义(双投影的 rig 侧载荷)。
pub tools: Vec<ToolDefinition>,
/// 采样温度。
pub temperature: Option<f32>,
/// token 上限。
pub max_tokens: Option<u32>,
}
/// token 用量(本地模型可能不报)。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
/// 输入 token。
pub prompt_tokens: Option<u32>,
/// 输出 token。
pub completion_tokens: Option<u32>,
}
/// 一次补全结果。
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ChatReply {
/// 文本正文。
pub text: String,
/// 工具调用(非空 ⇒ 循环继续)。
pub tool_calls: Vec<ToolCall>,
/// 用量。
pub usage: Option<Usage>,
}
impl ChatReply {
/// 纯文本回复。
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
tool_calls: Vec::new(),
usage: None,
}
}
/// 带工具调用的回复。
#[must_use]
pub fn with_tool_calls(tool_calls: Vec<ToolCall>) -> Self {
Self {
text: String::new(),
tool_calls,
usage: None,
}
}
}
/// 流式增量接收者。实现方必须**非阻塞**。
pub trait DeltaObserver: Send + Sync {
/// 收到一段文本增量。
fn on_delta(&self, text: &str);
}
/// 丢弃增量(不关心流式的调用方)。
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopObserver;
impl DeltaObserver for NoopObserver {
fn on_delta(&self, _text: &str) {}
}
/// 把增量转成 [`AgentEvent::TextDelta`](P1 聊天面板逐字渲染就靠它)。
#[derive(Clone)]
pub struct EventDeltaObserver {
sink: Arc<dyn AgentEventSink>,
}
impl EventDeltaObserver {
/// 用事件流构造。
#[must_use]
pub fn new(sink: Arc<dyn AgentEventSink>) -> Self {
Self { sink }
}
}
impl DeltaObserver for EventDeltaObserver {
fn on_delta(&self, text: &str) {
self.sink.emit(AgentEvent::TextDelta {
text: text.to_string(),
});
}
}
/// 收集增量(测试断言流式拼接)。
#[derive(Debug, Default)]
pub struct CollectingObserver {
chunks: std::sync::Mutex<Vec<String>>,
}
impl CollectingObserver {
/// 收完的内容。
#[must_use]
pub fn collected(&self) -> String {
self.chunks.lock().map(|c| c.concat()).unwrap_or_default()
}
}
impl DeltaObserver for CollectingObserver {
fn on_delta(&self, text: &str) {
if let Ok(mut chunks) = self.chunks.lock() {
chunks.push(text.to_string());
}
}
}
/// 补全未来的箱子类型。
pub type ProviderFuture<'a> =
Pin<Box<dyn Future<Output = Result<ChatReply, ProviderError>> + Send + 'a>>;
/// LLM 端点抽象:**这是 agent 唯一的异步边界**。
///
/// P0 实现 = 测试里的 mock;阶段 B 实现 = [`super::rig`] 的 rig 适配器。
/// 换实现(genai / 自写循环)只需要满足本 trait,[`super::Brain`] 不动。
pub trait ChatProvider: Send + Sync {
/// 当前配置(循环用它取 model/temperature,也用它判断是否启用)。
fn config(&self) -> &ProviderConfig;
/// 一次补全。流式增量通过 `observer` 回吐。
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a>;
}
/// 装配期用的 provider 工厂:按配置造实现(阶段 B 在这里接 rig,测试接 mock)。
pub type ProviderFactory =
Arc<dyn Fn(&ProviderConfig) -> Result<Arc<dyn ChatProvider>, ProviderError> + Send + Sync>;
#[cfg(test)]
mod tests {
use super::*;
use crate::event::MemoryEventSink;
#[test]
fn collecting_observer_concatenates_chunks() {
let observer = CollectingObserver::default();
observer.on_delta("正在");
observer.on_delta("读取日志");
assert_eq!(observer.collected(), "正在读取日志");
}
#[test]
fn event_observer_emits_text_delta() {
let sink = Arc::new(MemoryEventSink::new());
let observer = EventDeltaObserver::new(sink.clone());
observer.on_delta("嗨");
assert_eq!(sink.streamed_text(), "嗨");
}
#[test]
fn message_helpers_round_trip_through_json() {
let messages = vec![
ChatMessage::system("你是助手"),
ChatMessage::user("看看日志"),
ChatMessage::tool("call_1", "read_game_log", "{}"),
];
let json = serde_json::to_string(&messages).expect("可序列化");
let back: Vec<ChatMessage> = serde_json::from_str(&json).expect("可反序列化");
assert_eq!(back, messages);
assert!(json.contains("\"role\":\"tool\""));
}
}
+347
View File
@@ -0,0 +1,347 @@
//! 工具执行编排:schema 校验 → 分级确认 → 执行 → 审计 → 事件。
//!
//! **YOLO 只在这里**(mission M16 任务 3 的分工):工具自己不判断 policy,
//! 只声明「这次需要确认 + diff」;本模块按 [`ConfirmPolicy`] 决定问不问。
//!
//! 一次工具调用最多两条审计记录:
//! 1. 需要确认时先记一条 `awaiting_confirmation`(「AI 想改 X」本身必须留痕);
//! 2. 结局记一条 `ok` / `denied` / `tool_error`(带 `yolo` 与 `confirmed` 标记)。
//!
//! 这两条区分了三种在复盘里完全不同的事:用户点了拒绝、YOLO 自动放行、用户点了允许。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use serde_json::{json, Value};
use crate::audit::{make_record, AuditDraft, AuditOutcome, AuditSink};
use crate::brain::chat::ToolCall;
use crate::clock::SharedClock;
use crate::confirm::{ConfirmDecision, ConfirmGate, ConfirmPolicy, ConfirmRequest, DenyGuidance};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::validate::validate;
use crate::registry::{ToolCtx, ToolError, ToolRegistry};
/// 一次工具执行的结局(回灌给模型的内容 + 成败标记)。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolExecOutcome {
/// 回灌内容(结构化)。
pub content: Value,
/// 是否成功(UI/事件用;模型看的是 `content`)。
pub ok: bool,
}
impl ToolExecOutcome {
/// 成功。
#[must_use]
pub fn ok(content: Value) -> Self {
Self { content, ok: true }
}
/// 失败(`code` 给模型看,帮助它改正后重试)。
#[must_use]
pub fn error(code: &str, message: impl Into<String>) -> Self {
Self {
content: json!({ "status": "error", "code": code, "message": message.into() }),
ok: false,
}
}
/// 序列化成回灌给模型的字符串(工具消息的 `content`)。
#[must_use]
pub fn to_model_content(&self) -> String {
serde_json::to_string(&self.content).unwrap_or_else(|_| {
json!({ "status": "error", "code": "internal", "message": "结果序列化失败" })
.to_string()
})
}
}
/// 工具执行器(无状态,可跨轮复用)。
pub struct ToolExecutor {
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
guidance: DenyGuidance,
confirm_seq: AtomicU64,
}
impl ToolExecutor {
/// 装配(默认拒绝口径 = 内置对话:[`DenyGuidance::InApp`])。
pub fn new(
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
) -> Self {
Self {
registry,
audit,
gate,
policy,
clock,
sink,
guidance: DenyGuidance::InApp,
confirm_seq: AtomicU64::new(0),
}
}
/// 换拒绝口径(外部 MCP 客户端用 [`DenyGuidance::External`]:没有弹窗可点)。
///
/// 只改**文案**:策略、闸门、审计标记一个字都不变——这正是「同一注册表、
/// 同一安全策略、同一审计」在代码上的落点。
#[must_use]
pub fn with_guidance(mut self, guidance: DenyGuidance) -> Self {
self.guidance = guidance;
self
}
/// 执行一次工具调用(含确认与审计)。
pub async fn execute(
&self,
agent_session_id: &str,
call: &ToolCall,
step: usize,
) -> ToolExecOutcome {
let started_at = self.clock.now_unix_ms();
self.sink.emit(AgentEvent::ToolCallStarted {
tool: call.name.clone(),
// 与审计同一套脱敏:事件(P1 会持久化)不能带出凭据明文。
args: crate::redact::redact_args(&call.arguments),
step,
});
let Some(spec) = self.registry.get(&call.name).cloned() else {
let message = format!(
"没有名为 `{}` 的工具。可用工具:{}",
call.name,
self.registry.names().join("、")
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::UnknownTool,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("unknown_tool", message);
};
if let Err(errors) = validate(&spec.input_schema, &call.arguments) {
let message = format!("参数不合法:{}", errors.join(";"));
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::InvalidArgs,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("invalid_args", message);
}
let ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: false,
};
let quoted = match (spec.handler)(&ctx, &call.arguments) {
Ok(output) => output,
Err(err) => return self.fail(agent_session_id, call, err, started_at),
};
let Some(confirmation) = quoted.confirmation.clone() else {
return self.succeed(agent_session_id, call, quoted, false, false, started_at);
};
// ── 需要确认:决定问不问(YOLO 只影响这里)──
let asked = self.policy.requires_confirmation(confirmation.sensitivity);
let yolo_auto = !asked && confirmation.sensitivity == crate::confirm::Sensitivity::High;
if asked {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::AwaitingConfirmation,
&confirmation.summary,
false,
false,
started_at,
);
let request = ConfirmRequest {
id: format!(
"confirm-{}",
self.confirm_seq.fetch_add(1, Ordering::Relaxed) + 1
),
tool: call.name.clone(),
summary: confirmation.summary.clone(),
details: confirmation.details.clone(),
sensitivity: confirmation.sensitivity,
};
self.sink.emit(AgentEvent::ConfirmRequested {
request: Box::new(request.clone()),
});
if self.gate.request(request).await == ConfirmDecision::Denied {
let message = self.guidance.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Denied,
&message,
false,
false,
started_at,
);
self.sink.emit(AgentEvent::ToolDenied {
tool: call.name.clone(),
reason: message.clone(),
});
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("denied", message);
}
}
// ── 已获授权(用户允许或 YOLO):带 confirmed 重放 ──
let confirmed_ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: true,
};
match (spec.handler)(&confirmed_ctx, &call.arguments) {
Ok(output) => {
if let Some(second) = output.confirmation {
// 工具在"已确认"状态下仍要确认 = 实现写错了,明确报出来而不是死循环。
let message = format!(
"工具 `{}` 在已确认状态下仍请求确认:{}",
call.name, second.summary
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo_auto,
asked,
started_at,
);
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("internal", message);
}
self.succeed(agent_session_id, call, output, asked, yolo_auto, started_at)
}
Err(err) => {
self.fail_after_confirm(agent_session_id, call, err, asked, yolo_auto, started_at)
}
}
}
fn succeed(
&self,
agent_session_id: &str,
call: &ToolCall,
output: crate::registry::ToolOutput,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Ok,
&output.summary,
yolo,
confirmed,
started_at,
);
self.finish_event(
&call.name,
true,
&output.summary,
output.confirmation.is_some(),
);
ToolExecOutcome::ok(output.content)
}
fn fail(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
started_at: u64,
) -> ToolExecOutcome {
self.fail_after_confirm(agent_session_id, call, err, false, false, started_at)
}
fn fail_after_confirm(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
let message = err.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo,
confirmed,
started_at,
);
self.finish_event(&call.name, false, &message, false);
ToolExecOutcome::error(err.code(), message)
}
#[allow(clippy::too_many_arguments)]
fn record(
&self,
agent_session_id: &str,
tool: &str,
args: &Value,
outcome: AuditOutcome,
summary: &str,
yolo: bool,
confirmed: bool,
started_at: u64,
) {
let duration_ms = self.clock.now_unix_ms().saturating_sub(started_at);
let draft = AuditDraft {
tool,
args,
outcome,
summary,
yolo,
confirmed,
duration_ms,
};
self.audit
.record(&make_record(self.clock.as_ref(), agent_session_id, draft));
}
fn finish_event(&self, tool: &str, ok: bool, summary: &str, requires_confirmation: bool) {
self.sink.emit(AgentEvent::ToolCallFinished {
tool: tool.to_string(),
ok,
summary: summary.to_string(),
requires_confirmation,
});
}
}
+323
View File
@@ -0,0 +1,323 @@
//! Agent 大脑:Provider 抽象 + 对话循环 + 流式事件。
//!
//! 选型(M10 定稿):`rig-core` + `rig-agent`,**版本锁 `=0.45.x`**——上游 12 周 5 个 minor
//! 的 breaking 频率决定了必须锁死;依赖面只在本文件收口,其它模块不许直接碰 rig,
//! 以便随时换 genai / 自写循环逃生(`DevDocs/PlanDoc/02-ai-agent-design.md` §0)。
//!
//! Provider 形态(02 §2):OpenAI 兼容(默认,含本地 llama.cpp `127.0.0.1:PORT/v1`)、
//! Anthropic 原生、Ollama 原生、以及「禁用」——禁用时规则引擎仍可用。
//!
//! 统一 trait(M16 落地):`stream_chat(messages, tools) -> Stream<Event>`,含 tool_call 事件。
//! 本 crate 的落地形态是 [`chat::ChatProvider::complete`](`request + observer → ChatReply`):
//! rig 0.45 的流在 [`rig`] 适配层里展开成增量回调,循环侧只消费「文本增量 + 工具调用」两件事。
//! **rig 依赖面收口在 [`rig`] 一个文件**,本文件不 `use rig_*`。
//!
//! ## 对话循环(02 文档 §3.3)
//!
//! ```text
//! run_turn(user_input)
//! ├─ provider.complete(request) ← 流式增量经 EventDeltaObserver → UI
//! ├─ 有 tool_calls → ToolExecutor.execute(校验/确认/YOLO/审计)→ Tool 消息回灌
//! └─ 没有 tool_calls → 结束,返回最终文本
//! ```
//!
//! 三条不变量:
//! 1. **system prompt 每轮重新注入**,不进历史(改提示词立刻生效,也不会被压缩逻辑吃掉);
//! 2. 工具结果**永远回灌**(包括失败),模型才有机会改正;
//! 3. 步数上限 [`DEFAULT_MAX_STEPS`] 兜底,防止模型在两个工具间打转烧 token。
pub mod chat;
pub mod exec;
pub mod provider;
pub mod rig;
use std::fmt;
use std::sync::Arc;
use crate::audit::AuditSink;
use crate::clock::SharedClock;
use crate::confirm::{ConfirmGate, ConfirmPolicy};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::ToolRegistry;
pub use chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, EventDeltaObserver,
NoopObserver, ProviderFactory, ProviderFuture, ToolCall, Usage,
};
pub use exec::{ToolExecOutcome, ToolExecutor};
pub use provider::{ProviderConfig, ProviderError, ProviderKind};
/// 一轮对话最多几轮工具调用。
pub const DEFAULT_MAX_STEPS: usize = 8;
/// 内置系统提示词(P0 版本;P2 会按实例上下文按需注入)。
#[must_use]
pub fn default_system_prompt() -> String {
[
"你是 Unlucky-Launcher(ULL,Minecraft 启动器)的内置助手。",
"你能调用工具:读配置(get_config)、改配置(set_config)、列出游戏会话(list_game_sessions)、",
"读会话日志(read_game_log)、跑崩溃规则引擎(diagnose_crash)。",
"纪律:",
"1. 先查后改:改任何配置前先用 get_config 确认键名与现值。",
"2. 崩溃问题先看 diagnose_crash 的规则结论;规则未命中或需要更多信息时,才用 read_game_log",
" 取 stderr / exit / crash_found,不要拉全量日志。",
"3. 只能改 get_config 列出的可写键。敏感键会要求用户确认:被拒绝就换方案,不要重复尝试同一改动。",
"4. 用中文回答,给用户可以立即执行的下一步;不确定时直说不确定,不要编造规则或日志内容。",
]
.join("\n")
}
/// 循环参数。
#[derive(Debug, Clone)]
pub struct BrainConfig {
/// 最多几轮工具调用。
pub max_steps: usize,
/// 系统提示词。
pub system_prompt: String,
}
impl Default for BrainConfig {
fn default() -> Self {
Self {
max_steps: DEFAULT_MAX_STEPS,
system_prompt: default_system_prompt(),
}
}
}
impl BrainConfig {
/// 自定义提示词(步数用默认)。
#[must_use]
pub fn with_system_prompt(system_prompt: impl Into<String>) -> Self {
Self {
max_steps: DEFAULT_MAX_STEPS,
system_prompt: system_prompt.into(),
}
}
}
/// 循环的旁路依赖(审计/确认/时钟/事件)。
pub struct BrainDeps {
/// 审计出口。
pub audit: Arc<dyn AuditSink>,
/// 确认闸门(P0 = [`crate::confirm::NoUiGate`])。
pub gate: Arc<dyn ConfirmGate>,
/// YOLO 等策略快照。
pub policy: ConfirmPolicy,
/// 时间源。
pub clock: SharedClock,
/// 事件出口。
pub sink: Arc<dyn AgentEventSink>,
}
impl BrainDeps {
/// 装配。
pub fn new(
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
) -> Self {
Self {
audit,
gate,
policy,
clock,
sink,
}
}
}
/// 一轮对话的结局。
#[derive(Debug, Clone, PartialEq)]
pub struct TurnOutcome {
/// 最终文本(可能是空的,例如撞上步数上限且模型最后一句只有工具调用)。
pub text: String,
/// 用掉的轮次。
pub steps: usize,
/// 工具调用总次数。
pub tool_calls: usize,
/// 为什么停下来。
pub stopped_reason: StopReason,
}
/// 停止原因。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
/// 模型给出了最终回答。
Completed,
/// 撞上 [`BrainConfig::max_steps`]。
StepLimit,
}
/// 循环级错误。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrainError {
/// AI 被禁用(规则引擎仍可用)。
Disabled,
/// provider 报错。
Provider(ProviderError),
}
impl fmt::Display for BrainError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Disabled => f.write_str(DISABLED_MESSAGE),
Self::Provider(err) => write!(f, "LLM 调用失败:{err}"),
}
}
}
impl std::error::Error for BrainError {}
/// AI 被禁用时给用户的话(装配层直接展示,不用先构造 Brain)。
pub const DISABLED_MESSAGE: &str =
"AI 已禁用:请在设置 → AI 中配置端点。规则引擎崩溃诊断不受影响。";
/// 对话循环。
pub struct Brain {
provider: Arc<dyn ChatProvider>,
registry: Arc<ToolRegistry>,
executor: ToolExecutor,
config: BrainConfig,
sink: Arc<dyn AgentEventSink>,
}
impl Brain {
/// 装配。
pub fn new(
provider: Arc<dyn ChatProvider>,
registry: Arc<ToolRegistry>,
deps: BrainDeps,
config: BrainConfig,
) -> Self {
let executor = ToolExecutor::new(
Arc::clone(&registry),
Arc::clone(&deps.audit),
Arc::clone(&deps.gate),
deps.policy,
Arc::clone(&deps.clock),
Arc::clone(&deps.sink),
);
Self {
provider,
registry,
executor,
config,
sink: deps.sink,
}
}
/// 当前 provider 配置(设置页/UI 展示用)。
#[must_use]
pub fn provider_config(&self) -> &ProviderConfig {
self.provider.config()
}
/// 跑一轮对话。`history` 由调用方持有(P1 落盘为 `agent/sessions/*.jsonl`)。
///
/// # Errors
/// AI 被禁用([`BrainError::Disabled`])或 provider 报错([`BrainError::Provider`])。
pub async fn run_turn(
&self,
agent_session_id: &str,
history: &mut Vec<ChatMessage>,
user_input: &str,
) -> Result<TurnOutcome, BrainError> {
if !self.provider.config().is_enabled() {
self.sink.emit(AgentEvent::Error {
message: DISABLED_MESSAGE.to_string(),
});
return Err(BrainError::Disabled);
}
self.sink.emit(AgentEvent::TurnStarted {
session_id: agent_session_id.to_string(),
user_text: user_input.to_string(),
});
history.push(ChatMessage::user(user_input));
let observer = EventDeltaObserver::new(Arc::clone(&self.sink));
let mut tool_calls = 0usize;
let mut last_text = String::new();
let mut used_steps = 0usize;
for step in 1..=self.config.max_steps {
used_steps = step;
let request = self.build_request(history);
let reply = match self.provider.complete(&request, &observer).await {
Ok(reply) => reply,
Err(err) => {
self.sink.emit(AgentEvent::Error {
message: err.to_string(),
});
return Err(BrainError::Provider(err));
}
};
if !reply.text.trim().is_empty() {
last_text = reply.text.clone();
self.sink.emit(AgentEvent::AssistantText {
text: reply.text.clone(),
});
}
if reply.tool_calls.is_empty() {
history.push(ChatMessage::Assistant {
text: reply.text.clone(),
tool_calls: Vec::new(),
});
self.sink.emit(AgentEvent::TurnFinished {
steps: step,
text: reply.text.clone(),
});
return Ok(TurnOutcome {
text: reply.text,
steps: step,
tool_calls,
stopped_reason: StopReason::Completed,
});
}
history.push(ChatMessage::Assistant {
text: reply.text.clone(),
tool_calls: reply.tool_calls.clone(),
});
for call in &reply.tool_calls {
let outcome = self.executor.execute(agent_session_id, call, step).await;
tool_calls += 1;
history.push(ChatMessage::tool(
call.id.clone(),
call.name.clone(),
outcome.to_model_content(),
));
}
}
self.sink.emit(AgentEvent::TurnFinished {
steps: used_steps,
text: last_text.clone(),
});
Ok(TurnOutcome {
text: last_text,
steps: used_steps,
tool_calls,
stopped_reason: StopReason::StepLimit,
})
}
fn build_request(&self, history: &[ChatMessage]) -> ChatRequest {
let config = self.provider.config();
let mut messages = Vec::with_capacity(history.len() + 1);
messages.push(ChatMessage::system(self.config.system_prompt.clone()));
messages.extend_from_slice(history);
ChatRequest {
model: config.model.clone(),
messages,
tools: self.registry.definitions(),
temperature: config.temperature,
max_tokens: config.max_tokens,
}
}
}
+340
View File
@@ -0,0 +1,340 @@
//! Provider 四态端点配置(02 文档 §2)。
//!
//! | 形态 | 端点 | 备注 |
//! |------|------|------|
//! | OpenAI 兼容(默认) | `base_url` + `api_key` + `model` | 覆盖 OpenAI/DeepSeek/Qwen/GLM/**本地 llama.cpp server**(`http://127.0.0.1:8080/v1`)/LM Studio |
//! | Anthropic 原生 | `api_key` + `model` | Claude 系列(没有可用的 Rust 单厂商 SDK,见 research/02 §3.6) |
//! | Ollama 原生 | `http://127.0.0.1:11434` | 本地另一种主流 |
//! | 禁用 | — | 完全关闭 agent,规则引擎(`diagnose_crash`)仍可用 |
//!
//! 本文件只放**配置与校验**;对话协议在 [`super::chat`]。这样 rig 的 pre-1.0 破坏面
//! (research/02 §7 R1)被关在 [`super::rig`] 一个文件里,这里连 rig 的名字都不出现。
use std::fmt;
use serde::{Deserialize, Serialize};
/// 本地 llama.cpp server 的默认 OpenAI 兼容端点(research/02 §5.1)。
pub const LLAMACPP_DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080/v1";
/// Ollama 默认端点(research/02 §5.2)。
pub const OLLAMA_DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434";
/// Anthropic 官方 API 端点。
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
/// Provider 四态。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
/// OpenAI 兼容(含本地 llama.cpp / LM Studio / DeepSeek / Qwen…)。
OpenAiCompatible,
/// Anthropic 原生。
Anthropic,
/// Ollama 原生。
Ollama,
/// 完全禁用 agent。
Disabled,
}
impl ProviderKind {
/// 该形态是否必须提供 API key(本地推理通常不需要)。
#[must_use]
pub fn requires_api_key(self) -> bool {
matches!(self, Self::Anthropic)
}
/// 该形态的默认端点(OpenAI 兼容没有统一默认值,必须显式填;
/// 本地 llama.cpp 走 [`ProviderConfig::llamacpp_local`] 带的显式默认)。
#[must_use]
pub fn default_base_url(self) -> &'static str {
match self {
Self::OpenAiCompatible => "",
Self::Anthropic => ANTHROPIC_DEFAULT_BASE_URL,
Self::Ollama => OLLAMA_DEFAULT_BASE_URL,
Self::Disabled => "",
}
}
/// 设置页展示名。
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
Self::OpenAiCompatible => "OpenAI 兼容",
Self::Anthropic => "Anthropic 原生",
Self::Ollama => "Ollama 原生",
Self::Disabled => "已禁用",
}
}
}
/// Provider 配置(设置 → AI 的一份快照)。
///
/// `api_key` 只在进程内存在,**任何日志/审计/事件都不得带它**——
/// `Debug` 是手写的(掩码 key),要连形态一起看用 [`ProviderConfig::redacted_debug`]。
#[derive(Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderConfig {
/// 四态。
pub kind: ProviderKind,
/// 端点;空串表示用 [`ProviderKind::default_base_url`]。
pub base_url: String,
/// 模型名;`Disabled` 时可为空。
pub model: String,
/// API key(本地推理可空)。
pub api_key: Option<String>,
/// 采样温度。
pub temperature: Option<f32>,
/// 单次回复 token 上限。
pub max_tokens: Option<u32>,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self::disabled()
}
}
impl ProviderConfig {
fn base(kind: ProviderKind, base_url: &str, model: &str) -> Self {
Self {
kind,
base_url: base_url.to_string(),
model: model.to_string(),
api_key: None,
temperature: None,
max_tokens: None,
}
}
/// OpenAI 兼容端点(云端或自建)。
#[must_use]
pub fn openai_compatible(base_url: &str, model: &str) -> Self {
Self::base(ProviderKind::OpenAiCompatible, base_url, model)
}
/// 本机 llama.cpp server(用户就在跑 llama-server 的默认场景)。
#[must_use]
pub fn llamacpp_local(model: &str) -> Self {
Self::base(
ProviderKind::OpenAiCompatible,
LLAMACPP_DEFAULT_BASE_URL,
model,
)
}
/// Anthropic 原生。
#[must_use]
pub fn anthropic(model: &str) -> Self {
Self::base(ProviderKind::Anthropic, ANTHROPIC_DEFAULT_BASE_URL, model)
}
/// 本机 Ollama。
#[must_use]
pub fn ollama(model: &str) -> Self {
Self::base(ProviderKind::Ollama, OLLAMA_DEFAULT_BASE_URL, model)
}
/// 禁用 agent。
#[must_use]
pub fn disabled() -> Self {
Self::base(ProviderKind::Disabled, "", "")
}
/// 带上 API key。
#[must_use]
pub fn with_api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
/// 带上温度。
#[must_use]
pub fn with_temperature(mut self, temperature: f32) -> Self {
self.temperature = Some(temperature);
self
}
/// agent 是否启用。
#[must_use]
pub fn is_enabled(&self) -> bool {
self.kind != ProviderKind::Disabled
}
/// 实际使用的端点(空则回落默认值)。
#[must_use]
pub fn resolved_base_url(&self) -> &str {
if self.base_url.trim().is_empty() {
self.kind.default_base_url()
} else {
self.base_url.trim()
}
}
/// 配置是否可用(装配期调用一次,错就进设置页提示而不是等到对话时炸)。
///
/// # Errors
/// 禁用、缺模型名、缺端点、缺 API key 时返回可读错误。
pub fn validate(&self) -> Result<(), ProviderError> {
if !self.is_enabled() {
return Err(ProviderError::new(
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
));
}
if self.model.trim().is_empty() {
return Err(ProviderError::new("没有配置模型名(model)"));
}
if self.resolved_base_url().trim().is_empty() {
return Err(ProviderError::new(format!(
"{} 需要填写端点(base_url)",
self.kind.display_name()
)));
}
if self.kind.requires_api_key()
&& self
.api_key
.as_deref()
.is_none_or(|key| key.trim().is_empty())
{
return Err(ProviderError::new(format!(
"{} 需要 API key",
self.kind.display_name()
)));
}
Ok(())
}
/// 可打印的形态描述(**key 已掩码**,给日志/审计/设置页用)。
#[must_use]
pub fn redacted_debug(&self) -> String {
let key = match self.api_key.as_deref() {
None => "无".to_string(),
Some(k) if k.trim().is_empty() => "无".to_string(),
Some(_) => crate::redact::MASK.to_string(),
};
format!(
"{} base_url={} model={} api_key={key}",
self.kind.display_name(),
self.resolved_base_url(),
if self.model.is_empty() {
"(未设置)"
} else {
&self.model
},
)
}
}
impl fmt::Debug for ProviderConfig {
/// 手写 Debug:**key 掩码**(`api_key` 是唯一不能出现在日志里的字段)。
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("base_url", &self.resolved_base_url())
.field("model", &self.model)
.field(
"api_key",
&self.api_key.as_deref().map(|_| crate::redact::MASK),
)
.field("temperature", &self.temperature)
.field("max_tokens", &self.max_tokens)
.finish()
}
}
/// Provider 层错误(消息面向用户:端点连不上、key 无效、模型不存在)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderError {
message: String,
}
impl ProviderError {
/// 从可读文本构造。
#[must_use]
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for ProviderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ProviderError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn local_defaults_match_research() {
assert_eq!(
ProviderConfig::llamacpp_local("qwen2.5").resolved_base_url(),
LLAMACPP_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::ollama("llama3").resolved_base_url(),
OLLAMA_DEFAULT_BASE_URL
);
assert_eq!(
ProviderConfig::anthropic("claude-sonnet-4").resolved_base_url(),
ANTHROPIC_DEFAULT_BASE_URL
);
}
#[test]
fn validate_requires_model_base_url_and_key() {
assert!(!ProviderConfig::disabled().is_enabled());
assert!(ProviderConfig::disabled().validate().is_err());
assert!(ProviderConfig::llamacpp_local("").validate().is_err());
assert!(ProviderConfig::llamacpp_local("qwen").validate().is_ok());
// OpenAI 兼容必须显式给端点
assert!(ProviderConfig::openai_compatible("", "gpt-4o-mini")
.validate()
.is_err());
assert!(
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.validate()
.is_ok()
);
assert!(ProviderConfig::anthropic("claude").validate().is_err());
assert!(ProviderConfig::anthropic("claude")
.with_api_key("sk-ant")
.validate()
.is_ok());
}
#[test]
fn redacted_debug_never_leaks_key() {
let config =
ProviderConfig::openai_compatible("https://api.deepseek.com/v1", "deepseek-chat")
.with_api_key("sk-super-secret");
let text = config.redacted_debug();
assert!(!text.contains("sk-super-secret"), "{text}");
assert!(text.contains(crate::redact::MASK));
// `{:?}` 也必须掩码:Debug 是 panic 消息/日志的默认出口。
let debug = format!("{config:?}");
assert!(!debug.contains("sk-super-secret"), "{debug}");
assert!(debug.contains(crate::redact::MASK), "{debug}");
}
#[test]
fn empty_base_url_falls_back_to_default() {
let mut config = ProviderConfig::ollama("llama3");
config.base_url = String::new();
assert_eq!(config.resolved_base_url(), OLLAMA_DEFAULT_BASE_URL);
}
#[test]
fn four_states_serialize_as_snake_case() {
assert_eq!(
serde_json::to_string(&ProviderKind::OpenAiCompatible).unwrap(),
"\"open_ai_compatible\""
);
assert_eq!(
serde_json::to_string(&ProviderConfig::disabled()).unwrap(),
"{\"kind\":\"disabled\",\"base_url\":\"\",\"model\":\"\",\"api_key\":null,\"temperature\":null,\"max_tokens\":null}"
);
}
}
+368
View File
@@ -0,0 +1,368 @@
//! rig 适配层:[`ChatProvider`] 的**唯一**实现,也是整个 crate 唯一允许 `use rig_*` 的文件。
//!
//! 依赖面收窄纪律(`DevDocs/PlanDoc/02-ai-agent-design.md` §0 + research/02 §7 R1):
//! rig 是 pre-1.0、12 周 5 个 minor、CHANGELOG 满是 `[**breaking**]`(0.43 把 MCP 拆出 rig-agent、
//! 0.45 升 rmcp 3.5 并改 owned connection)。所以把它关在这一个文件里:它变了,只有这里编译失败,
//! [`super::ChatProvider`] 以上的循环/编排/审计一行不用动。
//!
//! ## 0.45 的接线(实读源码,非照文档猜)
//!
//! ```text
//! ProviderConfig ─┬─ OpenAiCompatible → OpenAIConfig::new(key).with_base_url(url).client().chat(model)
//! ├─ Anthropic → AnthropicConfig::new(key).with_base_url(url).client().completion(model)
//! ├─ Ollama → OllamaConfig::new().with_base_url(url).client().completion(model)
//! └─ Disabled → 不构造(Brain::run_turn 已提前返回)
//! ↓ Model<W>.erase()
//! DynModel<Completion>(wire + transport 擦除,本文件只持有这一个类型)
//! ```
//!
//! 循环侧只要两件事:流式文本增量、以及折好的 [`ChatReply`](文本 + 工具调用)。
//! 所以这里用 `model.stream(request)` 边转发 [`StreamEvent::Text`] 增量给 [`DeltaObserver`],
//! 最后 `Streamed::finish()` 拿到完整 [`CompletionResponse`]——工具调用从响应里取,
//! 不自己拼分片 JSON(拼分片是 rig 的 `ReplyFold` 已经做过的事)。
//!
//! ## 三个已确认的坑
//!
//! 1. **llama.cpp 默认端点就是 `http://localhost:8080/v1`**(`providers/llamacpp/mod.rs`),
//! 与 [`LLAMACPP_DEFAULT_BASE_URL`] 一致。这里统一走 OpenAI 方言 + 显式 base_url:
//! llama.cpp / LM Studio / DeepSeek 都是 OpenAI 兼容面,方言差异主要在默认端点与特有位
//! (`chat_template_kwargs`、`num_ctx`)——真要下发这些,再换成 `providers::llamacpp` 方言或
//! Ollama 的 `native_completion`。
//! 2. **Ollama 原生方言的工具结果用 `tool_name` 配对(没有 id)**,OpenAI 方言用 `tool_call_id`。
//! 这里走 rig 的 `Ollama::completion`(OpenAI 兼容 `/v1/chat/completions`),由 rig 负责回灌形状;
//! 要下发 `num_ctx`(崩溃日志几千行,上下文必须可配)才需要 `native_completion`,留作 P1。
//! 3. **取消**:`Streamed` 是普通 future/stream,丢弃即断开——P0 用「丢弃 future」实现停止生成,
//! P1 接 UI 的「停止」按钮时按 rig 的取消语义(drop stream)对齐即可,不需要 unsafe。
//!
//! ## 逃生口(保留但不是 Plan A)
//!
//! rig 的破坏变更真伤到(或 Rust 1.92 MSRV 追不上 rig 的 1.97.1)时:换 `genai`
//! (provider + function calling,无 agent 循环)或自己写 OpenAI 方言(约 300~500 行)。
//! **代价只是实现一个新的 [`ChatProvider`]**——这正是把它关在一个文件里的意义。
//!
//! [`LLAMACPP_DEFAULT_BASE_URL`]: super::provider::LLAMACPP_DEFAULT_BASE_URL
//! [`StreamEvent::Text`]: rig_core::streaming::StreamEvent::Text
use std::sync::Arc;
use futures_util::StreamExt;
use rig_core::completion::{CompletionRequest, ToolDefinition as RigToolDefinition};
use rig_core::error::ProviderError as RigError;
use rig_core::message::{
AssistantContent as RigAssistantContent, AssistantMessage as RigAssistantMessage, CallId,
Message as RigMessage, Text as RigText, ToolCall as RigToolCall, ToolFunction,
ToolName as RigToolName, ToolResult, ToolResultContent, UserContent,
};
use rig_core::operation::Completion;
use rig_core::providers::anthropic::AnthropicConfig;
use rig_core::providers::ollama::OllamaConfig;
use rig_core::providers::openai::OpenAIConfig;
use rig_core::streaming::{Item, StreamEvent};
use rig_core::DynModel;
use super::chat::{
ChatMessage, ChatProvider, ChatReply, ChatRequest, DeltaObserver, ProviderFactory,
ProviderFuture,
};
use super::provider::{ProviderConfig, ProviderError, ProviderKind};
/// 锁定的 rig 版本(与 `Cargo.toml` 的唯一记录点,避免两处不一致)。
pub const RIG_VERSION_PIN: &str = "=0.45.0";
/// 擦掉 wire / transport 之后的补全模型——本文件对外只暴露这一个 rig 类型之外的东西。
type ErasedModel = DynModel<Completion>;
/// rig 实现的 [`ChatProvider`]:四态配置 + 一个擦除后的模型。
pub struct RigProvider {
config: ProviderConfig,
model: ErasedModel,
}
impl RigProvider {
/// 按配置造 provider。`Disabled`、缺模型名、缺 key 都在这里被拒(fail-fast)。
///
/// # Errors
/// AI 被禁用、配置不完整、或 rig 端点构造失败时返回可读错误。
pub fn new(config: ProviderConfig) -> Result<Self, ProviderError> {
config.validate()?;
let model = build_model(&config)?;
Ok(Self { config, model })
}
/// 装配层用的工厂:`ProviderFactory` 直接塞给 `crates/app`。
#[must_use]
pub fn factory() -> ProviderFactory {
Arc::new(|config: &ProviderConfig| {
Ok(Arc::new(Self::new(config.clone())?) as Arc<dyn ChatProvider>)
})
}
}
impl ChatProvider for RigProvider {
fn config(&self) -> &ProviderConfig {
&self.config
}
fn complete<'a>(
&'a self,
request: &'a ChatRequest,
observer: &'a dyn DeltaObserver,
) -> ProviderFuture<'a> {
Box::pin(async move {
let rig_request = build_request(request)?;
let mut stream = self.model.stream(rig_request).map_err(map_error)?;
// 先转发增量,再拿折好的完整响应(工具调用只从响应取,不拼分片)。
let mut text = String::new();
while let Some(item) = stream.next().await {
match item.map_err(map_error)? {
Item::Event(StreamEvent::Text { text: fragment, .. }) => {
text.push_str(&fragment);
observer.on_delta(&fragment);
}
Item::Event(_) | Item::Unknown(_) => {}
}
}
let response = stream.finish().await.map_err(map_error)?;
to_reply(&response, text)
})
}
}
/// 四态 → rig 模型。
fn build_model(config: &ProviderConfig) -> Result<ErasedModel, ProviderError> {
let model = config.model.trim().to_string();
let base_url = config.resolved_base_url().to_string();
let api_key = config.api_key.clone().unwrap_or_default();
let erased = match config.kind {
ProviderKind::OpenAiCompatible => OpenAIConfig::new(api_key)
.with_base_url(base_url)
.client()
.chat(model)
.erase(),
ProviderKind::Anthropic => AnthropicConfig::new(api_key)
.with_base_url(base_url)
.client()
.completion(model)
.erase(),
ProviderKind::Ollama => OllamaConfig::new()
.with_base_url(base_url)
.client()
.completion(model)
.erase(),
ProviderKind::Disabled => {
return Err(ProviderError::new(
"AI 已禁用:请在设置 → AI 中选择端点(规则引擎诊断不受影响)",
))
}
};
Ok(erased)
}
/// 本方消息 → rig 补全请求。
fn build_request(request: &ChatRequest) -> Result<CompletionRequest, ProviderError> {
let mut history = to_rig_messages(&request.messages)?;
// rig 把历史最后一条当 prompt,其余是上下文;空历史没有可发的 prompt。
let prompt = history
.pop()
.ok_or_else(|| ProviderError::new("对话历史为空:至少需要一条用户消息"))?;
let prompt = Arc::unwrap_or_clone(prompt);
let tools = request
.tools
.iter()
.map(to_rig_tool)
.collect::<Result<Vec<_>, _>>()?;
Ok(CompletionRequest::new(prompt)
.messages(history)
.tools(tools)
.temperature(request.temperature.map(f64::from))
.max_tokens(request.max_tokens.map(u64::from)))
}
/// 本方工具定义 → rig 工具定义(入参都是裸 JSON Schema,这正是双投影的支点)。
fn to_rig_tool(tool: &crate::registry::ToolDefinition) -> Result<RigToolDefinition, ProviderError> {
let name = tool_name(&tool.name)?;
Ok(RigToolDefinition::new(
name,
tool.description.clone(),
tool.input_schema.clone(),
))
}
/// 本方消息 → rig 消息。
fn to_rig_messages(messages: &[ChatMessage]) -> Result<Vec<Arc<RigMessage>>, ProviderError> {
let mut out = Vec::with_capacity(messages.len());
for message in messages {
let rig = match message {
ChatMessage::System { content } => RigMessage::system(content.clone()),
ChatMessage::User { content } => RigMessage::user(content.clone()),
ChatMessage::Assistant { text, tool_calls } => {
let mut parts = Vec::with_capacity(tool_calls.len() + 1);
if !text.trim().is_empty() {
parts.push(RigAssistantContent::Text(RigText::new(text.clone())));
}
for call in tool_calls {
parts.push(RigAssistantContent::ToolCall(RigToolCall::new(
CallId::from_wire(call.id.clone()),
ToolFunction::new(tool_name(&call.name)?, call.arguments.clone()),
)));
}
RigMessage::Assistant(RigAssistantMessage::new(parts))
}
ChatMessage::Tool {
call_id,
name,
content,
} => RigMessage::User {
content: vec![UserContent::ToolResult(ToolResult {
call: CallId::from_wire(call_id.clone()),
name: tool_name(name)?,
content: vec![ToolResultContent::text(content.clone())],
is_error: false,
})],
},
};
out.push(Arc::new(rig));
}
Ok(out)
}
/// rig 响应 → 本方回复(文本 + 工具调用 + 用量)。
fn to_reply(
response: &rig_core::completion::CompletionResponse,
streamed: String,
) -> Result<ChatReply, ProviderError> {
let mut text = streamed;
let mut tool_calls = Vec::new();
for part in &response.choice {
match part {
RigAssistantContent::Text(chunk) => {
if text.is_empty() {
text.push_str(&chunk.text);
}
}
RigAssistantContent::ToolCall(call) => {
if let Some(raw) = call.function.invalid_arguments.as_deref() {
return Err(ProviderError::new(format!(
"模型返回的工具调用参数不是 JSON 对象(工具 `{}`):{raw}",
call.function.name.as_str()
)));
}
tool_calls.push(super::chat::ToolCall {
id: call.id.to_string(),
name: call.function.name.as_str().to_string(),
arguments: serde_json::Value::Object(call.function.arguments.clone()),
});
}
// 推理链/图片/未知块不进对话循环(P0 只消费文本 + 工具调用)。
_ => {}
}
}
Ok(ChatReply {
text,
tool_calls,
usage: Some(super::chat::Usage {
prompt_tokens: response
.usage
.input_tokens
.and_then(|v| u32::try_from(v).ok()),
completion_tokens: response
.usage
.output_tokens
.and_then(|v| u32::try_from(v).ok()),
}),
})
}
/// rig 错误 → 本方错误(文案面向用户)。
fn map_error(error: RigError) -> ProviderError {
ProviderError::new(error.to_string())
}
/// 工具名 → rig 的 [`RigToolName`](空名是唯一的非法输入)。
fn tool_name(name: &str) -> Result<RigToolName, ProviderError> {
RigToolName::new(name)
.map_err(|_| ProviderError::new("工具名不能为空(ToolSpec 注册阶段应已拦截)"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tool(id: &str, name: &str) -> super::super::chat::ToolCall {
super::super::chat::ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: serde_json::json!({ "path": "appearance.theme_mode" }),
}
}
#[test]
fn disabled_config_is_rejected_before_touching_the_network() {
let Err(err) = RigProvider::new(ProviderConfig::disabled()) else {
panic!("禁用态应在构造阶段被拒");
};
assert!(err.to_string().contains("禁用"));
}
#[test]
fn anthropic_without_key_is_rejected() {
assert!(RigProvider::new(ProviderConfig::anthropic("claude-sonnet-4")).is_err());
assert!(RigProvider::new(
ProviderConfig::anthropic("claude-sonnet-4").with_api_key("sk-ant")
)
.is_ok());
}
#[test]
fn local_states_build_offline() {
assert!(RigProvider::new(ProviderConfig::llamacpp_local("qwen2.5-7b")).is_ok());
assert!(RigProvider::new(ProviderConfig::ollama("llama3.1")).is_ok());
}
#[test]
fn request_maps_messages_and_tools() {
let request = ChatRequest {
model: "mock".to_string(),
messages: vec![
ChatMessage::system("你是助手"),
ChatMessage::user("把主题换成浅色"),
ChatMessage::Assistant {
text: String::new(),
tool_calls: vec![tool("call_1", "set_config")],
},
ChatMessage::tool("call_1", "set_config", "{\"status\":\"written\"}"),
],
tools: vec![crate::registry::ToolDefinition {
name: "set_config".to_string(),
description: "改配置".to_string(),
input_schema: serde_json::json!({ "type": "object" }),
}],
temperature: Some(0.5),
max_tokens: Some(512),
};
let built = build_request(&request).expect("应能构造");
assert_eq!(built.chat_history.len(), 4);
assert_eq!(built.tools.len(), 1);
assert_eq!(built.tools[0].name.as_str(), "set_config");
assert_eq!(built.temperature, Some(0.5));
assert_eq!(built.max_tokens, Some(512));
// 工具结果在 rig 0.45 里是 user 角色的 ToolResult 内容块
let json = serde_json::to_string(&built.chat_history[3]).expect("可序列化");
assert!(json.contains("toolresult"), "{json}");
}
#[test]
fn empty_history_is_a_readable_error() {
let request = ChatRequest {
model: "m".to_string(),
messages: Vec::new(),
tools: Vec::new(),
temperature: None,
max_tokens: None,
};
let err = build_request(&request).unwrap_err();
assert!(err.to_string().contains("对话历史为空"));
}
}