Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions docs/architecture/agent-runtime-services-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,8 +697,10 @@ pub struct HarnessExecutionContext {

当前 CLI 入口已使用类型化 `RuntimeServices` 构造 `ProductRuntimeParts`,并通过一个调用级上下文把
Agent Runtime SDK、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与
交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除、轮次提交和取消;SDK v1 尚未覆盖的恢复视图、
消息、分支、用量、快照和工具确认由 `assembly/core` 的单一兼容门面转发。`doctor` 与 `health` 校验真实组装结果及必需注册完整性;
交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除/恢复、类型化转录读取、轮次提交/取消,以及 CLI/TUI
的工具确认、拒绝和用户问题回答;Peer Host 当前只消费同一 SDK 的工具确认与拒绝。SDK v1 尚未覆盖的固定 ID
会话创建、模型更新、分支、用量、快照和
持久化维护等操作仍由 `assembly/core` 的单一兼容门面转发。`doctor` 与 `health` 校验真实组装结果及必需注册完整性;
Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊断不等于对这些外部服务做实时探活。

该切换仍是 `product-full` 兼容组装,不是 owner 迁移。协调器、调度器、持久化、工具管线和 Agentic Event Queue
Expand Down Expand Up @@ -777,8 +779,11 @@ ping 路由。未接入入口的 profile、枚举分支和单元测试仍不能
当前 Runtime SDK 已提供会话创建、列出、删除、恢复和类型化转录读取。`AgentSessionRestoreRequest/Result` 与
`AgentSessionRestorePort` 归 Agent Runtime SDK,以继续复用 Runtime owner 的完整 `SessionState`;类型化
`SessionTranscript` 归 `runtime-ports`。两者都由 `assembly/core` 注入真实 persistence owner,CLI/TUI 是当前恢复与
转录消费方。`CoreAgentRuntimeCompatibility` 仍承载未迁移的持久化、分支、用量、快照和交互操作;不能据此把整个
兼容门面一次性删除,也不能把这些操作提前声明为跨宿主稳定接口。
转录消费方。CLI/TUI 的工具确认、拒绝和用户问题回答,以及 Peer Host 的工具确认与拒绝,通过类型化
`AgentInteractionResponsePort` 回到 Core 的工具管线或用户输入 owner,不改变审批策略或交互所有权。
`CoreAgentRuntimeCompatibility` 仍承载未迁移的
固定 ID 会话创建、模型更新、持久化、分支、用量和快照等操作;不能据此把整个兼容门面一次性删除,也不能把这些
操作提前声明为跨宿主稳定接口。

### 4.3 Product Capability 设计

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/core-decomposition-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Peer Host 的 Runtime 接入和跨 Relay/Desktop/Web 的协议切换保持独立

下一步按独立纵向切片推进:

1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小固定 ID、恢复视图、消息、分支、用量和工具确认兼容面
1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小固定 ID 会话创建、模型更新、分支、用量、快照和持久化维护兼容面
2. 迁移 ACP 的会话/权限/事件投影,但保留 ACP stdio 生命周期在接口入口。
3. 继续拆分 TUI 副作用边界并补 package smoke,不以大规模重写替代现有回归保护。

Expand Down
6 changes: 4 additions & 2 deletions docs/plans/product-architecture-evolution-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,10 @@ HarmonyOS 手机 Remote App 不在该平台执行范围内。

## 7. 工作流五:入口逐项迁移

- CLI:只迁移 Runtime SDK 已有稳定调用方的 session/turn/cancel 等路径;恢复视图、消息、分支、用量和工具确认
在补齐端口与行为测试前继续由现有单一兼容路径转发。
- CLI:会话创建/列举/删除/恢复、类型化转录和轮次提交/取消已由真实入口消费 Runtime SDK;TUI 通过 SDK 处理
工具确认、拒绝和用户问题回答,Exec 与交互模式下的 Peer Host 处理工具确认与拒绝。固定 ID 会话创建、模型
更新、分支、用量、快照和持久化维护等操作在补齐端口与行为测试前继续由
现有单一兼容路径转发。
- ACP:CLI 行为稳定后单独迁移会话、权限和事件投影;ACP stdio 生命周期留在接口入口。
- Desktop:按服务簇迁移,Tauri、窗口和 app-local 资源留在 Desktop。
- SDK/Server/Remote:只有真实独立调用方出现后才增加;枚举、空计划或测试替身不构成发布能力。
Expand Down
72 changes: 44 additions & 28 deletions src/apps/cli/src/agent/core_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ use tokio::sync::Mutex;
use super::Agent;
use bitfun_agent_runtime::sdk::{
AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest,
AgentSessionListRequest, AgentSessionRestoreRequest, AgentTurnCancellationRequest,
AgentSessionListRequest, AgentSessionRestoreRequest, AgentToolConfirmationRequest,
AgentToolRejectionRequest, AgentTurnCancellationRequest, AgentUserAnswersRequest,
SessionTranscript, SessionTranscriptRequest,
};
use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY;
Expand Down Expand Up @@ -101,7 +102,7 @@ impl CoreAgentAdapter {
remote_ssh_host: None,
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))
.map_err(|error| anyhow::anyhow!(error.into_message()))
}

