feat: 拆分 worker 架构并增强 assistant 安全保护

- 新增 daemon/channel/assistant/function/scheduler/logging worker 架构和 JetStream 消息协议

- 修复邮件通知误入 assistant、迁移 checksum、日志 subject 和工具调用无限循环风险

- 新增 assistant 工具轮次熔断、工具结果截断和提示词软收敛规则

- 补充邮件推送关键字、可信发件人配置、升级日志和验证测试
This commit is contained in:
2026-07-10 00:41:36 +08:00
parent 7deae286ca
commit af6cfdaa83
70 changed files with 5953 additions and 1608 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "ias-assistant"
version = "0.1.0"
edition = "2024"
[dependencies]
ias-ai = { path = "../ias-ai" }
ias-common = { path = "../ias-common" }
ias-context = { path = "../ias-context" }
anyhow = "1"
serde_json = "1"
sqlx = { version = "0.9.0", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
"chrono",
] }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "sync", "time"] }
uuid = { version = "1", features = ["v4", "serde"] }
+877
View File
@@ -0,0 +1,877 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use ias_ai::core::request_llm;
use ias_common::model::ai::ChatCompletionRequestMessage;
use ias_common::mq::{
ConsumerSpec, FunctionCallPayload, FunctionResultPayload, InboundPayload, JetStreamBus,
JetStreamConfig, LogEventPayload, MessageEnvelope, OutboundCommandKind, OutboundCommandPayload,
StreamKind, StreamNames, function_call_subject, inbound_subject, log_subject, outbound_subject,
};
use ias_common::queue::ChannelMessage;
use ias_context::{ContextManager, NewSessionReason};
use sqlx::PgPool;
use tokio::sync::Mutex;
use uuid::Uuid;
type TurnMemory = Arc<Mutex<HashMap<String, String>>>;
const DEFAULT_MAX_TOOL_ROUNDS: i32 = 8;
const DEFAULT_MAX_TOOL_CALLS_PER_ROUND: usize = 5;
#[derive(Clone)]
struct TurnRegistry {
pool: PgPool,
memory: TurnMemory,
}
#[derive(Debug, sqlx::FromRow)]
struct TurnRow {
turn_id: String,
status: String,
}
#[derive(Debug, Default, Clone, Copy, sqlx::FromRow)]
struct ToolBudget {
tool_rounds: i32,
tool_calls: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TurnState {
Active,
Missing,
Stale,
}
impl TurnRegistry {
fn new(pool: PgPool) -> Self {
Self {
pool,
memory: Arc::new(Mutex::new(HashMap::new())),
}
}
async fn start_turn(&self, channel: &mut ChannelMessage, trace_id: &str) -> anyhow::Result<()> {
let turn_id = Uuid::new_v4().to_string();
let key = operation_key(channel);
channel.turn_id = Some(turn_id.clone());
self.memory
.lock()
.await
.insert(key.clone(), turn_id.clone());
sqlx::query(
r#"
INSERT INTO ias_assistant_turns (
operation_key,
channel,
account_id,
from_user_id,
turn_id,
trace_id,
status,
tool_rounds,
tool_calls,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, 'active', 0, 0, NOW())
ON CONFLICT (operation_key) DO UPDATE SET
channel = EXCLUDED.channel,
account_id = EXCLUDED.account_id,
from_user_id = EXCLUDED.from_user_id,
turn_id = EXCLUDED.turn_id,
trace_id = EXCLUDED.trace_id,
status = 'active',
tool_rounds = 0,
tool_calls = 0,
last_tool_at = NULL,
updated_at = NOW()
"#,
)
.bind(&key)
.bind(&channel.channel)
.bind(&channel.account_id)
.bind(&channel.from_user_id)
.bind(&turn_id)
.bind(trace_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn cancel_turn(&self, channel: &ChannelMessage, trace_id: &str) -> anyhow::Result<()> {
let key = operation_key(channel);
self.memory.lock().await.remove(&key);
sqlx::query(
r#"
INSERT INTO ias_assistant_turns (
operation_key,
channel,
account_id,
from_user_id,
turn_id,
trace_id,
status,
updated_at
)
VALUES ($1, $2, $3, $4, '', $5, 'cancelled', NOW())
ON CONFLICT (operation_key) DO UPDATE SET
trace_id = EXCLUDED.trace_id,
status = 'cancelled',
updated_at = NOW()
"#,
)
.bind(&key)
.bind(&channel.channel)
.bind(&channel.account_id)
.bind(&channel.from_user_id)
.bind(trace_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn recover_missing_turn(
&self,
channel: &ChannelMessage,
trace_id: &str,
) -> anyhow::Result<()> {
let Some(turn_id) = channel.turn_id.as_deref() else {
return Ok(());
};
let key = operation_key(channel);
self.memory
.lock()
.await
.insert(key.clone(), turn_id.to_string());
sqlx::query(
r#"
INSERT INTO ias_assistant_turns (
operation_key,
channel,
account_id,
from_user_id,
turn_id,
trace_id,
status,
updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, 'active', NOW())
ON CONFLICT (operation_key) DO NOTHING
"#,
)
.bind(&key)
.bind(&channel.channel)
.bind(&channel.account_id)
.bind(&channel.from_user_id)
.bind(turn_id)
.bind(trace_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn finish_turn(&self, channel: &ChannelMessage) -> anyhow::Result<()> {
let key = operation_key(channel);
self.memory.lock().await.remove(&key);
let Some(turn_id) = channel.turn_id.as_deref() else {
return Ok(());
};
sqlx::query(
r#"
UPDATE ias_assistant_turns
SET status = 'completed', updated_at = NOW()
WHERE operation_key = $1
AND turn_id = $2
AND status = 'active'
"#,
)
.bind(&key)
.bind(turn_id)
.execute(&self.pool)
.await?;
Ok(())
}
async fn state(&self, channel: &ChannelMessage) -> anyhow::Result<TurnState> {
let Some(turn_id) = channel.turn_id.as_deref() else {
return Ok(TurnState::Stale);
};
let key = operation_key(channel);
if let Some(active) = self.memory.lock().await.get(&key).cloned() {
return Ok(if active == turn_id {
TurnState::Active
} else {
TurnState::Stale
});
}
let row = sqlx::query_as::<_, TurnRow>(
r#"
SELECT turn_id, status
FROM ias_assistant_turns
WHERE operation_key = $1
"#,
)
.bind(&key)
.fetch_optional(&self.pool)
.await?;
match row {
None => Ok(TurnState::Missing),
Some(row) if row.turn_id == turn_id && row.status == "active" => {
self.memory.lock().await.insert(key, turn_id.to_string());
Ok(TurnState::Active)
}
Some(_) => Ok(TurnState::Stale),
}
}
async fn tool_budget(&self, channel: &ChannelMessage) -> anyhow::Result<ToolBudget> {
let Some(turn_id) = channel.turn_id.as_deref() else {
return Ok(ToolBudget::default());
};
let key = operation_key(channel);
let budget = sqlx::query_as::<_, ToolBudget>(
r#"
SELECT tool_rounds, tool_calls
FROM ias_assistant_turns
WHERE operation_key = $1
AND turn_id = $2
AND status = 'active'
"#,
)
.bind(&key)
.bind(turn_id)
.fetch_optional(&self.pool)
.await?
.unwrap_or_default();
Ok(budget)
}
async fn next_tool_round(
&self,
channel: &ChannelMessage,
tool_call_count: usize,
) -> anyhow::Result<i32> {
let Some(turn_id) = channel.turn_id.as_deref() else {
return Ok(i32::MAX);
};
let key = operation_key(channel);
let count = tool_call_count.min(i32::MAX as usize) as i32;
let rounds = sqlx::query_scalar::<_, i32>(
r#"
UPDATE ias_assistant_turns
SET
tool_rounds = tool_rounds + 1,
tool_calls = tool_calls + $3,
last_tool_at = NOW(),
updated_at = NOW()
WHERE operation_key = $1
AND turn_id = $2
AND status = 'active'
RETURNING tool_rounds
"#,
)
.bind(&key)
.bind(turn_id)
.bind(count)
.fetch_optional(&self.pool)
.await?
.unwrap_or(i32::MAX);
Ok(rounds)
}
}
pub async fn run_worker(pool: PgPool) -> anyhow::Result<()> {
let config = JetStreamConfig::from_env();
loop {
match run_once(pool.clone(), config.clone()).await {
Ok(()) => return Ok(()),
Err(error) => {
eprintln!("Assistant Worker 连接或消费失败: {error:#}");
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
async fn run_once(pool: PgPool, config: JetStreamConfig) -> anyhow::Result<()> {
let bus = JetStreamBus::connect(config.clone()).await?;
bus.ensure_streams().await?;
let names = StreamNames::new(config.stream_prefix.clone());
let context = ContextManager::new(pool.clone());
let turns = TurnRegistry::new(pool);
let inbound_bus = bus.clone();
let inbound_context = context.clone();
let inbound_turns = turns.clone();
let inbound_spec = ConsumerSpec {
stream: names.name(StreamKind::Inbound),
durable_name: "assistant-inbound".to_string(),
filter_subject: "inbound.>".to_string(),
ack_wait: Duration::from_secs(config.ack_wait_secs.max(180)),
max_deliver: config.max_deliver,
};
let result_bus = bus.clone();
let result_context = context.clone();
let result_turns = turns.clone();
let result_spec = ConsumerSpec {
stream: names.name(StreamKind::Function),
durable_name: "assistant-function-results".to_string(),
filter_subject: "function.result.>".to_string(),
ack_wait: Duration::from_secs(config.ack_wait_secs.max(180)),
max_deliver: config.max_deliver,
};
let inbound_consumer_bus = bus.clone();
let result_consumer_bus = bus.clone();
let inbound_future = inbound_consumer_bus.consume_envelopes::<InboundPayload, _, _>(
inbound_spec,
move |envelope| {
let bus = inbound_bus.clone();
let context = inbound_context.clone();
let active_turns = inbound_turns.clone();
async move { handle_inbound(bus, context, active_turns, envelope).await }
},
);
let result_publish_bus = result_bus.clone();
let result_future = result_consumer_bus.consume_envelopes::<FunctionResultPayload, _, _>(
result_spec,
move |envelope| {
let bus = result_publish_bus.clone();
let context = result_context.clone();
let active_turns = result_turns.clone();
async move { handle_function_result(bus, context, active_turns, envelope).await }
},
);
tokio::try_join!(inbound_future, result_future)?;
Ok(())
}
async fn handle_inbound(
bus: JetStreamBus,
context: ContextManager,
turns: TurnRegistry,
envelope: MessageEnvelope<InboundPayload>,
) -> anyhow::Result<()> {
let mut channel = envelope.payload.into_channel_message();
let trace_id = envelope.trace_id;
if channel.content.trim().eq_ignore_ascii_case("/new") {
turns.cancel_turn(&channel, &trace_id).await?;
context
.start_new_session(&channel, NewSessionReason::UserCommand)
.await?;
publish_typing_stop(&bus, &channel, &trace_id).await?;
publish_text(&bus, &channel, "已开启新的上下文。", &trace_id).await?;
publish_log(
&bus,
"info",
"worker.assistant",
"用户通过 /new 开启新上下文",
&trace_id,
serde_json::json!({
"channel": channel.channel,
"account_id": channel.account_id,
"from_user_id": channel.from_user_id,
}),
)
.await;
return Ok(());
}
turns.start_turn(&mut channel, &trace_id).await?;
publish_typing_start(&bus, &channel, &trace_id).await?;
let messages = match context.record_user_message(&channel).await {
Ok(snapshot) => snapshot.messages,
Err(error) => {
publish_log(
&bus,
"error",
"worker.assistant",
"记录用户上下文失败,退回单轮请求",
&trace_id,
serde_json::json!({"error": error.to_string()}),
)
.await;
vec![ChatCompletionRequestMessage::new(
"user".to_string(),
channel.content.clone(),
)]
}
};
request_and_route_llm(bus, context, turns, channel, messages, trace_id).await
}
async fn handle_function_result(
bus: JetStreamBus,
context: ContextManager,
turns: TurnRegistry,
envelope: MessageEnvelope<FunctionResultPayload>,
) -> anyhow::Result<()> {
let result = envelope.payload;
match turns.state(&result.channel).await? {
TurnState::Active => {}
TurnState::Missing => {
turns
.recover_missing_turn(&result.channel, &envelope.trace_id)
.await?;
publish_log(
&bus,
"warn",
"worker.assistant",
"恢复缺失的 active turn 后继续处理工具结果",
&envelope.trace_id,
serde_json::json!({"call_id": result.call_id, "tool": result.name}),
)
.await;
}
TurnState::Stale => {
publish_log(
&bus,
"warn",
"worker.assistant",
"跳过已被打断的工具结果",
&envelope.trace_id,
serde_json::json!({"call_id": result.call_id, "tool": result.name}),
)
.await;
return Ok(());
}
}
publish_typing_refresh(&bus, &result.channel, &envelope.trace_id).await?;
let snapshot = context
.record_tool_message(
&result.channel,
result.call_id.clone(),
result.name.clone(),
result.result.clone(),
)
.await?;
request_and_route_llm(
bus,
context,
turns,
result.channel,
snapshot.messages,
envelope.trace_id,
)
.await
}
async fn request_and_route_llm(
bus: JetStreamBus,
context: ContextManager,
turns: TurnRegistry,
channel: ChannelMessage,
messages: Vec<ChatCompletionRequestMessage>,
trace_id: String,
) -> anyhow::Result<()> {
match turns.state(&channel).await? {
TurnState::Active => {}
TurnState::Missing => {
turns.recover_missing_turn(&channel, &trace_id).await?;
}
TurnState::Stale => return Ok(()),
}
let budget = turns.tool_budget(&channel).await?;
let messages = with_tool_budget_message(messages, budget);
let Some(response) = request_llm(messages).await? else {
publish_typing_stop(&bus, &channel, &trace_id).await?;
turns.finish_turn(&channel).await?;
return Ok(());
};
for choice in response.choices {
if turns.state(&channel).await? == TurnState::Stale {
return Ok(());
}
let raw_message = serde_json::to_value(&choice.message).ok();
let content = choice.message.content.unwrap_or_default();
let reasoning = choice.message.reasoning_content.unwrap_or_default();
let tool_calls = choice.message.tool_calls.clone();
let has_tool_calls = tool_calls.as_ref().is_some_and(|calls| !calls.is_empty());
context
.record_assistant_message(
&channel,
content.clone(),
tool_calls.clone(),
Some(reasoning.clone()),
raw_message,
)
.await?;
if has_tool_calls {
let tool_calls = tool_calls.unwrap_or_default();
let max_rounds = max_tool_rounds();
let max_calls = max_tool_calls_per_round();
let tool_round = turns.next_tool_round(&channel, tool_calls.len()).await?;
if tool_round > max_rounds {
publish_log(
&bus,
"error",
"worker.assistant",
"工具调用轮次超过上限,已熔断当前 turn",
&trace_id,
serde_json::json!({
"channel": channel.channel,
"account_id": channel.account_id,
"from_user_id": channel.from_user_id,
"turn_id": channel.turn_id,
"tool_round": tool_round,
"max_tool_rounds": max_rounds,
}),
)
.await;
publish_typing_stop(&bus, &channel, &trace_id).await?;
publish_text(
&bus,
&channel,
"工具调用次数异常偏高,已自动停止本轮任务以避免继续消耗额度。请缩小查询范围后重试。",
&trace_id,
)
.await?;
turns.finish_turn(&channel).await?;
return Ok(());
}
if tool_calls.len() > max_calls {
publish_log(
&bus,
"error",
"worker.assistant",
"单轮工具调用数量超过上限,已熔断当前 turn",
&trace_id,
serde_json::json!({
"channel": channel.channel,
"account_id": channel.account_id,
"from_user_id": channel.from_user_id,
"turn_id": channel.turn_id,
"tool_call_count": tool_calls.len(),
"max_tool_calls_per_round": max_calls,
}),
)
.await;
publish_typing_stop(&bus, &channel, &trace_id).await?;
publish_text(
&bus,
&channel,
"本轮请求生成了过多工具调用,已自动停止以避免继续消耗额度。请缩小查询范围后重试。",
&trace_id,
)
.await?;
turns.finish_turn(&channel).await?;
return Ok(());
}
publish_typing_refresh(&bus, &channel, &trace_id).await?;
for tool_call in tool_calls {
let Some(function) = tool_call.function else {
continue;
};
let payload = FunctionCallPayload {
call_id: tool_call.id,
name: function.name,
arguments: function.arguments,
channel: channel.clone(),
};
let subject = function_call_subject(&payload.name);
let envelope = MessageEnvelope::new(
subject.as_str(),
format!("function-call/{}", payload.call_id),
trace_id.clone(),
payload,
);
bus.publish_envelope(subject.as_str(), &envelope).await?;
}
continue;
}
let text = if !content.trim().is_empty() {
content.trim()
} else {
reasoning.trim()
};
if !text.is_empty() {
publish_text(&bus, &channel, text, &trace_id).await?;
}
publish_typing_stop(&bus, &channel, &trace_id).await?;
turns.finish_turn(&channel).await?;
}
Ok(())
}
async fn publish_text(
bus: &JetStreamBus,
channel: &ChannelMessage,
text: &str,
trace_id: &str,
) -> anyhow::Result<()> {
let payload = OutboundCommandPayload::text(
channel.channel.clone(),
channel.account_id.clone(),
channel.from_user_id.clone(),
text.to_string(),
channel.context.clone(),
channel.turn_id.clone(),
);
publish_outbound(bus, payload, trace_id).await
}
async fn publish_typing_start(
bus: &JetStreamBus,
channel: &ChannelMessage,
trace_id: &str,
) -> anyhow::Result<()> {
let payload = OutboundCommandPayload::typing(
OutboundCommandKind::TypingStart,
channel.channel.clone(),
channel.account_id.clone(),
channel.from_user_id.clone(),
channel.context.clone(),
None,
channel.turn_id.clone(),
);
publish_outbound(bus, payload, trace_id).await
}
async fn publish_typing_refresh(
bus: &JetStreamBus,
channel: &ChannelMessage,
trace_id: &str,
) -> anyhow::Result<()> {
let payload = OutboundCommandPayload::typing(
OutboundCommandKind::TypingRefresh,
channel.channel.clone(),
channel.account_id.clone(),
channel.from_user_id.clone(),
channel.context.clone(),
None,
channel.turn_id.clone(),
);
publish_outbound(bus, payload, trace_id).await
}
async fn publish_typing_stop(
bus: &JetStreamBus,
channel: &ChannelMessage,
trace_id: &str,
) -> anyhow::Result<()> {
let payload = OutboundCommandPayload::typing(
OutboundCommandKind::TypingStop,
channel.channel.clone(),
channel.account_id.clone(),
channel.from_user_id.clone(),
channel.context.clone(),
None,
channel.turn_id.clone(),
);
publish_outbound(bus, payload, trace_id).await
}
async fn publish_outbound(
bus: &JetStreamBus,
payload: OutboundCommandPayload,
trace_id: &str,
) -> anyhow::Result<()> {
let subject = outbound_subject(&payload.channel, &payload.account_id);
let envelope = MessageEnvelope::new(
subject.as_str(),
payload.command_id.clone(),
trace_id.to_string(),
payload,
);
bus.publish_envelope(subject.as_str(), &envelope).await
}
async fn publish_log(
bus: &JetStreamBus,
level: &str,
source: &str,
message: &str,
trace_id: &str,
fields: serde_json::Value,
) {
let payload = LogEventPayload::new(level, source, message, trace_id.to_string(), fields);
let subject = log_subject(source);
let envelope = MessageEnvelope::new(
subject.as_str(),
payload.event_id.clone(),
trace_id.to_string(),
payload,
);
if let Err(error) = bus.publish_envelope(subject.as_str(), &envelope).await {
eprintln!("发布 assistant 日志事件失败: {error:#}");
}
}
fn with_tool_budget_message(
mut messages: Vec<ChatCompletionRequestMessage>,
budget: ToolBudget,
) -> Vec<ChatCompletionRequestMessage> {
let insert_at = messages
.iter()
.position(|message| message.role != "system")
.unwrap_or(messages.len());
messages.insert(
insert_at,
ChatCompletionRequestMessage::new("system".to_string(), tool_budget_prompt(budget)),
);
messages
}
fn tool_budget_prompt(budget: ToolBudget) -> String {
tool_budget_prompt_with_limits(budget, max_tool_rounds(), max_tool_calls_per_round())
}
fn tool_budget_prompt_with_limits(
budget: ToolBudget,
max_rounds: i32,
max_calls_per_round: usize,
) -> String {
let remaining_rounds = max_rounds.saturating_sub(budget.tool_rounds);
let round_guidance = if remaining_rounds <= 2 {
"已接近工具调用上限;除非只需一次必要的新查询,否则停止调用工具,直接总结当前结果或请用户缩小范围。"
} else {
"优先在 1 到 2 轮工具往返内完成任务;已有足够信息时必须直接回答。"
};
format!(
"本轮工具调用预算(软约束,硬熔断仍由系统执行):\n\
- 已使用 {}/{} 轮工具往返,累计 {} 次工具调用;每轮最多 {} 次工具调用。\n\
- 每次调用工具前确认它会带来新的、必要的信息;不要重复调用相同能力和相同参数。\n\
- 邮件类宽泛请求默认只查列表并汇总发件人、主题、时间和预览;不要逐封读取详情。同一邮件 ID 本轮最多读取一次。\n\
- 工具结果截断、返回过大或仍不完整时,先总结当前结果并请用户缩小范围,不要循环查看剩余内容。\n\
- {round_guidance}",
budget.tool_rounds, max_rounds, budget.tool_calls, max_calls_per_round
)
}
fn operation_key(channel: &ChannelMessage) -> String {
format!(
"{}:{}:{}",
channel.channel, channel.account_id, channel.from_user_id
)
}
fn max_tool_rounds() -> i32 {
read_i32_env("IAS_ASSISTANT_MAX_TOOL_ROUNDS", DEFAULT_MAX_TOOL_ROUNDS)
}
fn max_tool_calls_per_round() -> usize {
read_usize_env(
"IAS_ASSISTANT_MAX_TOOL_CALLS_PER_ROUND",
DEFAULT_MAX_TOOL_CALLS_PER_ROUND,
)
}
fn read_i32_env(name: &str, default: i32) -> i32 {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<i32>().ok())
.filter(|value| *value > 0)
.unwrap_or(default)
}
fn read_usize_env(name: &str, default: usize) -> usize {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or(default)
}
#[allow(dead_code)]
fn inbound_message_id(channel: &str, account_id: &str, external_message_id: &str) -> String {
let subject = inbound_subject(channel, account_id);
format!("{}/{}", subject.as_str(), external_message_id)
}
#[cfg(test)]
mod tests {
use super::{
ToolBudget, inbound_message_id, operation_key, tool_budget_prompt_with_limits,
with_tool_budget_message,
};
use ias_common::model::ai::ChatCompletionRequestMessage;
use ias_common::queue::ChannelMessage;
#[test]
fn operation_key_is_stable_for_active_turn_idempotency() {
let channel = ChannelMessage {
channel: "wechat".to_string(),
account_id: "acct".to_string(),
from_user_id: "user".to_string(),
content: "hello".to_string(),
context: Some("ctx".to_string()),
turn_id: None,
external_message_id: None,
session_id: None,
group_id: None,
create_time_ms: None,
};
assert_eq!(operation_key(&channel), "wechat:acct:user");
}
#[test]
fn inbound_message_id_uses_subject_and_external_id() {
assert_eq!(
inbound_message_id("wechat", "acct", "msg-1"),
"inbound.wechat.acct/msg-1"
);
}
#[test]
fn tool_budget_prompt_pushes_model_to_stop_near_limit() {
let prompt = tool_budget_prompt_with_limits(
ToolBudget {
tool_rounds: 6,
tool_calls: 14,
},
8,
5,
);
assert!(prompt.contains("已使用 6/8 轮工具往返"));
assert!(prompt.contains("同一邮件 ID 本轮最多读取一次"));
assert!(prompt.contains("不要重复调用相同能力和相同参数"));
assert!(prompt.contains("已接近工具调用上限"));
assert!(prompt.contains("直接总结当前结果或请用户缩小范围"));
}
#[test]
fn tool_budget_message_is_inserted_after_system_messages() {
let messages = vec![
ChatCompletionRequestMessage::new("system".to_string(), "固定规则".to_string()),
ChatCompletionRequestMessage::new("user".to_string(), "查邮件".to_string()),
];
let messages = with_tool_budget_message(
messages,
ToolBudget {
tool_rounds: 0,
tool_calls: 0,
},
);
assert_eq!(messages.len(), 3);
assert_eq!(messages[0].content, "固定规则");
assert_eq!(messages[1].role, "system");
assert!(messages[1].content.contains("本轮工具调用预算"));
assert_eq!(messages[2].content, "查邮件");
}
}