diff --git a/rust/README.md b/rust/README.md index 2f6251d23..3ff3724b6 100644 --- a/rust/README.md +++ b/rust/README.md @@ -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 不属于本期完成门槛, 以权威计划「原始范围复核」为准,不采用下方历史增量中的扩大范围表述。 diff --git a/rust/crates/agent-host/src/context.rs b/rust/crates/agent-host/src/context.rs new file mode 100644 index 000000000..147f4cff2 --- /dev/null +++ b/rust/crates/agent-host/src/context.rs @@ -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, +} + +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, 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, +} + +impl SkillActivationContextSource { + pub(super) fn new(items: &[ContextItem]) -> Self { + Self { + items: items.to_vec(), + } + } +} + +impl ContextSource for SkillActivationContextSource { + fn contribute(&self, _request: &ContextRequest) -> Result, ContextError> { + Ok(self.items.clone()) + } +} diff --git a/rust/crates/agent-host/src/external.rs b/rust/crates/agent-host/src/external.rs new file mode 100644 index 000000000..09cc5ce15 --- /dev/null +++ b/rust/crates/agent-host/src/external.rs @@ -0,0 +1,989 @@ +//! Host 的外部 backend 与 Codex session 生命周期桥接。 +//! +//! 这些适配器依赖 RuntimeService 记录外部调用,但不把执行循环下沉到 +//! Runtime;Runtime 只负责 durable 控制面,Host 仍负责 Engine glue。 + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use agent_codex::{ + CodexError, CodexProcessLifecycleEvent, CodexProcessLifecycleReason, CodexSessionLifecycle, + CodexSessionMetadata, CodexSessionMetadataSink, +}; +use agent_runtime_core::{ + BackendRequest, ExternalBackend, ExternalError, ExternalErrorKind, ToolCall, ToolContext, + ToolError, ToolErrorKind, ToolExecutor, ToolOrigin, ToolResult, +}; +use agent_runtime_sqlite::{ + ExternalSessionRecord, NewExternalSession, RuntimeService, SqliteStore, +}; +use serde_json::{Value, json}; + +use super::{HostError, host_error_from_runtime}; + +/// 为一个 Core `ExternalBackend` 提供 Host 侧工具桥。 +/// +/// 这个桥只做三件事:把当前 `ToolCall` 转成中立的 `BackendRequest`、把 +/// 返回值转成普通 `ToolResult`,以及把外部会话身份写入已有 SQLite 表。它 +/// 不依赖 Codex;Codex CLI、App Server 或其它远端执行器都可以实现同一个 +/// Core 端口后接入。工具仍需经过 `ApprovalPolicy`,本类型不会自动放行。 +pub struct ExternalBackendToolExecutor { + backend: Arc, + backend_name: String, + operation: String, + runtime: RuntimeService, + /// request_id -> durable lifecycle context for calls that have crossed the + /// external dispatch boundary. The map is only an in-process cancel + /// index; SQLite remains the recovery/audit source of truth. + active_calls: Arc>>, +} + +#[derive(Clone, Debug)] +struct ActiveExternalCall { + record_id: String, + request_id: String, + session_id: String, + run_id: String, + cancel_requested: Arc, + cancel_result: Arc>>, +} + +impl std::fmt::Debug for ExternalBackendToolExecutor { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExternalBackendToolExecutor") + .field("backend_name", &self.backend_name) + .field("operation", &self.operation) + .finish_non_exhaustive() + } +} + +/// 把 Codex app-server 分配的 thread/turn 身份写入 Runtime 外部会话表。 +/// +/// `agent-codex` 只定义中立 sink 合同;这个 Host 实现负责把一次 run 的 +/// session/run 归属和 metadata 接到 `RuntimeService`,不会让 Codex 适配器 +/// 依赖 SQLite。thread/turn ID 是不透明远端身份,不会被拼进工具权限或 +/// Core reducer 状态;同一 thread 的 turn 更新会复用一个本地 durable row。 +#[derive(Clone, Debug)] +pub struct CodexRuntimeSessionMetadataSink { + runtime: RuntimeService, + session_id: String, + run_id: String, + backend: String, +} + +impl CodexRuntimeSessionMetadataSink { + pub fn new( + runtime: RuntimeService, + session_id: impl Into, + run_id: impl Into, + backend: impl Into, + ) -> Result { + let session_id = session_id.into(); + let run_id = run_id.into(); + let backend = backend.into(); + if session_id.trim().is_empty() { + return Err(HostError::Config( + "Codex session sink 需要 session_id".to_owned(), + )); + } + if run_id.trim().is_empty() { + return Err(HostError::Config( + "Codex session sink 需要 run_id".to_owned(), + )); + } + ToolOrigin::external(&backend) + .map_err(|error| HostError::Config(format!("Codex session backend 无效: {error}")))?; + Ok(Self { + runtime, + session_id, + run_id, + backend, + }) + } + + fn metadata_json(metadata: &CodexSessionMetadata) -> Result { + let thread_id = metadata.thread_id.as_deref(); + let turn_id = metadata.turn_id.as_deref(); + if thread_id.is_none() && turn_id.is_none() { + return Err(CodexError::InvalidConfig( + "Codex session metadata 至少需要 thread_id 或 turn_id".to_owned(), + )); + } + if thread_id.is_some_and(|value| value.trim().is_empty()) { + return Err(CodexError::InvalidConfig( + "Codex session metadata thread_id 不能为空".to_owned(), + )); + } + if turn_id.is_some_and(|value| value.trim().is_empty()) { + return Err(CodexError::InvalidConfig( + "Codex session metadata turn_id 不能为空".to_owned(), + )); + } + let mut fields = serde_json::Map::new(); + fields.insert( + "source".to_owned(), + Value::String("codex-app-server".to_owned()), + ); + if let Some(thread_id) = thread_id { + fields.insert("threadId".to_owned(), Value::String(thread_id.to_owned())); + } + if let Some(turn_id) = turn_id { + fields.insert("turnId".to_owned(), Value::String(turn_id.to_owned())); + } + Ok(Value::Object(fields)) + } + + /// Persist one merged session observation. Runtime updates replace the + /// metadata JSON as a whole, so read/merge/write here keeps audit fields + /// from an earlier observation instead of silently dropping them. + fn persist_record( + &self, + metadata: &CodexSessionMetadata, + requested_status: &str, + external_id_override: Option<&str>, + lifecycle: Option<&CodexSessionLifecycle>, + ) -> Result<(), CodexError> { + let mut metadata_json = Self::metadata_json(metadata)?; + if let Some(lifecycle) = lifecycle { + let Value::Object(fields) = &mut metadata_json else { + return Err(CodexError::Protocol( + "Codex lifecycle metadata 不是对象".to_owned(), + )); + }; + fields.insert( + "lifecycle".to_owned(), + Value::String(lifecycle.status.as_str().to_owned()), + ); + if let Some(external_id) = lifecycle.external_id.as_deref() { + fields.insert( + "externalId".to_owned(), + Value::String(external_id.to_owned()), + ); + } + if let Some(exit_code) = lifecycle.exit_code { + fields.insert("exitCode".to_owned(), Value::from(exit_code)); + } + if let Some(cancel_result) = lifecycle.cancel_result.as_deref() { + fields.insert( + "cancelResult".to_owned(), + Value::String(cancel_result.to_owned()), + ); + } + } + + let requested_external_id = external_id_override + .or(metadata.turn_id.as_deref()) + .or(metadata.thread_id.as_deref()); + let Some(requested_external_id) = requested_external_id else { + return Err(CodexError::InvalidConfig( + "Codex session metadata 至少需要 thread_id 或 turn_id".to_owned(), + )); + }; + if requested_external_id.trim().is_empty() { + return Err(CodexError::InvalidConfig( + "Codex session external_id 不能为空".to_owned(), + )); + } + + let stable_id = metadata + .thread_id + .as_deref() + .unwrap_or(requested_external_id); + let record_id = external_session_record_id(&self.backend, &format!("thread:{stable_id}")); + let existing = self + .runtime + .get_external_session(&record_id) + .map_err(|error| { + CodexError::Protocol(format!("读取 Codex session 记录失败: {error}")) + })?; + if let Some(existing) = existing { + if existing.session_id != self.session_id + || existing.run_id.as_deref() != Some(self.run_id.as_str()) + { + return Err(CodexError::Protocol(format!( + "Codex session 记录归属不匹配: {}", + existing.id + ))); + } + let mut merged_metadata = existing.metadata.clone(); + match (&mut merged_metadata, metadata_json) { + (Value::Object(current), Value::Object(update)) => { + current.extend(update); + } + (_, update) => merged_metadata = update, + } + let status = if matches!( + existing.status.as_str(), + "completed" | "failed" | "cancelled" | "canceled" + ) { + // Late thread/turn metadata must not resurrect a terminal row. + existing.status.clone() + } else { + requested_status.to_owned() + }; + let external_id = external_id_override + .or(metadata.turn_id.as_deref()) + .unwrap_or(existing.external_id.as_str()) + .to_owned(); + self.runtime + .update_external_session(&existing.id, &external_id, &status, merged_metadata) + .map_err(|error| { + CodexError::Protocol(format!("更新 Codex session 记录失败: {error}")) + })?; + } else { + self.runtime + .upsert_external_session(NewExternalSession { + id: record_id, + session_id: self.session_id.clone(), + run_id: Some(self.run_id.clone()), + backend: self.backend.clone(), + external_id: requested_external_id.to_owned(), + status: requested_status.to_owned(), + metadata: metadata_json, + }) + .map_err(|error| { + CodexError::Protocol(format!("创建 Codex session 记录失败: {error}")) + })?; + } + Ok(()) + } + + /// Store process supervisor observations in a stable auxiliary row for the + /// run. This row is separate from the thread/turn identity row because a + /// process can exit before the app-server has returned either identity. + fn persist_process_record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { + let record_id = + external_session_record_id(&self.backend, &format!("process:run:{}", self.run_id)); + let external_id = format!("process:{}", self.run_id); + let status = match event.reason { + CodexProcessLifecycleReason::NaturalExit if event.exit_code == Some(0) => "completed", + CodexProcessLifecycleReason::NaturalExit => "failed", + CodexProcessLifecycleReason::ExplicitTerminate + | CodexProcessLifecycleReason::Cancel => "cancelled", + CodexProcessLifecycleReason::ReaderEof + | CodexProcessLifecycleReason::ReaderError + | CodexProcessLifecycleReason::Timeout + | CodexProcessLifecycleReason::Drop => "unknown", + }; + let metadata = json!({ + "source": "codex-app-server", + "processLifecycle": event.reason.as_str(), + "exitCode": event.exit_code, + }); + let existing = self + .runtime + .get_external_session(&record_id) + .map_err(|error| { + CodexError::Protocol(format!("读取 Codex process 记录失败: {error}")) + })?; + if let Some(existing) = existing { + if existing.session_id != self.session_id + || existing.run_id.as_deref() != Some(self.run_id.as_str()) + { + return Err(CodexError::Protocol(format!( + "Codex process 记录归属不匹配: {}", + existing.id + ))); + } + let mut merged = existing.metadata.clone(); + match (&mut merged, metadata) { + (Value::Object(current), Value::Object(update)) => current.extend(update), + (_, update) => merged = update, + } + let status = if matches!( + existing.status.as_str(), + "completed" | "failed" | "cancelled" | "canceled" + ) { + existing.status.clone() + } else { + status.to_owned() + }; + self.runtime + .update_external_session(&existing.id, &external_id, &status, merged) + .map_err(|error| { + CodexError::Protocol(format!("更新 Codex process 记录失败: {error}")) + })?; + } else { + self.runtime + .upsert_external_session(NewExternalSession { + id: record_id, + session_id: self.session_id.clone(), + run_id: Some(self.run_id.clone()), + backend: self.backend.clone(), + external_id, + status: status.to_owned(), + metadata, + }) + .map_err(|error| { + CodexError::Protocol(format!("创建 Codex process 记录失败: {error}")) + })?; + } + Ok(()) + } +} + +impl CodexSessionMetadataSink for CodexRuntimeSessionMetadataSink { + fn persist(&self, metadata: &CodexSessionMetadata) -> Result<(), CodexError> { + self.persist_record(metadata, "active", None, None) + } + + fn persist_lifecycle(&self, lifecycle: &CodexSessionLifecycle) -> Result<(), CodexError> { + // A process request can be dispatched before typed thread/turn + // identity is known; there is no stable external-session row to + // update in that case, so keep the lifecycle observation best-effort. + if lifecycle.metadata.thread_id.is_none() && lifecycle.metadata.turn_id.is_none() { + return Ok(()); + } + self.persist_record( + &lifecycle.metadata, + lifecycle.status.as_str(), + lifecycle.external_id.as_deref(), + Some(lifecycle), + ) + } + + fn persist_process_lifecycle( + &self, + event: &CodexProcessLifecycleEvent, + ) -> Result<(), CodexError> { + self.persist_process_record(event) + } +} + +impl ExternalBackendToolExecutor { + /// 构造一个带 durable 外部会话记录的桥。 + pub fn new( + backend_name: impl Into, + operation: impl Into, + backend: Arc, + store: SqliteStore, + ) -> Result { + Self::new_with_runtime( + backend_name, + operation, + backend, + RuntimeService::from_store(store), + ) + } + + /// 使用已经装配好的 Runtime facade,避免外部会话桥再次直接依赖 + /// SQLite adapter。保留上面的 Store 构造器供旧嵌入方逐步迁移。 + pub fn new_with_runtime( + backend_name: impl Into, + operation: impl Into, + backend: Arc, + runtime: RuntimeService, + ) -> Result { + let backend_name = backend_name.into(); + let operation = operation.into(); + if backend_name.trim().is_empty() { + return Err(HostError::Config("外部 backend 名称不能为空".to_owned())); + } + ToolOrigin::external(&backend_name) + .map_err(|error| HostError::Config(format!("外部 backend 名称无效: {error}")))?; + // 在真正执行前复用 Core 的 identifier 规则校验 operation,避免 + // 第一次工具调用才暴露配置错误。 + BackendRequest::try_new("external-probe", "run-probe", &operation, json!({})) + .map_err(|error| HostError::Config(format!("外部 backend operation 无效: {error}")))?; + Ok(Self { + backend, + backend_name, + operation, + runtime, + active_calls: Arc::new(Mutex::new(BTreeMap::new())), + }) + } + + pub fn backend_name(&self) -> &str { + &self.backend_name + } + + pub fn operation(&self) -> &str { + &self.operation + } + + /// 显式取消一个仍由外部 backend 管理的请求。 + /// + /// Engine 的 `ToolExecutor` 端口没有隐式 cancel 生命周期,因此 Host + /// 只暴露这个显式动作,不在超时或 Drop 时猜测外部副作用已经停止。 + pub fn cancel(&self, request_id: &str) -> Result<(), HostError> { + if request_id.trim().is_empty() { + return Err(HostError::Config( + "外部 backend cancel request_id 不能为空".to_owned(), + )); + } + let active = self + .active_calls + .lock() + .map_err(|_| HostError::Config("外部 backend 活动表锁已损坏".to_owned()))? + .get(request_id) + .cloned(); + + let Some(active) = active else { + // active index 只存在于当前进程。先查 request-id 别名对应的 + // durable row,让重开 Host 后的显式 cancel 仍能更新已有 session, + // 而不是凭空再插一条没有 run/session 归属的记录。 + let record_id = external_session_record_id(&self.backend_name, request_id); + if let Some(record) = self + .runtime + .get_external_session(&record_id) + .map_err(host_error_from_runtime)? + { + return self.cancel_persisted_record(&record, request_id); + } + // 没有 durable row 时仍保留 backend 的幂等 cancel 行为;这类 + // 调用可能来自尚未登记生命周期的旧嵌入方,不能伪造归属信息。 + return self + .backend + .cancel(request_id) + .map_err(|error| HostError::Config(format!("外部 backend 取消失败: {error}"))); + }; + + active.cancel_requested.store(true, Ordering::Release); + self.update_active_session( + &active, + "cancel_requested", + json!({ + "requestId": active.request_id, + "operation": self.operation, + "lifecycle": "cancel_requested", + "cancelRequested": true, + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + )?; + + match self.backend.cancel(request_id) { + Ok(()) => { + if let Ok(mut result) = active.cancel_result.lock() { + *result = Some("ok"); + } + self.update_active_session( + &active, + "cancelled", + json!({ + "requestId": active.request_id, + "operation": self.operation, + "lifecycle": "cancelled", + "cancelRequested": true, + "cancelResult": "ok", + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + )?; + Ok(()) + } + Err(error) => { + if let Ok(mut result) = active.cancel_result.lock() { + *result = Some("error"); + } + // A failed cancellation cannot prove that the child stopped; + // keep the row in the conservative unknown bucket and let the + // caller perform explicit reconciliation. + let persist_error = self.update_active_session( + &active, + "unknown", + json!({ + "requestId": active.request_id, + "operation": self.operation, + "lifecycle": "cancel_failed", + "cancelRequested": true, + "cancelResult": "error", + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + ); + persist_error?; + Err(HostError::Config(format!("外部 backend 取消失败: {error}"))) + } + } + } + + /// 按 durable external-session 主键显式取消一个在其它进程登记的调用。 + /// + /// 该入口只会调用 backend 的 `cancel`,不会重新执行 `invoke`,也不会 + /// 自动把 unknown 结果标成完成。调用方应在 backend 成功提供终态证明后 + /// 继续走既有 reconciliation;取消失败会保守地保留 `unknown` 状态。 + pub fn cancel_persisted(&self, record_id: &str) -> Result<(), HostError> { + if record_id.trim().is_empty() { + return Err(HostError::Config( + "外部 backend cancel external-session id 不能为空".to_owned(), + )); + } + let record = self + .runtime + .get_external_session(record_id) + .map_err(host_error_from_runtime)? + .ok_or_else(|| HostError::Config(format!("外部会话不存在: {record_id}")))?; + self.cancel_persisted_record(&record, persisted_request_id(&record)) + } + + fn cancel_persisted_record( + &self, + record: &ExternalSessionRecord, + request_id: &str, + ) -> Result<(), HostError> { + if record.backend != self.backend_name { + return Err(HostError::Config(format!( + "外部会话 backend 不匹配: expected={} actual={}", + self.backend_name, record.backend + ))); + } + if matches!( + record.status.as_str(), + "completed" | "failed" | "cancelled" | "canceled" + ) { + // 终态取消是幂等 no-op;不再向一个已经完成的外部调用发送 + // 可能带来额外副作用的 interrupt。 + return Ok(()); + } + let cancel_reference = if request_id.trim().is_empty() { + record.external_id.as_str() + } else { + request_id + }; + let mut requested_metadata = record.metadata.clone(); + merge_external_lifecycle_metadata( + &mut requested_metadata, + cancel_reference, + &self.operation, + "cancel_requested", + Some("pending"), + ); + self.runtime + .update_external_session( + &record.id, + &record.external_id, + "cancel_requested", + requested_metadata, + ) + .map_err(host_error_from_runtime)?; + + match self.backend.cancel(cancel_reference) { + Ok(()) => { + let mut metadata = record.metadata.clone(); + merge_external_lifecycle_metadata( + &mut metadata, + cancel_reference, + &self.operation, + "cancelled", + Some("ok"), + ); + self.runtime + .update_external_session(&record.id, &record.external_id, "cancelled", metadata) + .map_err(host_error_from_runtime)?; + Ok(()) + } + Err(error) => { + let mut metadata = record.metadata.clone(); + merge_external_lifecycle_metadata( + &mut metadata, + cancel_reference, + &self.operation, + "cancel_failed", + Some("error"), + ); + self.runtime + .update_external_session(&record.id, &record.external_id, "unknown", metadata) + .map_err(host_error_from_runtime)?; + Err(HostError::Config(format!("外部 backend 取消失败: {error}"))) + } + } + } + + fn update_active_session( + &self, + active: &ActiveExternalCall, + status: &str, + metadata: Value, + ) -> Result { + self.runtime + .update_external_session(&active.record_id, &active.request_id, status, metadata) + .map_err(host_error_from_runtime) + } + + fn persist_session( + &self, + context: &ToolContext, + external_id: &str, + status: &str, + metadata: serde_json::Value, + ) -> Result { + let session_id = context.session_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.session_id", + ) + })?; + let run_id = context.run_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.run_id", + ) + })?; + self.runtime + .upsert_external_session(NewExternalSession { + id: external_session_record_id(&self.backend_name, external_id), + session_id: session_id.to_owned(), + run_id: Some(run_id.to_owned()), + backend: self.backend_name.clone(), + external_id: external_id.to_owned(), + status: status.to_owned(), + metadata, + }) + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string())) + } + + /// 在外部 dispatch 前登记一个可恢复的 lifecycle row。 + /// + /// request id 是唯一已知的稳定关联键;如果后端稍后返回真正的 + /// external id,完成路径会额外写入该 id,并把这个 request-id 别名一并 + /// 收束,避免重启扫描时留下假 `running` 会话。 + fn begin_active_call( + &self, + context: &ToolContext, + request: &BackendRequest, + ) -> Result { + let session_id = context.session_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.session_id", + ) + })?; + let run_id = context.run_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.run_id", + ) + })?; + let record_id = external_session_record_id(&self.backend_name, request.request_id()); + let active = ActiveExternalCall { + record_id, + request_id: request.request_id().to_owned(), + session_id: session_id.to_owned(), + run_id: run_id.to_owned(), + cancel_requested: Arc::new(AtomicBool::new(false)), + cancel_result: Arc::new(Mutex::new(None)), + }; + + // 先检查本地 active index,再写 durable row;重复 request_id 不能 + // 让一次 cancel 不确定地作用于两个 child。 + { + let mut active_calls = self.active_calls.lock().map_err(|_| { + ToolError::new(ToolErrorKind::Failed, "外部 backend 活动表锁已损坏") + })?; + if active_calls.contains_key(request.request_id()) { + return Err(ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend request_id 已在执行", + )); + } + active_calls.insert(request.request_id().to_owned(), active.clone()); + } + + let persisted = self.persist_session( + context, + request.request_id(), + "running", + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "running", + "dispatchStarted": true, + "externalIdKnown": false, + "sideEffectUnknown": true, + }), + ); + if let Err(error) = persisted { + if let Ok(mut active_calls) = self.active_calls.lock() { + active_calls.remove(request.request_id()); + } + return Err(error); + } + Ok(active) + } + + fn finish_active_call( + &self, + active: &ActiveExternalCall, + external_id: &str, + status: &str, + mut metadata: Value, + ) -> Result<(), ToolError> { + if let Some(object) = metadata.as_object_mut() { + object.insert( + "cancelRequested".to_owned(), + Value::Bool(active.cancel_requested.load(Ordering::Acquire)), + ); + if let Ok(result) = active.cancel_result.lock() + && let Some(result) = *result + { + object.insert("cancelResult".to_owned(), Value::String(result.to_owned())); + } + } + + // 先收束 dispatch 前登记的 request-id 别名;这一步即使后端返回了 + // 另一个 external id 也不会留下旧的 running 状态。 + self.runtime + .update_external_session( + &active.record_id, + active.request_id.as_str(), + status, + metadata.clone(), + ) + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; + + if external_id != active.request_id { + // 保留现有按真实 external_id 查询的兼容路径;这条记录与上面的 + // request-id 别名共享同一 lifecycle metadata,不是第二次调用。 + self.runtime + .upsert_external_session(NewExternalSession { + id: external_session_record_id(&self.backend_name, external_id), + session_id: active.session_id.clone(), + run_id: Some(active.run_id.clone()), + backend: self.backend_name.clone(), + external_id: external_id.to_owned(), + status: status.to_owned(), + metadata, + }) + .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; + } + Ok(()) + } + + fn remove_active_call( + &self, + request_id: &str, + ) -> Result, ToolError> { + self.active_calls + .lock() + .map_err(|_| ToolError::new(ToolErrorKind::Failed, "外部 backend 活动表锁已损坏")) + .map(|mut calls| calls.remove(request_id)) + } +} + +impl ToolExecutor for ExternalBackendToolExecutor { + fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result { + // This executor is public and can be called outside AgentEngine; keep + // the same Core input boundary before registering or invoking an + // external side effect. + call.validate()?; + context.validate()?; + let run_id = context.run_id().ok_or_else(|| { + ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.run_id", + ) + })?; + // 在触发外部副作用前就检查两个 durable 身份;不能等调用返回后 + // 才发现没有 session,留下无法归属的 opaque 操作。 + if context.session_id().is_none() { + return Err(ToolError::new( + ToolErrorKind::InvalidInput, + "外部 backend 工具需要 ToolContext.session_id", + )); + } + // Tool call ID 是 Engine 的稳定幂等边界;同一请求重试时不生成第二 + // 个随机 ID,便于外部 backend 自己做去重或由 Host 对账。 + let request = + BackendRequest::try_new(call.id(), run_id, &self.operation, call.arguments().clone()) + .and_then(|request| { + request.with_metadata(json!({ + "toolName": call.name(), + "sessionId": context.session_id(), + "runId": run_id, + })) + }) + .map_err(|error| ToolError::new(ToolErrorKind::InvalidInput, error.to_string()))?; + + let active = self.begin_active_call(context, &request)?; + let result = match self.backend.invoke(&request) { + Ok(result) => result, + Err(error) => { + let _ = self.remove_active_call(request.request_id())?; + let unknown = matches!( + error.kind(), + ExternalErrorKind::UnknownSideEffect | ExternalErrorKind::Timeout + ); + let status = if unknown { "unknown" } else { "failed" }; + self.finish_active_call( + &active, + request.request_id(), + status, + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "invoke_error", + "externalIdKnown": false, + "sideEffectUnknown": unknown, + "errorKind": external_error_kind_name(error.kind()), + }), + )?; + return Err(external_error_as_tool_error(error)); + } + }; + let _ = self.remove_active_call(request.request_id())?; + if let Err(error) = result.validate() { + // A custom backend can still return a compatibility value; after + // dispatch, any malformed envelope is an unknown side effect and + // must remain behind reconciliation. + self.finish_active_call( + &active, + request.request_id(), + "unknown", + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "response_invalid", + "externalIdKnown": false, + "sideEffectUnknown": true, + "errorKind": "unknown-side-effect", + }), + )?; + return Err(ToolError::new( + ToolErrorKind::Unknown, + format!("外部 backend 返回非法结果: {error}"), + )); + } + if result.request_id() != request.request_id() { + // 返回身份错配意味着不能证明外部调用是否完成;先落一条 + // unknown 会话,再把错误交给 Engine 的 tool-in-flight gate。 + self.finish_active_call( + &active, + request.request_id(), + "unknown", + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": "response_identity_mismatch", + "externalIdKnown": false, + "sideEffectUnknown": true, + "errorKind": "unknown-side-effect", + }), + )?; + return Err(ToolError::new( + ToolErrorKind::Unknown, + format!( + "外部 backend 返回 request id 不匹配: expected={} actual={}", + request.request_id(), + result.request_id() + ), + )); + } + + let side_effect_unknown = result.side_effect_unknown(); + let external_id = result + .external_id() + .unwrap_or_else(|| request.request_id()) + .to_owned(); + let status = if side_effect_unknown { + "unknown" + } else { + "completed" + }; + self.finish_active_call( + &active, + &external_id, + status, + json!({ + "requestId": request.request_id(), + "operation": request.operation(), + "lifecycle": status, + "externalIdKnown": result.external_id().is_some(), + "sideEffectUnknown": side_effect_unknown, + }), + )?; + + let tool = ToolResult::try_new(call.id(), result.output().clone(), side_effect_unknown) + .and_then(|tool| { + tool.with_metadata(json!({ + "external": true, + "backend": self.backend_name, + "operation": self.operation, + "externalId": external_id, + "sideEffectUnknown": side_effect_unknown, + })) + }) + .map_err(ToolError::from)?; + if side_effect_unknown { + // unknown 结果不能写成 safe checkpoint;返回错误让 Engine/Host + // 保留 tool-in-flight,对账后才能继续,避免模型自动重试副作用。 + return Err(ToolError::new( + ToolErrorKind::Unknown, + format!("外部 backend 结果副作用未知: {external_id}"), + )); + } + Ok(tool) + } +} + +/// 外部会话表的稳定本地主键;backend/external_id 仍由 SQLite 唯一约束兜底。 +pub fn external_session_record_id(backend: &str, external_id: &str) -> String { + format!("external-session:{backend}:{external_id}") +} + +fn persisted_request_id(record: &ExternalSessionRecord) -> &str { + record + .metadata + .get("requestId") + .and_then(Value::as_str) + .filter(|request_id| !request_id.trim().is_empty()) + .unwrap_or(record.external_id.as_str()) +} + +/// 在不丢弃适配器已有非敏感字段的前提下更新外部生命周期元数据。 +/// 手工登记的 scalar metadata 也会被包在 `previousMetadata` 中,避免 +/// 为了写 cancel 状态而静默覆盖调用方的审计信息。 +fn merge_external_lifecycle_metadata( + metadata: &mut Value, + request_id: &str, + operation: &str, + lifecycle: &str, + cancel_result: Option<&str>, +) { + let previous = std::mem::replace(metadata, Value::Null); + let mut object = match previous { + Value::Object(object) => object, + other => { + let mut object = serde_json::Map::new(); + object.insert("previousMetadata".to_owned(), other); + object + } + }; + object.insert("requestId".to_owned(), Value::String(request_id.to_owned())); + object.insert("operation".to_owned(), Value::String(operation.to_owned())); + object.insert("lifecycle".to_owned(), Value::String(lifecycle.to_owned())); + object.insert("cancelRequested".to_owned(), Value::Bool(true)); + object.insert("sideEffectUnknown".to_owned(), Value::Bool(true)); + if let Some(cancel_result) = cancel_result { + object.insert( + "cancelResult".to_owned(), + Value::String(cancel_result.to_owned()), + ); + } + *metadata = Value::Object(object); +} + +fn external_error_kind_name(kind: ExternalErrorKind) -> &'static str { + match kind { + ExternalErrorKind::InvalidInput => "invalid-input", + ExternalErrorKind::Unavailable => "unavailable", + ExternalErrorKind::Timeout => "timeout", + ExternalErrorKind::UnknownSideEffect => "unknown-side-effect", + } +} + +fn external_error_as_tool_error(error: ExternalError) -> ToolError { + let kind = match error.kind() { + ExternalErrorKind::InvalidInput => ToolErrorKind::InvalidInput, + ExternalErrorKind::Unavailable => ToolErrorKind::Failed, + // A synchronous external timeout cannot tell us whether the request + // reached the remote side. Treat it as unknown rather than allowing + // an idempotent retry policy to issue the same side effect again. + ExternalErrorKind::Timeout => ToolErrorKind::Unknown, + ExternalErrorKind::UnknownSideEffect => ToolErrorKind::Unknown, + }; + ToolError::new(kind, format!("外部 backend 调用失败: {error}")) +} diff --git a/rust/crates/agent-host/src/lib.rs b/rust/crates/agent-host/src/lib.rs index d8b73ef2f..df97da551 100644 --- a/rust/crates/agent-host/src/lib.rs +++ b/rust/crates/agent-host/src/lib.rs @@ -3,7 +3,7 @@ //! Host 负责把可替换的 Engine 端口组合起来,并把一次运行的观察事件写入 //! SQLite。它不把业务规则塞进 Core,也不要求调用方了解数据库表结构。 -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; use std::io::Write; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -13,28 +13,22 @@ use std::time::{Duration, Instant}; use std::time::{SystemTime, UNIX_EPOCH}; use agent_codex::{ - CodexError, CodexProcessLifecycleEvent, CodexProcessLifecycleReason, CodexServerRequest, - CodexServerRequestHandler, CodexServerRequestResponse, CodexSessionLifecycle, - CodexSessionMetadata, CodexSessionMetadataSink, + CodexError, CodexServerRequest, CodexServerRequestHandler, CodexServerRequestResponse, codex_0_152_1::{ DynamicToolCallResponse01521, DynamicToolOutput01521, ServerRequest01521, ServerRequestHandler01521, ServerResponse01521, }, }; -use agent_mcp::{ - McpClient, McpError, McpErrorKind, McpServerConfig, McpToolDefinition, McpToolResult, -}; +use agent_mcp::{McpClient, McpServerConfig}; use agent_provider_fake::FakeProvider; use agent_provider_openai::{OpenAiProvider, OpenAiProviderConfig}; use agent_runtime_core::{ - ApprovalDecision, ApprovalPolicy, ApprovalRequest, BackendRequest, ContentPart, ContextError, - ContextErrorKind, ContextItem, ContextRequest, ContextSource, ExtensionError, - ExtensionErrorKind, ExternalBackend, ExternalError, ExternalErrorKind, ExternalObservation, - ExternalObservationRequest, ExternalObservationSource, Message, MessageRole, ModelProvider, - PromptBuilder, ProviderDescriptor, ProviderRegistry, ProviderRegistryError, ProviderTarget, - RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, SkillActivation, SkillSource, SystemClock, - ToolBinding, ToolCall, ToolContext, ToolDefinition, ToolError, ToolErrorKind, ToolExecutor, - ToolOrigin, ToolResult, ToolSource, reduce, + ApprovalDecision, ApprovalPolicy, ApprovalRequest, ContentPart, ContextSource, ExternalBackend, + ExternalError, ExternalObservation, ExternalObservationRequest, ExternalObservationSource, + Message, MessageRole, ModelProvider, PromptBuilder, ProviderDescriptor, ProviderRegistry, + ProviderRegistryError, ProviderTarget, RuntimeEvent, RuntimeEventKind, RuntimeSnapshot, + SkillActivation, SkillSource, SystemClock, ToolBinding, ToolCall, ToolContext, ToolDefinition, + ToolError, ToolExecutor, ToolOrigin, ToolResult, reduce, }; use agent_runtime_engine::{ AgentEngine, AgentInput, AgentOutput, AllowList, ApprovalResume, Cancellation, @@ -52,6 +46,27 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; +mod context; +mod external; +mod mcp; +mod tools; +use context::SkillActivationContextSource; +pub use context::SkillContextSource; +pub use external::{ + CodexRuntimeSessionMetadataSink, ExternalBackendToolExecutor, external_session_record_id, +}; +use mcp::validate_mcp_context_selection; +pub use mcp::{ + McpContextSelection, McpContextSource, McpPromptSelection, McpToolCatalog, McpToolExecutor, + bind_mcp_tool, +}; +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 { #[error("运行引擎错误: {0}")] @@ -607,302 +622,6 @@ impl DurableCheckpoints<'_> { } } -/// 一个最小、可扩展的工具路由器。注册表只负责按名称分发,不授予权限。 -#[derive(Clone, Default)] -pub struct ToolRouter { - definitions: Vec, - executors: BTreeMap>, - origins: BTreeMap, -} - -impl ToolRouter { - pub fn new() -> Self { - Self::default() - } - - pub fn register( - &mut self, - definition: ToolDefinition, - executor: Arc, - ) -> 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, - ) -> 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 { - // 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; -} - -/// 一个无动态状态的显式 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, - tool: impl Into, - target: impl Into, - ) -> 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, - tool: impl Into, - target: impl Into, - ) -> Result { - 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 { - 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 NamespaceToolResolver for Arc -where - T: NamespaceToolResolver + ?Sized, -{ - fn resolve_tool( - &self, - namespace: &str, - tool: &str, - ) -> Result { - (**self).resolve_tool(namespace, tool) - } -} - -fn default_namespace_tool_resolver() -> Arc { - Arc::new(StaticNamespaceToolResolver::new()) -} - -/// 从 optional JSON namespace 和 wire tool 名解析 Host 实际工具名。 -/// -/// 缺省或 JSON `null` 表示普通全局工具调用;任何非字符串 namespace 都 -/// 是格式错误;字符串 namespace 必须由显式 resolver 命中。这里不拼接、 -/// 不裁剪、也不把空字符串当作缺省值。 -fn resolve_dynamic_tool_name( - resolver: &dyn NamespaceToolResolver, - namespace: Option<&Value>, - tool: &str, -) -> Result { - 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(), - )), - } -} - -fn resolve_dynamic_tool_name_typed( - resolver: &dyn NamespaceToolResolver, - namespace: Option<&str>, - tool: &str, -) -> Result { - 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())) -} - /// Host 对 Codex App Server server-request 的中立接线。 /// /// 这个 handler 只处理中立的 `item/tool/call` 请求:先把参数解码为 Core @@ -1692,1441 +1411,6 @@ impl ToolExecutor for EchoTool { } } -/// 为一个 Core `ExternalBackend` 提供 Host 侧工具桥。 -/// -/// 这个桥只做三件事:把当前 `ToolCall` 转成中立的 `BackendRequest`、把 -/// 返回值转成普通 `ToolResult`,以及把外部会话身份写入已有 SQLite 表。它 -/// 不依赖 Codex;Codex CLI、App Server 或其它远端执行器都可以实现同一个 -/// Core 端口后接入。工具仍需经过 `ApprovalPolicy`,本类型不会自动放行。 -pub struct ExternalBackendToolExecutor { - backend: Arc, - backend_name: String, - operation: String, - runtime: RuntimeService, - /// request_id -> durable lifecycle context for calls that have crossed the - /// external dispatch boundary. The map is only an in-process cancel - /// index; SQLite remains the recovery/audit source of truth. - active_calls: Arc>>, -} - -#[derive(Clone, Debug)] -struct ActiveExternalCall { - record_id: String, - request_id: String, - session_id: String, - run_id: String, - cancel_requested: Arc, - cancel_result: Arc>>, -} - -impl std::fmt::Debug for ExternalBackendToolExecutor { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter - .debug_struct("ExternalBackendToolExecutor") - .field("backend_name", &self.backend_name) - .field("operation", &self.operation) - .finish_non_exhaustive() - } -} - -/// 把 Codex app-server 分配的 thread/turn 身份写入 Runtime 外部会话表。 -/// -/// `agent-codex` 只定义中立 sink 合同;这个 Host 实现负责把一次 run 的 -/// session/run 归属和 metadata 接到 `RuntimeService`,不会让 Codex 适配器 -/// 依赖 SQLite。thread/turn ID 是不透明远端身份,不会被拼进工具权限或 -/// Core reducer 状态;同一 thread 的 turn 更新会复用一个本地 durable row。 -#[derive(Clone, Debug)] -pub struct CodexRuntimeSessionMetadataSink { - runtime: RuntimeService, - session_id: String, - run_id: String, - backend: String, -} - -impl CodexRuntimeSessionMetadataSink { - pub fn new( - runtime: RuntimeService, - session_id: impl Into, - run_id: impl Into, - backend: impl Into, - ) -> Result { - let session_id = session_id.into(); - let run_id = run_id.into(); - let backend = backend.into(); - if session_id.trim().is_empty() { - return Err(HostError::Config( - "Codex session sink 需要 session_id".to_owned(), - )); - } - if run_id.trim().is_empty() { - return Err(HostError::Config( - "Codex session sink 需要 run_id".to_owned(), - )); - } - ToolOrigin::external(&backend) - .map_err(|error| HostError::Config(format!("Codex session backend 无效: {error}")))?; - Ok(Self { - runtime, - session_id, - run_id, - backend, - }) - } - - fn metadata_json(metadata: &CodexSessionMetadata) -> Result { - let thread_id = metadata.thread_id.as_deref(); - let turn_id = metadata.turn_id.as_deref(); - if thread_id.is_none() && turn_id.is_none() { - return Err(CodexError::InvalidConfig( - "Codex session metadata 至少需要 thread_id 或 turn_id".to_owned(), - )); - } - if thread_id.is_some_and(|value| value.trim().is_empty()) { - return Err(CodexError::InvalidConfig( - "Codex session metadata thread_id 不能为空".to_owned(), - )); - } - if turn_id.is_some_and(|value| value.trim().is_empty()) { - return Err(CodexError::InvalidConfig( - "Codex session metadata turn_id 不能为空".to_owned(), - )); - } - let mut fields = serde_json::Map::new(); - fields.insert( - "source".to_owned(), - Value::String("codex-app-server".to_owned()), - ); - if let Some(thread_id) = thread_id { - fields.insert("threadId".to_owned(), Value::String(thread_id.to_owned())); - } - if let Some(turn_id) = turn_id { - fields.insert("turnId".to_owned(), Value::String(turn_id.to_owned())); - } - Ok(Value::Object(fields)) - } - - /// Persist one merged session observation. Runtime updates replace the - /// metadata JSON as a whole, so read/merge/write here keeps audit fields - /// from an earlier observation instead of silently dropping them. - fn persist_record( - &self, - metadata: &CodexSessionMetadata, - requested_status: &str, - external_id_override: Option<&str>, - lifecycle: Option<&CodexSessionLifecycle>, - ) -> Result<(), CodexError> { - let mut metadata_json = Self::metadata_json(metadata)?; - if let Some(lifecycle) = lifecycle { - let Value::Object(fields) = &mut metadata_json else { - return Err(CodexError::Protocol( - "Codex lifecycle metadata 不是对象".to_owned(), - )); - }; - fields.insert( - "lifecycle".to_owned(), - Value::String(lifecycle.status.as_str().to_owned()), - ); - if let Some(external_id) = lifecycle.external_id.as_deref() { - fields.insert( - "externalId".to_owned(), - Value::String(external_id.to_owned()), - ); - } - if let Some(exit_code) = lifecycle.exit_code { - fields.insert("exitCode".to_owned(), Value::from(exit_code)); - } - if let Some(cancel_result) = lifecycle.cancel_result.as_deref() { - fields.insert( - "cancelResult".to_owned(), - Value::String(cancel_result.to_owned()), - ); - } - } - - let requested_external_id = external_id_override - .or(metadata.turn_id.as_deref()) - .or(metadata.thread_id.as_deref()); - let Some(requested_external_id) = requested_external_id else { - return Err(CodexError::InvalidConfig( - "Codex session metadata 至少需要 thread_id 或 turn_id".to_owned(), - )); - }; - if requested_external_id.trim().is_empty() { - return Err(CodexError::InvalidConfig( - "Codex session external_id 不能为空".to_owned(), - )); - } - - let stable_id = metadata - .thread_id - .as_deref() - .unwrap_or(requested_external_id); - let record_id = external_session_record_id(&self.backend, &format!("thread:{stable_id}")); - let existing = self - .runtime - .get_external_session(&record_id) - .map_err(|error| { - CodexError::Protocol(format!("读取 Codex session 记录失败: {error}")) - })?; - if let Some(existing) = existing { - if existing.session_id != self.session_id - || existing.run_id.as_deref() != Some(self.run_id.as_str()) - { - return Err(CodexError::Protocol(format!( - "Codex session 记录归属不匹配: {}", - existing.id - ))); - } - let mut merged_metadata = existing.metadata.clone(); - match (&mut merged_metadata, metadata_json) { - (Value::Object(current), Value::Object(update)) => { - current.extend(update); - } - (_, update) => merged_metadata = update, - } - let status = if matches!( - existing.status.as_str(), - "completed" | "failed" | "cancelled" | "canceled" - ) { - // Late thread/turn metadata must not resurrect a terminal row. - existing.status.clone() - } else { - requested_status.to_owned() - }; - let external_id = external_id_override - .or(metadata.turn_id.as_deref()) - .unwrap_or(existing.external_id.as_str()) - .to_owned(); - self.runtime - .update_external_session(&existing.id, &external_id, &status, merged_metadata) - .map_err(|error| { - CodexError::Protocol(format!("更新 Codex session 记录失败: {error}")) - })?; - } else { - self.runtime - .upsert_external_session(NewExternalSession { - id: record_id, - session_id: self.session_id.clone(), - run_id: Some(self.run_id.clone()), - backend: self.backend.clone(), - external_id: requested_external_id.to_owned(), - status: requested_status.to_owned(), - metadata: metadata_json, - }) - .map_err(|error| { - CodexError::Protocol(format!("创建 Codex session 记录失败: {error}")) - })?; - } - Ok(()) - } - - /// Store process supervisor observations in a stable auxiliary row for the - /// run. This row is separate from the thread/turn identity row because a - /// process can exit before the app-server has returned either identity. - fn persist_process_record(&self, event: &CodexProcessLifecycleEvent) -> Result<(), CodexError> { - let record_id = - external_session_record_id(&self.backend, &format!("process:run:{}", self.run_id)); - let external_id = format!("process:{}", self.run_id); - let status = match event.reason { - CodexProcessLifecycleReason::NaturalExit if event.exit_code == Some(0) => "completed", - CodexProcessLifecycleReason::NaturalExit => "failed", - CodexProcessLifecycleReason::ExplicitTerminate - | CodexProcessLifecycleReason::Cancel => "cancelled", - CodexProcessLifecycleReason::ReaderEof - | CodexProcessLifecycleReason::ReaderError - | CodexProcessLifecycleReason::Timeout - | CodexProcessLifecycleReason::Drop => "unknown", - }; - let metadata = json!({ - "source": "codex-app-server", - "processLifecycle": event.reason.as_str(), - "exitCode": event.exit_code, - }); - let existing = self - .runtime - .get_external_session(&record_id) - .map_err(|error| { - CodexError::Protocol(format!("读取 Codex process 记录失败: {error}")) - })?; - if let Some(existing) = existing { - if existing.session_id != self.session_id - || existing.run_id.as_deref() != Some(self.run_id.as_str()) - { - return Err(CodexError::Protocol(format!( - "Codex process 记录归属不匹配: {}", - existing.id - ))); - } - let mut merged = existing.metadata.clone(); - match (&mut merged, metadata) { - (Value::Object(current), Value::Object(update)) => current.extend(update), - (_, update) => merged = update, - } - let status = if matches!( - existing.status.as_str(), - "completed" | "failed" | "cancelled" | "canceled" - ) { - existing.status.clone() - } else { - status.to_owned() - }; - self.runtime - .update_external_session(&existing.id, &external_id, &status, merged) - .map_err(|error| { - CodexError::Protocol(format!("更新 Codex process 记录失败: {error}")) - })?; - } else { - self.runtime - .upsert_external_session(NewExternalSession { - id: record_id, - session_id: self.session_id.clone(), - run_id: Some(self.run_id.clone()), - backend: self.backend.clone(), - external_id, - status: status.to_owned(), - metadata, - }) - .map_err(|error| { - CodexError::Protocol(format!("创建 Codex process 记录失败: {error}")) - })?; - } - Ok(()) - } -} - -impl CodexSessionMetadataSink for CodexRuntimeSessionMetadataSink { - fn persist(&self, metadata: &CodexSessionMetadata) -> Result<(), CodexError> { - self.persist_record(metadata, "active", None, None) - } - - fn persist_lifecycle(&self, lifecycle: &CodexSessionLifecycle) -> Result<(), CodexError> { - // A process request can be dispatched before typed thread/turn - // identity is known; there is no stable external-session row to - // update in that case, so keep the lifecycle observation best-effort. - if lifecycle.metadata.thread_id.is_none() && lifecycle.metadata.turn_id.is_none() { - return Ok(()); - } - self.persist_record( - &lifecycle.metadata, - lifecycle.status.as_str(), - lifecycle.external_id.as_deref(), - Some(lifecycle), - ) - } - - fn persist_process_lifecycle( - &self, - event: &CodexProcessLifecycleEvent, - ) -> Result<(), CodexError> { - self.persist_process_record(event) - } -} - -impl ExternalBackendToolExecutor { - /// 构造一个带 durable 外部会话记录的桥。 - pub fn new( - backend_name: impl Into, - operation: impl Into, - backend: Arc, - store: SqliteStore, - ) -> Result { - Self::new_with_runtime( - backend_name, - operation, - backend, - RuntimeService::from_store(store), - ) - } - - /// 使用已经装配好的 Runtime facade,避免外部会话桥再次直接依赖 - /// SQLite adapter。保留上面的 Store 构造器供旧嵌入方逐步迁移。 - pub fn new_with_runtime( - backend_name: impl Into, - operation: impl Into, - backend: Arc, - runtime: RuntimeService, - ) -> Result { - let backend_name = backend_name.into(); - let operation = operation.into(); - if backend_name.trim().is_empty() { - return Err(HostError::Config("外部 backend 名称不能为空".to_owned())); - } - ToolOrigin::external(&backend_name) - .map_err(|error| HostError::Config(format!("外部 backend 名称无效: {error}")))?; - // 在真正执行前复用 Core 的 identifier 规则校验 operation,避免 - // 第一次工具调用才暴露配置错误。 - BackendRequest::try_new("external-probe", "run-probe", &operation, json!({})) - .map_err(|error| HostError::Config(format!("外部 backend operation 无效: {error}")))?; - Ok(Self { - backend, - backend_name, - operation, - runtime, - active_calls: Arc::new(Mutex::new(BTreeMap::new())), - }) - } - - pub fn backend_name(&self) -> &str { - &self.backend_name - } - - pub fn operation(&self) -> &str { - &self.operation - } - - /// 显式取消一个仍由外部 backend 管理的请求。 - /// - /// Engine 的 `ToolExecutor` 端口没有隐式 cancel 生命周期,因此 Host - /// 只暴露这个显式动作,不在超时或 Drop 时猜测外部副作用已经停止。 - pub fn cancel(&self, request_id: &str) -> Result<(), HostError> { - if request_id.trim().is_empty() { - return Err(HostError::Config( - "外部 backend cancel request_id 不能为空".to_owned(), - )); - } - let active = self - .active_calls - .lock() - .map_err(|_| HostError::Config("外部 backend 活动表锁已损坏".to_owned()))? - .get(request_id) - .cloned(); - - let Some(active) = active else { - // active index 只存在于当前进程。先查 request-id 别名对应的 - // durable row,让重开 Host 后的显式 cancel 仍能更新已有 session, - // 而不是凭空再插一条没有 run/session 归属的记录。 - let record_id = external_session_record_id(&self.backend_name, request_id); - if let Some(record) = self - .runtime - .get_external_session(&record_id) - .map_err(host_error_from_runtime)? - { - return self.cancel_persisted_record(&record, request_id); - } - // 没有 durable row 时仍保留 backend 的幂等 cancel 行为;这类 - // 调用可能来自尚未登记生命周期的旧嵌入方,不能伪造归属信息。 - return self - .backend - .cancel(request_id) - .map_err(|error| HostError::Config(format!("外部 backend 取消失败: {error}"))); - }; - - active.cancel_requested.store(true, Ordering::Release); - self.update_active_session( - &active, - "cancel_requested", - json!({ - "requestId": active.request_id, - "operation": self.operation, - "lifecycle": "cancel_requested", - "cancelRequested": true, - "externalIdKnown": false, - "sideEffectUnknown": true, - }), - )?; - - match self.backend.cancel(request_id) { - Ok(()) => { - if let Ok(mut result) = active.cancel_result.lock() { - *result = Some("ok"); - } - self.update_active_session( - &active, - "cancelled", - json!({ - "requestId": active.request_id, - "operation": self.operation, - "lifecycle": "cancelled", - "cancelRequested": true, - "cancelResult": "ok", - "externalIdKnown": false, - "sideEffectUnknown": true, - }), - )?; - Ok(()) - } - Err(error) => { - if let Ok(mut result) = active.cancel_result.lock() { - *result = Some("error"); - } - // A failed cancellation cannot prove that the child stopped; - // keep the row in the conservative unknown bucket and let the - // caller perform explicit reconciliation. - let persist_error = self.update_active_session( - &active, - "unknown", - json!({ - "requestId": active.request_id, - "operation": self.operation, - "lifecycle": "cancel_failed", - "cancelRequested": true, - "cancelResult": "error", - "externalIdKnown": false, - "sideEffectUnknown": true, - }), - ); - persist_error?; - Err(HostError::Config(format!("外部 backend 取消失败: {error}"))) - } - } - } - - /// 按 durable external-session 主键显式取消一个在其它进程登记的调用。 - /// - /// 该入口只会调用 backend 的 `cancel`,不会重新执行 `invoke`,也不会 - /// 自动把 unknown 结果标成完成。调用方应在 backend 成功提供终态证明后 - /// 继续走既有 reconciliation;取消失败会保守地保留 `unknown` 状态。 - pub fn cancel_persisted(&self, record_id: &str) -> Result<(), HostError> { - if record_id.trim().is_empty() { - return Err(HostError::Config( - "外部 backend cancel external-session id 不能为空".to_owned(), - )); - } - let record = self - .runtime - .get_external_session(record_id) - .map_err(host_error_from_runtime)? - .ok_or_else(|| HostError::Config(format!("外部会话不存在: {record_id}")))?; - self.cancel_persisted_record(&record, persisted_request_id(&record)) - } - - fn cancel_persisted_record( - &self, - record: &ExternalSessionRecord, - request_id: &str, - ) -> Result<(), HostError> { - if record.backend != self.backend_name { - return Err(HostError::Config(format!( - "外部会话 backend 不匹配: expected={} actual={}", - self.backend_name, record.backend - ))); - } - if matches!( - record.status.as_str(), - "completed" | "failed" | "cancelled" | "canceled" - ) { - // 终态取消是幂等 no-op;不再向一个已经完成的外部调用发送 - // 可能带来额外副作用的 interrupt。 - return Ok(()); - } - let cancel_reference = if request_id.trim().is_empty() { - record.external_id.as_str() - } else { - request_id - }; - let mut requested_metadata = record.metadata.clone(); - merge_external_lifecycle_metadata( - &mut requested_metadata, - cancel_reference, - &self.operation, - "cancel_requested", - Some("pending"), - ); - self.runtime - .update_external_session( - &record.id, - &record.external_id, - "cancel_requested", - requested_metadata, - ) - .map_err(host_error_from_runtime)?; - - match self.backend.cancel(cancel_reference) { - Ok(()) => { - let mut metadata = record.metadata.clone(); - merge_external_lifecycle_metadata( - &mut metadata, - cancel_reference, - &self.operation, - "cancelled", - Some("ok"), - ); - self.runtime - .update_external_session(&record.id, &record.external_id, "cancelled", metadata) - .map_err(host_error_from_runtime)?; - Ok(()) - } - Err(error) => { - let mut metadata = record.metadata.clone(); - merge_external_lifecycle_metadata( - &mut metadata, - cancel_reference, - &self.operation, - "cancel_failed", - Some("error"), - ); - self.runtime - .update_external_session(&record.id, &record.external_id, "unknown", metadata) - .map_err(host_error_from_runtime)?; - Err(HostError::Config(format!("外部 backend 取消失败: {error}"))) - } - } - } - - fn update_active_session( - &self, - active: &ActiveExternalCall, - status: &str, - metadata: Value, - ) -> Result { - self.runtime - .update_external_session(&active.record_id, &active.request_id, status, metadata) - .map_err(host_error_from_runtime) - } - - fn persist_session( - &self, - context: &ToolContext, - external_id: &str, - status: &str, - metadata: serde_json::Value, - ) -> Result { - let session_id = context.session_id().ok_or_else(|| { - ToolError::new( - ToolErrorKind::InvalidInput, - "外部 backend 工具需要 ToolContext.session_id", - ) - })?; - let run_id = context.run_id().ok_or_else(|| { - ToolError::new( - ToolErrorKind::InvalidInput, - "外部 backend 工具需要 ToolContext.run_id", - ) - })?; - self.runtime - .upsert_external_session(NewExternalSession { - id: external_session_record_id(&self.backend_name, external_id), - session_id: session_id.to_owned(), - run_id: Some(run_id.to_owned()), - backend: self.backend_name.clone(), - external_id: external_id.to_owned(), - status: status.to_owned(), - metadata, - }) - .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string())) - } - - /// 在外部 dispatch 前登记一个可恢复的 lifecycle row。 - /// - /// request id 是唯一已知的稳定关联键;如果后端稍后返回真正的 - /// external id,完成路径会额外写入该 id,并把这个 request-id 别名一并 - /// 收束,避免重启扫描时留下假 `running` 会话。 - fn begin_active_call( - &self, - context: &ToolContext, - request: &BackendRequest, - ) -> Result { - let session_id = context.session_id().ok_or_else(|| { - ToolError::new( - ToolErrorKind::InvalidInput, - "外部 backend 工具需要 ToolContext.session_id", - ) - })?; - let run_id = context.run_id().ok_or_else(|| { - ToolError::new( - ToolErrorKind::InvalidInput, - "外部 backend 工具需要 ToolContext.run_id", - ) - })?; - let record_id = external_session_record_id(&self.backend_name, request.request_id()); - let active = ActiveExternalCall { - record_id, - request_id: request.request_id().to_owned(), - session_id: session_id.to_owned(), - run_id: run_id.to_owned(), - cancel_requested: Arc::new(AtomicBool::new(false)), - cancel_result: Arc::new(Mutex::new(None)), - }; - - // 先检查本地 active index,再写 durable row;重复 request_id 不能 - // 让一次 cancel 不确定地作用于两个 child。 - { - let mut active_calls = self.active_calls.lock().map_err(|_| { - ToolError::new(ToolErrorKind::Failed, "外部 backend 活动表锁已损坏") - })?; - if active_calls.contains_key(request.request_id()) { - return Err(ToolError::new( - ToolErrorKind::InvalidInput, - "外部 backend request_id 已在执行", - )); - } - active_calls.insert(request.request_id().to_owned(), active.clone()); - } - - let persisted = self.persist_session( - context, - request.request_id(), - "running", - json!({ - "requestId": request.request_id(), - "operation": request.operation(), - "lifecycle": "running", - "dispatchStarted": true, - "externalIdKnown": false, - "sideEffectUnknown": true, - }), - ); - if let Err(error) = persisted { - if let Ok(mut active_calls) = self.active_calls.lock() { - active_calls.remove(request.request_id()); - } - return Err(error); - } - Ok(active) - } - - fn finish_active_call( - &self, - active: &ActiveExternalCall, - external_id: &str, - status: &str, - mut metadata: Value, - ) -> Result<(), ToolError> { - if let Some(object) = metadata.as_object_mut() { - object.insert( - "cancelRequested".to_owned(), - Value::Bool(active.cancel_requested.load(Ordering::Acquire)), - ); - if let Ok(result) = active.cancel_result.lock() - && let Some(result) = *result - { - object.insert("cancelResult".to_owned(), Value::String(result.to_owned())); - } - } - - // 先收束 dispatch 前登记的 request-id 别名;这一步即使后端返回了 - // 另一个 external id 也不会留下旧的 running 状态。 - self.runtime - .update_external_session( - &active.record_id, - active.request_id.as_str(), - status, - metadata.clone(), - ) - .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; - - if external_id != active.request_id { - // 保留现有按真实 external_id 查询的兼容路径;这条记录与上面的 - // request-id 别名共享同一 lifecycle metadata,不是第二次调用。 - self.runtime - .upsert_external_session(NewExternalSession { - id: external_session_record_id(&self.backend_name, external_id), - session_id: active.session_id.clone(), - run_id: Some(active.run_id.clone()), - backend: self.backend_name.clone(), - external_id: external_id.to_owned(), - status: status.to_owned(), - metadata, - }) - .map_err(|error| ToolError::new(ToolErrorKind::Failed, error.to_string()))?; - } - Ok(()) - } - - fn remove_active_call( - &self, - request_id: &str, - ) -> Result, ToolError> { - self.active_calls - .lock() - .map_err(|_| ToolError::new(ToolErrorKind::Failed, "外部 backend 活动表锁已损坏")) - .map(|mut calls| calls.remove(request_id)) - } -} - -impl ToolExecutor for ExternalBackendToolExecutor { - fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result { - // This executor is public and can be called outside AgentEngine; keep - // the same Core input boundary before registering or invoking an - // external side effect. - call.validate()?; - context.validate()?; - let run_id = context.run_id().ok_or_else(|| { - ToolError::new( - ToolErrorKind::InvalidInput, - "外部 backend 工具需要 ToolContext.run_id", - ) - })?; - // 在触发外部副作用前就检查两个 durable 身份;不能等调用返回后 - // 才发现没有 session,留下无法归属的 opaque 操作。 - if context.session_id().is_none() { - return Err(ToolError::new( - ToolErrorKind::InvalidInput, - "外部 backend 工具需要 ToolContext.session_id", - )); - } - // Tool call ID 是 Engine 的稳定幂等边界;同一请求重试时不生成第二 - // 个随机 ID,便于外部 backend 自己做去重或由 Host 对账。 - let request = - BackendRequest::try_new(call.id(), run_id, &self.operation, call.arguments().clone()) - .and_then(|request| { - request.with_metadata(json!({ - "toolName": call.name(), - "sessionId": context.session_id(), - "runId": run_id, - })) - }) - .map_err(|error| ToolError::new(ToolErrorKind::InvalidInput, error.to_string()))?; - - let active = self.begin_active_call(context, &request)?; - let result = match self.backend.invoke(&request) { - Ok(result) => result, - Err(error) => { - let _ = self.remove_active_call(request.request_id())?; - let unknown = matches!( - error.kind(), - ExternalErrorKind::UnknownSideEffect | ExternalErrorKind::Timeout - ); - let status = if unknown { "unknown" } else { "failed" }; - self.finish_active_call( - &active, - request.request_id(), - status, - json!({ - "requestId": request.request_id(), - "operation": request.operation(), - "lifecycle": "invoke_error", - "externalIdKnown": false, - "sideEffectUnknown": unknown, - "errorKind": external_error_kind_name(error.kind()), - }), - )?; - return Err(external_error_as_tool_error(error)); - } - }; - let _ = self.remove_active_call(request.request_id())?; - if let Err(error) = result.validate() { - // A custom backend can still return a compatibility value; after - // dispatch, any malformed envelope is an unknown side effect and - // must remain behind reconciliation. - self.finish_active_call( - &active, - request.request_id(), - "unknown", - json!({ - "requestId": request.request_id(), - "operation": request.operation(), - "lifecycle": "response_invalid", - "externalIdKnown": false, - "sideEffectUnknown": true, - "errorKind": "unknown-side-effect", - }), - )?; - return Err(ToolError::new( - ToolErrorKind::Unknown, - format!("外部 backend 返回非法结果: {error}"), - )); - } - if result.request_id() != request.request_id() { - // 返回身份错配意味着不能证明外部调用是否完成;先落一条 - // unknown 会话,再把错误交给 Engine 的 tool-in-flight gate。 - self.finish_active_call( - &active, - request.request_id(), - "unknown", - json!({ - "requestId": request.request_id(), - "operation": request.operation(), - "lifecycle": "response_identity_mismatch", - "externalIdKnown": false, - "sideEffectUnknown": true, - "errorKind": "unknown-side-effect", - }), - )?; - return Err(ToolError::new( - ToolErrorKind::Unknown, - format!( - "外部 backend 返回 request id 不匹配: expected={} actual={}", - request.request_id(), - result.request_id() - ), - )); - } - - let side_effect_unknown = result.side_effect_unknown(); - let external_id = result - .external_id() - .unwrap_or_else(|| request.request_id()) - .to_owned(); - let status = if side_effect_unknown { - "unknown" - } else { - "completed" - }; - self.finish_active_call( - &active, - &external_id, - status, - json!({ - "requestId": request.request_id(), - "operation": request.operation(), - "lifecycle": status, - "externalIdKnown": result.external_id().is_some(), - "sideEffectUnknown": side_effect_unknown, - }), - )?; - - let tool = ToolResult::try_new(call.id(), result.output().clone(), side_effect_unknown) - .and_then(|tool| { - tool.with_metadata(json!({ - "external": true, - "backend": self.backend_name, - "operation": self.operation, - "externalId": external_id, - "sideEffectUnknown": side_effect_unknown, - })) - }) - .map_err(ToolError::from)?; - if side_effect_unknown { - // unknown 结果不能写成 safe checkpoint;返回错误让 Engine/Host - // 保留 tool-in-flight,对账后才能继续,避免模型自动重试副作用。 - return Err(ToolError::new( - ToolErrorKind::Unknown, - format!("外部 backend 结果副作用未知: {external_id}"), - )); - } - Ok(tool) - } -} - -/// 外部会话表的稳定本地主键;backend/external_id 仍由 SQLite 唯一约束兜底。 -pub fn external_session_record_id(backend: &str, external_id: &str) -> String { - format!("external-session:{backend}:{external_id}") -} - -fn persisted_request_id(record: &ExternalSessionRecord) -> &str { - record - .metadata - .get("requestId") - .and_then(Value::as_str) - .filter(|request_id| !request_id.trim().is_empty()) - .unwrap_or(record.external_id.as_str()) -} - -/// 在不丢弃适配器已有非敏感字段的前提下更新外部生命周期元数据。 -/// 手工登记的 scalar metadata 也会被包在 `previousMetadata` 中,避免 -/// 为了写 cancel 状态而静默覆盖调用方的审计信息。 -fn merge_external_lifecycle_metadata( - metadata: &mut Value, - request_id: &str, - operation: &str, - lifecycle: &str, - cancel_result: Option<&str>, -) { - let previous = std::mem::replace(metadata, Value::Null); - let mut object = match previous { - Value::Object(object) => object, - other => { - let mut object = serde_json::Map::new(); - object.insert("previousMetadata".to_owned(), other); - object - } - }; - object.insert("requestId".to_owned(), Value::String(request_id.to_owned())); - object.insert("operation".to_owned(), Value::String(operation.to_owned())); - object.insert("lifecycle".to_owned(), Value::String(lifecycle.to_owned())); - object.insert("cancelRequested".to_owned(), Value::Bool(true)); - object.insert("sideEffectUnknown".to_owned(), Value::Bool(true)); - if let Some(cancel_result) = cancel_result { - object.insert( - "cancelResult".to_owned(), - Value::String(cancel_result.to_owned()), - ); - } - *metadata = Value::Object(object); -} - -fn external_error_kind_name(kind: ExternalErrorKind) -> &'static str { - match kind { - ExternalErrorKind::InvalidInput => "invalid-input", - ExternalErrorKind::Unavailable => "unavailable", - ExternalErrorKind::Timeout => "timeout", - ExternalErrorKind::UnknownSideEffect => "unknown-side-effect", - } -} - -fn external_error_as_tool_error(error: ExternalError) -> ToolError { - let kind = match error.kind() { - ExternalErrorKind::InvalidInput => ToolErrorKind::InvalidInput, - ExternalErrorKind::Unavailable => ToolErrorKind::Failed, - // A synchronous external timeout cannot tell us whether the request - // reached the remote side. Treat it as unknown rather than allowing - // an idempotent retry policy to issue the same side effect again. - ExternalErrorKind::Timeout => ToolErrorKind::Unknown, - ExternalErrorKind::UnknownSideEffect => ToolErrorKind::Unknown, - }; - ToolError::new(kind, format!("外部 backend 调用失败: {error}")) -} - -/// 将一个已经握手的 MCP client 暴露为 Core 工具执行器。 -/// -/// `McpClient` 的同步 transport 由互斥锁保护;Engine 仍只看到统一的 -/// `ToolExecutor`,不会感知子进程、HTTP 或 JSON-RPC 细节。调用错误保持在 -/// 当前 tool call 内,不会伪造成功结果。 -pub struct McpToolExecutor { - client: Arc>, -} - -impl McpToolExecutor { - pub fn new(client: Arc>) -> Self { - Self { client } - } - - pub fn client(&self) -> &Arc> { - &self.client - } -} - -impl ToolExecutor for McpToolExecutor { - fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result { - // 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 策略重放未知调用;只有本地配置、 -/// 权限和取消错误保留可区分的非副作用类别。 -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 { - 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, -} - -impl McpToolCatalog { - pub fn new(server: impl Into, definitions: Vec) -> Self { - Self { - server: server.into(), - definitions, - } - } -} - -impl ToolSource for McpToolCatalog { - fn list_tools(&self) -> Result, 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, -} - -impl McpContextSource { - pub fn new() -> Self { - Self::default() - } - - pub fn from_resource( - server: &str, - resource: &agent_mcp::McpResourceDefinition, - result: &agent_mcp::McpReadResourceResult, - ) -> Result { - 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 { - 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, ContextError> { - Ok(self.items.clone()) - } -} - -/// 一次 MCP 装配中明确选择的外部上下文。 -/// -/// MCP 资源和 prompt 不会因为“发现了能力”就自动进入每次运行;调用方必须 -/// 逐项加入这个选择。这样既保持资源内容的不可信边界,也避免启动 Host 时 -/// 把整个远端目录无界地读进上下文。 -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct McpContextSelection { - resource_uris: Vec, - prompts: Vec, -} - -/// 一个显式展开的 MCP prompt 及其字符串参数。 -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct McpPromptSelection { - name: String, - arguments: BTreeMap, -} - -impl McpPromptSelection { - pub fn new(name: impl Into) -> Self { - Self { - name: name.into(), - arguments: BTreeMap::new(), - } - } - - pub fn with_arguments(mut self, arguments: I) -> Self - where - I: IntoIterator, - K: Into, - V: Into, - { - 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 { - &self.arguments - } -} - -impl McpContextSelection { - pub fn new() -> Self { - Self::default() - } - - /// 选择一个资源 URI;不会在此处发起网络/进程调用。 - pub fn with_resource_uri(mut self, uri: impl Into) -> Self { - self.resource_uris.push(uri.into()); - self - } - - /// 选择一个不带参数的 prompt。 - pub fn with_prompt(mut self, name: impl Into) -> 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() - } -} - -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 { - 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())) -} - -/// 已显式激活 Skill 的上下文源。Skill 正文按不可信内容注入,不能改变审批策略。 -#[derive(Clone, Debug, Default)] -pub struct SkillContextSource { - active: Vec, -} - -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, 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)] -struct SkillActivationContextSource { - items: Vec, -} - -impl SkillActivationContextSource { - fn new(items: &[ContextItem]) -> Self { - Self { - items: items.to_vec(), - } - } -} - -impl ContextSource for SkillActivationContextSource { - fn contribute(&self, _request: &ContextRequest) -> Result, ContextError> { - Ok(self.items.clone()) - } -} - /// 可注入 Provider/工具的单 Agent 宿主。 pub struct AgentHost { /// Durable run/lease/recovery 的唯一装配入口。Host 不再重复持有 @@ -3900,20 +2184,19 @@ impl AgentHost { for prompt in &selection.prompts { if !prompts .iter() - .any(|definition| definition.name == prompt.name) + .any(|definition| definition.name == prompt.name()) { return Err(HostError::Config(format!( "MCP prompt 未找到: {}", - prompt.name + prompt.name() ))); } - let result = - client - .get_prompt(&prompt.name, &prompt.arguments) - .map_err(|error| { - HostError::Config(format!("MCP prompts/get 失败: {error}")) - })?; - let source = McpContextSource::from_prompt(&server, &prompt.name, &result)?; + let result = client + .get_prompt(prompt.name(), prompt.arguments()) + .map_err(|error| { + HostError::Config(format!("MCP prompts/get 失败: {error}")) + })?; + let source = McpContextSource::from_prompt(&server, prompt.name(), &result)?; for item in source.items() { context.push(item.clone()); } @@ -4026,22 +2309,8 @@ impl AgentHost { approval_id: &str, decision: ApprovalDecision, ) -> Result { - let (status, payload) = match decision { - ApprovalDecision::Allow => ("allowed", json!({"decision": "allow"})), - ApprovalDecision::Deny { reason } => { - if reason.trim().is_empty() { - return Err(HostError::Config("审批拒绝原因不能为空".to_owned())); - } - ("denied", json!({"decision": "deny", "reason": reason})) - } - ApprovalDecision::Ask => { - return Err(HostError::Config( - "不能把 Ask 作为已决 approval 写回".to_owned(), - )); - } - }; self.runtime - .resolve_approval(approval_id, "pending", status, payload) + .resolve_approval_decision(approval_id, decision) .map_err(host_error_from_runtime) } @@ -4287,8 +2556,21 @@ impl AgentHost { )); return result; } - match self.reconcile_external_result(run_id, &phase, &record.external_id, messages) - { + let reconciled = match phase.as_str() { + "provider_in_flight" => self.runtime.reconcile_provider_result( + run_id, + &record.external_id, + messages, + ), + "tool_in_flight" => { + self.runtime + .reconcile_tool_result(run_id, &record.external_id, messages) + } + _ => Err(RuntimeServiceError::InvalidInput(format!( + "不支持的对账 checkpoint phase: {phase}" + ))), + }; + match reconciled { Ok(_) => match self.update_external_reconciliation_metadata( record, "completed", @@ -4394,7 +2676,9 @@ impl AgentHost { provider_request_id: &str, messages: Vec, ) -> Result { - self.reconcile_external_result(run_id, "provider_in_flight", provider_request_id, messages) + self.runtime + .reconcile_provider_result(run_id, provider_request_id, messages) + .map_err(host_error_from_runtime) } /// 记录调用方已经核对过的工具结果,并把 tool-in-flight 游标变成 safe。 @@ -4407,33 +2691,9 @@ impl AgentHost { tool_call_id: &str, messages: Vec, ) -> Result { - 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, - ) -> Result { - let checkpoint = self - .runtime - .read_checkpoint(run_id)? - .ok_or_else(|| HostError::Config(format!("run 没有可对账 checkpoint: {run_id}")))?; - validate_reconciliation_messages(&checkpoint, phase, external_id, &messages)?; - let encoded = serde_json::to_value(&messages) - .map_err(|error| HostError::Config(format!("对账消息无法编码: {error}")))?; - // step/attempt 来自刚刚读取的 checkpoint,storage 会在同一事务内再做 - // 一次条件检查;若期间有其它恢复器写入,CAS 失败而不会覆盖新结果。 - Ok(self.runtime.record_reconciliation_result( - run_id, - phase, - external_id, - checkpoint.step, - checkpoint.attempt, - encoded, - )?) + self.runtime + .reconcile_tool_result(run_id, tool_call_id, messages) + .map_err(host_error_from_runtime) } /// 将已过期的 worker 运行转入 reconciliation gate。 @@ -4556,103 +2816,10 @@ impl AgentHost { run_id: &str, error: impl Into, ) -> Result { - let error = error.into(); - if error.trim().is_empty() { - return Err(HostError::Config("failed 原因不能为空".to_owned())); - } - let record = self - .runtime - .get_run(run_id)? - .ok_or_else(|| HostError::Config(format!("找不到指定 run: {run_id}")))?; - if record.status == "failed" { - return Ok(record); - } - if matches!( - record.status.as_str(), - "completed" | "cancelled" | "canceled" - ) { - return Err(StorageError::TerminalRun { - id: run_id.to_owned(), - status: record.status, - } - .into()); - } - if !matches!(record.status.as_str(), "queued" | "reconciling") { - return Err(HostError::Config(format!( - "只有无 lease 的 queued/reconciling run 可以失败收口,当前为 {}: {run_id}", - record.status - ))); - } - if record.cancel_requested || self.runtime.get_run_lease(run_id)?.is_some() { - return Err(HostError::Config(format!( - "run 已请求取消或仍由 worker 持有 lease,不能无 lease 失败收口: {run_id}" - ))); - } - - let runtime_id = self - .runtime - .runtime_id_for_run(run_id)? - .ok_or_else(|| HostError::Config(format!("run 缺少 runtime 身份: {run_id}")))?; - let runtime_snapshot = self - .runtime - .load_runtime_snapshot(&runtime_id)? - .ok_or_else(|| HostError::Config(format!("找不到 runtime: {runtime_id}")))?; - let run_snapshot = runtime_snapshot - .run(run_id) - .cloned() - .ok_or_else(|| HostError::Config(format!("runtime 中找不到 run: {run_id}")))?; - if run_snapshot.status().is_terminal() { - return Err(HostError::Config(format!( - "runtime run 已处于终态 {:?},不能失败收口: {run_id}", - run_snapshot.status() - ))); - } - - let mut next_runtime = runtime_snapshot.clone(); - let mut events = Vec::new(); - if run_snapshot.status() == agent_runtime_core::RunStatus::Pending { - let started = RuntimeEvent::status_changed( - &runtime_id, - next_runtime.revision() + 1, - SystemClock.now_millis(), - run_id, - RuntimeEventKind::RunStarted, - ) - .map_err(|event_error| HostError::Config(event_error.to_string()))?; - next_runtime = reduce_runtime_event(&next_runtime, &started)?; - 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(|event_error| HostError::Config(event_error.to_string()))?; - next_runtime = reduce_runtime_event(&next_runtime, &failed)?; - events.push(failed); - } - if events.is_empty() { - return Err(HostError::Config(format!( - "runtime 没有可失败收口的事件: {run_id}" - ))); - } - - // snapshot 来自已存在的 runtime row,因此 expected revision 必须保留 - // `Some(0)` 这类合法值,不能用 None 把它误当成“尚不存在”。 + // 失败收口属于 durable Runtime 控制面;Host 仅保留该委托入口, + // 不再在 Engine glue 层重复构造 reducer 事件和跨表事务。 self.runtime - .fail_run_with_runtime( - run_id, - Some(json!({"error": error})), - &runtime_id, - Some(runtime_snapshot.revision()), - &next_runtime, - &events, - ) + .fail_unclaimed_run(run_id, error) .map_err(host_error_from_runtime) } @@ -5883,101 +4050,11 @@ impl AgentHost { /// “没有外部副作用”。持有有效 lease 的 worker 仍走 cooperative 路径, /// 等待当前 Provider/工具调用返回后再收口。 pub fn cancel(&self, run_id: &str) -> Result { - // 保留 request_cancel 前的状态:request_cancel 会把 queued/running/ - // reconciling 等状态统一改成 cancel_requested,之后已无法区分 - // “从未启动”的 queued 和“可能已经触发外部调用”的历史 running。 - let before = self - .runtime - .get_run(run_id)? - .ok_or_else(|| HostError::Config(format!("找不到指定 run: {run_id}")))?; - // 在发出 request_cancel 之前,queued run 先走 Runtime 的原子 - // expected-queued + 无 checkpoint/lease 入口,保留“尚未启动”的 - // 可辨识状态。若 worker 在此期间赢得领取竞争,入口返回 None; - // 这不是错误,下面的 request_cancel 会把它转成 cooperative gate。 - // 最终 predicate 在同一 SQLite 写事务内校验,避免旧的读后写窗口。 - if before.status == "queued" - && let Some(cancelled) = self.runtime.finish_queued_cancelled_if_unclaimed(run_id)? - { - self.runtime - .cancel_pending_approvals(run_id) - .map_err(host_error_from_runtime)?; - return Ok(cancelled); - } - - let record = self.runtime.request_cancel(run_id)?; - - // 终态请求保持幂等;不要为了读取 checkpoint 或做 stale probe - // 重新触碰一个已经完成的 runtime。 - if matches!(record.status.as_str(), "completed" | "failed" | "cancelled") { - self.runtime - .cancel_pending_approvals(run_id) - .map_err(host_error_from_runtime)?; - return Ok(record); - } - - let checkpoint = self.runtime.read_checkpoint(run_id)?; - let lease = self.runtime.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")); - - // queued 没有 Engine/外部调用游标;safe 和 awaiting_approval 也 - // 明确表示没有未知的 Provider/工具副作用。过期 lease 先经过 - // stale probe 清掉 fencing,再走同一个 runtime-aware 终态事务。 - let can_finish_unclaimed = !lease_active && safe_checkpoint; - if can_finish_unclaimed { - if lease.is_some() { - // 过期 lease 不能直接调用无 lease 终态 API;先让 Runtime - // 把运行边界收进 reconciliation,再确认 fencing 已清理。 - if self - .runtime - .reconcile_expired_run_if_stale(run_id)? - .is_none() - { - // lease 可能在读取后被续期或被其它 worker 接管;保守 - // 地保留 cancel_requested,不越权写终态。 - self.runtime - .cancel_pending_approvals(run_id) - .map_err(host_error_from_runtime)?; - return Ok(self.runtime.get_run(run_id)?.unwrap_or(record)); - } - } - if self.runtime.get_run_lease(run_id)?.is_none() { - let cancelled = self - .runtime - .finish_unclaimed_cancelled_if_safe(run_id) - .map_err(host_error_from_runtime)?; - self.runtime - .cancel_pending_approvals(run_id) - .map_err(host_error_from_runtime)?; - return Ok(cancelled); - } - } - - if !lease_active { - // 无 lease、过期 lease,以及已经落在 reconciling 的历史记录, - // 都必须先经过同一个 recovery gate。该入口只写状态/事件, - // 保留 provider_in_flight、tool_in_flight、compacting 或缺失 - // checkpoint 的未知边界,不会自动重放外部调用。 - if let Some(recovered) = self.runtime.reconcile_expired_run_if_stale(run_id)? { - self.runtime - .cancel_pending_approvals(run_id) - .map_err(host_error_from_runtime)?; - return Ok(recovered); - } - } - - // A worker may still be unwinding a local approval gate. Marking the - // pending request cancelled here prevents a late control-plane resolve - // from re-queueing a run after the explicit cancel wins. + // 取消策略属于 durable Runtime 控制面;Host 只把命令转发, + // Engine worker 的 cooperative 收口仍通过下方 finish_cancelled。 self.runtime - .cancel_pending_approvals(run_id) - .map_err(host_error_from_runtime)?; - Ok(record) + .cancel_run(run_id) + .map_err(host_error_from_runtime) } } @@ -6974,144 +5051,13 @@ fn append_runtime_message( commit_runtime_event(runtime, snapshot, runtime_event) } -/// resolver metadata 复用 queued metadata 的大小和 secret-key 规则,避免 +/// Resolver metadata 复用 queued metadata 的大小和 secret-key 规则,避免 /// 对账状态更新把凭据写入 durable external-session 行。 fn validate_external_reconciliation_metadata(metadata: &Value) -> Result<(), HostError> { AgentHost::validate_queue_metadata(metadata) .map_err(|error| HostError::Config(format!("外部对账 resolver metadata 无效: {error}"))) } -/// 在调用 storage CAS 前用 Core 类型检查外部对账历史。 -/// -/// SQLite 适配器故意只依赖稳定 wire shape;Host 这一层可以进一步检查 -/// tool call/result 的顺序和角色,确保写成 `safe` 的历史确实能被 Runtime -/// reducer 和下一次 Engine 共同消费。这里不调用 Provider 或工具。 -fn validate_reconciliation_messages( - checkpoint: &CheckpointRecord, - phase: &str, - external_id: &str, - messages: &[Message], -) -> Result<(), HostError> { - if !matches!(phase, "provider_in_flight" | "tool_in_flight") { - return Err(HostError::Config(format!( - "不支持的对账 checkpoint phase: {phase}" - ))); - } - if messages.is_empty() { - return Err(HostError::Config("对账消息不能为空".to_owned())); - } - if checkpoint.phase != phase { - return Err(HostError::Config(format!( - "checkpoint phase 不匹配:expected={phase} actual={}", - checkpoint.phase - ))); - } - if checkpoint.step != checkpoint.next_step { - return Err(HostError::Config( - "in-flight checkpoint 的 step/next_step 游标无效".to_owned(), - )); - } - match phase { - "provider_in_flight" - if checkpoint.provider_request_id.as_deref() != Some(external_id) - || checkpoint.tool_call_id.is_some() => - { - return Err(HostError::Config( - "Provider request identity 与 checkpoint 不匹配".to_owned(), - )); - } - "tool_in_flight" if checkpoint.tool_call_id.as_deref() != Some(external_id) => { - return Err(HostError::Config( - "tool call identity 与 checkpoint 不匹配".to_owned(), - )); - } - _ => {} - } - - let checkpoint_messages = - serde_json::from_value::>(checkpoint.messages.clone()) - .map_err(|error| HostError::Config(format!("checkpoint 消息无效: {error}")))?; - if messages.len() <= checkpoint_messages.len() { - return Err(HostError::Config( - "对账消息必须包含完整 checkpoint 前缀和新增结果".to_owned(), - )); - } - if !checkpoint_messages - .iter() - .zip(messages) - .all(|(expected, actual)| expected == actual) - { - return Err(HostError::Config( - "对账消息没有保留 checkpoint 的完整前缀".to_owned(), - )); - } - - let mut calls = BTreeSet::new(); - let mut results = 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(HostError::Config(format!( - "tool call 必须位于 assistant 消息: index={message_index}" - ))); - } - if !calls.insert(id.clone()) { - return Err(HostError::Config(format!("对账消息重复 tool call: {id}"))); - } - } - ContentPart::ToolResult { tool_call_id, .. } => { - if message.role() != MessageRole::Tool { - return Err(HostError::Config(format!( - "tool result 必须位于 tool 消息: index={message_index}" - ))); - } - // Runtime 只接受已经观察到的 call;不允许凭一个 result - // 猜测此前存在过未落盘的调用。 - if !calls.contains(tool_call_id) { - return Err(HostError::Config(format!( - "tool result 引用了尚未出现的 call: {tool_call_id}" - ))); - } - if !results.insert(tool_call_id.clone()) { - return Err(HostError::Config(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(HostError::Config( - "对账消息仍包含未完成的 tool call,不能标记 safe".to_owned(), - )); - } - if phase == "provider_in_flight" && !suffix_has_assistant { - return Err(HostError::Config( - "Provider 对账后缀必须包含 assistant 响应".to_owned(), - )); - } - if phase == "tool_in_flight" && !suffix_has_matching_tool_result { - return Err(HostError::Config( - "工具对账后缀必须包含对应 tool result".to_owned(), - )); - } - Ok(()) -} - fn event_type(event: &EngineEvent) -> &'static str { match event { EngineEvent::StepStarted { .. } => "step_started", @@ -7135,14 +5081,20 @@ fn unique_suffix() -> u128 { #[cfg(test)] mod tests { + use super::mcp::mcp_error_as_tool_error; use super::*; - use agent_codex::CodexSessionLifecycleStatus; + use agent_codex::{ + CodexProcessLifecycleEvent, CodexProcessLifecycleReason, CodexSessionLifecycle, + CodexSessionLifecycleStatus, CodexSessionMetadata, CodexSessionMetadataSink, + }; use agent_mcp::{ JsonRpcRequest, JsonRpcResponse, McpClient, McpClientOptions, McpSyncTransport, + McpToolDefinition, }; use agent_runtime_core::{ - BackendResult, ModelProvider, ProviderError, ProviderErrorKind, ProviderInstanceId, - ProviderProtocolId, ProviderRequest, ProviderResponse, SkillActivation, SkillDefinition, + BackendRequest, BackendResult, ContextItem, ExternalErrorKind, ModelProvider, + ProviderError, ProviderErrorKind, ProviderInstanceId, ProviderProtocolId, ProviderRequest, + ProviderResponse, SkillActivation, SkillDefinition, ToolErrorKind, ToolSource, }; use agent_runtime_engine::CompressionRequest; use serde_json::Value; diff --git a/rust/crates/agent-host/src/mcp.rs b/rust/crates/agent-host/src/mcp.rs new file mode 100644 index 000000000..817d0d478 --- /dev/null +++ b/rust/crates/agent-host/src/mcp.rs @@ -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>, +} + +impl McpToolExecutor { + pub fn new(client: Arc>) -> Self { + Self { client } + } + + pub fn client(&self) -> &Arc> { + &self.client + } +} + +impl ToolExecutor for McpToolExecutor { + fn execute(&self, call: &ToolCall, context: &ToolContext) -> Result { + // 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 { + 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, +} + +impl McpToolCatalog { + pub fn new(server: impl Into, definitions: Vec) -> Self { + Self { + server: server.into(), + definitions, + } + } +} + +impl ToolSource for McpToolCatalog { + fn list_tools(&self) -> Result, 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, +} + +impl McpContextSource { + pub fn new() -> Self { + Self::default() + } + + pub fn from_resource( + server: &str, + resource: &agent_mcp::McpResourceDefinition, + result: &agent_mcp::McpReadResourceResult, + ) -> Result { + 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 { + 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, ContextError> { + Ok(self.items.clone()) + } +} + +/// 一次 MCP 装配中明确选择的外部上下文。 +/// +/// MCP 资源和 prompt 不会因为“发现了能力”就自动进入每次运行;调用方必须 +/// 逐项加入这个选择。这样既保持资源内容的不可信边界,也避免启动 Host 时 +/// 把整个远端目录无界地读进上下文。 +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct McpContextSelection { + pub(super) resource_uris: Vec, + pub(super) prompts: Vec, +} + +/// 一个显式展开的 MCP prompt 及其字符串参数。 +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct McpPromptSelection { + name: String, + arguments: BTreeMap, +} + +impl McpPromptSelection { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + arguments: BTreeMap::new(), + } + } + + pub fn with_arguments(mut self, arguments: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + 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 { + &self.arguments + } +} + +impl McpContextSelection { + pub fn new() -> Self { + Self::default() + } + + /// 选择一个资源 URI;不会在此处发起网络/进程调用。 + pub fn with_resource_uri(mut self, uri: impl Into) -> Self { + self.resource_uris.push(uri.into()); + self + } + + /// 选择一个不带参数的 prompt。 + pub fn with_prompt(mut self, name: impl Into) -> 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 { + 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())) +} diff --git a/rust/crates/agent-host/src/tools.rs b/rust/crates/agent-host/src/tools.rs new file mode 100644 index 000000000..cb01295b1 --- /dev/null +++ b/rust/crates/agent-host/src/tools.rs @@ -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, + executors: BTreeMap>, + origins: BTreeMap, +} + +impl ToolRouter { + pub fn new() -> Self { + Self::default() + } + + pub fn register( + &mut self, + definition: ToolDefinition, + executor: Arc, + ) -> 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, + ) -> 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 { + // 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; +} + +/// 一个无动态状态的显式 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, + tool: impl Into, + target: impl Into, + ) -> 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, + tool: impl Into, + target: impl Into, + ) -> Result { + 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 { + 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 NamespaceToolResolver for Arc +where + T: NamespaceToolResolver + ?Sized, +{ + fn resolve_tool( + &self, + namespace: &str, + tool: &str, + ) -> Result { + (**self).resolve_tool(namespace, tool) + } +} + +pub(super) fn default_namespace_tool_resolver() -> Arc { + 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 { + 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 { + 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())) +} diff --git a/rust/crates/agent-runtime-sqlite/src/control.rs b/rust/crates/agent-runtime-sqlite/src/control.rs new file mode 100644 index 000000000..648727d75 --- /dev/null +++ b/rust/crates/agent-runtime-sqlite/src/control.rs @@ -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 { + 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 { + 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, + ) -> super::Result { + 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, + ) -> super::Result { + 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, + ) -> super::Result { + 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, + ) -> super::Result { + 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) -> 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::>(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(()) +} diff --git a/rust/crates/agent-runtime-sqlite/src/lib.rs b/rust/crates/agent-runtime-sqlite/src/lib.rs index 82ee0c19e..43662445a 100644 --- a/rust/crates/agent-runtime-sqlite/src/lib.rs +++ b/rust/crates/agent-runtime-sqlite/src/lib.rs @@ -45,6 +45,7 @@ pub use agent_runtime::{ DurableToolCallCheckpointRuntimeCommit, DurableToolCallInput, DurableToolCallRuntimeCommit, DurableToolCallView, }; +mod control; mod durable_sqlite; pub use durable_sqlite::{SqliteDurableStore, SqliteDurableStoreError}; diff --git a/rust/crates/agent-runtime-sqlite/tests/control.rs b/rust/crates/agent-runtime-sqlite/tests/control.rs new file mode 100644 index 000000000..54fe39acd --- /dev/null +++ b/rust/crates/agent-runtime-sqlite/tests/control.rs @@ -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() + ); +} diff --git a/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md b/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md index 2711aaca3..04be75864 100644 --- a/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md +++ b/rust/docs/【任务】Agent内核落地TODO-2026-09-01.md @@ -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/末条消息内容去重的推断。 diff --git a/rust/docs/【架构】独立Agent运行时-2026-09-01.md b/rust/docs/【架构】独立Agent运行时-2026-09-01.md index ceb822f50..b6bfe7db2 100644 --- a/rust/docs/【架构】独立Agent运行时-2026-09-01.md +++ b/rust/docs/【架构】独立Agent运行时-2026-09-01.md @@ -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; diff --git a/rust/docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md b/rust/docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md index fec40a084..e5a207f19 100644 --- a/rust/docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md +++ b/rust/docs/【测试】Agent测试集与真实Provider接入-2026-09-02.md @@ -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)。 diff --git a/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md b/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md index d1c96bf6f..797c3adde 100644 --- a/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md +++ b/rust/docs/【计划】独立通用Agent内核与单Agent程序建设计划-2026-09-02.md @@ -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 配置变更执行。 diff --git a/rust/docs/【验收】Agent内核与通用程序-2026-09-01.md b/rust/docs/【验收】Agent内核与通用程序-2026-09-01.md index 19e4bb7ac..543569ef7 100644 --- a/rust/docs/【验收】Agent内核与通用程序-2026-09-01.md +++ b/rust/docs/【验收】Agent内核与通用程序-2026-09-01.md @@ -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 的当前增量闭环通过本地验收: diff --git a/rust/scripts/check-dependencies.sh b/rust/scripts/check-dependencies.sh index 091735a70..b25272a0a 100755 --- a/rust/scripts/check-dependencies.sh +++ b/rust/scripts/check-dependencies.sh @@ -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.