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
28 changes: 26 additions & 2 deletions docs/architecture/agent-runtime-services-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,10 +798,20 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊

当前 Runtime SDK 已提供会话创建、列出、删除、恢复、模型/模式更新、类型化转录读取、本地分支、用量生成,以及
精确轮次结算。模型与模式更新只接受会话 ID 和被选择的稳定 ID,
不承载目录、提供方配置、选择策略或宿主 UI 语义。`AgentSessionRestoreRequest/Result` 与
不承载目录、提供方配置、选择策略或宿主 UI 语义。模式 ID 由 Core 对当前有效目录校验;同值更新不刷新活动时间,
有效变更先按会话串行化,再通过与轮次元数据保存、删除共用的规范化物理路径锁持久化,成功后才提交到活动会话。
该锁只保证单进程内同一会话元数据的读改写顺序,不宣称跨进程事务,也不把 metadata/state 等多文件更新声明为崩溃原子提交。
恢复标准主会话时,如果持久化模式已从当前目录移除,
Core 会选择仍可执行的内置回退模式并同步修正元数据;内部子会话的专用模式不走这条迁移规则。
Desktop 的通用会话元数据命令必须显式声明 UI 字段集合,并只在 owner 锁内更新这些字段;Review 状态与未读、关注、标题等
独立写入意图不得通过完整旧快照互相覆盖。
Relay 导入的会话元数据以私有 `pending/complete` 标记区分仅有摘要与完整历史;打开本地会话时先检查并补齐该导入,
再让 Core 恢复模型上下文。普通本地会话只做一次元数据读取且不访问账号或网络;部分导入失败保持可重试并停止本次打开,
不能让 UI 与 Core 分别发布不同的截断历史。
`AgentSessionRestoreRequest/Result` 与
`AgentSessionRestorePort` 归 Agent Runtime SDK,以继续复用 Runtime owner 的完整 `SessionState`;类型化
`SessionTranscript` 归 `runtime-ports`。两者都由 `assembly/core` 注入真实持久化 owner,当前由 CLI/TUI
消费;ACP 的活动会话模式更新也通过窄端口回到同一 Core owner。ACP 为保证模型配置与完整历史来自同一次恢复,继续通过
消费;TUI 与 ACP 的活动会话模式更新也通过窄端口回到同一 Core owner。ACP 为保证模型配置与完整历史来自同一次恢复,继续通过
Core 兼容门面读取协议回放所需的完整轮次,避免为单一协议扩张通用 transcript,也不绕过附件内容所需的独立授权能力。
会话分支请求显式携带可选远程身份;当前本地 provider 对远程身份返回 `NotAvailable`,不据本地路径
推断远程语义。CLI/TUI 的工具确认、拒绝和用户问题回答,以及 ACP 服务端 / Peer Host 的工具确认与拒绝,通过类型化
Expand Down Expand Up @@ -876,6 +886,20 @@ CLI 托管的 ACP 服务端使用 `DeliveryProfile::Acp` 组装一个 Agent Runt
活动会话的模型/模式写入已经通过 SDK 回到 Core owner,
不据此扩张通用 runtime DTO。

`session/load` 在恢复前占用会话 ID,先完成纯参数校验和临时 MCP 建立,再恢复 Core;只有完整历史通知发送成功后才发布
活动 ACP 状态并返回成功。同一 ID 的重叠打开或关闭在产生回放和 MCP 副作用前以可重试临时状态拒绝;恢复后的任一步失败
都会卸载本请求加载的 Core 内存状态并回收临时 MCP,但不删除既有历史。`session/new` 先生成稳定 ID,完成目录校验和
临时 MCP 建立后再以同一 ID 创建 Core 会话;建立过程失败时尝试回收临时 MCP 和本请求创建的 Core 会话。首次落盘若因
回滚失败留下目录,会以类型化残余资源结果进入同一补偿路径,不报告“未创建 Core 会话”。补偿失败会携带会话 ID、
残余资源种类、Core 是否由本请求创建及恢复动作,不伪装成普通输入错误,也不建议 `session/load` 删除既有历史。
成功的 `session/close` 先阻止新轮次,清空已接收队列、取消后台子会话与活动轮次并确认调度排空,再卸载 Core 临时状态、
回收临时 MCP 和连接映射;持久化历史及其存储绑定保留,可由后续 `session/load` 重新打开。
任一步未完成时保留 ACP 会话所有权和持久化历史,返回 `session_close_incomplete`、失败阶段与可重试动作;只有临时 MCP
未回收时才标记具体残余资源,不能沿用 `session/new` 的“本请求创建 Core 会话”语义。
无效请求与会话不存在分别保持协议可识别的参数错误和资源不存在错误,其他后端故障不泄漏为可重试的客户端输入错误。
活动会话占用范围是一个 ACP stdio 进程;当前不宣称同一持久化会话可由多个 ACP 进程并发写入。跨进程共享需要先定义
执行域、权限、冲突和崩溃恢复契约,不在本切片中用临时文件锁提前固化。

