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,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\""));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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(®istry),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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("对话历史为空"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user