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
+118
View File
@@ -0,0 +1,118 @@
use std::time::Duration;
use ias_ai::toolkit::call_tools;
use ias_common::model::ai::{ChatCompletionFunction, ChatCompletionToolCall};
use ias_common::mq::{
ConsumerSpec, FunctionCallPayload, FunctionResultPayload, JetStreamBus, JetStreamConfig,
MessageEnvelope, StreamKind, StreamNames, function_result_subject,
};
use ias_common::queue::QueueMessage;
pub async fn run_worker(name: Option<String>) -> anyhow::Result<()> {
let worker_name = name.unwrap_or_else(|| "default".to_string());
let config = JetStreamConfig::from_env();
loop {
match run_once(worker_name.clone(), config.clone()).await {
Ok(()) => return Ok(()),
Err(error) => {
eprintln!("Function Worker 连接或消费失败: {error:#}");
tokio::time::sleep(Duration::from_secs(5)).await;
}
}
}
}
async fn run_once(worker_name: String, config: JetStreamConfig) -> anyhow::Result<()> {
let bus = JetStreamBus::connect(config.clone()).await?;
bus.ensure_streams().await?;
let names = StreamNames::new(config.stream_prefix.clone());
let spec = ConsumerSpec {
stream: names.name(StreamKind::Function),
durable_name: format!("function-worker-{worker_name}"),
filter_subject: "function.call.>".to_string(),
ack_wait: config.ack_wait(),
max_deliver: config.max_deliver,
};
let publish_bus = bus.clone();
bus.consume_envelopes::<FunctionCallPayload, _, _>(spec, move |envelope| {
let bus = publish_bus.clone();
async move {
let call = envelope.payload;
let tool_call = ChatCompletionToolCall {
id: call.call_id.clone(),
call_type: "function".to_string(),
function: Some(ChatCompletionFunction {
name: call.name.clone(),
arguments: call.arguments.clone(),
}),
};
let result = call_tools(call.channel.clone(), vec![tool_call]).await?;
let (name, result) = match result {
Some(QueueMessage::Tools(_, tools)) => {
let tool = tools
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("工具执行没有返回结果"))?;
(tool.name, tool.result)
}
Some(QueueMessage::Tool(_, _, name, result)) => (name, result),
Some(_) => anyhow::bail!("工具执行返回了非工具消息"),
None => anyhow::bail!("工具执行没有生成队列消息"),
};
let payload = FunctionResultPayload {
call_id: call.call_id.clone(),
name,
result,
channel: call.channel,
};
let subject = function_result_subject(&payload.call_id);
let envelope = MessageEnvelope::new(
subject.as_str(),
format!("function-result/{}", payload.call_id),
envelope.trace_id,
payload,
);
bus.publish_envelope(subject.as_str(), &envelope).await?;
Ok(())
}
})
.await
}
#[cfg(test)]
mod tests {
use ias_common::mq::{FunctionCallPayload, MessageEnvelope, function_call_subject};
use ias_common::queue::ChannelMessage;
#[test]
fn function_call_payload_keeps_call_id_as_idempotency_key() {
let payload = FunctionCallPayload {
call_id: "call-1".to_string(),
name: "query_capabilities".to_string(),
arguments: "{}".to_string(),
channel: ChannelMessage {
channel: "wechat".to_string(),
account_id: "acct".to_string(),
from_user_id: "user".to_string(),
content: "hello".to_string(),
context: None,
turn_id: Some("turn-1".to_string()),
external_message_id: None,
session_id: None,
group_id: None,
create_time_ms: None,
},
};
let subject = function_call_subject(&payload.name);
let envelope = MessageEnvelope::new(
subject.as_str(),
format!("function-call/{}", payload.call_id),
"trace-1",
payload,
);
let encoded = serde_json::to_string(&envelope).unwrap();
assert!(encoded.contains("function-call/call-1"));
assert!(encoded.contains("trace-1"));
}
}