7.4 中断恢复与取消机制

概述

当 Agent 因 `FinishReason::AwaitingApproval` 暂停后,调用方需要一种机制来恢复执行。RAF 通过**消息上下文重建**实现状态恢复:审批暂停时 `FunctionInvokingChatClient` 已将 `assistant(tool_calls)` 消息保存在循环状态中,恢复时从消息历史中提取待处理工具调用,匹配审批响应,然后继续执行。

同时,为了支持用户主动取消长时间运行或不需要继续的操作,RAF 提供了基于 `Arc<AtomicBool>` 的取消机制,零外部依赖。

审批恢复:消息重建

暂停时保存的消息格式

当审批触发时,`FunctionInvokingChatClient` 构造一条 `Assistant` 角色的消息并通过 `msg_tx` 通道传递:

// crates/framework/src/decorators/invoke.rs

let assistant_tool_msg = ChatMessage {
    role: MessageRole::Assistant,
    content: text_delta.clone(),      // 该轮 LLM 输出的文本内容
    name: None,
    tool_calls: Some(
        tool_calls.iter().map(|tc| ToolCall {
            id: tc.id.clone(),
            name: tc.name.clone(),
            arguments: serde_json::Value::String(tc.arguments.clone()),
        }).collect(),
    ),
    tool_call_id: None,
    source: None,
};
next_messages.push(assistant_tool_msg);
msg_tx.send(next_messages).await;

这条消息会被 `LoopState::Streaming` 状态接收并合并到 `LoopState::Looping.messages` 中,成为下一轮循环的上下文。

恢复时消息重建流程

sequenceDiagram
    participant Caller as 调用方
    participant LS as LoopState::Looping
    participant MSG as messages 列表
    participant TC as 工具执行

    Caller->>LS: run(messages, options.tool_approval_responses)

    Note over LS: 检测到 approval_responses 非空

    LS->>LS: 构建 approval_map:<br/>call_id → ToolApprovalResponse

    LS->>MSG: 从后往前查找<br/>role=Assistant 且有 tool_calls 的消息

    MSG-->>LS: pending: Vec<ToolCall>

    loop 对每个 pending tool_call
        LS->>LS: 在 approval_map 中查找 call_id
        alt 批准
            LS->>TC: 执行工具
            TC-->>LS: ToolResult
            LS->>MSG: 追加 tool(result) 消息
        else 拒绝
            LS->>MSG: 追加 tool("Rejected: {reason}") 消息
        end
    end

    LS->>LS: 清空 tool_approval_responses
    LS->>LS: round += 1

    LS->>LS: 继续正常循环 → 调用 LLM

恢复代码实现详解

