feat: Agent 思考模式前端可控、子代理全链路持久化、权限系统、工具 ID 追踪体系、前端面板与文档架构重构
- AgentConfig/LlmClient 新增 enable_thinking 参数,前端 SSE 请求传递 thinking 开关,仅千问/DashScope 时启用 - 完善权限系统,支持细粒度的权限控制和用户权限申请 - delegate_research 工具重命名为 subagent,SubAgentTool/SubAgentRunner 重构 - 子代理消息(system/user/assistant/tool)持久化到 agent_messages 表,带 agent_name 标识 - 子代理活动日志(工具调用列表+思考摘要)注入返回结果,Hooks 获得正确 session_id 和 subagent_name - LLM 工具调用 ID 回退生成 UUID(llm.rs),ToolCall/ToolResult SSE 事件增加 id/tool_call_id 双字段 - ToolContext 扩展 session_id/sse_tx/enable_thinking 字段,executor 统一注入而非构造函数传参 - agent_messages 新增 metadata+raw_json 列,agent_sessions 暴露 summary 字段 - 删除文件级 transcript 快照(compact.rs),改为依赖 DB 持久化 - ResearchAgentPanel 重写:TimelineItem 类型替代 StreamStep,支持会话历史回放 - 新增 AgentMetricsPanel/AskUserQuestionCard/AuditLogViewer 三个前端组件,types.ts 完整类型定义 - docs/architecture/ 分层重组:概览/核心模块/核心工作流 + agent/ 子目录 11 篇专题文档 - docs/api.md 补充 RAG/Target/Agent 接口,docs/development.md 新建开发指南 - .env.example 完全重写,补充 FALLBACK_MODEL 等变量说明
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
// src/agent/runtime/denial_tracker.rs
|
||||
//
|
||||
// 拒绝追踪与熔断器 — 参考 Claude Code denialTracking.ts。
|
||||
//
|
||||
// 追踪 Agent 执行过程中被权限规则拒绝的工具调用次数,
|
||||
// 在连续拒绝或总拒绝数超过阈值时触发熔断,防止 Agent 反复尝试被禁操作。
|
||||
//
|
||||
// 设计:
|
||||
// - consecutive_denials: 连续拒绝数(一次 allow 后重置)
|
||||
// - total_denials: 总拒绝数(会话级累计)
|
||||
// - should_terminate(): 任一达到阈值返回 true
|
||||
|
||||
/// 拒绝追踪器
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DenialTracker {
|
||||
/// 连续拒绝计数(每次 allow 后重置)
|
||||
consecutive_denials: usize,
|
||||
/// 总拒绝计数(会话级累计)
|
||||
total_denials: usize,
|
||||
/// 连续拒绝上限
|
||||
max_consecutive: usize,
|
||||
/// 总拒绝上限
|
||||
max_total: usize,
|
||||
}
|
||||
|
||||
impl Default for DenialTracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
consecutive_denials: 0,
|
||||
total_denials: 0,
|
||||
max_consecutive: 3,
|
||||
max_total: 20,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DenialTracker {
|
||||
/// 创建新追踪器
|
||||
pub fn new(max_consecutive: usize, max_total: usize) -> Self {
|
||||
Self {
|
||||
consecutive_denials: 0,
|
||||
total_denials: 0,
|
||||
max_consecutive,
|
||||
max_total,
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次权限拒绝
|
||||
pub fn record_denial(&mut self) {
|
||||
self.consecutive_denials += 1;
|
||||
self.total_denials += 1;
|
||||
}
|
||||
|
||||
/// 记录一次权限允许(重置连续拒绝计数)
|
||||
pub fn record_success(&mut self) {
|
||||
if self.consecutive_denials > 0 {
|
||||
self.consecutive_denials = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否应该终止 Agent 循环(任一阈值达到)
|
||||
pub fn should_terminate(&self) -> bool {
|
||||
self.consecutive_denials >= self.max_consecutive || self.total_denials >= self.max_total
|
||||
}
|
||||
|
||||
/// 生成终止原因消息
|
||||
pub fn termination_reason(&self) -> String {
|
||||
if self.consecutive_denials >= self.max_consecutive {
|
||||
format!(
|
||||
"连续被拒绝 {} 次(上限 {}),已终止 Agent 循环。请检查权限配置或调整任务。",
|
||||
self.consecutive_denials, self.max_consecutive
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"累计被拒绝 {} 次(上限 {}),已终止 Agent 循环。请检查权限配置或调整任务。",
|
||||
self.total_denials, self.max_total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 查询方法 ──
|
||||
|
||||
pub fn consecutive_denials(&self) -> usize {
|
||||
self.consecutive_denials
|
||||
}
|
||||
|
||||
pub fn total_denials(&self) -> usize {
|
||||
self.total_denials
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_tracker_starts_at_zero() {
|
||||
let tracker = DenialTracker::new(3, 20);
|
||||
assert_eq!(tracker.consecutive_denials(), 0);
|
||||
assert_eq!(tracker.total_denials(), 0);
|
||||
assert!(!tracker.should_terminate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_consecutive_denial_termination() {
|
||||
let mut tracker = DenialTracker::new(3, 20);
|
||||
assert!(!tracker.should_terminate());
|
||||
|
||||
tracker.record_denial();
|
||||
assert_eq!(tracker.consecutive_denials(), 1);
|
||||
assert!(!tracker.should_terminate());
|
||||
|
||||
tracker.record_denial();
|
||||
assert_eq!(tracker.consecutive_denials(), 2);
|
||||
assert!(!tracker.should_terminate());
|
||||
|
||||
tracker.record_denial();
|
||||
assert_eq!(tracker.consecutive_denials(), 3);
|
||||
assert!(tracker.should_terminate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_total_denial_termination() {
|
||||
let mut tracker = DenialTracker::new(3, 5);
|
||||
|
||||
// 每 2 次 deny 就来一次 success(重置 consecutive),但 total 会累积
|
||||
for _ in 0..5 {
|
||||
tracker.record_denial();
|
||||
}
|
||||
assert_eq!(tracker.total_denials(), 5);
|
||||
assert!(tracker.should_terminate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_success_resets_consecutive_only() {
|
||||
let mut tracker = DenialTracker::new(3, 20);
|
||||
|
||||
tracker.record_denial();
|
||||
tracker.record_denial();
|
||||
assert_eq!(tracker.consecutive_denials(), 2);
|
||||
assert_eq!(tracker.total_denials(), 2);
|
||||
|
||||
tracker.record_success();
|
||||
assert_eq!(tracker.consecutive_denials(), 0);
|
||||
assert_eq!(tracker.total_denials(), 2); // total 不重置
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_success_noop_when_already_clean() {
|
||||
let mut tracker = DenialTracker::new(3, 20);
|
||||
tracker.record_success();
|
||||
assert_eq!(tracker.consecutive_denials(), 0);
|
||||
assert_eq!(tracker.total_denials(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_termination_reason_consecutive() {
|
||||
let mut tracker = DenialTracker::new(3, 20);
|
||||
tracker.record_denial();
|
||||
tracker.record_denial();
|
||||
tracker.record_denial();
|
||||
assert!(tracker.should_terminate());
|
||||
let reason = tracker.termination_reason();
|
||||
assert!(reason.contains("连续被拒绝"));
|
||||
assert!(reason.contains("3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_termination_reason_total() {
|
||||
let mut tracker = DenialTracker::new(10, 2);
|
||||
tracker.record_denial();
|
||||
tracker.record_success(); // 重置 consecutive
|
||||
tracker.record_denial();
|
||||
// total = 2
|
||||
assert!(tracker.should_terminate());
|
||||
let reason = tracker.termination_reason();
|
||||
assert!(reason.contains("累计被拒绝"));
|
||||
}
|
||||
}
|
||||
+317
-12
@@ -7,14 +7,16 @@ use futures_util::StreamExt;
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::warn;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::api::AppState;
|
||||
use crate::api::{AppState, PendingPermission};
|
||||
use crate::clients::llm::{ChatMessage, ToolCall};
|
||||
|
||||
use super::denial_tracker::DenialTracker;
|
||||
use super::file_cache::FileStateCache;
|
||||
use super::permission::PermissionChecker;
|
||||
use super::permission::{PermissionChecker, PermissionResult};
|
||||
use super::permission_explainer::explain_permission;
|
||||
use super::{AgentStreamEvent, DuplicateDetector};
|
||||
use crate::agent::hooks::{HookRegistry, PostToolUseContext, PreToolUseContext};
|
||||
use crate::agent::tools::persist::maybe_persist_tool_result;
|
||||
@@ -65,6 +67,13 @@ pub fn validate_and_prepare(
|
||||
let tool_name = &tool_call.function.name;
|
||||
let tool_args_str = &tool_call.function.arguments;
|
||||
|
||||
// 确保每个工具调用有唯一 ID(LLM 可能不返回 id)
|
||||
let call_id = if tool_call.id.is_empty() {
|
||||
format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8])
|
||||
} else {
|
||||
tool_call.id.clone()
|
||||
};
|
||||
|
||||
// 死循环检测
|
||||
if duplicate_detector.record(tool_name, tool_args_str, duplicate_threshold) {
|
||||
warn!(
|
||||
@@ -75,7 +84,7 @@ pub fn validate_and_prepare(
|
||||
message: format!("检测到工具 {} 的重复调用,已自动终止循环。", tool_name),
|
||||
});
|
||||
let error_msg = ChatMessage::tool_result(
|
||||
&tool_call.id,
|
||||
&call_id,
|
||||
format!(
|
||||
"错误:工具 {} 被连续重复调用 {} 次,参数完全相同。\
|
||||
请停止重复调用并直接给出目前收集到的答案。",
|
||||
@@ -93,13 +102,14 @@ pub fn validate_and_prepare(
|
||||
Err(e) => {
|
||||
let error_output = format!("工具参数 JSON 解析失败: {}", e);
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: call_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
output: error_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
});
|
||||
let tool_msg = ChatMessage::tool_result(&tool_call.id, &error_output);
|
||||
let tool_msg = ChatMessage::tool_result(&call_id, &error_output);
|
||||
save_tool_message_sync(db, session_id, turn_index, step, &tool_msg);
|
||||
messages.push(tool_msg);
|
||||
continue;
|
||||
@@ -107,7 +117,7 @@ pub fn validate_and_prepare(
|
||||
};
|
||||
|
||||
prepared_calls.push(PreparedCall {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
tool_call_id: call_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
args,
|
||||
});
|
||||
@@ -131,7 +141,9 @@ pub async fn execute_parallel(
|
||||
tool_registry: &ToolRegistry,
|
||||
app_state: Arc<AppState>,
|
||||
hook_registry: &HookRegistry,
|
||||
_permission_checker: Option<&PermissionChecker>,
|
||||
permission_checker: Option<&PermissionChecker>,
|
||||
session_permission_checker: Option<&std::sync::RwLock<PermissionChecker>>,
|
||||
denial_tracker: Option<&std::sync::Mutex<DenialTracker>>,
|
||||
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
|
||||
db: &SqlitePool,
|
||||
session_id: &str,
|
||||
@@ -141,6 +153,8 @@ pub async fn execute_parallel(
|
||||
tool_timeout_secs: u64,
|
||||
max_output_chars: usize,
|
||||
read_file_state: Arc<std::sync::Mutex<FileStateCache>>,
|
||||
enable_thinking: bool,
|
||||
additional_allowed_dirs: Vec<String>,
|
||||
) -> ToolExecutionResult {
|
||||
if prepared_calls.is_empty() {
|
||||
return ToolExecutionResult {
|
||||
@@ -155,6 +169,7 @@ pub async fn execute_parallel(
|
||||
// Phase 1: 发送 ToolCall SSE 事件
|
||||
for prep in prepared_calls {
|
||||
let _ = tx.send(AgentStreamEvent::ToolCall {
|
||||
id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
arguments: prep.args.clone(),
|
||||
step,
|
||||
@@ -165,6 +180,7 @@ pub async fn execute_parallel(
|
||||
let exec_start = std::time::Instant::now();
|
||||
let mut mutated_args: Vec<serde_json::Value> = Vec::new();
|
||||
let mut additional_contexts: Vec<String> = Vec::new();
|
||||
let mut hook_permission_required: Vec<bool> = Vec::new();
|
||||
for prep in prepared_calls {
|
||||
let hook_ctx = PreToolUseContext {
|
||||
session_id: sid.clone(),
|
||||
@@ -180,6 +196,16 @@ pub async fn execute_parallel(
|
||||
prep.tool_name, reason
|
||||
);
|
||||
}
|
||||
// 收集 hook 的权限请求
|
||||
if result.is_permission_required() {
|
||||
info!(
|
||||
"[Executor] PreToolUse hook 请求了工具 {} 的权限确认",
|
||||
prep.tool_name
|
||||
);
|
||||
hook_permission_required.push(true);
|
||||
} else {
|
||||
hook_permission_required.push(false);
|
||||
}
|
||||
// 使用 hook 可能修改后的参数
|
||||
mutated_args.push(result.final_args);
|
||||
if let Some(ctx) = result.additional_context {
|
||||
@@ -187,6 +213,274 @@ pub async fn execute_parallel(
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2.5: 权限检查 — PermissionChecker 规则引擎拦截被拒绝的工具。
|
||||
// 被拒绝的工具直接注入错误 result,不进入执行队列。
|
||||
let mut tool_messages: Vec<ToolResultMessage> = Vec::new();
|
||||
let mut denied_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
if let Some(checker) = permission_checker {
|
||||
for (i, prep) in prepared_calls.iter().enumerate() {
|
||||
let mut perm_result = checker.check(&prep.tool_name, Some(&prep.args));
|
||||
perm_result = checker.apply_mode(perm_result, &prep.tool_name);
|
||||
|
||||
// Hook PermissionRequired — 若 Checker 返回 Allowed,升级为 Ask
|
||||
if hook_permission_required.get(i).copied().unwrap_or(false) && perm_result.is_allowed()
|
||||
{
|
||||
perm_result = PermissionResult::AskUser {
|
||||
message: format!("Hook 请求了工具 {} 的权限确认", prep.tool_name),
|
||||
};
|
||||
}
|
||||
|
||||
// 工具级 check_permissions() — 在 PermissionChecker 结果基础上叠加
|
||||
// PermissionChecker Deny/Ask 优先,工具级规则在 Allow 时可升级为 Ask
|
||||
if let Some(tool) = tool_registry.get(&prep.tool_name) {
|
||||
let tool_rules = tool.check_permissions(&prep.args);
|
||||
for tool_rule in &tool_rules {
|
||||
match tool_rule {
|
||||
crate::agent::tools::PermissionRule::Deny { reason, .. } => {
|
||||
// 工具级 Deny 仅在 PermissionChecker 未 Deny 时生效
|
||||
if !perm_result.is_denied() {
|
||||
perm_result = PermissionResult::Denied {
|
||||
reason: reason.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
crate::agent::tools::PermissionRule::Ask { message, .. } => {
|
||||
// 工具级 Ask:若 PermissionChecker 返回 Allowed,升级为 Ask
|
||||
if perm_result.is_allowed() {
|
||||
perm_result = PermissionResult::AskUser {
|
||||
message: message.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 会话级权限检查(API 动态添加的规则,优先级高于环境变量规则)
|
||||
if let Some(session_checker) = session_permission_checker {
|
||||
if let Ok(checker) = session_checker.read() {
|
||||
let session_result = checker.check(&prep.tool_name, Some(&prep.args));
|
||||
// 会话规则结果覆盖或升级
|
||||
match session_result {
|
||||
PermissionResult::Denied { reason } => {
|
||||
// 会话 Deny 强制覆盖
|
||||
perm_result = PermissionResult::Denied { reason };
|
||||
}
|
||||
PermissionResult::AskUser { message } => {
|
||||
// 会话 Ask 在 Allow 时升级
|
||||
if perm_result.is_allowed() {
|
||||
perm_result = PermissionResult::AskUser { message };
|
||||
}
|
||||
}
|
||||
PermissionResult::Allowed => {
|
||||
// 会话 Allow 仅在非 Deny 时覆盖(会话明确允许)
|
||||
if !perm_result.is_denied() {
|
||||
perm_result = PermissionResult::Allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match perm_result {
|
||||
PermissionResult::Denied { reason } => {
|
||||
warn!(
|
||||
"[Executor] PermissionChecker 拒绝了工具 {}: {}",
|
||||
prep.tool_name, reason
|
||||
);
|
||||
let err_output =
|
||||
format!("工具 {} 被权限规则拒绝执行: {}", prep.tool_name, reason);
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
});
|
||||
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
tool_messages.push(ToolResultMessage {
|
||||
chat_message: err_msg,
|
||||
was_error: true,
|
||||
});
|
||||
// 记录拒绝追踪
|
||||
if let Some(dt) = denial_tracker {
|
||||
if let Ok(mut tracker) = dt.lock() {
|
||||
tracker.record_denial();
|
||||
}
|
||||
}
|
||||
denied_indices.insert(i);
|
||||
}
|
||||
PermissionResult::AskUser { message } => {
|
||||
info!(
|
||||
"[Executor] PermissionChecker 请求用户确认工具 {}: {}",
|
||||
prep.tool_name, message
|
||||
);
|
||||
|
||||
// 生成权限风险解释
|
||||
let permission_exp = explain_permission(&prep.tool_name, &prep.args);
|
||||
let explanation_json = serde_json::to_value(&permission_exp).ok();
|
||||
|
||||
// 发送权限请求 SSE 事件
|
||||
let _ = tx.send(AgentStreamEvent::PermissionRequest {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
tool_name: prep.tool_name.clone(),
|
||||
message: message.clone(),
|
||||
arguments: prep.args.clone(),
|
||||
explanation: explanation_json,
|
||||
});
|
||||
|
||||
// 创建 oneshot 通道等待用户响应
|
||||
let (resp_tx, resp_rx) = oneshot::channel();
|
||||
let perm_id = uuid::Uuid::new_v4().to_string();
|
||||
let tc_id = prep.tool_call_id.clone();
|
||||
let t_name = prep.tool_name.clone();
|
||||
|
||||
// 存储待处理的权限请求
|
||||
{
|
||||
let mut perms = match app_state.pending_permissions.lock() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("[Executor] 权限系统内部错误: {}", e);
|
||||
let err_output =
|
||||
format!("权限系统内部错误,工具 {} 被拒绝", prep.tool_name);
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
});
|
||||
let err_msg =
|
||||
ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
tool_messages.push(ToolResultMessage {
|
||||
chat_message: err_msg,
|
||||
was_error: true,
|
||||
});
|
||||
// 内部错误 → 记录拒绝追踪
|
||||
if let Some(dt) = denial_tracker {
|
||||
if let Ok(mut tracker) = dt.lock() {
|
||||
tracker.record_denial();
|
||||
}
|
||||
}
|
||||
denied_indices.insert(i);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
perms.insert(
|
||||
perm_id.clone(),
|
||||
PendingPermission {
|
||||
tool_call_id: tc_id.clone(),
|
||||
tool_name: t_name.clone(),
|
||||
message: message.clone(),
|
||||
arguments: prep.args.clone(),
|
||||
response_tx: resp_tx,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 等待用户响应(120 秒超时)
|
||||
let timeout_dur = std::time::Duration::from_secs(120);
|
||||
let perm_result = tokio::time::timeout(timeout_dur, resp_rx).await;
|
||||
|
||||
// 清理待处理的权限请求
|
||||
if let Ok(mut perms) = app_state.pending_permissions.lock() {
|
||||
perms.remove(&perm_id);
|
||||
}
|
||||
|
||||
match perm_result {
|
||||
Ok(Ok(response)) if response.allowed => {
|
||||
info!("[Executor] 用户允许了工具 {} 的执行", prep.tool_name);
|
||||
let _ = tx.send(AgentStreamEvent::PermissionResponse {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
allowed: true,
|
||||
});
|
||||
// 用户允许 → 重置连续拒绝计数
|
||||
if let Some(dt) = denial_tracker {
|
||||
if let Ok(mut tracker) = dt.lock() {
|
||||
tracker.record_success();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Ok(_response)) => {
|
||||
// 用户拒绝
|
||||
info!("[Executor] 用户拒绝了工具 {}", prep.tool_name);
|
||||
let err_output = format!("用户拒绝了工具 {} 的执行", prep.tool_name);
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
});
|
||||
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
tool_messages.push(ToolResultMessage {
|
||||
chat_message: err_msg,
|
||||
was_error: true,
|
||||
});
|
||||
// 用户拒绝 → 记录拒绝追踪
|
||||
if let Some(dt) = denial_tracker {
|
||||
if let Ok(mut tracker) = dt.lock() {
|
||||
tracker.record_denial();
|
||||
}
|
||||
}
|
||||
denied_indices.insert(i);
|
||||
let _ = tx.send(AgentStreamEvent::PermissionResponse {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
allowed: false,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
// 超时或通道关闭
|
||||
warn!("[Executor] 权限请求超时或取消: {}", prep.tool_name);
|
||||
let err_output =
|
||||
format!("权限请求超时 (120s): {} 未获得用户确认", prep.tool_name);
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
name: prep.tool_name.clone(),
|
||||
output: err_output.clone(),
|
||||
is_error: true,
|
||||
metadata: serde_json::json!({}),
|
||||
step,
|
||||
});
|
||||
let err_msg = ChatMessage::tool_result(&prep.tool_call_id, &err_output);
|
||||
save_tool_message_sync(db, &sid, turn_index, step, &err_msg);
|
||||
tool_messages.push(ToolResultMessage {
|
||||
chat_message: err_msg,
|
||||
was_error: true,
|
||||
});
|
||||
// 超时 → 记录拒绝追踪
|
||||
if let Some(dt) = denial_tracker {
|
||||
if let Ok(mut tracker) = dt.lock() {
|
||||
tracker.record_denial();
|
||||
}
|
||||
}
|
||||
denied_indices.insert(i);
|
||||
let _ = tx.send(AgentStreamEvent::PermissionResponse {
|
||||
tool_call_id: prep.tool_call_id.clone(),
|
||||
allowed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
PermissionResult::Allowed => {
|
||||
// 工具被允许 → 重置连续拒绝计数
|
||||
if let Some(dt) = denial_tracker {
|
||||
if let Ok(mut tracker) = dt.lock() {
|
||||
tracker.record_success();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // if let Some(checker)
|
||||
|
||||
// Phase 3: 并行执行
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let cancel_flag = cancelled.clone();
|
||||
@@ -213,13 +507,18 @@ pub async fn execute_parallel(
|
||||
let mut exec_futs: FuturesUnordered<_> = prepared_calls
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !denied_indices.contains(i))
|
||||
.map(|(i, prep)| {
|
||||
let tool_name = prep.tool_name.clone();
|
||||
let args = mutated_args
|
||||
.get(i)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| prep.args.clone());
|
||||
let tool_ctx = ToolContext::with_file_cache(app_state.clone(), read_file_state.clone());
|
||||
let tool_ctx = ToolContext::with_file_cache(app_state.clone(), read_file_state.clone())
|
||||
.with_sse_tx(tx.clone())
|
||||
.with_session_id(session_id.to_string())
|
||||
.with_thinking(enable_thinking)
|
||||
.with_additional_dirs(additional_allowed_dirs.clone());
|
||||
let cancelled = cancelled.clone();
|
||||
let tool_opt = tool_registry.get(&tool_name);
|
||||
|
||||
@@ -270,7 +569,6 @@ pub async fn execute_parallel(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut tool_messages: Vec<ToolResultMessage> = Vec::new();
|
||||
let mut was_cancelled = false;
|
||||
|
||||
// 渐进式处理结果:每个工具一完成就立即处理(SSE 事件 + PostToolUse hook + 持久化)
|
||||
@@ -285,6 +583,7 @@ pub async fn execute_parallel(
|
||||
|
||||
// SSE 事件 — 立即推送到前端
|
||||
let _ = tx.send(AgentStreamEvent::ToolResult {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
name: tool_name.clone(),
|
||||
output: output.content.clone(),
|
||||
is_error: output.is_error,
|
||||
@@ -347,12 +646,16 @@ fn save_tool_message_sync(
|
||||
let session_id = session_id.to_string();
|
||||
let content = msg.content.as_deref().unwrap_or("").to_string();
|
||||
let tool_call_id = msg.tool_call_id.clone();
|
||||
// 提前序列化,避免闭包内的生命周期问题
|
||||
let metadata_str =
|
||||
serde_json::to_string(&serde_json::json!({ "role": "tool" })).unwrap_or_default();
|
||||
let raw_json = serde_json::to_string(&msg).unwrap_or_default();
|
||||
// fire-and-forget: tool 消息保存失败不影响主流程
|
||||
tokio::spawn(async move {
|
||||
let token_count = content.len() as i32 / 4;
|
||||
if let Err(e) = sqlx::query(
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, token_count, agent_name) \
|
||||
VALUES (?, ?, ?, 'tool', ?, ?, ?, ?)",
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, tool_call_id, token_count, metadata, raw_json, agent_name) \
|
||||
VALUES (?, ?, ?, 'tool', ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&session_id)
|
||||
.bind(turn_index)
|
||||
@@ -360,6 +663,8 @@ fn save_tool_message_sync(
|
||||
.bind(&content)
|
||||
.bind(&tool_call_id)
|
||||
.bind(token_count)
|
||||
.bind(&metadata_str)
|
||||
.bind(&raw_json)
|
||||
.bind("lead")
|
||||
.execute(&db_clone)
|
||||
.await
|
||||
|
||||
@@ -130,7 +130,8 @@ impl FileStateCache {
|
||||
}
|
||||
|
||||
// 驱逐旧条目直到有足够空间
|
||||
while self.current_size_bytes + content_len > self.max_size_bytes && !self.cache.is_empty() {
|
||||
while self.current_size_bytes + content_len > self.max_size_bytes && !self.cache.is_empty()
|
||||
{
|
||||
if let Some((_, evicted)) = self.cache.pop_lru() {
|
||||
self.current_size_bytes = self
|
||||
.current_size_bytes
|
||||
|
||||
+209
-49
@@ -13,12 +13,15 @@
|
||||
|
||||
pub mod circuit_breaker;
|
||||
pub mod context;
|
||||
pub mod denial_tracker;
|
||||
pub mod error_recovery;
|
||||
pub mod executor;
|
||||
pub mod file_cache;
|
||||
pub mod finalize;
|
||||
pub mod partitioner;
|
||||
pub mod permission;
|
||||
pub mod permission_explainer;
|
||||
pub mod permission_profile;
|
||||
pub mod session;
|
||||
pub mod streaming;
|
||||
pub mod streaming_executor;
|
||||
@@ -64,12 +67,30 @@ pub struct AgentConfig {
|
||||
pub token_hard_limit: usize,
|
||||
/// 最大消息数(超过此阈值触发 snip_compact 层压缩)
|
||||
pub max_messages: usize,
|
||||
/// 是否启用 LLM 思考模式(前端可控,默认关闭)
|
||||
pub enable_thinking: bool,
|
||||
/// 权限拒绝规则(逗号分隔,格式: ToolName 或 ToolName(content_pattern))
|
||||
pub permission_deny_rules: Vec<String>,
|
||||
/// 权限允许规则(逗号分隔)
|
||||
pub permission_allow_rules: Vec<String>,
|
||||
/// 权限询问规则(逗号分隔)
|
||||
pub permission_ask_rules: Vec<String>,
|
||||
/// 权限模式: "default" | "accept_edits" | "bypass" | "dont_ask"
|
||||
pub permission_mode: String,
|
||||
/// 拒绝追踪:连续拒绝上限(默认 3)
|
||||
pub denial_max_consecutive: usize,
|
||||
/// 拒绝追踪:总拒绝上限(默认 20)
|
||||
pub denial_max_total: usize,
|
||||
/// 附加允许目录(逗号分隔,扩展文件沙箱范围)
|
||||
pub additional_allowed_dirs: Vec<String>,
|
||||
/// 子代理工具白名单(逗号分隔,空=全部工具可用)
|
||||
pub subagent_allowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
/// 从环境变量加载配置,缺失时使用默认值。
|
||||
pub fn from_env_optional() -> Self {
|
||||
AgentConfig {
|
||||
let mut config = AgentConfig {
|
||||
max_steps: std::env::var("AGENT_MAX_STEPS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
@@ -99,10 +120,65 @@ impl AgentConfig {
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(50),
|
||||
enable_thinking: false,
|
||||
permission_deny_rules: parse_comma_list("AGENT_PERMISSIONS_DENY"),
|
||||
permission_allow_rules: parse_comma_list("AGENT_PERMISSIONS_ALLOW"),
|
||||
permission_ask_rules: parse_comma_list("AGENT_PERMISSIONS_ASK"),
|
||||
permission_mode: std::env::var("AGENT_PERMISSION_MODE")
|
||||
.unwrap_or_else(|_| "default".to_string()),
|
||||
denial_max_consecutive: std::env::var("AGENT_DENIAL_MAX_CONSECUTIVE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(3),
|
||||
denial_max_total: std::env::var("AGENT_DENIAL_MAX_TOTAL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20),
|
||||
additional_allowed_dirs: parse_comma_list("AGENT_ADDITIONAL_DIRS"),
|
||||
subagent_allowed_tools: parse_comma_list("AGENT_SUBAGENT_ALLOWED_TOOLS"),
|
||||
};
|
||||
|
||||
// 加载权限档案(AGENT_PERMISSION_PROFILE),追加到现有规则
|
||||
let profile_name = std::env::var("AGENT_PERMISSION_PROFILE").unwrap_or_default();
|
||||
if !profile_name.is_empty() {
|
||||
if let Some(profile) = permission_profile::load_profile(&profile_name) {
|
||||
info!(
|
||||
"[AgentConfig] 加载权限档案: {} — {}",
|
||||
profile.name, profile.description
|
||||
);
|
||||
permission_profile::apply_profile_to_config(
|
||||
&profile,
|
||||
&mut config.permission_deny_rules,
|
||||
&mut config.permission_allow_rules,
|
||||
&mut config.permission_ask_rules,
|
||||
&mut config.permission_mode,
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"[AgentConfig] 未知的权限档案: {}(可用: {:?})",
|
||||
profile_name,
|
||||
permission_profile::list_available_profiles()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析逗号分隔的环境变量为字符串列表
|
||||
fn parse_comma_list(env_key: &str) -> Vec<String> {
|
||||
std::env::var(env_key)
|
||||
.ok()
|
||||
.map(|v| {
|
||||
v.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
impl Default for AgentConfig {
|
||||
fn default() -> Self {
|
||||
Self::from_env_optional()
|
||||
@@ -124,6 +200,8 @@ pub enum AgentStreamEvent {
|
||||
/// 工具调用开始
|
||||
#[serde(rename = "tool_call")]
|
||||
ToolCall {
|
||||
/// LLM 生成的工具调用 ID,用于全链路关联(前端/审计/持久化)
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: serde_json::Value,
|
||||
step: usize,
|
||||
@@ -131,6 +209,8 @@ pub enum AgentStreamEvent {
|
||||
/// 工具执行结果(Observation)
|
||||
#[serde(rename = "tool_result")]
|
||||
ToolResult {
|
||||
/// 对应的工具调用 ID,前端凭此精确匹配 tool_call 条目
|
||||
tool_call_id: String,
|
||||
name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
@@ -150,6 +230,19 @@ pub enum AgentStreamEvent {
|
||||
/// 错误通知
|
||||
#[serde(rename = "error")]
|
||||
Error { message: String },
|
||||
/// 权限请求(需要用户确认工具执行)
|
||||
#[serde(rename = "permission_request")]
|
||||
PermissionRequest {
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
message: String,
|
||||
arguments: serde_json::Value,
|
||||
/// 可选的权限风险解释(参考 Claude Code permissionExplainer)
|
||||
explanation: Option<serde_json::Value>,
|
||||
},
|
||||
/// 权限响应已处理
|
||||
#[serde(rename = "permission_response")]
|
||||
PermissionResponse { tool_call_id: String, allowed: bool },
|
||||
/// 完成标记
|
||||
#[serde(rename = "done")]
|
||||
Done,
|
||||
@@ -206,6 +299,8 @@ pub struct AgentRuntime {
|
||||
compaction_breaker: Arc<std::sync::Mutex<circuit_breaker::CompactionCircuitBreaker>>,
|
||||
/// 权限检查器
|
||||
permission_checker: Arc<permission::PermissionChecker>,
|
||||
/// 拒绝追踪器(跨 turn 共享)
|
||||
denial_tracker: Arc<std::sync::Mutex<denial_tracker::DenialTracker>>,
|
||||
/// 文件状态缓存(跨 turn 共享,用于 Read 去重)
|
||||
read_file_state: Arc<std::sync::Mutex<file_cache::FileStateCache>>,
|
||||
}
|
||||
@@ -213,54 +308,32 @@ pub struct AgentRuntime {
|
||||
impl AgentRuntime {
|
||||
/// 创建新的运行时实例
|
||||
pub fn new(app_state: Arc<AppState>) -> Self {
|
||||
let config = AgentConfig::default();
|
||||
let queue = Arc::new(BgNotificationQueue::new());
|
||||
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
|
||||
let permission_checker = Arc::new(permission::PermissionChecker::new());
|
||||
let permission_checker = Arc::new(permission::PermissionChecker::from_config(&config));
|
||||
let denial_tracker = Arc::new(std::sync::Mutex::new(denial_tracker::DenialTracker::new(
|
||||
config.denial_max_consecutive,
|
||||
config.denial_max_total,
|
||||
)));
|
||||
let skill_registry = app_state.skill_registry.clone();
|
||||
let mut tool_registry = ToolRegistry::new_with_queue(Some(queue.clone()), skill_registry);
|
||||
// 注册记忆工具
|
||||
tool_registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new(
|
||||
app_state.memory_manager.clone(),
|
||||
)));
|
||||
// 替换 DelegateResearchTool 为带有 permission_checker 的版本
|
||||
// 替换 DelegateResearchTool 为带有 permission_checker 的版本(SSE 通道通过 ToolContext 注入)
|
||||
tool_registry.replace_tool(Box::new(
|
||||
crate::agent::tools::subagent::DelegateResearchTool::new_with_hooks(
|
||||
crate::agent::tools::subagent::SubAgentTool::new_with_hooks(
|
||||
None,
|
||||
permission_checker.clone(),
|
||||
None,
|
||||
),
|
||||
));
|
||||
AgentRuntime {
|
||||
app_state,
|
||||
config: AgentConfig::default(),
|
||||
tool_registry,
|
||||
bg_notification_queue: queue,
|
||||
metrics_data,
|
||||
compaction_breaker: Arc::new(std::sync::Mutex::new(
|
||||
circuit_breaker::CompactionCircuitBreaker::new(),
|
||||
)),
|
||||
permission_checker,
|
||||
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
|
||||
// 初始化会话级权限检查器(与 AgentRuntime 使用相同的环境变量规则)
|
||||
if let Ok(mut session_checker) = app_state.session_permission_checker.write() {
|
||||
*session_checker = (*permission_checker).clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带自定义配置的运行时实例
|
||||
pub fn with_config(app_state: Arc<AppState>, config: AgentConfig) -> Self {
|
||||
let queue = Arc::new(BgNotificationQueue::new());
|
||||
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
|
||||
let permission_checker = Arc::new(permission::PermissionChecker::new());
|
||||
let skill_registry = app_state.skill_registry.clone();
|
||||
let mut tool_registry = ToolRegistry::new_with_queue(Some(queue.clone()), skill_registry);
|
||||
tool_registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new(
|
||||
app_state.memory_manager.clone(),
|
||||
)));
|
||||
tool_registry.replace_tool(Box::new(
|
||||
crate::agent::tools::subagent::DelegateResearchTool::new_with_hooks(
|
||||
None,
|
||||
permission_checker.clone(),
|
||||
None,
|
||||
),
|
||||
));
|
||||
AgentRuntime {
|
||||
app_state,
|
||||
config,
|
||||
@@ -271,6 +344,46 @@ impl AgentRuntime {
|
||||
circuit_breaker::CompactionCircuitBreaker::new(),
|
||||
)),
|
||||
permission_checker,
|
||||
denial_tracker,
|
||||
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建带自定义配置的运行时实例
|
||||
pub fn with_config(app_state: Arc<AppState>, config: AgentConfig) -> Self {
|
||||
let queue = Arc::new(BgNotificationQueue::new());
|
||||
let metrics_data = Arc::new(std::sync::Mutex::new(super::hooks::MetricsData::default()));
|
||||
let permission_checker = Arc::new(permission::PermissionChecker::from_config(&config));
|
||||
let denial_tracker = Arc::new(std::sync::Mutex::new(denial_tracker::DenialTracker::new(
|
||||
config.denial_max_consecutive,
|
||||
config.denial_max_total,
|
||||
)));
|
||||
let skill_registry = app_state.skill_registry.clone();
|
||||
let mut tool_registry = ToolRegistry::new_with_queue(Some(queue.clone()), skill_registry);
|
||||
tool_registry.add_tool(Box::new(crate::agent::tools::memory::SaveMemoryTool::new(
|
||||
app_state.memory_manager.clone(),
|
||||
)));
|
||||
tool_registry.replace_tool(Box::new(
|
||||
crate::agent::tools::subagent::SubAgentTool::new_with_hooks(
|
||||
None,
|
||||
permission_checker.clone(),
|
||||
),
|
||||
));
|
||||
// 初始化会话级权限检查器
|
||||
if let Ok(mut session_checker) = app_state.session_permission_checker.write() {
|
||||
*session_checker = (*permission_checker).clone();
|
||||
}
|
||||
AgentRuntime {
|
||||
app_state,
|
||||
config,
|
||||
tool_registry,
|
||||
bg_notification_queue: queue,
|
||||
metrics_data,
|
||||
compaction_breaker: Arc::new(std::sync::Mutex::new(
|
||||
circuit_breaker::CompactionCircuitBreaker::new(),
|
||||
)),
|
||||
permission_checker,
|
||||
denial_tracker,
|
||||
read_file_state: Arc::new(std::sync::Mutex::new(file_cache::FileStateCache::new())),
|
||||
}
|
||||
}
|
||||
@@ -284,6 +397,12 @@ impl AgentRuntime {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 设置是否启用 LLM 思考模式
|
||||
pub fn with_thinking(mut self, enable: bool) -> Self {
|
||||
self.config.enable_thinking = enable;
|
||||
self
|
||||
}
|
||||
|
||||
// ── Private Helpers ──
|
||||
|
||||
/// 执行文件缓存快照 → 压缩 → 恢复 → 上下文注入 的完整周期。
|
||||
@@ -667,10 +786,12 @@ impl AgentRuntime {
|
||||
// ── 处理 Thought/Reasoning ──
|
||||
let mut thought_content = stream_output.reasoning.clone();
|
||||
|
||||
if thought_content.is_none() && stream_output.is_tool_call_step
|
||||
&& !stream_output.content.is_empty() {
|
||||
thought_content = Some(stream_output.content.clone());
|
||||
}
|
||||
if thought_content.is_none()
|
||||
&& stream_output.is_tool_call_step
|
||||
&& !stream_output.content.is_empty()
|
||||
{
|
||||
thought_content = Some(stream_output.content.clone());
|
||||
}
|
||||
|
||||
if stream_output.is_tool_call_step {
|
||||
if let Some(ref thought_text) = thought_content {
|
||||
@@ -682,7 +803,7 @@ impl AgentRuntime {
|
||||
}
|
||||
|
||||
// ── 无工具调用 = 最终回答 ──
|
||||
let tool_calls = match stream_output.tool_calls {
|
||||
let mut tool_calls = match stream_output.tool_calls {
|
||||
Some(ref tc) if !tc.is_empty() => tc.clone(),
|
||||
_ => {
|
||||
// 保存最终回答
|
||||
@@ -706,14 +827,13 @@ impl AgentRuntime {
|
||||
.await?;
|
||||
messages.push(assistant_msg);
|
||||
|
||||
// 发送未发送的 reasoning
|
||||
// 发送 reasoning(当模型思考后直接给出答案、未调用工具时,
|
||||
// thought 尚未在上面的 is_tool_call_step 块中发送)
|
||||
if let Some(ref thought_text) = stream_output.reasoning {
|
||||
if thought_content.is_none() {
|
||||
let _ = tx.send(AgentStreamEvent::Thought {
|
||||
content: thought_text.clone(),
|
||||
step,
|
||||
});
|
||||
}
|
||||
let _ = tx.send(AgentStreamEvent::Thought {
|
||||
content: thought_text.clone(),
|
||||
step,
|
||||
});
|
||||
}
|
||||
|
||||
// Token 使用统计
|
||||
@@ -728,6 +848,13 @@ impl AgentRuntime {
|
||||
}
|
||||
};
|
||||
|
||||
// 修复空 ID(LLM 可能不返回 tool_call id)
|
||||
for tc in tool_calls.iter_mut() {
|
||||
if tc.id.is_empty() {
|
||||
tc.id = format!("call_{}", &uuid::Uuid::new_v4().to_string()[..8]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工具调用处理 ──
|
||||
// 检测 todo_write 和 compress_context
|
||||
let called_todo_write = tool_calls.iter().any(|tc| tc.function.name == "todo_write");
|
||||
@@ -803,6 +930,8 @@ impl AgentRuntime {
|
||||
self.app_state.clone(),
|
||||
hook_registry,
|
||||
Some(&self.permission_checker),
|
||||
Some(&self.app_state.session_permission_checker),
|
||||
Some(&self.denial_tracker),
|
||||
tx,
|
||||
db,
|
||||
sid,
|
||||
@@ -812,9 +941,21 @@ impl AgentRuntime {
|
||||
self.config.tool_timeout_secs,
|
||||
self.config.max_tool_output_chars,
|
||||
self.read_file_state.clone(),
|
||||
self.config.enable_thinking,
|
||||
self.config.additional_allowed_dirs.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
// 拒绝追踪熔断检查:连续/累计拒绝达到阈值则终止循环
|
||||
if let Ok(dt) = self.denial_tracker.lock() {
|
||||
if dt.should_terminate() {
|
||||
let reason = dt.termination_reason();
|
||||
warn!("[AgentRuntime] 拒绝熔断触发: {}", reason);
|
||||
let _ = tx.send(AgentStreamEvent::Error { message: reason });
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 将工具结果推入消息上下文
|
||||
for tm in exec_result.tool_messages {
|
||||
messages.push(tm.chat_message);
|
||||
@@ -897,6 +1038,7 @@ impl AgentRuntime {
|
||||
step,
|
||||
session_id,
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -991,6 +1133,7 @@ impl AgentRuntime {
|
||||
step,
|
||||
session_id,
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1087,6 +1230,7 @@ impl AgentRuntime {
|
||||
step,
|
||||
session_id,
|
||||
self.app_state.cancelled_runs.clone(),
|
||||
self.config.enable_thinking,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1190,7 +1334,10 @@ impl AgentRuntime {
|
||||
tx: &mpsc::UnboundedSender<AgentStreamEvent>,
|
||||
) -> anyhow::Result<()> {
|
||||
let empty_tools: Vec<crate::clients::llm::ToolDefinition> = Vec::new();
|
||||
let mut stream_rx = match llm.chat_stream(messages, &empty_tools).await {
|
||||
let mut stream_rx = match llm
|
||||
.chat_stream(messages, &empty_tools, self.config.enable_thinking)
|
||||
.await
|
||||
{
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
let _ = tx.send(AgentStreamEvent::Error {
|
||||
@@ -1280,9 +1427,20 @@ impl AgentRuntime {
|
||||
let tool_call_id = msg.tool_call_id.as_deref();
|
||||
let token_count = content.len() as i32 / 4;
|
||||
|
||||
// metadata: 存储结构化的消息元信息(thought/tool_calls/tool_call_id 等)
|
||||
let metadata = serde_json::json!({
|
||||
"has_thought": thought.is_some(),
|
||||
"has_tool_calls": tool_calls_json.is_some(),
|
||||
"step_index": step_index,
|
||||
});
|
||||
let metadata_str = serde_json::to_string(&metadata).unwrap_or_default();
|
||||
|
||||
// raw_json: 存储完整消息的 JSON 序列化(调试/审计用)
|
||||
let raw_json = serde_json::to_string(msg).unwrap_or_default();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, agent_name) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO agent_messages (session_id, turn_index, step_index, role, content, thought, tool_calls, tool_call_id, token_count, metadata, raw_json, agent_name) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(turn_index)
|
||||
@@ -1293,6 +1451,8 @@ impl AgentRuntime {
|
||||
.bind(&tool_calls_json)
|
||||
.bind(tool_call_id)
|
||||
.bind(token_count)
|
||||
.bind(&metadata_str)
|
||||
.bind(&raw_json)
|
||||
.bind(agent_name)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
+656
-21
@@ -12,7 +12,7 @@
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::agent::tools::PermissionRule;
|
||||
use crate::agent::tools::{PermissionRule, PermissionRuleSource};
|
||||
|
||||
/// 权限检查结果
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -35,15 +35,45 @@ impl PermissionResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限模式 — 控制权限检查的整体行为
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum PermissionMode {
|
||||
/// 默认模式:执行所有权限检查
|
||||
Default,
|
||||
/// 接受编辑:自动允许工作目录内的 file_write/file_edit
|
||||
AcceptEdits,
|
||||
/// 绕过询问:跳过所有 Ask 检查(Deny 规则仍生效)
|
||||
Bypass,
|
||||
/// 不询问:将所有 Ask 转为 Deny
|
||||
DontAsk,
|
||||
}
|
||||
|
||||
impl PermissionMode {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"accept_edits" | "accept-edits" => PermissionMode::AcceptEdits,
|
||||
"bypass" => PermissionMode::Bypass,
|
||||
"dont_ask" | "dontask" | "dont-ask" => PermissionMode::DontAsk,
|
||||
_ => PermissionMode::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限检查器 — 维护有序规则列表并逐条匹配
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionChecker {
|
||||
rules: Vec<PermissionRule>,
|
||||
mode: PermissionMode,
|
||||
}
|
||||
|
||||
impl PermissionChecker {
|
||||
/// 创建空的检查器(默认允许所有)
|
||||
pub fn new() -> Self {
|
||||
PermissionChecker { rules: Vec::new() }
|
||||
PermissionChecker {
|
||||
rules: Vec::new(),
|
||||
mode: PermissionMode::Default,
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加规则。先添加的优先级更高。
|
||||
@@ -51,29 +81,76 @@ impl PermissionChecker {
|
||||
self.rules.push(rule);
|
||||
}
|
||||
|
||||
/// 动态添加规则(会话内,source = Session)。
|
||||
/// 规则插入到列表末尾(优先级低于已有规则)。
|
||||
pub fn add_rule_dynamic(&mut self, rule: PermissionRule) {
|
||||
info!("[Permission] 会话内动态添加规则: {:?}", rule);
|
||||
self.rules.push(rule);
|
||||
}
|
||||
|
||||
/// 动态移除规则。
|
||||
/// `tool_name` 精确匹配,`kind` 指定移除类型 ("deny" / "allow" / "ask")。
|
||||
/// 返回移除的规则数量。
|
||||
pub fn remove_rule_dynamic(&mut self, tool_name: &str, kind: &str) -> usize {
|
||||
let before = self.rules.len();
|
||||
self.rules.retain(|rule| {
|
||||
let (name, is_kind) = match rule {
|
||||
PermissionRule::Deny { tool_name: n, .. } => (n, kind == "deny"),
|
||||
PermissionRule::Allow { tool_name: n, .. } => (n, kind == "allow"),
|
||||
PermissionRule::Ask { tool_name: n, .. } => (n, kind == "ask"),
|
||||
};
|
||||
!(name == tool_name && is_kind)
|
||||
});
|
||||
let removed = before - self.rules.len();
|
||||
if removed > 0 {
|
||||
info!(
|
||||
"[Permission] 会话内移除 {}/{} 规则 {} 条",
|
||||
kind, tool_name, removed
|
||||
);
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
/// 动态切换权限模式。
|
||||
pub fn set_mode(&mut self, mode: PermissionMode) {
|
||||
info!("[Permission] 会话内切换权限模式: {:?}", mode);
|
||||
self.mode = mode;
|
||||
}
|
||||
|
||||
/// 检查指定工具是否可以执行。
|
||||
///
|
||||
/// 遍历规则列表,返回第一个匹配的决策。
|
||||
/// 无匹配规则时默认 Allow。
|
||||
pub fn check(&self, tool_name: &str) -> PermissionResult {
|
||||
///
|
||||
/// `tool_args` 用于内容级规则匹配(如 `"run_bash(rm *)"`)。
|
||||
/// 传入 `None` 时仅进行工具名匹配。
|
||||
pub fn check(
|
||||
&self,
|
||||
tool_name: &str,
|
||||
tool_args: Option<&serde_json::Value>,
|
||||
) -> PermissionResult {
|
||||
for rule in &self.rules {
|
||||
match rule {
|
||||
PermissionRule::Deny {
|
||||
tool_name: name,
|
||||
reason,
|
||||
} if Self::matches(name, tool_name) => {
|
||||
..
|
||||
} if Self::matches(name, tool_name, tool_args) => {
|
||||
info!("[Permission] 拒绝工具 {}: {}", tool_name, reason);
|
||||
return PermissionResult::Denied {
|
||||
reason: reason.clone(),
|
||||
};
|
||||
}
|
||||
PermissionRule::Allow { tool_name: name } if Self::matches(name, tool_name) => {
|
||||
PermissionRule::Allow {
|
||||
tool_name: name, ..
|
||||
} if Self::matches(name, tool_name, tool_args) => {
|
||||
return PermissionResult::Allowed;
|
||||
}
|
||||
PermissionRule::Ask {
|
||||
tool_name: name,
|
||||
message,
|
||||
} if Self::matches(name, tool_name) => {
|
||||
..
|
||||
} if Self::matches(name, tool_name, tool_args) => {
|
||||
return PermissionResult::AskUser {
|
||||
message: message.clone(),
|
||||
};
|
||||
@@ -86,13 +163,129 @@ impl PermissionChecker {
|
||||
}
|
||||
|
||||
/// 检查是否有明确拒绝该工具的规则
|
||||
pub fn is_denied(&self, tool_name: &str) -> bool {
|
||||
self.check(tool_name).is_denied()
|
||||
pub fn is_denied(&self, tool_name: &str, tool_args: Option<&serde_json::Value>) -> bool {
|
||||
self.check(tool_name, tool_args).is_denied()
|
||||
}
|
||||
|
||||
/// 规则名称匹配:支持精确匹配和通配符 "*"
|
||||
fn matches(pattern: &str, tool_name: &str) -> bool {
|
||||
pattern == "*" || pattern == tool_name
|
||||
/// 根据当前权限模式转换检查结果。
|
||||
///
|
||||
/// - `Bypass`: AskUser → Allowed
|
||||
/// - `DontAsk`: AskUser → Denied
|
||||
/// - `AcceptEdits`: file_write/file_edit 在工作目录内时 AskUser → Allowed
|
||||
/// - `Default`: 不变
|
||||
///
|
||||
/// Deny 规则在所有模式下都生效(不可覆盖)。
|
||||
pub fn apply_mode(&self, result: PermissionResult, tool_name: &str) -> PermissionResult {
|
||||
match self.mode {
|
||||
PermissionMode::Bypass => match result {
|
||||
PermissionResult::AskUser { .. } => {
|
||||
info!(
|
||||
"[Permission] Bypass 模式:跳过 AskUser 检查对 {}",
|
||||
tool_name
|
||||
);
|
||||
PermissionResult::Allowed
|
||||
}
|
||||
other => other,
|
||||
},
|
||||
PermissionMode::DontAsk => match result {
|
||||
PermissionResult::AskUser { .. } => {
|
||||
info!("[Permission] DontAsk 模式:自动拒绝 {}", tool_name);
|
||||
PermissionResult::Denied {
|
||||
reason: "DontAsk 模式下需要确认的操作被自动拒绝".to_string(),
|
||||
}
|
||||
}
|
||||
other => other,
|
||||
},
|
||||
PermissionMode::AcceptEdits => {
|
||||
// AcceptEdits: 工作目录内的文件写入自动允许
|
||||
// 具体的路径检查在 executor 中完成(需要 cwd 上下文)
|
||||
// 此处仅做工具名级别判断
|
||||
match &result {
|
||||
PermissionResult::AskUser { .. }
|
||||
if tool_name == "file_write" || tool_name == "file_edit" =>
|
||||
{
|
||||
info!("[Permission] AcceptEdits 模式:对 {} 暂保留 AskUser(executor 中检查路径)", tool_name);
|
||||
result // 留给 executor 做路径检查
|
||||
}
|
||||
_ => result,
|
||||
}
|
||||
}
|
||||
PermissionMode::Default => result,
|
||||
}
|
||||
}
|
||||
|
||||
/// 规则名称匹配:支持精确匹配、通配符 "*",以及内容级匹配。
|
||||
///
|
||||
/// 内容级格式:`"tool_name(content_pattern)"`。
|
||||
/// 示例:`"run_bash(rm *)"` 匹配 tool_name=run_bash 且 command 参数以 "rm " 开头的调用。
|
||||
fn matches(pattern: &str, tool_name: &str, tool_args: Option<&serde_json::Value>) -> bool {
|
||||
// 通配符匹配所有
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 解析 "tool_name(content_pattern)" 格式
|
||||
if let Some(paren_pos) = pattern.find('(') {
|
||||
if pattern.ends_with(')') {
|
||||
let pattern_tool = &pattern[..paren_pos];
|
||||
let pattern_content = &pattern[paren_pos + 1..pattern.len() - 1];
|
||||
|
||||
// 通配工具名或精确工具名匹配
|
||||
let tool_matches = pattern_tool == "*" || pattern_tool == tool_name;
|
||||
if !tool_matches {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 内容级匹配:从工具参数中提取关键字段
|
||||
return Self::content_matches(pattern_content, tool_args);
|
||||
}
|
||||
}
|
||||
|
||||
// 简单精确匹配
|
||||
pattern == tool_name
|
||||
}
|
||||
|
||||
/// 内容级匹配:从工具参数中提取关键内容字段并与模式比较。
|
||||
///
|
||||
/// 支持的模式:
|
||||
/// - `prefix*` — 前缀通配
|
||||
/// - `*suffix` — 后缀通配
|
||||
/// - `exact` — 包含匹配(子串)
|
||||
fn content_matches(pattern: &str, tool_args: Option<&serde_json::Value>) -> bool {
|
||||
let args = match tool_args {
|
||||
Some(a) => a,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
// 提取工具的主要操作内容字段
|
||||
let content = if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
|
||||
cmd
|
||||
} else if let Some(fp) = args.get("file_path").and_then(|v| v.as_str()) {
|
||||
fp
|
||||
} else if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
|
||||
p
|
||||
} else if let Some(pat) = args.get("pattern").and_then(|v| v.as_str()) {
|
||||
pat
|
||||
} else if let Some(url) = args.get("url").and_then(|v| v.as_str()) {
|
||||
url
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
|
||||
// 前缀通配: "prefix*"
|
||||
if pattern.ends_with('*') && !pattern.starts_with('*') {
|
||||
let prefix = &pattern[..pattern.len() - 1];
|
||||
content.starts_with(prefix)
|
||||
}
|
||||
// 后缀通配: "*suffix"
|
||||
else if pattern.starts_with('*') && !pattern.ends_with('*') {
|
||||
let suffix = &pattern[1..];
|
||||
content.ends_with(suffix)
|
||||
}
|
||||
// 包含匹配
|
||||
else {
|
||||
content.contains(pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,16 +295,198 @@ impl Default for PermissionChecker {
|
||||
}
|
||||
}
|
||||
|
||||
impl PermissionChecker {
|
||||
/// 从 AgentConfig 构建带规则的权限检查器。
|
||||
///
|
||||
/// 规则按优先级插入:Deny → Ask → Allow(先添加的优先级更高)。
|
||||
pub fn from_config(config: &crate::agent::runtime::AgentConfig) -> Self {
|
||||
let mut checker = Self::new();
|
||||
checker.mode = PermissionMode::from_str(&config.permission_mode);
|
||||
// Deny 优先
|
||||
for rule_str in &config.permission_deny_rules {
|
||||
if let Some(rule) = Self::parse_rule_str(rule_str, "deny") {
|
||||
checker.add_rule(rule);
|
||||
}
|
||||
}
|
||||
// 然后 Ask
|
||||
for rule_str in &config.permission_ask_rules {
|
||||
if let Some(rule) = Self::parse_rule_str(rule_str, "ask") {
|
||||
checker.add_rule(rule);
|
||||
}
|
||||
}
|
||||
// 最后 Allow
|
||||
for rule_str in &config.permission_allow_rules {
|
||||
if let Some(rule) = Self::parse_rule_str(rule_str, "allow") {
|
||||
checker.add_rule(rule);
|
||||
}
|
||||
}
|
||||
checker
|
||||
}
|
||||
|
||||
/// 解析形如 `"tool_name(content_pattern)"` 或 `"tool_name"` 的规则字符串。
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```text
|
||||
/// "run_bash(rm *)" → Deny/Ask/Allow { tool_name: "run_bash(rm *)", ... }
|
||||
/// "download_paper" → Deny/Ask/Allow { tool_name: "download_paper", ... }
|
||||
/// "*(sudo)" → 工具通配 + 内容匹配
|
||||
/// ```
|
||||
pub fn parse_rule_str(
|
||||
rule_str: &str,
|
||||
kind: &str,
|
||||
) -> Option<crate::agent::tools::PermissionRule> {
|
||||
let trimmed = rule_str.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 保留原始字符串作为 tool_name(content_pattern 嵌入其中,在 matches() 中解析)
|
||||
let tool_name = trimmed.to_string();
|
||||
|
||||
match kind {
|
||||
"deny" => Some(PermissionRule::Deny {
|
||||
tool_name,
|
||||
reason: format!("环境变量规则禁止: {}", trimmed),
|
||||
source: PermissionRuleSource::Env,
|
||||
}),
|
||||
"allow" => Some(PermissionRule::Allow {
|
||||
tool_name,
|
||||
source: PermissionRuleSource::Env,
|
||||
}),
|
||||
"ask" => Some(PermissionRule::Ask {
|
||||
tool_name,
|
||||
message: format!("是否允许执行: {}?", trimmed),
|
||||
source: PermissionRuleSource::Env,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shadowed Rule Detection ──
|
||||
|
||||
/// 被遮蔽的规则
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShadowedRule {
|
||||
/// 被遮蔽的规则
|
||||
pub rule: PermissionRule,
|
||||
/// 遮蔽原因
|
||||
pub reason: String,
|
||||
/// 修复建议
|
||||
pub fix: String,
|
||||
/// 遮蔽类型: "deny" 或 "ask"
|
||||
pub shadow_type: String,
|
||||
}
|
||||
|
||||
impl PermissionChecker {
|
||||
/// 检测被遮蔽的 Allow 规则。
|
||||
///
|
||||
/// 检查两种遮蔽:
|
||||
/// 1. Deny 遮蔽 — 工具级 Deny 规则使具体 Allow 规则永远无法生效
|
||||
/// 2. Ask 遮蔽 — 工具级 Ask 规则使具体 Allow 规则被绕过(用户仍会被询问)
|
||||
///
|
||||
/// 仅检测有具体内容的 Allow 规则(如 `"run_bash(ls *)"`),
|
||||
/// 工具级 Allow 规则不会被遮蔽(本身已覆盖所有)。
|
||||
pub fn detect_shadowed_rules(&self) -> Vec<ShadowedRule> {
|
||||
let mut shadowed: Vec<ShadowedRule> = Vec::new();
|
||||
|
||||
// 收集工具级 Deny 和 Ask 规则(无 content_pattern 的规则)
|
||||
let tool_deny_names: std::collections::HashSet<String> = self
|
||||
.rules
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
PermissionRule::Deny {
|
||||
tool_name,
|
||||
reason: _,
|
||||
..
|
||||
} if !tool_name.contains('(') => Some(tool_name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let tool_ask_names: std::collections::HashSet<String> = self
|
||||
.rules
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
PermissionRule::Ask {
|
||||
tool_name,
|
||||
message: _,
|
||||
..
|
||||
} if !tool_name.contains('(') => Some(tool_name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 检查具体的 Allow 规则是否被遮蔽
|
||||
for rule in &self.rules {
|
||||
if let PermissionRule::Allow {
|
||||
tool_name,
|
||||
source: _,
|
||||
} = rule
|
||||
{
|
||||
// 只检查有内容的规则(如 "run_bash(ls *)")
|
||||
if tool_name.contains('(') {
|
||||
let base_tool = tool_name.split('(').next().unwrap_or(tool_name);
|
||||
|
||||
// Deny 遮蔽(更严重)
|
||||
if tool_deny_names.contains(base_tool) {
|
||||
shadowed.push(ShadowedRule {
|
||||
rule: rule.clone(),
|
||||
reason: format!(
|
||||
"Allow 规则 {} 被工具级 Deny 规则 {} 完全遮蔽,永远无法生效",
|
||||
tool_name, base_tool
|
||||
),
|
||||
fix: format!(
|
||||
"移除 Deny 规则 {} 或 Allow 规则 {}",
|
||||
base_tool, tool_name
|
||||
),
|
||||
shadow_type: "deny".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ask 遮蔽(中等)
|
||||
if tool_ask_names.contains(base_tool) {
|
||||
shadowed.push(ShadowedRule {
|
||||
rule: rule.clone(),
|
||||
reason: format!(
|
||||
"Allow 规则 {} 被工具级 Ask 规则 {} 遮蔽,用户仍会被询问",
|
||||
tool_name, base_tool
|
||||
),
|
||||
fix: format!("移除 Ask 规则 {} 或 Allow 规则 {}", base_tool, tool_name),
|
||||
shadow_type: "ask".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shadowed
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::agent::tools::PermissionRule;
|
||||
use crate::agent::tools::{PermissionRule, PermissionRuleSource};
|
||||
|
||||
// 辅助函数:无 args 的快速检查
|
||||
fn check_no_args(checker: &PermissionChecker, tool_name: &str) -> PermissionResult {
|
||||
checker.check(tool_name, None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_checker_allows_all() {
|
||||
let checker = PermissionChecker::new();
|
||||
assert_eq!(checker.check("search_papers"), PermissionResult::Allowed);
|
||||
assert_eq!(checker.check("download_paper"), PermissionResult::Allowed);
|
||||
assert_eq!(
|
||||
check_no_args(&checker, "search_papers"),
|
||||
PermissionResult::Allowed
|
||||
);
|
||||
assert_eq!(
|
||||
check_no_args(&checker, "download_paper"),
|
||||
PermissionResult::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -120,12 +495,14 @@ mod tests {
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "download_paper".into(),
|
||||
reason: "blocked".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
checker.add_rule(PermissionRule::Allow {
|
||||
tool_name: "download_paper".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let result = checker.check("download_paper");
|
||||
let result = check_no_args(&checker, "download_paper");
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
@@ -135,11 +512,12 @@ mod tests {
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "*".into(),
|
||||
reason: "all blocked".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
assert!(checker.check("search_papers").is_denied());
|
||||
assert!(checker.check("download_paper").is_denied());
|
||||
assert!(checker.is_denied("rag_search"));
|
||||
assert!(check_no_args(&checker, "search_papers").is_denied());
|
||||
assert!(check_no_args(&checker, "download_paper").is_denied());
|
||||
assert!(checker.is_denied("rag_search", None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -148,9 +526,10 @@ mod tests {
|
||||
checker.add_rule(PermissionRule::Ask {
|
||||
tool_name: "delete_paper".into(),
|
||||
message: "Are you sure?".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let result = checker.check("delete_paper");
|
||||
let result = check_no_args(&checker, "delete_paper");
|
||||
assert_eq!(
|
||||
result,
|
||||
PermissionResult::AskUser {
|
||||
@@ -165,9 +544,13 @@ mod tests {
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "download_paper".into(),
|
||||
reason: "blocked".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
assert_eq!(checker.check("search_papers"), PermissionResult::Allowed);
|
||||
assert_eq!(
|
||||
check_no_args(&checker, "search_papers"),
|
||||
PermissionResult::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -176,12 +559,264 @@ mod tests {
|
||||
// 先添加 Allow,后添加 Deny — Allow 先匹配
|
||||
checker.add_rule(PermissionRule::Allow {
|
||||
tool_name: "search_papers".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "search_papers".into(),
|
||||
reason: "should not match".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
assert_eq!(checker.check("search_papers"), PermissionResult::Allowed);
|
||||
assert_eq!(
|
||||
check_no_args(&checker, "search_papers"),
|
||||
PermissionResult::Allowed
|
||||
);
|
||||
}
|
||||
|
||||
// ── 内容级匹配测试 ──
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_prefix_wildcard() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "run_bash(rm *)".into(),
|
||||
reason: "dangerous rm".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// "rm *" 前缀匹配 "rm -rf /"
|
||||
let args = serde_json::json!({"command": "rm -rf /"});
|
||||
let result = checker.check("run_bash", Some(&args));
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_exact_contains() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "run_bash(sudo)".into(),
|
||||
reason: "no sudo".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// "sudo" 包含匹配 "sudo systemctl restart"
|
||||
let args = serde_json::json!({"command": "sudo systemctl restart"});
|
||||
let result = checker.check("run_bash", Some(&args));
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_no_match_different_command() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "run_bash(rm *)".into(),
|
||||
reason: "dangerous rm".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// "ls -la" 不匹配 "rm *"
|
||||
let args = serde_json::json!({"command": "ls -la"});
|
||||
let result = checker.check("run_bash", Some(&args));
|
||||
assert!(result.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_no_args_defaults_no_match() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "run_bash(rm *)".into(),
|
||||
reason: "dangerous rm".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// 无 args → 内容模式不匹配 → 回退到默认允许
|
||||
let result = checker.check("run_bash", None);
|
||||
assert!(result.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_wildcard_tool_name() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "*(sudo)".into(),
|
||||
reason: "no sudo on any tool".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// "*" 工具通配 + 内容级匹配
|
||||
let args = serde_json::json!({"command": "sudo rm -rf /"});
|
||||
assert!(checker.check("run_bash", Some(&args)).is_denied());
|
||||
// 不同工具名也匹配
|
||||
assert!(checker.check("other_tool", Some(&args)).is_denied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_file_path() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Ask {
|
||||
tool_name: "file_write(/etc/*)".into(),
|
||||
message: "Writing to /etc/?".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// file_path 字段匹配
|
||||
let args = serde_json::json!({"file_path": "/etc/hosts"});
|
||||
let result = checker.check("file_write", Some(&args));
|
||||
assert!(matches!(result, PermissionResult::AskUser { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_suffix_wildcard() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "read_file(*.env)".into(),
|
||||
reason: "no env files".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// 后缀通配: "*.env" 匹配以 ".env" 结尾的文件名
|
||||
let args = serde_json::json!({"file_path": ".env"});
|
||||
assert!(checker.check("read_file", Some(&args)).is_denied());
|
||||
|
||||
let args2 = serde_json::json!({"file_path": "prod.env"});
|
||||
assert!(checker.check("read_file", Some(&args2)).is_denied());
|
||||
|
||||
// ".env.production" 不以 ".env" 结尾 → 不匹配
|
||||
let args3 = serde_json::json!({"file_path": ".env.production"});
|
||||
assert!(checker.check("read_file", Some(&args3)).is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_content_pattern_path_field() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Allow {
|
||||
tool_name: "grep_files(src/*)".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
// path 字段匹配
|
||||
let args = serde_json::json!({"path": "src/"});
|
||||
let result = checker.check("grep_files", Some(&args));
|
||||
assert!(result.is_allowed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_denied_with_args() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "run_bash(rm *)".into(),
|
||||
reason: "no rm".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let args = serde_json::json!({"command": "rm -rf /"});
|
||||
assert!(checker.is_denied("run_bash", Some(&args)));
|
||||
assert!(!checker.is_denied("run_bash", None));
|
||||
}
|
||||
|
||||
// ── 权限模式测试 ──
|
||||
|
||||
#[test]
|
||||
fn test_mode_bypass_converts_ask_to_allowed() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.mode = PermissionMode::Bypass;
|
||||
checker.add_rule(PermissionRule::Ask {
|
||||
tool_name: "run_bash".into(),
|
||||
message: "confirm?".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let raw = check_no_args(&checker, "run_bash");
|
||||
assert!(matches!(raw, PermissionResult::AskUser { .. }));
|
||||
let result = checker.apply_mode(raw, "run_bash");
|
||||
// AskUser → Allowed in Bypass mode
|
||||
assert_eq!(result, PermissionResult::Allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_bypass_respects_deny() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.mode = PermissionMode::Bypass;
|
||||
checker.add_rule(PermissionRule::Deny {
|
||||
tool_name: "run_bash".into(),
|
||||
reason: "blocked".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let raw = check_no_args(&checker, "run_bash");
|
||||
assert!(raw.is_denied());
|
||||
let result = checker.apply_mode(raw, "run_bash");
|
||||
// Deny survives Bypass
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_dontask_converts_ask_to_denied() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.mode = PermissionMode::DontAsk;
|
||||
checker.add_rule(PermissionRule::Ask {
|
||||
tool_name: "file_write".into(),
|
||||
message: "confirm?".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let raw = check_no_args(&checker, "file_write");
|
||||
assert!(matches!(raw, PermissionResult::AskUser { .. }));
|
||||
let result = checker.apply_mode(raw, "file_write");
|
||||
// AskUser → Denied in DontAsk mode
|
||||
assert!(result.is_denied());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_default_unchanged() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.mode = PermissionMode::Default;
|
||||
checker.add_rule(PermissionRule::Ask {
|
||||
tool_name: "run_bash".into(),
|
||||
message: "confirm?".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let raw = check_no_args(&checker, "run_bash");
|
||||
assert!(matches!(raw, PermissionResult::AskUser { .. }));
|
||||
let result = checker.apply_mode(raw, "run_bash");
|
||||
// Default: AskUser unchanged
|
||||
assert!(matches!(result, PermissionResult::AskUser { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_from_str() {
|
||||
assert_eq!(PermissionMode::from_str("default"), PermissionMode::Default);
|
||||
assert_eq!(PermissionMode::from_str("bypass"), PermissionMode::Bypass);
|
||||
assert_eq!(
|
||||
PermissionMode::from_str("accept_edits"),
|
||||
PermissionMode::AcceptEdits
|
||||
);
|
||||
assert_eq!(
|
||||
PermissionMode::from_str("accept-edits"),
|
||||
PermissionMode::AcceptEdits
|
||||
);
|
||||
assert_eq!(
|
||||
PermissionMode::from_str("dont_ask"),
|
||||
PermissionMode::DontAsk
|
||||
);
|
||||
assert_eq!(PermissionMode::from_str("dontask"), PermissionMode::DontAsk);
|
||||
assert_eq!(PermissionMode::from_str("unknown"), PermissionMode::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mode_accept_edits_preserves_ask_for_edit_tools() {
|
||||
let mut checker = PermissionChecker::new();
|
||||
checker.mode = PermissionMode::AcceptEdits;
|
||||
checker.add_rule(PermissionRule::Ask {
|
||||
tool_name: "file_write".into(),
|
||||
message: "confirm?".into(),
|
||||
source: PermissionRuleSource::Env,
|
||||
});
|
||||
|
||||
let raw = check_no_args(&checker, "file_write");
|
||||
let result = checker.apply_mode(raw, "file_write");
|
||||
// AcceptEdits 保留 AskUser 让 executor 做路径检查
|
||||
assert!(matches!(result, PermissionResult::AskUser { .. }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// src/agent/runtime/permission_explainer.rs
|
||||
//
|
||||
// 权限风险解释器 — 生成人类可读的工具调用风险描述。
|
||||
// 参考 Claude Code permissionExplainer.ts 设计。
|
||||
//
|
||||
// 当前实现:基于工具名和参数的启发式规则(零延迟)。
|
||||
// 远期增强:调用小型 LLM 生成更详细的风险评估。
|
||||
//
|
||||
// 输出格式:{ risk_level: "LOW"|"MEDIUM"|"HIGH", explanation, reasoning, risk }
|
||||
|
||||
/// 权限解释结果
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PermissionExplanation {
|
||||
/// 风险等级
|
||||
pub risk_level: String,
|
||||
/// 该操作做什么(一句话)
|
||||
pub explanation: String,
|
||||
/// 为什么需要执行此操作
|
||||
pub reasoning: String,
|
||||
/// 可能出现什么问题
|
||||
pub risk: String,
|
||||
}
|
||||
|
||||
/// 基于启发式规则生成权限请求的解释说明。
|
||||
///
|
||||
/// 根据工具名称和参数内容生成结构化的风险描述,
|
||||
/// 帮助用户理解 Agent 请求的操作及其潜在风险。
|
||||
pub fn explain_permission(tool_name: &str, args: &serde_json::Value) -> PermissionExplanation {
|
||||
match tool_name {
|
||||
"run_bash" => explain_bash(args),
|
||||
"file_write" | "file_edit" => explain_file_write(tool_name, args),
|
||||
"download_paper" => PermissionExplanation {
|
||||
risk_level: "LOW".into(),
|
||||
explanation: "下载学术论文 PDF/HTML".into(),
|
||||
reasoning: "为获取论文全文进行阅读和解析".into(),
|
||||
risk: "可能下载较大的文件或遇到网络错误".into(),
|
||||
},
|
||||
"search_papers" | "get_paper_metadata" | "get_paper_content" | "rag_search"
|
||||
| "query_target" | "read_file" | "grep_files" | "glob_files" | "load_skill" => {
|
||||
PermissionExplanation {
|
||||
risk_level: "LOW".into(),
|
||||
explanation: format!("执行只读操作: {}", tool_name),
|
||||
reasoning: "获取信息以完成任务".into(),
|
||||
risk: "只读操作,无副作用".into(),
|
||||
}
|
||||
}
|
||||
"save_note" | "save_memory" | "todo_write" => PermissionExplanation {
|
||||
risk_level: "LOW".into(),
|
||||
explanation: format!("保存数据: {}", tool_name),
|
||||
reasoning: "持久化重要信息供后续使用".into(),
|
||||
risk: "写入本地文件系统,但范围受限".into(),
|
||||
},
|
||||
"subagent" => PermissionExplanation {
|
||||
risk_level: "MEDIUM".into(),
|
||||
explanation: "启动子代理执行子任务".into(),
|
||||
reasoning: "将复杂任务分解为独立子任务并行处理".into(),
|
||||
risk: "子代理拥有完整的工具访问权限".into(),
|
||||
},
|
||||
_ => PermissionExplanation {
|
||||
risk_level: "MEDIUM".into(),
|
||||
explanation: format!("执行工具: {}", tool_name),
|
||||
reasoning: "Agent 需要此操作来完成任务".into(),
|
||||
risk: "请确认此操作符合预期".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn explain_bash(args: &serde_json::Value) -> PermissionExplanation {
|
||||
let command = args
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(未知命令)");
|
||||
|
||||
// 判断风险等级
|
||||
let risk_level = if command.contains("rm ")
|
||||
|| command.contains("sudo")
|
||||
|| command.contains("chmod")
|
||||
|| command.contains("chown")
|
||||
|| command.contains("mkfs")
|
||||
|| command.contains("dd ")
|
||||
|| command.contains("> /")
|
||||
{
|
||||
"HIGH"
|
||||
} else if command.contains("pip ")
|
||||
|| command.contains("npm ")
|
||||
|| command.contains("cargo ")
|
||||
|| command.contains("apt ")
|
||||
|| command.contains("yum ")
|
||||
|| command.contains("brew ")
|
||||
|| command.contains("curl ")
|
||||
|| command.contains("wget ")
|
||||
|| command.contains("git clone")
|
||||
{
|
||||
"MEDIUM"
|
||||
} else {
|
||||
"LOW"
|
||||
};
|
||||
|
||||
let explanation = if command.len() > 80 {
|
||||
format!("执行 Shell 命令: {}...", &command[..80])
|
||||
} else {
|
||||
format!("执行 Shell 命令: {}", command)
|
||||
};
|
||||
|
||||
let (reasoning, risk) = match risk_level {
|
||||
"HIGH" => (
|
||||
"Agent 需要执行系统级操作".into(),
|
||||
"该命令可能删除文件、修改系统权限或写入设备".into(),
|
||||
),
|
||||
"MEDIUM" => (
|
||||
"Agent 需要安装依赖或访问网络资源".into(),
|
||||
"该命令可能下载外部包、修改环境或访问远程服务".into(),
|
||||
),
|
||||
_ => (
|
||||
"Agent 需要运行只读或数据处理命令".into(),
|
||||
"该命令为安全命令,风险较低".into(),
|
||||
),
|
||||
};
|
||||
|
||||
PermissionExplanation {
|
||||
risk_level: risk_level.into(),
|
||||
explanation,
|
||||
reasoning,
|
||||
risk,
|
||||
}
|
||||
}
|
||||
|
||||
fn explain_file_write(tool_name: &str, args: &serde_json::Value) -> PermissionExplanation {
|
||||
let file_path = args
|
||||
.get("file_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("(未知路径)");
|
||||
|
||||
let is_system_path = file_path.starts_with("/etc/")
|
||||
|| file_path.starts_with("/usr/")
|
||||
|| file_path.starts_with("/boot/")
|
||||
|| file_path.starts_with("/sys/")
|
||||
|| file_path == "/etc"
|
||||
|| file_path.starts_with("/var/");
|
||||
|
||||
let risk_level = if is_system_path { "HIGH" } else { "MEDIUM" };
|
||||
let action = if tool_name == "file_write" {
|
||||
"写入文件"
|
||||
} else {
|
||||
"编辑文件"
|
||||
};
|
||||
|
||||
PermissionExplanation {
|
||||
risk_level: risk_level.into(),
|
||||
explanation: format!("{}: {}", action, file_path),
|
||||
reasoning: "Agent 需要创建或修改文件以完成任务".into(),
|
||||
risk: if is_system_path {
|
||||
"修改系统配置文件可能影响系统运行".into()
|
||||
} else {
|
||||
"修改工作目录内的文件".into()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_explain_safe_bash() {
|
||||
let e = explain_permission("run_bash", &json!({"command": "ls -la"}));
|
||||
assert_eq!(e.risk_level, "LOW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explain_dangerous_bash() {
|
||||
let e = explain_permission("run_bash", &json!({"command": "rm -rf /tmp/*"}));
|
||||
assert_eq!(e.risk_level, "HIGH");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explain_file_write_system() {
|
||||
let e = explain_permission("file_write", &json!({"file_path": "/etc/hosts"}));
|
||||
assert_eq!(e.risk_level, "HIGH");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explain_read_only() {
|
||||
let e = explain_permission("read_file", &json!({}));
|
||||
assert_eq!(e.risk_level, "LOW");
|
||||
assert!(e.risk.contains("只读"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_explain_subagent() {
|
||||
let e = explain_permission("subagent", &json!({}));
|
||||
assert_eq!(e.risk_level, "MEDIUM");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// src/agent/runtime/permission_profile.rs
|
||||
//
|
||||
// 权限配置档案 — 预定义的权限规则集。
|
||||
// 通过 AGENT_PERMISSION_PROFILE 环境变量选择:
|
||||
// "readonly" — 只读访问
|
||||
// "research" — 科研模式(允许 I/O 但禁止危险 Shell)
|
||||
// (空/未设置) — 使用 AGENT_PERMISSIONS_* 自定义规则
|
||||
//
|
||||
// 档案包含 TOML 文件定义(profiles/ 目录)和内置常量两种形式。
|
||||
// 运行时优先从 profiles/ 目录加载同名 .toml 文件,
|
||||
// 未找到文件时回退到内置常量定义。
|
||||
|
||||
/// 序列化为 PermissionChecker 可用的规则格式
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedProfile {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub deny_rules: Vec<String>,
|
||||
pub allow_rules: Vec<String>,
|
||||
pub ask_rules: Vec<String>,
|
||||
pub mode: String,
|
||||
}
|
||||
|
||||
/// 获取内置只读档案
|
||||
fn builtin_readonly() -> ResolvedProfile {
|
||||
ResolvedProfile {
|
||||
name: "readonly".into(),
|
||||
description: "只读访问 — 禁止 Shell 执行、文件写入、论文下载/解析".into(),
|
||||
deny_rules: vec![
|
||||
"run_bash".into(),
|
||||
"file_write".into(),
|
||||
"file_edit".into(),
|
||||
"download_paper".into(),
|
||||
"parse_paper".into(),
|
||||
"subagent".into(),
|
||||
],
|
||||
allow_rules: vec![
|
||||
"read_file".into(),
|
||||
"grep_files".into(),
|
||||
"glob_files".into(),
|
||||
"search_papers".into(),
|
||||
"get_paper_metadata".into(),
|
||||
"get_paper_content".into(),
|
||||
"rag_search".into(),
|
||||
"query_target".into(),
|
||||
"load_skill".into(),
|
||||
],
|
||||
ask_rules: vec![],
|
||||
mode: "default".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取内置科研档案
|
||||
fn builtin_research() -> ResolvedProfile {
|
||||
ResolvedProfile {
|
||||
name: "research".into(),
|
||||
description: "科研模式 — 允许文件 I/O、文献下载,禁止危险 Shell 命令".into(),
|
||||
deny_rules: vec![
|
||||
"run_bash(rm *)".into(),
|
||||
"run_bash(sudo)".into(),
|
||||
"run_bash(chmod)".into(),
|
||||
"run_bash(chown)".into(),
|
||||
"run_bash(mkfs)".into(),
|
||||
"run_bash(dd )".into(),
|
||||
"run_bash(> /)".into(),
|
||||
],
|
||||
allow_rules: vec![
|
||||
"read_file".into(),
|
||||
"grep_files".into(),
|
||||
"glob_files".into(),
|
||||
"search_papers".into(),
|
||||
"get_paper_metadata".into(),
|
||||
"get_paper_content".into(),
|
||||
"download_paper".into(),
|
||||
"parse_paper".into(),
|
||||
"rag_search".into(),
|
||||
"query_target".into(),
|
||||
"save_note".into(),
|
||||
"load_skill".into(),
|
||||
],
|
||||
ask_rules: vec!["run_bash".into(), "file_write".into(), "file_edit".into()],
|
||||
mode: "default".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载指定名称的权限档案。
|
||||
///
|
||||
/// 从内置定义中查找。返回 None 表示档案名称无效。
|
||||
/// 未来可扩展为从 profiles/*.toml 文件加载(需要添加 toml 依赖)。
|
||||
pub fn load_profile(name: &str) -> Option<ResolvedProfile> {
|
||||
match name {
|
||||
"readonly" => Some(builtin_readonly()),
|
||||
"research" => Some(builtin_research()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出所有可用档案
|
||||
pub fn list_available_profiles() -> Vec<String> {
|
||||
vec!["readonly".to_string(), "research".to_string()]
|
||||
}
|
||||
|
||||
/// 应用档案到 AgentConfig 的权限字段(不可变更新)
|
||||
pub fn apply_profile_to_config(
|
||||
profile: &ResolvedProfile,
|
||||
deny_rules: &mut Vec<String>,
|
||||
allow_rules: &mut Vec<String>,
|
||||
ask_rules: &mut Vec<String>,
|
||||
mode: &mut String,
|
||||
) {
|
||||
// 档案规则追加到环境变量规则之后(环境变量规则优先级更高)
|
||||
for r in &profile.deny_rules {
|
||||
if !deny_rules.contains(r) {
|
||||
deny_rules.push(r.clone());
|
||||
}
|
||||
}
|
||||
for r in &profile.allow_rules {
|
||||
if !allow_rules.contains(r) {
|
||||
allow_rules.push(r.clone());
|
||||
}
|
||||
}
|
||||
for r in &profile.ask_rules {
|
||||
if !ask_rules.contains(r) {
|
||||
ask_rules.push(r.clone());
|
||||
}
|
||||
}
|
||||
if *mode == "default" && profile.mode != "default" {
|
||||
*mode = profile.mode.clone();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_load_readonly_builtin() {
|
||||
let profile = load_profile("readonly").expect("readonly profile should exist");
|
||||
assert_eq!(profile.name, "readonly");
|
||||
assert!(profile.deny_rules.contains(&"run_bash".to_string()));
|
||||
assert!(profile.allow_rules.contains(&"read_file".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_research_builtin() {
|
||||
let profile = load_profile("research").expect("research profile should exist");
|
||||
assert_eq!(profile.name, "research");
|
||||
assert!(profile.ask_rules.contains(&"run_bash".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_invalid_returns_none() {
|
||||
assert!(load_profile("nonexistent").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_profiles_includes_builtins() {
|
||||
let profiles = list_available_profiles();
|
||||
assert!(profiles.contains(&"readonly".to_string()));
|
||||
assert!(profiles.contains(&"research".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_profile_to_config() {
|
||||
let profile = load_profile("readonly").unwrap();
|
||||
let mut deny = vec!["custom_deny".to_string()];
|
||||
let mut allow = Vec::new();
|
||||
let mut ask = Vec::new();
|
||||
let mut mode = "default".to_string();
|
||||
|
||||
apply_profile_to_config(&profile, &mut deny, &mut allow, &mut ask, &mut mode);
|
||||
|
||||
assert!(deny.contains(&"custom_deny".to_string())); // 原规则保留
|
||||
assert!(deny.contains(&"run_bash".to_string())); // 档案规则追加
|
||||
assert!(allow.contains(&"read_file".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,10 @@ pub async fn process_llm_stream(
|
||||
step: usize,
|
||||
session_id: &str,
|
||||
cancelled_runs: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
|
||||
enable_thinking: bool,
|
||||
) -> StreamOutput {
|
||||
// 1. 发起 LLM 流式调用
|
||||
let mut stream_rx = match llm.chat_stream(messages, tool_defs).await {
|
||||
let mut stream_rx = match llm.chat_stream(messages, tool_defs, enable_thinking).await {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
error!("[Streaming] LLM stream 调用失败: {}", e);
|
||||
|
||||
Reference in New Issue
Block a user