继续拥有:

- ACP protocol。
Expand Down
7 changes: 6 additions & 1 deletion docs/architecture/cli-product-line-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop
`exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、本地分支、
用量生成、轮次提交/取消和精确结算;普通创建
DTO 保持 v1 字段集合,固定 ID 冲突返回 `InvalidRequest`。会话模型更新、工具确认/拒绝和用户问题回答也通过
SDK 的窄端口回到 Core owner,ACP 的活动会话模式更新也复用同一窄端口;模型/模式目录和提供方配置仍由产品入口解释。TUI 用量卡片持久化、快照及 Peer Host/ACP 维护
SDK 的窄端口回到 Core owner,TUI 与 ACP 的活动会话模式更新也复用同一窄端口;模型/模式目录和提供方配置仍由产品入口解释。
TUI 模式切换以异步待提交状态调用 Core,期间终端输入、resize 和重绘保持响应,新的对话提交不会消费用户输入;
只有 Core 校验并持久化成功后才更新本地显示,失败时保留原模式并给出可重试提示,同值选择不产生持久化写入。
等待期间可以切换或新建会话;只有原会话的发送继续等待。首次退出请求在持久化成功后自动退出,失败时留在界面提示重试;再次退出允许立即离开,
并明确提示下次恢复以 Core 的持久化模式为准。恢复主会话时,已从当前目录移除的持久化模式由 Core 迁移到可执行回退模式;
TUI 对比恢复前后的会话摘要并显示模式变化,如果同时携带启动输入,只预填而不自动执行,须由用户确认后发送。TUI 用量卡片持久化、快照及 Peer Host/ACP 维护
等 SDK v1 缺口由一个 Core 兼容门面转发给原 owner。
- Agentic Event Queue 仍是唯一事件 owner;TUI、`exec` 与 Peer Host 使用独立广播订阅,不互相消费事件。
- 有界旧队列只承担兼容存储;达到容量时不得抑制广播。CLI 保持一个后台 drain,订阅方一旦报告 lag/closed,
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/product-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,11 +327,11 @@ flowchart LR
| 产品形态 | 当前扩展能力 | 入口行为 |
|---|---|---|
| Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;“外部 AI 应用”设置已消费 OpenCode Command、standalone Tool 和 Subagent 的统一来源快照、审批、冲突选择与诊断;Skills 场景和设置列表显示已发现 Skill 的生态来源与覆盖结果 | 仅本地执行域支持当前三条可执行纵向切片;Skill 仍使用独立 Registry,不据来源展示宣称已并入外部来源协调器。受管 package plugin 仍只有静态预览,不能据设置页接入宣称完整 OpenCode 插件运行时 |
| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并在通用 `/tools` 与 `/agents` 入口按文字分组消费与 Desktop 相同的审批、冲突和刷新状态;`/agents` 同时承载主 Agent 切换、Subagent 管理和“外部 AI 应用”,不再注册 `/subagents` 或 `external-*` 平行命令。活动 turn 期间仍可查看和管理,只有主 Agent 切换被禁用。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;非交互入口和 Remote 未接入时不得借本机 TUI 路径代执行。本地 Agent 与 Peer Host 路径选择 `DeliveryProfile::Cli` 并消费同一 Runtime Parts/SDK;主会话的恢复、转录、本地分支、用量报告生成与精确结算走 SDK,远程分支明确不支持。TUI 用量卡片持久化、快照及 Peer Host/ACP 持久化维护缺口由单一 Core 兼容门面转发本切片不改变扩展执行边界 |
| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并在通用 `/tools` 与 `/agents` 入口按文字分组消费与 Desktop 相同的审批、冲突和刷新状态;`/agents` 同时承载主 Agent 切换、Subagent 管理和“外部 AI 应用”,不再注册 `/subagents` 或 `external-*` 平行命令。活动 turn 期间仍可查看和管理,只有主 Agent 切换被禁用。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;非交互入口和 Remote 未接入时不得借本机 TUI 路径代执行。本地 Agent 与 Peer Host 路径选择 `DeliveryProfile::Cli` 并消费同一 Runtime Parts/SDK;主会话的恢复、转录、本地分支、用量报告生成、模式更新与精确结算走 SDK,远程分支明确不支持。TUI 模式切换异步持久化,期间保持输入和 resize 响应,只在成功后提交本地状态,失败保留原选择;用户可切换会话,退出有等待完成与再次强制退出两级路径。失效的主会话模式由 Core 恢复为可执行模式,TUI 显示迁移并阻止启动输入自动执行。用量卡片持久化、快照及 Peer Host/ACP 持久化维护缺口仍由单一 Core 兼容门面转发本切片不改变扩展执行边界 |
| HarmonyOS PC 原生 CLI/TUI | 未来平台目标,当前未实现 | 目标、问题和风险见平台规约;具体适配另立专题,HAP、手机 Remote App 与远端代执行均不替代 |
| HarmonyOS PC GUI | 完整 HarmonyOS PC 支持的另一目标形态,当前未实现 | 与 CLI/TUI 共享稳定能力和 Runtime 语义,但独立设计宿主、界面与发布验证;Web、Remote 或现有 Tauri Desktop 均不能替代 |
| HarmonyOS 手机 Remote App | `src/apps/mobile/harmonyos` 是 phone-only ArkTS 远程入口,不持有本地 Rust Agent Runtime | 保持当前能力并按移动端专题独立演进;本轮不提前设计移动 Runtime/TUI/GUI,也不能据此宣称 HarmonyOS PC 本地能力 |
| ACP | CLI 托管的服务端仍以 `bitfun-core/product-full` 作为兼容执行层 | 入口已选择 `DeliveryProfile::Acp` 并消费 Runtime Parts;组装层在入队前原子拒绝忙碌会话,不改变其他产品入口的排队行为;活动会话模型与模式写入走 SDK。会话恢复及完整历史、模型/模式目录与配置读取、MCP、客户端与协议生命周期仍留在现有 Core/ACP 归属,不据此宣称完整解耦 |
| ACP | CLI 托管的服务端仍以 `bitfun-core/product-full` 作为兼容执行层 | 入口已选择 `DeliveryProfile::Acp` 并消费 Runtime Parts;组装层在入队前原子拒绝忙碌会话,不改变其他产品入口的排队行为;活动会话模型与模式写入走 SDK。`session/load` 先校验和建立临时 MCP,再恢复 Core,在历史回放成功后才发布活动状态;失败会卸载本次内存状态而不删除历史。同 ID 的重叠打开/关闭被明确拒绝。成功的 `session/close` 阻止新轮次、排空队列和后台子会话,再卸载临时 Core 状态并回收 MCP 与连接;失败保留会话所有权和历史,返回可重试阶段。持久化历史仍可重新加载。完整历史、模型/模式目录与配置读取仍留在现有 Core/ACP 归属,不据此宣称完整解耦 |
| Server / Remote | 当前生产路由没有插件状态消费闭环;Remote 插件执行未实现 | 不在本地替远端项目发现、准备或执行插件;未接入时返回明确不支持 |
| Web / Mobile Web | 依赖现有后端入口,不持有插件执行单元 | 对应 profile 当前为空计划或未接入生产,不能据枚举值宣称独立产品能力 |
| SDK | 仅有 preview 门面、空 profile 计划和测试替身 | 不牵引 `product-full`、具体服务管理器或插件 host ABI;未满足独立嵌入验证前不宣称可发布 |
Expand Down
93 changes: 85 additions & 8 deletions src/apps/cli/src/agent/runtime_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ use tokio::sync::Mutex;
use bitfun_agent_runtime::sdk::{
AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest,
AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest,
AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, AgentSessionUsageRequest,
AgentToolConfirmationRequest, AgentToolRejectionRequest, AgentTurnCancellationRequest,
AgentTurnSettlementRequest, AgentUserAnswersRequest, PortErrorKind, RuntimeError,
SessionTranscript, SessionTranscriptRequest, SessionUsageReport,
AgentSessionModeUpdateRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest,
AgentSessionUsageRequest, AgentToolConfirmationRequest, AgentToolRejectionRequest,
AgentTurnCancellationRequest, AgentTurnSettlementRequest, AgentUserAnswersRequest,
PortErrorKind, RuntimeError, SessionTranscript, SessionTranscriptRequest, SessionUsageReport,
};
use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY;
use bitfun_runtime_ports::{AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy};
Expand All @@ -41,6 +41,31 @@ fn validated_session_summary(
})
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct SessionModeMigrationNotice {
pub(crate) previous_mode_id: String,
pub(crate) restored_mode_id: String,
}

impl SessionModeMigrationNotice {
pub(crate) fn user_message(&self) -> String {
format!(
"Session mode \"{}\" is unavailable. This session was restored with \"{}\". Review the mode before continuing.",
self.previous_mode_id, self.restored_mode_id
)
}
}

fn session_mode_migration_notice(
previous: &AgentSessionSummary,
restored: &AgentSessionSummary,
) -> Option<SessionModeMigrationNotice> {
(previous.agent_type != restored.agent_type).then(|| SessionModeMigrationNotice {
previous_mode_id: previous.agent_type.clone(),
restored_mode_id: restored.agent_type.clone(),
})
}

/// CLI-owned client for the portable Agent Runtime SDK.
/// Stateless regarding agent_type; callers pass it per call.
pub(crate) struct CliAgentRuntimeClient {
Expand Down Expand Up @@ -109,14 +134,19 @@ impl CliAgentRuntimeClient {
pub(crate) async fn restore_session_in_current_workspace(
&self,
session_id: &str,
) -> Result<(AgentSessionSummary, PathBuf)> {
) -> Result<(
AgentSessionSummary,
PathBuf,
Option<SessionModeMigrationNotice>,
)> {
tracing::info!("Restoring session: {}", session_id);

let effective_workspace = self.current_workspace_path();
let sessions = self
.list_sessions_in_workspace(&effective_workspace)
.await?;
validated_session_summary(&sessions, session_id, &effective_workspace)?;
let previous_summary =
validated_session_summary(&sessions, session_id, &effective_workspace)?;

let restored = self
.runtime
Expand All @@ -139,7 +169,8 @@ impl CliAgentRuntimeClient {
*session_id_guard = Some(session_id.to_string());
*turn_id_guard = None;

Ok((restored.session, effective_workspace))
let migration_notice = session_mode_migration_notice(&previous_summary, &restored.session);
Ok((restored.session, effective_workspace, migration_notice))
}

pub(crate) async fn delete_session(&self, session_id: &str) -> Result<()> {
Expand Down Expand Up @@ -178,6 +209,16 @@ impl CliAgentRuntimeClient {
.map_err(|error| anyhow::anyhow!(error.into_message()))
}

pub(crate) async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> Result<()> {
self.runtime
.update_session_mode(AgentSessionModeUpdateRequest {
session_id: session_id.to_string(),
mode_id: mode_id.to_string(),
})
.await
.map_err(|error| anyhow::anyhow!(error.into_message()))
}

pub(crate) async fn branch_session_at_latest_turn(
&self,
source_session_id: &str,
Expand Down Expand Up @@ -552,7 +593,7 @@ mod tests {

use bitfun_runtime_ports::AgentSessionSummary;

use super::validated_session_summary;
use super::{session_mode_migration_notice, validated_session_summary};

#[test]
fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() {
Expand All @@ -565,6 +606,20 @@ mod tests {
assert!(!source.contains(&compatibility_update));
}

#[test]
fn mode_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() {
let source = include_str!("runtime_client.rs").replace("\r\n", "\n");
let runtime_update = ["self.runtime", "\n .update_session_mode"].concat();
let compatibility_update = [
"self.compatibility",
"\n .update_session_agent_type",
]
.concat();

assert!(source.contains(&runtime_update));
assert!(!source.contains(&compatibility_update));
}

fn session_summary(session_id: &str) -> AgentSessionSummary {
AgentSessionSummary {
session_id: session_id.to_string(),
Expand Down Expand Up @@ -605,4 +660,26 @@ mod tests {
assert!(message.contains("session-from-another-workspace"));
assert!(message.contains("D:/workspace/current"));
}

#[test]
fn restore_reports_a_cli_local_notice_when_core_migrates_the_mode() {
let previous = AgentSessionSummary {
agent_type: "removed-mode".to_string(),
..session_summary("mode-migration")
};
let restored = session_summary("mode-migration");

let notice = session_mode_migration_notice(&previous, &restored)
.expect("changed mode should be reported to the TUI");

assert_eq!(notice.previous_mode_id, "removed-mode");
assert_eq!(notice.restored_mode_id, "agentic");
}

#[test]
fn restore_does_not_report_a_notice_when_the_mode_is_unchanged() {
let summary = session_summary("unchanged-mode");

assert!(session_mode_migration_notice(&summary, &summary).is_none());
}
}
Loading
Loading