diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index bb40b06759..25c5fc4730 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -708,11 +708,12 @@ pub struct HarnessExecutionContext { `DeliveryProfile::Acp` 构造 `ProductRuntimeParts`。Desktop 主交互直接从现有协调器和调度器端口构造窄口径 Agent Runtime SDK 门面,不注册未实现的 `RuntimeServices` 能力,也不宣称完整 Desktop profile 可用。CLI 通过 一个调用级上下文把 Agent Runtime SDK、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 -交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除/恢复、会话模型更新、类型化转录读取、轮次提交/取消,以及 CLI/TUI -的工具确认、拒绝和用户问题回答;固定 ID 创建使用独立的 `create_session_with_id` 方法,普通创建 DTO 保持 v1 +交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除/恢复、会话模型更新、类型化转录读取、本地分支、用量生成、 +轮次提交/取消与精确结算,以及 CLI/TUI 的工具确认、拒绝和用户问题回答;固定 ID 创建使用独立的 +`create_session_with_id` 方法,普通创建 DTO 保持 v1 字段集合。未实现该能力的提供方返回类型化不支持错误;实现成功时 Runtime 必须校验返回 ID 与请求完全一致,不能 替换为自动生成的 ID。`SessionSelector::Create` 仍保持自动生成。Peer Host 通过同一 SDK 处理对话提交、精确取消、 -工具确认、拒绝和会话模型更新。SDK v1 尚未覆盖的分支、用量、快照和持久化维护等操作仍由 +工具确认、拒绝和会话模型更新。TUI 用量卡片持久化、快照及 Peer Host/ACP 维护等产品操作仍由 `assembly/core` 的单一兼容门面转发。 `doctor` 与 `health` 校验真实组装结果及必需注册完整性; Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊断不等于对这些外部服务做实时探活。 @@ -795,15 +796,17 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊 | ACP | ACP 协议、客户端生命周期、远端探测 | 外部智能体/工具能力、环境事实、权限桥接 | | Web UI / mobile web | UI 状态、hydration、配对、会话展示、插件状态视图 | 接口/传输 DTO、运行时事件事实、能力服务读模型 | -当前 Runtime SDK 已提供会话创建、列出、删除、恢复、模型更新和类型化转录读取。模型更新只接受会话 ID 与模型 ID, +当前 Runtime SDK 已提供会话创建、列出、删除、恢复、模型更新、类型化转录读取、本地分支、用量生成,以及 +精确轮次结算。模型更新只接受会话 ID 与模型 ID, 不承载模型目录、模式、提供方配置或宿主 UI 选择语义。`AgentSessionRestoreRequest/Result` 与 `AgentSessionRestorePort` 归 Agent Runtime SDK,以继续复用 Runtime owner 的完整 `SessionState`;类型化 `SessionTranscript` 归 `runtime-ports`。两者都由 `assembly/core` 注入真实持久化 owner,当前由 CLI/TUI 消费;ACP 为保证模型配置与完整历史来自同一次恢复,继续通过 Core 兼容门面读取协议回放所需的完整轮次,避免为单一协议 -扩张通用 transcript。CLI/TUI 的工具确认、拒绝和用户问题回答,以及 ACP 服务端 / Peer Host 的工具确认与拒绝,通过类型化 +扩张通用 transcript。会话分支请求显式携带可选远程身份;当前本地 provider 对远程身份返回 `NotAvailable`,不据本地路径 +推断远程语义。CLI/TUI 的工具确认、拒绝和用户问题回答,以及 ACP 服务端 / Peer Host 的工具确认与拒绝,通过类型化 `AgentInteractionResponsePort` 回到 Core 的工具管线或用户输入 owner,不改变审批策略或交互所有权。 `CoreAgentRuntimeCompatibility` 仍承载未迁移的 -持久化、分支、用量和快照等操作;不能据此把整个兼容门面一次性删除,也不能把这些 +TUI 用量卡片持久化、快照及 Peer Host/ACP 维护等操作;不能据此把整个兼容门面一次性删除,也不能把这些 操作提前声明为跨宿主稳定接口。旧固定 ID 创建方法仅作为标记废弃的源码迁移转发保留一个兼容周期,生产 CLI 调用已经迁移到 Runtime SDK;该转发不得重新成为新调用入口。 @@ -1064,9 +1067,9 @@ Product 测试: - `bitfun-core` 可继续作为 `product-full` 兼容门面,避免迁移期间一次性重写入口。 - CLI 已以 `DeliveryProfile::Cli` 构造真实 Runtime Parts 和 SDK runtime;本地 Agent 入口、会话、用量和 Peer Host 共用一个调用级上下文与广播事件源,审批策略不再写回全局配置。Peer Host 通过 SDK 提交/精确取消 - turn、更新会话模型并处理工具确认/拒绝,通过单一 Core 兼容门面处理会话与快照缺口,不再构造独立调度器、持久化 manager 或事件队列; + turn、更新会话模型并处理工具确认/拒绝,通过单一 Core 兼容门面处理快照及持久化维护缺口,不再构造独立调度器、持久化 manager 或事件队列; wire schema、Relay ACK/重放和重连协议未在该切换中扩张。 -- CLI 通过 SDK 处理已覆盖的 session/turn/cancel 操作,并通过一个 Core 兼容门面处理 SDK v1 缺口; +- CLI 主会话客户端通过 SDK 处理 session、transcript、fork、usage report、turn、cancel 与 settlement;TUI 用量卡片持久化和其他 SDK v1 缺口仍通过一个 Core 兼容门面处理; 该门面复用现有 owner,不建立第二套状态或事件 schema。 - CLI 托管的 ACP 服务端已以 `DeliveryProfile::Acp` 构造真实 Runtime Parts;会话创建/列举、轮次、取消、会话模型更新、工具确认/拒绝和 Agent 事件订阅复用同一 SDK 语义,ACP stdio、连接与协议投影保持不变。Agentic Event Queue 仍是唯一事件 owner; diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index d647538c03..fac00bc34f 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -94,17 +94,18 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop - CLI 本地 Agent 入口以类型化 `RuntimeServices` 调用 `ProductAssembler`,选择 `DeliveryProfile::Cli`, 并把 `ProductRuntimeParts`、Agent Runtime SDK、事件源和调用级审批策略保存在一个 `CliRuntimeContext` 中。 - TUI、`exec`、会话、用量和交互模式下的 Peer Host 复用同一上下文。SDK 已承接会话创建(包括 - `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除、轮次提交和取消;普通创建 + `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、本地分支、 + 用量生成、轮次提交/取消和精确结算;普通创建 DTO 保持 v1 字段集合,固定 ID 冲突返回 `InvalidRequest`。会话模型更新、工具确认/拒绝和用户问题回答也通过 - SDK 的窄端口回到 Core owner;模型目录、模式和提供方配置仍由产品入口解释。SDK v1 尚未覆盖的恢复视图、 - 消息、分支、用量和快照由一个 Core - 兼容门面转发给原 owner。 + SDK 的窄端口回到 Core owner;模型目录、模式和提供方配置仍由产品入口解释。TUI 用量卡片持久化、快照及 Peer Host/ACP 维护 + 等 SDK v1 缺口由一个 Core 兼容门面转发给原 owner。 - Agentic Event Queue 仍是唯一事件 owner;TUI、`exec` 与 Peer Host 使用独立广播订阅,不互相消费事件。 - 有界旧队列只承担兼容存储;达到容量时不得抑制广播。CLI 保持一个后台 drain,订阅方一旦报告 lag/closed, 必须取消活动 turn 并显式失败,不能在状态不完整时继续报告成功。 - 会话 ID 在进入存储路径前统一校验;运行时索引同时绑定 ID 与规范化存储路径,并以待提交 claim 计数保护 并发恢复。同一进程不能把另一个工作区中已加载的同 ID 会话当作当前会话,单个失败恢复也不能释放其他 - 同路径恢复仍在使用的绑定;已加载会话只校验身份,不通过完整 restore 重置活动状态。删除路径不能通过 + 同路径恢复仍在使用的绑定;主会话提交不做前置完整 restore,只有 Runtime owner 返回结构化 `NotFound` 时才恢复或 + 按原 ID 重建并重试一次,其他资源缺失与后端错误原样失败。删除路径不能通过 相对路径、绝对路径或分隔符越出 sessions 根目录。 - TUI 终端句柄由恢复守卫持有;初始化中途失败、正常返回、错误返回或 panic 展开都会尽力退出 alternate screen、 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 启动页进程冒烟测试已验证 resize 后仍可交互、 @@ -140,9 +141,8 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop | 缺口 | 影响 | 本设计的处理 | |---|---|---| -| CLI 已消费 Runtime Parts,但部分执行与持久化操作仍由 `bitfun-core/product-full` 兼容 owner 提供 | SDK 尚不能独立覆盖完整产品会话,过早删除兼容路径会改变行为 | 仅在稳定端口、真实嵌入方和行为等价测试齐备后迁移 owner;兼容门面保持单一且不扩展成第二套 Runtime。 | +| CLI 主会话客户端已仅消费 Runtime SDK,但快照及 Peer Host/ACP 的持久化维护仍由 `bitfun-core/product-full` 兼容 owner 提供 | SDK 尚不能独立覆盖全部宿主维护能力,过早删除兼容路径会改变行为 | 仅在稳定端口、真实嵌入方和行为等价测试齐备后迁移 owner;兼容门面保持单一且不扩展成第二套 Runtime。 | | TUI 编排、输入、命令、副作用和渲染仍有大文件聚集 | 交互回归难以隔离,终端状态与业务状态容易耦合 | 在现有模块上增量收敛为事件、状态归约、副作用和渲染四个边界,不重写全部 TUI。 | -| `ShortcutsConfig` 已加载,但主要按键分发仍硬编码;Slash、Palette、帮助和执行来自不同位置 | 配置可能保存却不生效,展示和真实行为会漂移 | 在 CLI 宿主建立单一 action registry 和上下文键位解析;不借机重写 renderer。 | | CLI 配置只覆盖入口本地选项,缺少统一层级、来源解释和兼容导入 | 用户无法安全复用其他 CLI 资产,也难以解释最终配置来源 | 建立 BitFun Canonical Config、持续来源视图和可选的显式导入报告。 | | OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 第一条闭环只完成一个无外部依赖的契约样例;取得真实 `execute` 并注册到 Tool Runtime 后才显示可用。 | | 当前 CLI 使用 `product-full`,OHOS target 图包含多组未验证的平台依赖 | 不能据依赖可解析、`hdc shell` 或移动 Remote App 推导 PC 本地 CLI/TUI 可用 | 问题与风险统一记录在平台规约;具体工作另立专题,HAP 不作为替代。 | @@ -212,7 +212,7 @@ CLI-P1 应保证: |---|---| | `text` | 最终助手文本写 stdout;进度、思考、工具状态、日志和诊断写 stderr。显式 `--output-patch -` 是用户选择的额外 stdout 内容。 | | `json` | stdout 只写一个结果对象,包含 `type=result`、`subtype`、`is_error`、`result`,以及已建立时的 `session_id`/`turn_id`、本 turn 累计 `usage` 和可用的 `patch`。 | -| `stream-json` | 每行直接序列化一个现有 `AgenticEventEnvelope`;不增加 `schema_version`、`sequence` 或第二套 CLI 事件 taxonomy。 | +| `stream-json` | 每行直接序列化一个现有 `AgenticEventEnvelope`;不增加 `schema_version`、`sequence` 或第二套 CLI 事件 taxonomy。成功的 `DialogTurnCompleted` 只在精确结算和 Patch 生成完成后发布;结算或 Patch 失败改为发布 `SystemError` 并以非零状态退出,避免消费者提前确认成功。 | | 事件范围 | 只输出本次 session/turn 的事件,以及与其明确关联的 subagent link/tool 事件;同 session 的其他并发 turn 不得混入。 | | Patch | `json` 可把 `--output-patch -` 放入最终对象;`stream-json` 要求显式文件路径。Patch 是写出显式 Patch 文件前捕获的仓库 `HEAD` 相对工作区快照,包含 staged、unstaged、untracked 及命令启动前已有改动,不包含输出 artifact 本身,也不表达改动归因。 | | 权限 | 非交互默认拒绝并返回权限失败;`--auto` 只改变当前提交策略,不修改持久化配置。 | @@ -269,7 +269,8 @@ TUI renderer、实验性接口和完整外部 Server 协议按总矩阵明确降 CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Runtime SDK 的类型化端口完成;TUI 只把 `SessionTranscript` 投影为本地渲染状态,不再消费 Core `Message`。Peer Host 的对话提交、精确取消、会话模型更新和 -工具确认/拒绝通过 SDK 回到 Core owner;账户同步、会话分支、用量、快照及其他未覆盖操作仍使用经过审查的 Core +工具确认/拒绝通过 SDK 回到 Core owner;本地会话分支通过显式本地范围的 SDK 端口完成,携带远程身份的请求返回类型化 +`NotAvailable`,本轮不扩展远程分支。账户同步、TUI 用量卡片持久化、快照及其他未覆盖操作仍使用经过审查的 Core compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 这是一条垂直链路迁移,不是删除整个兼容门面或新建 CLI 专用服务层。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index ad16b00f8b..13f46a8e19 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -326,7 +326,7 @@ flowchart LR | 产品形态 | 当前扩展能力 | 入口行为 | |---|---|---| | Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;“外部 AI 应用”设置已消费 OpenCode Command、standalone Tool 和 Subagent 的统一来源快照、审批、冲突选择与诊断 | 仅本地执行域支持当前三条纵向切片;受管 package plugin 仍只有静态预览,不能据设置页接入宣称完整 OpenCode 插件运行时 | -| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并通过 `/external-tools`、`/external-agents` 消费与 Desktop 相同的审批、冲突和刷新状态 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;非交互入口和 Remote 未接入时不得借本机 TUI 路径代执行 | +| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并通过 `/external-tools`、`/external-agents` 消费与 Desktop 相同的审批、冲突和刷新状态 | 已批准的 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 兼容门面转发;本切片不改变扩展执行边界 | | 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 本地能力 | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index d048b4de12..1017d74310 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -59,9 +59,10 @@ assembly → apps。embedded 的 bind、静态 fallback 和任务生命周期移 CLI 是首个入口迁移对象,因为它已有独立产品诉求、显式设计和最小 CI 命令。 当前纵向切片已经完成:入口只提交一次 `DeliveryProfile::Cli`,通过现有 `ProductAssembler` 获得计划、服务可用性、 -Harness 和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。会话创建(包括 -`exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/ -删除、会话模型更新、轮次提交和取消走 Agent Runtime SDK;SDK v1 缺口集中在一个 Core 兼容门面。Agentic Event Queue 仍是唯一 +Harness 和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。主会话客户端的创建(包括 +`exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、本地分支、用量生成、 +会话模型更新、轮次提交/取消和精确轮次结算均走 Agent Runtime SDK;TUI 用量卡片持久化、快照、Peer Host/ACP 的维护等 +未迁移能力继续集中在一个 Core 兼容门面。Agentic Event Queue 仍是唯一 owner,各入口只建立独立广播订阅,有界兼容队列满载不再阻断广播。TUI 与 Exec 审批均为调用级策略,不写全局 配置;CLI 本地路径不获取具体 PersistenceManager。交互、执行和管理入口分别控制 Peer Host/MCP 生命周期,管理查询不启动 这两类外部服务。结构化输出复用现有 Agentic envelope;会话 ID 与 @@ -72,7 +73,7 @@ Peer Host 的 Runtime 接入和跨 Relay/Desktop/Web 的协议切换保持独立 下一步按独立纵向切片推进: -1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小分支、用量、快照和持久化维护兼容面;会话模型更新已迁移,模型目录与配置仍保留在产品入口。 +1. 以真实调用方和行为等价测试逐项缩小快照及 Peer Host/ACP 持久化维护兼容面;远程分支另行定义身份和存储语义,模型目录与配置仍保留在产品入口。 2. 继续迁移 ACP 尚未接入 SDK 的持久化历史、模型目录/模式和 MCP 操作;ACP stdio 与协议投影生命周期保留在接口入口。 3. 继续按真实故障样例拆分 TUI 副作用边界,不以大规模重写替代现有回归保护。 diff --git a/docs/plans/product-architecture-evolution-plan.md b/docs/plans/product-architecture-evolution-plan.md index 8c067d3a3d..1fe9a5789a 100644 --- a/docs/plans/product-architecture-evolution-plan.md +++ b/docs/plans/product-architecture-evolution-plan.md @@ -7,8 +7,8 @@ [OpenCode 兼容](opencode-extension-compatibility-plan.md);能力 Provider、SDK 和外部宿主双向集成边界见 [能力装配与宿主集成](../architecture/extensions/capability-runtime-integration-design.md)。专项文档不能用自己的阶段编号扩大本计划范围。 -本轮对照的上游基线为 `da027e2e8`(2026-07-18);本文所在提交记录 -本轮实现事实。后续事实变化必须随代码显式更新,只有代码、入口消费和对应验证同时成立的项目才标记为完成。 +本文所在提交记录本轮实现事实。后续事实变化必须随代码显式更新,只有代码、入口消费和对应验证同时成立的项目才标记为完成; +不在长期计划中固定易失效的上游提交号。 ## 1. 裁决原则 @@ -28,7 +28,7 @@ | 范围 | 当前事实 | 近期结论 | |---|---|---| | 编译依赖 | `assembly/core -> apps/relay-server` 已移除;通用检查覆盖 normal/build/dev 依赖及 optional/target 变体 | 后续反向依赖和未知 crate 层级直接失败 | -| 公开面 | `bitfun-core` 仍有迁移期 re-export;CLI 只完成部分 Runtime SDK 接入 | 按入口逐项迁移,不做全仓逐 symbol 台账或批量删除 | +| 公开面 | `bitfun-core` 仍有迁移期 re-export;CLI 主会话客户端已仅消费 Runtime SDK,其他产品入口仍保留兼容路径 | 按入口逐项迁移,不做全仓逐 symbol 台账或批量删除 | | CLI/TUI | `ShortcutsConfig` 已加载但真实按键分发仍硬编码;Slash、Palette、帮助和执行不是同一来源 | 先统一宿主 action 声明和键位解析,不重写 renderer | | OpenCode | Prompt Command、受支持的单文件 JavaScript Tool 和 Subagent 安全子集已分别通过能力专属 provider 接入;受管 package plugin 仍只有静态预览 | 先收敛三条已交付路径的诊断、运行时提示和配置失败语义,再按真实阻塞样例评估下一能力切片 | | HarmonyOS PC | 未来平台目标,当前未实现 | 目标、问题、风险和旧设计闭环见平台规约;具体工作后续分别立项 | @@ -101,10 +101,10 @@ HarmonyOS 手机 Remote App 不在该平台执行范围内。 ## 7. 工作流五:入口逐项迁移 -- CLI:会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、轮次 - 提交/取消、活动会话模型更新和 TUI 工具确认/拒绝/用户问题回答已由真实入口消费 Runtime SDK;交互模式下的 Peer Host - 也通过同一 SDK 提交/精确取消 turn、更新活动会话模型并处理工具确认/拒绝。分支、用量、快照和持久化维护等未覆盖操作 - 继续由现有单一兼容路径转发。 +- CLI:主会话客户端的会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、 + 类型化转录、本地分支、用量生成、轮次提交/取消与精确结算、活动会话模型更新和 TUI 工具确认/拒绝/用户问题回答 + 已由真实入口消费 Runtime SDK;交互模式下的 Peer Host 也通过同一 SDK 提交/精确取消 turn、更新活动会话模型并处理工具 + 确认/拒绝。远程分支保持不支持;TUI 用量卡片持久化、快照和 Peer Host/ACP 维护等产品操作继续由现有单一兼容路径转发。 - ACP:CLI 托管服务端的会话创建/列举、轮次提交/取消、活动会话模型更新、工具确认/拒绝、用户问题回答和事件订阅 已复用同一 SDK 语义;ACP stdio、连接、权限 RPC 与通知生命周期仍留在接口入口。 - Desktop:主界面轮次提交/取消、活动会话模型更新、工具确认/拒绝和用户问题回答已通过现有协调器与调度器端口构造的窄口径 SDK 门面; diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index 7ce5b44063..f43aec75a2 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -1,43 +1,6 @@ -/// Agent integration module +/// CLI/TUI agent integration. /// -/// Wraps interaction with bitfun-core's Agentic system. -/// The Agent trait provides a thin adapter over ConversationCoordinator. -/// Event consumption is done externally (in the chat/exec mode main loops). +/// Session operations use the shared Agent Runtime SDK. Event consumption +/// remains in the chat and exec mode loops. pub(crate) mod agentic_system; -pub(crate) mod core_adapter; - -use anyhow::Result; - -/// Agent interface — thin wrapper over core's ConversationCoordinator. -/// Agent is stateless regarding agent_type; callers pass it per-call. -#[async_trait::async_trait] -pub(crate) trait Agent: Send + Sync { - /// Ensure a core session exists, return session_id - async fn ensure_session(&self, agent_type: &str) -> Result; - - /// Send a message to start a new dialog turn. - /// Returns the turn_id. Events are observed through the runtime event source. - async fn send_message(&self, message: String, agent_type: &str) -> Result; - - /// Cancel the current dialog turn (if any) - async fn cancel_current_turn(&self) -> Result<()>; - - /// Create a brand-new session (ignoring any existing session) - async fn create_new_session(&self, agent_type: &str) -> Result; - - /// Restore an existing session from persistence - async fn restore_session(&self, session_id: &str) -> Result<()>; - - /// Confirm tool execution (allow once) - async fn confirm_tool( - &self, - tool_id: &str, - updated_input: Option, - ) -> Result<()>; - - /// Reject tool execution - async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()>; - - /// Submit answers for AskUserQuestion tool - async fn submit_user_answers(&self, tool_id: &str, answers: serde_json::Value) -> Result<()>; -} +pub(crate) mod runtime_client; diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/runtime_client.rs similarity index 83% rename from src/apps/cli/src/agent/core_adapter.rs rename to src/apps/cli/src/agent/runtime_client.rs index 740353c562..fb36110c2a 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -1,6 +1,7 @@ -//! Core Agent adapter +//! CLI/TUI Agent Runtime SDK client. //! -//! Adapts bitfun-core's Agentic system to CLI's Agent interface. +//! Keeps CLI session state while product operations remain behind portable +//! Runtime SDK ports. //! Event consumption is NOT done here — it's done in the chat/exec mode main loops. use anyhow::Result; @@ -8,18 +9,15 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use tokio::sync::Mutex; -use super::Agent; use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, - AgentSessionListRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, + AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, + AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, AgentSessionUsageRequest, AgentToolConfirmationRequest, AgentToolRejectionRequest, AgentTurnCancellationRequest, - AgentUserAnswersRequest, SessionTranscript, SessionTranscriptRequest, + AgentTurnSettlementRequest, AgentUserAnswersRequest, PortErrorKind, RuntimeError, + SessionTranscript, SessionTranscriptRequest, SessionUsageReport, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; -use bitfun_core::agentic::persistence::session_branch::SessionBranchResult; -use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility; -use bitfun_core::service::session::DialogTurnData; -use bitfun_core::service::session_usage::{SessionUsageReport, SessionUsageReportRequest}; use bitfun_runtime_ports::{AgentSessionSummary, AgentSubmissionSource, DialogSubmissionPolicy}; use crate::runtime::approval::CliApprovalPolicy; @@ -43,11 +41,10 @@ fn validated_session_summary( }) } -/// Core-based Agent implementation. -/// Stateless regarding agent_type — callers pass it per-call. -pub(crate) struct CoreAgentAdapter { +/// CLI-owned client for the portable Agent Runtime SDK. +/// Stateless regarding agent_type; callers pass it per call. +pub(crate) struct CliAgentRuntimeClient { runtime: AgentRuntime, - compatibility: CoreAgentRuntimeCompatibility, event_source: CliAgentEventSource, approval_policy: CliApprovalPolicy, workspace_path: Arc>>, @@ -57,11 +54,10 @@ pub(crate) struct CoreAgentAdapter { current_turn_id: Arc>>, } -impl CoreAgentAdapter { +impl CliAgentRuntimeClient { pub(crate) fn new(runtime: &CliRuntimeContext, workspace_path: Option) -> Self { Self { runtime: runtime.agent_runtime().clone(), - compatibility: runtime.compatibility().clone(), event_source: runtime.agent_events().clone(), approval_policy: runtime.approval_policy(), workspace_path: Arc::new(RwLock::new(workspace_path)), @@ -185,45 +181,41 @@ impl CoreAgentAdapter { pub(crate) async fn branch_session_at_latest_turn( &self, source_session_id: &str, - ) -> Result { - self.compatibility - .branch_session_at_latest_turn(&self.workspace_path_buf(), source_session_id) + ) -> Result { + self.runtime + .fork_session(AgentSessionForkRequest { + workspace_path: self.workspace_path_string(), + source_session_id: source_session_id.to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) .await - .map_err(Into::into) + .map_err(|error| anyhow::anyhow!(error.into_message())) } pub(crate) async fn generate_session_usage_report( &self, - request: SessionUsageReportRequest, + request: AgentSessionUsageRequest, ) -> Result { - self.compatibility - .generate_session_usage_report(request) + self.runtime + .generate_session_usage(request) .await - .map_err(Into::into) + .map_err(|error| anyhow::anyhow!(error.into_message())) } - pub(crate) async fn append_completed_local_command_turn( + pub(crate) async fn wait_for_turn_settlement( &self, session_id: &str, - content: String, - turn_id: Option, - timestamp_ms: Option, - metadata: Option, - ) -> Result { - self.compatibility - .append_completed_local_command_turn( - session_id, - content, - turn_id, - timestamp_ms, - metadata, - ) + turn_id: &str, + wait_timeout_ms: u64, + ) -> std::result::Result<(), RuntimeError> { + self.runtime + .wait_for_turn_settlement(AgentTurnSettlementRequest { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + wait_timeout_ms, + }) .await - .map_err(Into::into) - } - - pub(crate) fn is_turn_processing(&self, session_id: &str, turn_id: &str) -> bool { - self.compatibility.is_turn_processing(session_id, turn_id) } fn build_default_session_name() -> String { @@ -233,11 +225,11 @@ impl CoreAgentAdapter { ) } - fn is_session_not_found_error(error_msg: &str) -> bool { - let msg = error_msg.to_lowercase(); - msg.contains("session not found") - || msg.contains("session does not exist") - || msg.contains("not found") + fn is_session_not_found_error(error: &RuntimeError) -> bool { + matches!( + error, + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::NotFound + ) } async fn recreate_session_with_id(&self, session_id: &str, agent_type: &str) -> Result<()> { @@ -281,13 +273,6 @@ impl CoreAgentAdapter { async fn ensure_backend_session_alive(&self, session_id: &str, agent_type: &str) -> Result<()> { let workspace = self.workspace_path_buf(); - if self - .compatibility - .is_session_loaded(&workspace, session_id) - .await? - { - return Ok(()); - } match self .runtime .restore_session(AgentSessionRestoreRequest { @@ -303,8 +288,9 @@ impl CoreAgentAdapter { Ok(()) } Err(error) => { + let session_not_found = Self::is_session_not_found_error(&error); let message = error.into_message(); - if Self::is_session_not_found_error(&message) { + if session_not_found { tracing::warn!( "Session is unavailable, recreating backend session: {}", session_id @@ -348,13 +334,11 @@ impl CoreAgentAdapter { } } -#[async_trait::async_trait] -impl Agent for CoreAgentAdapter { - async fn ensure_session(&self, agent_type: &str) -> Result { +impl CliAgentRuntimeClient { + pub(crate) async fn ensure_session(&self, agent_type: &str) -> Result { let mut session_id_guard = self.session_id.lock().await; if let Some(ref id) = *session_id_guard { - self.ensure_backend_session_alive(id, agent_type).await?; return Ok(id.clone()); } @@ -379,7 +363,7 @@ impl Agent for CoreAgentAdapter { Ok(id) } - async fn send_message(&self, message: String, agent_type: &str) -> Result { + pub(crate) async fn send_message(&self, message: String, agent_type: &str) -> Result { let session_id = self.ensure_session(agent_type).await?; tracing::info!("Sending message to session {}: {}", session_id, message); @@ -419,8 +403,9 @@ impl Agent for CoreAgentAdapter { let start_result = self.runtime.submit_dialog_turn(request.clone()).await; if let Err(err) = start_result { + let session_not_found = Self::is_session_not_found_error(&err); let error_message = err.into_message(); - if Self::is_session_not_found_error(&error_message) { + if session_not_found { tracing::warn!( "Session missing when starting turn, attempting recovery and retry: session_id={}, error={}", session_id, @@ -440,7 +425,7 @@ impl Agent for CoreAgentAdapter { Ok(turn_id) } - async fn cancel_current_turn(&self) -> Result<()> { + pub(crate) async fn cancel_current_turn(&self) -> Result<()> { let session_id = self.session_id.lock().await.clone(); let turn_id = self.current_turn_id.lock().await.clone(); @@ -467,7 +452,7 @@ impl Agent for CoreAgentAdapter { Ok(()) } - async fn create_new_session(&self, agent_type: &str) -> Result { + pub(crate) async fn create_new_session(&self, agent_type: &str) -> Result { let mut session_id_guard = self.session_id.lock().await; let session = self @@ -491,13 +476,13 @@ impl Agent for CoreAgentAdapter { Ok(id) } - async fn restore_session(&self, session_id: &str) -> Result<()> { + pub(crate) async fn restore_session(&self, session_id: &str) -> Result<()> { self.restore_session_in_current_workspace(session_id) .await?; Ok(()) } - async fn confirm_tool( + pub(crate) async fn confirm_tool( &self, tool_id: &str, updated_input: Option, @@ -512,7 +497,7 @@ impl Agent for CoreAgentAdapter { .map_err(|e| anyhow::anyhow!("Confirm tool failed: {}", e.into_message())) } - async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()> { + pub(crate) async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<()> { tracing::info!("Rejecting tool execution: {}, reason: {}", tool_id, reason); self.runtime .reject_tool(AgentToolRejectionRequest { @@ -523,7 +508,11 @@ impl Agent for CoreAgentAdapter { .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<()> { + pub(crate) async fn submit_user_answers( + &self, + tool_id: &str, + answers: serde_json::Value, + ) -> Result<()> { tracing::info!("Submitting user answers for tool: {}", tool_id); self.runtime .submit_user_answers(AgentUserAnswersRequest { @@ -535,6 +524,28 @@ impl Agent for CoreAgentAdapter { } } +#[cfg(test)] +mod recovery_tests { + use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; + + use super::CliAgentRuntimeClient; + + #[test] + fn session_recovery_requires_structured_not_found_error() { + let missing_session = + RuntimeError::Port(PortError::new(PortErrorKind::NotFound, "session not found")); + let unrelated_backend_error = + RuntimeError::Port(PortError::new(PortErrorKind::Backend, "model not found")); + + assert!(CliAgentRuntimeClient::is_session_not_found_error( + &missing_session + )); + assert!(!CliAgentRuntimeClient::is_session_not_found_error( + &unrelated_backend_error + )); + } +} + #[cfg(test)] mod tests { use std::path::Path; @@ -545,10 +556,10 @@ mod tests { #[test] fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { - let source = include_str!("core_adapter.rs"); - let runtime_update = ["runtime", "\n .update_session_model"].concat(); + let source = include_str!("runtime_client.rs").replace("\r\n", "\n"); + let runtime_update = ["self.runtime", "\n .update_session_model"].concat(); let compatibility_update = - ["compatibility", "\n .update_session_model"].concat(); + ["self.compatibility", "\n .update_session_model"].concat(); assert!(source.contains(&runtime_update)); assert!(!source.contains(&compatibility_update)); diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index 21d50ec277..bc08477667 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Context, Result}; use std::path::Path; use std::time::Duration; +use bitfun_agent_runtime::sdk::AgentSessionUsageRequest; use bitfun_core::agentic::get_agent_registry; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::plugin_runtime::{ @@ -16,9 +17,7 @@ use bitfun_core::plugin_source::{ use bitfun_core::product_assembly::ProductRuntimeParts; use bitfun_core::runtime_ports::PluginRuntimeAvailability; use bitfun_core::service::config::initialize_global_config; -use bitfun_core::service::session_usage::{ - render_usage_report_markdown, SessionUsageReportRequest, -}; +use bitfun_core::service::session_usage::render_usage_report_markdown; async fn ensure_global_config_service( ) -> Result> { @@ -241,8 +240,8 @@ pub(crate) async fn print_usage_report(session_id: Option<&str>) -> Result<()> { }; let report = runtime - .compatibility() - .generate_session_usage_report(SessionUsageReportRequest { + .agent_runtime() + .generate_session_usage(AgentSessionUsageRequest { session_id: resolved_session_id, workspace_path: Some(workspace_path.to_string_lossy().to_string()), remote_connection_id: None, @@ -250,7 +249,7 @@ pub(crate) async fn print_usage_report(session_id: Option<&str>) -> Result<()> { include_hidden_subagents: true, }) .await - .map_err(|error| anyhow!(error.to_string()))?; + .map_err(|error| anyhow!(error.into_message()))?; println!("{}", render_usage_report_markdown(&report)); Ok(()) diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 696f672e0a..37a7b9102f 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -18,6 +18,7 @@ use std::sync::{ use std::time::{Duration, Instant}; use tokio::sync::broadcast::error::TryRecvError; +use bitfun_agent_runtime::sdk::{AgentSessionUsageRequest, SessionUsageReport}; use bitfun_events::{AgenticEvent, ToolEventData}; use resize::ResizeRedrawState; @@ -25,7 +26,7 @@ use crate::actions::{ action_by_id, action_for_alias, slash_actions, ActionContext, ActionHandler, ActionSpec, ActionState, ResolvedKeymap, }; -use crate::agent::{core_adapter::CoreAgentAdapter, Agent}; +use crate::agent::runtime_client::CliAgentRuntimeClient; use crate::chat_state::ChatState; use crate::config::CliConfig; use crate::runtime::CliRuntimeContext; @@ -73,9 +74,7 @@ use bitfun_core::external_sources::{ ExternalToolRuntimeKind, PromptCommandAvailability, }; use bitfun_core::service::config::GlobalConfigManager; -use bitfun_core::service::session_usage::{ - render_usage_report_markdown, SessionUsageReportRequest, -}; +use bitfun_core::service::session_usage::render_usage_report_markdown; /// Spinner/UI redraw interval while a turn is processing. const SPINNER_REDRAW_INTERVAL_MS: u64 = 100; @@ -161,7 +160,7 @@ pub(crate) struct ChatMode { /// Current agent type (e.g. "agentic", "plan", "debug") agent_type: String, workspace: Option, - agent: Arc, + agent: Arc, runtime: Arc, /// If set, restore this existing session instead of creating a new one restore_session_id: Option, @@ -198,7 +197,7 @@ impl ChatMode { workspace: Option, runtime: Arc, ) -> Self { - let agent = Arc::new(CoreAgentAdapter::new( + let agent = Arc::new(CliAgentRuntimeClient::new( runtime.as_ref(), workspace.clone().map(PathBuf::from), )); diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 2c64975faa..5094c54118 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -5,6 +5,20 @@ enum ModelSelectionApplyOutcome { }, } +fn usage_report_metadata(report: &SessionUsageReport) -> Result { + let usage_report = serde_json::to_value(report) + .map_err(|error| anyhow!("Failed to serialize usage report: {error}"))?; + Ok(serde_json::json!({ + "localCommandKind": "usage_report", + "reportId": report.report_id, + "schemaVersion": report.schema_version, + "generatedAt": report.generated_at, + "modelVisible": false, + "usageReport": usage_report, + "usageReportStatus": "completed", + })) +} + fn apply_model_selection_feedback( chat_state: &mut ChatState, selected_display_name: &str, @@ -80,19 +94,21 @@ impl ChatMode { .or_else(|| self.workspace.clone()) .or_else(|| Some(self.agent.workspace_path_string())); let agent = self.agent.clone(); + let runtime = Arc::clone(&self.runtime); let report_result: Result = tokio::task::block_in_place(|| { let session_id = session_id.clone(); let workspace_path = workspace_path.clone(); let agent = agent.clone(); + let runtime = Arc::clone(&runtime); rt_handle.block_on(async move { let workspace_path = workspace_path .filter(|path| !path.trim().is_empty()) .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; let report = agent - .generate_session_usage_report(SessionUsageReportRequest { + .generate_session_usage_report(AgentSessionUsageRequest { session_id: session_id.clone(), workspace_path: Some(workspace_path), remote_connection_id: None, @@ -103,19 +119,9 @@ impl ChatMode { let markdown = render_usage_report_markdown(&report); let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); - let usage_report = serde_json::to_value(&report) - .map_err(|error| anyhow!("Failed to serialize usage report: {}", error))?; - let metadata = serde_json::json!({ - "localCommandKind": "usage_report", - "reportId": report.report_id.clone(), - "schemaVersion": report.schema_version, - "generatedAt": report.generated_at, - "modelVisible": false, - "usageReport": usage_report, - "usageReportStatus": "completed", - }); - - agent + let metadata = usage_report_metadata(&report)?; + runtime + .compatibility() .append_completed_local_command_turn( &session_id, markdown, @@ -495,3 +501,25 @@ impl ChatMode { // ============ MCP management ============ } + +#[cfg(test)] +mod usage_metadata_tests { + use super::{SessionUsageReport, usage_report_metadata}; + + #[test] + fn usage_metadata_preserves_the_existing_tui_transcript_schema() { + let mut report = SessionUsageReport::partial_unavailable("session-1", 1_778_347_200_000); + report.report_id = "usage-session-1-1778347200000".to_string(); + + let metadata = usage_report_metadata(&report).expect("usage metadata"); + + assert_eq!(metadata["localCommandKind"], "usage_report"); + assert_eq!(metadata["reportId"], report.report_id); + assert_eq!(metadata["schemaVersion"], report.schema_version); + assert_eq!(metadata["generatedAt"], report.generated_at); + assert_eq!(metadata["modelVisible"], false); + assert_eq!(metadata["usageReportStatus"], "completed"); + assert_eq!(metadata["usageReport"]["sessionId"], "session-1"); + assert_eq!(metadata.as_object().map(serde_json::Map::len), Some(7)); + } +} diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 2f78372989..630629b404 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -11,11 +11,12 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; +use bitfun_agent_runtime::sdk::{PortErrorKind, RuntimeError}; use bitfun_agent_tools::effective_tool_invocation; use bitfun_events::{AgenticEvent, ToolEventIdentity}; -use tokio::time::{sleep, Instant}; +use tokio::time::Instant; -use crate::agent::{core_adapter::CoreAgentAdapter, Agent}; +use crate::agent::runtime_client::CliAgentRuntimeClient; use crate::config::CliConfig; use crate::diagnostics::{emit_exit_diagnostic, ExitContext, ExitKind}; use crate::runtime::CliRuntimeContext; @@ -195,6 +196,48 @@ fn completed_turn_failure( }) } +fn is_successful_exec_terminal(event: &AgenticEvent, turn_id: &str) -> bool { + matches!( + event, + AgenticEvent::DialogTurnCompleted { + turn_id: event_turn_id, + success, + finish_reason, + has_final_response, + .. + } if event_turn_id == turn_id + && completed_turn_failure( + *success, + finish_reason.as_deref(), + *has_final_response, + ) + .is_none() + ) +} + +fn settlement_failure(error: RuntimeError, session_id: &str, turn_id: &str) -> (ExitKind, String) { + let is_timeout = matches!( + &error, + RuntimeError::Port(port_error) if port_error.kind == PortErrorKind::Timeout + ); + let detail = error.into_message(); + if is_timeout { + ( + ExitKind::SettlementTimedOut, + format!( + "Timed out waiting for exec turn settlement: session_id={session_id}, turn_id={turn_id}: {detail}" + ), + ) + } else { + ( + ExitKind::SystemError, + format!( + "Failed to wait for exec turn settlement: session_id={session_id}, turn_id={turn_id}: {detail}" + ), + ) + } +} + impl ExecJsonResult { pub(crate) fn success( session_id: impl Into, @@ -308,7 +351,7 @@ pub(crate) struct ExecMode { config: CliConfig, message: String, agent_type: String, - agent: Arc, + agent: Arc, _runtime: Arc, workspace_path: Option, /// None: no patch output, Some("-"): output to stdout, Some(path): save to file @@ -334,7 +377,7 @@ impl ExecMode { crate::runtime::approval::CliApprovalPolicy::Ask | crate::runtime::approval::CliApprovalPolicy::Reject => ExecApprovalMode::Reject, }; - let agent = Arc::new(CoreAgentAdapter::new( + let agent = Arc::new(CliAgentRuntimeClient::new( runtime.as_ref(), workspace_path.clone(), )); @@ -594,6 +637,7 @@ impl ExecMode { let mut terminal_message: Option = None; let mut assistant_text = String::new(); let mut usage: Option = None; + let mut deferred_success_envelope: Option = None; 'event_loop: loop { let envelope = tokio::select! { @@ -758,7 +802,11 @@ impl ExecMode { continue; } - self.emit_stream_envelope(&envelope)?; + if is_successful_exec_terminal(event, &turn_id) { + deferred_success_envelope = Some(envelope.clone()); + } else { + self.emit_stream_envelope(&envelope)?; + } if let Some(model_config_id) = ExecTokenUsage::accumulate_event(&mut usage, event, &turn_id) @@ -1029,21 +1077,35 @@ impl ExecMode { } } - if let Err(error) = self.wait_for_turn_settlement(&session_id, &turn_id).await { - let message = match terminal_message.take() { - Some(existing) => format!("{existing}; {error}"), - None => error.to_string(), - }; - emit_exit_diagnostic( - ExitKind::SettlementTimedOut, - &message, - &self.exit_context(Some(&session_id), Some(&turn_id)), - ); - terminal_status = Some(ExecTerminalStatus::Error); - terminal_message = Some(message.clone()); - terminal_outcome = Some(Err(anyhow::anyhow!(message))); - } - let (patch, patch_error) = self.output_patch_if_needed(); + let turn_settled = match self.wait_for_turn_settlement(&session_id, &turn_id).await { + Ok(()) => true, + Err(error) => { + let (exit_kind, settlement_message) = + settlement_failure(error, &session_id, &turn_id); + let message = match terminal_message.take() { + Some(existing) => format!("{existing}; {settlement_message}"), + None => settlement_message, + }; + emit_exit_diagnostic( + exit_kind, + &message, + &self.exit_context(Some(&session_id), Some(&turn_id)), + ); + terminal_status = Some(ExecTerminalStatus::Error); + terminal_message = Some(message.clone()); + terminal_outcome = Some(Err(anyhow::anyhow!(message))); + self.emit_stream_error( + &session_id, + terminal_message.as_deref().unwrap_or_default(), + )?; + false + } + }; + let (patch, patch_error) = if turn_settled { + self.output_patch_if_needed() + } else { + (None, None) + }; if let Some(error) = patch_error { let message = match terminal_message.take() { Some(existing) => format!("{existing}; {error}"), @@ -1052,6 +1114,12 @@ impl ExecMode { terminal_status = Some(ExecTerminalStatus::Error); terminal_message = Some(message.clone()); terminal_outcome = Some(Err(anyhow::anyhow!(message))); + self.emit_stream_error(&session_id, terminal_message.as_deref().unwrap_or_default())?; + } + if terminal_status == Some(ExecTerminalStatus::Success) { + if let Some(envelope) = deferred_success_envelope.as_ref() { + self.emit_stream_envelope(envelope)?; + } } if self.output_format == ExecOutputFormat::Json { let result_text = terminal_message.unwrap_or(assistant_text); @@ -1144,6 +1212,18 @@ impl ExecMode { Ok(()) } + fn emit_stream_error(&self, session_id: &str, message: &str) -> Result<()> { + let envelope = bitfun_events::AgenticEventEnvelope::new( + AgenticEvent::SystemError { + session_id: Some(session_id.to_string()), + error: message.to_string(), + recoverable: false, + }, + bitfun_events::AgenticEventPriority::Critical, + ); + self.emit_stream_envelope(&envelope) + } + fn print_text(&self, f: impl FnOnce()) { if self.output_format == ExecOutputFormat::Text { f(); @@ -1238,22 +1318,14 @@ impl ExecMode { ) } - async fn wait_for_turn_settlement(&self, session_id: &str, turn_id: &str) -> Result<()> { - let deadline = Instant::now() + Duration::from_secs(5); - - loop { - if !self.agent.is_turn_processing(session_id, turn_id) { - return Ok(()); - } - - if Instant::now() >= deadline { - return Err(anyhow::anyhow!( - "Timed out waiting for exec turn settlement: session_id={session_id}, turn_id={turn_id}" - )); - } - - sleep(Duration::from_millis(50)).await; - } + async fn wait_for_turn_settlement( + &self, + session_id: &str, + turn_id: &str, + ) -> std::result::Result<(), RuntimeError> { + self.agent + .wait_for_turn_settlement(session_id, turn_id, 5_000) + .await } async fn drain_interrupted_turn_events( @@ -1304,9 +1376,11 @@ mod patch_tests { use super::{ completed_turn_failure, effective_event_invocation, event_belongs_to_exec_turn, - event_turn_id, serialize_stream_envelope, write_patch_to_path, ExecApprovalMode, - ExecJsonResult, ExecMode, ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, + event_turn_id, is_successful_exec_terminal, serialize_stream_envelope, settlement_failure, + write_patch_to_path, ExecApprovalMode, ExecJsonResult, ExecMode, ExecTokenUsage, ExitKind, + TOOL_START_INPUT_PREVIEW_CHARS, }; + use bitfun_agent_runtime::sdk::{PortError, PortErrorKind, RuntimeError}; use bitfun_events::{ AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, ToolEventIdentity, }; @@ -1660,6 +1734,44 @@ mod patch_tests { assert!(completed_turn_failure(None, None, None).is_none()); } + #[test] + fn successful_terminal_event_is_deferred_until_exec_settlement() { + let event = AgenticEvent::DialogTurnCompleted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + total_rounds: 1, + total_tools: 0, + duration_ms: 10, + partial_recovery_reason: None, + success: Some(true), + finish_reason: Some("complete".to_string()), + has_final_response: Some(true), + }; + + assert!(is_successful_exec_terminal(&event, "turn-1")); + assert!(!is_successful_exec_terminal(&event, "turn-other")); + } + + #[test] + fn settlement_failure_preserves_timeout_and_runtime_error_kinds() { + let timeout = RuntimeError::Port(PortError::new( + PortErrorKind::Timeout, + "turn did not settle", + )); + let backend = RuntimeError::Port(PortError::new( + PortErrorKind::Backend, + "settlement provider failed", + )); + + let (timeout_kind, timeout_message) = settlement_failure(timeout, "session-1", "turn-1"); + let (backend_kind, backend_message) = settlement_failure(backend, "session-1", "turn-1"); + + assert_eq!(timeout_kind, ExitKind::SettlementTimedOut); + assert!(timeout_message.starts_with("Timed out waiting")); + assert_eq!(backend_kind, ExitKind::SystemError); + assert!(backend_message.starts_with("Failed to wait")); + } + #[test] fn exec_turn_filter_rejects_other_turn_events_in_the_same_session() { let event = AgenticEvent::TextChunk { diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs index 38950933dd..e6718721ce 100644 --- a/src/apps/cli/src/root_handlers.rs +++ b/src/apps/cli/src/root_handlers.rs @@ -301,9 +301,15 @@ pub(crate) async fn handle_session_action( let session_id = resolve_cli_session_id(runtime.agent_runtime(), &workspace_path, &id).await?; let result = runtime - .compatibility() - .branch_session_at_latest_turn(&workspace_path, &session_id) - .await?; + .agent_runtime() + .fork_session(bitfun_agent_runtime::sdk::AgentSessionForkRequest { + workspace_path: workspace_path.to_string_lossy().to_string(), + source_session_id: session_id.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .map_err(|error| anyhow::anyhow!(error.into_message()))?; if id_only { println!("{}", result.session_id); diff --git a/src/apps/cli/src/runtime/mod.rs b/src/apps/cli/src/runtime/mod.rs index 8a1f904cc6..eb0432f2ef 100644 --- a/src/apps/cli/src/runtime/mod.rs +++ b/src/apps/cli/src/runtime/mod.rs @@ -94,6 +94,7 @@ impl CliRuntimeContext { let agent_runtime = CoreProductAgentRuntime::build( agentic_system.coordinator.clone(), scheduler.clone(), + agentic_system.token_usage_service.clone(), services.clone(), harness_registry, ) diff --git a/src/apps/cli/tests/product_assembly_cli.rs b/src/apps/cli/tests/product_assembly_cli.rs index a99b8900b3..fb18832f17 100644 --- a/src/apps/cli/tests/product_assembly_cli.rs +++ b/src/apps/cli/tests/product_assembly_cli.rs @@ -190,3 +190,30 @@ fn cli_local_persistence_stays_behind_core_compatibility_facade() { "Peer Host persistence operations must stay behind the Core compatibility facade" ); } + +#[test] +fn primary_cli_session_client_uses_only_the_runtime_sdk_boundary() { + const AGENT_MODULE: &str = include_str!("../src/agent/mod.rs"); + const PRIMARY_CLIENT: &str = include_str!("../src/agent/runtime_client.rs"); + + assert!( + !AGENT_MODULE.contains("trait Agent"), + "a one-implementation private trait must not obscure the Runtime SDK client boundary" + ); + assert!( + !PRIMARY_CLIENT.contains("CoreAgentRuntimeCompatibility") + && !PRIMARY_CLIENT.contains("compatibility:") + && !PRIMARY_CLIENT.contains("is_turn_processing"), + "the primary CLI/TUI session client must not depend on Core compatibility or state polling" + ); + for sdk_operation in [ + "fork_session", + "generate_session_usage", + "wait_for_turn_settlement", + ] { + assert!( + PRIMARY_CLIENT.contains(sdk_operation), + "primary session client must route {sdk_operation} through the Runtime SDK" + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 7a002fd819..b93c4c783b 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -8,6 +8,7 @@ use super::{ get_global_scheduler, DialogSubmissionPolicy, HiddenSubagentQueueCancelHandle, }, turn_outcome::TurnOutcome, + turn_settlement::TurnSettlementTracker, }; use crate::agentic::agents::get_agent_registry; use crate::agentic::context_profile::ContextProfilePolicy; @@ -815,6 +816,10 @@ pub struct ConversationCoordinator { /// Map value is a counter shared between the coordinator and the spawn /// task; spawn task increments on entry and decrements on exit. active_turns_per_session: Arc>>, + /// Exact `(session_id, turn_id)` completion signals. A registration stays + /// active through persistence finalization, not merely until session state + /// changes to Idle. + turn_settlements: Arc, thread_goal_runtime: Arc, terminal_port: OnceLock>, remote_exec_port: OnceLock>, @@ -1360,6 +1365,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet scheduler_notify_tx: OnceLock::new(), round_injection_source: OnceLock::new(), active_turns_per_session: Arc::new(DashMap::new()), + turn_settlements: Arc::new(TurnSettlementTracker::default()), thread_goal_runtime: Arc::new(ThreadGoalRuntime::new()), terminal_port: OnceLock::new(), remote_exec_port: OnceLock::new(), @@ -2119,7 +2125,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await; let current_agent = agent_registry .get_agent(agent_type, workspace.map(|binding| binding.root_path())) - .ok_or_else(|| BitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; + .ok_or_else(|| { + BitFunError::Validation(format!("Unknown agent type: {}", agent_type)) + })?; let current_agent_reminder = current_agent .get_system_reminder(previous_agent_type, workspace) .await?; @@ -3643,6 +3651,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet counter: active_counter.clone(), armed: true, }; + let turn_settlement_registration = self + .turn_settlements + .register_accepted(session_id.clone(), turn_id.clone()); let cancellation_token = CancellationToken::new(); self.execution_engine .register_cancel_token(&turn_id, cancellation_token); @@ -3847,6 +3858,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let scheduler_notify_tx = self.scheduler_notify_tx.get().cloned(); tokio::spawn(async move { + // Keep exact turn settlement pending until every tail write in + // this spawned task has completed. + let _turn_settlement_registration = turn_settlement_registration; // RAII guard: on drop (ANY exit path, including panic), decrements // the in-flight counter and resets Processing → Idle only if this // task still owns the current turn. @@ -4015,6 +4029,61 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } + pub(crate) async fn wait_for_turn_settlement( + &self, + session_id: &str, + turn_id: &str, + max_wait: Duration, + ) -> BitFunResult<()> { + match self + .turn_settlements + .wait(session_id, turn_id, max_wait) + .await + { + super::turn_settlement::TurnSettlementWait::Settled => return Ok(()), + super::turn_settlement::TurnSettlementWait::TimedOut => {} + super::turn_settlement::TurnSettlementWait::Unknown => { + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| { + BitFunError::NotFound(format!("Session not found: {session_id}")) + })?; + if !session.dialog_turn_ids.iter().any(|known| known == turn_id) { + return Err(BitFunError::NotFound(format!( + "Dialog turn not found: {turn_id}" + ))); + } + return Err(BitFunError::Service(format!( + "Turn settlement evidence is unavailable: session_id={session_id}, turn_id={turn_id}" + ))); + } + } + Err(BitFunError::Timeout(format!( + "Turn did not settle before timeout: session_id={session_id}, turn_id={turn_id}, timeout_ms={}", + max_wait.as_millis() + ))) + } + + #[cfg(test)] + pub(super) fn register_turn_settlement( + &self, + session_id: &str, + turn_id: &str, + ) -> super::turn_settlement::TurnSettlementRegistration { + self.turn_settlements + .register_accepted(session_id.to_string(), turn_id.to_string()) + } + + pub(super) fn try_register_turn_settlement( + &self, + session_id: &str, + turn_id: &str, + ) -> Option { + self.turn_settlements + .try_register_pending(session_id.to_string(), turn_id.to_string()) + } + /// Strict maintenance barrier for callers that must not overlap an older /// turn's tail writes. Unlike normal interactive cancellation, timeout is /// returned as an error instead of being treated as best effort. diff --git a/src/crates/assembly/core/src/agentic/coordination/mod.rs b/src/crates/assembly/core/src/agentic/coordination/mod.rs index b3faeff862..59a14bfc2c 100644 --- a/src/crates/assembly/core/src/agentic/coordination/mod.rs +++ b/src/crates/assembly/core/src/agentic/coordination/mod.rs @@ -6,6 +6,7 @@ pub mod coordinator; pub mod scheduler; pub mod state_manager; pub mod turn_outcome; +mod turn_settlement; pub use coordinator::*; pub use scheduler::*; diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 3b0f366b3e..03b345e047 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -14,6 +14,7 @@ use super::coordinator::{ ConversationCoordinator, DialogTriggerSource, HiddenSubagentExecutionRequest, SubagentResult, }; use super::turn_outcome::TurnOutcome; +use super::turn_settlement::TurnSettlementRegistration; use crate::agentic::core::{InternalReminderKind, Message, SessionState}; use crate::agentic::events::AgenticEvent; use crate::agentic::goal_mode::{ @@ -82,9 +83,18 @@ pub struct QueuedTurn { pub image_contexts: Option>, #[allow(dead_code)] pub enqueued_at: SystemTime, + _settlement_registration: Option, execution: QueuedTurnExecution, } +impl QueuedTurn { + fn accept_settlement(&self) { + if let Some(registration) = self._settlement_registration.as_ref() { + registration.accept(); + } + } +} + #[derive(Debug, Clone, Default)] pub(crate) enum QueuedTurnExecution { #[default] @@ -135,6 +145,66 @@ fn queued_background_task_id(turn: &QueuedTurn) -> Option<&str> { .flatten() } +#[derive(Debug)] +enum SchedulerSubmitError { + Core(BitFunError), + Port(PortError), + Message(String), +} + +impl SchedulerSubmitError { + fn into_port_error(self) -> PortError { + match self { + Self::Core(BitFunError::Validation(message)) => { + PortError::new(PortErrorKind::InvalidRequest, message) + } + Self::Core(BitFunError::NotFound(message)) => { + PortError::new(PortErrorKind::NotFound, message) + } + Self::Core(BitFunError::Cancelled(message)) => { + PortError::new(PortErrorKind::Cancelled, message) + } + Self::Core(BitFunError::Timeout(message)) => { + PortError::new(PortErrorKind::Timeout, message) + } + Self::Core(BitFunError::NotImplemented(message)) => { + PortError::new(PortErrorKind::NotAvailable, message) + } + Self::Core(error) => PortError::new(PortErrorKind::Backend, error.to_string()), + Self::Port(error) => error, + Self::Message(message) => PortError::new(PortErrorKind::Backend, message), + } + } +} + +impl std::fmt::Display for SchedulerSubmitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Core(error) => error.fmt(formatter), + Self::Port(error) => error.fmt(formatter), + Self::Message(message) => formatter.write_str(message), + } + } +} + +impl From for SchedulerSubmitError { + fn from(error: BitFunError) -> Self { + Self::Core(error) + } +} + +impl From for SchedulerSubmitError { + fn from(message: String) -> Self { + Self::Message(message) + } +} + +impl From for SchedulerSubmitError { + fn from(error: PortError) -> Self { + Self::Port(error) + } +} + #[derive(Debug, Clone)] pub(crate) struct HiddenSubagentQueuedExecution { request: HiddenSubagentExecutionRequest, @@ -771,6 +841,7 @@ impl DialogScheduler { user_message_metadata: delivery.user_message_metadata, image_contexts: None, enqueued_at: SystemTime::now(), + _settlement_registration: None, execution: QueuedTurnExecution::Standard, }; if background_task_id @@ -801,7 +872,7 @@ impl DialogScheduler { .finish_background_subagent_delivery(background_task_id); } } - result.map(|_| ()) + result.map(|_| ()).map_err(|error| error.to_string()) } pub async fn submit_init_agents_md( @@ -924,10 +995,12 @@ impl DialogScheduler { user_message_metadata, image_contexts, enqueued_at: SystemTime::now(), + _settlement_registration: None, execution: QueuedTurnExecution::Standard, }; self.submit_queued_turn(session_id, resolved_turn_id, queued_turn, false) .await + .map_err(|error| error.to_string()) } pub(crate) async fn submit_hidden_subagent( @@ -972,6 +1045,7 @@ impl DialogScheduler { user_message_metadata: None, image_contexts: None, enqueued_at: SystemTime::now(), + _settlement_registration: None, execution: QueuedTurnExecution::HiddenSubagent(HiddenSubagentQueuedExecution { request, timeout_seconds, @@ -986,7 +1060,8 @@ impl DialogScheduler { queued_turn, false, ) - .await?; + .await + .map_err(|error| error.to_string())?; Ok(HiddenSubagentSubmitResult { receiver: result_rx, cancel_handle: HiddenSubagentQueueCancelHandle { @@ -1038,7 +1113,8 @@ impl DialogScheduler { remote_connection_id, remote_ssh_host, ) - .await?; + .await + .map_err(|error| error.to_string())?; self.session_manager .restore_session_from_storage_path(&restore_path, session_id) .await @@ -1057,7 +1133,7 @@ impl DialogScheduler { workspace_path: &str, remote_connection_id: Option<&str>, remote_ssh_host: Option<&str>, - ) -> Result { + ) -> Result { let request = SessionStoragePathRequest { workspace_path: PathBuf::from(workspace_path), remote_connection_id: remote_connection_id.map(ToOwned::to_owned), @@ -1068,7 +1144,7 @@ impl DialogScheduler { .resolve_session_storage_path(request) .await .map(|resolution| resolution.effective_storage_path) - .map_err(|error| error.to_string()) + .map_err(SchedulerSubmitError::Port) } async fn submit_queued_turn( @@ -1077,7 +1153,7 @@ impl DialogScheduler { resolved_turn_id: String, queued_turn: QueuedTurn, reject_if_busy: bool, - ) -> Result { + ) -> Result { let _operation_guard = self.lock_session_operation(&session_id).await; self.submit_queued_turn_locked(session_id, resolved_turn_id, queued_turn, reject_if_busy) .await @@ -1089,7 +1165,7 @@ impl DialogScheduler { resolved_turn_id: String, queued_turn: QueuedTurn, reject_if_busy: bool, - ) -> Result { + ) -> Result { if let Some(workspace_path) = queued_turn.workspace_path.as_deref() { let requested_storage_path = Self::resolve_session_restore_path( workspace_path, @@ -1099,7 +1175,7 @@ impl DialogScheduler { .await?; self.session_manager .validate_session_storage_path_binding(&session_id, &requested_storage_path) - .map_err(|error| error.to_string())?; + .map_err(SchedulerSubmitError::Core)?; } let state = self .session_manager @@ -1125,12 +1201,15 @@ impl DialogScheduler { | DialogSubmitQueueAction::EnqueueForActiveTurn ) { - return Err("Session state does not allow starting new dialog: Processing".to_string()); + return Err(SchedulerSubmitError::Message( + "Session state does not allow starting new dialog: Processing".to_string(), + )); } match action { DialogSubmitQueueAction::StartImmediately => { let tid = self.start_turn(&session_id, &queued_turn).await?; + queued_turn.accept_settlement(); self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) .await; Ok(DialogSubmitOutcome::Started { @@ -1142,6 +1221,7 @@ impl DialogScheduler { DialogSubmitQueueAction::ClearQueueAndStartImmediately => { self.clear_queue(&session_id).await; let tid = self.start_turn(&session_id, &queued_turn).await?; + queued_turn.accept_settlement(); self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) .await; Ok(DialogSubmitOutcome::Started { @@ -1152,6 +1232,7 @@ impl DialogScheduler { DialogSubmitQueueAction::EnqueueThenStartNext => { self.enqueue(&session_id, queued_turn.clone())?; + queued_turn.accept_settlement(); self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) .await; let started_tid = self.try_start_next_queued_locked(&session_id).await?; @@ -1162,7 +1243,8 @@ impl DialogScheduler { DialogSubmitQueueAction::EnqueueForActiveTurn => { let accepted_agent_type = queued_turn.agent_type.clone(); - self.enqueue(&session_id, queued_turn)?; + self.enqueue(&session_id, queued_turn.clone())?; + queued_turn.accept_settlement(); self.record_last_submitted_agent_type(&session_id, &accepted_agent_type) .await; Ok(DialogSubmitOutcome::Queued { @@ -1584,7 +1666,10 @@ impl DialogScheduler { self.queues.requeue_front(session_id, turn, priority); } - async fn try_start_next_queued(&self, session_id: &str) -> Result, String> { + async fn try_start_next_queued( + &self, + session_id: &str, + ) -> Result, SchedulerSubmitError> { let _operation_guard = self.lock_session_operation(session_id).await; self.try_start_next_queued_locked(session_id).await } @@ -1592,7 +1677,7 @@ impl DialogScheduler { async fn try_start_next_queued_locked( &self, session_id: &str, - ) -> Result, String> { + ) -> Result, SchedulerSubmitError> { let state = self .session_manager .get_session(session_id) @@ -1624,11 +1709,12 @@ impl DialogScheduler { &self, session_id: &str, queued_turn: &QueuedTurn, - ) -> Result { + ) -> Result { if let QueuedTurnExecution::HiddenSubagent(execution) = &queued_turn.execution { return self .start_hidden_subagent_turn(session_id, queued_turn, execution) - .await; + .await + .map_err(SchedulerSubmitError::Message); } let images = queued_turn @@ -1715,7 +1801,7 @@ impl DialogScheduler { } }; - res.map_err(|e| e.to_string())?; + res.map_err(SchedulerSubmitError::Core)?; // Standard scheduler submissions resolve and persist their turn ID // before entering the coordinator. Reading SessionState here races a @@ -1931,7 +2017,10 @@ impl DialogScheduler { } async fn dispatch_next_if_idle(&self, session_id: &str) -> Result<(), String> { - let _ = self.try_start_next_queued(session_id).await?; + let _ = self + .try_start_next_queued(session_id) + .await + .map_err(|error| error.to_string())?; Ok(()) } @@ -2322,6 +2411,18 @@ impl DialogScheduler { let resolved_turn_id = request .turn_id .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let settlement_registration = self + .coordinator + .try_register_turn_settlement(&request.session_id, &resolved_turn_id) + .ok_or_else(|| { + PortError::new( + PortErrorKind::InvalidRequest, + format!( + "Dialog turn ID is already active or completed: session_id={}, turn_id={resolved_turn_id}", + request.session_id + ), + ) + })?; let queued_turn = QueuedTurn { user_input: request.message, original_user_input: request.original_message, @@ -2336,6 +2437,7 @@ impl DialogScheduler { user_message_metadata, image_contexts, enqueued_at: SystemTime::now(), + _settlement_registration: Some(settlement_registration), execution: QueuedTurnExecution::Standard, }; @@ -2346,7 +2448,7 @@ impl DialogScheduler { reject_if_busy, ) .await - .map_err(|error| PortError::new(PortErrorKind::Backend, error)) + .map_err(SchedulerSubmitError::into_port_error) } } @@ -2633,6 +2735,7 @@ mod tests { user_message_metadata: None, image_contexts: None, enqueued_at: SystemTime::now(), + _settlement_registration: None, execution: QueuedTurnExecution::Standard, } } @@ -3098,6 +3201,121 @@ mod tests { ); } + #[tokio::test] + async fn dialog_port_preserves_not_found_for_a_missing_session() { + let (scheduler, _, _, root) = test_scheduler(); + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + + let error = scheduler + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: "missing-session".to_string(), + message: "hello".to_string(), + original_message: None, + turn_id: Some("missing-turn".to_string()), + agent_type: "agentic".to_string(), + workspace_path: Some(workspace.to_string_lossy().to_string()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await + .expect_err("a missing session must remain distinguishable"); + + assert_eq!(error.kind, PortErrorKind::NotFound); + assert!(error.message.contains("missing-session"), "{error}"); + assert!(matches!( + scheduler + .coordinator + .wait_for_turn_settlement( + "missing-session", + "missing-turn", + Duration::from_millis(10), + ) + .await, + Err(BitFunError::NotFound(_)) + )); + } + + #[tokio::test] + async fn dialog_port_tracks_settlement_from_queue_admission_through_cancellation() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "queued-session"; + let turn_id = "queued-turn"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Queued".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("create queued session"); + session_manager + .update_session_state( + session_id, + SessionState::Processing { + current_turn_id: "active-turn".to_string(), + phase: ProcessingPhase::Thinking, + }, + ) + .await + .expect("mark another turn active"); + + let outcome = scheduler + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: session_id.to_string(), + message: "queued prompt".to_string(), + original_message: None, + turn_id: Some(turn_id.to_string()), + agent_type: "agentic".to_string(), + workspace_path: None, + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await + .expect("queue the submitted turn"); + + assert_eq!( + outcome, + DialogSubmitOutcome::Queued { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + } + ); + assert!(matches!( + scheduler + .coordinator + .wait_for_turn_settlement(session_id, turn_id, Duration::from_millis(10)) + .await, + Err(BitFunError::Timeout(_)) + )); + + assert!(scheduler + .cancel_queued_or_active_turn(session_id, turn_id) + .await + .expect("cancel queued turn")); + scheduler + .coordinator + .wait_for_turn_settlement(session_id, turn_id, Duration::from_millis(10)) + .await + .expect("cancelled queued turn should settle"); + } + #[tokio::test] async fn reject_busy_dialog_port_does_not_enqueue_or_replace_the_active_turn() { let (scheduler, session_manager, _, root) = test_scheduler(); @@ -3156,6 +3374,204 @@ mod tests { .state, SessionState::Processing { current_turn_id, .. } if current_turn_id == "active-turn" )); + assert!(matches!( + scheduler + .coordinator + .wait_for_turn_settlement(session_id, "rejected-turn", Duration::from_millis(10),) + .await, + Err(BitFunError::NotFound(_)) + )); + } + + #[tokio::test] + async fn dialog_port_rejects_duplicate_active_turn_id() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "duplicate-active-session"; + let turn_id = "duplicate-turn"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Duplicate".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("create session"); + let _active_registration = scheduler + .coordinator + .register_turn_settlement(session_id, turn_id); + session_manager + .update_session_state( + session_id, + SessionState::Processing { + current_turn_id: turn_id.to_string(), + phase: ProcessingPhase::Thinking, + }, + ) + .await + .expect("mark active turn"); + + let error = scheduler + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: session_id.to_string(), + message: "duplicate".to_string(), + original_message: None, + turn_id: Some(turn_id.to_string()), + agent_type: "agentic".to_string(), + workspace_path: None, + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await + .expect_err("duplicate active turn ID must be rejected"); + + assert_eq!(error.kind, PortErrorKind::InvalidRequest); + } + + #[tokio::test] + async fn dialog_port_preserves_invalid_request_for_wrong_workspace() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "workspace-bound-session"; + let turn_id = "wrong-workspace-turn"; + let workspace_a = root.path().join("workspace-a"); + let workspace_b = root.path().join("workspace-b"); + std::fs::create_dir_all(&workspace_a).expect("workspace a"); + std::fs::create_dir_all(&workspace_b).expect("workspace b"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Workspace".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_a.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("create session"); + let error = scheduler + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: session_id.to_string(), + message: "wrong workspace".to_string(), + original_message: None, + turn_id: Some(turn_id.to_string()), + agent_type: "agentic".to_string(), + workspace_path: Some(workspace_b.to_string_lossy().to_string()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await + .expect_err("wrong workspace must be rejected"); + + assert_eq!(error.kind, PortErrorKind::InvalidRequest); + assert!(matches!( + scheduler + .coordinator + .wait_for_turn_settlement(session_id, turn_id, Duration::from_millis(10)) + .await, + Err(BitFunError::NotFound(_)) + )); + } + + #[tokio::test] + async fn dialog_port_treats_unknown_agent_as_invalid_request() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "invalid-agent-session"; + let turn_id = "invalid-agent-turn"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Invalid agent".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("create session"); + + let error = scheduler + .submit_dialog_turn(AgentDialogTurnRequest { + session_id: session_id.to_string(), + message: "invalid agent".to_string(), + original_message: None, + turn_id: Some(turn_id.to_string()), + agent_type: "agent-that-does-not-exist".to_string(), + workspace_path: None, + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli), + reply_route: None, + prepended_reminders: Vec::new(), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }) + .await + .expect_err("unknown agent must be rejected"); + + assert_eq!(error.kind, PortErrorKind::InvalidRequest); + } + + #[tokio::test] + async fn missing_settlement_evidence_for_known_turn_fails_closed() { + let (scheduler, session_manager, _, root) = test_scheduler(); + let session_id = "known-turn-session"; + let turn_id = "known-turn"; + let workspace = root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + session_manager + .create_session_with_id( + Some(session_id.to_string()), + "Known turn".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("create session"); + session_manager + .start_dialog_turn( + session_id, + "agentic".to_string(), + "hello".to_string(), + Some(turn_id.to_string()), + None, + None, + ) + .await + .expect("record turn"); + session_manager + .update_session_state(session_id, SessionState::Idle) + .await + .expect("mark idle"); + + let error = scheduler + .coordinator + .wait_for_turn_settlement(session_id, turn_id, Duration::from_millis(10)) + .await + .expect_err("missing settlement evidence must not be treated as success"); + + assert!(matches!(error, BitFunError::Service(_)), "{error}"); } #[test] diff --git a/src/crates/assembly/core/src/agentic/coordination/turn_settlement.rs b/src/crates/assembly/core/src/agentic/coordination/turn_settlement.rs new file mode 100644 index 0000000000..6e4df60dfc --- /dev/null +++ b/src/crates/assembly/core/src/agentic/coordination/turn_settlement.rs @@ -0,0 +1,339 @@ +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use dashmap::mapref::entry::Entry; +use dashmap::DashMap; +use tokio::sync::watch; + +type TurnKey = (String, String); +const COMPLETED_TURN_CAPACITY: usize = 1_024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TurnSettlementWait { + Settled, + TimedOut, + Unknown, +} + +#[derive(Default)] +pub(super) struct TurnSettlementTracker { + active: DashMap>, + completed: DashMap>, + completed_order: Mutex)>>, +} + +struct TurnSettlementEntry { + sender: watch::Sender, + registrations: AtomicUsize, + accepted: AtomicBool, +} + +impl TurnSettlementTracker { + pub(super) fn try_register_pending( + self: &Arc, + session_id: impl Into, + turn_id: impl Into, + ) -> Option { + let key = (session_id.into(), turn_id.into()); + let entry = match self.active.entry(key.clone()) { + Entry::Occupied(_) => return None, + Entry::Vacant(vacant) => { + if self.completed.contains_key(&key) { + return None; + } + let (sender, _) = watch::channel(false); + let entry = Arc::new(TurnSettlementEntry { + sender, + registrations: AtomicUsize::new(1), + accepted: AtomicBool::new(false), + }); + vacant.insert(Arc::clone(&entry)); + entry + } + }; + Some(TurnSettlementRegistration { + tracker: Arc::clone(self), + key, + entry, + }) + } + + pub(super) fn register_accepted( + self: &Arc, + session_id: impl Into, + turn_id: impl Into, + ) -> TurnSettlementRegistration { + let key = (session_id.into(), turn_id.into()); + let entry = match self.active.entry(key.clone()) { + Entry::Occupied(occupied) => { + let entry = Arc::clone(occupied.get()); + entry.registrations.fetch_add(1, Ordering::AcqRel); + entry.accepted.store(true, Ordering::Release); + entry + } + Entry::Vacant(vacant) => { + self.completed.remove(&key); + let (sender, _) = watch::channel(false); + let entry = Arc::new(TurnSettlementEntry { + sender, + registrations: AtomicUsize::new(1), + accepted: AtomicBool::new(true), + }); + vacant.insert(Arc::clone(&entry)); + entry + } + }; + TurnSettlementRegistration { + tracker: Arc::clone(self), + key, + entry, + } + } + + pub(super) async fn wait( + &self, + session_id: &str, + turn_id: &str, + max_wait: Duration, + ) -> TurnSettlementWait { + let key = (session_id.to_string(), turn_id.to_string()); + let mut receiver = { + let Some(entry) = self.active.get(&key) else { + return if self.completed.contains_key(&key) { + TurnSettlementWait::Settled + } else { + TurnSettlementWait::Unknown + }; + }; + entry.sender.subscribe() + }; + if *receiver.borrow() { + return TurnSettlementWait::Settled; + } + + let outcome = + match tokio::time::timeout(max_wait, receiver.wait_for(|settled| *settled)).await { + Ok(Ok(_)) => TurnSettlementWait::Settled, + Ok(Err(_)) => TurnSettlementWait::Unknown, + Err(_) => TurnSettlementWait::TimedOut, + }; + outcome + } + + fn remember_completed(&self, key: TurnKey, entry: Arc) { + self.completed.insert(key.clone(), Arc::clone(&entry)); + let mut order = self + .completed_order + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + order.push_back((key, entry)); + while order.len() > COMPLETED_TURN_CAPACITY { + let Some((old_key, old_entry)) = order.pop_front() else { + break; + }; + if self + .completed + .get(&old_key) + .is_some_and(|current| Arc::ptr_eq(current.value(), &old_entry)) + { + self.completed.remove(&old_key); + } + } + } +} + +pub(super) struct TurnSettlementRegistration { + tracker: Arc, + key: TurnKey, + entry: Arc, +} + +impl std::fmt::Debug for TurnSettlementRegistration { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TurnSettlementRegistration") + .field("session_id", &self.key.0) + .field("turn_id", &self.key.1) + .finish_non_exhaustive() + } +} + +impl Clone for TurnSettlementRegistration { + fn clone(&self) -> Self { + self.entry.registrations.fetch_add(1, Ordering::AcqRel); + Self { + tracker: Arc::clone(&self.tracker), + key: self.key.clone(), + entry: Arc::clone(&self.entry), + } + } +} + +impl TurnSettlementRegistration { + pub(super) fn accept(&self) { + self.entry.accepted.store(true, Ordering::Release); + } +} + +impl Drop for TurnSettlementRegistration { + fn drop(&mut self) { + let Entry::Occupied(occupied) = self.tracker.active.entry(self.key.clone()) else { + return; + }; + if !Arc::ptr_eq(occupied.get(), &self.entry) { + return; + } + let previous = self.entry.registrations.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous > 0, "turn settlement registration underflow"); + if previous == 1 { + if self.entry.accepted.load(Ordering::Acquire) { + self.entry.sender.send_replace(true); + self.tracker + .remember_completed(self.key.clone(), Arc::clone(&self.entry)); + } + occupied.remove(); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::time::Duration; + + use super::{TurnSettlementTracker, TurnSettlementWait}; + + #[tokio::test] + async fn settlement_is_scoped_to_the_exact_session_and_turn() { + let tracker = Arc::new(TurnSettlementTracker::default()); + let first = tracker.register_accepted("session-1", "turn-1"); + let second = tracker.register_accepted("session-1", "turn-2"); + + drop(second); + + assert_eq!( + tracker + .wait("session-1", "turn-2", Duration::from_millis(10)) + .await, + TurnSettlementWait::Settled + ); + assert_eq!( + tracker + .wait("session-1", "turn-1", Duration::from_millis(10)) + .await, + TurnSettlementWait::TimedOut + ); + + drop(first); + assert_eq!( + tracker + .wait("session-1", "turn-1", Duration::from_millis(10)) + .await, + TurnSettlementWait::Settled + ); + } + + #[tokio::test] + async fn recent_finished_turn_is_settled_and_unknown_turn_is_distinct() { + let tracker = Arc::new(TurnSettlementTracker::default()); + let registration = tracker.register_accepted("session-1", "turn-1"); + drop(registration); + + assert_eq!( + tracker + .wait("session-1", "turn-1", Duration::from_secs(1)) + .await, + TurnSettlementWait::Settled + ); + assert_eq!( + tracker + .wait("session-1", "turn-never-registered", Duration::from_secs(1)) + .await, + TurnSettlementWait::Unknown + ); + } + + #[test] + fn completion_before_subscribe_keeps_the_settled_value() { + let tracker = Arc::new(TurnSettlementTracker::default()); + let registration = tracker.register_accepted("session-1", "turn-1"); + let entry = Arc::clone( + tracker + .active + .get(&("session-1".to_string(), "turn-1".to_string())) + .expect("registered turn") + .value(), + ); + + drop(registration); + let receiver = entry.sender.subscribe(); + + assert!(*receiver.borrow()); + } + + #[tokio::test] + async fn cloned_registration_covers_queued_and_active_lifetimes() { + let tracker = Arc::new(TurnSettlementTracker::default()); + let queued = tracker.register_accepted("session-1", "turn-1"); + let active = queued.clone(); + + drop(queued); + assert_eq!( + tracker + .wait("session-1", "turn-1", Duration::from_millis(10)) + .await, + TurnSettlementWait::TimedOut + ); + + drop(active); + assert_eq!( + tracker + .wait("session-1", "turn-1", Duration::from_millis(10)) + .await, + TurnSettlementWait::Settled + ); + } + + #[tokio::test] + async fn rejected_registration_is_not_recorded_as_settled() { + let tracker = Arc::new(TurnSettlementTracker::default()); + let registration = tracker + .try_register_pending("session-1", "turn-rejected") + .expect("first registration"); + + drop(registration); + + assert_eq!( + tracker + .wait("session-1", "turn-rejected", Duration::from_millis(10),) + .await, + TurnSettlementWait::Unknown + ); + } + + #[test] + fn pending_registration_rejects_active_and_completed_turn_ids() { + let tracker = Arc::new(TurnSettlementTracker::default()); + let active = tracker + .try_register_pending("session-1", "turn-1") + .expect("first registration"); + assert!( + tracker + .try_register_pending("session-1", "turn-1") + .is_none(), + "an active turn ID must not create a second settlement generation" + ); + + active.accept(); + drop(active); + assert!( + tracker + .try_register_pending("session-1", "turn-1") + .is_none(), + "a recently completed turn ID must not be reused" + ); + } +} diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 46c5982591..9f2c9db505 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -348,17 +348,6 @@ impl SessionManager { Ok(()) } - pub(crate) async fn is_session_loaded_for_workspace_path( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult { - let storage_path = self - .resolve_storage_path_for_restore_workspace_path(workspace_path) - .await?; - self.is_session_loaded_from_storage_path(&storage_path, session_id) - } - pub(crate) fn is_session_loaded_from_storage_path( &self, storage_path: &Path, diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index e7966219ad..e31d1f9fe0 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -8,16 +8,24 @@ mod runtime_services; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Duration; -use bitfun_agent_runtime::sdk::{AgentEventSource, AgentRuntime}; +use bitfun_agent_runtime::sdk::{ + AgentEventSource, AgentRuntime, AgentSessionForkPort, AgentSessionForkRequest, + AgentSessionForkResult, AgentSessionUsagePort, AgentSessionUsageRequest, + AgentTurnSettlementPort, AgentTurnSettlementRequest, +}; use bitfun_harness::HarnessRegistry; -use bitfun_runtime_ports::{SessionStoragePathRequest, SessionStorePort, SessionViewRestoreTiming}; +use bitfun_runtime_ports::{ + PortError, PortErrorKind, PortResult, SessionStoragePathRequest, SessionStorePort, + SessionViewRestoreTiming, +}; use bitfun_runtime_services::RuntimeServices; use crate::agentic::coordination::{ ConversationCoordinator, DialogScheduler, SessionMaintenancePermit, }; -use crate::agentic::core::{Session, SessionConfig, SessionState}; +use crate::agentic::core::{Session, SessionConfig}; use crate::agentic::keyed_lock::KeyedAsyncLockGuard; use crate::agentic::persistence::session_branch::{SessionBranchRequest, SessionBranchResult}; use crate::agentic::persistence::{PersistenceManager, SessionMetadataPage}; @@ -79,12 +87,21 @@ impl CoreProductAgentRuntime { pub fn build( coordinator: Arc, scheduler: Arc, + token_usage_service: Arc, services: RuntimeServices, harness_registry: HarnessRegistry, ) -> Result { + let session_operations = Arc::new(CoreSessionOperationsPort::new( + coordinator.clone(), + scheduler.clone(), + token_usage_service, + )); CoreServiceAgentRuntime::product_agent_runtime( coordinator, scheduler, + session_operations.clone(), + session_operations.clone(), + session_operations, services, harness_registry, ) @@ -272,17 +289,6 @@ impl CoreAgentRuntimeCompatibility { } } - pub async fn is_session_loaded( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult { - self.coordinator - .get_session_manager() - .is_session_loaded_for_workspace_path(workspace_path, session_id) - .await - } - pub async fn branch_session_at_latest_turn( &self, workspace_path: &Path, @@ -632,17 +638,108 @@ impl CoreAgentRuntimeCompatibility { ) .await } +} + +#[derive(Clone)] +struct CoreSessionOperationsPort { + compatibility: CoreAgentRuntimeCompatibility, + coordinator: Arc, +} + +impl CoreSessionOperationsPort { + fn new( + coordinator: Arc, + scheduler: Arc, + token_usage_service: Arc, + ) -> Self { + Self { + compatibility: CoreAgentRuntimeCompatibility::build( + coordinator.clone(), + scheduler, + token_usage_service, + ), + coordinator, + } + } +} - pub fn is_turn_processing(&self, session_id: &str, turn_id: &str) -> bool { +fn runtime_port_error(error: BitFunError) -> PortError { + let kind = match &error { + BitFunError::Validation(_) => PortErrorKind::InvalidRequest, + BitFunError::NotFound(_) => PortErrorKind::NotFound, + BitFunError::Timeout(_) => PortErrorKind::Timeout, + BitFunError::Cancelled(_) => PortErrorKind::Cancelled, + _ => PortErrorKind::Backend, + }; + PortError::new(kind, error.to_string()) +} + +fn validate_local_session_fork_request(request: &AgentSessionForkRequest) -> PortResult<()> { + if request.remote_connection_id.is_some() || request.remote_ssh_host.is_some() { + return Err(PortError::new( + PortErrorKind::NotAvailable, + "Remote session fork is not supported by the local CLI runtime", + )); + } + Ok(()) +} + +#[async_trait::async_trait] +impl AgentSessionForkPort for CoreSessionOperationsPort { + async fn fork_session( + &self, + request: AgentSessionForkRequest, + ) -> PortResult { + validate_local_session_fork_request(&request)?; + let result = self + .compatibility + .branch_session_at_latest_turn( + Path::new(&request.workspace_path), + &request.source_session_id, + ) + .await + .map_err(runtime_port_error)?; + Ok(AgentSessionForkResult { + session_id: result.session_id, + session_name: result.session_name, + agent_type: result.agent_type, + }) + } +} + +#[async_trait::async_trait] +impl AgentSessionUsagePort for CoreSessionOperationsPort { + async fn generate_session_usage( + &self, + request: AgentSessionUsageRequest, + ) -> PortResult { + self.compatibility + .generate_session_usage_report(request) + .await + .map_err(runtime_port_error) + } +} + +#[async_trait::async_trait] +impl AgentTurnSettlementPort for CoreSessionOperationsPort { + async fn wait_for_turn_settlement( + &self, + request: AgentTurnSettlementRequest, + ) -> PortResult<()> { + if request.wait_timeout_ms == 0 { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "turn settlement timeout must be greater than zero", + )); + } self.coordinator - .get_session_manager() - .get_session(session_id) - .is_some_and(|session| { - matches!( - session.state, - SessionState::Processing { current_turn_id, .. } if current_turn_id == turn_id - ) - }) + .wait_for_turn_settlement( + &request.session_id, + &request.turn_id, + Duration::from_millis(request.wait_timeout_ms), + ) + .await + .map_err(runtime_port_error) } } @@ -655,20 +752,29 @@ mod tests { use bitfun_runtime_services::RuntimeServices; use super::{ - validate_persisted_session_id, CoreAgentRuntimeCompatibility, CoreProductAgentRuntime, + validate_local_session_fork_request, validate_persisted_session_id, + CoreAgentRuntimeCompatibility, CoreProductAgentRuntime, }; use crate::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use crate::service::token_usage::TokenUsageService; + use bitfun_agent_runtime::sdk::{AgentSessionForkRequest, PortErrorKind}; #[test] fn product_agent_runtime_has_one_sdk_safe_builder_boundary() { fn build( coordinator: Arc, scheduler: Arc, + token_usage_service: Arc, services: RuntimeServices, harness_registry: HarnessRegistry, ) -> Result { - CoreProductAgentRuntime::build(coordinator, scheduler, services, harness_registry) + CoreProductAgentRuntime::build( + coordinator, + scheduler, + token_usage_service, + services, + harness_registry, + ) } let _ = build; @@ -693,7 +799,6 @@ mod tests { let _ = CoreAgentRuntimeCompatibility::list_persisted_sessions; let _ = CoreAgentRuntimeCompatibility::load_persisted_session_turns; let _ = CoreAgentRuntimeCompatibility::update_session_agent_type; - let _ = CoreAgentRuntimeCompatibility::is_turn_processing; } #[test] @@ -703,4 +808,17 @@ mod tests { assert!(error.to_string().contains("session_id"), "{error}"); } + + #[test] + fn local_session_fork_rejects_remote_identity() { + let error = validate_local_session_fork_request(&AgentSessionForkRequest { + workspace_path: "D:/workspace/project".to_string(), + source_session_id: "session-1".to_string(), + remote_connection_id: Some("remote-1".to_string()), + remote_ssh_host: None, + }) + .expect_err("the local fork provider must reject remote identity"); + + assert_eq!(error.kind, PortErrorKind::NotAvailable); + } } diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index 16ff8114c4..60a6a11feb 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -15,23 +15,10 @@ use crate::service::token_usage::{ }; use crate::util::errors::{BitFunError, BitFunResult}; use chrono::Utc; -use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::Path; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SessionUsageReportRequest { - pub session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_path: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, - #[serde(default)] - pub include_hidden_subagents: bool, -} +pub use bitfun_runtime_ports::AgentSessionUsageRequest as SessionUsageReportRequest; pub async fn generate_session_usage_report( persistence_manager: &PersistenceManager, diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index d435d76355..93533622cb 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -7,7 +7,8 @@ use bitfun_agent_runtime::sdk::{ AgentEventSource, AgentInteractionResponsePort, AgentRuntime, AgentRuntimeBuilder, - AgentSessionModelPort, AgentSessionModelUpdateRequest, AgentSessionRestorePort, RuntimeError, + AgentSessionForkPort, AgentSessionModelPort, AgentSessionModelUpdateRequest, + AgentSessionRestorePort, AgentSessionUsagePort, AgentTurnSettlementPort, RuntimeError, }; use bitfun_runtime_ports::{ AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, @@ -908,6 +909,9 @@ impl CoreServiceAgentRuntime { pub(crate) fn product_agent_runtime( coordinator: Arc, scheduler: Arc, + session_fork: Arc, + session_usage: Arc, + turn_settlement: Arc, services: bitfun_runtime_services::RuntimeServices, harness_registry: bitfun_harness::HarnessRegistry, ) -> Result { @@ -917,6 +921,9 @@ impl CoreServiceAgentRuntime { scheduler, dialog_turn, None, + Some(session_fork), + Some(session_usage), + Some(turn_settlement), services, harness_registry, ) @@ -936,6 +943,9 @@ impl CoreServiceAgentRuntime { scheduler, dialog_turn, Some(event_source), + None, + None, + None, services, harness_registry, ) @@ -946,6 +956,9 @@ impl CoreServiceAgentRuntime { scheduler: Arc, dialog_turn: Arc, event_source: Option, + session_fork: Option>, + session_usage: Option>, + turn_settlement: Option>, services: bitfun_runtime_services::RuntimeServices, harness_registry: bitfun_harness::HarnessRegistry, ) -> Result { @@ -977,6 +990,18 @@ impl CoreServiceAgentRuntime { Some(event_source) => builder.with_event_source(event_source), None => builder, }; + let builder = match session_fork { + Some(port) => builder.with_session_fork_port(port), + None => builder, + }; + let builder = match session_usage { + Some(port) => builder.with_session_usage_port(port), + None => builder, + }; + let builder = match turn_settlement { + Some(port) => builder.with_turn_settlement_port(port), + None => builder, + }; builder .with_services(services) .with_harness_registry(Arc::new(harness_registry)) @@ -1163,6 +1188,10 @@ impl CoreRemoteInteractionRuntimeHost { } } +fn generate_remote_turn_id() -> String { + format!("turn_{}", uuid::Uuid::new_v4()) +} + #[async_trait::async_trait] impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { type ImageContext = ImageContextData; @@ -1265,7 +1294,7 @@ impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { } fn generate_turn_id(&self) -> String { - format!("turn_{}", chrono::Utc::now().timestamp_millis()) + generate_remote_turn_id() } async fn submit_dialog( @@ -1640,6 +1669,8 @@ impl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost { #[cfg(test)] mod tests { + use std::collections::HashSet; + use bitfun_runtime_ports::SessionTranscriptReader; use super::*; @@ -1665,6 +1696,19 @@ mod tests { assert_runtime_ports::(); } + #[test] + fn remote_generated_turn_ids_are_uuid_unique() { + let ids = (0..1_024) + .map(|_| generate_remote_turn_id()) + .collect::>(); + + assert_eq!(ids.len(), 1_024); + assert!(ids.iter().all(|id| { + id.strip_prefix("turn_") + .is_some_and(|value| uuid::Uuid::parse_str(value).is_ok()) + })); + } + #[test] fn core_service_agent_runtime_owner_keeps_scheduler_lifecycle_port_contracts() { fn assert_scheduler_ports() diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index ea080e445e..33d67af08e 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -7,6 +7,7 @@ pub mod ai; pub mod errors; pub mod lsp; pub mod session; +pub mod session_usage; pub mod surface; pub mod tool_image_attachment; @@ -19,6 +20,7 @@ pub use errors::{AiErrorDetail, ErrorCategory}; pub use session::{ validate_session_id, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, }; +pub use session_usage::*; pub use surface::{ ApprovalSource, CapabilityRequest, CapabilityRequestKind, PermissionDecision, PermissionScope, RuntimeArtifactKind, RuntimeArtifactRef, SurfaceKind, ThreadEnvironment, ThreadEnvironmentKind, diff --git a/src/crates/contracts/core-types/src/session_usage.rs b/src/crates/contracts/core-types/src/session_usage.rs new file mode 100644 index 0000000000..39e077e213 --- /dev/null +++ b/src/crates/contracts/core-types/src/session_usage.rs @@ -0,0 +1,591 @@ +use serde::{Deserialize, Serialize}; + +pub const SESSION_USAGE_REPORT_SCHEMA_VERSION: u16 = 1; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageToolCategory { + Git, + Shell, + File, + Other, +} + +// PartialEq only (not Eq) because nested UsageTokenBreakdown/UsageModelBreakdown +// hold `cache_hit_rate: Option`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SessionUsageReport { + pub schema_version: u16, + pub report_id: String, + pub session_id: String, + pub generated_at: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generated_from_app_version: Option, + pub workspace: UsageWorkspace, + pub scope: UsageScope, + pub coverage: UsageCoverage, + pub time: UsageTimeBreakdown, + pub tokens: UsageTokenBreakdown, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub tools: Vec, + pub files: UsageFileBreakdown, + pub compression: UsageCompressionBreakdown, + pub errors: UsageErrorBreakdown, + #[serde(default)] + pub slowest: Vec, + pub privacy: UsagePrivacy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageWorkspace { + pub kind: UsageWorkspaceKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageWorkspaceKind { + Local, + RemoteSsh, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageScope { + pub kind: UsageScopeKind, + pub turn_count: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub from_turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to_turn_id: Option, + pub includes_subagents: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageScopeKind { + EntireSession, + TurnRange, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageCoverage { + pub level: UsageCoverageLevel, + #[serde(default)] + pub available: Vec, + #[serde(default)] + pub missing: Vec, + #[serde(default)] + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageCoverageLevel { + Complete, + Partial, + Minimal, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum UsageCoverageKey { + ModelRoundTiming, + ToolPhaseTiming, + CachedTokens, + TokenDetailBreakdown, + SubagentScope, + RemoteSnapshotStats, + FileLineStats, + WorkspaceIdentity, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageTimeBreakdown { + pub accounting: UsageTimeAccounting, + pub denominator: UsageTimeDenominator, + /// First recorded turn start to last recorded turn end; may include idle gaps. + pub wall_time_ms: Option, + /// Sum of persisted turn durations; may include orchestration/waiting inside a turn. + pub active_turn_ms: Option, + /// Sum of persisted model-round spans, not provider streaming throughput. + pub model_ms: Option, + pub tool_ms: Option, + pub idle_gap_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageTimeAccounting { + Approximate, + Exact, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageTimeDenominator { + SessionWallTime, + ActiveTurnTime, + Unavailable, +} + +// PartialEq only (not Eq) because `cache_hit_rate: Option` precludes +// total equality. Existing call sites compare with `==`, which works on f64 +// via PartialEq (NaN-aware). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UsageTokenBreakdown { + pub source: UsageTokenSource, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cached_tokens: Option, + pub cache_coverage: UsageCacheCoverage, + /// `cached_tokens / input_tokens` over records that explicitly report + /// cached tokens. `None` when no record has cached coverage. Range: 0.0–1.0. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_hit_rate: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageTokenSource { + TokenUsageRecords, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageCacheCoverage { + Available, + Partial, + Unavailable, +} + +// PartialEq only (not Eq) — see comment on UsageTokenBreakdown. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UsageModelBreakdown { + pub model_id: String, + pub call_count: u64, + pub input_tokens: Option, + pub output_tokens: Option, + pub total_tokens: Option, + pub cached_tokens: Option, + /// Per-model hit rate. Same semantic as [`UsageTokenBreakdown::cache_hit_rate`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_hit_rate: Option, + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_turn_index: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageToolBreakdown { + pub tool_name: String, + pub category: UsageToolCategory, + pub call_count: u64, + pub success_count: u64, + pub error_count: u64, + pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub p95_duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queue_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preflight_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirmation_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_turn_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_item_id: Option, + pub redacted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageFileBreakdown { + pub scope: UsageFileScope, + pub changed_files: Option, + pub added_lines: Option, + pub deleted_lines: Option, + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageFileScope { + SnapshotSummary, + ToolInputsOnly, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageFileRow { + pub path_label: String, + pub operation_count: u64, + pub added_lines: Option, + pub deleted_lines: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub turn_indexes: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub operation_ids: Vec, + pub redacted: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageSnapshotFacts { + pub source_available: bool, + #[serde(default)] + pub operations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageSnapshotOperationSummary { + pub operation_id: String, + pub session_id: String, + pub turn_index: usize, + pub file_path: String, + pub lines_added: u64, + pub lines_removed: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageCompressionBreakdown { + pub compaction_count: u64, + pub manual_compaction_count: u64, + pub automatic_compaction_count: u64, + pub saved_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageErrorBreakdown { + pub total_errors: u64, + pub tool_errors: u64, + pub model_errors: u64, + #[serde(default)] + pub examples: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageErrorExample { + pub label: String, + pub count: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_turn_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sample_item_id: Option, + pub redacted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsageSlowSpan { + pub label: String, + pub kind: UsageSlowSpanKind, + pub duration_ms: u64, + pub redacted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub item_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timed_out: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_summary: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queue_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preflight_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirmation_wait_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_ms: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UsageSlowSpanKind { + Model, + Tool, + Turn, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UsagePrivacy { + pub prompt_content_included: bool, + pub tool_inputs_included: bool, + pub command_outputs_included: bool, + pub file_contents_included: bool, + #[serde(default)] + pub redacted_fields: Vec, +} + +impl SessionUsageReport { + pub fn partial_unavailable(session_id: impl Into, generated_at: i64) -> Self { + Self { + schema_version: SESSION_USAGE_REPORT_SCHEMA_VERSION, + report_id: format!("usage-{}", generated_at), + session_id: session_id.into(), + generated_at, + generated_from_app_version: None, + workspace: UsageWorkspace { + kind: UsageWorkspaceKind::Unknown, + path_label: None, + workspace_id: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + scope: UsageScope { + kind: UsageScopeKind::EntireSession, + turn_count: 0, + from_turn_id: None, + to_turn_id: None, + includes_subagents: false, + }, + coverage: UsageCoverage { + level: UsageCoverageLevel::Partial, + available: vec![], + missing: vec![ + UsageCoverageKey::CachedTokens, + UsageCoverageKey::TokenDetailBreakdown, + ], + notes: vec![ + "Report uses available persisted session facts and lists unavailable metrics explicitly." + .to_string(), + ], + }, + time: UsageTimeBreakdown { + accounting: UsageTimeAccounting::Unavailable, + denominator: UsageTimeDenominator::Unavailable, + wall_time_ms: None, + active_turn_ms: None, + model_ms: None, + tool_ms: None, + idle_gap_ms: None, + }, + tokens: UsageTokenBreakdown { + source: UsageTokenSource::Unavailable, + input_tokens: None, + output_tokens: None, + total_tokens: None, + cached_tokens: None, + cache_coverage: UsageCacheCoverage::Unavailable, + cache_hit_rate: None, + }, + models: vec![], + tools: vec![], + files: UsageFileBreakdown { + scope: UsageFileScope::Unavailable, + changed_files: None, + added_lines: None, + deleted_lines: None, + files: vec![], + }, + compression: UsageCompressionBreakdown { + compaction_count: 0, + manual_compaction_count: 0, + automatic_compaction_count: 0, + saved_tokens: None, + }, + errors: UsageErrorBreakdown { + total_errors: 0, + tool_errors: 0, + model_errors: 0, + examples: vec![], + }, + slowest: vec![], + privacy: UsagePrivacy { + prompt_content_included: false, + tool_inputs_included: false, + command_outputs_included: false, + file_contents_included: false, + redacted_fields: vec![], + }, + } + } +} + +#[cfg(test)] +pub(crate) fn test_report() -> SessionUsageReport { + let mut report = SessionUsageReport::partial_unavailable("session-1", 1_778_347_200_000); + report.report_id = "usage-session-1-1778347200000".to_string(); + report.workspace = UsageWorkspace { + kind: UsageWorkspaceKind::Local, + path_label: Some("D:/workspace/bitfun".to_string()), + workspace_id: Some("workspace-1".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + }; + report.scope = UsageScope { + kind: UsageScopeKind::EntireSession, + turn_count: 3, + from_turn_id: Some("turn-1".to_string()), + to_turn_id: Some("turn-3".to_string()), + includes_subagents: true, + }; + report.coverage.available = vec![UsageCoverageKey::WorkspaceIdentity]; + report.coverage.missing = vec![ + UsageCoverageKey::CachedTokens, + UsageCoverageKey::TokenDetailBreakdown, + ]; + report.time = UsageTimeBreakdown { + accounting: UsageTimeAccounting::Approximate, + denominator: UsageTimeDenominator::SessionWallTime, + wall_time_ms: Some(62_000), + active_turn_ms: Some(51_000), + model_ms: None, + tool_ms: Some(12_000), + idle_gap_ms: Some(11_000), + }; + report.tokens = UsageTokenBreakdown { + source: UsageTokenSource::TokenUsageRecords, + input_tokens: Some(1200), + output_tokens: Some(340), + total_tokens: Some(1540), + cached_tokens: None, + cache_coverage: UsageCacheCoverage::Unavailable, + cache_hit_rate: None, + }; + report.models = vec![UsageModelBreakdown { + model_id: "test-model".to_string(), + call_count: 2, + input_tokens: Some(1200), + output_tokens: Some(340), + total_tokens: Some(1540), + cached_tokens: None, + cache_hit_rate: None, + duration_ms: None, + sample_turn_id: Some("turn-1".to_string()), + sample_turn_index: Some(0), + }]; + report.tools = vec![UsageToolBreakdown { + tool_name: "read_file".to_string(), + category: UsageToolCategory::File, + call_count: 1, + success_count: 1, + error_count: 0, + duration_ms: Some(1200), + p95_duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + sample_turn_id: Some("turn-1".to_string()), + sample_turn_index: Some(0), + sample_item_id: Some("tool-1".to_string()), + redacted: false, + }]; + report.files = UsageFileBreakdown { + scope: UsageFileScope::ToolInputsOnly, + changed_files: Some(1), + added_lines: None, + deleted_lines: None, + files: vec![UsageFileRow { + path_label: "src/main.rs".to_string(), + operation_count: 1, + added_lines: None, + deleted_lines: None, + session_id: None, + turn_indexes: vec![], + operation_ids: vec![], + redacted: false, + }], + }; + report +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_usage_report_round_trips_with_partial_coverage() { + let report = test_report(); + let json = serde_json::to_string(&report).expect("serialize report"); + let restored: SessionUsageReport = serde_json::from_str(&json).expect("deserialize report"); + + assert_eq!(restored, report); + assert_eq!( + restored.coverage.level, + UsageCoverageLevel::Partial, + "Partial usage reports must make partial coverage explicit" + ); + } + + #[test] + fn session_usage_report_round_trips_with_workspace_scope_and_privacy() { + let mut report = test_report(); + report.workspace.kind = UsageWorkspaceKind::RemoteSsh; + report.workspace.remote_ssh_host = Some("example.internal".to_string()); + report + .privacy + .redacted_fields + .push("slowest.label".to_string()); + + let json = serde_json::to_string(&report).expect("serialize report"); + + assert!(json.contains("remote_ssh")); + assert!(json.contains("redactedFields")); + + let restored: SessionUsageReport = serde_json::from_str(&json).expect("deserialize report"); + assert_eq!(restored.workspace.kind, UsageWorkspaceKind::RemoteSsh); + assert_eq!(restored.privacy.redacted_fields, vec!["slowest.label"]); + } + + #[test] + fn token_cache_unavailable_does_not_require_cached_value() { + let report = test_report(); + + assert_eq!( + report.tokens.cache_coverage, + UsageCacheCoverage::Unavailable + ); + assert_eq!(report.tokens.cached_tokens, None); + } +} diff --git a/src/crates/contracts/core-types/tests/session_usage_contracts.rs b/src/crates/contracts/core-types/tests/session_usage_contracts.rs new file mode 100644 index 0000000000..49cbc16926 --- /dev/null +++ b/src/crates/contracts/core-types/tests/session_usage_contracts.rs @@ -0,0 +1,29 @@ +use bitfun_core_types::{ + SessionUsageReport, UsageCoverageLevel, UsageToolCategory, SESSION_USAGE_REPORT_SCHEMA_VERSION, +}; + +#[test] +fn session_usage_report_is_a_shared_stable_contract() { + let mut report = SessionUsageReport::partial_unavailable("session-1", 1_778_347_200_000); + report.report_id = "usage-session-1-1778347200000".to_string(); + + let json = serde_json::to_value(&report).expect("serialize usage report"); + let restored: SessionUsageReport = + serde_json::from_value(json.clone()).expect("deserialize usage report"); + + assert_eq!(restored, report); + assert_eq!( + json["schemaVersion"], + serde_json::json!(SESSION_USAGE_REPORT_SCHEMA_VERSION) + ); + assert_eq!(json["sessionId"], "session-1"); + assert_eq!(report.coverage.level, UsageCoverageLevel::Partial); +} + +#[test] +fn usage_tool_category_keeps_the_persisted_wire_values() { + assert_eq!( + serde_json::to_value(UsageToolCategory::Git).expect("serialize category"), + "git" + ); +} diff --git a/src/crates/contracts/runtime-ports/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index e6962a9712..b25e741a84 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -12,6 +12,7 @@ crate-type = ["rlib"] [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +bitfun-core-types = { path = "../core-types" } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 1dcc1c668f..320a83a89b 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -1024,6 +1024,47 @@ pub struct AgentSessionModelUpdateRequest { pub model_id: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionForkRequest { + pub workspace_path: String, + pub source_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionForkResult { + pub session_id: String, + pub session_name: String, + pub agent_type: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionUsageRequest { + pub session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, + #[serde(default)] + pub include_hidden_subagents: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTurnSettlementRequest { + pub session_id: String, + pub turn_id: String, + pub wait_timeout_ms: u64, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionWorkspaceRequest { @@ -1703,6 +1744,28 @@ pub trait AgentSessionModelPort: Send + Sync { -> PortResult<()>; } +#[async_trait::async_trait] +pub trait AgentSessionForkPort: Send + Sync { + async fn fork_session( + &self, + request: AgentSessionForkRequest, + ) -> PortResult; +} + +#[async_trait::async_trait] +pub trait AgentSessionUsagePort: Send + Sync { + async fn generate_session_usage( + &self, + request: AgentSessionUsageRequest, + ) -> PortResult; +} + +#[async_trait::async_trait] +pub trait AgentTurnSettlementPort: Send + Sync { + async fn wait_for_turn_settlement(&self, request: AgentTurnSettlementRequest) + -> PortResult<()>; +} + #[async_trait::async_trait] pub trait AgentDialogTurnPort: Send + Sync { async fn submit_dialog_turn( diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index ac72dc712e..f54183462c 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -12,15 +12,18 @@ use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, AgentSessionCreateRequest, - AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, + AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionForkPort, + AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModelPort, AgentSessionModelUpdateRequest, - AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, - AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentSessionSummary, AgentSessionUsagePort, AgentSessionUsageRequest, + AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, + AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, - AgentTurnCancellationRequest, AgentTurnCancellationResult, DialogSubmitOutcome, - PluginRuntimeBinding, PortError, PortErrorKind, PortResult, RuntimeEventEnvelope, - SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, ThreadGoal, + AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, + AgentTurnSettlementRequest, DialogSubmitOutcome, PluginRuntimeBinding, PortError, + PortErrorKind, PortResult, RuntimeEventEnvelope, SessionTranscript, SessionTranscriptReader, + SessionTranscriptRequest, ThreadGoal, }; use bitfun_runtime_services::RuntimeServices; @@ -185,6 +188,9 @@ pub struct AgentRuntime { submission: Arc, session_management: Option>, session_model: Option>, + session_fork: Option>, + session_usage: Option>, + turn_settlement: Option>, session_restore: Option>, session_transcript_reader: Option>, thread_goal_management: Option>, @@ -220,6 +226,27 @@ impl std::fmt::Debug for AgentRuntime { .as_ref() .map(|_| ""), ) + .field( + "session_fork", + &self + .session_fork + .as_ref() + .map(|_| ""), + ) + .field( + "session_usage", + &self + .session_usage + .as_ref() + .map(|_| ""), + ) + .field( + "turn_settlement", + &self + .turn_settlement + .as_ref() + .map(|_| ""), + ) .field( "session_restore", &self @@ -320,6 +347,9 @@ pub struct AgentRuntimeBuilder { submission: Option>, session_management: Option>, session_model: Option>, + session_fork: Option>, + session_usage: Option>, + turn_settlement: Option>, session_restore: Option>, session_transcript_reader: Option>, thread_goal_management: Option>, @@ -360,6 +390,21 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_fork_port(mut self, port: Arc) -> Self { + self.session_fork = Some(port); + self + } + + pub fn with_session_usage_port(mut self, port: Arc) -> Self { + self.session_usage = Some(port); + self + } + + pub fn with_turn_settlement_port(mut self, port: Arc) -> Self { + self.turn_settlement = Some(port); + self + } + pub fn with_session_restore_port(mut self, port: Arc) -> Self { self.session_restore = Some(port); self @@ -452,6 +497,9 @@ impl AgentRuntimeBuilder { submission, session_management, session_model, + session_fork, + session_usage, + turn_settlement, session_restore, session_transcript_reader, thread_goal_management, @@ -477,6 +525,9 @@ impl AgentRuntimeBuilder { submission: submission.ok_or(RuntimeBuildError::MissingSubmissionPort)?, session_management, session_model, + session_fork, + session_usage, + turn_settlement, session_restore, session_transcript_reader, thread_goal_management, @@ -756,6 +807,49 @@ impl AgentRuntime { .map_err(RuntimeError::from) } + pub async fn fork_session( + &self, + request: AgentSessionForkRequest, + ) -> Result { + let port = self.session_fork.as_ref().ok_or_else(|| { + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent session fork port is not registered", + )) + })?; + port.fork_session(request).await.map_err(RuntimeError::from) + } + + pub async fn generate_session_usage( + &self, + request: AgentSessionUsageRequest, + ) -> Result { + let port = self.session_usage.as_ref().ok_or_else(|| { + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent session usage port is not registered", + )) + })?; + port.generate_session_usage(request) + .await + .map_err(RuntimeError::from) + } + + pub async fn wait_for_turn_settlement( + &self, + request: AgentTurnSettlementRequest, + ) -> Result<(), RuntimeError> { + let port = self.turn_settlement.as_ref().ok_or_else(|| { + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent turn settlement port is not registered", + )) + })?; + port.wait_for_turn_settlement(request) + .await + .map_err(RuntimeError::from) + } + pub async fn restore_session( &self, request: AgentSessionRestoreRequest, diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index fc2f482cc5..c96443fbac 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -49,6 +49,7 @@ pub use crate::runtime::{ }; pub use crate::session_state::{session_state_label_for_state, ProcessingPhase, SessionState}; pub use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; +pub use bitfun_core_types::SessionUsageReport; pub use bitfun_harness::{ build_descriptor_harness_registry, HarnessCapability, HarnessProviderDescriptor, HarnessRegistry, HarnessWorkflow, @@ -56,15 +57,18 @@ pub use bitfun_harness::{ pub use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, AgentSessionCreateRequest, - AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, + AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionForkPort, + AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionModelPort, AgentSessionModelUpdateRequest, - AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, - AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentSessionSummary, AgentSessionUsagePort, AgentSessionUsageRequest, + AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, + AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, - AgentTurnCancellationRequest, AgentTurnCancellationResult, ClockPort, DialogSubmissionPolicy, - DialogSubmitOutcome, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionDecision, - PermissionPort, PermissionRequest, PortError, PortResult, RemoteAssistantWorkspaceFacts, + AgentTurnCancellationRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, + AgentTurnSettlementRequest, ClockPort, DialogSubmissionPolicy, DialogSubmitOutcome, + FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionDecision, PermissionPort, + PermissionRequest, PortError, PortErrorKind, PortResult, RemoteAssistantWorkspaceFacts, RemoteCapabilityPort, RemoteConnectionPort, RemoteProjectionPort, RemoteRecentWorkspaceFacts, RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, RuntimeEventEnvelope, RuntimeEventSink, @@ -119,6 +123,21 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_fork_port(mut self, port: Arc) -> Self { + self.inner = self.inner.with_session_fork_port(port); + self + } + + pub fn with_session_usage_port(mut self, port: Arc) -> Self { + self.inner = self.inner.with_session_usage_port(port); + self + } + + pub fn with_turn_settlement_port(mut self, port: Arc) -> Self { + self.inner = self.inner.with_turn_settlement_port(port); + self + } + pub fn with_session_restore_port(mut self, port: Arc) -> Self { self.inner = self.inner.with_session_restore_port(port); self @@ -274,6 +293,27 @@ impl AgentRuntime { self.inner.update_session_model(request).await } + pub async fn fork_session( + &self, + request: AgentSessionForkRequest, + ) -> Result { + self.inner.fork_session(request).await + } + + pub async fn generate_session_usage( + &self, + request: AgentSessionUsageRequest, + ) -> Result { + self.inner.generate_session_usage(request).await + } + + pub async fn wait_for_turn_settlement( + &self, + request: AgentTurnSettlementRequest, + ) -> Result<(), RuntimeError> { + self.inner.wait_for_turn_settlement(request).await + } + pub async fn restore_session( &self, request: AgentSessionRestoreRequest, diff --git a/src/crates/execution/agent-runtime/tests/session_operation_ports.rs b/src/crates/execution/agent-runtime/tests/session_operation_ports.rs new file mode 100644 index 0000000000..11867efcd6 --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/session_operation_ports.rs @@ -0,0 +1,155 @@ +use std::sync::{Arc, Mutex}; + +use bitfun_agent_runtime::sdk::{ + AgentRuntimeBuilder, AgentSessionForkPort, AgentSessionForkRequest, AgentSessionForkResult, + AgentSessionUsagePort, AgentSessionUsageRequest, AgentSubmissionPort, AgentSubmissionRequest, + AgentSubmissionResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, PortErrorKind, + PortResult, SessionUsageReport, +}; +use bitfun_agent_runtime::sdk::{AgentSessionCreateRequest, AgentSessionCreateResult}; + +#[derive(Default)] +struct FakeSubmissionPort; + +#[async_trait::async_trait] +impl AgentSubmissionPort for FakeSubmissionPort { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + Ok(AgentSessionCreateResult { + session_id: "session-1".to_string(), + session_name: request.session_name, + agent_type: request.agent_type, + }) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + Ok(AgentSubmissionResult { + turn_id: request.turn_id.unwrap_or_else(|| "turn-1".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type(&self, _session_id: &str) -> PortResult> { + Ok(Some("agentic".to_string())) + } +} + +#[derive(Default)] +struct RecordingSessionOperations { + settlement_requests: Mutex>, +} + +#[async_trait::async_trait] +impl AgentSessionForkPort for RecordingSessionOperations { + async fn fork_session( + &self, + request: AgentSessionForkRequest, + ) -> PortResult { + assert_eq!(request.workspace_path, "D:/workspace/project"); + assert_eq!(request.source_session_id, "session-1"); + assert_eq!(request.remote_connection_id, None); + assert_eq!(request.remote_ssh_host, None); + Ok(AgentSessionForkResult { + session_id: "session-2".to_string(), + session_name: "Main (fork)".to_string(), + agent_type: "agentic".to_string(), + }) + } +} + +#[async_trait::async_trait] +impl AgentSessionUsagePort for RecordingSessionOperations { + async fn generate_session_usage( + &self, + request: AgentSessionUsageRequest, + ) -> PortResult { + Ok(SessionUsageReport::partial_unavailable( + request.session_id, + 1_778_347_200_000, + )) + } +} + +#[async_trait::async_trait] +impl AgentTurnSettlementPort for RecordingSessionOperations { + async fn wait_for_turn_settlement( + &self, + request: AgentTurnSettlementRequest, + ) -> PortResult<()> { + self.settlement_requests.lock().unwrap().push(request); + Ok(()) + } +} + +#[tokio::test] +async fn runtime_delegates_narrow_session_operations_to_registered_ports() { + let operations = Arc::new(RecordingSessionOperations::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeSubmissionPort)) + .with_session_fork_port(operations.clone()) + .with_session_usage_port(operations.clone()) + .with_turn_settlement_port(operations.clone()) + .build() + .expect("runtime"); + + let fork = runtime + .fork_session(AgentSessionForkRequest { + workspace_path: "D:/workspace/project".to_string(), + source_session_id: "session-1".to_string(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + .expect("fork session"); + assert_eq!(fork.session_id, "session-2"); + + let report = runtime + .generate_session_usage(AgentSessionUsageRequest { + session_id: "session-1".to_string(), + workspace_path: Some("D:/workspace/project".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: true, + }) + .await + .expect("generate usage"); + assert_eq!(report.session_id, "session-1"); + runtime + .wait_for_turn_settlement(AgentTurnSettlementRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + wait_timeout_ms: 5_000, + }) + .await + .expect("wait for turn settlement"); + + assert_eq!(operations.settlement_requests.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn missing_new_ports_preserve_the_v1_runtime_error_shape() { + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeSubmissionPort)) + .build() + .expect("runtime"); + + let error = runtime + .wait_for_turn_settlement(AgentTurnSettlementRequest { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + wait_timeout_ms: 5_000, + }) + .await + .expect_err("missing port must fail"); + + assert!(matches!( + error, + bitfun_agent_runtime::sdk::RuntimeError::Port(ref port_error) + if port_error.kind == PortErrorKind::NotAvailable + )); +} diff --git a/src/crates/services/services-core/src/session_usage/classifier.rs b/src/crates/services/services-core/src/session_usage/classifier.rs index f0adc5b5e5..d9831adaa8 100644 --- a/src/crates/services/services-core/src/session_usage/classifier.rs +++ b/src/crates/services/services-core/src/session_usage/classifier.rs @@ -1,13 +1,4 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageToolCategory { - Git, - Shell, - File, - Other, -} +pub use bitfun_core_types::UsageToolCategory; pub fn classify_tool_usage( tool_name: &str, diff --git a/src/crates/services/services-core/src/session_usage/types.rs b/src/crates/services/services-core/src/session_usage/types.rs index e7cf9e9e08..2d59053222 100644 --- a/src/crates/services/services-core/src/session_usage/types.rs +++ b/src/crates/services/services-core/src/session_usage/types.rs @@ -1,446 +1,12 @@ -use serde::{Deserialize, Serialize}; - -pub const SESSION_USAGE_REPORT_SCHEMA_VERSION: u16 = 1; - -// PartialEq only (not Eq) because nested UsageTokenBreakdown/UsageModelBreakdown -// hold `cache_hit_rate: Option`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct SessionUsageReport { - pub schema_version: u16, - pub report_id: String, - pub session_id: String, - pub generated_at: i64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub generated_from_app_version: Option, - pub workspace: UsageWorkspace, - pub scope: UsageScope, - pub coverage: UsageCoverage, - pub time: UsageTimeBreakdown, - pub tokens: UsageTokenBreakdown, - #[serde(default)] - pub models: Vec, - #[serde(default)] - pub tools: Vec, - pub files: UsageFileBreakdown, - pub compression: UsageCompressionBreakdown, - pub errors: UsageErrorBreakdown, - #[serde(default)] - pub slowest: Vec, - pub privacy: UsagePrivacy, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageWorkspace { - pub kind: UsageWorkspaceKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path_label: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageWorkspaceKind { - Local, - RemoteSsh, - Unknown, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageScope { - pub kind: UsageScopeKind, - pub turn_count: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub from_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub to_turn_id: Option, - pub includes_subagents: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageScopeKind { - EntireSession, - TurnRange, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageCoverage { - pub level: UsageCoverageLevel, - #[serde(default)] - pub available: Vec, - #[serde(default)] - pub missing: Vec, - #[serde(default)] - pub notes: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageCoverageLevel { - Complete, - Partial, - Minimal, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -#[serde(rename_all = "snake_case")] -pub enum UsageCoverageKey { - ModelRoundTiming, - ToolPhaseTiming, - CachedTokens, - TokenDetailBreakdown, - SubagentScope, - RemoteSnapshotStats, - FileLineStats, - WorkspaceIdentity, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageTimeBreakdown { - pub accounting: UsageTimeAccounting, - pub denominator: UsageTimeDenominator, - /// First recorded turn start to last recorded turn end; may include idle gaps. - pub wall_time_ms: Option, - /// Sum of persisted turn durations; may include orchestration/waiting inside a turn. - pub active_turn_ms: Option, - /// Sum of persisted model-round spans, not provider streaming throughput. - pub model_ms: Option, - pub tool_ms: Option, - pub idle_gap_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageTimeAccounting { - Approximate, - Exact, - Unavailable, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageTimeDenominator { - SessionWallTime, - ActiveTurnTime, - Unavailable, -} - -// PartialEq only (not Eq) because `cache_hit_rate: Option` precludes -// total equality. Existing call sites compare with `==`, which works on f64 -// via PartialEq (NaN-aware). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UsageTokenBreakdown { - pub source: UsageTokenSource, - pub input_tokens: Option, - pub output_tokens: Option, - pub total_tokens: Option, - pub cached_tokens: Option, - pub cache_coverage: UsageCacheCoverage, - /// `cached_tokens / input_tokens` over records that explicitly report - /// cached tokens. `None` when no record has cached coverage. Range: 0.0–1.0. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_hit_rate: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageTokenSource { - TokenUsageRecords, - Unavailable, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageCacheCoverage { - Available, - Partial, - Unavailable, -} - -// PartialEq only (not Eq) — see comment on UsageTokenBreakdown. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct UsageModelBreakdown { - pub model_id: String, - pub call_count: u64, - pub input_tokens: Option, - pub output_tokens: Option, - pub total_tokens: Option, - pub cached_tokens: Option, - /// Per-model hit rate. Same semantic as [`UsageTokenBreakdown::cache_hit_rate`]. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cache_hit_rate: Option, - pub duration_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_index: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageToolBreakdown { - pub tool_name: String, - pub category: super::classifier::UsageToolCategory, - pub call_count: u64, - pub success_count: u64, - pub error_count: u64, - pub duration_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub p95_duration_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub queue_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preflight_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub confirmation_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_item_id: Option, - pub redacted: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageFileBreakdown { - pub scope: UsageFileScope, - pub changed_files: Option, - pub added_lines: Option, - pub deleted_lines: Option, - #[serde(default)] - pub files: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageFileScope { - SnapshotSummary, - ToolInputsOnly, - Unavailable, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageFileRow { - pub path_label: String, - pub operation_count: u64, - pub added_lines: Option, - pub deleted_lines: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub turn_indexes: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub operation_ids: Vec, - pub redacted: bool, -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageSnapshotFacts { - pub source_available: bool, - #[serde(default)] - pub operations: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageSnapshotOperationSummary { - pub operation_id: String, - pub session_id: String, - pub turn_index: usize, - pub file_path: String, - pub lines_added: u64, - pub lines_removed: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageCompressionBreakdown { - pub compaction_count: u64, - pub manual_compaction_count: u64, - pub automatic_compaction_count: u64, - pub saved_tokens: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageErrorBreakdown { - pub total_errors: u64, - pub tool_errors: u64, - pub model_errors: u64, - #[serde(default)] - pub examples: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageErrorExample { - pub label: String, - pub count: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_turn_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sample_item_id: Option, - pub redacted: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsageSlowSpan { - pub label: String, - pub kind: UsageSlowSpanKind, - pub duration_ms: u64, - pub redacted: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub turn_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub item_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub input_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub status: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_seconds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timed_out: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub error_summary: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub queue_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preflight_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub confirmation_wait_ms: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution_ms: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UsageSlowSpanKind { - Model, - Tool, - Turn, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UsagePrivacy { - pub prompt_content_included: bool, - pub tool_inputs_included: bool, - pub command_outputs_included: bool, - pub file_contents_included: bool, - #[serde(default)] - pub redacted_fields: Vec, -} - -impl SessionUsageReport { - pub fn partial_unavailable(session_id: impl Into, generated_at: i64) -> Self { - Self { - schema_version: SESSION_USAGE_REPORT_SCHEMA_VERSION, - report_id: format!("usage-{}", generated_at), - session_id: session_id.into(), - generated_at, - generated_from_app_version: None, - workspace: UsageWorkspace { - kind: UsageWorkspaceKind::Unknown, - path_label: None, - workspace_id: None, - remote_connection_id: None, - remote_ssh_host: None, - }, - scope: UsageScope { - kind: UsageScopeKind::EntireSession, - turn_count: 0, - from_turn_id: None, - to_turn_id: None, - includes_subagents: false, - }, - coverage: UsageCoverage { - level: UsageCoverageLevel::Partial, - available: vec![], - missing: vec![ - UsageCoverageKey::CachedTokens, - UsageCoverageKey::TokenDetailBreakdown, - ], - notes: vec![ - "P0 report uses existing persisted session facts and marks not-reported metrics explicitly." - .to_string(), - ], - }, - time: UsageTimeBreakdown { - accounting: UsageTimeAccounting::Unavailable, - denominator: UsageTimeDenominator::Unavailable, - wall_time_ms: None, - active_turn_ms: None, - model_ms: None, - tool_ms: None, - idle_gap_ms: None, - }, - tokens: UsageTokenBreakdown { - source: UsageTokenSource::Unavailable, - input_tokens: None, - output_tokens: None, - total_tokens: None, - cached_tokens: None, - cache_coverage: UsageCacheCoverage::Unavailable, - cache_hit_rate: None, - }, - models: vec![], - tools: vec![], - files: UsageFileBreakdown { - scope: UsageFileScope::Unavailable, - changed_files: None, - added_lines: None, - deleted_lines: None, - files: vec![], - }, - compression: UsageCompressionBreakdown { - compaction_count: 0, - manual_compaction_count: 0, - automatic_compaction_count: 0, - saved_tokens: None, - }, - errors: UsageErrorBreakdown { - total_errors: 0, - tool_errors: 0, - model_errors: 0, - examples: vec![], - }, - slowest: vec![], - privacy: UsagePrivacy { - prompt_content_included: false, - tool_inputs_included: false, - command_outputs_included: false, - file_contents_included: false, - redacted_fields: vec![], - }, - } - } -} +pub use bitfun_core_types::{ + SessionUsageReport, UsageCacheCoverage, UsageCompressionBreakdown, UsageCoverage, + UsageCoverageKey, UsageCoverageLevel, UsageErrorBreakdown, UsageErrorExample, + UsageFileBreakdown, UsageFileRow, UsageFileScope, UsageModelBreakdown, UsagePrivacy, + UsageScope, UsageScopeKind, UsageSlowSpan, UsageSlowSpanKind, UsageSnapshotFacts, + UsageSnapshotOperationSummary, UsageTimeAccounting, UsageTimeBreakdown, UsageTimeDenominator, + UsageTokenBreakdown, UsageTokenSource, UsageToolBreakdown, UsageWorkspace, UsageWorkspaceKind, + SESSION_USAGE_REPORT_SCHEMA_VERSION, +}; #[cfg(test)] pub(crate) fn test_report() -> SessionUsageReport { @@ -497,7 +63,7 @@ pub(crate) fn test_report() -> SessionUsageReport { }]; report.tools = vec![UsageToolBreakdown { tool_name: "read_file".to_string(), - category: super::classifier::UsageToolCategory::File, + category: bitfun_core_types::UsageToolCategory::File, call_count: 1, success_count: 1, error_count: 0, @@ -530,53 +96,3 @@ pub(crate) fn test_report() -> SessionUsageReport { }; report } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn session_usage_report_round_trips_with_partial_coverage() { - let report = test_report(); - let json = serde_json::to_string(&report).expect("serialize report"); - let restored: SessionUsageReport = serde_json::from_str(&json).expect("deserialize report"); - - assert_eq!(restored, report); - assert_eq!( - restored.coverage.level, - UsageCoverageLevel::Partial, - "P0 reports must make partial coverage explicit" - ); - } - - #[test] - fn session_usage_report_round_trips_with_workspace_scope_and_privacy() { - let mut report = test_report(); - report.workspace.kind = UsageWorkspaceKind::RemoteSsh; - report.workspace.remote_ssh_host = Some("example.internal".to_string()); - report - .privacy - .redacted_fields - .push("slowest.label".to_string()); - - let json = serde_json::to_string(&report).expect("serialize report"); - - assert!(json.contains("remote_ssh")); - assert!(json.contains("redactedFields")); - - let restored: SessionUsageReport = serde_json::from_str(&json).expect("deserialize report"); - assert_eq!(restored.workspace.kind, UsageWorkspaceKind::RemoteSsh); - assert_eq!(restored.privacy.redacted_fields, vec!["slowest.label"]); - } - - #[test] - fn token_cache_unavailable_does_not_require_cached_value() { - let report = test_report(); - - assert_eq!( - report.tokens.cache_coverage, - UsageCacheCoverage::Unavailable - ); - assert_eq!(report.tokens.cached_tokens, None); - } -}