diff --git a/rust/README.md b/rust/README.md index 18b1f2d88..89f4d2fe3 100644 --- a/rust/README.md +++ b/rust/README.md @@ -13,8 +13,8 @@ Host 的 durable 控制面已进一步下沉到 `agent-runtime-sqlite::RuntimeSe 审批决议和 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.rs` 承接 checkpoint listener/trace,执行循环和 Codex -server-request handler 仍在根编排层。 +re-export 稳定类型;`checkpoint.rs` 承接 checkpoint listener/trace,`codex.rs` 承接 Codex +server-request handler,执行循环仍在根编排层。 公开许可证、registry、自动 webhook、跨主机调度及全量 Codex schema 不属于本期完成门槛, 以权威计划「原始范围复核」为准,不采用下方历史增量中的扩大范围表述。 diff --git a/rust/crates/agent-host/src/codex.rs b/rust/crates/agent-host/src/codex.rs new file mode 100644 index 000000000..4556cebe2 --- /dev/null +++ b/rust/crates/agent-host/src/codex.rs @@ -0,0 +1,805 @@ +//! Codex server-request 到 Host 工具端口的接线。 +//! +//! 中立与版本化 handler 共用工具校验、审批和 durable tool-call 记录, +//! 不负责 Agent 执行循环或创建另一套运行状态。 + +use std::sync::Arc; + +use agent_codex::{ + CodexError, CodexServerRequest, CodexServerRequestHandler, CodexServerRequestResponse, + codex_0_152_1::{ + DynamicToolCallResponse01521, DynamicToolOutput01521, ServerRequest01521, + ServerRequestHandler01521, ServerResponse01521, + }, +}; +use agent_runtime_core::{ + ApprovalDecision, ApprovalPolicy, ApprovalRequest, ToolCall, ToolContext, ToolError, + ToolExecutor, ToolResult, +}; +use agent_runtime_engine::validate_tool_arguments; +use agent_runtime_sqlite::{NewToolCall, RuntimeService}; +use serde_json::{Value, json}; + +use super::tools::{ + default_namespace_tool_resolver, resolve_dynamic_tool_name, resolve_dynamic_tool_name_typed, +}; +use super::{AgentHost, NamespaceToolResolver, ToolRouter}; + +/// Host 对 Codex App Server server-request 的中立接线。 +/// +/// 这个 handler 只处理中立的 `item/tool/call` 请求:先把参数解码为 Core +/// `ToolCall`,再经过已有 `ApprovalPolicy`,最后交给同一个 `ToolRouter`。 +/// 输入同时兼容旧的 `name` 和已审计 Codex 0.152.1 的 `tool` 字段(两者同时 +/// 出现时必须一致)。返回值仍保持本 handler 的中立 +/// `callId`/`output`/`isError` 形状;需要 Codex 0.152.1 的 +/// `contentItems`/`success` response 时,请使用下面明确命名的 typed handler, +/// 因而这里不会被误解为完整版本适配器。`from_host` 构造的实例还会把 +/// 工具调用写入现有 Runtime 的 `tool_calls` 表,`new` 构造则保持无持久化。 +/// 未知 method、拒绝/询问和参数错误都返回 JSON-RPC error,不会因为 Codex +/// 请求来自 server 端就自动放行,也不会创建第二套 session 或 durable run。 +/// 显式 namespace 只有在 Host 注入的 `NamespaceToolResolver` 命中后才会路由。 +pub struct CodexHostServerRequestHandler { + tools: Arc, + namespace_resolver: Arc, + approval: Arc, + context: ToolContext, + /// 只有 `from_host` 注入 Runtime;`new` 保持原来的无持久化行为。 + runtime: Option, +} + +impl std::fmt::Debug for CodexHostServerRequestHandler { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CodexHostServerRequestHandler") + .field("tool_count", &self.tools.definitions().len()) + .field("session_id", &self.context.session_id()) + .field("run_id", &self.context.run_id()) + .finish_non_exhaustive() + } +} + +impl CodexHostServerRequestHandler { + /// 使用 Host 当前已经装配好的 Router/ApprovalPolicy 创建 handler。 + /// `ToolContext` 中的 run/session 身份用于审批绑定、工具执行和同一 run + /// 的 durable tool-call 记录;不会在这里创建新的 session/run。 + pub fn from_host(host: &AgentHost, context: ToolContext) -> Self { + Self { + tools: host.tools.clone(), + namespace_resolver: host.namespace_resolver.clone(), + approval: host.approval.clone(), + context, + runtime: Some(host.runtime.clone()), + } + } + + /// 允许其它 Host-like 装配层显式提供同一组 Core 端口。 + pub fn new( + tools: Arc, + approval: Arc, + context: ToolContext, + ) -> Self { + Self::new_with_namespace_resolver( + tools, + approval, + context, + default_namespace_tool_resolver(), + ) + } + + /// 显式注入 namespace resolver;resolver 只负责名称映射,不负责权限。 + pub fn new_with_namespace_resolver( + tools: Arc, + approval: Arc, + context: ToolContext, + namespace_resolver: R, + ) -> Self + where + R: NamespaceToolResolver + 'static, + { + Self { + tools, + namespace_resolver: Arc::new(namespace_resolver), + approval, + context, + runtime: None, + } + } + + pub fn context(&self) -> &ToolContext { + &self.context + } + + fn request_id(request: &CodexServerRequest) -> Result { + match request.id() { + serde_json::Value::String(value) if !value.trim().is_empty() => Ok(value.clone()), + serde_json::Value::Number(value) => Ok(value.to_string()), + value => Err(CodexError::InvalidConfig(format!( + "Codex server request id 无法绑定工具调用: {value}" + ))), + } + } + + fn parse_tool_call(&self, request: &CodexServerRequest) -> Result { + let params = request.params().as_object().ok_or_else(|| { + CodexError::InvalidConfig("item/tool/call params 必须是 JSON 对象".to_owned()) + })?; + let wire_name = tool_name_alias(params)?; + let name = resolve_dynamic_tool_name( + self.namespace_resolver.as_ref(), + params.get("namespace"), + &wire_name, + )?; + let arguments = params.get("arguments").ok_or_else(|| { + CodexError::InvalidConfig("item/tool/call params 缺少 arguments".to_owned()) + })?; + let call_id = ["callId", "toolCallId", "id"] + .iter() + .find_map(|field| params.get(*field).and_then(serde_json::Value::as_str)) + .map(ToOwned::to_owned) + .unwrap_or_else(|| Self::request_id(request).unwrap_or_default()); + + let call = if let Some(arguments) = arguments.as_str() { + ToolCall::from_json_text(call_id, name, arguments) + } else { + ToolCall::try_new(call_id, name, arguments.clone()) + }; + call.map_err(|error| CodexError::InvalidConfig(format!("item/tool/call 参数无效: {error}"))) + } + + fn error(code: i64, message: impl Into) -> CodexServerRequestResponse { + CodexServerRequestResponse::error(code, message) + } +} + +/// 解析中立 handler 和 0.152.1 typed handler 共用的工具名别名。 +/// +/// 0.152.1 dynamic-tool wire 使用 `tool`,旧的中立 fixture 使用 `name`。 +/// 两个字段若同时存在却不相同,必须在审批和路由前拒绝,避免调用方看到的 +/// 名称与实际执行的名称分裂。`namespace` 不通过分隔符拼接;需要 namespaced +/// 调用时,Host 只使用 `NamespaceToolResolver` 的显式映射。猜测分隔符会把 +/// 一个合法工具重写到另一个权限条目。带 namespace 的请求只有在 resolver +/// 显式命中后才会继续。 +fn tool_name_alias(params: &serde_json::Map) -> Result { + fn string_field<'a>( + params: &'a serde_json::Map, + field: &str, + ) -> Result, CodexError> { + match params.get(field) { + None => Ok(None), + Some(value) => value.as_str().map(Some).ok_or_else(|| { + CodexError::InvalidConfig(format!("item/tool/call params {field} 必须是字符串")) + }), + } + } + + let name = string_field(params, "name")?; + let tool = string_field(params, "tool")?; + match (name, tool) { + (Some(name), Some(tool)) if name != tool => Err(CodexError::InvalidConfig( + "item/tool/call params 的 name 与 tool 不一致".to_owned(), + )), + (Some(name), _) => Ok(name.to_owned()), + (_, Some(tool)) => Ok(tool.to_owned()), + (None, None) => Err(CodexError::InvalidConfig( + "item/tool/call params 缺少字符串 name 或 tool".to_owned(), + )), + } +} + +/// 直接从 Host 进入的 Codex server-request 也要共享 Engine 的 durable +/// tool-call 记录。没有对应的 durable run 时旁路,保留旧 `new`/fixture +/// 用法;`from_host` 的正常运行路径会在插入前校验 session/run 归属。 +enum CodexDurableToolCallAction { + Execute, + Cached(ToolResult), + InFlight, +} + +fn begin_codex_durable_tool_call( + runtime: Option<&RuntimeService>, + context: &ToolContext, + call: &ToolCall, +) -> Result { + let Some(runtime) = runtime else { + return Ok(CodexDurableToolCallAction::Execute); + }; + let (Some(session_id), Some(run_id)) = (context.session_id(), context.run_id()) else { + // Older direct handlers accepted a context containing only run_id (or + // neither ID in tests). There is no valid SQLite foreign-key identity + // to persist in that shape, so keep the compatibility path unchanged. + return Ok(CodexDurableToolCallAction::Execute); + }; + let Some(run) = runtime + .get_run(run_id) + .map_err(|error| CodexError::Protocol(format!("读取 Codex durable run 失败: {error}")))? + else { + return Ok(CodexDurableToolCallAction::Execute); + }; + if run.session_id != session_id { + return Err(CodexError::InvalidConfig(format!( + "Codex tool call 的 session_id 与 run 不匹配: {}", + call.id() + ))); + } + + let (record, existed) = match runtime + .get_tool_call(call.id()) + .map_err(|error| CodexError::Protocol(format!("读取 Codex tool call 失败: {error}")))? + { + Some(record) => (record, true), + None => ( + runtime + .create_tool_call(NewToolCall { + id: call.id().to_owned(), + session_id: session_id.to_owned(), + run_id: run_id.to_owned(), + tool_name: call.name().to_owned(), + arguments: call.arguments().clone(), + status: "requested".to_owned(), + }) + .map_err(|error| { + CodexError::Protocol(format!("创建 Codex tool call 记录失败: {error}")) + })?, + false, + ), + }; + + if record.session_id != session_id + || record.run_id != run_id + || record.tool_name != call.name() + || record.arguments != *call.arguments() + { + return Err(CodexError::InvalidConfig(format!( + "Codex tool call identity 已存在但内容不一致: {}", + call.id() + ))); + } + match record.status.as_str() { + "completed" | "error" | "failed" | "cancelled" | "canceled" => { + let output = record.result.ok_or_else(|| { + CodexError::Protocol(format!("Codex tool call 终态记录缺少结果: {}", call.id())) + })?; + let result = ToolResult::try_new(call.id(), output, record.status != "completed") + .map_err(|error| { + CodexError::Protocol(format!("Codex tool call 缓存结果无效: {error}")) + })?; + Ok(CodexDurableToolCallAction::Cached(result)) + } + _ if existed => Ok(CodexDurableToolCallAction::InFlight), + _ => Ok(CodexDurableToolCallAction::Execute), + } +} + +fn complete_codex_durable_tool_call( + runtime: Option<&RuntimeService>, + call: &ToolCall, + result: &ToolResult, +) -> Result<(), CodexError> { + let Some(runtime) = runtime else { + return Ok(()); + }; + // A compatibility handler without a durable row is intentionally a no-op; + // `begin_codex_durable_tool_call` already gated this path on a valid run. + if runtime + .get_tool_call(call.id()) + .map_err(|error| CodexError::Protocol(format!("读取 Codex tool call 记录失败: {error}")))? + .is_none() + { + return Ok(()); + } + runtime + .complete_tool_call( + call.id(), + if result.is_error() { + "error" + } else { + "completed" + }, + result.output().clone(), + ) + .map_err(|error| CodexError::Protocol(format!("收束 Codex tool call 失败: {error}")))?; + Ok(()) +} + +fn fail_codex_durable_tool_call( + runtime: Option<&RuntimeService>, + call: &ToolCall, + error: &ToolError, +) -> Result<(), CodexError> { + let Some(runtime) = runtime else { + return Ok(()); + }; + if runtime + .get_tool_call(call.id()) + .map_err(|runtime_error| { + CodexError::Protocol(format!("读取 Codex tool call 记录失败: {runtime_error}")) + })? + .is_none() + { + return Ok(()); + } + runtime + .complete_tool_call(call.id(), "error", json!({"error": error.to_string()})) + .map_err(|runtime_error| { + CodexError::Protocol(format!( + "收束 Codex tool call 错误状态失败: {runtime_error}" + )) + })?; + Ok(()) +} + +impl CodexServerRequestHandler for CodexHostServerRequestHandler { + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result { + if request.kind() != agent_codex::CodexServerRequestKind::ToolCall { + return Ok(Self::error( + -32601, + format!("Host 不支持 Codex server request: {}", request.method()), + )); + } + + if let Err(error) = self.context.validate() { + return Ok(Self::error(-32602, error.to_string())); + } + if self.context.is_cancelled() { + return Ok(Self::error( + -32800, + "Codex tool call 已取消;Host 不会触发审批或工具执行", + )); + } + + let call = match self.parse_tool_call(request) { + Ok(call) => call, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + // Server requests enter Host below the normal Engine loop, so repeat + // the same Core JSON-Schema gate before approval or any side effect. + // An approval decision must never be used to bless malformed args. + let Some(definition) = self + .tools + .definitions() + .iter() + .find(|definition| definition.name() == call.name()) + else { + return Ok(Self::error(-32602, format!("未注册工具: {}", call.name()))); + }; + if let Err(error) = validate_tool_arguments(&call, definition) { + return Ok(Self::error(-32602, error.to_string())); + } + + let approval_id = match Self::request_id(request) { + Ok(id) => id, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + let run_id = self.context.run_id().ok_or_else(|| { + CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) + })?; + let approval = ApprovalRequest::try_new(approval_id, run_id.to_owned(), call.clone()) + .map_err(|error| { + CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) + })?; + let decision = self.approval.decide(&approval).map_err(|error| { + CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")) + })?; + match decision { + ApprovalDecision::Allow => {} + ApprovalDecision::Deny { reason } => { + return Ok(Self::error( + -32001, + format!("Codex tool call 被 Host 拒绝: {reason}"), + )); + } + ApprovalDecision::Ask => { + return Ok(Self::error( + -32002, + "Codex tool call 需要外部审批;Host 不会在此 handler 中自动等待或放行", + )); + } + } + + match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { + CodexDurableToolCallAction::Cached(result) => { + return Ok(CodexServerRequestResponse::result(json!({ + "callId": result.call_id(), + "output": result.output(), + "isError": result.is_error(), + }))); + } + CodexDurableToolCallAction::InFlight => { + return Ok(Self::error( + -32000, + format!("Codex tool call 正在执行,拒绝重复 call_id: {}", call.id()), + )); + } + CodexDurableToolCallAction::Execute => {} + } + + let result = match self.tools.execute(&call, &self.context) { + Ok(result) => result, + Err(error) => { + fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; + return Err(CodexError::Protocol(format!( + "Codex tool call 执行失败: {error}" + ))); + } + }; + complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; + Ok(CodexServerRequestResponse::result(json!({ + "callId": result.call_id(), + "output": result.output(), + "isError": result.is_error(), + }))) + } +} + +/// Codex CLI 0.152.1 dynamic-tool 的 Host 侧窄 typed bridge。 +/// +/// 这个类型只消费 `agent_codex::codex_0_152_1::ServerRequest01521` 中的 +/// `item/tool/call` 变体:请求字段是审计过的 `tool`/`callId`,成功或工具自身 +/// 失败都按该版本的 `contentItems`/`success` 结果形状返回。其它 typed +/// server-request 仍返回 JSON-RPC `-32601`。它复用 Host 的 schema gate、审批 +/// 和工具路由,不创建第二套 session、run、checkpoint 或 durable approval。 +/// +/// Core `ToolResult::output` 可以是任意 JSON,而 0.152.1 dynamic-tool 的窄 +/// response 只定义 text/image/audio content item。为避免猜测业务 JSON 的媒体 +/// 语义,这个 bridge 将 output 序列化为一个 `inputText`;需要更丰富的媒体 +/// 映射时应由版本化上层 adapter 明确转换,而不是把本类型当成完整 Codex +/// generated-schema 实现。 +/// 显式 namespace 复用同一个 `NamespaceToolResolver`,未命中时保持 fail-closed。 +pub struct Codex01521HostServerRequestHandler { + tools: Arc, + namespace_resolver: Arc, + approval: Arc, + context: ToolContext, + /// 只有 `from_host` 注入 Runtime;`new` 保持原来的无持久化行为。 + runtime: Option, +} + +impl std::fmt::Debug for Codex01521HostServerRequestHandler { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Codex01521HostServerRequestHandler") + .field("tool_count", &self.tools.definitions().len()) + .field("session_id", &self.context.session_id()) + .field("run_id", &self.context.run_id()) + .finish_non_exhaustive() + } +} + +impl Codex01521HostServerRequestHandler { + /// 使用 Host 当前已经装配好的 Router/ApprovalPolicy 创建 typed bridge; + /// `from_host` 同时启用同一 run 的 durable tool-call 记录。 + pub fn from_host(host: &AgentHost, context: ToolContext) -> Self { + Self { + tools: host.tools.clone(), + namespace_resolver: host.namespace_resolver.clone(), + approval: host.approval.clone(), + context, + runtime: Some(host.runtime.clone()), + } + } + + /// 允许其它 Host-like 装配层显式提供同一组 Core 端口。 + pub fn new( + tools: Arc, + approval: Arc, + context: ToolContext, + ) -> Self { + Self::new_with_namespace_resolver( + tools, + approval, + context, + default_namespace_tool_resolver(), + ) + } + + /// 显式注入 namespace resolver;typed wire 和中立 wire 共用同一映射。 + pub fn new_with_namespace_resolver( + tools: Arc, + approval: Arc, + context: ToolContext, + namespace_resolver: R, + ) -> Self + where + R: NamespaceToolResolver + 'static, + { + Self { + tools, + namespace_resolver: Arc::new(namespace_resolver), + approval, + context, + runtime: None, + } + } + + pub fn context(&self) -> &ToolContext { + &self.context + } + + fn error(code: i64, message: impl Into) -> CodexServerRequestResponse { + CodexServerRequestResponse::error(code, message) + } + + fn dynamic_result(result: &ToolResult) -> Value { + let text = match result.output() { + Value::String(text) => text.clone(), + output => output.to_string(), + }; + json!({ + "contentItems": [{"type": "inputText", "text": text}], + "success": !result.is_error(), + }) + } + + fn dynamic_typed_result(result: &ToolResult) -> DynamicToolCallResponse01521 { + let text = match result.output() { + Value::String(text) => text.clone(), + output => output.to_string(), + }; + DynamicToolCallResponse01521 { + content_items: vec![DynamicToolOutput01521::Text(text)], + success: !result.is_error(), + } + } +} + +impl CodexServerRequestHandler for Codex01521HostServerRequestHandler { + fn handle( + &mut self, + request: &CodexServerRequest, + ) -> Result { + if request.kind() != agent_codex::CodexServerRequestKind::ToolCall { + return Ok(Self::error( + -32601, + format!( + "Codex 0.152.1 typed Host 不支持 server request: {}", + request.method() + ), + )); + } + + if let Err(error) = self.context.validate() { + return Ok(Self::error(-32602, error.to_string())); + } + if self.context.is_cancelled() { + return Ok(Self::error( + -32800, + "Codex 0.152.1 dynamic tool 已取消;typed Host 不会触发审批或工具执行", + )); + } + + // Validate optional legacy `name` before the typed decoder so an extra + // alias cannot silently disagree with the audited `tool` field. The + // versioned decoder then enforces all required 0.152.1 fields. + let raw_params = request.params().as_object().ok_or_else(|| { + CodexError::InvalidConfig("item/tool/call params 必须是 JSON 对象".to_owned()) + }); + let raw_params = match raw_params { + Ok(params) => params, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + if let Err(error) = tool_name_alias(raw_params) { + return Ok(Self::error(-32602, error.to_string())); + } + + let typed = match ServerRequest01521::decode(request) { + Ok(typed) => typed, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + let params = match typed { + ServerRequest01521::DynamicTool { params, .. } => params, + _ => { + // `kind()` is method based; retain a defensive typed match if + // the version adapter grows another mapping for this method. + return Ok(Self::error( + -32601, + format!( + "Codex 0.152.1 typed Host 不支持 server request: {}", + request.method() + ), + )); + } + }; + + let name = match resolve_dynamic_tool_name_typed( + self.namespace_resolver.as_ref(), + params.namespace.as_deref(), + ¶ms.tool, + ) { + Ok(name) => name, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + let call = ToolCall::try_new(params.call_id, name, params.arguments) + .map_err(|error| CodexError::InvalidConfig(format!("动态工具参数无效: {error}"))); + let call = match call { + Ok(call) => call, + Err(error) => return Ok(Self::error(-32602, error.to_string())), + }; + + let Some(definition) = self + .tools + .definitions() + .iter() + .find(|definition| definition.name() == call.name()) + else { + return Ok(Self::error(-32602, format!("未注册工具: {}", call.name()))); + }; + if let Err(error) = validate_tool_arguments(&call, definition) { + return Ok(Self::error(-32602, error.to_string())); + } + + let request_id = match request.id() { + Value::String(value) if !value.trim().is_empty() => value.clone(), + Value::Number(value) => value.to_string(), + value => { + return Ok(Self::error( + -32602, + format!("Codex server request id 无法绑定工具调用: {value}"), + )); + } + }; + let run_id = self.context.run_id().ok_or_else(|| { + CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) + })?; + let approval = ApprovalRequest::try_new(request_id, run_id.to_owned(), call.clone()) + .map_err(|error| { + CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) + })?; + let decision = self.approval.decide(&approval).map_err(|error| { + CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")) + })?; + match decision { + ApprovalDecision::Allow => {} + ApprovalDecision::Deny { reason } => { + return Ok(Self::error( + -32001, + format!("Codex 0.152.1 dynamic tool 被 Host 拒绝: {reason}"), + )); + } + ApprovalDecision::Ask => { + return Ok(Self::error( + -32002, + "Codex 0.152.1 dynamic tool 需要外部审批;typed Host 不会在此 handler 中自动等待或放行", + )); + } + } + + match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { + CodexDurableToolCallAction::Cached(result) => { + return Ok(CodexServerRequestResponse::result(Self::dynamic_result( + &result, + ))); + } + CodexDurableToolCallAction::InFlight => { + return Ok(Self::error( + -32000, + format!("Codex tool call 正在执行,拒绝重复 call_id: {}", call.id()), + )); + } + CodexDurableToolCallAction::Execute => {} + } + + let result = match self.tools.execute(&call, &self.context) { + Ok(result) => result, + Err(error) => { + fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; + return Err(CodexError::Protocol(format!( + "Codex tool call 执行失败: {error}" + ))); + } + }; + complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; + Ok(CodexServerRequestResponse::result(Self::dynamic_result( + &result, + ))) + } +} + +/// 让同一个 Host handler 也能直接传给 `AppServer01521` 的版本化客户端。 +/// +/// typed trait 没有 JSON-RPC error response 变体,因此拒绝、询问和执行错误以 +/// `CodexError` 返回,由版本化 client 的既有错误边界处理;中立 trait 仍保留 +/// JSON-RPC `-32001/-32002` 结果,供不绑定发行版的 client 使用。 +impl ServerRequestHandler01521 for Codex01521HostServerRequestHandler { + fn handle(&mut self, request: &ServerRequest01521) -> Result { + let ServerRequest01521::DynamicTool { id, params } = request else { + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 typed Host 不支持 server request: {}", + request.method() + ))); + }; + + self.context + .validate() + .map_err(|error| CodexError::InvalidConfig(error.to_string()))?; + if self.context.is_cancelled() { + return Err(CodexError::Interrupted); + } + + let name = resolve_dynamic_tool_name_typed( + self.namespace_resolver.as_ref(), + params.namespace.as_deref(), + ¶ms.tool, + )?; + let call = ToolCall::try_new(params.call_id.clone(), name, params.arguments.clone()) + .map_err(|error| CodexError::InvalidConfig(format!("动态工具参数无效: {error}")))?; + let Some(definition) = self + .tools + .definitions() + .iter() + .find(|definition| definition.name() == call.name()) + else { + return Err(CodexError::InvalidConfig(format!( + "未注册工具: {}", + call.name() + ))); + }; + validate_tool_arguments(&call, definition) + .map_err(|error| CodexError::InvalidConfig(error.to_string()))?; + let approval_id = match id { + Value::String(value) if !value.trim().is_empty() => value.clone(), + Value::Number(value) => value.to_string(), + value => { + return Err(CodexError::InvalidConfig(format!( + "Codex server request id 无法绑定工具调用: {value}" + ))); + } + }; + let run_id = self.context.run_id().ok_or_else(|| { + CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) + })?; + let approval = ApprovalRequest::try_new(approval_id, run_id.to_owned(), call.clone()) + .map_err(|error| { + CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) + })?; + match self + .approval + .decide(&approval) + .map_err(|error| CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")))? + { + ApprovalDecision::Allow => {} + ApprovalDecision::Deny { reason } => { + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 dynamic tool 被 Host 拒绝: {reason}" + ))); + } + ApprovalDecision::Ask => { + return Err(CodexError::Protocol( + "Codex 0.152.1 dynamic tool 需要外部审批;typed Host 不会在此 handler 中自动等待或放行" + .to_owned(), + )); + } + } + + match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { + CodexDurableToolCallAction::Cached(result) => { + return Ok(ServerResponse01521::DynamicTool( + Self::dynamic_typed_result(&result), + )); + } + CodexDurableToolCallAction::InFlight => { + return Err(CodexError::Protocol(format!( + "Codex tool call 正在执行,拒绝重复 call_id: {}", + call.id() + ))); + } + CodexDurableToolCallAction::Execute => {} + } + + let result = match self.tools.execute(&call, &self.context) { + Ok(result) => result, + Err(error) => { + fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; + return Err(CodexError::Protocol(format!( + "Codex 0.152.1 dynamic tool 执行失败: {error}" + ))); + } + }; + complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; + Ok(ServerResponse01521::DynamicTool( + Self::dynamic_typed_result(&result), + )) + } +} diff --git a/rust/crates/agent-host/src/lib.rs b/rust/crates/agent-host/src/lib.rs index 1831966b3..643ae4a35 100644 --- a/rust/crates/agent-host/src/lib.rs +++ b/rust/crates/agent-host/src/lib.rs @@ -12,13 +12,6 @@ use std::time::{Duration, Instant}; #[cfg(test)] use std::time::{SystemTime, UNIX_EPOCH}; -use agent_codex::{ - CodexError, CodexServerRequest, CodexServerRequestHandler, CodexServerRequestResponse, - codex_0_152_1::{ - DynamicToolCallResponse01521, DynamicToolOutput01521, ServerRequest01521, - ServerRequestHandler01521, ServerResponse01521, - }, -}; use agent_mcp::{McpClient, McpServerConfig}; use agent_provider_fake::FakeProvider; use agent_provider_openai::{OpenAiProvider, OpenAiProviderConfig}; @@ -33,13 +26,13 @@ use agent_runtime_core::{ use agent_runtime_engine::{ AgentEngine, AgentInput, AgentOutput, AllowList, ApprovalResume, Cancellation, ContextCompressor, EchoProvider, EngineError, EngineEvent, EventListener, - OwnedProviderContextCompressor, validate_tool_arguments, + OwnedProviderContextCompressor, }; use agent_runtime_sqlite::{ ApprovalRecord, CheckpointRecord, EventRecord, ExternalSessionRecord, - MAX_EXTERNAL_SESSION_SCAN_LIMIT, NewApproval, NewEvent, NewExternalSession, NewToolCall, - RunRecord, RuntimeService, RuntimeServiceError, SessionRecord, SqliteStore, StorageError, - ToolCallRecord, WorkerLease, + MAX_EXTERNAL_SESSION_SCAN_LIMIT, NewApproval, NewEvent, NewExternalSession, RunRecord, + RuntimeService, RuntimeServiceError, SessionRecord, SqliteStore, StorageError, ToolCallRecord, + WorkerLease, }; use agent_skills::{ActivatedSkill, SkillLoader}; use serde::{Deserialize, Serialize}; @@ -47,11 +40,13 @@ use serde_json::{Value, json}; use thiserror::Error; mod checkpoint; +mod codex; mod context; mod external; mod mcp; mod tools; use checkpoint::{DurableCheckpoints, RuntimeTraceProgress, checkpoint_input_from_record}; +pub use codex::{Codex01521HostServerRequestHandler, CodexHostServerRequestHandler}; use context::SkillActivationContextSource; pub use context::SkillContextSource; pub use external::{ @@ -62,12 +57,10 @@ pub use mcp::{ McpContextSelection, McpContextSource, McpPromptSelection, McpToolCatalog, McpToolExecutor, bind_mcp_tool, }; +use tools::default_namespace_tool_resolver; pub use tools::{ NamespaceToolResolver, NamespaceToolResolverError, StaticNamespaceToolResolver, ToolRouter, }; -use tools::{ - default_namespace_tool_resolver, resolve_dynamic_tool_name, resolve_dynamic_tool_name_typed, -}; #[derive(Debug, Error)] pub enum HostError { @@ -263,785 +256,6 @@ where } } -/// Host 对 Codex App Server server-request 的中立接线。 -/// -/// 这个 handler 只处理中立的 `item/tool/call` 请求:先把参数解码为 Core -/// `ToolCall`,再经过已有 `ApprovalPolicy`,最后交给同一个 `ToolRouter`。 -/// 输入同时兼容旧的 `name` 和已审计 Codex 0.152.1 的 `tool` 字段(两者同时 -/// 出现时必须一致)。返回值仍保持本 handler 的中立 -/// `callId`/`output`/`isError` 形状;需要 Codex 0.152.1 的 -/// `contentItems`/`success` response 时,请使用下面明确命名的 typed handler, -/// 因而这里不会被误解为完整版本适配器。`from_host` 构造的实例还会把 -/// 工具调用写入现有 Runtime 的 `tool_calls` 表,`new` 构造则保持无持久化。 -/// 未知 method、拒绝/询问和参数错误都返回 JSON-RPC error,不会因为 Codex -/// 请求来自 server 端就自动放行,也不会创建第二套 session 或 durable run。 -/// 显式 namespace 只有在 Host 注入的 `NamespaceToolResolver` 命中后才会路由。 -pub struct CodexHostServerRequestHandler { - tools: Arc, - namespace_resolver: Arc, - approval: Arc, - context: ToolContext, - /// 只有 `from_host` 注入 Runtime;`new` 保持原来的无持久化行为。 - runtime: Option, -} - -impl std::fmt::Debug for CodexHostServerRequestHandler { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("CodexHostServerRequestHandler") - .field("tool_count", &self.tools.definitions().len()) - .field("session_id", &self.context.session_id()) - .field("run_id", &self.context.run_id()) - .finish_non_exhaustive() - } -} - -impl CodexHostServerRequestHandler { - /// 使用 Host 当前已经装配好的 Router/ApprovalPolicy 创建 handler。 - /// `ToolContext` 中的 run/session 身份用于审批绑定、工具执行和同一 run - /// 的 durable tool-call 记录;不会在这里创建新的 session/run。 - pub fn from_host(host: &AgentHost, context: ToolContext) -> Self { - Self { - tools: host.tools.clone(), - namespace_resolver: host.namespace_resolver.clone(), - approval: host.approval.clone(), - context, - runtime: Some(host.runtime.clone()), - } - } - - /// 允许其它 Host-like 装配层显式提供同一组 Core 端口。 - pub fn new( - tools: Arc, - approval: Arc, - context: ToolContext, - ) -> Self { - Self::new_with_namespace_resolver( - tools, - approval, - context, - default_namespace_tool_resolver(), - ) - } - - /// 显式注入 namespace resolver;resolver 只负责名称映射,不负责权限。 - pub fn new_with_namespace_resolver( - tools: Arc, - approval: Arc, - context: ToolContext, - namespace_resolver: R, - ) -> Self - where - R: NamespaceToolResolver + 'static, - { - Self { - tools, - namespace_resolver: Arc::new(namespace_resolver), - approval, - context, - runtime: None, - } - } - - pub fn context(&self) -> &ToolContext { - &self.context - } - - fn request_id(request: &CodexServerRequest) -> Result { - match request.id() { - serde_json::Value::String(value) if !value.trim().is_empty() => Ok(value.clone()), - serde_json::Value::Number(value) => Ok(value.to_string()), - value => Err(CodexError::InvalidConfig(format!( - "Codex server request id 无法绑定工具调用: {value}" - ))), - } - } - - fn parse_tool_call(&self, request: &CodexServerRequest) -> Result { - let params = request.params().as_object().ok_or_else(|| { - CodexError::InvalidConfig("item/tool/call params 必须是 JSON 对象".to_owned()) - })?; - let wire_name = tool_name_alias(params)?; - let name = resolve_dynamic_tool_name( - self.namespace_resolver.as_ref(), - params.get("namespace"), - &wire_name, - )?; - let arguments = params.get("arguments").ok_or_else(|| { - CodexError::InvalidConfig("item/tool/call params 缺少 arguments".to_owned()) - })?; - let call_id = ["callId", "toolCallId", "id"] - .iter() - .find_map(|field| params.get(*field).and_then(serde_json::Value::as_str)) - .map(ToOwned::to_owned) - .unwrap_or_else(|| Self::request_id(request).unwrap_or_default()); - - let call = if let Some(arguments) = arguments.as_str() { - ToolCall::from_json_text(call_id, name, arguments) - } else { - ToolCall::try_new(call_id, name, arguments.clone()) - }; - call.map_err(|error| CodexError::InvalidConfig(format!("item/tool/call 参数无效: {error}"))) - } - - fn error(code: i64, message: impl Into) -> CodexServerRequestResponse { - CodexServerRequestResponse::error(code, message) - } -} - -/// 解析中立 handler 和 0.152.1 typed handler 共用的工具名别名。 -/// -/// 0.152.1 dynamic-tool wire 使用 `tool`,旧的中立 fixture 使用 `name`。 -/// 两个字段若同时存在却不相同,必须在审批和路由前拒绝,避免调用方看到的 -/// 名称与实际执行的名称分裂。`namespace` 不通过分隔符拼接;需要 namespaced -/// 调用时,Host 只使用 `NamespaceToolResolver` 的显式映射。猜测分隔符会把 -/// 一个合法工具重写到另一个权限条目。带 namespace 的请求只有在 resolver -/// 显式命中后才会继续。 -fn tool_name_alias(params: &serde_json::Map) -> Result { - fn string_field<'a>( - params: &'a serde_json::Map, - field: &str, - ) -> Result, CodexError> { - match params.get(field) { - None => Ok(None), - Some(value) => value.as_str().map(Some).ok_or_else(|| { - CodexError::InvalidConfig(format!("item/tool/call params {field} 必须是字符串")) - }), - } - } - - let name = string_field(params, "name")?; - let tool = string_field(params, "tool")?; - match (name, tool) { - (Some(name), Some(tool)) if name != tool => Err(CodexError::InvalidConfig( - "item/tool/call params 的 name 与 tool 不一致".to_owned(), - )), - (Some(name), _) => Ok(name.to_owned()), - (_, Some(tool)) => Ok(tool.to_owned()), - (None, None) => Err(CodexError::InvalidConfig( - "item/tool/call params 缺少字符串 name 或 tool".to_owned(), - )), - } -} - -/// 直接从 Host 进入的 Codex server-request 也要共享 Engine 的 durable -/// tool-call 记录。没有对应的 durable run 时旁路,保留旧 `new`/fixture -/// 用法;`from_host` 的正常运行路径会在插入前校验 session/run 归属。 -enum CodexDurableToolCallAction { - Execute, - Cached(ToolResult), - InFlight, -} - -fn begin_codex_durable_tool_call( - runtime: Option<&RuntimeService>, - context: &ToolContext, - call: &ToolCall, -) -> Result { - let Some(runtime) = runtime else { - return Ok(CodexDurableToolCallAction::Execute); - }; - let (Some(session_id), Some(run_id)) = (context.session_id(), context.run_id()) else { - // Older direct handlers accepted a context containing only run_id (or - // neither ID in tests). There is no valid SQLite foreign-key identity - // to persist in that shape, so keep the compatibility path unchanged. - return Ok(CodexDurableToolCallAction::Execute); - }; - let Some(run) = runtime - .get_run(run_id) - .map_err(|error| CodexError::Protocol(format!("读取 Codex durable run 失败: {error}")))? - else { - return Ok(CodexDurableToolCallAction::Execute); - }; - if run.session_id != session_id { - return Err(CodexError::InvalidConfig(format!( - "Codex tool call 的 session_id 与 run 不匹配: {}", - call.id() - ))); - } - - let (record, existed) = match runtime - .get_tool_call(call.id()) - .map_err(|error| CodexError::Protocol(format!("读取 Codex tool call 失败: {error}")))? - { - Some(record) => (record, true), - None => ( - runtime - .create_tool_call(NewToolCall { - id: call.id().to_owned(), - session_id: session_id.to_owned(), - run_id: run_id.to_owned(), - tool_name: call.name().to_owned(), - arguments: call.arguments().clone(), - status: "requested".to_owned(), - }) - .map_err(|error| { - CodexError::Protocol(format!("创建 Codex tool call 记录失败: {error}")) - })?, - false, - ), - }; - - if record.session_id != session_id - || record.run_id != run_id - || record.tool_name != call.name() - || record.arguments != *call.arguments() - { - return Err(CodexError::InvalidConfig(format!( - "Codex tool call identity 已存在但内容不一致: {}", - call.id() - ))); - } - match record.status.as_str() { - "completed" | "error" | "failed" | "cancelled" | "canceled" => { - let output = record.result.ok_or_else(|| { - CodexError::Protocol(format!("Codex tool call 终态记录缺少结果: {}", call.id())) - })?; - let result = ToolResult::try_new(call.id(), output, record.status != "completed") - .map_err(|error| { - CodexError::Protocol(format!("Codex tool call 缓存结果无效: {error}")) - })?; - Ok(CodexDurableToolCallAction::Cached(result)) - } - _ if existed => Ok(CodexDurableToolCallAction::InFlight), - _ => Ok(CodexDurableToolCallAction::Execute), - } -} - -fn complete_codex_durable_tool_call( - runtime: Option<&RuntimeService>, - call: &ToolCall, - result: &ToolResult, -) -> Result<(), CodexError> { - let Some(runtime) = runtime else { - return Ok(()); - }; - // A compatibility handler without a durable row is intentionally a no-op; - // `begin_codex_durable_tool_call` already gated this path on a valid run. - if runtime - .get_tool_call(call.id()) - .map_err(|error| CodexError::Protocol(format!("读取 Codex tool call 记录失败: {error}")))? - .is_none() - { - return Ok(()); - } - runtime - .complete_tool_call( - call.id(), - if result.is_error() { - "error" - } else { - "completed" - }, - result.output().clone(), - ) - .map_err(|error| CodexError::Protocol(format!("收束 Codex tool call 失败: {error}")))?; - Ok(()) -} - -fn fail_codex_durable_tool_call( - runtime: Option<&RuntimeService>, - call: &ToolCall, - error: &ToolError, -) -> Result<(), CodexError> { - let Some(runtime) = runtime else { - return Ok(()); - }; - if runtime - .get_tool_call(call.id()) - .map_err(|runtime_error| { - CodexError::Protocol(format!("读取 Codex tool call 记录失败: {runtime_error}")) - })? - .is_none() - { - return Ok(()); - } - runtime - .complete_tool_call(call.id(), "error", json!({"error": error.to_string()})) - .map_err(|runtime_error| { - CodexError::Protocol(format!( - "收束 Codex tool call 错误状态失败: {runtime_error}" - )) - })?; - Ok(()) -} - -impl CodexServerRequestHandler for CodexHostServerRequestHandler { - fn handle( - &mut self, - request: &CodexServerRequest, - ) -> Result { - if request.kind() != agent_codex::CodexServerRequestKind::ToolCall { - return Ok(Self::error( - -32601, - format!("Host 不支持 Codex server request: {}", request.method()), - )); - } - - if let Err(error) = self.context.validate() { - return Ok(Self::error(-32602, error.to_string())); - } - if self.context.is_cancelled() { - return Ok(Self::error( - -32800, - "Codex tool call 已取消;Host 不会触发审批或工具执行", - )); - } - - let call = match self.parse_tool_call(request) { - Ok(call) => call, - Err(error) => return Ok(Self::error(-32602, error.to_string())), - }; - // Server requests enter Host below the normal Engine loop, so repeat - // the same Core JSON-Schema gate before approval or any side effect. - // An approval decision must never be used to bless malformed args. - let Some(definition) = self - .tools - .definitions() - .iter() - .find(|definition| definition.name() == call.name()) - else { - return Ok(Self::error(-32602, format!("未注册工具: {}", call.name()))); - }; - if let Err(error) = validate_tool_arguments(&call, definition) { - return Ok(Self::error(-32602, error.to_string())); - } - - let approval_id = match Self::request_id(request) { - Ok(id) => id, - Err(error) => return Ok(Self::error(-32602, error.to_string())), - }; - let run_id = self.context.run_id().ok_or_else(|| { - CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) - })?; - let approval = ApprovalRequest::try_new(approval_id, run_id.to_owned(), call.clone()) - .map_err(|error| { - CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) - })?; - let decision = self.approval.decide(&approval).map_err(|error| { - CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")) - })?; - match decision { - ApprovalDecision::Allow => {} - ApprovalDecision::Deny { reason } => { - return Ok(Self::error( - -32001, - format!("Codex tool call 被 Host 拒绝: {reason}"), - )); - } - ApprovalDecision::Ask => { - return Ok(Self::error( - -32002, - "Codex tool call 需要外部审批;Host 不会在此 handler 中自动等待或放行", - )); - } - } - - match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { - CodexDurableToolCallAction::Cached(result) => { - return Ok(CodexServerRequestResponse::result(json!({ - "callId": result.call_id(), - "output": result.output(), - "isError": result.is_error(), - }))); - } - CodexDurableToolCallAction::InFlight => { - return Ok(Self::error( - -32000, - format!("Codex tool call 正在执行,拒绝重复 call_id: {}", call.id()), - )); - } - CodexDurableToolCallAction::Execute => {} - } - - let result = match self.tools.execute(&call, &self.context) { - Ok(result) => result, - Err(error) => { - fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; - return Err(CodexError::Protocol(format!( - "Codex tool call 执行失败: {error}" - ))); - } - }; - complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; - Ok(CodexServerRequestResponse::result(json!({ - "callId": result.call_id(), - "output": result.output(), - "isError": result.is_error(), - }))) - } -} - -/// Codex CLI 0.152.1 dynamic-tool 的 Host 侧窄 typed bridge。 -/// -/// 这个类型只消费 `agent_codex::codex_0_152_1::ServerRequest01521` 中的 -/// `item/tool/call` 变体:请求字段是审计过的 `tool`/`callId`,成功或工具自身 -/// 失败都按该版本的 `contentItems`/`success` 结果形状返回。其它 typed -/// server-request 仍返回 JSON-RPC `-32601`。它复用 Host 的 schema gate、审批 -/// 和工具路由,不创建第二套 session、run、checkpoint 或 durable approval。 -/// -/// Core `ToolResult::output` 可以是任意 JSON,而 0.152.1 dynamic-tool 的窄 -/// response 只定义 text/image/audio content item。为避免猜测业务 JSON 的媒体 -/// 语义,这个 bridge 将 output 序列化为一个 `inputText`;需要更丰富的媒体 -/// 映射时应由版本化上层 adapter 明确转换,而不是把本类型当成完整 Codex -/// generated-schema 实现。 -/// 显式 namespace 复用同一个 `NamespaceToolResolver`,未命中时保持 fail-closed。 -pub struct Codex01521HostServerRequestHandler { - tools: Arc, - namespace_resolver: Arc, - approval: Arc, - context: ToolContext, - /// 只有 `from_host` 注入 Runtime;`new` 保持原来的无持久化行为。 - runtime: Option, -} - -impl std::fmt::Debug for Codex01521HostServerRequestHandler { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("Codex01521HostServerRequestHandler") - .field("tool_count", &self.tools.definitions().len()) - .field("session_id", &self.context.session_id()) - .field("run_id", &self.context.run_id()) - .finish_non_exhaustive() - } -} - -impl Codex01521HostServerRequestHandler { - /// 使用 Host 当前已经装配好的 Router/ApprovalPolicy 创建 typed bridge; - /// `from_host` 同时启用同一 run 的 durable tool-call 记录。 - pub fn from_host(host: &AgentHost, context: ToolContext) -> Self { - Self { - tools: host.tools.clone(), - namespace_resolver: host.namespace_resolver.clone(), - approval: host.approval.clone(), - context, - runtime: Some(host.runtime.clone()), - } - } - - /// 允许其它 Host-like 装配层显式提供同一组 Core 端口。 - pub fn new( - tools: Arc, - approval: Arc, - context: ToolContext, - ) -> Self { - Self::new_with_namespace_resolver( - tools, - approval, - context, - default_namespace_tool_resolver(), - ) - } - - /// 显式注入 namespace resolver;typed wire 和中立 wire 共用同一映射。 - pub fn new_with_namespace_resolver( - tools: Arc, - approval: Arc, - context: ToolContext, - namespace_resolver: R, - ) -> Self - where - R: NamespaceToolResolver + 'static, - { - Self { - tools, - namespace_resolver: Arc::new(namespace_resolver), - approval, - context, - runtime: None, - } - } - - pub fn context(&self) -> &ToolContext { - &self.context - } - - fn error(code: i64, message: impl Into) -> CodexServerRequestResponse { - CodexServerRequestResponse::error(code, message) - } - - fn dynamic_result(result: &ToolResult) -> Value { - let text = match result.output() { - Value::String(text) => text.clone(), - output => output.to_string(), - }; - json!({ - "contentItems": [{"type": "inputText", "text": text}], - "success": !result.is_error(), - }) - } - - fn dynamic_typed_result(result: &ToolResult) -> DynamicToolCallResponse01521 { - let text = match result.output() { - Value::String(text) => text.clone(), - output => output.to_string(), - }; - DynamicToolCallResponse01521 { - content_items: vec![DynamicToolOutput01521::Text(text)], - success: !result.is_error(), - } - } -} - -impl CodexServerRequestHandler for Codex01521HostServerRequestHandler { - fn handle( - &mut self, - request: &CodexServerRequest, - ) -> Result { - if request.kind() != agent_codex::CodexServerRequestKind::ToolCall { - return Ok(Self::error( - -32601, - format!( - "Codex 0.152.1 typed Host 不支持 server request: {}", - request.method() - ), - )); - } - - if let Err(error) = self.context.validate() { - return Ok(Self::error(-32602, error.to_string())); - } - if self.context.is_cancelled() { - return Ok(Self::error( - -32800, - "Codex 0.152.1 dynamic tool 已取消;typed Host 不会触发审批或工具执行", - )); - } - - // Validate optional legacy `name` before the typed decoder so an extra - // alias cannot silently disagree with the audited `tool` field. The - // versioned decoder then enforces all required 0.152.1 fields. - let raw_params = request.params().as_object().ok_or_else(|| { - CodexError::InvalidConfig("item/tool/call params 必须是 JSON 对象".to_owned()) - }); - let raw_params = match raw_params { - Ok(params) => params, - Err(error) => return Ok(Self::error(-32602, error.to_string())), - }; - if let Err(error) = tool_name_alias(raw_params) { - return Ok(Self::error(-32602, error.to_string())); - } - - let typed = match ServerRequest01521::decode(request) { - Ok(typed) => typed, - Err(error) => return Ok(Self::error(-32602, error.to_string())), - }; - let params = match typed { - ServerRequest01521::DynamicTool { params, .. } => params, - _ => { - // `kind()` is method based; retain a defensive typed match if - // the version adapter grows another mapping for this method. - return Ok(Self::error( - -32601, - format!( - "Codex 0.152.1 typed Host 不支持 server request: {}", - request.method() - ), - )); - } - }; - - let name = match resolve_dynamic_tool_name_typed( - self.namespace_resolver.as_ref(), - params.namespace.as_deref(), - ¶ms.tool, - ) { - Ok(name) => name, - Err(error) => return Ok(Self::error(-32602, error.to_string())), - }; - let call = ToolCall::try_new(params.call_id, name, params.arguments) - .map_err(|error| CodexError::InvalidConfig(format!("动态工具参数无效: {error}"))); - let call = match call { - Ok(call) => call, - Err(error) => return Ok(Self::error(-32602, error.to_string())), - }; - - let Some(definition) = self - .tools - .definitions() - .iter() - .find(|definition| definition.name() == call.name()) - else { - return Ok(Self::error(-32602, format!("未注册工具: {}", call.name()))); - }; - if let Err(error) = validate_tool_arguments(&call, definition) { - return Ok(Self::error(-32602, error.to_string())); - } - - let request_id = match request.id() { - Value::String(value) if !value.trim().is_empty() => value.clone(), - Value::Number(value) => value.to_string(), - value => { - return Ok(Self::error( - -32602, - format!("Codex server request id 无法绑定工具调用: {value}"), - )); - } - }; - let run_id = self.context.run_id().ok_or_else(|| { - CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) - })?; - let approval = ApprovalRequest::try_new(request_id, run_id.to_owned(), call.clone()) - .map_err(|error| { - CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) - })?; - let decision = self.approval.decide(&approval).map_err(|error| { - CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")) - })?; - match decision { - ApprovalDecision::Allow => {} - ApprovalDecision::Deny { reason } => { - return Ok(Self::error( - -32001, - format!("Codex 0.152.1 dynamic tool 被 Host 拒绝: {reason}"), - )); - } - ApprovalDecision::Ask => { - return Ok(Self::error( - -32002, - "Codex 0.152.1 dynamic tool 需要外部审批;typed Host 不会在此 handler 中自动等待或放行", - )); - } - } - - match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { - CodexDurableToolCallAction::Cached(result) => { - return Ok(CodexServerRequestResponse::result(Self::dynamic_result( - &result, - ))); - } - CodexDurableToolCallAction::InFlight => { - return Ok(Self::error( - -32000, - format!("Codex tool call 正在执行,拒绝重复 call_id: {}", call.id()), - )); - } - CodexDurableToolCallAction::Execute => {} - } - - let result = match self.tools.execute(&call, &self.context) { - Ok(result) => result, - Err(error) => { - fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; - return Err(CodexError::Protocol(format!( - "Codex tool call 执行失败: {error}" - ))); - } - }; - complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; - Ok(CodexServerRequestResponse::result(Self::dynamic_result( - &result, - ))) - } -} - -/// 让同一个 Host handler 也能直接传给 `AppServer01521` 的版本化客户端。 -/// -/// typed trait 没有 JSON-RPC error response 变体,因此拒绝、询问和执行错误以 -/// `CodexError` 返回,由版本化 client 的既有错误边界处理;中立 trait 仍保留 -/// JSON-RPC `-32001/-32002` 结果,供不绑定发行版的 client 使用。 -impl ServerRequestHandler01521 for Codex01521HostServerRequestHandler { - fn handle(&mut self, request: &ServerRequest01521) -> Result { - let ServerRequest01521::DynamicTool { id, params } = request else { - return Err(CodexError::Protocol(format!( - "Codex 0.152.1 typed Host 不支持 server request: {}", - request.method() - ))); - }; - - self.context - .validate() - .map_err(|error| CodexError::InvalidConfig(error.to_string()))?; - if self.context.is_cancelled() { - return Err(CodexError::Interrupted); - } - - let name = resolve_dynamic_tool_name_typed( - self.namespace_resolver.as_ref(), - params.namespace.as_deref(), - ¶ms.tool, - )?; - let call = ToolCall::try_new(params.call_id.clone(), name, params.arguments.clone()) - .map_err(|error| CodexError::InvalidConfig(format!("动态工具参数无效: {error}")))?; - let Some(definition) = self - .tools - .definitions() - .iter() - .find(|definition| definition.name() == call.name()) - else { - return Err(CodexError::InvalidConfig(format!( - "未注册工具: {}", - call.name() - ))); - }; - validate_tool_arguments(&call, definition) - .map_err(|error| CodexError::InvalidConfig(error.to_string()))?; - let approval_id = match id { - Value::String(value) if !value.trim().is_empty() => value.clone(), - Value::Number(value) => value.to_string(), - value => { - return Err(CodexError::InvalidConfig(format!( - "Codex server request id 无法绑定工具调用: {value}" - ))); - } - }; - let run_id = self.context.run_id().ok_or_else(|| { - CodexError::InvalidConfig("Codex tool call 需要 ToolContext.run_id 才能审批".to_owned()) - })?; - let approval = ApprovalRequest::try_new(approval_id, run_id.to_owned(), call.clone()) - .map_err(|error| { - CodexError::InvalidConfig(format!("Codex tool call 审批绑定失败: {error}")) - })?; - match self - .approval - .decide(&approval) - .map_err(|error| CodexError::Protocol(format!("Codex tool call 审批不可用: {error}")))? - { - ApprovalDecision::Allow => {} - ApprovalDecision::Deny { reason } => { - return Err(CodexError::Protocol(format!( - "Codex 0.152.1 dynamic tool 被 Host 拒绝: {reason}" - ))); - } - ApprovalDecision::Ask => { - return Err(CodexError::Protocol( - "Codex 0.152.1 dynamic tool 需要外部审批;typed Host 不会在此 handler 中自动等待或放行" - .to_owned(), - )); - } - } - - match begin_codex_durable_tool_call(self.runtime.as_ref(), &self.context, &call)? { - CodexDurableToolCallAction::Cached(result) => { - return Ok(ServerResponse01521::DynamicTool( - Self::dynamic_typed_result(&result), - )); - } - CodexDurableToolCallAction::InFlight => { - return Err(CodexError::Protocol(format!( - "Codex tool call 正在执行,拒绝重复 call_id: {}", - call.id() - ))); - } - CodexDurableToolCallAction::Execute => {} - } - - let result = match self.tools.execute(&call, &self.context) { - Ok(result) => result, - Err(error) => { - fail_codex_durable_tool_call(self.runtime.as_ref(), &call, &error)?; - return Err(CodexError::Protocol(format!( - "Codex 0.152.1 dynamic tool 执行失败: {error}" - ))); - } - }; - complete_codex_durable_tool_call(self.runtime.as_ref(), &call, &result)?; - Ok(ServerResponse01521::DynamicTool( - Self::dynamic_typed_result(&result), - )) - } -} - /// 默认回显工具,用于 CLI 离线自检;真实宿主可以注册自己的实现。 #[derive(Clone, Debug, Default)] pub struct EchoTool; @@ -4472,6 +3686,7 @@ mod tests { use agent_codex::{ CodexProcessLifecycleEvent, CodexProcessLifecycleReason, CodexSessionLifecycle, CodexSessionLifecycleStatus, CodexSessionMetadata, CodexSessionMetadataSink, + codex_0_152_1::{ServerRequest01521, ServerRequestHandler01521}, }; use agent_mcp::{ JsonRpcRequest, JsonRpcResponse, McpClient, McpClientOptions, McpSyncTransport, diff --git a/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md b/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md index 02b1c5fde..de823e5d5 100644 --- a/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md +++ b/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md @@ -25,7 +25,9 @@ 生命周期合同保持不变。 - [x] `checkpoint.rs` 已承接 DurableCheckpoints、checkpoint listener 和 runtime trace 投影; 通用 reducer helper 仍在根模块供执行与恢复共享。 -- [ ] Engine execution 与 Codex server-request handler 仍在根编排层,后续按风险分批拆分。 +- [x] `codex.rs` 已承接两个 Codex server-request handler 与 durable tool-call helper,根路径 + 公开 re-export 保持兼容。 +- [ ] Engine execution 仍在根编排层,后续按风险分批拆分。 ### 当前范围与消息一致性验收(2026-09-06) diff --git a/rust/docs/【架构】独立Agent运行时-2026-09-01.md b/rust/docs/【架构】独立Agent运行时-2026-09-01.md index b6bfe7db2..6c0ac9096 100644 --- a/rust/docs/【架构】独立Agent运行时-2026-09-01.md +++ b/rust/docs/【架构】独立Agent运行时-2026-09-01.md @@ -52,6 +52,10 @@ heartbeat 和 Codex/External backend 生命周期仍在根执行编排中,避 server-request handler 仍留在 Host 根编排,原因是它同时绑定 ToolRouter、ApprovalPolicy 和版本化 wire。`agent-host` 根模块只导出稳定类型并保留跨模块委托,避免形成第二套公开 API。 +Codex server-request handler 和 durable tool-call helper 现在位于私有 `codex.rs`;根模块只 +re-export 两个公开 handler 类型。它们同时绑定 ToolRouter、ApprovalPolicy、Runtime 记录和 +版本化 wire,因此不下沉到 Runtime 或通用进程 adapter。 + ## 当前实现顺序 1. Core 契约和纯 reducer; diff --git a/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md b/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md index 2b6108baa..9b692cb9e 100644 --- a/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md +++ b/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md @@ -304,8 +304,9 @@ system/developer 内容固定保留,外部内容标记为不可信。先做确 workspace 双特性、两套 Clippy、Rustdoc、fmt、编码、依赖和 diff 门禁通过。 - [x] `checkpoint.rs` 已承接 DurableCheckpoints、RuntimeTraceProgress、checkpoint listener 和 runtime trace 投影;根模块保留通用 reducer helper 及执行编排,公开 API 不变。 -- [ ] `execution` 与 Codex server-request handler 仍保留在根文件,后续拆分必须继续维持 - Engine glue 不进入 Runtime 的边界。 +- [x] `codex.rs` 已承接两个 Codex server-request handler 及 durable tool-call helper;根模块只 + 显式 re-export 公开类型,版本化 wire 与 Host 权限边界保持不变。 +- [ ] `execution` 仍保留在根文件,后续拆分必须继续维持 Engine glue 不进入 Runtime 的边界。 ### 原始范围复核(2026-09-06)