拆分 Agent Host 运行职责

将 durable 取消、失败收口、审批决议和外部结果对账迁入 RuntimeService

把 Host 工具、MCP、Skill 与外部 backend 桥接拆为私有模块并保持公开 API

补充 Runtime 控制测试、依赖反向门禁和架构验收文档
This commit is contained in:
2026-09-09 19:20:54 +08:00
parent 202279c6d9
commit 69fc3d8ca0
15 changed files with 2483 additions and 2126 deletions
@@ -0,0 +1,350 @@
//! Runtime-only control operations formerly implemented by `agent-host`.
//!
//! These methods deliberately depend only on Core values and the existing
//! `RuntimeService` facade. They do not construct an Engine or call an
//! external adapter; Host remains responsible for deciding when to invoke
//! them.
use super::{
ApprovalRecord, CheckpointRecord, RunRecord, RuntimeService, RuntimeServiceError, StorageError,
};
use agent_runtime_core::{
ApprovalDecision, ContentPart, Message, MessageRole, RunStatus, RuntimeEvent, RuntimeEventKind,
SystemClock,
};
use serde_json::json;
impl RuntimeService {
/// Resolve a pending approval without starting a worker.
pub fn resolve_approval_decision(
&self,
approval_id: &str,
decision: ApprovalDecision,
) -> super::Result<ApprovalRecord> {
let (status, payload) = match decision {
ApprovalDecision::Allow => ("allowed", json!({"decision": "allow"})),
ApprovalDecision::Deny { reason } => {
if reason.trim().is_empty() {
return Err(RuntimeServiceError::InvalidInput(
"审批拒绝原因不能为空".to_owned(),
));
}
("denied", json!({"decision": "deny", "reason": reason}))
}
ApprovalDecision::Ask => {
return Err(RuntimeServiceError::InvalidInput(
"不能把 Ask 作为已决 approval 写回".to_owned(),
));
}
};
self.resolve_approval(approval_id, "pending", status, payload)
}
/// Apply the Host cancellation policy at the Runtime boundary.
///
/// A queued run is cancelled atomically before requesting cancellation;
/// an active worker receives a cooperative request, while a stale worker
/// is moved to reconciliation rather than being guessed safe.
pub fn cancel_run(&self, run_id: &str) -> super::Result<RunRecord> {
let before = self
.get_run(run_id)?
.ok_or_else(|| invalid(format!("找不到指定 run: {run_id}")))?;
if before.status == "queued"
&& let Some(cancelled) = self.finish_queued_cancelled_if_unclaimed(run_id)?
{
self.cancel_pending_approvals(run_id)?;
return Ok(cancelled);
}
let record = self.request_cancel(run_id)?;
if matches!(record.status.as_str(), "completed" | "failed" | "cancelled") {
self.cancel_pending_approvals(run_id)?;
return Ok(record);
}
let checkpoint = self.read_checkpoint(run_id)?;
let lease = self.get_run_lease(run_id)?;
let now = SystemClock.now_millis().min(i64::MAX as u64) as i64;
let lease_active = lease
.as_ref()
.is_some_and(|value| value.lease_expires_at > now);
let safe_checkpoint = checkpoint
.as_ref()
.is_some_and(|value| matches!(value.phase.as_str(), "safe" | "awaiting_approval"));
if !lease_active && safe_checkpoint {
if lease.is_some() && self.reconcile_expired_run_if_stale(run_id)?.is_none() {
self.cancel_pending_approvals(run_id)?;
return Ok(self.get_run(run_id)?.unwrap_or(record));
}
if self.get_run_lease(run_id)?.is_none() {
let cancelled = self.finish_unclaimed_cancelled_if_safe(run_id)?;
self.cancel_pending_approvals(run_id)?;
return Ok(cancelled);
}
}
if !lease_active && let Some(recovered) = self.reconcile_expired_run_if_stale(run_id)? {
self.cancel_pending_approvals(run_id)?;
return Ok(recovered);
}
self.cancel_pending_approvals(run_id)?;
Ok(record)
}
/// Atomically fail a queued/reconciling run before an Engine starts.
pub fn fail_unclaimed_run(
&self,
run_id: &str,
error: impl Into<String>,
) -> super::Result<RunRecord> {
let error = error.into();
if error.trim().is_empty() {
return Err(invalid("failed 原因不能为空"));
}
let record = self
.get_run(run_id)?
.ok_or_else(|| invalid(format!("找不到指定 run: {run_id}")))?;
if record.status == "failed" {
return Ok(record);
}
if matches!(
record.status.as_str(),
"completed" | "cancelled" | "canceled"
) {
return Err(RuntimeServiceError::Storage(StorageError::TerminalRun {
id: run_id.to_owned(),
status: record.status,
}));
}
if !matches!(record.status.as_str(), "queued" | "reconciling") {
return Err(invalid(format!(
"只有无 lease 的 queued/reconciling run 可以失败收口,当前为 {}: {run_id}",
record.status
)));
}
if record.cancel_requested || self.get_run_lease(run_id)?.is_some() {
return Err(invalid(format!(
"run 已请求取消或仍由 worker 持有 lease,不能无 lease 失败收口: {run_id}"
)));
}
let runtime_id = self
.runtime_id_for_run(run_id)?
.ok_or_else(|| invalid(format!("run 缺少 runtime 身份: {run_id}")))?;
let runtime_snapshot = self
.load_runtime_snapshot(&runtime_id)?
.ok_or_else(|| invalid(format!("找不到 runtime: {runtime_id}")))?;
let run_snapshot = runtime_snapshot
.run(run_id)
.cloned()
.ok_or_else(|| invalid(format!("runtime 中找不到 run: {run_id}")))?;
if run_snapshot.status().is_terminal() {
return Err(invalid(format!(
"runtime run 已处于终态 {:?},不能失败收口: {run_id}",
run_snapshot.status()
)));
}
let mut next_runtime = runtime_snapshot.clone();
let mut events = Vec::new();
if run_snapshot.status() == RunStatus::Pending {
let started = RuntimeEvent::status_changed(
&runtime_id,
next_runtime.revision() + 1,
SystemClock.now_millis(),
run_id,
RuntimeEventKind::RunStarted,
)
.map_err(core_error)?;
next_runtime =
agent_runtime_core::reduce(&next_runtime, &started).map_err(core_error)?;
events.push(started);
}
if let Some(run) = next_runtime.run(run_id)
&& !run.status().is_terminal()
{
let failed = RuntimeEvent::failed(
&runtime_id,
next_runtime.revision() + 1,
SystemClock.now_millis(),
run_id,
error.clone(),
)
.map_err(core_error)?;
next_runtime =
agent_runtime_core::reduce(&next_runtime, &failed).map_err(core_error)?;
events.push(failed);
}
if events.is_empty() {
return Err(invalid(format!("runtime 没有可失败收口的事件: {run_id}")));
}
self.fail_run_with_runtime(
run_id,
Some(json!({"error": error})),
&runtime_id,
Some(runtime_snapshot.revision()),
&next_runtime,
&events,
)
}
/// Record a verified Provider result and make its checkpoint safe.
pub fn reconcile_provider_result(
&self,
run_id: &str,
provider_request_id: &str,
messages: Vec<Message>,
) -> super::Result<CheckpointRecord> {
self.reconcile_external_result(run_id, "provider_in_flight", provider_request_id, messages)
}
/// Record a verified Tool result and make its checkpoint safe.
pub fn reconcile_tool_result(
&self,
run_id: &str,
tool_call_id: &str,
messages: Vec<Message>,
) -> super::Result<CheckpointRecord> {
self.reconcile_external_result(run_id, "tool_in_flight", tool_call_id, messages)
}
fn reconcile_external_result(
&self,
run_id: &str,
phase: &str,
external_id: &str,
messages: Vec<Message>,
) -> super::Result<CheckpointRecord> {
let checkpoint = self
.read_checkpoint(run_id)?
.ok_or_else(|| invalid(format!("run 没有可对账 checkpoint: {run_id}")))?;
validate_reconciliation_messages(&checkpoint, phase, external_id, &messages)?;
let encoded = serde_json::to_value(&messages)
.map_err(|error| invalid(format!("对账消息无法编码: {error}")))?;
self.record_reconciliation_result(
run_id,
phase,
external_id,
checkpoint.step,
checkpoint.attempt,
encoded,
)
}
}
fn invalid(message: impl Into<String>) -> RuntimeServiceError {
RuntimeServiceError::InvalidInput(message.into())
}
fn core_error(error: impl std::fmt::Display) -> RuntimeServiceError {
RuntimeServiceError::Core(error.to_string())
}
/// Validate the complete message history supplied by an external reconciler.
/// Storage receives only a validated JSON wire value and performs its own CAS.
fn validate_reconciliation_messages(
checkpoint: &CheckpointRecord,
phase: &str,
external_id: &str,
messages: &[Message],
) -> super::Result<()> {
if !matches!(phase, "provider_in_flight" | "tool_in_flight") {
return Err(invalid(format!("不支持的对账 checkpoint phase: {phase}")));
}
if messages.is_empty() {
return Err(invalid("对账消息不能为空"));
}
if checkpoint.phase != phase {
return Err(invalid(format!(
"checkpoint phase 不匹配:expected={phase} actual={}",
checkpoint.phase
)));
}
if checkpoint.step != checkpoint.next_step {
return Err(invalid("in-flight checkpoint 的 step/next_step 游标无效"));
}
match phase {
"provider_in_flight"
if checkpoint.provider_request_id.as_deref() != Some(external_id)
|| checkpoint.tool_call_id.is_some() =>
{
return Err(invalid("Provider request identity 与 checkpoint 不匹配"));
}
"tool_in_flight" if checkpoint.tool_call_id.as_deref() != Some(external_id) => {
return Err(invalid("tool call identity 与 checkpoint 不匹配"));
}
_ => {}
}
let checkpoint_messages = serde_json::from_value::<Vec<Message>>(checkpoint.messages.clone())
.map_err(|error| invalid(format!("checkpoint 消息无效: {error}")))?;
if messages.len() <= checkpoint_messages.len() {
return Err(invalid("对账消息必须包含完整 checkpoint 前缀和新增结果"));
}
if !checkpoint_messages
.iter()
.zip(messages)
.all(|(expected, actual)| expected == actual)
{
return Err(invalid("对账消息没有保留 checkpoint 的完整前缀"));
}
let mut calls = std::collections::BTreeSet::new();
let mut results = std::collections::BTreeSet::new();
let mut suffix_has_assistant = false;
let mut suffix_has_matching_tool_result = false;
for (message_index, message) in messages.iter().enumerate() {
let in_suffix = message_index >= checkpoint_messages.len();
if in_suffix && message.role() == MessageRole::Assistant {
suffix_has_assistant = true;
}
for part in message.content() {
match part {
ContentPart::ToolCall { id, .. } => {
if message.role() != MessageRole::Assistant {
return Err(invalid(format!(
"tool call 必须位于 assistant 消息: index={message_index}"
)));
}
if !calls.insert(id.clone()) {
return Err(invalid(format!("对账消息重复 tool call: {id}")));
}
}
ContentPart::ToolResult { tool_call_id, .. } => {
if message.role() != MessageRole::Tool {
return Err(invalid(format!(
"tool result 必须位于 tool 消息: index={message_index}"
)));
}
if !calls.contains(tool_call_id) {
return Err(invalid(format!(
"tool result 引用了尚未出现的 call: {tool_call_id}"
)));
}
if !results.insert(tool_call_id.clone()) {
return Err(invalid(format!("对账消息重复 tool result: {tool_call_id}")));
}
if in_suffix && phase == "tool_in_flight" && tool_call_id == external_id {
suffix_has_matching_tool_result = true;
}
}
ContentPart::Text { .. } | ContentPart::Image { .. } => {}
}
}
}
if calls.iter().any(|call_id| !results.contains(call_id)) {
return Err(invalid("对账消息仍包含未完成的 tool call,不能标记 safe"));
}
if phase == "provider_in_flight" && !suffix_has_assistant {
return Err(invalid("Provider 对账后缀必须包含 assistant 响应"));
}
if phase == "tool_in_flight" && !suffix_has_matching_tool_result {
return Err(invalid("工具对账后缀必须包含对应 tool result"));
}
Ok(())
}
@@ -45,6 +45,7 @@ pub use agent_runtime::{
DurableToolCallCheckpointRuntimeCommit, DurableToolCallInput, DurableToolCallRuntimeCommit,
DurableToolCallView,
};
mod control;
mod durable_sqlite;
pub use durable_sqlite::{SqliteDurableStore, SqliteDurableStoreError};
@@ -0,0 +1,154 @@
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use agent_runtime_core::{ApprovalDecision, Message, RunStatus};
use agent_runtime_sqlite::{RuntimeService, RuntimeServiceError, WorkerLease};
fn user(text: &str) -> Message {
Message::user(text).expect("valid user message")
}
fn wait_until_epoch_ms(target: i64) {
let deadline = Instant::now() + Duration::from_secs(2);
loop {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock")
.as_millis() as i64;
if now >= target {
return;
}
assert!(Instant::now() < deadline, "lease did not expire");
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn queued_cancel_is_atomic_and_runtime_visible() {
let service = RuntimeService::in_memory().expect("runtime");
let handle = service.prepare_run("queued cancel").expect("prepare");
let cancelled = service.cancel_run(&handle.run_id).expect("cancel");
assert_eq!(cancelled.status, "cancelled");
assert_eq!(
service
.get_run(&handle.run_id)
.expect("run")
.unwrap()
.status,
"cancelled"
);
let snapshot = service
.load_runtime_snapshot(&handle.runtime_id)
.expect("snapshot")
.expect("runtime exists");
assert_eq!(
snapshot.run(&handle.run_id).expect("run snapshot").status(),
RunStatus::Cancelled
);
}
#[test]
fn active_and_expired_cancel_stay_on_the_reconciliation_gate() {
let service = RuntimeService::in_memory().expect("runtime");
let active = service.prepare_run("active cancel").expect("prepare");
let active_lease = WorkerLease::new(&active.run_id);
service
.claim_run_with_lease(&active.run_id, &active_lease, Duration::from_secs(30))
.expect("claim active");
let requested = service.cancel_run(&active.run_id).expect("request cancel");
assert_eq!(requested.status, "cancel_requested");
assert_eq!(
service
.get_run(&active.run_id)
.expect("run")
.unwrap()
.status,
"cancel_requested"
);
// A short lease represents a worker that disappeared before its first
// heartbeat; cancellation must reconcile it instead of guessing safe.
let stale = service.prepare_run("stale cancel").expect("prepare");
let stale_lease = WorkerLease::new(&stale.run_id);
let (_, stale_record) = service
.claim_run_with_lease(&stale.run_id, &stale_lease, Duration::from_millis(1))
.expect("claim stale");
wait_until_epoch_ms(stale_record.lease_expires_at);
let reconciled = service.cancel_run(&stale.run_id).expect("reconcile cancel");
assert_eq!(reconciled.status, "reconciling");
let snapshot = service
.load_runtime_snapshot(&stale.runtime_id)
.expect("snapshot")
.expect("runtime exists");
assert_eq!(
snapshot.run(&stale.run_id).expect("run snapshot").status(),
RunStatus::Reconciling
);
}
#[test]
fn fail_unclaimed_run_closes_run_runtime_and_session() {
let service = RuntimeService::in_memory().expect("runtime");
let handle = service.prepare_run("setup failure").expect("prepare");
let failed = service
.fail_unclaimed_run(&handle.run_id, "provider 配置失败")
.expect("fail run");
assert_eq!(failed.status, "failed");
assert_eq!(
service
.get_session(&handle.session_id)
.expect("session")
.unwrap()
.status,
"failed"
);
let snapshot = service
.load_runtime_snapshot(&handle.runtime_id)
.expect("snapshot")
.expect("runtime exists");
assert_eq!(
snapshot.run(&handle.run_id).expect("run snapshot").status(),
RunStatus::Failed
);
}
#[test]
fn invalid_approval_and_reconciliation_leave_runtime_unchanged() {
let service = RuntimeService::in_memory().expect("runtime");
let handle = service
.prepare_run_with_messages("invalid control", vec![user("invalid control")])
.expect("prepare");
let before = service
.load_runtime_snapshot(&handle.runtime_id)
.expect("snapshot")
.expect("runtime exists");
let approval_error = service
.resolve_approval_decision("missing-approval", ApprovalDecision::Allow)
.expect_err("missing approval must fail");
assert!(!matches!(approval_error, RuntimeServiceError::Core(_)));
let reconcile_error = service
.reconcile_provider_result(&handle.run_id, "missing-provider-request", vec![user("x")])
.expect_err("missing checkpoint must fail");
assert!(matches!(
reconcile_error,
RuntimeServiceError::InvalidInput(_)
));
let after = service
.load_runtime_snapshot(&handle.runtime_id)
.expect("snapshot")
.expect("runtime exists");
assert_eq!(after.revision(), before.revision());
assert_eq!(
after.run(&handle.run_id).expect("run snapshot").status(),
before.run(&handle.run_id).expect("run snapshot").status()
);
assert!(
service
.get_approval("missing-approval")
.expect("approval lookup")
.is_none()
);
}