Files
Unlucky-Launcher/crates/agent/src/brain/exec.rs
T
AskaEth 5f8382565e
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Unlucky Launcher v0.1.0
单提交快照:main 只保留已发布版本的代码树,开发史在 dev。
2026-11-01 20:02:49 +08:00

348 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 工具执行编排:schema 校验 → 分级确认 → 执行 → 审计 → 事件。
//!
//! **YOLO 只在这里**(mission M16 任务 3 的分工):工具自己不判断 policy,
//! 只声明「这次需要确认 + diff」;本模块按 [`ConfirmPolicy`] 决定问不问。
//!
//! 一次工具调用最多两条审计记录:
//! 1. 需要确认时先记一条 `awaiting_confirmation`(「AI 想改 X」本身必须留痕);
//! 2. 结局记一条 `ok` / `denied` / `tool_error`(带 `yolo` 与 `confirmed` 标记)。
//!
//! 这两条区分了三种在复盘里完全不同的事:用户点了拒绝、YOLO 自动放行、用户点了允许。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use serde_json::{json, Value};
use crate::audit::{make_record, AuditDraft, AuditOutcome, AuditSink};
use crate::brain::chat::ToolCall;
use crate::clock::SharedClock;
use crate::confirm::{ConfirmDecision, ConfirmGate, ConfirmPolicy, ConfirmRequest, DenyGuidance};
use crate::event::{AgentEvent, AgentEventSink};
use crate::registry::validate::validate;
use crate::registry::{ToolCtx, ToolError, ToolRegistry};
/// 一次工具执行的结局(回灌给模型的内容 + 成败标记)。
#[derive(Debug, Clone, PartialEq)]
pub struct ToolExecOutcome {
/// 回灌内容(结构化)。
pub content: Value,
/// 是否成功(UI/事件用;模型看的是 `content`)。
pub ok: bool,
}
impl ToolExecOutcome {
/// 成功。
#[must_use]
pub fn ok(content: Value) -> Self {
Self { content, ok: true }
}
/// 失败(`code` 给模型看,帮助它改正后重试)。
#[must_use]
pub fn error(code: &str, message: impl Into<String>) -> Self {
Self {
content: json!({ "status": "error", "code": code, "message": message.into() }),
ok: false,
}
}
/// 序列化成回灌给模型的字符串(工具消息的 `content`)。
#[must_use]
pub fn to_model_content(&self) -> String {
serde_json::to_string(&self.content).unwrap_or_else(|_| {
json!({ "status": "error", "code": "internal", "message": "结果序列化失败" })
.to_string()
})
}
}
/// 工具执行器(无状态,可跨轮复用)。
pub struct ToolExecutor {
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
guidance: DenyGuidance,
confirm_seq: AtomicU64,
}
impl ToolExecutor {
/// 装配(默认拒绝口径 = 内置对话:[`DenyGuidance::InApp`])。
pub fn new(
registry: Arc<ToolRegistry>,
audit: Arc<dyn AuditSink>,
gate: Arc<dyn ConfirmGate>,
policy: ConfirmPolicy,
clock: SharedClock,
sink: Arc<dyn AgentEventSink>,
) -> Self {
Self {
registry,
audit,
gate,
policy,
clock,
sink,
guidance: DenyGuidance::InApp,
confirm_seq: AtomicU64::new(0),
}
}
/// 换拒绝口径(外部 MCP 客户端用 [`DenyGuidance::External`]:没有弹窗可点)。
///
/// 只改**文案**:策略、闸门、审计标记一个字都不变——这正是「同一注册表、
/// 同一安全策略、同一审计」在代码上的落点。
#[must_use]
pub fn with_guidance(mut self, guidance: DenyGuidance) -> Self {
self.guidance = guidance;
self
}
/// 执行一次工具调用(含确认与审计)。
pub async fn execute(
&self,
agent_session_id: &str,
call: &ToolCall,
step: usize,
) -> ToolExecOutcome {
let started_at = self.clock.now_unix_ms();
self.sink.emit(AgentEvent::ToolCallStarted {
tool: call.name.clone(),
// 与审计同一套脱敏:事件(P1 会持久化)不能带出凭据明文。
args: crate::redact::redact_args(&call.arguments),
step,
});
let Some(spec) = self.registry.get(&call.name).cloned() else {
let message = format!(
"没有名为 `{}` 的工具。可用工具:{}",
call.name,
self.registry.names().join("、")
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::UnknownTool,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("unknown_tool", message);
};
if let Err(errors) = validate(&spec.input_schema, &call.arguments) {
let message = format!("参数不合法:{}", errors.join(";"));
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::InvalidArgs,
&message,
false,
false,
started_at,
);
self.finish_event(&call.name, false, &message, false);
return ToolExecOutcome::error("invalid_args", message);
}
let ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: false,
};
let quoted = match (spec.handler)(&ctx, &call.arguments) {
Ok(output) => output,
Err(err) => return self.fail(agent_session_id, call, err, started_at),
};
let Some(confirmation) = quoted.confirmation.clone() else {
return self.succeed(agent_session_id, call, quoted, false, false, started_at);
};
// ── 需要确认:决定问不问(YOLO 只影响这里)──
let asked = self.policy.requires_confirmation(confirmation.sensitivity);
let yolo_auto = !asked && confirmation.sensitivity == crate::confirm::Sensitivity::High;
if asked {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::AwaitingConfirmation,
&confirmation.summary,
false,
false,
started_at,
);
let request = ConfirmRequest {
id: format!(
"confirm-{}",
self.confirm_seq.fetch_add(1, Ordering::Relaxed) + 1
),
tool: call.name.clone(),
summary: confirmation.summary.clone(),
details: confirmation.details.clone(),
sensitivity: confirmation.sensitivity,
};
self.sink.emit(AgentEvent::ConfirmRequested {
request: Box::new(request.clone()),
});
if self.gate.request(request).await == ConfirmDecision::Denied {
let message = self.guidance.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Denied,
&message,
false,
false,
started_at,
);
self.sink.emit(AgentEvent::ToolDenied {
tool: call.name.clone(),
reason: message.clone(),
});
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("denied", message);
}
}
// ── 已获授权(用户允许或 YOLO):带 confirmed 重放 ──
let confirmed_ctx = ToolCtx {
agent_session_id: agent_session_id.to_string(),
confirmed: true,
};
match (spec.handler)(&confirmed_ctx, &call.arguments) {
Ok(output) => {
if let Some(second) = output.confirmation {
// 工具在"已确认"状态下仍要确认 = 实现写错了,明确报出来而不是死循环。
let message = format!(
"工具 `{}` 在已确认状态下仍请求确认:{}",
call.name, second.summary
);
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo_auto,
asked,
started_at,
);
self.finish_event(&call.name, false, &message, true);
return ToolExecOutcome::error("internal", message);
}
self.succeed(agent_session_id, call, output, asked, yolo_auto, started_at)
}
Err(err) => {
self.fail_after_confirm(agent_session_id, call, err, asked, yolo_auto, started_at)
}
}
}
fn succeed(
&self,
agent_session_id: &str,
call: &ToolCall,
output: crate::registry::ToolOutput,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::Ok,
&output.summary,
yolo,
confirmed,
started_at,
);
self.finish_event(
&call.name,
true,
&output.summary,
output.confirmation.is_some(),
);
ToolExecOutcome::ok(output.content)
}
fn fail(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
started_at: u64,
) -> ToolExecOutcome {
self.fail_after_confirm(agent_session_id, call, err, false, false, started_at)
}
fn fail_after_confirm(
&self,
agent_session_id: &str,
call: &ToolCall,
err: ToolError,
confirmed: bool,
yolo: bool,
started_at: u64,
) -> ToolExecOutcome {
let message = err.message().to_string();
self.record(
agent_session_id,
&call.name,
&call.arguments,
AuditOutcome::ToolError,
&message,
yolo,
confirmed,
started_at,
);
self.finish_event(&call.name, false, &message, false);
ToolExecOutcome::error(err.code(), message)
}
#[allow(clippy::too_many_arguments)]
fn record(
&self,
agent_session_id: &str,
tool: &str,
args: &Value,
outcome: AuditOutcome,
summary: &str,
yolo: bool,
confirmed: bool,
started_at: u64,
) {
let duration_ms = self.clock.now_unix_ms().saturating_sub(started_at);
let draft = AuditDraft {
tool,
args,
outcome,
summary,
yolo,
confirmed,
duration_ms,
};
self.audit
.record(&make_record(self.clock.as_ref(), agent_session_id, draft));
}
fn finish_event(&self, tool: &str, ok: bool, summary: &str, requires_confirmation: bool) {
self.sink.emit(AgentEvent::ToolCallFinished {
tool: tool.to_string(),
ok,
summary: summary.to_string(),
requires_confirmation,
});
}
}