LoopState::Looping { messages, round, options } => {
    // ── 审批恢复入口 ──
    if !options.tool_approval_responses.is_empty() {
        // 1. 构建快速查找映射
        let approval_map: HashMap<&str, &ToolApprovalResponse> =
            options.tool_approval_responses
                .iter()
                .map(|r| (r.call_id.as_str(), r))
                .collect();

        // 2. 从消息历史中提取 pending tool_calls
        //    从后往前找第一条 role=Assistant 且有 tool_calls 的消息
        let pending: Vec<ToolCall> = messages
            .iter()
            .rev()
            .find(|m| m.role == MessageRole::Assistant && m.tool_calls.is_some())
            .and_then(|m| m.tool_calls.clone())
            .unwrap_or_default();

        // 3. 为每个 pending tool_call 处理审批结果
        let mut next_messages = messages.clone();
        for tc in &pending {
            let approved = approval_map
                .get(tc.id.as_str())
                .map(|r| r.approved)
                .unwrap_or(false);

            if approved {
                // 参数标准化:LLM 的参数可能以 String(json_str) 形式存储
                let args = match &tc.arguments {
                    serde_json::Value::String(s) => {
                        serde_json::from_str(s).unwrap_or(serde_json::Value::Null)
                    }
                    other => other.clone(),
                };

                // 执行工具
                let result = match tools.iter().find(|t| t.name() == tc.name) {
                    Some(tool) => match tool.execute(args).await {
                        Ok(output) => serde_json::to_string(&output)
                            .unwrap_or_else(|e| format!(r#"{{"ok":false,"error":"{}"}}"#, e)),
                        Err(e) => serde_json::json!({
                            "ok": false,
                            "error": format!("Framework error: {}", e),
                            "data": null
                        }).to_string(),
                    },
                    None => format!("Tool '{}' not found", tc.name),
                };

                // 追加 tool(result) 消息
                next_messages.push(ChatMessage {
                    role: MessageRole::Tool,
                    content: result,
                    name: Some(tc.name.clone()),
                    tool_call_id: Some(tc.id.clone()),
                    ..Default::default()
                });
            } else {
                // 拒绝:追加拒绝消息
                let reason = approval_map
                    .get(tc.id.as_str())
                    .and_then(|r| r.reason.as_deref())
                    .unwrap_or("User denied");
                next_messages.push(ChatMessage {
                    role: MessageRole::Tool,
                    content: format!("Rejected: {}", reason),
                    name: Some(tc.name.clone()),
                    tool_call_id: Some(tc.id.clone()),
                    ..Default::default()
                });
            }
        }

        // 4. 清理审批响应,进入下一轮循环
        let mut opts = options.clone();
        opts.tool_approval_responses.clear();

        return Some((Ok(AgentResponseUpdate::TextDelta { delta: String::new() }),
            LoopState::Looping {
                messages: next_messages,
                round: round + 1,
                options: opts,
            }));
    }
    // ... 正常循环逻辑 ...
}

恢复后的 LLM 对话上下文

恢复后,LLM 收到的消息序列如下:

[system] "你是文件管理助手..."
[user] "帮我删除 /tmp/logs"
[assistant, tool_calls=[run_command(call_1)]]
[tool, tool_call_id=call_1] {"ok":true,"data":{"stdout":"已删除","stderr":""}}

或(拒绝时):

[system] "你是文件管理助手..."
[user] "帮我删除 /tmp/logs"
[assistant, tool_calls=[run_command(call_1)]]
[tool, tool_call_id=call_1] "Rejected: 需要先确认目录内容"

LLM 看到 tool 角色的消息后,会根据内容(成功结果或拒绝原因)生成后续回复。

取消机制

设计:Arc<AtomicBool>

RAF 使用 `Arc<AtomicBool>` 实现取消,无需额外的取消 token 库:

// crates/core/src/run_options.rs

pub struct AgentRunOptions {
    // ...
    /// 取消标志。调用方持有克隆并将其设为 true
    /// 以在下一个工具循环迭代时中断 Agent
    #[serde(skip)]
    pub cancelled: Option<Arc<AtomicBool>>,
}

取消检查位置

取消检查位于 `FunctionInvokingChatClient` 的 `LoopState::Looping` 分支,在**每个循环迭代开始时**执行。这确保了在任意轮次之间都能响应取消请求:

LoopState::Looping { messages, round, options } => {
    // ── 取消检查(在任何其他逻辑之前)──
    if let Some(ref flag) = options.cancelled {
        if flag.load(Ordering::Relaxed) {
            tracing::info!(round, "Agent run cancelled");
            let err_update = AgentResponseUpdate::Error {
                message: "Agent run cancelled".into(),
            };
            return Some((Ok(err_update), LoopState::Done));
        }
    }
    // ... 审批恢复或正常循环逻辑 ...
}

**重要**:取消检查在审批恢复检查**之前**执行。这意味着即使有待处理的审批响应,调用方也可以通过设置取消标志来提前终止执行。

使用方式

use std::sync::Arc;
use std::sync::atomic::AtomicBool;

// 创建取消标志
let cancel_flag = Arc::new(AtomicBool::new(false));

// 在 Agent 调用中传递
let options = AgentRunOptions::new()
    .with_cancelled(cancel_flag.clone());

let stream = agent.run(messages, session, Some(options)).await?;

// 在另一个线程/异步任务中取消
tokio::spawn(async move {
    tokio::time::sleep(Duration::from_secs(30)).await;
    cancel_flag.store(true, Ordering::Relaxed);
});

取消后的流行为

取消后,流会发出一个 `AgentResponseUpdate::Error` 事件,然后进入 `LoopState::Done` 状态,流结束:

...之前的流内容...
AgentResponseUpdate::Error {
    message: "Agent run cancelled"
}
[流结束]

取消与审批的关系

优先级:取消 > 审批恢复 > 正常循环

LoopState::Looping {
    1. if cancelled → Error + Done    ← 最高优先级
    2. if approval_responses → 恢复   ← 次优先级
    3. if round >= max_rounds → MaxRounds + Done
    4. 调用 LLM → 正常循环
}

这意味着即使用户已提交审批响应,调用方仍可以通过设置取消标志来终止执行。这对于"审批超时"或"用户在等待期间改变了主意"等场景非常有用。

取消的传播路径

调用方设置 cancel_flag.store(true)
    ↓
AgentRunOptions.cancelled
    ↓ AgentRunOptions::to_chat_client_run_options()
ChatClientRunOptions.cancelled
    ↓ ChatClientAgent.run() → IChatClient.run()
FunctionInvokingChatClient.run() → LoopState::Looping
    ↓ 每轮迭代检查
cancel_flag.load(Ordering::Relaxed) == true
    ↓
发送 Error 事件 + Done

取消的局限性

- **不是中断式的**:取消检查只在循环迭代边界发生,不会中断正在进行的 LLM HTTP 请求。如果 LLM 响应非常慢,取消可能需要等待当前请求完成。 - **不保证立即停止**:如果 Agent 在工具执行中(`join_all` 等待所有工具完成),取消信号需要等到所有工具执行完毕、进入下一轮迭代时才会生效。 - **不影响已发送的网络请求**:当前 `reqwest` 请求没有主动 abort。这是已知限制,未来可能通过 `reqwest::RequestBuilder::timeout()` 或请求取消 token 改进。

完整示例:审批 + 取消

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use tokio::time::{timeout, Duration};

async fn run_with_approval_and_cancel() {
    let session = /* ... */;
    let agent = /* 构建带 ApprovalRequiredTool 的 Agent */;

    let cancel_flag = Arc::new(AtomicBool::new(false));

    // 发送第一条消息
    let options = AgentRunOptions::new()
        .with_cancelled(cancel_flag.clone());

    let mut stream = agent.run(
        vec![ChatMessage::user("删除所有临时文件")],
        Some(session.clone()),
        Some(options),
    ).await.unwrap();

    // 消费流,检测审批请求
    let mut call_id = None;
    while let Some(chunk) = stream.next().await {
        match chunk.unwrap() {
            r if r.finish_reason == Some(FinishReason::AwaitingApproval) => break,
            r => { /* 处理中间内容 */ }
        }
    }

    // 模拟用户 30 秒超时
    let timeout_result = timeout(Duration::from_secs(30), async {
        // 实际应用中:等待用户 UI 输入
        tokio::time::sleep(Duration::from_secs(5)).await;
        ToolApprovalResponse {
            call_id: call_id.unwrap(),
            approved: true,
            reason: None,
        }
    }).await;

    match timeout_result {
        Ok(response) => {
            // 用户批准 → 恢复执行
            let resume_options = AgentRunOptions::new()
                .with_tool_approval_responses(vec![response])
                .with_cancelled(cancel_flag.clone());

            let mut stream = agent.run(
                vec![ChatMessage::user("继续")],
                Some(session.clone()),
                Some(resume_options),
            ).await.unwrap();

            while let Some(chunk) = stream.next().await {
                // 消费恢复后的结果
            }
        }
        Err(_) => {
            // 超时 → 取消
            cancel_flag.store(true, Ordering::Relaxed);
        }
    }
}

归纳

| 机制 | 核心类型 | 检查时机 | 行为 | |------|---------|---------|------| | 审批恢复 | `ToolApprovalResponse` + `AgentRunOptions.tool_approval_responses` | 循环迭代开始(审批恢复检测优先于正常 LLM 调用) | 从消息历史重建 pending tool_calls,执行批准的工具,拒绝的工具追加拒绝消息 | | 取消 | `Arc<AtomicBool>` + `AgentRunOptions.cancelled` | 循环迭代开始(最高优先级) | 发出 `Error("Agent run cancelled")` 事件,进入 Done 状态 |

两种机制共同确保了 Agent 在需要人工介入的场景下既有灵活的控制能力(审批),又有安全的终止能力(取消)。