拆分 Agent Host 运行职责
将 durable 取消、失败收口、审批决议和外部结果对账迁入 RuntimeService 把 Host 工具、MCP、Skill 与外部 backend 桥接拆为私有模块并保持公开 API 补充 Runtime 控制测试、依赖反向门禁和架构验收文档
This commit is contained in:
@@ -8,6 +8,12 @@
|
||||
当前消息持久化按 Host 执行尝试内的已提交事件位置衔接 checkpoint 与 trace,避免正常工具
|
||||
完成、连续工具和审批恢复重复写入同一消息;压缩前先提交旧上下文的工具结果。集成回归逐条比较
|
||||
Engine 输出与 Runtime 消息,并从空快照重放事件;Fake CLI smoke 也会重新打开 SQLite 只读核验消息。
|
||||
|
||||
Host 的 durable 控制面已进一步下沉到 `agent-runtime-sqlite::RuntimeService`:取消、无主失败收口、
|
||||
审批决议和 Provider/Tool 对账由 Runtime 负责,Host 只保留薄委托;Engine 执行、checkpoint/trace、
|
||||
worker 和外部工具桥仍留在 Host。该拆分不改公开 Host API、SQLite schema 或 Cargo.lock。
|
||||
Host 内部桥接按职责位于私有 `tools.rs`、`mcp.rs`、`context.rs` 和 `external.rs`,根模块显式
|
||||
re-export 稳定类型;执行循环、checkpoint 和 Codex server-request handler 仍在根编排层。
|
||||
公开许可证、registry、自动 webhook、跨主机调度及全量 Codex schema 不属于本期完成门槛,
|
||||
以权威计划「原始范围复核」为准,不采用下方历史增量中的扩大范围表述。
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Skill context adapters for the Host.
|
||||
|
||||
use agent_runtime_core::{
|
||||
ContextError, ContextErrorKind, ContextItem, ContextRequest, ContextSource, Message,
|
||||
};
|
||||
use agent_skills::ActivatedSkill;
|
||||
|
||||
/// 已显式激活 Skill 的上下文源。Skill 正文按不可信内容注入,不能改变审批策略。
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SkillContextSource {
|
||||
active: Vec<ActivatedSkill>,
|
||||
}
|
||||
|
||||
impl SkillContextSource {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn activate(mut self, skill: ActivatedSkill) -> Self {
|
||||
self.active.push(skill);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.active.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.active.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextSource for SkillContextSource {
|
||||
fn contribute(&self, _request: &ContextRequest) -> Result<Vec<ContextItem>, ContextError> {
|
||||
self.active
|
||||
.iter()
|
||||
.map(|skill| {
|
||||
let name = skill.descriptor.name();
|
||||
let metadata =
|
||||
serde_json::to_value(skill.descriptor.metadata()).map_err(|error| {
|
||||
ContextError::new(ContextErrorKind::InvalidInput, error.to_string())
|
||||
})?;
|
||||
ContextItem::try_new(
|
||||
format!("skill:{name}"),
|
||||
// Skill 正文是上下文而非用户授权;使用普通 user 消息承载
|
||||
// 可兼容的出站形状,同时由 source_id/metadata 保留来源。
|
||||
Message::user(skill.body()).map_err(ContextError::from)?,
|
||||
10,
|
||||
false,
|
||||
)
|
||||
.map_err(ContextError::from)
|
||||
.and_then(|item| item.with_metadata(metadata).map_err(ContextError::from))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Core `SkillActivation` 的上下文桥接。
|
||||
///
|
||||
/// Core 只保存已经构造好的 `ContextItem`,Host 负责把它们挂到 Engine 的
|
||||
/// 可插拔 source 列表。这里不重新解释 Skill 正文,也不把 metadata 当成
|
||||
/// 工具权限;需要执行器的绑定由下方 API 在进入 Host 前显式拒绝。
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct SkillActivationContextSource {
|
||||
items: Vec<ContextItem>,
|
||||
}
|
||||
|
||||
impl SkillActivationContextSource {
|
||||
pub(super) fn new(items: &[ContextItem]) -> Self {
|
||||
Self {
|
||||
items: items.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextSource for SkillActivationContextSource {
|
||||
fn contribute(&self, _request: &ContextRequest) -> Result<Vec<ContextItem>, ContextError> {
|
||||
Ok(self.items.clone())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+77
-2125
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
//! MCP tool/context adapters for the Host.
|
||||
//!
|
||||
//! MCP transport details are adapted into Core's tool and context ports here;
|
||||
//! Runtime and Engine lifecycles stay outside this module.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use super::HostError;
|
||||
use agent_mcp::{McpClient, McpError, McpErrorKind, McpToolDefinition, McpToolResult};
|
||||
use agent_runtime_core::{
|
||||
ContextError, ContextItem, ContextRequest, ContextSource, ExtensionError, ExtensionErrorKind,
|
||||
Message, ToolBinding, ToolCall, ToolContext, ToolDefinition, ToolError, ToolErrorKind,
|
||||
ToolExecutor, ToolOrigin, ToolResult, ToolSource,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
/// 将一个已经握手的 MCP client 暴露为 Core 工具执行器。
|
||||
///
|
||||
/// `McpClient` 的同步 transport 由互斥锁保护;Engine 仍只看到统一的
|
||||
/// `ToolExecutor`,不会感知子进程、HTTP 或 JSON-RPC 细节。调用错误保持在
|
||||
/// 当前 tool call 内,不会伪造成功结果。
|
||||
pub struct McpToolExecutor {
|
||||
client: Arc<Mutex<McpClient>>,
|
||||
}
|
||||
|
||||
impl McpToolExecutor {
|
||||
pub fn new(client: Arc<Mutex<McpClient>>) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
pub fn client(&self) -> &Arc<Mutex<McpClient>> {
|
||||
&self.client
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutor for McpToolExecutor {
|
||||
fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
// MCP execution is also an exposed adapter port, so do not depend on
|
||||
// Engine/ToolRouter having validated serde-compatible values first.
|
||||
call.validate()?;
|
||||
context.validate()?;
|
||||
// 发送前门禁保证已取消的 Engine 不会触碰 MCP transport。正在阻塞的
|
||||
// 同步 I/O 仍由具体 MCP adapter 的硬取消能力负责;这里不强杀线程。
|
||||
if context.is_cancelled() {
|
||||
return Err(ToolError::new(
|
||||
ToolErrorKind::Cancelled,
|
||||
"MCP 工具调用已取消(发送前)",
|
||||
));
|
||||
}
|
||||
let mut client = self
|
||||
.client
|
||||
.lock()
|
||||
.map_err(|_| ToolError::new(ToolErrorKind::Failed, "MCP client 锁已损坏"))?;
|
||||
// 取消可能在等待 client 锁期间到达;再次检查,避免拿到锁后仍发送
|
||||
// 一个已经被宿主取消的 tools/call。
|
||||
if context.is_cancelled() {
|
||||
return Err(ToolError::new(
|
||||
ToolErrorKind::Cancelled,
|
||||
"MCP 工具调用已取消(发送前)",
|
||||
));
|
||||
}
|
||||
let result: McpToolResult = client
|
||||
.call_namespaced_tool(call.name(), call.arguments().clone())
|
||||
.map_err(mcp_error_as_tool_error)?;
|
||||
let McpToolResult {
|
||||
content,
|
||||
is_error,
|
||||
structured_content,
|
||||
extra,
|
||||
} = result;
|
||||
// 优先保留 MCP 的 structuredContent;只有纯 content 时才包装为稳定
|
||||
// JSON,避免把服务端返回的结构化数据丢给下一轮 Provider。
|
||||
let output = structured_content.unwrap_or_else(|| {
|
||||
json!({
|
||||
"content": content,
|
||||
"isError": is_error,
|
||||
"extra": extra,
|
||||
})
|
||||
});
|
||||
ToolResult::try_new(call.id(), output, is_error).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP tools/call 已经写入 transport 后,超时、断线、协议/编码错误和
|
||||
/// 远端 HTTP/JSON-RPC 错误都不能证明副作用没有发生。统一映射为 Unknown
|
||||
/// 可阻止 Engine 的显式 retry_on_failed 策略重放未知调用;只有本地配置、
|
||||
/// 权限和取消错误保留可区分的非副作用类别。
|
||||
pub(super) fn mcp_error_as_tool_error(error: McpError) -> ToolError {
|
||||
let kind = match error.kind() {
|
||||
McpErrorKind::PermissionDenied | McpErrorKind::PermissionRequired => {
|
||||
ToolErrorKind::PermissionDenied
|
||||
}
|
||||
McpErrorKind::Cancelled => ToolErrorKind::Cancelled,
|
||||
McpErrorKind::Configuration | McpErrorKind::Authentication => ToolErrorKind::InvalidInput,
|
||||
// `Unsupported` can be emitted after a tools/call has already been
|
||||
// written (for example when the server sends an unhandled request).
|
||||
// The call boundary is therefore unknown, not a safe local input
|
||||
// failure; do not allow an idempotent retry to replay it.
|
||||
McpErrorKind::Unsupported => ToolErrorKind::Unknown,
|
||||
McpErrorKind::Encoding
|
||||
| McpErrorKind::Connection
|
||||
| McpErrorKind::Timeout
|
||||
| McpErrorKind::Protocol
|
||||
| McpErrorKind::Remote
|
||||
| McpErrorKind::HttpStatus
|
||||
| McpErrorKind::RecoveryExhausted => ToolErrorKind::Unknown,
|
||||
};
|
||||
ToolError::new(kind, format!("MCP 工具调用失败: {error}"))
|
||||
}
|
||||
|
||||
/// 将 MCP 的工具目录项转换成 Core 的带来源绑定。
|
||||
/// 传输层仍由 `agent-mcp`/Host 负责,转换本身不授予执行权限。
|
||||
pub fn bind_mcp_tool(
|
||||
server: &str,
|
||||
definition: &McpToolDefinition,
|
||||
) -> Result<ToolBinding, ExtensionError> {
|
||||
let name = definition.namespaced_name(server);
|
||||
let description = definition
|
||||
.description
|
||||
.as_deref()
|
||||
.or(definition.title.as_deref())
|
||||
.unwrap_or("MCP tool");
|
||||
let tool = ToolDefinition::try_new(&name, description, definition.input_schema.clone())
|
||||
.map_err(|error| {
|
||||
ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string())
|
||||
})?;
|
||||
let origin = ToolOrigin::mcp(server).map_err(|error| {
|
||||
ExtensionError::new(ExtensionErrorKind::InvalidInput, error.to_string())
|
||||
})?;
|
||||
Ok(ToolBinding::new(tool, origin))
|
||||
}
|
||||
|
||||
/// 一个只读的 MCP 工具目录。真正调用时可把命名后的请求交给 MCP transport。
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct McpToolCatalog {
|
||||
server: String,
|
||||
definitions: Vec<McpToolDefinition>,
|
||||
}
|
||||
|
||||
impl McpToolCatalog {
|
||||
pub fn new(server: impl Into<String>, definitions: Vec<McpToolDefinition>) -> Self {
|
||||
Self {
|
||||
server: server.into(),
|
||||
definitions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolSource for McpToolCatalog {
|
||||
fn list_tools(&self) -> Result<Vec<ToolBinding>, ExtensionError> {
|
||||
self.definitions
|
||||
.iter()
|
||||
.map(|definition| bind_mcp_tool(&self.server, definition))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// MCP resources/prompts 的只读上下文桥接。
|
||||
///
|
||||
/// 读取动作由调用方显式触发,结果进入 Engine 时一律标记为不可信;该源
|
||||
/// 不会把资源内容变成工具,也不会在每个 step 隐式重复请求远端服务。
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct McpContextSource {
|
||||
items: Vec<ContextItem>,
|
||||
}
|
||||
|
||||
impl McpContextSource {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn from_resource(
|
||||
server: &str,
|
||||
resource: &agent_mcp::McpResourceDefinition,
|
||||
result: &agent_mcp::McpReadResourceResult,
|
||||
) -> Result<Self, HostError> {
|
||||
let mut source = Self::new();
|
||||
for (index, content) in result.contents.iter().enumerate() {
|
||||
let text = content
|
||||
.text
|
||||
.clone()
|
||||
.or_else(|| {
|
||||
content
|
||||
.blob
|
||||
.as_ref()
|
||||
.map(|blob| format!("[base64 blob] {blob}"))
|
||||
})
|
||||
.unwrap_or_else(|| serde_json::to_string(content).unwrap_or_default());
|
||||
let message =
|
||||
Message::user(text).map_err(|error| HostError::Config(error.to_string()))?;
|
||||
let metadata = json!({
|
||||
"server": server,
|
||||
"uri": &resource.uri,
|
||||
"mimeType": &content.mime_type,
|
||||
"kind": "mcp-resource"
|
||||
});
|
||||
let item = ContextItem::try_new(
|
||||
format!("mcp:{server}:resource:{}:{index}", resource.name),
|
||||
message,
|
||||
5,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| HostError::Config(error.to_string()))?
|
||||
.with_metadata(metadata)
|
||||
.map_err(|error| HostError::Config(error.to_string()))?;
|
||||
source.items.push(item);
|
||||
}
|
||||
if source.items.is_empty() {
|
||||
return Err(HostError::Config(format!(
|
||||
"MCP resource 没有可注入内容: {}",
|
||||
resource.uri
|
||||
)));
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub fn from_prompt(
|
||||
server: &str,
|
||||
prompt_name: &str,
|
||||
result: &agent_mcp::McpGetPromptResult,
|
||||
) -> Result<Self, HostError> {
|
||||
let mut source = Self::new();
|
||||
for (index, prompt) in result.messages.iter().enumerate() {
|
||||
let message = prompt_message(prompt)?;
|
||||
let metadata = json!({
|
||||
"server": server,
|
||||
"prompt": prompt_name,
|
||||
"kind": "mcp-prompt"
|
||||
});
|
||||
let item = ContextItem::try_new(
|
||||
format!("mcp:{server}:prompt:{prompt_name}:{index}"),
|
||||
message,
|
||||
5,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| HostError::Config(error.to_string()))?
|
||||
.with_metadata(metadata)
|
||||
.map_err(|error| HostError::Config(error.to_string()))?;
|
||||
source.items.push(item);
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub fn push(&mut self, item: ContextItem) {
|
||||
self.items.push(item);
|
||||
}
|
||||
|
||||
pub fn items(&self) -> &[ContextItem] {
|
||||
&self.items
|
||||
}
|
||||
|
||||
/// 返回当前来源是否没有可注入的上下文项。
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.items.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextSource for McpContextSource {
|
||||
fn contribute(&self, _request: &ContextRequest) -> Result<Vec<ContextItem>, ContextError> {
|
||||
Ok(self.items.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次 MCP 装配中明确选择的外部上下文。
|
||||
///
|
||||
/// MCP 资源和 prompt 不会因为“发现了能力”就自动进入每次运行;调用方必须
|
||||
/// 逐项加入这个选择。这样既保持资源内容的不可信边界,也避免启动 Host 时
|
||||
/// 把整个远端目录无界地读进上下文。
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct McpContextSelection {
|
||||
pub(super) resource_uris: Vec<String>,
|
||||
pub(super) prompts: Vec<McpPromptSelection>,
|
||||
}
|
||||
|
||||
/// 一个显式展开的 MCP prompt 及其字符串参数。
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct McpPromptSelection {
|
||||
name: String,
|
||||
arguments: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl McpPromptSelection {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
arguments: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_arguments<I, K, V>(mut self, arguments: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = (K, V)>,
|
||||
K: Into<String>,
|
||||
V: Into<String>,
|
||||
{
|
||||
self.arguments = arguments
|
||||
.into_iter()
|
||||
.map(|(key, value)| (key.into(), value.into()))
|
||||
.collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn arguments(&self) -> &BTreeMap<String, String> {
|
||||
&self.arguments
|
||||
}
|
||||
}
|
||||
|
||||
impl McpContextSelection {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 选择一个资源 URI;不会在此处发起网络/进程调用。
|
||||
pub fn with_resource_uri(mut self, uri: impl Into<String>) -> Self {
|
||||
self.resource_uris.push(uri.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 选择一个不带参数的 prompt。
|
||||
pub fn with_prompt(mut self, name: impl Into<String>) -> Self {
|
||||
self.prompts.push(McpPromptSelection::new(name));
|
||||
self
|
||||
}
|
||||
|
||||
/// 选择一个带字符串参数的 prompt。
|
||||
pub fn with_prompt_selection(mut self, prompt: McpPromptSelection) -> Self {
|
||||
self.prompts.push(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn resource_uris(&self) -> &[String] {
|
||||
&self.resource_uris
|
||||
}
|
||||
|
||||
pub fn prompts(&self) -> &[McpPromptSelection] {
|
||||
&self.prompts
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.resource_uris.is_empty() && self.prompts.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate_mcp_context_selection(
|
||||
selection: &McpContextSelection,
|
||||
) -> Result<(), HostError> {
|
||||
let mut resources = BTreeSet::new();
|
||||
for uri in &selection.resource_uris {
|
||||
if uri.trim().is_empty() || uri.chars().any(char::is_control) {
|
||||
return Err(HostError::Config(
|
||||
"MCP context resource URI 不能为空或包含控制字符".to_owned(),
|
||||
));
|
||||
}
|
||||
if !resources.insert(uri) {
|
||||
return Err(HostError::Config(format!(
|
||||
"MCP context resource URI 重复: {uri}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut prompts = BTreeSet::new();
|
||||
for prompt in &selection.prompts {
|
||||
if prompt.name.trim().is_empty() || prompt.name.chars().any(char::is_control) {
|
||||
return Err(HostError::Config(
|
||||
"MCP context prompt 名称不能为空或包含控制字符".to_owned(),
|
||||
));
|
||||
}
|
||||
if !prompts.insert(&prompt.name) {
|
||||
return Err(HostError::Config(format!(
|
||||
"MCP context prompt 重复: {}",
|
||||
prompt.name
|
||||
)));
|
||||
}
|
||||
if prompt
|
||||
.arguments
|
||||
.keys()
|
||||
.chain(prompt.arguments.values())
|
||||
.any(|value| value.chars().any(char::is_control))
|
||||
{
|
||||
return Err(HostError::Config(
|
||||
"MCP context prompt 参数不能包含控制字符".to_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prompt_message(prompt: &agent_mcp::McpPromptMessage) -> Result<Message, HostError> {
|
||||
let text = if prompt.content.kind == "text" {
|
||||
prompt
|
||||
.content
|
||||
.data
|
||||
.get("text")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_owned()
|
||||
} else {
|
||||
serde_json::to_string(&prompt.content)
|
||||
.map_err(|error| HostError::Config(error.to_string()))?
|
||||
};
|
||||
match prompt.role.as_str() {
|
||||
"system" => Message::system(text),
|
||||
"developer" => Message::developer(text),
|
||||
"assistant" => Message::assistant(text),
|
||||
_ => Message::user(text),
|
||||
}
|
||||
.map_err(|error| HostError::Config(error.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
//! Host 的工具注册、执行与 Codex namespace 路由。
|
||||
//!
|
||||
//! 该模块只持有 Core 工具端口,不负责 Provider、Runtime 或 worker 生命周期。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use agent_codex::CodexError;
|
||||
use agent_runtime_core::{
|
||||
ToolBinding, ToolCall, ToolContext, ToolDefinition, ToolError, ToolErrorKind, ToolExecutor,
|
||||
ToolOrigin, ToolResult,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::HostError;
|
||||
|
||||
/// 一个最小、可扩展的工具路由器。注册表只负责按名称分发,不授予权限。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ToolRouter {
|
||||
definitions: Vec<ToolDefinition>,
|
||||
executors: BTreeMap<String, Arc<dyn ToolExecutor>>,
|
||||
origins: BTreeMap<String, ToolOrigin>,
|
||||
}
|
||||
|
||||
impl ToolRouter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
&mut self,
|
||||
definition: ToolDefinition,
|
||||
executor: Arc<dyn ToolExecutor>,
|
||||
) -> Result<(), HostError> {
|
||||
// ToolRouter is a public registration boundary; a definition decoded
|
||||
// from serde must not become selectable merely because its name is
|
||||
// unique. Engine repeats the check at run time as a second boundary.
|
||||
definition
|
||||
.validate()
|
||||
.map_err(|error| HostError::Config(format!("工具定义无效: {error}")))?;
|
||||
if self.executors.contains_key(definition.name()) {
|
||||
return Err(HostError::Config(format!(
|
||||
"工具重复: {}",
|
||||
definition.name()
|
||||
)));
|
||||
}
|
||||
self.executors
|
||||
.insert(definition.name().to_owned(), executor);
|
||||
self.origins
|
||||
.insert(definition.name().to_owned(), ToolOrigin::Local);
|
||||
self.definitions.push(definition);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 直接注册一个已经带有来源信息的工具;来源用于审计,执行仍由 policy 控制。
|
||||
pub fn register_binding(
|
||||
&mut self,
|
||||
binding: ToolBinding,
|
||||
executor: Arc<dyn ToolExecutor>,
|
||||
) -> Result<(), HostError> {
|
||||
binding
|
||||
.validate()
|
||||
.map_err(|error| HostError::Config(format!("工具绑定无效: {error}")))?;
|
||||
let name = binding.tool().name().to_owned();
|
||||
let origin = binding.origin().clone();
|
||||
self.register(binding.tool().clone(), executor)?;
|
||||
self.origins.insert(name, origin);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn definitions(&self) -> &[ToolDefinition] {
|
||||
&self.definitions
|
||||
}
|
||||
|
||||
pub fn origin(&self, tool_name: &str) -> Option<&ToolOrigin> {
|
||||
self.origins.get(tool_name)
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutor for ToolRouter {
|
||||
fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
// Router is also a public Host port used by Codex server-request
|
||||
// handlers; do not rely on the normal Engine input validation path.
|
||||
call.validate()?;
|
||||
context.validate()?;
|
||||
let Some(executor) = self.executors.get(call.name()) else {
|
||||
return Err(ToolError::new(
|
||||
ToolErrorKind::NotFound,
|
||||
format!("未注册工具: {}", call.name()),
|
||||
));
|
||||
};
|
||||
executor.execute(call, context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Namespace 到 Host 工具名的显式解析错误。
|
||||
///
|
||||
/// namespace 只是一段 wire 元数据,不能靠拼接分隔符猜出实际注册名。
|
||||
/// resolver 通过这个错误把“没有声明映射”和“映射目标不存在”分开,
|
||||
/// 让调用方在进入审批/执行前就能 fail-closed。
|
||||
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||
pub enum NamespaceToolResolverError {
|
||||
#[error("namespace 不能为空")]
|
||||
EmptyNamespace,
|
||||
#[error("namespace 工具名不能为空")]
|
||||
EmptyTool,
|
||||
#[error("namespace 未注册: {0}")]
|
||||
UnknownNamespace(String),
|
||||
#[error("namespace 工具映射不存在: {namespace}/{tool}")]
|
||||
UnknownTool { namespace: String, tool: String },
|
||||
#[error("namespace 工具映射目标不能为空")]
|
||||
EmptyTarget,
|
||||
#[error("namespace 工具映射冲突: {namespace}/{tool} 已指向 {existing}, 不能改为 {requested}")]
|
||||
Conflict {
|
||||
namespace: String,
|
||||
tool: String,
|
||||
existing: String,
|
||||
requested: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// 将 wire namespace/tool 映射到 `ToolRouter` 中已经注册的全局工具名。
|
||||
///
|
||||
/// 解析器不持有工具执行器,也不授予权限;返回的目标名仍会由 Host
|
||||
/// 重新查找 definition、校验 JSON Schema,并交给 ApprovalPolicy。这样同一
|
||||
/// 个 wire tool 可以在多个 namespace 下指向不同的工具,且未知 namespace
|
||||
/// 不会因为某个全局同名工具而被意外放行。
|
||||
pub trait NamespaceToolResolver: Send + Sync {
|
||||
fn resolve_tool(
|
||||
&self,
|
||||
namespace: &str,
|
||||
tool: &str,
|
||||
) -> Result<String, NamespaceToolResolverError>;
|
||||
}
|
||||
|
||||
/// 一个无动态状态的显式 namespace 映射表,适合 Host 装配和测试。
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct StaticNamespaceToolResolver {
|
||||
mappings: BTreeMap<(String, String), String>,
|
||||
}
|
||||
|
||||
impl StaticNamespaceToolResolver {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 注册 `(namespace, wire_tool) -> registered_tool` 映射。
|
||||
///
|
||||
/// 同一映射重复注册为幂等;尝试把它改到另一个目标则拒绝,避免
|
||||
/// 装配顺序悄悄改变审批绑定。空 namespace、空工具名和空目标都无效。
|
||||
pub fn register(
|
||||
&mut self,
|
||||
namespace: impl Into<String>,
|
||||
tool: impl Into<String>,
|
||||
target: impl Into<String>,
|
||||
) -> Result<(), NamespaceToolResolverError> {
|
||||
let namespace = namespace.into();
|
||||
let tool = tool.into();
|
||||
let target = target.into();
|
||||
validate_namespace_mapping_parts(&namespace, &tool, &target)?;
|
||||
let key = (namespace.clone(), tool.clone());
|
||||
if let Some(existing) = self.mappings.get(&key) {
|
||||
if existing == &target {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(NamespaceToolResolverError::Conflict {
|
||||
namespace,
|
||||
tool,
|
||||
existing: existing.clone(),
|
||||
requested: target,
|
||||
});
|
||||
}
|
||||
self.mappings.insert(key, target);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 链式注册单条映射。
|
||||
pub fn with_mapping(
|
||||
mut self,
|
||||
namespace: impl Into<String>,
|
||||
tool: impl Into<String>,
|
||||
target: impl Into<String>,
|
||||
) -> Result<Self, NamespaceToolResolverError> {
|
||||
self.register(namespace, tool, target)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.mappings.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.mappings.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_namespace_mapping_parts(
|
||||
namespace: &str,
|
||||
tool: &str,
|
||||
target: &str,
|
||||
) -> Result<(), NamespaceToolResolverError> {
|
||||
if namespace.trim().is_empty() {
|
||||
return Err(NamespaceToolResolverError::EmptyNamespace);
|
||||
}
|
||||
if tool.trim().is_empty() {
|
||||
return Err(NamespaceToolResolverError::EmptyTool);
|
||||
}
|
||||
if target.trim().is_empty() {
|
||||
return Err(NamespaceToolResolverError::EmptyTarget);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl NamespaceToolResolver for StaticNamespaceToolResolver {
|
||||
fn resolve_tool(
|
||||
&self,
|
||||
namespace: &str,
|
||||
tool: &str,
|
||||
) -> Result<String, NamespaceToolResolverError> {
|
||||
if namespace.trim().is_empty() {
|
||||
return Err(NamespaceToolResolverError::EmptyNamespace);
|
||||
}
|
||||
if tool.trim().is_empty() {
|
||||
return Err(NamespaceToolResolverError::EmptyTool);
|
||||
}
|
||||
let namespace_key = namespace.to_owned();
|
||||
let tool_key = tool.to_owned();
|
||||
self.mappings
|
||||
.get(&(namespace_key.clone(), tool_key.clone()))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
if self
|
||||
.mappings
|
||||
.keys()
|
||||
.any(|(registered_namespace, _)| registered_namespace == namespace)
|
||||
{
|
||||
NamespaceToolResolverError::UnknownTool {
|
||||
namespace: namespace_key,
|
||||
tool: tool_key,
|
||||
}
|
||||
} else {
|
||||
NamespaceToolResolverError::UnknownNamespace(namespace_key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 允许把已经放在 `Arc` 中的 resolver 继续注入 Host/handler。
|
||||
impl<T> NamespaceToolResolver for Arc<T>
|
||||
where
|
||||
T: NamespaceToolResolver + ?Sized,
|
||||
{
|
||||
fn resolve_tool(
|
||||
&self,
|
||||
namespace: &str,
|
||||
tool: &str,
|
||||
) -> Result<String, NamespaceToolResolverError> {
|
||||
(**self).resolve_tool(namespace, tool)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn default_namespace_tool_resolver() -> Arc<dyn NamespaceToolResolver> {
|
||||
Arc::new(StaticNamespaceToolResolver::new())
|
||||
}
|
||||
|
||||
/// 从 optional JSON namespace 和 wire tool 名解析 Host 实际工具名。
|
||||
///
|
||||
/// 缺省或 JSON `null` 表示普通全局工具调用;任何非字符串 namespace 都
|
||||
/// 是格式错误;字符串 namespace 必须由显式 resolver 命中。这里不拼接、
|
||||
/// 不裁剪、也不把空字符串当作缺省值。
|
||||
pub(super) fn resolve_dynamic_tool_name(
|
||||
resolver: &dyn NamespaceToolResolver,
|
||||
namespace: Option<&Value>,
|
||||
tool: &str,
|
||||
) -> Result<String, CodexError> {
|
||||
match namespace {
|
||||
None | Some(Value::Null) => Ok(tool.to_owned()),
|
||||
Some(Value::String(namespace)) => {
|
||||
if namespace.trim().is_empty() {
|
||||
return Err(CodexError::InvalidConfig(
|
||||
"Codex dynamic tool namespace 不能为空".to_owned(),
|
||||
));
|
||||
}
|
||||
resolver.resolve_tool(namespace, tool).map_err(|error| {
|
||||
CodexError::InvalidConfig(format!("Codex dynamic tool namespace 解析失败: {error}"))
|
||||
})
|
||||
}
|
||||
Some(_) => Err(CodexError::InvalidConfig(
|
||||
"Codex dynamic tool namespace 必须是字符串或 null".to_owned(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_dynamic_tool_name_typed(
|
||||
resolver: &dyn NamespaceToolResolver,
|
||||
namespace: Option<&str>,
|
||||
tool: &str,
|
||||
) -> Result<String, CodexError> {
|
||||
namespace
|
||||
.map(|namespace| {
|
||||
if namespace.trim().is_empty() {
|
||||
return Err(CodexError::InvalidConfig(
|
||||
"Codex dynamic tool namespace 不能为空".to_owned(),
|
||||
));
|
||||
}
|
||||
resolver.resolve_tool(namespace, tool).map_err(|error| {
|
||||
CodexError::InvalidConfig(format!("Codex dynamic tool namespace 解析失败: {error}"))
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Ok(tool.to_owned()))
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,25 @@
|
||||
|
||||
## 当前交付
|
||||
|
||||
### Host 控制面拆分(2026-09-09)
|
||||
|
||||
- [x] RuntimeService 承接 cancel、无主失败收口、审批决议和 Provider/Tool 对账;Host
|
||||
保持原公开方法并只做错误映射委托。
|
||||
- [x] Runtime-only 控制测试 4/4 通过;Host 80/80 单测、workspace 双特性、Clippy 和
|
||||
依赖边界检查通过。
|
||||
- [x] 未把 `finish_cancelled`、Engine 执行、checkpoint listener、trace 投影或外部工具桥
|
||||
下沉;这些仍属于 Host 的执行编排职责。
|
||||
- [ ] 后续可按职责把 Host 私有实现拆成 execution/checkpoint/external/tools-context 模块,
|
||||
但本轮不改变公开 API 或新增平行装配层。
|
||||
|
||||
### Host 工具与上下文物理模块化(2026-09-09)
|
||||
|
||||
- [x] `tools.rs`、`mcp.rs`、`context.rs` 已从 `agent-host/src/lib.rs` 移出;根路径公开类型通过显式 re-export 保持兼容。
|
||||
- [x] 拆分后 Host 80/80、Runtime control 4/4、workspace 双特性和 Clippy 通过。
|
||||
- [x] `external.rs` 已承接 ExternalBackendToolExecutor 与 Codex session metadata sink;公开类型和
|
||||
生命周期合同保持不变。
|
||||
- [ ] Engine execution、checkpoint/trace 与 Codex/External backend 仍在根编排层,后续按风险分批拆分。
|
||||
|
||||
### 当前范围与消息一致性验收(2026-09-06)
|
||||
|
||||
- [x] 复现并修复正常完成仍重复保存 assistant/tool-call 的缺陷,删除按最终 phase/末条消息内容去重的推断。
|
||||
|
||||
@@ -25,6 +25,33 @@ phase、消息是否相等或最后一条消息判断。正常完成、串行多
|
||||
|
||||
验收必须逐条比较 Engine 消息、Runtime 消息与事件从零重放结果;仅检查最终文本或“包含一个工具消息”不足以验收。
|
||||
|
||||
## Host / Runtime 控制面拆分(2026-09-09)
|
||||
|
||||
`agent-runtime-sqlite::RuntimeService` 现在承接不依赖 Engine 或外部适配器的 durable 控制命令:
|
||||
`cancel_run`、`fail_unclaimed_run`、`resolve_approval_decision`、`reconcile_provider_result`
|
||||
和 `reconcile_tool_result`。这些入口在 Runtime 内完成状态检查、Core reducer 事件构造和 SQLite
|
||||
事务调用;Host 只保留同名的兼容/装配委托,因此 CLI 和嵌入方的调用合同不变。
|
||||
|
||||
Host 继续拥有 `run_claimed_with_lease`、Engine checkpoint listener、事件 trace 投影、
|
||||
Cancellation/heartbeat worker,以及 MCP/Skill/Codex/ExternalBackend 工具桥。这些逻辑依赖
|
||||
Engine 或具体适配器,不能下沉到 portable Runtime,也不能反向进入 Core。Runtime 控制面拆分
|
||||
不改变 SQLite schema、checkpoint 格式或公开 Host API。
|
||||
|
||||
依赖门禁额外检查:Engine 不得依赖 Runtime/Storage/Host/适配器;portable Runtime 不得依赖
|
||||
Engine、Host 或 SQLite;SQLite Runtime 只允许装配 portable Runtime 与 SQLite storage,不得
|
||||
带入 Engine 或外部适配器。Runtime 控制入口由 `agent-runtime-sqlite/tests/control.rs`
|
||||
独立回归,覆盖 queued/active/stale cancel、失败收口和无副作用错误路径。
|
||||
|
||||
Host 的适配器桥接随后按私有模块拆分:`tools.rs` 负责 ToolRouter 和 namespace resolver,
|
||||
`mcp.rs` 负责 MCP 工具/目录/资源与 prompt 上下文,`context.rs` 负责 Skill 上下文源。
|
||||
根 `lib.rs` 只通过显式 `pub use` 保持原有公开类型路径;Engine 执行、checkpoint/trace、
|
||||
heartbeat 和 Codex/External backend 生命周期仍在根执行编排中,避免为了文件拆分而改变依赖边界。
|
||||
|
||||
当前进一步把 `ExternalBackendToolExecutor` 和 `CodexRuntimeSessionMetadataSink` 移至
|
||||
`external.rs`;它们依赖 Runtime 和外部 backend 合同,但不依赖 Engine 执行循环。Codex
|
||||
server-request handler 仍留在 Host 根编排,原因是它同时绑定 ToolRouter、ApprovalPolicy
|
||||
和版本化 wire。`agent-host` 根模块只导出稳定类型并保留跨模块委托,避免形成第二套公开 API。
|
||||
|
||||
## 当前实现顺序
|
||||
|
||||
1. Core 契约和纯 reducer;
|
||||
|
||||
@@ -20,6 +20,12 @@ Host 单测另外验证 checkpoint 错 lease 时不推进投影游标、同一 t
|
||||
`runtime_states.snapshot_json` 中当前 run 的消息与 CLI 返回的 Engine 消息;Fake 两个用例通过。
|
||||
它不再仅凭 completed、最终文本或最少事件数判定持久化正确;真实 Provider 仍仅在显式 opt-in 时调用。
|
||||
|
||||
## Host / Runtime 控制面拆分回归(2026-09-09)
|
||||
|
||||
`agent-runtime-sqlite/tests/control.rs` 的 4 个 Runtime-only 测试覆盖 queued/active/stale cancel、
|
||||
无主失败收口以及审批/对账错误不写入;Host 80 个单测保持通过。Host 的 Engine 执行和外部适配器
|
||||
桥接未搬入 Runtime,避免 Runtime 反向依赖 Engine 或具体 Provider/MCP/Skill/Codex。
|
||||
|
||||
这份测试集用于先验证运行时闭环,再接入自己的真实 Provider。测试数据在
|
||||
[`../tests/agent-test-set.jsonl`](../tests/agent-test-set.jsonl),执行器是
|
||||
[`../scripts/run-agent-test-set.sh`](../scripts/run-agent-test-set.sh)。
|
||||
|
||||
@@ -287,6 +287,24 @@ system/developer 内容固定保留,外部内容标记为不可信。先做确
|
||||
|
||||
## 当前执行状态
|
||||
|
||||
### Host 控制面拆分(2026-09-09)
|
||||
|
||||
- [x] `agent-runtime-sqlite::RuntimeService` 新增 `cancel_run`、`fail_unclaimed_run`、
|
||||
`resolve_approval_decision`、`reconcile_provider_result` 和 `reconcile_tool_result`;
|
||||
新增 4 个 Runtime-only 集成回归,证明 queued/active/stale cancel、失败收口及错误不写入。
|
||||
- [x] `agent-host` 的同名入口改为薄委托,删除重复的审批/对账/失败/取消控制逻辑;保留
|
||||
`finish_cancelled`、Engine 执行、checkpoint/trace、heartbeat 和外部工具桥,因为这些
|
||||
依赖 Engine 或具体适配器。
|
||||
- [x] 依赖边界脚本新增 Engine、portable Runtime、SQLite Runtime 的反向依赖黑名单;不新增
|
||||
crate、数据库字段或 Cargo.lock 变更。
|
||||
- [x] Host 已完成第一批物理模块化:`tools.rs`、`mcp.rs`、`context.rs` 分别承接工具路由/
|
||||
namespace、MCP 桥接和 Skill 上下文;公开类型由根模块显式 re-export,未降低 API 可见性。
|
||||
- [x] `external.rs` 已承接通用 ExternalBackendToolExecutor、Codex session metadata sink 及
|
||||
生命周期 helper;根模块只保留 re-export 和装配调用。Host 80/80、Runtime control 4/4、
|
||||
workspace 双特性、两套 Clippy、Rustdoc、fmt、编码、依赖和 diff 门禁通过。
|
||||
- [ ] `execution`、`checkpoint`、`external` 仍保留在根文件,后续拆分必须继续维持 Engine glue
|
||||
不进入 Runtime 的边界。
|
||||
|
||||
### 原始范围复核(2026-09-06)
|
||||
|
||||
按用户原始附件及后续明确的 `rust/` workspace / Runtime 分层 / endpoint 配置变更执行。
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
原始范围已纠正:registry、公开许可证、全量 schema、自动 webhook 和跨主机自动调度不作为本期阻塞项。
|
||||
总计划仍须按权威计划现状表逐项验收所声明的协议行为与恢复路径;下方历史计数/“未完成”扩张描述不覆盖本节。
|
||||
|
||||
## Host / Runtime 控制面拆分(2026-09-09)
|
||||
|
||||
- `agent-runtime-sqlite::RuntimeService` 新增 `cancel_run`、`fail_unclaimed_run`、
|
||||
`resolve_approval_decision`、`reconcile_provider_result` 和 `reconcile_tool_result`;
|
||||
Runtime-only 控制测试 4/4 通过。
|
||||
- `AgentHost` 的同名入口改为薄委托,删除重复的 durable 状态判断、reducer 事件构造和对账消息校验;
|
||||
Engine 执行、checkpoint/trace、heartbeat/Cancellation worker、MCP/Skill/Codex 桥接仍留在 Host。
|
||||
- 依赖门禁新增 Engine、portable Runtime、SQLite Runtime 的反向依赖黑名单;本次未改 SQLite schema、
|
||||
Cargo.lock 或公开 Host API。定向 Host 80/80 单测、workspace 双特性、Clippy、Rustdoc、fmt、编码和
|
||||
diff 门禁通过。
|
||||
|
||||
## 结论
|
||||
|
||||
截至 2026-09-06,独立 `rust/` workspace 的当前增量闭环通过本地验收:
|
||||
|
||||
@@ -54,6 +54,16 @@ for forbidden in tokio reqwest rusqlite mcp codex agent-mcp agent-codex; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Engine 只编排 Core 中立端口,不能反向依赖持久化控制面或具体适配器。
|
||||
# 检查完整 normal 依赖树,避免通过中间 crate 间接带入这些职责。
|
||||
engine_tree="$(cargo tree --locked --manifest-path "$workspace_manifest" --edges normal -p agent-runtime-engine)"
|
||||
for forbidden in rusqlite agent-storage-sqlite agent-runtime agent-runtime-contracts agent-runtime-sqlite agent-host agent-app agent-cli agent-provider-openai agent-provider-fake agent-mcp agent-skills agent-codex; do
|
||||
if grep -Eq "(^|[[:space:]])${forbidden}([[:space:]]|$)" <<<"$engine_tree"; then
|
||||
echo "agent-runtime-engine unexpectedly depends on ${forbidden}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Durable command/view contracts must remain database and transport neutral too.
|
||||
# The portable runtime facade now shares that boundary; SQLite-specific service
|
||||
# assembly lives in the sibling agent-runtime-sqlite crate.
|
||||
@@ -70,7 +80,8 @@ done
|
||||
# prevents workspace feature unification from hiding an accidental dependency.
|
||||
portable_runtime_tree="$(cargo tree --locked --manifest-path "$workspace_manifest" \
|
||||
--no-default-features --edges normal -p agent-runtime)"
|
||||
for forbidden in rusqlite agent-storage-sqlite; do
|
||||
# Runtime 接受中立命令;Engine 执行和 Provider/MCP/Skill/Codex 装配留在 Host。
|
||||
for forbidden in rusqlite agent-storage-sqlite agent-runtime-sqlite agent-runtime-engine agent-host agent-app agent-cli agent-provider-openai agent-provider-fake agent-mcp agent-skills agent-codex; do
|
||||
if grep -Eq "(^|[[:space:]])${forbidden}([[:space:]]|$)" <<<"$portable_runtime_tree"; then
|
||||
echo "agent-runtime portable facade unexpectedly depends on ${forbidden}" >&2
|
||||
exit 1
|
||||
@@ -89,6 +100,14 @@ for required in agent-runtime agent-storage-sqlite; do
|
||||
fi
|
||||
done
|
||||
|
||||
# SQLite 层可以依赖存储实现,但不能借拆分 Host 把执行器和外部适配器搬进来。
|
||||
for forbidden in agent-runtime-engine agent-host agent-app agent-cli agent-provider-openai agent-provider-fake agent-mcp agent-skills agent-codex; do
|
||||
if grep -Eq "(^|[[:space:]])${forbidden}([[:space:]]|$)" <<<"$sqlite_runtime_tree"; then
|
||||
echo "agent-runtime-sqlite unexpectedly depends on ${forbidden}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# The generic program configuration layer must remain below Host/Runtime. It
|
||||
# may depend on concrete protocol configuration types, but it must not acquire
|
||||
# durable state, worker lifecycle or the CLI's application backend by accident.
|
||||
|
||||
Reference in New Issue
Block a user