pub(crate) async fn list_sessions(&self) -> Result<Vec<AgentSessionSummary>> {
Expand Down Expand Up @@ -130,7 +131,7 @@ impl CoreAgentAdapter {
remote_ssh_host: None,
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
.map_err(|error| anyhow::anyhow!(error.into_message()))?;

let mut session_id_guard = self.session_id.lock().await;
let mut turn_id_guard = self.current_turn_id.lock().await;
Expand All @@ -154,7 +155,7 @@ impl CoreAgentAdapter {
remote_ssh_host: None,
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))
.map_err(|error| anyhow::anyhow!(error.into_message()))
}

pub(crate) async fn get_transcript(&self, session_id: &str) -> Result<SessionTranscript> {
Expand All @@ -164,7 +165,7 @@ impl CoreAgentAdapter {
turn_id: None,
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))
.map_err(|error| anyhow::anyhow!(error.into_message()))
}

pub(crate) async fn update_session_model(
Expand Down Expand Up @@ -292,14 +293,18 @@ impl CoreAgentAdapter {
tracing::info!("Backend session restored: {}", session_id);
Ok(())
}
Err(error) if Self::is_session_not_found_error(&error.to_string()) => {
tracing::warn!(
"Session is unavailable, recreating backend session: {}",
session_id
);
self.recreate_session_with_id(session_id, agent_type).await
Err(error) => {
let message = error.into_message();
if Self::is_session_not_found_error(&message) {
tracing::warn!(
"Session is unavailable, recreating backend session: {}",
session_id
);
self.recreate_session_with_id(session_id, agent_type).await
} else {
Err(anyhow::anyhow!(message))
}
}
Err(error) => Err(anyhow::anyhow!(error.to_string())),
}
}

Expand Down Expand Up @@ -349,7 +354,7 @@ impl Agent for CoreAgentAdapter {
metadata: serde_json::Map::new(),
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
.map_err(|error| anyhow::anyhow!(error.into_message()))?;

let id = session.session_id.clone();

Expand Down Expand Up @@ -399,20 +404,21 @@ impl Agent for CoreAgentAdapter {
let start_result = self.runtime.submit_dialog_turn(request.clone()).await;

if let Err(err) = start_result {
if Self::is_session_not_found_error(&err.to_string()) {
let error_message = err.into_message();
if Self::is_session_not_found_error(&error_message) {
tracing::warn!(
"Session missing when starting turn, attempting recovery and retry: session_id={}, error={}",
session_id,
err
error_message
);
self.ensure_backend_session_alive(&session_id, agent_type)
.await?;
self.runtime
.submit_dialog_turn(request)
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
.map_err(|error| anyhow::anyhow!(error.into_message()))?;
} else {
return Err(anyhow::anyhow!(err.to_string()));
return Err(anyhow::anyhow!(error_message));
}
}

Expand All @@ -435,7 +441,7 @@ impl Agent for CoreAgentAdapter {
wait_timeout_ms: None,
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
.map_err(|error| anyhow::anyhow!(error.into_message()))?;

let mut turn_id_guard = self.current_turn_id.lock().await;
if turn_id_guard.as_deref() == Some(turn_id.as_str()) {
Expand All @@ -460,7 +466,7 @@ impl Agent for CoreAgentAdapter {
metadata: serde_json::Map::new(),
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
.map_err(|error| anyhow::anyhow!(error.into_message()))?;

let id = session.session_id.clone();

Expand All @@ -482,25 +488,35 @@ impl Agent for CoreAgentAdapter {
updated_input: Option<serde_json::Value>,
) -> Result<()> {
tracing::info!("Confirming tool execution: {}", tool_id);
self.compatibility
.confirm_tool(tool_id, updated_input)
self.runtime
.confirm_tool(AgentToolConfirmationRequest {
tool_id: tool_id.to_string(),
updated_input,
})
.await
.map_err(|e| anyhow::anyhow!("Confirm tool failed: {}", e))
.map_err(|e| anyhow::anyhow!("Confirm tool failed: {}", e.into_message()))
}

async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()> {
tracing::info!("Rejecting tool execution: {}, reason: {}", tool_id, reason);
self.compatibility
.reject_tool(tool_id, reason)
self.runtime
.reject_tool(AgentToolRejectionRequest {
tool_id: tool_id.to_string(),
reason,
})
.await
.map_err(|e| anyhow::anyhow!("Reject tool failed: {}", e))
.map_err(|e| anyhow::anyhow!("Reject tool failed: {}", e.into_message()))
}

async fn submit_user_answers(&self, tool_id: &str, answers: serde_json::Value) -> Result<()> {
tracing::info!("Submitting user answers for tool: {}", tool_id);
self.compatibility
.submit_user_answers(tool_id, answers)
.map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e))
self.runtime
.submit_user_answers(AgentUserAnswersRequest {
tool_id: tool_id.to_string(),
answers,
})
.await
.map_err(|e| anyhow::anyhow!("Submit user answers failed: {}", e.into_message()))
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/apps/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -954,7 +954,7 @@ async fn run_interactive_with_session(
remote_ssh_host: None,
})
.await
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
.map_err(|error| anyhow::anyhow!(error.into_message()))?;
let agent_type = sessions
.iter()
.find(|session| session.session_id == session_id)
Expand Down
87 changes: 77 additions & 10 deletions src/apps/cli/src/peer_host/commands/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

use serde_json::{json, Value};

use bitfun_agent_runtime::sdk::{
AgentToolConfirmationRequest, AgentToolRejectionRequest, RuntimeError,
};
use bitfun_runtime_ports::{
AgentDialogTurnRequest, AgentSubmissionSource, AgentTurnCancellationRequest,
DialogSubmissionPolicy, DialogTriggerSource,
Expand All @@ -11,6 +14,17 @@ use crate::peer_host::args::{get_string, optional_string, request_value};
use crate::peer_host::control::{attached_controller_lease, is_controller_lease_current};
use crate::peer_host::state::{PeerHostState, PeerTurnKey};

fn restore_confirmation_after_runtime_error(
turns: &crate::peer_host::state::PeerTurnTracker,
tool_id: String,
ownership: PeerTurnKey,
action: &str,
error: RuntimeError,
) -> String {
turns.restore_confirmation(tool_id, ownership);
format!("{action} tool failed: {}", error.into_message())
}

fn peer_dialog_metadata(request: &Value) -> Result<serde_json::Map<String, Value>, String> {
let mut metadata = match request.get("userMessageMetadata") {
Some(Value::Object(metadata)) => metadata.clone(),
Expand Down Expand Up @@ -82,7 +96,10 @@ pub(crate) async fn start_dialog_turn(
.await;
if let Err(error) = submit_result {
state.turns.finish_turn(&turn);
return Err(format!("Failed to start dialog turn: {error}"));
return Err(format!(
"Failed to start dialog turn: {}",
error.into_message()
));
}
if !state
.turns
Expand All @@ -101,6 +118,7 @@ pub(crate) async fn start_dialog_turn(
})
.await;
if let Err(error) = cancellation {
let error = error.into_message();
return Err(format!(
"Peer continuity was lost after dialog submission and cancellation could not be confirmed: session_id={session_id}, turn_id={turn_id}, error={error}"
));
Expand Down Expand Up @@ -135,7 +153,7 @@ pub(crate) async fn cancel_dialog_turn(
wait_timeout_ms: Some(1_500),
})
.await
.map_err(|e| format!("Failed to cancel dialog turn: {e}"))?;
.map_err(|error| format!("Failed to cancel dialog turn: {}", error.into_message()))?;
Ok(json!({ "success": true }))
}

Expand All @@ -159,12 +177,20 @@ pub(crate) async fn confirm_tool_execution(
}
let updated_input = request.get("updatedInput").cloned();
if let Err(error) = state
.compatibility
.confirm_tool(&tool_id, updated_input)
.agent_runtime
.confirm_tool(AgentToolConfirmationRequest {
tool_id: tool_id.clone(),
updated_input,
})
.await
{
state.turns.restore_confirmation(tool_id, ownership);
return Err(format!("Confirm tool failed: {error}"));
return Err(restore_confirmation_after_runtime_error(
&state.turns,
tool_id,
ownership,
"Confirm",
error,
));
}
Ok(Value::Null)
}
Expand All @@ -188,9 +214,21 @@ pub(crate) async fn reject_tool_execution(
return Err("Tool confirmation session or turn does not match its Peer owner".to_string());
}
let reason = optional_string(request, "reason").unwrap_or_else(|| "User rejected".to_string());
if let Err(error) = state.compatibility.reject_tool(&tool_id, reason).await {
state.turns.restore_confirmation(tool_id, ownership);
return Err(format!("Reject tool failed: {error}"));
if let Err(error) = state
.agent_runtime
.reject_tool(AgentToolRejectionRequest {
tool_id: tool_id.clone(),
reason,
})
.await
{
return Err(restore_confirmation_after_runtime_error(
&state.turns,
tool_id,
ownership,
"Reject",
error,
));
}
Ok(Value::Null)
}
Expand All @@ -199,7 +237,8 @@ pub(crate) async fn reject_tool_execution(
mod tests {
use serde_json::json;

use super::peer_dialog_metadata;
use super::{peer_dialog_metadata, restore_confirmation_after_runtime_error};
use crate::peer_host::state::{PeerTurnKey, PeerTurnTracker};

#[test]
fn peer_metadata_forces_confirmation_and_cannot_claim_acp_transport() {
Expand Down Expand Up @@ -238,6 +277,34 @@ mod tests {
assert_eq!(metadata.get("caller"), Some(&json!("desktop")));
}

#[test]
fn runtime_failure_restores_the_exact_peer_confirmation_claim() {
let turns = PeerTurnTracker::new();
turns.mark_event_stream_ready();
let owner = PeerTurnKey::new("session-1", "turn-1");
turns.register_root(owner.clone()).expect("register turn");
turns
.record_confirmation(&owner, "tool-1".to_string())
.expect("record confirmation");
let claimed = turns
.claim_confirmation("tool-1")
.expect("claim confirmation");

let message = restore_confirmation_after_runtime_error(
&turns,
"tool-1".to_string(),
claimed,
"Confirm",
bitfun_agent_runtime::sdk::RuntimeError::MissingInteractionResponsePort,
);

assert_eq!(
message,
"Confirm tool failed: agent interaction response port is not registered"
);
assert_eq!(turns.claim_confirmation("tool-1"), Some(owner));
}

#[test]
fn peer_metadata_preserves_non_lineage_classification() {
let metadata = peer_dialog_metadata(&json!({
Expand Down
2 changes: 1 addition & 1 deletion src/apps/cli/src/peer_host/commands/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ pub(crate) async fn delete_session(state: &PeerHostState, args: &Value) -> Resul
remote_ssh_host: optional_string(request, "remoteSshHost"),
})
.await
.map_err(|error| format!("Failed to delete session: {error}"))?;
.map_err(|error| format!("Failed to delete session: {}", error.into_message()))?;
Ok(Value::Null)
}

Expand Down
Loading
Loading