diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 79249616d..a36f8fad9 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -17,6 +17,7 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 | 模块 | 路径 | Agent 文档 | |---|---|---| | Core(产品逻辑) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | +| 已拆出的 core 支撑 crate | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (使用 core 指南) | | Transport 适配层 | `src/crates/transport` | (使用 core 指南) | | API layer | `src/crates/api-layer` | (使用 core 指南) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | @@ -96,6 +97,13 @@ await api.invoke('your_command', { request: { ... } }); ## 架构 +### Core 拆解护栏 + +任何 `bitfun-core` 拆解、feature 边界、依赖边界或 Rust 构建提速重构, +都必须先阅读 +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md)。 +该文档定义产品行为不变量、crate 归属目标、禁止依赖方向、feature 安全规则和里程碑验证门禁。 + ### 后端链路 大多数功能建议按这个顺序追踪: @@ -133,7 +141,7 @@ SessionManager → Session → DialogTurn → ModelRound | `core`、`transport`、`api-layer` 或共享服务中的 Rust 逻辑 | `cargo check --workspace && cargo test --workspace` | | 桌面端集成、Tauri API、browser/computer-use 或桌面专属行为 | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | | 被桌面端 smoke/functional 流覆盖的行为 | `cargo build -p bitfun-desktop` 后运行最接近的 E2E spec,或 `pnpm run e2e:test:l0` | -| `src/crates/ai-adapters` | 运行上面相关 Rust 检查,**并且**运行 `src/crates/core/tests` 中的 stream integration tests | +| `src/crates/ai-adapters` | 运行上面相关 Rust 检查,**并且**运行 `cargo test -p bitfun-agent-stream` 验证 stream contract | | 安装器应用 | `pnpm run installer:build` | ## 先看哪里 diff --git a/AGENTS.md b/AGENTS.md index 90794b391..7315195c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ Repository rule: **keep product logic platform-agnostic, then expose it through | Module | Path | Agent doc | |---|---|---| | Core (product logic) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | +| Extracted core support | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (use core guide) | | Transport adapters | `src/crates/transport` | (use core guide) | | API layer | `src/crates/api-layer` | (use core guide) | | AI adapters | `src/crates/ai-adapters` | [AGENTS.md](src/crates/ai-adapters/AGENTS.md) | @@ -96,6 +97,15 @@ await api.invoke('your_command', { request: { ... } }); ## Architecture +### Core decomposition guardrails + +For any `bitfun-core` decomposition, feature-boundary, dependency-boundary, or +Rust build-speed refactor, read +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) +before editing. The guardrail document defines product-behavior invariants, +crate ownership targets, forbidden dependency directions, feature safety rules, +and milestone verification gates. + ### Backend flow Trace most features in this order: @@ -133,7 +143,7 @@ Session data is stored under `.bitfun/sessions/{session_id}/`. | Shared Rust logic in `core`, `transport`, `api-layer`, or services | `cargo check --workspace && cargo test --workspace` | | Desktop integration, Tauri APIs, browser/computer-use, or desktop-only behavior | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | | Behavior covered by desktop smoke/functional flows | `cargo build -p bitfun-desktop` then the nearest E2E spec or `pnpm run e2e:test:l0` | -| `src/crates/ai-adapters` | Relevant Rust checks above **and** stream integration tests in `src/crates/core/tests` | +| `src/crates/ai-adapters` | Relevant Rust checks above **and** `cargo test -p bitfun-agent-stream` for stream contracts | | Installer app | `pnpm run installer:build` | ## Where to look first diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d2d30d15c..551a85e5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,10 @@ Do not use platform-specific dependencies in `core`: - ❌ `tauri::AppHandle` - ✅ `bitfun_events::EventEmitter` +For `bitfun-core` decomposition or build-speed refactors, follow +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md) +and do not change product feature sets or release scripts as a side effect. + ### Tauri command conventions - Command names use `snake_case` diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 66dc780ff..b84d654ec 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -85,6 +85,10 @@ pnpm run e2e:test - ❌ `tauri::AppHandle` - ✅ `bitfun_events::EventEmitter` +进行 `bitfun-core` 拆解或构建提速重构时,请遵循 +[`docs/architecture/core-decomposition.md`](docs/architecture/core-decomposition.md), +不要把产品 feature set 或 release 脚本变更作为顺手改动。 + ### Tauri 命令规范 - 命令名使用 `snake_case` diff --git a/Cargo.toml b/Cargo.toml index 3c6ab4eb0..b232f845b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,14 @@ [workspace] members = [ + "src/crates/core-types", "src/crates/events", "src/crates/ai-adapters", + "src/crates/agent-stream", + "src/crates/runtime-ports", "src/crates/acp", "src/crates/core", + "src/crates/terminal", + "src/crates/tool-runtime", "src/crates/transport", "src/crates/api-layer", "src/crates/webdriver", diff --git a/README.md b/README.md index 0b2448cdd..32327ae65 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,9 @@ For more details, see the [Contributing guide](./CONTRIBUTING.md). ## Project structure at a glance ``` -src/crates/core # Product logic hub: agentic / service / infrastructure +src/crates/core # Compatibility facade and product runtime assembly +src/crates/{core-types,agent-stream,runtime-ports} # Extracted core support boundaries +src/crates/{terminal,tool-runtime} # Workspace-level terminal/tool helper crates src/crates/transport # Tauri / WebSocket / CLI transport adapters src/crates/api-layer # Shared handlers and DTOs src/apps/desktop # Tauri desktop host diff --git a/README.zh-CN.md b/README.zh-CN.md index d2dad3088..a14261a18 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -163,7 +163,9 @@ pnpm run desktop:build ## 项目结构一览 ``` -src/crates/core # 产品逻辑中心:agentic / service / infrastructure +src/crates/core # 兼容门面与完整产品 runtime 组装点 +src/crates/{core-types,agent-stream,runtime-ports} # 已拆出的 core 支撑边界 +src/crates/{terminal,tool-runtime} # workspace 顶层 terminal / tool 辅助 crate src/crates/transport # Tauri / WebSocket / CLI 传输适配 src/crates/api-layer # 共享 handler 与 DTO src/apps/desktop # Tauri 桌面宿主 diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md new file mode 100644 index 000000000..ebc5f303a --- /dev/null +++ b/docs/architecture/core-decomposition.md @@ -0,0 +1,143 @@ +# BitFun Core 拆解护栏(Core Decomposition Guardrails) + +本文是逐步拆解 `bitfun-core` 的执行护栏(execution guardrail)。它用于补充 +[`bitfun-core-decomposition-plan.md`](../plans/core-decomposition-plan.md) +中的详细里程碑计划。 + +目标是在不改变任何受支持构建形态(build shape)下产品行为的前提下,把稳定、 +边界清晰的逻辑从较重的 `bitfun-core` runtime 聚合体中移出,从而减少不必要的 +Rust 编译和链接面。 + +## 不可协商的不变量 + +- 拆解过程中不得改变产品行为。 +- 不得为了提升本地速度而减少 CI 或 release 覆盖范围。 +- 除非后续有明确的产品变更要求,否则产品 crate 必须保持相同的能力集合 + (capability set)。 +- 构建脚本和安装器脚本不属于本次重构范围: + - `package.json` + - `scripts/dev.cjs` + - `scripts/desktop-tauri-build.mjs` + - `scripts/ensure-openssl-windows.mjs` + - `scripts/ci/setup-openssl-windows.ps1` + - `BitFun-Installer/**` +- 共享产品逻辑必须保持平台无关(platform-agnostic)。桌面端专属逻辑应保留在 + app adapters 中,再通过 transport/API layers 回流。 +- 不要引入仓库级、机器相关的编译器或链接器默认配置,例如 `sccache`、`lld-link` + 或 `mold`。 + +## 执行顺序 + +按里程碑执行,不按孤立的重构想法零散推进: + +1. **安全保护和最小编译面验证** + - 在任何默认 feature 变轻之前,先加入 `product-full` feature 安全网。 + - 把已经独立成 crate 的 nested crate 移到 workspace 顶层路径。 + - 先抽取 `core-types`,承载稳定 DTO 和 port DTO;只有在 concrete runtime / + network 转换依赖完成解耦后,才移动 `BitFunError`。 + - 如果 stream 测试可以不依赖完整 core 运行,则抽取 stream processing。 + - 移动重服务之前先引入 ports。第一层轻量边界位于 `bitfun-runtime-ports`; + 该 crate 只包含 DTO 和 trait。 + - 第一批 adapter 实现只视为边界搭建。只有相关 service migration 和回归测试 + 完成后,才能声明 service/agent 的 concrete call site 已经被替换。 +2. **中等粒度 owner crate** + - 优先使用 8 到 12 个 owner crate,而不是大量小 crate。 + - 使用 `services-core` 和 `services-integrations`,不要为每个 service 文件夹 + 单独建立 crate。 + - 使用 `agent-tools` 加 `tool-packs` feature group,不要为每个具体工具族 + 单独建立 crate。 +3. **Facade 收敛和边界强制** + - `bitfun-core` 收敛为兼容门面(compatibility facade)和完整产品 runtime + 组装点(full product runtime assembly)。 + - 新 crate 抽出后,再加入轻量边界检查。 + - 更轻的默认 feature 只能作为单独且完整验证过的 PR 进行评估。 + +## Crate 归属目标(Crate Ownership Targets) + +初始目标 crate 应保持中等粒度。下表同时包含新的 owner crate 目标,以及属于拆解 +边界的一些已有基础 crate。 + +| 目标 crate | 归属职责 | +|---|---| +| `bitfun-core` | 兼容门面和完整产品 runtime 组装点 | +| `bitfun-core-types` | 稳定 DTO、port DTO、纯 domain type,以及最终的纯错误类型 | +| `bitfun-events` | 已有的传输层无关事件 DTO 和事件抽象 | +| `bitfun-ai-adapters` | 已有 AI provider adapter,以及 provider / protocol DTO 归属 | +| `bitfun-agent-stream` | Stream 聚合和 stream-focused 测试 | +| `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | +| `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | +| `bitfun-agent-tools` | Tool trait、context、registry、provider contract | +| `bitfun-tool-packs` | 由 feature group 隔离的具体工具实现 | +| `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | +| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | +| `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | +| `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | +| `tool-runtime` | 已有 tool runtime,移动到 workspace 顶层路径 | + +除非有实测证据证明继续拆分可以减少关键编译目标或测试目标,并且该模块已经具备稳定的 +owner 边界,否则不要把一个 feature group 继续拆成更小的 crate。 + +## 依赖方向规则(Dependency Direction Rules) + +- 新拆出的 crate 不得反向依赖 `bitfun-core`。 +- `bitfun-core` 可以依赖新拆出的 crate,并通过 re-export 保持旧路径兼容。 +- `bitfun-runtime-ports` 必须保持 DTO/trait-only;不得依赖 concrete manager、 + service implementation、app crate 或 platform adapter。 +- `bitfun-core-types` 不得依赖 runtime manager、service crate、agent runtime、 + app crate、Tauri、network client、process execution,或 `git2`、`rmcp`、`image`、 + `tokio-tungstenite` 等重集成依赖。 +- `ErrorCategory`、`AiErrorDetail` 以及纯 AI 错误分类/detail helper 应放在 + `bitfun-core-types` 中,并通过已有更高层路径 re-export 或委托,以保持公开行为稳定。 +- 在剩余 concrete error-wrapper 依赖完成审核前,不要把 `BitFunError` 移入 + `bitfun-core-types`。错误边界中已经移除了 `reqwest::Error` 和 + `tokio::sync::AcquireError` 引用;`serde_json::Error`、`anyhow::Error` 以及历史 + `From` 行为仍需要单独做兼容性处理后,才能移动该类型。 +- Service crate 必须通过小型 port 调用 agent runtime,不要直接访问全局 coordinator。 +- 迁移期间,adapter implementation 可以暂时放在 `bitfun-core` 中,但新的 service + 代码必须面向 port contract,而不是新增对 coordinator 或 manager 的直接依赖。 +- Agent runtime 必须通过 ports/providers 依赖 service 行为,不要依赖 concrete 的重集成 + crate。 +- Tool framework crate 不得依赖 concrete service implementation。 +- 产品 crate 可以通过显式 product feature 组装完整 runtime。 + +## Feature 安全规则 + +- 在让任何默认 feature 变轻之前,先引入 `product-full`。 +- 评估默认 feature 缩减之前,产品 crate 必须显式启用完整产品 runtime。 +- `product-full` 是产品能力保护开关(product capability guardrail),不是新的万能聚合点 + (dumping ground)。每个新的 owner crate 都应暴露具体 feature group;只有为了保持既有 + 产品形态时,`product-full` 才可以包含它们。 +- 拆解完成后不要自动移除或减轻 `product-full`。如果未来要用 per-product explicit + feature set 替代它,必须作为 P3 之后的独立评估,并且先通过完整产品矩阵。 +- 不要把 feature 默认值变更和模块移动放在同一个变更中。 +- 不要把改变产品构建产物能力集合作为减少本地测试编译面的副作用。 + +## 测试和验证策略(Test And Verification Policy) + +先运行能够证明当前变更的最小验证,再在进入下一个里程碑前运行里程碑门禁。 + +对于保持行为不变的重构: + +- 如果被移动的行为尚未被测试覆盖,先补测试,再移动逻辑。 +- 当模块已经移出 `bitfun-core` 后,优先使用小 crate 测试。 +- 如果变更影响 feature assembly、产品 crate manifest、desktop integration、CLI、 + server 或 transport path,则必须保留完整产品检查。 + +对于仅调整文档护栏的变更: + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望结果:无 diff。 + +详细计划中列出了各里程碑门禁。没有针对对应门禁的最新验证证据时,不要声明里程碑完成。 + +## 冗余清理策略(Redundancy Cleanup Policy) + +冗余清理不是主要的编译提速手段。只有在输入、输出、错误路径、副作用、日志、时序和平台 +条件都能证明等价时,才抽取重复逻辑。 + +如果等价性不清晰,就保留重复代码。不要仅仅因为两个流程看起来相似,就创建新的共享抽象。 + +冗余清理 PR 必须独立于 crate splitting、feature 默认值变更和依赖升级。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md new file mode 100644 index 000000000..83209ebdc --- /dev/null +++ b/docs/plans/core-decomposition-plan.md @@ -0,0 +1,1558 @@ +# BitFun Core 拆解与构建提速可执行计划 + +> **执行约定:** 后续实施本计划时,建议按独立 PR 分步推进。每个阶段使用本文的 checkbox 跟踪,不要把多个高风险拆分混在一个 PR 中。 + +**目标:** 将当前职责过重的 `bitfun-core` 逐步拆成边界明确、依赖可控、可独立验证的 Rust crate 和能力 feature,同时不改变任何产品功能、CI/release 构建内容、关键构建脚本执行逻辑或各形态产品的依赖范围。 + +**总体策略:** 采用 Strangler Facade(绞杀者门面)迁移。`bitfun-core` 在迁移期继续作为兼容门面和完整产品 runtime 组装点,旧公开路径尽量保持可用;新的实现逐步迁移到独立 owner crate 中,跨层调用通过端口接口、provider、adapter 连接。 + +**拆分粒度修正:** 不追求把每个目录都拆成独立 crate。目标是先形成 8 到 12 个中等粒度 owner crate,并在 crate 内用模块和 feature group 继续隔离能力。过多小 crate 会增加 Cargo metadata、check 调度、增量编译管理和测试链接成本,可能抵消一部分优化收益。 + +**核心收益:** + +- 让单元测试和局部测试可以依赖更小 crate,减少不必要编译和链接。 +- 让重依赖归属到真正需要它们的能力模块,例如 `git2`、`rmcp`、`russh`、`image`、`tokio-tungstenite`。 +- 用 crate 边界和接口阻止新的循环引用,而不是只靠文件夹、注释或团队约定。 +- 为后续依赖版本收敛和 feature 最小化提供稳定边界。 + +--- + +## 0. 不可变更边界 + +以下约束优先级高于所有优化收益: + +- 重构期间产品行为不变。 +- `bitfun-desktop`、`bitfun-cli`、`bitfun-server`、`bitfun-relay-server`、`bitfun-acp`、installer 相关构建能力不被削减。 +- 不通过减少 CI 覆盖来换取速度。 +- 不在仓库级默认引入 `.cargo/config.toml` 强制 `sccache`、`lld-link`、`mold` 或其它机器相关工具。 +- 不把 `bitfun-core` 重新包装成另一个 `common`、`shared`、`platform` 式超级 crate。 +- 新拆出的 crate 不允许依赖回 `bitfun-core`。 +- `bitfun-core` 可以依赖新 crate 并 re-export 旧路径,用于兼容。 +- 任何会减少 `bitfun-core` 默认能力的 feature 调整,必须先让所有产品 crate 显式启用等价的完整产品能力。 +- 以下关键脚本不作为 core 拆解的一部分修改: + - `package.json` + - `scripts/dev.cjs` + - `scripts/desktop-tauri-build.mjs` + - `scripts/ensure-openssl-windows.mjs` + - `scripts/ci/setup-openssl-windows.ps1` + - `BitFun-Installer/**` + +每个阶段合并前必须执行脚本保护检查: + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望结果:没有 diff。若某个阶段确实需要改构建脚本,必须从本文计划中拆出,作为独立的显式产品构建变更评审。 + +--- + +## 0A. 架构原则复核与偏移防线 + +后续每个 PR 都必须先对照本节。若发现任意原则无法满足,应暂停该 PR,并将问题拆成更小的前置重构或独立设计评审。 + +### 0A.1 平台边界不能偏移 + +必须保持: + +- product logic 仍保持 platform-agnostic。 +- Tauri、desktop-only、server-only、CLI-only 能力仍留在 platform adapter 或 product assembly 层。 +- shared core、runtime、services crate 不直接引入 `tauri::AppHandle`、desktop API 或其它 host-specific 依赖。 +- Web UI 到 desktop/server 的调用路径仍经过现有 adapter/API/transport 边界。 + +禁止: + +- 为了拆 crate,把 desktop-only 逻辑下沉到 `core-types`、`agent-runtime` 或 `services-core`。 +- 为了方便调用,让新 service crate 反向依赖 app crate。 + +验收方式: + +- 检查新增 crate 的 `Cargo.toml`,确认没有不应出现的平台依赖。 +- 对涉及 desktop/server/CLI 的 PR,执行对应产品 check,而不是只执行新 crate 的测试。 + +### 0A.2 功能集合不能偏移 + +必须保持: + +- `product-full` 是完整产品能力保护开关。 +- 产品 crate 显式启用完整能力后,才允许继续拆能力 feature。 +- `bitfun-core` 的旧公开路径通过 facade 或 re-export 保持 import-compatible。 +- tool registry、MCP dynamic tools、remote SSH、remote connect、miniapp、function agents 的产品可见行为保持一致。 + +禁止: + +- 在同一个 PR 中同时“拆模块”和“改变产品默认能力”。 +- 以减少编译为理由删除 CI 或 release 覆盖。 +- 在没有完整产品矩阵验证前修改 `bitfun-core default`。 + +验收方式: + +- 拆分前记录关键清单,例如 tool registry 工具列表、feature graph、产品 crate 对 `bitfun-core` 的 feature 使用。 +- 拆分后用等价性测试或产品 check 证明能力仍存在。 + +### 0A.3 依赖方向不能偏移 + +必须保持: + +- 新 crate 不依赖回 `bitfun-core`。 +- `bitfun-core` 作为 facade 可以依赖新 crate。 +- service crate 不直接依赖 agent runtime concrete implementation;通过 ports 调用。 +- agent runtime 不依赖 heavy integration concrete service;通过 ports/provider 调用。 +- `core-types` 只承载错误、DTO、port DTO、纯 domain type。 + +禁止: + +- 新增万能上下文,例如 `CoreContext`、`AppContext`,把所有 manager 都挂进去绕过依赖边界。 +- 通过 `pub use` 掩盖实际反向依赖。 +- 在 `core-types` 中引入 IO、网络、进程、Tauri、`git2`、`rmcp`、`image` 等运行时依赖。 + +验收方式: + +- 每个新增 crate 的 `Cargo.toml` 必须能说明依赖原因。 +- 至少在关键 crate 拆出后,用 boundary check 阻止 forbidden imports 回流。 + +### 0A.4 性能方向不能反向 + +本计划不保证每个中间 PR 都立即变快,但不得明显变慢。 + +必须保持: + +- 不新增大量微小 crate;默认目标是 8 到 12 个中等粒度 owner crate。 +- heavy dependency 通过 owner crate 和 feature group 隔离。 +- 局部测试优先落到小 crate,例如 `agent-stream`、`services-core`、`agent-tools`。 +- 不引入团队机器相关的 repo-wide 编译参数或 linker 默认配置。 + +禁止: + +- 为了“架构纯粹”把高频一起变化的模块拆成多个互相调用的小 crate。 +- 为了局部快,把产品完整构建路径变复杂或变脆弱。 +- 在没有实测依据时继续把 feature group 拆成独立 crate。 + +验收方式: + +- 每个里程碑结束时至少对比一次关键目标: + - 新增 crate 数量是否仍在中等粒度范围。 + - 关键局部测试是否能依赖更小 crate。 + - `cargo check -p bitfun-core --features product-full` 没有因为 facade 组装明显恶化。 + - 产品矩阵仍通过。 + +### 0A.5 阶段边界必须明确 + +每个 PR 只能落入以下一种类型: + +- 文档/基线/边界检查。 +- feature 安全网,不移动业务实现。 +- 类型或 port 抽取,不移动重 service。 +- 单个中等粒度 crate 抽取。 +- 单个 feature group 迁移。 +- facade/re-export 收敛。 +- 低风险直接依赖版本收敛。 + +禁止: + +- 同一个 PR 同时改 feature 默认值、移动大量模块、调整产品调用路径。 +- 同一个 PR 同时做架构拆分和三方库大版本升级。 +- 同一个 PR 同时修改构建脚本和 core 拆分。 + +暂停条件: + +- 发现需要改变产品行为才能继续。 +- 发现产品 crate 需要减少能力才能编译通过。 +- 发现新 crate 必须依赖回 `bitfun-core`。 +- 发现某个 feature group 拆分会导致多个平台产品使用不同代码路径。 +- 发现构建脚本必须修改才能完成当前拆分。 + +### 0A.6 冗余清理只处理绝对等价逻辑 + +冗余清理不是本计划的主线性能优化。除非能证明逻辑完全等价,否则不因为“看起来类似”就抽公共函数或合并流程。 + +允许处理: + +- 逐行对照后可以证明输入、输出、错误处理、日志、副作用、超时、平台条件完全一致的重复代码。 +- 纯 helper 层重复,例如同一目录内完全一致的常量映射、权限字符串格式化、pairing 过期判断。 +- 有现成测试或可以先补等价性测试的重复逻辑。 + +暂不处理: + +- 不同平台、不同第三方协议、不同产品入口之间只是流程形状相似的代码。 +- MIME by extension 与 MIME by bytes 这类语义不同的检测逻辑。 +- Telegram、Feishu、Weixin 这种 provider 协议逻辑,除非抽取点只覆盖完全一致的本地状态管理。 +- UI 组件或样式中相似但承载不同交互语义的结构。 + +执行要求: + +- 冗余清理必须是独立 PR,不能混入 crate 拆分或 feature 默认值调整。 +- PR 描述中必须列出“等价证明”:调用方、输入、输出、错误路径、副作用是否一致。 +- 如果等价性说不清,宁可保留重复代码。 +- 不为了减少代码行数引入新的公共抽象中心。 + +当前仅作为候选观察,不默认执行: + +- Remote Connect bot 的 pairing store,如果逐行确认 `register_pairing` / `verify_pairing_code` 行为完全一致,可以抽 `BotPairingStore`。 +- filesystem 中 extension-based MIME mapping 和 permission string formatting,如果逐行确认行为完全一致,可以抽本地 helper。 + +这些候选不阻塞里程碑推进,也不应优先于 feature 安全网和 `core-types` / `agent-stream` 拆分。 + +--- + +## 1. 当前问题与风险合集 + +### 1.1 `bitfun-core` 已经是完整产品 runtime 聚合 + +现状: + +- `src/crates/core/src/lib.rs` 暴露 `agentic`、`service`、`infrastructure`、`miniapp`、`function_agents`、`util`。 +- `src/crates/core/Cargo.toml` 直接承载大量重依赖,例如 `git2`、`rmcp`、`image`、`notify`、`qrcode`、`tokio-tungstenite`、`bitfun-relay-server`、`terminal-core`、`tool-runtime`。 + +风险: + +- 一个很小的纯逻辑测试也可能触发大块 runtime 依赖编译。 +- `cargo test` 需要为大量测试 target 链接可执行文件,Windows MSVC 下会产生多个 `Microsoft Incremental Linker` 进程。 +- 新功能只要被放进 core,就天然继承整个重依赖图。 + +解决方向: + +- 保留 `bitfun-core` 作为兼容门面。 +- 将实现迁移到明确 owner crate。 +- 测试逐步改为依赖最小 crate,而不是默认依赖完整 core。 + +### 1.2 `service` 与 `agentic` 存在双向耦合 + +观察到的耦合方向: + +- `service -> agentic`:remote connect、MCP、cron、snapshot、config canonicalization、token usage、session usage 等。 +- `agentic -> service`:tools、coordinator、agents、persistence、session、execution、insights 等。 + +风险: + +- 直接把 `service` 和 `agentic` 拆成 crate 会立刻形成循环依赖。 +- 只用文件夹或注释约束不能阻止新代码继续反向引用。 + +解决方向: + +- 先抽取 port trait,再移动实现。 +- 典型端口: + - `AgentSubmissionPort` + - `ToolRegistryPort` + - `DynamicToolProvider` + - `WorkspaceIdentityProvider` + - `SessionTranscriptReader` + - `ConfigReadPort` + - `EventSink` + - `StorageRootProvider` + +### 1.3 feature 边界不完整,不能直接改默认 feature + +现状: + +- `bitfun-core` 当前有 `default = ["ssh-remote"]`。 +- `ssh-remote` 控制 `russh`、`russh-sftp`、`russh-keys`、`shellexpand`、`ssh_config`。 +- 其它重能力多数还是无条件依赖。 + +风险: + +- 如果直接把 default 改轻,可能改变 desktop、CLI、server、ACP 的实际产品能力。 +- Cargo feature 是 additive 的,无法可靠表达“某能力关闭后其它模块就完全不可见”的业务边界。 + +解决方向: + +- 先引入 `product-full`,保持 default 行为不变。 +- 产品 crate 显式启用 `product-full`。 +- 只有在产品显式启用完整能力后,才逐步考虑拆 feature 或调整 default。 + +### 1.4 tool registry 会牵引所有工具实现 + +现状: + +- `agentic/tools/registry.rs` 直接注册所有工具。 +- snapshot service 在 registry 注册阶段参与包装。 +- MCP service 会向全局 registry 注册动态工具。 + +风险: + +- 任何依赖 registry 的测试都会编译所有具体工具及其依赖。 +- registry 成为 service 和 agentic 互相引用的粘合点。 + +解决方向: + +- 拆出 tool framework、registry、tool provider、tool pack。 +- 使用 Provider Registry 和 Decorator: + - `ToolProvider` 注册一组工具。 + - `DynamicToolProvider` 提供 MCP 等动态工具。 + - `ToolDecorator` 处理 snapshot 等横切逻辑。 + +### 1.5 shared type 位于错误层级 + +例子: + +- `util/types/config.rs` 依赖 `service::config::types::AIModelConfig`。 +- `service::session` 使用 `agentic::core::SessionKind`。 +- 远程 workspace identity 同时被 service 和 agentic 使用。 + +风险: + +- 看似基础的类型依赖高层 runtime 模块。 +- 拆 crate 时容易产生循环引用或复制 DTO。 + +解决方向: + +- 建立 `bitfun-core-types`。 +- 只放稳定 DTO、错误类型、轻量 domain type。 +- 不放 manager、service、global registry、IO、runtime orchestration。 + +### 1.6 nested crate 已经存在,但位置仍在 core 内部 + +现状: + +- `src/crates/core/src/service/terminal/Cargo.toml` 包名 `terminal-core`。 +- `src/crates/core/src/agentic/tools/implementations/tool-runtime/Cargo.toml` 包名 `tool-runtime`。 + +风险: + +- 物理路径仍暗示它们属于 core 内部实现。 +- 后续拆分时 workspace 依赖关系不清晰。 + +解决方向: + +- 先移动到 `src/crates/terminal` 和 `src/crates/tool-runtime`。 +- 保持 package/lib 名称不变,降低兼容风险。 + +--- + +## 2. 目标 crate 版图 + +这是目标方向,不要求一个 PR 完成。目标不是把所有 service 都拆成单独 crate,而是先用中等粒度 owner crate 降低编译面,同时避免 crate 数量膨胀。 + +下方列表同时包含“新 owner crate 目标”和已经存在的基础 crate(例如 `events`、`ai-adapters`、`terminal`、`tool-runtime`)。`8 到 12 个中等粒度 owner crate` 的约束主要用于新增拆分边界,不把这些已存在基础 crate 误算成继续拆小的理由。 + +### 2.1 推荐目标:中等粒度合并 + +```text +src/crates/core # 兼容门面 + 完整产品 runtime 组装 +src/crates/core-types # 错误、DTO、port DTO、纯 domain type +src/crates/events # 现有事件定义 +src/crates/ai-adapters # 现有 AI adapter;只接收纯协议 stream 逻辑 +src/crates/agent-stream # stream processor 与相关测试,若无法干净放入 ai-adapters +src/crates/agent-runtime # session、execution、coordination、agent system +src/crates/agent-tools # tool trait、registry、provider contract +src/crates/tool-packs # 具体工具实现,按 feature group 隔离 +src/crates/services-core # config/session/workspace/storage/filesystem/system 等基础服务 +src/crates/services-integrations # git/MCP/remote SSH/remote connect 等重集成,按 feature group 隔离 +src/crates/product-domains # miniapp、function agents 等产品子域 +src/crates/tool-runtime # 现有 tool-runtime 移出 core 子树 +src/crates/terminal # 现有 terminal-core 移出 core 子树 +``` + +### 2.2 为什么不拆成三十个 crate + +- 每个 crate 都会带来 Cargo metadata、fingerprint、增量编译缓存和 dependency graph 管理成本。 +- `cargo test` 的主要链接压力来自测试二进制数量和每个测试二进制需要链接的代码量;crate 过碎虽然可能减少局部重编译,但也会增加调度和 rlib 组合成本。 +- service 目录中很多模块会一起变化,例如 config/session/workspace/storage,强行拆开会提高跨 crate API 维护成本。 +- 重依赖真正需要隔离的是能力族,而不是文件夹数量。更合理的边界是 `services-core` 与 `services-integrations`,再用 feature group 控制 `git`、`mcp`、`remote-ssh`、`remote-connect`。 + +### 2.3 何时允许继续拆小 + +只有满足以下条件之一,才把中等粒度 crate 继续拆小: + +- 该能力有独立重依赖,并且大多数测试不需要它。 +- 该能力的变更频率和 owner 明显独立。 +- 该能力已经通过 port/provider 与其它模块解耦。 +- 实测显示拆分后能减少关键测试或 check 的编译面。 + +不满足这些条件时,优先用同一 crate 内的模块、feature group 和边界检查约束。 + +--- + +## 3. 模块覆盖矩阵 + +拆解时不能遗漏当前 core 模块。下表给出每个模块的中等粒度目标归属。 + +| 当前模块 | 目标 owner | 说明 | +|---|---|---| +| `util::errors` | `bitfun-core-types` | `BitFunError`、`BitFunResult`,不包含 runtime | +| `util::types` | `bitfun-core-types` / `bitfun-ai-adapters` | 纯 DTO 入 types,AI 协议 DTO 优先留在 ai-adapters | +| `util::types::ai` 和 provider 协议 DTO | `bitfun-ai-adapters` | provider 请求/响应、stream 协议和 adapter-owned DTO 留在 AI adapter 边界内 | +| `util::process_manager` | `bitfun-services-core` | 涉及进程执行,不进入纯 types | +| `infrastructure::app_paths` | `bitfun-services-core` | 通过 `StorageRootProvider` 暴露 | +| `infrastructure::events` | `bitfun-events` / transport | 事件定义和发送抽象从 core 解耦 | +| `infrastructure::ai` | `bitfun-ai-adapters` + assembly | 通过 `ConfigReadPort` 消除反向依赖 | +| `infrastructure::storage` | `bitfun-services-core` | 依赖路径抽象,不依赖全局 core | +| `infrastructure::filesystem` | `bitfun-services-core` | 本地/远程文件系统通过 provider 隔离 | +| `infrastructure::debug_log` | `bitfun-services-integrations` feature `debug-log` | HTTP server 依赖需要 feature-gate | +| `service::config` | `bitfun-services-core` | agent/tool canonicalization 移到 runtime assembly | +| `service::session` | `bitfun-services-core` | `SessionKind` 等共享类型先移入 types | +| `service::workspace` | `bitfun-services-core` | workspace identity 独立 | +| `service::workspace_runtime` | `bitfun-services-core` | workspace runtime layout owner | +| `service::remote_ssh` | `bitfun-services-integrations` feature `remote-ssh` | 第一批重依赖隔离候选 | +| `service::mcp` | `bitfun-services-integrations` feature `mcp` | 动态工具通过 provider 注入 | +| `service::remote_connect` | `bitfun-services-integrations` feature `remote-connect` | 依赖 agent submission port | +| `service::git` | `bitfun-services-integrations` feature `git` | `git2` 边界清晰,适合早拆 | +| `service::lsp` | `bitfun-services-core` feature `lsp` | 依赖 workspace/runtime port | +| `service::search` | `bitfun-services-core` feature `search` | 依赖 workspace/filesystem provider | +| `service::snapshot` | `bitfun-services-core` feature `snapshot` | tool wrapping 改为 decorator | +| `service::cron` | `bitfun-services-core` feature `cron` | 调 agent runtime 通过 `AgentSubmissionPort` | +| `service::token_usage` | `bitfun-services-core` | 只依赖事件和 usage DTO | +| `service::session_usage` | `bitfun-services-core` | 依赖 transcript 边界 | +| `service::project_context` | `bitfun-services-core` | 避免直接依赖 coordinator | +| `service::announcement` | `bitfun-services-integrations` feature `announcement` | 远程 fetch 依赖独立 feature-gate | +| `service::filesystem` | `bitfun-services-core` | 本地/远程 provider | +| `service::file_watch` | `bitfun-services-integrations` feature `file-watch` | `notify` 依赖独立 | +| `service::system` | `bitfun-services-core` | 命令检测和执行 | +| `service::runtime` | `bitfun-services-core` | runtime capability detection | +| `service::i18n` | `bitfun-services-core` | config 依赖保持单向 | +| `service::ai_rules` | `bitfun-services-core` | 只依赖 paths/storage | +| `service::ai_memory` | `bitfun-services-core` | 只依赖 paths/storage | +| `service::agent_memory` | `bitfun-agent-runtime` 或 `bitfun-services-core` | prompt helper 随 runtime/prompt builder 迁移 | +| `service::bootstrap` | `bitfun-services-core` | workspace persona bootstrap | +| `service::diff` | `bitfun-core-types` 或 `bitfun-services-core` | 纯 diff 可入 types,否则入 services-core | +| `agentic::core` | `bitfun-agent-runtime` + `bitfun-core-types` | DTO 入 types,行为入 runtime | +| `agentic::events` | `bitfun-events` + runtime router | 事件定义不留在 core | +| `agentic::execution` | `bitfun-agent-runtime`,stream 可入 `bitfun-agent-stream` | stream processor 先拆以验证收益 | +| `agentic::coordination` | `bitfun-agent-runtime` | 依赖 service port,不依赖具体 service | +| `agentic::session` | `bitfun-agent-runtime` | persistence/config 通过 port | +| `agentic::persistence` | `bitfun-agent-runtime` + `bitfun-services-core` | DTO storage 和 orchestration 分离 | +| `agentic::agents` | `bitfun-agent-runtime` | registry 通过 config port | +| `agentic::tools::framework` | `bitfun-agent-tools` | 不包含具体工具实现 | +| `agentic::tools::registry` | `bitfun-agent-tools` | provider-based registration | +| `agentic::tools::implementations` | `bitfun-tool-packs` | 同一 crate 内按 feature group 分模块 | +| `agentic::deep_review_policy` | `bitfun-agent-runtime` | config input 通过 port | +| `agentic::fork_agent` | `bitfun-agent-runtime` | runtime concern | +| `agentic::round_preempt` | `bitfun-agent-runtime` | runtime concern | +| `agentic::image_analysis` | `bitfun-tool-packs` feature `image-analysis` 或 runtime feature | 隔离 `image` 依赖 | +| `agentic::side_question` | `bitfun-agent-runtime` | runtime concern | +| `agentic::insights` | `bitfun-agent-runtime` feature `insights` | 依赖 config/i18n/session ports | +| `agentic::workspace` | `bitfun-core-types` + `bitfun-agent-runtime` | remote identity DTO 入 types | +| `miniapp` | `bitfun-product-domains` feature `miniapp` | desktop API 先走 core facade | +| `function_agents` | `bitfun-product-domains` feature `function-agents` | 依赖 runtime 和 service ports | + +--- + +## 4. 设计模式与关键接口 + +### 4.1 Facade:保留旧路径,不让迁移影响调用方 + +`bitfun-core` 迁移期只做兼容门面和完整 runtime 组装: + +```rust +//! Compatibility facade and full product runtime assembly. +//! +//! New implementation code should live in owner crates under `src/crates/*`. +//! This crate re-exports legacy paths and wires the full BitFun product runtime. +``` + +旧路径示例: + +```rust +pub mod service { + pub use bitfun_services_git as git; +} +``` + +要求: + +- 新实现不继续堆到 `bitfun-core`。 +- re-export 必须加注释说明这是兼容层。 +- 不要把 facade 变成新的业务实现聚合。 + +### 4.2 Dependency Inversion:先抽接口,再移动实现 + +示例端口: + +```rust +#[async_trait::async_trait] +pub trait AgentSubmissionPort: Send + Sync { + async fn submit_user_message( + &self, + request: AgentSubmissionRequest, + ) -> Result; +} +``` + +使用原则: + +- service crate 调 agent runtime 时,只依赖 port。 +- agent runtime 调 config/session/workspace 时,也只依赖 port。 +- port DTO 必须在 `core-types` 或专门的 `runtime-ports` crate 中,不能依赖 concrete manager。 + +### 4.3 Provider Registry:工具按能力包注册 + +示例: + +```rust +pub trait ToolProvider: Send + Sync { + fn provider_id(&self) -> &'static str; + fn register_tools(&self, registry: &mut dyn ToolRegistryPort) -> BitFunResult<()>; +} +``` + +使用原则: + +- `agent-tools` 只包含 tool trait、context、registry、provider contract。 +- `tool-packs` 拥有具体工具实现,并通过 `git`、`mcp`、`computer-use` 等 feature group 隔离重依赖。 +- 产品完整 runtime 由 assembly 层安装所有 provider,保证产品行为不变。 + +### 4.4 Decorator:snapshot 等横切逻辑不侵入 registry + +示例: + +```rust +pub trait ToolDecorator: Send + Sync { + fn decorate(&self, tool: Arc) -> Arc; +} +``` + +使用原则: + +- snapshot service 不再直接改 registry 内部实现。 +- registry 支持 decorator chain。 +- 产品完整 runtime 默认安装同等 snapshot decorator,保持原行为。 + +### 4.5 Adapter:平台差异留在产品 adapter 层 + +要求: + +- Tauri、desktop-only、server-only、CLI-only 逻辑不下沉到纯 domain crate。 +- platform adapter 组装 runtime 后,通过 `bitfun-core` facade 或明确 concrete crate 暴露。 +- shared product logic 仍保持 platform-agnostic。 + +--- + +## 5. 分阶段执行计划 + +### Plan 0:基线与安全护栏 + +**目的:** 在开始移动代码前建立可度量基线和团队约束。 + +**文件范围:** + +- 新增:`docs/architecture/core-decomposition.md` +- 修改:`AGENTS.md` +- 修改:`src/crates/core/AGENTS.md` + +**任务:** + +- [ ] 记录依赖和构建基线,生成文件只放 `target/`,不提交。 + +```powershell +cargo metadata --format-version 1 --locked > target/core-decomposition-metadata-baseline.json +cargo tree -p bitfun-core -d > target/core-decomposition-core-duplicates.txt +cargo tree -p bitfun-desktop -e features > target/core-decomposition-desktop-features.txt +cargo test -p bitfun-core --no-run --timings +``` + +- [x] 在 `docs/architecture/core-decomposition.md` 记录 invariants、crate 归属、禁止依赖规则。 +- [x] 在 `AGENTS.md` 增加短链接,说明 core 拆解期间先看架构文档。 +- [x] 在 `src/crates/core/AGENTS.md` 增加约束: + +```markdown +During core decomposition, `bitfun-core` is a compatibility facade. New modules +should prefer the extracted owner crate listed in `docs/architecture/core-decomposition.md`. +Do not add new cross-layer references from `service` to `agentic` without a port. +``` + +- [x] 执行脚本保护检查。 + +**验证:** + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +**风险与处理:** + +- 风险:基线命令在低性能机器耗时较长。 +- 处理:只在需要建立基线的机器运行;生成文件不提交;普通开发者不强制执行 timing。 + +--- + +### Plan 1:引入 `product-full` feature 安全网 + +**目的:** 在任何默认 feature 变轻之前,先让产品 crate 显式声明完整能力,避免多形态产品构建内容被意外改变。 + +**文件范围:** + +- 修改:`src/crates/core/Cargo.toml` +- 修改:`src/apps/desktop/Cargo.toml` +- 修改:`src/apps/cli/Cargo.toml` +- 修改:`src/crates/acp/Cargo.toml` +- 不修改:`src/apps/server/Cargo.toml`,除非它已经在当前产品构建中显式依赖 `bitfun-core` +- 不修改:`src/apps/relay-server/Cargo.toml`,除非它已经在当前产品构建中显式依赖 `bitfun-core` + +**任务:** + +- [x] 在 `bitfun-core` 中新增 `product-full`,但保持当前 default 行为不变。 + +```toml +[features] +# Full product runtime feature set. Product binaries must depend on this +# explicitly before `bitfun-core` default features are made lighter. +default = ["product-full"] +product-full = ["ssh-remote"] +tauri-support = ["tauri"] +ssh-remote = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] +``` + +- [x] 产品 crate 显式启用完整能力。 + +```toml +bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } +``` + +- [x] 这个阶段禁止把 `default` 改成空。 +- [x] 为 `product-full` 增加注释,说明它是多形态产品能力保护开关。 +- [x] 只更新当前已经依赖 `bitfun-core` 的 crate。不要为了统一写法给 server 或 relay-server 新增 `bitfun-core` 依赖。 + +**生命周期说明:** + +- `product-full` 是迁移期和发布期的完整能力保护开关,不是新功能的万能聚合点。新增 owner crate 时,必须先定义具体 feature group,再由产品完整 runtime 显式选择是否纳入 `product-full`。 +- P3 结束前不评估移除或减轻 `product-full`。如果未来希望用更细粒度的 per-product feature set 替代它,必须作为独立发布风险评估执行,并先通过完整产品矩阵。 +- 不允许在模块移动 PR 中同时做 `product-full` 淘汰、`default = []` 或产品能力裁剪。 + +**验证:** + +```powershell +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check -p bitfun-acp +cargo check --workspace +``` + +**风险与处理:** + +- 风险:某产品 crate 之前依赖隐式 default,现在路径写错导致能力缺失。 +- 处理:每个产品 crate 单独 check;不改构建脚本;不减少 release feature。 + +--- + +### Plan 2:把现有 nested crate 移到 workspace 顶层 + +**目的:** 先处理已经是 crate 的模块,降低后续拆分歧义,且风险较低。 + +**文件范围:** + +- 移动:`src/crates/core/src/service/terminal` -> `src/crates/terminal` +- 移动:`src/crates/core/src/agentic/tools/implementations/tool-runtime` -> `src/crates/tool-runtime` +- 修改:workspace 根 `Cargo.toml` +- 修改:`src/crates/core/Cargo.toml` +- 必要时修改:旧路径 re-export + +**任务:** + +- [x] 移动 `terminal-core` 目录到 `src/crates/terminal`。 +- [x] 保持 package name `terminal-core` 和 lib name `terminal_core` 不变。 +- [x] 移动 `tool-runtime` 到 `src/crates/tool-runtime`。 +- [x] 保持 package name `tool-runtime` 和 lib name `tool_runtime` 不变。 +- [x] 更新 workspace members。 +- [x] 更新 `src/crates/core/Cargo.toml` path: + +```toml +terminal-core = { path = "../terminal" } +tool-runtime = { path = "../tool-runtime" } +``` + +- [x] 在旧 re-export 点加关键节点注释: + +```rust +// Terminal is implemented in the workspace-level `terminal-core` crate. +// This re-export preserves the legacy `bitfun_core::service::terminal` path. +pub use terminal_core as terminal; +``` + +**验证:** + +```powershell +cargo check -p terminal-core +cargo check -p tool-runtime +cargo check -p bitfun-core --features product-full +cargo check --workspace +``` + +**风险与处理:** + +- 风险:路径移动影响相对路径、测试 fixture 或 include。 +- 处理:保持 package/lib 名称不变;只改 Cargo path;不改行为。 + +--- + +### Plan 3:抽取 `bitfun-core-types` + +**目的:** 建立真正底层的共享类型 crate,让后续服务和 agent runtime 不需要依赖 `bitfun-core`。 + +**文件范围:** + +- 新增:`src/crates/core-types/Cargo.toml` +- 新增:`src/crates/core-types/src/lib.rs` +- 新增:`src/crates/core-types/src/errors.rs` +- 后续按依赖确认再新增:`session.rs`、`workspace.rs`、`config.rs` +- 修改:workspace 根 `Cargo.toml` +- 修改:`src/crates/core/Cargo.toml` +- 修改:旧模块 re-export + +**第一批只移动:** + +- 纯 error DTO:`ErrorCategory`、`AiErrorDetail` +- 纯 AI 错误分类/detail 构造 helper +- 已去除 runtime/network 依赖后的 `BitFunError`(当前未移动) +- 已去除 runtime/network 依赖后的 `BitFunResult`(当前未移动) +- 已确认无 runtime 依赖的 session/workspace/config DTO + +**第一批禁止移动:** + +- manager +- global service +- registry +- 文件 IO +- process spawning +- async runtime orchestration +- 任何需要 Tauri、git2、rmcp、reqwest、image 的类型实现 + +**任务:** + +- [x] 建立轻依赖 crate,当前只允许 `serde`: + +```toml +[dependencies] +serde = { workspace = true } +``` + +- [x] 先把 `ErrorCategory` / `AiErrorDetail` 抽到 `core-types`,并由 `bitfun-events::agentic` re-export 保持旧路径不变。 +- [x] 把 AI 错误分类和 detail 构造 helper 下沉到 `core-types`,`BitFunError::error_category` / `error_detail` 只做委托。 +- [x] 将原本依赖完整 `bitfun-core` 的 AI 错误分类测试迁移到 `bitfun-core-types` 单元测试,作为后续错误边界移动的轻量保护。 +- [x] 先拆解 `BitFunError` 的 runtime/network 依赖边界。`reqwest::Error` 已改为字符串承载,`tokio::sync::AcquireError` 已改为调用点显式映射,错误模块不再直接引用这两个类型。 +- [ ] 拆解 `BitFunError` 剩余 concrete error-wrapper 依赖。当前仍保留 `serde_json::Error`、`anyhow::Error` 和相关 `From` 兼容行为,不能直接搬进只依赖 `serde` 的 `core-types`。 +- [ ] 只有当错误类型不再需要 runtime/network 依赖时,才移动 `BitFunError`、`BitFunResult`。 +- [ ] `BitFunError` 移动后保留旧路径 re-export: + +```rust +pub use bitfun_core_types::errors::{BitFunError, BitFunResult}; +``` + +- [x] crate 顶部增加边界注释: + +```rust +//! Shared BitFun domain types. +//! +//! This crate must not depend on `bitfun-core`, service crates, agent runtime, +//! platform adapters, process execution, or network clients. +``` + +- [x] 已移动第一批 shared DTO/helper,并确认依赖方向为 `bitfun-events -> bitfun-core-types`、`bitfun-core -> bitfun-core-types`。 +- [ ] 逐个移动后续 shared DTO,每移动一个 DTO 都确认依赖方向。 + +**当前状态:** Plan 3 是部分完成。`ErrorCategory`、`AiErrorDetail` 和第一批纯 helper 已进入 `core-types`;`BitFunError` / `BitFunResult` 迁移、剩余 concrete wrapper 处理和后续 DTO 迁移仍是后续任务。未完成项不阻塞 P1 的安全边界验证,但会阻塞“错误类型完全归属 core-types”的完成声明。 + +**验证:** + +```powershell +cargo test -p bitfun-core-types +cargo check -p bitfun-core --features product-full +cargo check --workspace +``` + +**风险与处理:** + +- 风险:把带行为的类型误放入 types,导致 types 变重。 +- 处理:核心判断是“是否需要 IO、全局状态、网络、平台 API、runtime manager”。需要则不能进入 types。 +- 当前阻塞:`BitFunError` 还带有 `serde_json::Error` / `anyhow::Error` concrete wrapper 和 `From` 兼容行为。先保持在 `bitfun-core`,后续单独评估是把这些 wrapper 字符串化,还是允许 `core-types` 引入轻量 error 依赖后再移动。 + +--- + +### Plan 4:抽取 `bitfun-agent-stream` + +**目的:** 让 stream processor 相关测试脱离完整 `bitfun-core`,这是较容易验证构建提速收益的拆分点。 + +**文件范围:** + +- 新增:`src/crates/agent-stream/Cargo.toml` +- 新增:`src/crates/agent-stream/src/lib.rs` +- 移动/适配:`src/crates/core/src/agentic/execution/stream_processor.rs` +- 移动/适配测试: + - `src/crates/core/tests/stream_processor_openai.rs` + - `src/crates/core/tests/stream_processor_anthropic.rs` + - `src/crates/core/tests/stream_processor_tool_arguments.rs` + - `src/crates/core/tests/stream_replay_regressions.rs` + - 相关 fixture/helper +- 修改:`src/crates/core/src/agentic/execution/mod.rs` +- 修改:`src/crates/core/Cargo.toml` +- 修改:workspace 根 `Cargo.toml` + +**任务:** + +- [x] 创建 `bitfun-agent-stream`,依赖控制在 stream 所需范围: + +```toml +anyhow = { workspace = true } +async-trait = { workspace = true } +bitfun-events = { path = "../events" } +bitfun-ai-adapters = { path = "../ai-adapters" } +futures = { workspace = true } +serde = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } +uuid = { workspace = true } +``` + +- [x] 移动 stream result/error/context processor。 +- [x] 消除对 `crate::agentic` 的直接引用,改为依赖 `bitfun-events`、`bitfun-ai-adapters`。 +- [x] 旧路径 compatibility wrapper: + +```rust +//! Compatibility wrapper for the extracted agent stream processor. + +pub struct StreamProcessor { + inner: bitfun_agent_stream::StreamProcessor, +} +``` + +- [x] stream 测试迁移到 `src/crates/agent-stream/tests`,fixture harness 改为测试内事件 sink,不再依赖完整 `bitfun-core`。 + +**验证:** + +```powershell +cargo test -p bitfun-agent-stream +cargo test -p bitfun-core --lib stream_processor +cargo check -p bitfun-core --features product-full +cargo check --workspace +``` + +**风险与处理:** + +- 风险:stream test 依赖旧 core test helper。 +- 处理:只迁移 stream 所需 fixture;不要把 core test helper 整体搬成新重依赖。 + +--- + +### Plan 5:引入 runtime ports,准备打断 `service <-> agentic` 循环 + +**目的:** 在真正移动 service crate 之前,先建立可替换的 cross-layer 调用边界;具体 service call-site 迁移按后续 owner crate 阶段逐步完成。 + +**文件范围:** + +- 新增:`src/crates/core-types/src/ports.rs` 或独立 `src/crates/runtime-ports` +- 修改:`src/crates/core/src/service/remote_connect/**` +- 修改:`src/crates/core/src/service/mcp/**` +- 修改:`src/crates/core/src/service/cron/**` +- 修改:`src/crates/core/src/service/snapshot/**` +- 修改:`src/crates/core/src/agentic/tools/registry.rs` +- 修改:`src/crates/core/src/agentic/coordination/**` + +**任务:** + +- [x] 先定义 port DTO 和 trait,不移动大模块。 +- [x] 新增独立轻量 `bitfun-runtime-ports`,只包含 DTO / trait,不依赖 `bitfun-core`、manager、service concrete、app crate 或平台 adapter。 +- [x] 为 `ConversationCoordinator` 提供 `AgentSubmissionPort` / `SessionTranscriptReader` adapter,作为 remote connect / service 后续迁移入口。 +- [x] 为 `ToolRegistry` 提供 `DynamicToolProvider` adapter。 +- [x] 用 `ToolDecorator` 注入 registry 注册装饰入口,保留默认 snapshot wrapping 行为。 +- [x] 为 `ConfigService` 提供 `ConfigReadPort` adapter,先建立读取边界,不移动 config service。 +- [ ] remote connect / cron / MCP 的 concrete call-site 替换尚未完成;这不是当前第一批 ports adapter 的完成条件,必须在 P2 service 迁移中逐步接入并补 regression。 +- [ ] `AgentSubmissionPort` 当前只接受纯文本消息;通用 attachment / image context 需要在 remote bot 或桌面 API 接入前单独设计和验证,避免丢失多模态行为。 +- [ ] P2 concrete call-site 迁移前,把 `AgentSubmissionRequest.turn_id` 提升为显式可选 DTO 字段(序列化为 `turnId`),coordinator 兼容期先读显式字段再回退 `metadata["turnId"]`,并补充序列化与 adapter 回归测试。 +- [ ] P2/P3 tool owner 迁移前,避免 `DynamicToolProvider` 从 `mcp__server__tool` 注册名反推 `provider_id`;MCP wrapper 或 registry entry 应显式携带 provider metadata,并用多 server / 特殊 server id 测试证明 provider 身份不依赖命名格式。 + +示例: + +```rust +#[async_trait::async_trait] +pub trait SessionTranscriptReader: Send + Sync { + async fn read_session_transcript( + &self, + request: SessionTranscriptRequest, + ) -> PortResult; +} +``` + +**验证:** + +```powershell +cargo check -p bitfun-core --features product-full +cargo test -p bitfun-core remote_connect +cargo test -p bitfun-core mcp +cargo check --workspace +``` + +**风险与处理:** + +- 风险:接口抽象过大,变成另一个 service god object。 +- 处理:每个 port 只覆盖一个调用方向和一个能力集合;避免 `CoreContext` 这种万能接口。 + +--- + +### Plan 6:抽取中等粒度 service crate + +**目的:** 用两个 service owner crate 承载当前 `service` 目录,而不是把每个 service 都拆成独立 crate。这样可以隔离重依赖,同时避免 crate 数量过多。 + +#### Plan 6A:抽取 `bitfun-services-core` + +**文件范围:** + +- 新增:`src/crates/services-core/**` +- 移动/适配基础服务: + - `src/crates/core/src/service/config/**` + - `src/crates/core/src/service/session/**` + - `src/crates/core/src/service/workspace/**` + - `src/crates/core/src/service/workspace_runtime/**` + - `src/crates/core/src/service/filesystem/**` + - `src/crates/core/src/service/system/**` + - `src/crates/core/src/service/runtime/**` + - `src/crates/core/src/service/i18n/**` + - `src/crates/core/src/service/ai_rules/**` + - `src/crates/core/src/service/ai_memory/**` + - `src/crates/core/src/service/bootstrap/**` + - `src/crates/core/src/service/diff/**` + - `src/crates/core/src/service/session_usage/**` + - `src/crates/core/src/service/token_usage/**` + - `src/crates/core/src/service/project_context/**` +- 暂留或 feature-gate: + - `src/crates/core/src/service/search/**` + - `src/crates/core/src/service/lsp/**` + - `src/crates/core/src/service/cron/**` + - `src/crates/core/src/service/snapshot/**` + +**任务:** + +- [ ] 新建 `bitfun-services-core`,默认 feature 尽量轻。 +- [ ] 基础 DTO 从 `bitfun-core-types` 引入。 +- [ ] 与 agent runtime 的调用通过 ports 完成。 +- [ ] `search`、`lsp`、`cron`、`snapshot` 先作为同 crate 内 feature group,不单独拆 crate。 +- [ ] core 旧路径通过 re-export 保持。 + +**验证:** + +```powershell +cargo test -p bitfun-services-core +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +``` + +#### Plan 6B:抽取 `bitfun-services-integrations` + +**文件范围:** + +- 新增:`src/crates/services-integrations/**` +- 移动/适配重集成服务: + - `src/crates/core/src/service/git/**` + - `src/crates/core/src/service/mcp/**` + - `src/crates/core/src/service/remote_ssh/**` + - `src/crates/core/src/service/remote_connect/**` + - `src/crates/core/src/service/announcement/**` + - `src/crates/core/src/service/file_watch/**` + +**feature group:** + +```toml +[features] +default = [] +git = ["git2"] +mcp = ["rmcp"] +remote-ssh = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] +remote-connect = ["tokio-tungstenite", "qrcode", "image", "bitfun-relay-server"] +announcement = ["reqwest"] +file-watch = ["notify"] +debug-log = ["axum"] +product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "file-watch", "debug-log"] +``` + +**任务:** + +- [ ] 先迁移 `git`,因为边界相对清晰。 +- [ ] 再迁移 `remote-ssh`,保留 `ssh-remote` 语义。 +- [ ] 再迁移 `mcp`,动态工具通过 `DynamicToolProvider` 接入。 +- [ ] 最后迁移 `remote-connect`,通过 `AgentSubmissionPort`、`SessionTranscriptReader`、`EventSink` 解耦 agent runtime。 +- [ ] 每迁移一个集成能力,都保持 core 旧路径 re-export。 +- [ ] 产品完整 runtime 通过 `services-integrations/product-full` 启用所有集成能力。 + +**验证:** + +```powershell +cargo test -p bitfun-services-integrations --features git +cargo check -p bitfun-services-integrations --features product-full +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +``` + +**Plan 6 总体风险与处理:** + +- 风险:`services-integrations` 内 feature 互相污染,导致局部测试仍编译过多依赖。 +- 处理:默认 feature 为空;局部测试显式启用单一 feature;产品 crate 只通过 `product-full` 启用完整能力。 +- 风险:两个 service crate 仍然偏大。 +- 处理:先接受中等粒度。只有实测某个 feature group 仍显著拖慢关键测试时,再把它升级为独立 crate。 + +--- + +### Plan 7:拆解 agent tools + +**目的:** 避免 tool registry 拉入所有工具实现和对应 service 依赖。 + +**目标 crate:** + +- `src/crates/agent-tools` +- `src/crates/tool-packs` + +**任务:** + +- [ ] 抽出 tool trait、tool context、tool result、registry 到 `agent-tools`。 +- [ ] `agent-tools` 不依赖任何 concrete service。 +- [ ] 将工具实现迁移到 `tool-packs` crate,并按 feature group 分模块: + - basic file/search/terminal + - git + - MCP + - browser/web + - computer use + - miniapp + - cron/task/agent control +- [ ] `tool-packs` 默认 feature 为空,产品完整 runtime 启用 `product-full`。 +- [ ] 产品 runtime assembly 注册所有 provider: + +```rust +registry.install_provider(BasicToolProvider::new()); +registry.install_provider(GitToolProvider::new(git_service)); +registry.install_provider(McpToolProvider::new(mcp_service)); +``` + +- [ ] 保持兼容构造函数: + +```rust +pub fn create_tool_registry() -> ToolRegistry { + product_full_tool_registry() +} +``` + +- [ ] 增加 registry 等价性测试:完整产品 registry 中的工具集合与拆分前一致。 + +**验证:** + +```powershell +cargo test -p bitfun-agent-tools +cargo test -p bitfun-tool-packs --features basic +cargo check -p bitfun-tool-packs --features product-full +cargo check -p bitfun-core --features product-full +cargo test -p bitfun-core registry_includes +cargo check -p bitfun-desktop +``` + +**风险与处理:** + +- 风险:工具列表遗漏导致产品能力缺失。 +- 处理:拆分前生成工具清单基线;拆分后 registry 等价性测试必须通过。 +- 风险:单个 `tool-packs` crate 过重。 +- 处理:先用 feature group 控制编译面;只有某个工具族被实测证明明显拖慢局部测试时,再拆成独立 crate。 + +--- + +### Plan 8:抽取产品子域到 `bitfun-product-domains` + +**目的:** 把相对独立的产品子域移出 core,但不为每个子域创建独立 crate。 + +**文件范围:** + +- 新增:`src/crates/product-domains/**` +- 移动/适配: + - `src/crates/core/src/miniapp/**` + - `src/crates/core/src/function_agents/**` + +**feature group:** + +```toml +[features] +default = [] +miniapp = [] +function-agents = [] +product-full = ["miniapp", "function-agents"] +``` + +**任务:** + +- [ ] miniapp runtime、compiler、permission、builtin 迁移到 `product-domains::miniapp`。 +- [ ] function agents 迁移到 `product-domains::function_agents`。 +- [ ] 与 agent/tool 的连接通过 provider 或 port。 +- [ ] core 旧路径 re-export。 +- [ ] function agents 依赖 agent runtime port,不直接依赖 service concrete manager。 +- [ ] server/desktop 调用路径保持不变。 + +**验证:** + +```powershell +cargo test -p bitfun-product-domains --features miniapp +cargo check -p bitfun-product-domains --features product-full +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-server +``` + +--- + +### Plan 9:将 `bitfun-core` 收敛为 facade + product runtime assembly + +**目的:** 完成迁移收束,让 `bitfun-core` 不再是新实现承载点。 + +**文件范围:** + +- 修改:`src/crates/core/src/lib.rs` +- 修改:`src/crates/core/src/service/mod.rs` +- 修改:`src/crates/core/src/agentic/mod.rs` +- 修改:`src/crates/core/Cargo.toml` + +**任务:** + +- [ ] 将可替换的实现模块改为 re-export。 +- [ ] 在顶层加入关键节点注释: + +```rust +//! Compatibility facade and full product runtime assembly. +//! +//! New implementation code should live in owner crates under `src/crates/*`. +//! This crate re-exports legacy paths and wires the full BitFun product runtime. +``` + +- [ ] `bitfun-core/Cargo.toml` 只保留 facade 和 product assembly 所需依赖。 +- [ ] 旧路径保持 import-compatible。 +- [ ] 只有所有产品 crate 都显式启用完整 runtime 后,才可以在独立 PR 中评估: + +```toml +default = [] +``` + +**验证:** + +```powershell +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check -p bitfun-relay-server +cargo check -p bitfun-acp +cargo check --workspace +``` + +**风险与处理:** + +- 风险:facade re-export 引发公开路径破坏。 +- 处理:每个旧路径迁移都必须有兼容 shim;必要时加 compile-only compatibility test。 + +--- + +## 6. 依赖版本收敛计划 + +依赖版本收敛必须和 crate 拆解并行但不要混入高风险移动 PR。 + +### 6.1 先做低风险直接依赖收敛 + +候选: + +- `base64 0.21/0.22` +- `dirs 5/6` +- `toml 0.8/0.9` + +执行原则: + +- 只处理本仓库直接依赖。 +- 不为了收敛版本强行升级外部库。 +- 每次只收敛一类库。 + +示例检查: + +```powershell +cargo tree -d -i base64 +cargo tree -d -i dirs +cargo tree -d -i toml +``` + +验证: + +```powershell +cargo check --workspace +cargo test -p +``` + +### 6.2 高风险重复依赖暂不优先强收敛 + +候选: + +- `image 0.24/0.25` +- `rmcp 0.12/1.5` +- `reqwest 0.12/0.13` +- `windows*` + +原因: + +- 这些通常来自传递依赖或大版本 API 变化。 +- 贸然统一可能比保留重复版本风险更高。 + +处理方式: + +- 优先通过 crate 边界隔离它们的编译范围。 +- 等 owner crate 独立后,再在对应 crate 内评估升级。 + +--- + +## 7. 边界强制规则 + +在至少两个 crate 被抽出后,增加轻量检查脚本,而不是一开始就把工具链复杂化。 + +**建议新增:** `scripts/check-core-boundaries.mjs` + +检查规则: + +- `bitfun-core-types` 不允许依赖: + - `bitfun-core` + - service crate + - agent runtime + - Tauri + - `reqwest` + - `git2` + - `rmcp` + - `image` + - `tokio-tungstenite` +- service crate 不允许依赖 `bitfun-core`。 +- agent runtime 不允许依赖 concrete heavy service crate,只依赖 ports。 +- tool framework 不允许依赖 concrete service implementation。 +- product crate 可以依赖 facade 或明确 concrete crate。 + +运行: + +```powershell +node scripts/check-core-boundaries.mjs +``` + +注意: + +- 不要在大型移动 PR 中同时新增复杂检查。 +- 检查脚本应简单扫描 Cargo.toml 和 `src/**/*.rs` 的 forbidden imports。 + +--- + +## 8. 验证矩阵 + +### 8.1 每个 PR 的最小验证 + +```powershell +cargo check -p +cargo test -p +cargo check -p bitfun-core --features product-full +``` + +### 8.2 产品矩阵 + +```powershell +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check -p bitfun-relay-server +cargo check -p bitfun-acp +cargo check --workspace +``` + +### 8.3 default feature 变更前的完整门禁 + +```powershell +cargo test --workspace +cargo build -p bitfun-desktop +pnpm run desktop:build:fast +pnpm run desktop:build:release-fast +``` + +### 8.4 构建脚本保护 + +```powershell +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望: + +- 没有脚本或 installer diff。 +- 如果出现 diff,该 PR 不应作为 core 拆解 PR 合并。 + +--- + +## 9. 风险登记表 + +| 风险 | 概率 | 影响 | 缓解方式 | +|---|---:|---:|---| +| 产品 feature set 被意外改变 | 中 | 高 | `product-full` 先行;产品 crate 显式启用;产品矩阵验证 | +| 新 crate 依赖回 `bitfun-core` | 高 | 高 | boundary script;code review;`core-types` 先行 | +| service-agentic 循环阻塞拆分 | 高 | 高 | 先引入 ports,再移动 crate | +| port DTO 仍依赖非结构化 metadata | 中 | 中 | P2 concrete call-site 迁移前显式化 `turnId` 等跨边界字段;保留 metadata fallback 只作为兼容期 | +| tool registry 行为变化 | 中 | 高 | 完整工具清单基线;provider 等价性测试 | +| 动态工具 provider 身份耦合注册名 | 中 | 中 | MCP wrapper / registry entry 显式携带 provider metadata;不要从 `mcp__...` 名称反推身份 | +| remote SSH 行为变化 | 中 | 高 | workspace identity DTO 稳定后再拆;保留 `ssh-remote` 语义 | +| MCP 动态工具丢失 | 中 | 高 | `DynamicToolProvider` contract;MCP regression test | +| desktop 构建脚本被误改 | 低 | 高 | 每 PR 执行 build script guard | +| facade 阶段编译速度收益不明显 | 中 | 中 | 预期中间态;衡量小 crate 测试收益,不把 facade 视为终点 | +| 抽象过度导致开发复杂度上升 | 中 | 中 | port 粒度小;禁止万能 `CoreContext` | +| crate 拆得过碎导致链接和调度成本上升 | 中 | 中 | 采用中等粒度目标;默认只拆 8 到 12 个 owner crate;后续拆小必须有实测依据 | + +--- + +## 10. 三个关键里程碑 + +后续执行按里程碑推进,而不是按单个技术点零散推进。每个里程碑都必须独立可验收,并且不改变产品功能集合。 + +### 执行优先级 + +优先级从高到低: + +1. **P0:安全边界。** 文档、feature 安全网、构建脚本保护、产品能力不变。 +2. **P1:最小编译面验证。** `core-types`、`agent-stream`、runtime ports,优先验证小 crate 测试是否能绕开完整 core。 +3. **P2:中等粒度 owner crate。** `services-core`、`services-integrations`、`agent-tools`、`tool-packs`、`product-domains`。 +4. **P3:facade 收敛与边界强制。** `bitfun-core` 只做兼容门面和 product runtime assembly。 +5. **P4:冗余清理。** 只处理绝对等价重复,且必须独立 PR。P4 不阻塞任何里程碑。 + +不允许跳过 P0/P1 直接进入重 service 拆分。任何 P2/P3 任务如果需要改变产品功能集合、默认 feature、构建脚本或平台边界,必须回退到 P0/P1 重新补安全网。 + +### 里程碑一:边界安全网与最小收益验证 + +**覆盖计划:** + +- Plan 0:基线与安全护栏。 +- Plan 1:`product-full` feature 安全网。 +- Plan 2:移动 nested `terminal-core` 和 `tool-runtime`。 +- Plan 3:抽取 `bitfun-core-types`。 +- Plan 4:抽取 `bitfun-agent-stream`。 +- Plan 5:引入 runtime ports。 + +**目标:** + +- 建立后续拆分不会偏移产品能力的 feature 安全网。 +- 建立底层共享类型和 port 基础,避免后续循环依赖。 +- 通过 `agent-stream` 先验证“小 crate 承载局部测试”是否能减少编译面。 +- 不移动重 service,不调整产品构建脚本,不改变 release/CI 行为。 + +**启动队列:** + +1. 文档和基线护栏:只记录边界、验证命令、禁止项,不移动代码。 +2. `product-full` feature:保持 default 行为不变,让产品 crate 显式启用完整能力。 +3. nested crate 位置整理:移动已经独立的 `terminal-core` 和 `tool-runtime`,保持 package/lib 名称不变。 +4. `core-types`:只抽错误和纯 DTO,不引入运行时依赖。 +5. `agent-stream`:迁移 stream processor 和 stream 测试,验证小 crate 测试收益。 +6. runtime ports:新增轻量 ports crate 和第一批 adapter,建立后续替换跨层 concrete 调用的入口,不移动重 service。 + +**实现边界:** + +- 可以新增 `core-types`、`agent-stream`、workspace 顶层 `terminal`、`tool-runtime`。 +- 可以新增 port trait 和 DTO。 +- 可以在 core 中添加兼容 re-export。 +- 不允许改变 `bitfun-core default` 为轻量模式。 +- 不允许修改 `package.json`、`scripts/*`、`BitFun-Installer/**`。 +- 不允许把 desktop/server/CLI 的平台逻辑下沉到 shared crate。 + +**验收门:** + +```powershell +cargo check -p bitfun-core --features product-full +cargo test -p bitfun-runtime-ports +cargo test -p bitfun-agent-stream +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望: + +- 产品 crate 仍显式拥有完整能力。 +- `agent-stream` 测试不需要依赖完整 `bitfun-core`。 +- 旧公开 import 路径可用。 +- 构建脚本无 diff。 + +**当前回合质量核对(2026-05-11,latest `origin/main`):** + +- 变基到最新 `origin/main` 后重新验证:P1 范围内的 feature 安全网、workspace 顶层 crate 移动、`core-types` 第一批类型、`agent-stream` 独立测试和 runtime ports 初始边界均保持通过。`cargo check --workspace` 与 `cargo test --workspace` 均已通过;Web UI lint、type-check 和 full test 也已通过,用于覆盖 rebase 时合并的 `/usage` 面板冲突。全量 workspace test 是本次 P1 退出的补充证据,不改变后续小范围文档或计划修正的默认最小门禁。 +- 已满足:`product-full` 默认能力保护未改变;产品 crate 仍显式启用完整 runtime;构建脚本和 installer 范围保持无 diff。 +- 已满足:`bitfun-agent-stream` 不依赖 `bitfun-core`,stream 旧路径通过 core compatibility wrapper 委托到新 crate。 +- 已满足:`bitfun-runtime-ports` 仍保持 DTO / trait-only,第一批 core adapter 已建立。 +- 已收敛:`DynamicToolProvider` adapter 只暴露 MCP 命名空间动态工具,不把内置工具误报为动态 provider。 +- 尚未完成:remote connect / cron / MCP 的 concrete call-site 尚未迁移到 ports;这部分属于里程碑二 service owner crate 迁移,不应在当前回合声明完成。 +- 尚未完成:generic attachments / image context 尚未接入 `AgentSubmissionPort`;接入前必须补多模态行为保护测试。 + +**P1 退出审查补充(2026-05-11):** + +- 审查当前 `origin/main..HEAD` 的 P1 相关变更后,未发现需要阻塞 P1 退出的产品正确性回归。 +- `AgentSubmissionRequest.source` 已显式化,但 `turnId` 仍藏在 metadata 中。该问题不推翻第一批 + port 边界已经建立的 P1 结论,但必须作为 P2 迁移 remote connect / cron / MCP concrete + call-site 前的 contract hardening。 +- `DynamicToolProvider` 已过滤为 MCP 动态工具,但 provider 身份仍从 `mcp__server__tool` 注册名反推。 + 该问题属于 P2/P3 tool owner 迁移前的维护性债务;在抽出 `agent-tools` 或调整 MCP 命名策略前必须改为 + 显式 provider metadata。 +- 当前验证通过:`cargo test -p bitfun-runtime-ports`、`cargo test -p bitfun-agent-stream`、 + `cargo check -p bitfun-core --features product-full`、`cargo check -p bitfun-desktop`、 + `cargo check -p bitfun-cli`、`cargo check -p bitfun-server`、`cargo check --workspace`、 + `cargo test --workspace`、`pnpm run lint:web`、`pnpm run type-check:web`、 + `pnpm --dir src/web-ui run test:run`,并确认构建脚本 / installer 保护范围无 diff。 + 现存 Cargo 输出仅包含既有 desktop unused import 警告,不阻塞 P1 退出。 +- 结论:按当前 P1 范围,边界安全网与最小编译面验证已经完成;未迁移的 concrete call-site、 + attachments / image context、显式 `turnId` 和 provider metadata hardening 转入 P2/P3 前置队列, + 不应被计入 P1 未完成项。 + +**暂停条件:** + +- `core-types` 需要引入运行时依赖才能通过编译。 +- port 设计开始变成万能 context。 +- `agent-stream` 无法脱离完整 core,说明应重新评估 stream 边界。 +- 任何任务需要顺手清理非绝对等价重复代码。 + +### 里程碑二:中等粒度 owner crate 成型 + +**覆盖计划:** + +- Plan 6:抽取 `bitfun-services-core` 和 `bitfun-services-integrations`。 +- Plan 7:拆解 `bitfun-agent-tools` 和 `bitfun-tool-packs`。 +- Plan 8:抽取 `bitfun-product-domains`。 +- 低风险直接依赖版本收敛只允许作为独立小 PR 插入。 + +**目标:** + +- 将当前 core 中最重的 service、tool、product domain 职责迁移到中等粒度 owner crate。 +- 用 feature group 隔离重依赖,而不是拆成大量小 crate。 +- 让局部 service/tool/domain 测试可以绕开完整 product runtime。 +- 保持产品完整 runtime 通过 `product-full` 组装同等能力。 +- 在重 service/tool 迁移前先收紧 P1 暴露出的 port/tool contract:显式 `turnId`、显式 dynamic tool provider metadata、以及迁移路径的回归测试入口。 + +**主要工作:** + +- `bitfun-services-core`:先迁移 config、session、workspace、storage、filesystem、system、session_usage、token_usage 等基础服务,保持旧 core 路径 re-export。 +- `bitfun-services-integrations`:按 git、remote-ssh、MCP、remote-connect 顺序迁移重集成;每迁移一个 feature group 都保留产品完整 runtime 等价性。 +- `bitfun-agent-tools` / `bitfun-tool-packs`:拆出 tool trait、context、registry、provider contract,并通过 feature group 承载具体工具实现。 +- `bitfun-product-domains`:承接 miniapp 和 function-agent 产品子域,避免继续扩大 `bitfun-core` 的产品职责。 + +**影响面:** + +- Rust crate graph、workspace manifests、core compatibility re-export、feature group 组装。 +- `src/crates/core/src/service/**`、`agentic/tools/**`、MCP / remote SSH / remote connect / git integration。 +- Desktop、CLI、server 通过 `product-full` 组装的完整能力验证。 + +**优先风险:** + +- service/tool 迁移改变产品 feature set 或默认能力。 +- 新 owner crate 反向依赖 `bitfun-core`,导致 facade 计划失效。 +- remote connect / cron / MCP 接入 ports 时丢失 `turnId`、attachment、subagent、cancellation 或 transcript 关联语义。 +- MCP 动态工具 provider metadata 在 registry/tool owner 迁移中断裂。 +- 工具清单、snapshot wrapping、permission / concurrency safety 行为与迁移前不等价。 + +**实现边界:** + +- service 侧只拆成 `services-core` 和 `services-integrations`,继续拆小必须有实测依据。 +- tool 侧只拆成 `agent-tools` 和 `tool-packs`,具体工具族通过 feature group 控制。 +- miniapp 和 function agents 先合并到 `product-domains`,不分别建独立 crate。 +- 每次只迁移一个 feature group 或一个模块簇。 +- 不允许在同一 PR 中做三方库大版本升级。 +- 不允许改变产品默认能力、CI 覆盖或 release 脚本。 + +**验收门:** + +```powershell +cargo test -p bitfun-services-core +cargo check -p bitfun-services-integrations --features product-full +cargo test -p bitfun-agent-tools +cargo check -p bitfun-tool-packs --features product-full +cargo check -p bitfun-product-domains --features product-full +cargo check -p bitfun-core --features product-full +cargo check -p bitfun-desktop +cargo check -p bitfun-cli +cargo check -p bitfun-server +cargo check --workspace +``` + +期望: + +- 新 owner crate 不依赖回 `bitfun-core`。 +- 产品完整 runtime 的工具、MCP、remote SSH、remote connect、miniapp、function agents 仍可用。 +- 新增 crate 数量仍保持中等粒度。 +- heavy dependency 所属 crate 清晰。 + +**暂停条件:** + +- 某个迁移必须让产品 crate 减少 feature 才能通过。 +- `services-integrations` 的 feature group 互相强耦合,无法单独 check。 +- tool registry 迁移后工具清单无法证明等价。 +- 新 owner crate 反向依赖 core。 + +### 里程碑三:facade 收敛、边界强制与可选默认轻量化评估 + +**覆盖计划:** + +- Plan 9:`bitfun-core` 收敛为 facade + product runtime assembly。 +- 边界检查脚本。 +- 依赖版本收敛复查。 +- 可选评估 `bitfun-core default = []`,但仅在完整门禁通过后单独执行。 + +**目标:** + +- `bitfun-core` 不再承载新实现,只负责旧路径兼容和完整产品 runtime 组装。 +- 用边界检查防止新 crate 重新依赖回 core。 +- 评估是否值得让 `bitfun-core` default 变轻,但不把它作为默认结论。 +- 保证整体性能没有明显负向影响。 + +**实现边界:** + +- 可以把旧模块改为 re-export。 +- 可以新增 boundary check 脚本。 +- 可以做低风险直接依赖版本收敛。 +- `default = []` 必须是单独 PR,且只在所有产品 crate 显式启用完整 runtime 后评估。 +- 不允许把 facade 变成新的业务实现聚合。 + +**验收门:** + +```powershell +node scripts/check-core-boundaries.mjs +cargo check -p bitfun-core --features product-full +cargo test --workspace +cargo build -p bitfun-desktop +pnpm run desktop:build:fast +pnpm run desktop:build:release-fast +git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer +``` + +期望: + +- `bitfun-core` 旧路径兼容。 +- 边界检查通过。 +- 完整 workspace 测试和 desktop build 通过。 +- 构建脚本无 diff。 +- 若性能收益不明显,也不能有明显退化;必要时保留中等粒度边界,不继续拆小。 + +**暂停条件:** + +- 完整产品矩阵无法通过。 +- default feature 轻量化会改变任一产品能力。 +- boundary check 发现 extracted crate 依赖回 core。 +- 构建或链接时间因 crate 过碎出现明显退化且无法通过合并修正。 + +--- + +## 11. 推荐 PR 顺序 + +1. 文档与基线护栏。 +2. `product-full` feature 安全网,不改变 default 行为。 +3. 移动 nested `terminal-core` 和 `tool-runtime` 到 workspace 顶层。 +4. 抽取 `bitfun-core-types`,先放错误和第一批稳定 DTO。 +5. 抽取 `bitfun-agent-stream`,迁移 stream processor 测试。 +6. 引入 runtime ports 初始边界;后续在 service 迁移中逐步打断 `service <-> agentic` concrete 循环。 +7. 抽取 `bitfun-services-core`。 +8. 抽取 `bitfun-services-integrations`,按 `git`、`remote-ssh`、`mcp`、`remote-connect` 顺序迁移 feature group。 +9. 拆解 agent tools 为 `bitfun-agent-tools` 和 `bitfun-tool-packs`。 +10. 抽取 `bitfun-product-domains`,承载 miniapp 和 function agents。 +11. 将 `bitfun-core` 收敛为 facade + product runtime assembly。 +12. 只有在全产品显式启用完整 runtime 且完整门禁通过后,单独评估 `bitfun-core default = []`。 + +冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 + +--- + +## 12. 完成标准 + +- stream processor 和纯 service 测试可以在不编译完整产品 runtime 的情况下运行。 +- 产品构建脚本和 release/fast build 脚本没有因为 core 拆解被修改。 +- 产品 crate 仍拥有拆解前的完整能力集合。 +- `bitfun-core` 对现有调用方保持 import-compatible。 +- 新拆出的 crate 不依赖回 `bitfun-core`。 +- 新增 crate 数量保持在中等粒度范围;继续拆小必须有依赖、owner 或实测收益依据。 +- 重依赖归属于真正需要它们的 owner crate。 +- `service` 与 `agentic` 的跨层调用通过 ports/providers,而不是 global concrete access。 +- 至少在关键 crate 拆出后,有边界检查脚本防止回退。 +- 每个关键迁移点都有注释说明兼容门面、owner crate 或接口边界。 +- 冗余清理只处理已证明绝对等价的重复代码;不因为相似流程引入新抽象。 diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 6dd42ccf0..edf7c4674 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -11,7 +11,7 @@ path = "src/main.rs" [dependencies] # Internal crates -bitfun-core = { path = "../../crates/core" } +bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } bitfun-events = { path = "../../crates/events" } bitfun-acp = { path = "../../crates/acp" } diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 8f90e4177..afbc88c15 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -19,7 +19,7 @@ serde_json = { workspace = true } [dependencies] # Internal crates -bitfun-core = { path = "../../crates/core", features = ["ssh-remote"] } +bitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] } bitfun-transport = { path = "../../crates/transport", features = ["tauri-adapter"] } bitfun-webdriver = { path = "../../crates/webdriver" } bitfun-acp = { path = "../../crates/acp" } diff --git a/src/crates/acp/Cargo.toml b/src/crates/acp/Cargo.toml index 9b33fb2c5..c469cd732 100644 --- a/src/crates/acp/Cargo.toml +++ b/src/crates/acp/Cargo.toml @@ -9,7 +9,7 @@ description = "BitFun Agent Client Protocol integration" name = "bitfun_acp" [dependencies] -bitfun-core = { path = "../core" } +bitfun-core = { path = "../core", default-features = false, features = ["product-full"] } bitfun-events = { path = "../events" } agent-client-protocol = { version = "=0.11.1", features = ["unstable"] } diff --git a/src/crates/agent-stream/Cargo.toml b/src/crates/agent-stream/Cargo.toml new file mode 100644 index 000000000..b8f38366a --- /dev/null +++ b/src/crates/agent-stream/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bitfun-agent-stream" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Lightweight agent stream processing for BitFun" + +[lib] +name = "bitfun_agent_stream" +crate-type = ["rlib"] + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +bitfun-ai-adapters = { path = "../ai-adapters" } +bitfun-events = { path = "../events" } +futures = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +axum = { workspace = true } +reqwest = { workspace = true } +tokio-stream = { workspace = true } diff --git a/src/crates/agent-stream/src/lib.rs b/src/crates/agent-stream/src/lib.rs new file mode 100644 index 000000000..cbfbc9bec --- /dev/null +++ b/src/crates/agent-stream/src/lib.rs @@ -0,0 +1,1339 @@ +//! Stream Processor +//! +//! Processes AI streaming responses, supports tool pre-detection and parameter streaming + +use bitfun_ai_adapters::tool_call_accumulator::{ + FinalizedToolCall, PendingToolCalls, ToolCallBoundary, ToolCallStreamKey, +}; +use bitfun_ai_adapters::{GeminiUsage, UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; +use bitfun_events::{ + AgenticEvent, AgenticEventPriority as EventPriority, + SubagentParentInfo as EventSubagentParentInfo, ToolEventData, +}; +use futures::{Stream, StreamExt}; +use log::{debug, error, trace}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::mpsc; + +/// Minimal tool-call value emitted by the stream processor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub tool_id: String, + pub tool_name: String, + pub arguments: serde_json::Value, + /// Original provider-emitted argument JSON, preserved for replay stability when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_arguments: Option, + /// Record whether tool parameters are valid. + pub is_error: bool, + /// True when truncated raw JSON arguments were repaired into a partial tool call. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recovered_from_truncation: bool, +} + +impl ToolCall { + pub fn is_valid(&self) -> bool { + !self.tool_id.is_empty() && !self.tool_name.is_empty() && !self.is_error + } +} + +/// Parent task metadata needed to scope stream events for subagents. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubagentParentInfo { + #[serde(rename = "toolCallId")] + pub tool_call_id: String, + #[serde(rename = "sessionId")] + pub session_id: String, + #[serde(rename = "dialogTurnId")] + pub dialog_turn_id: String, +} + +impl From for EventSubagentParentInfo { + fn from(info: SubagentParentInfo) -> Self { + Self { + tool_call_id: info.tool_call_id, + session_id: info.session_id, + dialog_turn_id: info.dialog_turn_id, + } + } +} + +/// Stream-processor specific error that avoids depending on core runtime errors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StreamProcessorError { + AiClient(String), + Cancelled(String), +} + +impl fmt::Display for StreamProcessorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AiClient(msg) => write!(f, "AI client error: {}", msg), + Self::Cancelled(msg) => write!(f, "Operation cancelled: {}", msg), + } + } +} + +impl std::error::Error for StreamProcessorError {} + +/// Event sink abstraction used by stream processing. Product crates can adapt +/// their own queue implementation without making this crate depend on core. +#[async_trait::async_trait] +pub trait StreamEventSink: Send + Sync { + async fn enqueue(&self, event: AgenticEvent, priority: Option); +} + +fn elapsed_ms_u64(started_at: Instant) -> u64 { + started_at + .elapsed() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +//============================================================================== +// SSE Log Collector - Outputs raw SSE data on error +//============================================================================== + +/// SSE log collector configuration +#[derive(Debug, Clone, Default)] +pub struct SseLogConfig { + /// Maximum number of SSE data entries to output on error, None means unlimited + pub max_output: Option, +} + +/// SSE log collector - Collects raw SSE data, outputs only on error +pub struct SseLogCollector { + buffer: Vec, + config: SseLogConfig, +} + +impl SseLogCollector { + pub fn new(config: SseLogConfig) -> Self { + Self { + buffer: Vec::new(), + config, + } + } + + /// Push one SSE data entry + pub fn push(&mut self, data: String) { + self.buffer.push(data); + } + + /// Get number of collected data entries + pub fn len(&self) -> usize { + self.buffer.len() + } + + /// Check if empty + pub fn is_empty(&self) -> bool { + self.buffer.is_empty() + } + + /// Flush all SSE data to log on error + pub fn flush_on_error(&self, error_context: &str) { + if self.buffer.is_empty() { + error!("SSE Error: {} (no SSE data collected)", error_context); + return; + } + + error!("SSE Error: {}", error_context); + let mut sse_msg = format!("SSE history ({} events):\n", self.buffer.len()); + + match self.config.max_output { + None => { + // No limit, output all + for (i, data) in self.buffer.iter().enumerate() { + sse_msg.push_str(&format!("{:>6}: {}\n", i, data)); + } + } + Some(max) if self.buffer.len() <= max => { + // Within limit, output all + for (i, data) in self.buffer.iter().enumerate() { + sse_msg.push_str(&format!("{:>6}: {}\n", i, data)); + } + } + Some(max) => { + // Exceeds limit, smart truncation: output beginning + end + let head = 50.min(max / 2); + let tail = max - head; + let total = self.buffer.len(); + + for (i, data) in self.buffer.iter().take(head).enumerate() { + sse_msg.push_str(&format!("{:>6}: {}\n", i, data)); + } + sse_msg.push_str(&format!("... ({} events omitted) ...\n", total - max)); + for (i, data) in self.buffer.iter().skip(total - tail).enumerate() { + sse_msg.push_str(&format!("{:>6}: {}\n", total - tail + i, data)); + } + } + } + + error!("{}", sse_msg); + } +} + +/// Placeholder name for tool calls whose name was not received before the stream terminated. +const UNKNOWN_TOOL_PLACEHOLDER: &str = "unknown_tool"; + +/// Stream processing result +#[derive(Debug, Clone)] +pub struct StreamResult { + pub full_thinking: String, + /// Whether the provider emitted a reasoning/thinking field even if its content was empty. + pub reasoning_content_present: bool, + /// Signature of Anthropic extended thinking (passed back in multi-turn conversations) + pub thinking_signature: Option, + pub full_text: String, + pub tool_calls: Vec, + /// Token usage statistics (from model response) + pub usage: Option, + /// Provider-specific metadata captured from the stream tail. + pub provider_metadata: Option, + /// Whether this stream produced any user-visible output (text/thinking/tool events) + pub has_effective_output: bool, + /// Milliseconds from stream processing start to the first upstream response item. + pub first_chunk_ms: Option, + /// Milliseconds from stream processing start to the first event visible to the UI. + pub first_visible_output_ms: Option, + /// When set, the stream terminated abnormally but was recovered with partial output. + /// Contains a human-readable reason (e.g. "Stream processing error: ..." or + /// "Stream processor watchdog timeout ..."). + pub partial_recovery_reason: Option, +} + +/// Stream processing error with output diagnostics. +#[derive(Debug)] +pub struct StreamProcessError { + pub error: StreamProcessorError, + pub has_effective_output: bool, +} + +impl StreamProcessError { + fn new(error: StreamProcessorError, has_effective_output: bool) -> Self { + Self { + error, + has_effective_output, + } + } +} + +/// Stream processing context, encapsulates state during stream processing +struct StreamContext { + session_id: String, + dialog_turn_id: String, + round_id: String, + event_subagent_parent_info: Option, + subagent_parent_info: Option, + + // Accumulated results + full_thinking: String, + reasoning_content_present: bool, + /// Signature of Anthropic extended thinking (passed back in multi-turn conversations) + thinking_signature: Option, + full_text: String, + tool_calls: Vec, + usage: Option, + provider_metadata: Option, + + // Current tool call state + pending_tool_calls: PendingToolCalls, + + // Counters and flags + stream_started_at: Instant, + first_chunk_ms: Option, + first_visible_output_ms: Option, + text_chunks_count: usize, + thinking_chunks_count: usize, + thinking_completed_sent: bool, + has_effective_output: bool, + partial_recovery_reason: Option, +} + +impl StreamContext { + fn new( + session_id: String, + dialog_turn_id: String, + round_id: String, + subagent_parent_info: Option, + ) -> Self { + let event_subagent_parent_info = subagent_parent_info.clone().map(|info| info.into()); + Self { + session_id, + dialog_turn_id, + round_id, + event_subagent_parent_info, + subagent_parent_info, + full_thinking: String::new(), + reasoning_content_present: false, + thinking_signature: None, + full_text: String::new(), + tool_calls: Vec::new(), + usage: None, + provider_metadata: None, + pending_tool_calls: PendingToolCalls::default(), + stream_started_at: Instant::now(), + first_chunk_ms: None, + first_visible_output_ms: None, + text_chunks_count: 0, + thinking_chunks_count: 0, + thinking_completed_sent: false, + has_effective_output: false, + partial_recovery_reason: None, + } + } + + fn into_result(self) -> StreamResult { + StreamResult { + full_thinking: self.full_thinking, + reasoning_content_present: self.reasoning_content_present, + thinking_signature: self.thinking_signature, + full_text: self.full_text, + tool_calls: self.tool_calls, + usage: self.usage, + provider_metadata: self.provider_metadata, + has_effective_output: self.has_effective_output, + first_chunk_ms: self.first_chunk_ms, + first_visible_output_ms: self.first_visible_output_ms, + partial_recovery_reason: self.partial_recovery_reason, + } + } + + fn mark_first_stream_chunk(&mut self) { + if self.first_chunk_ms.is_none() { + self.first_chunk_ms = Some(elapsed_ms_u64(self.stream_started_at)); + } + } + + fn mark_first_visible_output(&mut self) { + if self.first_visible_output_ms.is_none() { + self.first_visible_output_ms = Some(elapsed_ms_u64(self.stream_started_at)); + } + } + + fn can_recover_as_partial_result(&self) -> bool { + self.has_effective_output + } + + fn record_finalized_tool_call(&mut self, finalized: &FinalizedToolCall) { + let tool_name = if finalized.tool_name.is_empty() { + UNKNOWN_TOOL_PLACEHOLDER.to_string() + } else { + finalized.tool_name.clone() + }; + let tool_id = if finalized.tool_id.is_empty() { + uuid::Uuid::new_v4().to_string() + } else { + finalized.tool_id.clone() + }; + self.tool_calls.push(ToolCall { + tool_id, + tool_name, + arguments: finalized.arguments.clone(), + raw_arguments: (!finalized.raw_arguments.is_empty()) + .then_some(finalized.raw_arguments.clone()), + is_error: finalized.is_error, + recovered_from_truncation: finalized.recovered_from_truncation, + }); + } + + fn finalize_all_pending_tool_calls( + &mut self, + boundary: ToolCallBoundary, + ) -> Vec { + let finalized = self.pending_tool_calls.finalize_all(boundary); + for tool_call in &finalized { + self.record_finalized_tool_call(tool_call); + } + finalized + } + + /// Force finish pending tool calls, used when the stream is shutting down before a natural tool boundary. + fn force_finish_pending_tool_calls(&mut self) { + for finalized in self.finalize_all_pending_tool_calls(ToolCallBoundary::GracefulShutdown) { + error!( + "force finish pending tool call: tool_id={}, tool_name={}, raw_len={}, is_error={}", + finalized.tool_id, + finalized.tool_name, + finalized.raw_arguments.len(), + finalized.is_error + ); + } + } +} + +enum TimedStreamItem { + Item(T), + End, + TimedOut, +} + +async fn next_stream_item( + stream: &mut S, + watchdog_timeout: Option, +) -> TimedStreamItem +where + S: Stream + Unpin, +{ + match watchdog_timeout { + Some(timeout) => match tokio::time::timeout(timeout, stream.next()).await { + Ok(Some(item)) => TimedStreamItem::Item(item), + Ok(None) => TimedStreamItem::End, + Err(_) => TimedStreamItem::TimedOut, + }, + None => match stream.next().await { + Some(item) => TimedStreamItem::Item(item), + None => TimedStreamItem::End, + }, + } +} + +/// Stream processor +pub struct StreamProcessor { + event_sink: Arc, +} + +impl StreamProcessor { + const WATCHDOG_GRACE_SECS: u64 = 5; + + pub fn new(event_sink: Arc) -> Self + where + E: StreamEventSink + 'static, + { + Self { event_sink } + } + + pub fn derive_watchdog_timeout( + stream_idle_timeout: Option, + ) -> Option { + stream_idle_timeout.map(|timeout| { + timeout + .checked_add(std::time::Duration::from_secs(Self::WATCHDOG_GRACE_SECS)) + .unwrap_or(std::time::Duration::MAX) + }) + } + + fn merge_json_value(target: &mut Value, overlay: Value) { + match (target, overlay) { + (Value::Object(target_map), Value::Object(overlay_map)) => { + for (key, value) in overlay_map { + let entry = target_map.entry(key).or_insert(Value::Null); + Self::merge_json_value(entry, value); + } + } + (target_slot, overlay_value) => { + *target_slot = overlay_value; + } + } + } + + // ==================== Helper Methods ==================== + + /// Send thinking end event (if needed) + async fn send_thinking_end_if_needed(&self, ctx: &mut StreamContext) { + if ctx.thinking_chunks_count > 0 && !ctx.thinking_completed_sent { + ctx.thinking_completed_sent = true; + debug!("Thinking process ended, sending ThinkingChunk end event"); + let _ = self + .event_sink + .enqueue( + AgenticEvent::ThinkingChunk { + session_id: ctx.session_id.clone(), + turn_id: ctx.dialog_turn_id.clone(), + round_id: ctx.round_id.clone(), + content: String::new(), + is_end: true, + subagent_parent_info: ctx.event_subagent_parent_info.clone(), + }, + Some(EventPriority::Normal), + ) + .await; + } + } + + /// Check cancellation and execute graceful shutdown, returns Some(Err) if processing needs to be interrupted + async fn check_cancellation( + &self, + ctx: &mut StreamContext, + cancellation_token: &tokio_util::sync::CancellationToken, + location: &str, + ) -> Option> { + if cancellation_token.is_cancelled() { + debug!( + "Cancellation detected at {}: location={}", + location, location + ); + self.graceful_shutdown_from_ctx(ctx, "User cancelled stream processing".to_string()) + .await; + Some(Err(StreamProcessError::new( + StreamProcessorError::Cancelled("Stream processing cancelled".to_string()), + ctx.has_effective_output, + ))) + } else { + None + } + } + + /// Execute graceful shutdown from context + async fn graceful_shutdown_from_ctx(&self, ctx: &mut StreamContext, reason: String) { + ctx.force_finish_pending_tool_calls(); + self.graceful_shutdown( + ctx.session_id.clone(), + ctx.dialog_turn_id.clone(), + ctx.tool_calls.clone(), + reason, + ctx.subagent_parent_info.clone(), + ) + .await; + } + + /// Graceful shutdown: cleanup all unfinished tool states and notify frontend + async fn graceful_shutdown( + &self, + session_id: String, + turn_id: String, + tool_calls: Vec, + reason: String, + subagent_parent_info: Option, + ) { + debug!( + "Starting graceful shutdown: session_id={}, reason={}", + session_id, reason + ); + + let is_user_cancellation = reason.contains("cancelled") || reason.contains("cancelled"); + let tool_call_count = tool_calls.len(); + let event_subagent_parent_info = subagent_parent_info.map(|info| info.clone().into()); + + // 1. Cleanup all tool calls + for tool_call in tool_calls { + trace!( + "Cleaning up tool: {} ({})", + tool_call.tool_name, + tool_call.tool_id + ); + + let tool_event = if is_user_cancellation { + ToolEventData::Cancelled { + tool_id: tool_call.tool_id, + tool_name: tool_call.tool_name, + reason: reason.clone(), + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + } + } else { + ToolEventData::Failed { + tool_id: tool_call.tool_id, + tool_name: tool_call.tool_name, + error: reason.clone(), + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + } + }; + + let _ = self + .event_sink + .enqueue( + AgenticEvent::ToolEvent { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + tool_event, + subagent_parent_info: event_subagent_parent_info.clone(), + }, + Some(EventPriority::High), + ) + .await; + } + + // 2. Send dialog turn status update (if tools were cleaned up) + if tool_call_count > 0 { + let event = if is_user_cancellation { + AgenticEvent::DialogTurnCancelled { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + subagent_parent_info: event_subagent_parent_info.clone(), + } + } else { + AgenticEvent::DialogTurnFailed { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + error: reason, + error_category: None, + error_detail: None, + subagent_parent_info: event_subagent_parent_info.clone(), + } + }; + let _ = self + .event_sink + .enqueue(event, Some(EventPriority::Critical)) + .await; + } + + debug!( + "Graceful shutdown completed: cleaned up {} tools", + tool_call_count + ); + } + + /// Handle usage statistics + fn handle_usage(&self, ctx: &mut StreamContext, response_usage: &UnifiedTokenUsage) { + ctx.usage = Some(GeminiUsage { + prompt_token_count: response_usage.prompt_token_count, + candidates_token_count: response_usage.candidates_token_count, + total_token_count: response_usage.total_token_count, + reasoning_token_count: response_usage.reasoning_token_count, + cached_content_token_count: response_usage.cached_content_token_count, + }); + debug!( + "Received token usage stats: input={}, output={}, total={}", + response_usage.prompt_token_count, + response_usage.candidates_token_count, + response_usage.total_token_count + ); + } + + /// Handle tool call chunk + async fn handle_tool_call_chunk(&self, ctx: &mut StreamContext, tool_call: UnifiedToolCall) { + let UnifiedToolCall { + tool_call_index, + id, + name, + arguments, + arguments_is_snapshot, + } = tool_call; + let outcome = ctx.pending_tool_calls.apply_delta( + ToolCallStreamKey::from(tool_call_index), + id, + name, + arguments, + arguments_is_snapshot, + ); + + if let Some(finalized) = outcome.finalized_previous { + ctx.record_finalized_tool_call(&finalized); + } + + if let Some(early_detected) = outcome.early_detected { + ctx.has_effective_output = true; + ctx.mark_first_visible_output(); + debug!("Tool detected: {}", early_detected.tool_name); + let _ = self + .event_sink + .enqueue( + AgenticEvent::ToolEvent { + session_id: ctx.session_id.clone(), + turn_id: ctx.dialog_turn_id.clone(), + tool_event: ToolEventData::EarlyDetected { + tool_id: early_detected.tool_id, + tool_name: early_detected.tool_name, + }, + subagent_parent_info: ctx.event_subagent_parent_info.clone(), + }, + None, + ) + .await; + } + + if let Some(params_partial) = outcome.params_partial { + ctx.has_effective_output = true; + ctx.mark_first_visible_output(); + let _ = self + .event_sink + .enqueue( + AgenticEvent::ToolEvent { + session_id: ctx.session_id.clone(), + turn_id: ctx.dialog_turn_id.clone(), + tool_event: ToolEventData::ParamsPartial { + tool_id: params_partial.tool_id, + tool_name: params_partial.tool_name, + params: params_partial.params_chunk, + }, + subagent_parent_info: ctx.event_subagent_parent_info.clone(), + }, + None, + ) + .await; + } + } + + /// Handle text chunk + async fn handle_text_chunk(&self, ctx: &mut StreamContext, text: String) { + if !text.trim().is_empty() { + ctx.has_effective_output = true; + ctx.mark_first_visible_output(); + } + ctx.full_text.push_str(&text); + ctx.text_chunks_count += 1; + + // Send streaming text event + let _ = self + .event_sink + .enqueue( + AgenticEvent::TextChunk { + session_id: ctx.session_id.clone(), + turn_id: ctx.dialog_turn_id.clone(), + round_id: ctx.round_id.clone(), + text, + subagent_parent_info: ctx.event_subagent_parent_info.clone(), + }, + None, + ) + .await; + } + + /// Handle thinking chunk + async fn handle_thinking_chunk(&self, ctx: &mut StreamContext, thinking_content: String) { + // Thinking-only output does NOT count as "effective" for retry purposes: + // if the stream fails after producing only thinking (no text/tool calls), + // it is safe to retry because the model will re-think from scratch. + ctx.full_thinking.push_str(&thinking_content); + ctx.mark_first_visible_output(); + ctx.thinking_chunks_count += 1; + + // Send thinking chunk event + let _ = self + .event_sink + .enqueue( + AgenticEvent::ThinkingChunk { + session_id: ctx.session_id.clone(), + turn_id: ctx.dialog_turn_id.clone(), + round_id: ctx.round_id.clone(), + content: thinking_content, + is_end: false, + subagent_parent_info: ctx.event_subagent_parent_info.clone(), + }, + None, + ) + .await; + } + + /// Print stream processing end log + fn log_stream_result(&self, ctx: &StreamContext) { + debug!( + "Stream loop ended: text_chunks={}, thinking_chunks={}, tool_calls({}), first_chunk_ms={:?}, first_visible_output_ms={:?}: {}", + ctx.text_chunks_count, + ctx.thinking_chunks_count, + ctx.tool_calls.len(), + ctx.first_chunk_ms, + ctx.first_visible_output_ms, + ctx.tool_calls + .iter() + .map(|tc| tc.tool_name.as_str()) + .collect::>() + .join(", ") + ); + + if log::log_enabled!(log::Level::Debug) { + if !ctx.full_thinking.is_empty() { + debug!(target: "ai::stream_processor", "Full thinking content: \n{}", ctx.full_thinking); + } + if !ctx.full_text.is_empty() { + debug!(target: "ai::stream_processor", "Full text content: \n{}", ctx.full_text); + } + if !ctx.tool_calls.is_empty() { + let log_str: String = ctx + .tool_calls + .iter() + .map(|tc| { + format!( + "Tool name: {}, arguments: {}\n", + tc.tool_name, + serde_json::to_string(&tc.arguments) + .unwrap_or_else(|_| "Serialization failed".to_string()) + ) + }) + .collect(); + debug!(target: "ai::stream_processor", "Tool call details: \n{}", log_str); + } + } + + trace!( + "Returning StreamResult: thinking_len={}, text_len={}, tool_calls={}, has_usage={}, has_effective_output={}", + ctx.full_thinking.len(), + ctx.full_text.len(), + ctx.tool_calls.len(), + ctx.usage.is_some(), + ctx.has_effective_output + ); + } + + // ==================== Main Processing Methods ==================== + + /// Process AI streaming response + /// + /// # Arguments + /// * `stream` - Parsed response stream + /// * `raw_sse_rx` - Optional raw SSE data receiver (for collecting raw data during error diagnosis) + /// * `session_id` - Session ID + /// * `dialog_turn_id` - Dialog turn ID + /// * `round_id` - Model round ID + /// * `subagent_parent_info` - Subagent parent info + /// * `cancellation_token` - Cancellation token + #[allow(clippy::too_many_arguments)] + pub async fn process_stream( + &self, + mut stream: futures::stream::BoxStream<'static, Result>, + watchdog_timeout: Option, + raw_sse_rx: Option>, + session_id: String, + dialog_turn_id: String, + round_id: String, + subagent_parent_info: Option, + cancellation_token: &tokio_util::sync::CancellationToken, + ) -> Result { + let mut ctx = + StreamContext::new(session_id, dialog_turn_id, round_id, subagent_parent_info); + // Start SSE log collector (if raw_sse_rx is provided) + let sse_collector = if let Some(mut rx) = raw_sse_rx { + let collector = Arc::new(tokio::sync::Mutex::new(SseLogCollector::new( + SseLogConfig::default(), // No limit for now + ))); + let collector_clone = collector.clone(); + + // Start background task to collect SSE data + tokio::spawn(async move { + while let Some(data) = rx.recv().await { + collector_clone.lock().await.push(data); + } + }); + + Some(collector) + } else { + None + }; + + // Define a helper closure to flush SSE logs on error + let flush_sse_on_error = |collector: &Option>>, + error_context: &str| { + let collector = collector.clone(); + let error_context = error_context.to_string(); + async move { + if let Some(c) = collector { + // Wait a short time for background task to finish collecting data + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + c.lock().await.flush_on_error(&error_context); + } + } + }; + + loop { + tokio::select! { + // Check cancellation token + _ = cancellation_token.cancelled() => { + debug!("Cancel token detected, stopping stream processing: session_id={}", ctx.session_id); + self.graceful_shutdown_from_ctx(&mut ctx, "User cancelled stream processing".to_string()).await; + return Err(StreamProcessError::new( + StreamProcessorError::Cancelled("Stream processing cancelled".to_string()), + ctx.has_effective_output, + )); + } + + // Watch the adapter -> processor stream only when the upstream stream idle timeout is configured. + next_result = next_stream_item(&mut stream, watchdog_timeout) => { + let response = match next_result { + TimedStreamItem::Item(Ok(response)) => response, + TimedStreamItem::End => { + debug!("Stream ended normally (no more data)"); + break; + } + TimedStreamItem::Item(Err(e)) => { + let error_msg = format!("Stream processing error: {}", e); + error!("{}", error_msg); + let non_recoverable_stream_error = + error_msg.contains("SSE Parsing Error"); + if !non_recoverable_stream_error && ctx.can_recover_as_partial_result() + { + flush_sse_on_error(&sse_collector, &error_msg).await; + self.send_thinking_end_if_needed(&mut ctx).await; + ctx.force_finish_pending_tool_calls(); + ctx.partial_recovery_reason = Some(error_msg.clone()); + self.log_stream_result(&ctx); + break; + } + // log SSE for network errors + flush_sse_on_error(&sse_collector, &error_msg).await; + self.graceful_shutdown_from_ctx(&mut ctx, error_msg.clone()).await; + return Err(StreamProcessError::new( + StreamProcessorError::AiClient(error_msg), + ctx.has_effective_output, + )); + } + TimedStreamItem::TimedOut => { + let timeout_secs = + watchdog_timeout.map(|timeout| timeout.as_secs()).unwrap_or(0); + let error_msg = format!( + "Stream processor watchdog timeout (no data received for {} seconds)", + timeout_secs + ); + error!( + "Stream processor watchdog timeout ({} seconds), forcing termination", + timeout_secs + ); + // log SSE for timeout errors + flush_sse_on_error(&sse_collector, &error_msg).await; + if ctx.can_recover_as_partial_result() { + self.send_thinking_end_if_needed(&mut ctx).await; + ctx.force_finish_pending_tool_calls(); + ctx.partial_recovery_reason = Some(error_msg.clone()); + self.log_stream_result(&ctx); + break; + } + self.graceful_shutdown_from_ctx(&mut ctx, error_msg.clone()).await; + return Err(StreamProcessError::new( + StreamProcessorError::AiClient(error_msg), + ctx.has_effective_output, + )); + } + }; + + let UnifiedResponse { + text, + reasoning_content, + thinking_signature, + tool_call, + usage, + finish_reason, + provider_metadata, + } = response; + ctx.mark_first_stream_chunk(); + + // Handle thinking_signature + if let Some(signature) = thinking_signature { + if !signature.is_empty() { + ctx.reasoning_content_present = true; + ctx.thinking_signature = Some(signature); + trace!("Received thinking_signature"); + } + } + + // Handle different types of response content + // Normalize empty strings to None + // (some models send empty text alongside reasoning content) + let text = text.filter(|t| !t.is_empty()); + + if let Some(thinking_content) = reasoning_content { + ctx.reasoning_content_present = true; + if !thinking_content.is_empty() { + self.handle_thinking_chunk(&mut ctx, thinking_content).await; + if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing thinking chunk").await { + return err; + } + } + } + + if let Some(text) = text { + self.send_thinking_end_if_needed(&mut ctx).await; + self.handle_text_chunk(&mut ctx, text).await; + if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing text chunk").await { + return err; + } + } + + if let Some(tool_call) = tool_call { + self.send_thinking_end_if_needed(&mut ctx).await; + self.handle_tool_call_chunk(&mut ctx, tool_call).await; + if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing tool call").await { + return err; + } + } + + if let Some(ref response_usage) = usage { + self.handle_usage(&mut ctx, response_usage); + } + + if let Some(provider_metadata) = provider_metadata { + match ctx.provider_metadata.as_mut() { + Some(existing) => Self::merge_json_value(existing, provider_metadata), + None => ctx.provider_metadata = Some(provider_metadata), + } + } + + if finish_reason.is_some() { + let _ = ctx.finalize_all_pending_tool_calls(ToolCallBoundary::FinishReason); + } + } + } + } + + // Ensure thinking end marker is sent + self.send_thinking_end_if_needed(&mut ctx).await; + + let _ = ctx.finalize_all_pending_tool_calls(ToolCallBoundary::StreamEnd); + + // Invalid tool payloads that survive to finalization still need detailed SSE logs for diagnosis. + if ctx.tool_calls.iter().any(|tc| !tc.is_valid()) { + flush_sse_on_error(&sse_collector, "Has invalid tool calls").await; + } + + self.log_stream_result(&ctx); + + Ok(ctx.into_result()) + } +} + +#[cfg(test)] +mod tests { + use super::{StreamEventSink, StreamProcessor}; + use bitfun_ai_adapters::{UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall}; + use bitfun_events::{AgenticEvent, AgenticEventPriority as EventPriority}; + use futures::StreamExt; + use serde_json::json; + use std::sync::Arc; + use std::time::Duration; + use tokio_stream::iter; + use tokio_util::sync::CancellationToken; + + struct NoopEventSink; + + #[async_trait::async_trait] + impl StreamEventSink for NoopEventSink { + async fn enqueue(&self, _event: AgenticEvent, _priority: Option) {} + } + + fn build_processor() -> StreamProcessor { + StreamProcessor::new(Arc::new(NoopEventSink)) + } + + #[test] + fn derives_watchdog_timeout_from_stream_idle_timeout() { + assert_eq!(StreamProcessor::derive_watchdog_timeout(None), None); + assert_eq!( + StreamProcessor::derive_watchdog_timeout(Some(Duration::from_secs(10))), + Some(Duration::from_secs(15)) + ); + } + + fn sample_usage(total_tokens: u32) -> UnifiedTokenUsage { + UnifiedTokenUsage { + prompt_token_count: 1, + candidates_token_count: total_tokens.saturating_sub(1), + total_token_count: total_tokens, + reasoning_token_count: None, + cached_content_token_count: None, + } + } + + #[tokio::test] + async fn keeps_collecting_tool_args_across_usage_chunks() { + let processor = build_processor(); + let stream = iter(vec![ + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: None, + id: Some("call_1".to_string()), + name: Some("tool_a".to_string()), + arguments: Some("{\"a\":".to_string()), + arguments_is_snapshot: false, + }), + usage: Some(sample_usage(5)), + ..Default::default() + }), + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: None, + id: None, + name: None, + arguments: Some("1}".to_string()), + arguments_is_snapshot: false, + }), + usage: Some(sample_usage(7)), + ..Default::default() + }), + ]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].tool_id, "call_1"); + assert_eq!(result.tool_calls[0].tool_name, "tool_a"); + assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); + assert_eq!( + result.tool_calls[0].raw_arguments.as_deref(), + Some("{\"a\":1}") + ); + assert!(!result.tool_calls[0].is_error); + assert_eq!(result.usage.as_ref().map(|u| u.total_token_count), Some(7)); + } + + #[tokio::test] + async fn whitespace_only_text_is_not_effective_output() { + let processor = build_processor(); + let stream = iter(vec![Ok(UnifiedResponse { + text: Some("\n\n ".to_string()), + ..Default::default() + })]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.full_text, "\n\n "); + assert!(!result.has_effective_output); + assert_eq!(result.first_visible_output_ms, None); + } + + #[tokio::test] + async fn finalizes_tool_after_same_chunk_finish_reason() { + let processor = build_processor(); + let stream = iter(vec![Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: None, + id: Some("call_1".to_string()), + name: Some("tool_a".to_string()), + arguments: Some("{\"a\":1}".to_string()), + arguments_is_snapshot: false, + }), + usage: Some(sample_usage(9)), + finish_reason: Some("tool_calls".to_string()), + ..Default::default() + })]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); + assert_eq!(result.usage.as_ref().map(|u| u.total_token_count), Some(9)); + } + + #[tokio::test] + async fn does_not_repair_tool_args_with_one_extra_trailing_right_brace() { + let processor = build_processor(); + let stream = iter(vec![Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: None, + id: Some("call_1".to_string()), + name: Some("tool_a".to_string()), + arguments: Some("{\"a\":1}}".to_string()), + arguments_is_snapshot: false, + }), + finish_reason: Some("tool_calls".to_string()), + ..Default::default() + })]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].tool_id, "call_1"); + assert_eq!(result.tool_calls[0].tool_name, "tool_a"); + assert_eq!(result.tool_calls[0].arguments, json!({})); + assert_eq!( + result.tool_calls[0].raw_arguments.as_deref(), + Some("{\"a\":1}}") + ); + assert!(result.tool_calls[0].is_error); + } + + #[tokio::test] + async fn replaces_tool_args_when_snapshot_chunk_arrives() { + let processor = build_processor(); + let stream = iter(vec![ + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: None, + id: Some("call_1".to_string()), + name: Some("tool_a".to_string()), + arguments: Some("{\"city\":\"Bei".to_string()), + arguments_is_snapshot: false, + }), + ..Default::default() + }), + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: None, + id: None, + name: None, + arguments: Some("{\"city\":\"Beijing\"}".to_string()), + arguments_is_snapshot: true, + }), + finish_reason: Some("tool_calls".to_string()), + ..Default::default() + }), + ]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 1); + assert_eq!(result.tool_calls[0].tool_id, "call_1"); + assert_eq!(result.tool_calls[0].tool_name, "tool_a"); + assert_eq!(result.tool_calls[0].arguments, json!({"city": "Beijing"})); + assert_eq!( + result.tool_calls[0].raw_arguments.as_deref(), + Some("{\"city\":\"Beijing\"}") + ); + assert!(!result.tool_calls[0].is_error); + } + + #[tokio::test] + async fn keeps_interleaved_indexed_tool_calls_separate() { + let processor = build_processor(); + let stream = iter(vec![ + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: Some(0), + id: Some("call_0".to_string()), + name: Some("tool_a".to_string()), + arguments: None, + arguments_is_snapshot: false, + }), + ..Default::default() + }), + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: Some(1), + id: Some("call_1".to_string()), + name: Some("tool_b".to_string()), + arguments: None, + arguments_is_snapshot: false, + }), + ..Default::default() + }), + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: Some(0), + id: None, + name: None, + arguments: Some("{\"a\":1}".to_string()), + arguments_is_snapshot: false, + }), + ..Default::default() + }), + Ok(UnifiedResponse { + tool_call: Some(UnifiedToolCall { + tool_call_index: Some(1), + id: None, + name: None, + arguments: Some("{\"b\":2}".to_string()), + arguments_is_snapshot: false, + }), + finish_reason: Some("tool_calls".to_string()), + ..Default::default() + }), + ]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert_eq!(result.tool_calls.len(), 2); + assert_eq!(result.tool_calls[0].tool_id, "call_0"); + assert_eq!(result.tool_calls[0].tool_name, "tool_a"); + assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); + assert_eq!(result.tool_calls[1].tool_id, "call_1"); + assert_eq!(result.tool_calls[1].tool_name, "tool_b"); + assert_eq!(result.tool_calls[1].arguments, json!({"b": 2})); + } + + #[tokio::test] + async fn preserves_empty_reasoning_presence_for_replay() { + let processor = build_processor(); + let stream = iter(vec![Ok(UnifiedResponse { + reasoning_content: Some(String::new()), + finish_reason: Some("stop".to_string()), + ..Default::default() + })]) + .boxed(); + + let result = processor + .process_stream( + stream, + None, + None, + "session_1".to_string(), + "turn_1".to_string(), + "round_1".to_string(), + None, + &CancellationToken::new(), + ) + .await + .expect("stream result"); + + assert!(result.reasoning_content_present); + assert!(result.full_thinking.is_empty()); + assert!(!result.has_effective_output); + } +} diff --git a/src/crates/core/tests/common/fixture_loader.rs b/src/crates/agent-stream/tests/common/fixture_loader.rs similarity index 100% rename from src/crates/core/tests/common/fixture_loader.rs rename to src/crates/agent-stream/tests/common/fixture_loader.rs diff --git a/src/crates/core/tests/common/mod.rs b/src/crates/agent-stream/tests/common/mod.rs similarity index 100% rename from src/crates/core/tests/common/mod.rs rename to src/crates/agent-stream/tests/common/mod.rs diff --git a/src/crates/core/tests/common/sse_fixture_server.rs b/src/crates/agent-stream/tests/common/sse_fixture_server.rs similarity index 100% rename from src/crates/core/tests/common/sse_fixture_server.rs rename to src/crates/agent-stream/tests/common/sse_fixture_server.rs diff --git a/src/crates/core/tests/common/stream_test_harness.rs b/src/crates/agent-stream/tests/common/stream_test_harness.rs similarity index 85% rename from src/crates/core/tests/common/stream_test_harness.rs rename to src/crates/agent-stream/tests/common/stream_test_harness.rs index d9cdbfe26..b18264226 100644 --- a/src/crates/core/tests/common/stream_test_harness.rs +++ b/src/crates/agent-stream/tests/common/stream_test_harness.rs @@ -1,18 +1,35 @@ use super::fixture_loader::load_fixture_bytes; use super::sse_fixture_server::{FixtureSseServer, FixtureSseServerOptions}; +use bitfun_agent_stream::{StreamEventSink, StreamProcessError, StreamProcessor, StreamResult}; use bitfun_ai_adapters::stream::{ handle_anthropic_stream, handle_gemini_stream, handle_openai_stream, handle_responses_stream, UnifiedResponse, }; -use bitfun_core::agentic::events::{AgenticEvent, EventQueue, EventQueueConfig}; -use bitfun_core::agentic::execution::{StreamProcessError, StreamResult}; -use bitfun_core::StreamProcessor; +use bitfun_events::{AgenticEvent, AgenticEventPriority as EventPriority}; use futures::StreamExt; use std::sync::Arc; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, Mutex}; use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_util::sync::CancellationToken; +#[derive(Default)] +struct RecordingEventSink { + events: Mutex>, +} + +#[async_trait::async_trait] +impl StreamEventSink for RecordingEventSink { + async fn enqueue(&self, event: AgenticEvent, _priority: Option) { + self.events.lock().await.push(event); + } +} + +impl RecordingEventSink { + async fn drain_all(&self) -> Vec { + std::mem::take(&mut *self.events.lock().await) + } +} + #[allow(dead_code)] #[derive(Debug, Clone, Copy)] pub enum StreamFixtureProvider { @@ -137,8 +154,8 @@ pub async fn run_stream_fixture_with_options( } } - let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); - let processor = StreamProcessor::new(event_queue.clone()); + let event_sink = Arc::new(RecordingEventSink::default()); + let processor = StreamProcessor::new(event_sink.clone()); let unified_stream = UnboundedReceiverStream::new(rx_event).boxed(); let cancellation_token = CancellationToken::new(); @@ -155,21 +172,7 @@ pub async fn run_stream_fixture_with_options( ) .await; - let events = drain_all_events(&event_queue).await; + let events = event_sink.drain_all().await; StreamFixtureRunOutput { result, events } } - -async fn drain_all_events(event_queue: &Arc) -> Vec { - let mut events = Vec::new(); - - loop { - let batch = event_queue.dequeue_batch(256).await; - if batch.is_empty() { - break; - } - events.extend(batch.into_iter().map(|envelope| envelope.event)); - } - - events -} diff --git a/src/crates/core/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse b/src/crates/agent-stream/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse rename to src/crates/agent-stream/tests/fixtures/stream/anthropic/empty_thinking_signature_text_and_tool_use.sse diff --git a/src/crates/core/tests/fixtures/stream/anthropic/extended_thinking.sse b/src/crates/agent-stream/tests/fixtures/stream/anthropic/extended_thinking.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/anthropic/extended_thinking.sse rename to src/crates/agent-stream/tests/fixtures/stream/anthropic/extended_thinking.sse diff --git a/src/crates/core/tests/fixtures/stream/anthropic/inline_think_text.sse b/src/crates/agent-stream/tests/fixtures/stream/anthropic/inline_think_text.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/anthropic/inline_think_text.sse rename to src/crates/agent-stream/tests/fixtures/stream/anthropic/inline_think_text.sse diff --git a/src/crates/core/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse b/src/crates/agent-stream/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse rename to src/crates/agent-stream/tests/fixtures/stream/anthropic/interleaved_parallel_tool_use.sse diff --git a/src/crates/core/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse b/src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse rename to src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_content_block_delta.sse diff --git a/src/crates/core/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse b/src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse rename to src/crates/agent-stream/tests/fixtures/stream/anthropic/malformed_tool_arguments_extra_brace.sse diff --git a/src/crates/core/tests/fixtures/stream/gemini/function_call_string_args.sse b/src/crates/agent-stream/tests/fixtures/stream/gemini/function_call_string_args.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/gemini/function_call_string_args.sse rename to src/crates/agent-stream/tests/fixtures/stream/gemini/function_call_string_args.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/empty_reasoning_content_text_and_tool_call.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/inline_think_text.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/inline_think_text.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/inline_think_text.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/inline_think_text.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/interleaved_parallel_tool_args_by_index.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/thinking_text_three_tools_with_empty_toolcall_anomaly.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_snapshot_stop_reason.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_args_split_with_usage.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_split_with_usage.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/tool_args_split_with_usage.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/tool_args_split_with_usage.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_call_missing_type_field.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_missing_type_field.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/tool_call_missing_type_field.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_missing_type_field.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/tool_call_trailing_empty_args_finish_chunk.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_only_orphan_filtered.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/tool_id_prelude_then_payload_without_id.sse diff --git a/src/crates/core/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse b/src/crates/agent-stream/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse rename to src/crates/agent-stream/tests/fixtures/stream/openai/two_tools_first_final_chunk_contains_orphan_id_only.sse diff --git a/src/crates/core/tests/fixtures/stream/responses/malformed_function_call_arguments.sse b/src/crates/agent-stream/tests/fixtures/stream/responses/malformed_function_call_arguments.sse similarity index 100% rename from src/crates/core/tests/fixtures/stream/responses/malformed_function_call_arguments.sse rename to src/crates/agent-stream/tests/fixtures/stream/responses/malformed_function_call_arguments.sse diff --git a/src/crates/core/tests/stream_processor_anthropic.rs b/src/crates/agent-stream/tests/stream_processor_anthropic.rs similarity index 99% rename from src/crates/core/tests/stream_processor_anthropic.rs rename to src/crates/agent-stream/tests/stream_processor_anthropic.rs index d64b6fffa..2cc172b25 100644 --- a/src/crates/core/tests/stream_processor_anthropic.rs +++ b/src/crates/agent-stream/tests/stream_processor_anthropic.rs @@ -1,6 +1,6 @@ mod common; -use bitfun_core::agentic::events::AgenticEvent; +use bitfun_events::AgenticEvent; use common::stream_test_harness::{ run_stream_fixture_with_options, StreamFixtureProvider, StreamFixtureRunOptions, }; diff --git a/src/crates/core/tests/stream_processor_openai.rs b/src/crates/agent-stream/tests/stream_processor_openai.rs similarity index 99% rename from src/crates/core/tests/stream_processor_openai.rs rename to src/crates/agent-stream/tests/stream_processor_openai.rs index ee872a56f..1f4f40831 100644 --- a/src/crates/core/tests/stream_processor_openai.rs +++ b/src/crates/agent-stream/tests/stream_processor_openai.rs @@ -1,6 +1,6 @@ mod common; -use bitfun_core::agentic::events::{AgenticEvent, ToolEventData}; +use bitfun_events::{AgenticEvent, ToolEventData}; use common::sse_fixture_server::FixtureSseServerOptions; use common::stream_test_harness::{ run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, diff --git a/src/crates/core/tests/stream_processor_tool_arguments.rs b/src/crates/agent-stream/tests/stream_processor_tool_arguments.rs similarity index 98% rename from src/crates/core/tests/stream_processor_tool_arguments.rs rename to src/crates/agent-stream/tests/stream_processor_tool_arguments.rs index a1d6fe714..64f2c9182 100644 --- a/src/crates/core/tests/stream_processor_tool_arguments.rs +++ b/src/crates/agent-stream/tests/stream_processor_tool_arguments.rs @@ -1,6 +1,6 @@ mod common; -use bitfun_core::agentic::events::AgenticEvent; +use bitfun_events::AgenticEvent; use common::sse_fixture_server::FixtureSseServerOptions; use common::stream_test_harness::{run_stream_fixture, StreamFixtureProvider}; use serde_json::json; diff --git a/src/crates/core/tests/stream_replay_regressions.rs b/src/crates/agent-stream/tests/stream_replay_regressions.rs similarity index 84% rename from src/crates/core/tests/stream_replay_regressions.rs rename to src/crates/agent-stream/tests/stream_replay_regressions.rs index b6a58684b..04861d333 100644 --- a/src/crates/core/tests/stream_replay_regressions.rs +++ b/src/crates/agent-stream/tests/stream_replay_regressions.rs @@ -1,9 +1,9 @@ mod common; +use bitfun_agent_stream::StreamResult; use bitfun_ai_adapters::providers::{openai::OpenAIMessageConverter, AnthropicMessageConverter}; -use bitfun_core::agentic::core::Message as CoreMessage; -use bitfun_core::agentic::events::{AgenticEvent, ToolEventData}; -use bitfun_core::util::types::Message as AIMessage; +use bitfun_ai_adapters::{Message as AIMessage, ToolCall as AIToolCall}; +use bitfun_events::{AgenticEvent, ToolEventData}; use common::sse_fixture_server::FixtureSseServerOptions; use common::stream_test_harness::{ run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, @@ -11,9 +11,7 @@ use common::stream_test_harness::{ }; use serde_json::json; -fn build_replay_assistant_message( - result: &bitfun_core::agentic::execution::StreamResult, -) -> CoreMessage { +fn build_replay_assistant_message(result: &StreamResult) -> AIMessage { let reasoning = if result.full_thinking.is_empty() { if result.reasoning_content_present { Some(String::new()) @@ -24,12 +22,28 @@ fn build_replay_assistant_message( Some(result.full_thinking.clone()) }; - CoreMessage::assistant_with_reasoning( - reasoning, - result.full_text.clone(), - result.tool_calls.clone(), - ) - .with_thinking_signature(result.thinking_signature.clone()) + AIMessage { + role: "assistant".to_string(), + content: Some(result.full_text.clone()), + reasoning_content: reasoning, + thinking_signature: result.thinking_signature.clone(), + tool_calls: Some( + result + .tool_calls + .iter() + .map(|tool_call| AIToolCall { + id: tool_call.tool_id.clone(), + name: tool_call.tool_name.clone(), + arguments: tool_call.arguments.clone(), + raw_arguments: tool_call.raw_arguments.clone(), + }) + .collect(), + ), + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -96,7 +110,7 @@ async fn replays_structurally_empty_openai_reasoning_content_with_tool_call() { ] ); - let replay_message: AIMessage = build_replay_assistant_message(&result).into(); + let replay_message = build_replay_assistant_message(&result); let openai_payload = OpenAIMessageConverter::convert_messages(vec![replay_message]); assert_eq!(openai_payload.len(), 1); @@ -176,7 +190,7 @@ async fn replays_structurally_empty_anthropic_thinking_with_signature_and_tool_u "expected tool_use block to trigger early detection" ); - let replay_message: AIMessage = build_replay_assistant_message(&result).into(); + let replay_message = build_replay_assistant_message(&result); let (_, anthropic_messages) = AnthropicMessageConverter::convert_messages(vec![replay_message]); let content = anthropic_messages[0]["content"] .as_array() diff --git a/src/crates/core-types/Cargo.toml b/src/crates/core-types/Cargo.toml new file mode 100644 index 000000000..f289040f5 --- /dev/null +++ b/src/crates/core-types/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "bitfun-core-types" +version.workspace = true +edition.workspace = true +description = "BitFun shared low-level product DTOs" + +[lib] +name = "bitfun_core_types" +crate-type = ["rlib"] + +[dependencies] +serde = { workspace = true } diff --git a/src/crates/core-types/src/errors.rs b/src/crates/core-types/src/errors.rs new file mode 100644 index 000000000..ff9a17b92 --- /dev/null +++ b/src/crates/core-types/src/errors.rs @@ -0,0 +1,350 @@ +use serde::{Deserialize, Serialize}; + +/// Error category for classifying dialog turn failures. +/// Used by the frontend to show user-friendly error messages without string matching. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCategory { + /// Network interruption, SSE stream closed, connection reset + Network, + /// API authentication failure, invalid/expired key + Auth, + /// Rate limit exceeded + RateLimit, + /// Conversation exceeds model context window + ContextOverflow, + /// Model response timed out + Timeout, + /// Provider/account quota, balance, or resource package is exhausted + ProviderQuota, + /// Provider billing plan, subscription, or package is invalid or expired + ProviderBilling, + /// Provider service is overloaded or temporarily unavailable + ProviderUnavailable, + /// API key is valid but does not have access to the requested resource + Permission, + /// Request format, parameters, model name, or payload size is invalid + InvalidRequest, + /// Provider policy or content safety system blocked the request + ContentPolicy, + /// Model returned an error + ModelError, + /// Unclassified error + Unknown, +} + +/// Structured AI error details for user-facing recovery and diagnostics. +/// +/// Keep this shape provider-agnostic: stable categories drive UI behavior while +/// provider-specific codes/messages remain optional metadata for diagnostics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiErrorDetail { + pub category: ErrorCategory, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub http_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retryable: Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub action_hints: Vec, +} + +/// Classify an AI client error message into a structured category. +pub fn classify_ai_error_message(msg: &str) -> ErrorCategory { + let m = msg.to_lowercase(); + if contains_any( + &m, + &[ + "code=1113", + "\"code\":\"1113\"", + "insufficient_quota", + "insufficient quota", + "insufficient balance", + "not_enough_balance", + "not enough balance", + "exceeded_current_quota_error", + "exceeded current quota", + "you exceeded your current quota", + "no available resource package", + "无可用资源包", + "余额不足", + "账户已欠费", + "account has exceeded", + "http 402", + "error 402", + "402 - insufficient balance", + ], + ) { + ErrorCategory::ProviderQuota + } else if contains_any( + &m, + &[ + "billing", + "membership expired", + "subscription expired", + "plan expired", + "套餐已到期", + "1309", + ], + ) { + ErrorCategory::ProviderBilling + } else if contains_any( + &m, + &[ + "overloaded_error", + "server overloaded", + "temporarily overloaded", + "provider unavailable", + "service unavailable", + "http 503", + "error 503", + "http 529", + "error 529", + "1305", + ], + ) { + ErrorCategory::ProviderUnavailable + } else if contains_any( + &m, + &[ + "content policy", + "policy blocked", + "safety", + "sensitive", + "content_filter", + "1301", + "api 调用被策略阻止", + ], + ) { + ErrorCategory::ContentPolicy + } else if m.contains("rate limit") + || m.contains("429") + || m.contains("too many requests") + || m.contains("1302") + || m.contains("concurrency") + || m.contains("请求并发超额") + { + ErrorCategory::RateLimit + } else if m.contains("authentication") + || m.contains("401") + || m.contains("invalid api key") + || m.contains("incorrect api key") + || m.contains("unauthorized") + || m.contains("1000") + || m.contains("1002") + { + ErrorCategory::Auth + } else if contains_any( + &m, + &[ + "permission_error", + "permission denied", + "forbidden", + "not authorized", + "no permission", + "无权访问", + "1220", + ], + ) { + ErrorCategory::Permission + } else if m.contains("context window") + || m.contains("token limit") + || m.contains("max_tokens") + || m.contains("context length") + { + ErrorCategory::ContextOverflow + } else if contains_any( + &m, + &[ + "invalid_request_error", + "invalid request", + "bad request", + "invalid format", + "invalid parameter", + "model not found", + "unsupported model", + "request too large", + "http 400", + "error 400", + "http 413", + "error 413", + "http 422", + "error 422", + "1210", + "1211", + "435", + ], + ) { + ErrorCategory::InvalidRequest + } else if m.contains("timeout") || m.contains("timed out") { + ErrorCategory::Timeout + } else if m.contains("stream closed") + || m.contains("sse error") + || m.contains("connection reset") + || m.contains("broken pipe") + { + ErrorCategory::Network + } else { + ErrorCategory::ModelError + } +} + +/// Build a structured, provider-agnostic AI error detail for UI recovery. +pub fn ai_error_detail_from_message(message: &str, category: ErrorCategory) -> AiErrorDetail { + AiErrorDetail { + category: category.clone(), + provider: extract_error_field(message, "provider"), + provider_code: extract_error_field(message, "code"), + provider_message: extract_error_field(message, "message"), + request_id: extract_error_field(message, "request_id"), + http_status: extract_http_status(message), + retryable: Some(is_retryable_category(&category)), + action_hints: action_hints_for_category(&category), + } +} + +fn contains_any(value: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| value.contains(needle)) +} + +fn is_retryable_category(category: &ErrorCategory) -> bool { + matches!( + category, + ErrorCategory::Network + | ErrorCategory::RateLimit + | ErrorCategory::Timeout + | ErrorCategory::ProviderUnavailable + ) +} + +fn action_hints_for_category(category: &ErrorCategory) -> Vec { + let hints: &[&str] = match category { + ErrorCategory::ProviderQuota | ErrorCategory::ProviderBilling => { + &["open_model_settings", "switch_model", "copy_diagnostics"] + } + ErrorCategory::Auth | ErrorCategory::Permission => { + &["open_model_settings", "copy_diagnostics"] + } + ErrorCategory::RateLimit | ErrorCategory::ProviderUnavailable => { + &["wait_and_retry", "switch_model", "copy_diagnostics"] + } + ErrorCategory::ContextOverflow => &["compress_context", "start_new_chat"], + ErrorCategory::Network | ErrorCategory::Timeout => { + &["retry", "switch_model", "copy_diagnostics"] + } + ErrorCategory::ContentPolicy | ErrorCategory::InvalidRequest => &["copy_diagnostics"], + ErrorCategory::ModelError | ErrorCategory::Unknown => { + &["retry", "switch_model", "copy_diagnostics"] + } + }; + + hints.iter().map(|hint| (*hint).to_string()).collect() +} + +fn extract_error_field(message: &str, field: &str) -> Option { + let key = format!("{field}="); + if let Some(start) = message.find(&key) { + let value_start = start + key.len(); + let value = message[value_start..] + .split([',', ';']) + .next() + .unwrap_or_default() + .trim() + .trim_matches('"'); + if !value.is_empty() { + return Some(value.to_string()); + } + } + + let json_key = format!("\"{field}\""); + if let Some(start) = message.find(&json_key) { + let after_key = &message[start + json_key.len()..]; + if let Some(colon_pos) = after_key.find(':') { + let after_colon = after_key[colon_pos + 1..].trim_start(); + let value = after_colon + .trim_start_matches('"') + .split(['"', ',', '}']) + .next() + .unwrap_or_default() + .trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + +fn extract_http_status(message: &str) -> Option { + let m = message.to_lowercase(); + for marker in ["http ", "error ", "status "] { + if let Some(start) = m.find(marker) { + let digits = m[start + marker.len()..] + .chars() + .take_while(|ch| ch.is_ascii_digit()) + .collect::(); + if let Ok(status) = digits.parse::() { + return Some(status); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::{ai_error_detail_from_message, classify_ai_error_message, ErrorCategory}; + + #[test] + fn classifies_quota_and_provider_unavailable_errors() { + assert_eq!( + classify_ai_error_message("Provider error: provider=glm, code=1113, message=余额不足"), + ErrorCategory::ProviderQuota + ); + assert_eq!( + classify_ai_error_message( + "DeepSeek API error 402 - Insufficient Balance: You have run out of balance" + ), + ErrorCategory::ProviderQuota + ); + assert_eq!( + classify_ai_error_message( + "Anthropic API error 529: overloaded_error: Anthropic API is temporarily overloaded" + ), + ErrorCategory::ProviderUnavailable + ); + } + + #[test] + fn builds_ai_error_detail_from_provider_metadata() { + let detail = ai_error_detail_from_message( + r#"AI client error: provider=openai, code=rate_limit_exceeded, message="Too many requests", request_id=req_123, http 429"#, + ErrorCategory::RateLimit, + ); + + assert_eq!(detail.category, ErrorCategory::RateLimit); + assert_eq!(detail.provider.as_deref(), Some("openai")); + assert_eq!(detail.provider_code.as_deref(), Some("rate_limit_exceeded")); + assert_eq!( + detail.provider_message.as_deref(), + Some("Too many requests") + ); + assert_eq!(detail.request_id.as_deref(), Some("req_123")); + assert_eq!(detail.http_status, Some(429)); + assert_eq!(detail.retryable, Some(true)); + assert_eq!( + detail.action_hints, + vec!["wait_and_retry", "switch_model", "copy_diagnostics"] + ); + } +} diff --git a/src/crates/core-types/src/lib.rs b/src/crates/core-types/src/lib.rs new file mode 100644 index 000000000..d66cb144f --- /dev/null +++ b/src/crates/core-types/src/lib.rs @@ -0,0 +1,8 @@ +//! Shared low-level product DTOs. +//! +//! This crate must stay lightweight: do not add runtime, network, platform, or +//! product assembly dependencies here. + +pub mod errors; + +pub use errors::{AiErrorDetail, ErrorCategory}; diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 040f515a5..82f52689c 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -28,6 +28,13 @@ SessionManager → Session → DialogTurn → ModelRound - Avoid host-specific APIs such as `tauri::AppHandle` - Use shared abstractions such as `bitfun_events::EventEmitter` - Desktop-only integrations belong in `src/apps/desktop`, then flow through transport/API layers +- During core decomposition, `bitfun-core` is a compatibility facade and full + product runtime assembly point. New modules should prefer the extracted owner + crate listed in `docs/architecture/core-decomposition.md`. +- Do not add new cross-layer references from `service` to `agentic` without a + small port/interface boundary. +- Do not move platform-specific logic, build-script behavior, or product + capability selection into shared core as part of decomposition. Narrower rules already exist: diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 05698f006..33551f369 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -84,11 +84,14 @@ sse-stream = "0.2.1" # Shared AI protocol adapters bitfun-ai-adapters = { path = "../ai-adapters" } +# Lightweight agent stream processing +bitfun-agent-stream = { path = "../agent-stream" } + # Tool runtime -tool-runtime = { path = "src/agentic/tools/implementations/tool-runtime" } +tool-runtime = { path = "../tool-runtime" } # terminal -terminal-core = { path = "src/service/terminal" } +terminal-core = { path = "../terminal" } # I18n internationalization fluent-bundle = { workspace = true } @@ -122,7 +125,9 @@ ssh_config = { version = "0.1", optional = true } bitfun-relay-server = { path = "../../apps/relay-server" } # Event layer dependency (lowest layer) +bitfun-core-types = { path = "../core-types" } bitfun-events = { path = "../events" } +bitfun-runtime-ports = { path = "../runtime-ports" } # Transport layer dependency bitfun-transport = { path = "../transport" } @@ -141,7 +146,10 @@ rustls-native-certs = "0.8" schannel = "0.1" [features] -default = ["ssh-remote"] +# Full product runtime feature set. Product crates should depend on this +# explicitly before `bitfun-core` default features are made lighter. +default = ["product-full"] +product-full = ["ssh-remote"] tauri-support = ["tauri"] # Optional tauri support ssh-remote = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] # russh-keys pure-Rust crypto backend (no openssl) diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 03fd65c12..be0c94676 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -2464,7 +2464,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let permit = match (cancel_token, deadline) { (Some(token), Some(deadline)) => { tokio::select! { - result = semaphore.acquire_owned() => result?, + result = semaphore.acquire_owned() => result + .map_err(|error| BitFunError::Semaphore(error.to_string()))?, _ = token.cancelled() => { return Err(BitFunError::Cancelled( "Subagent task was cancelled while waiting for a concurrency slot".to_string(), @@ -2480,7 +2481,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } (Some(token), None) => { tokio::select! { - result = semaphore.acquire_owned() => result?, + result = semaphore.acquire_owned() => result + .map_err(|error| BitFunError::Semaphore(error.to_string()))?, _ = token.cancelled() => { return Err(BitFunError::Cancelled( "Subagent task was cancelled while waiting for a concurrency slot".to_string(), @@ -2490,7 +2492,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } (None, Some(deadline)) => { tokio::select! { - result = semaphore.acquire_owned() => result?, + result = semaphore.acquire_owned() => result + .map_err(|error| BitFunError::Semaphore(error.to_string()))?, _ = tokio::time::sleep_until(deadline) => { return Err(BitFunError::Timeout(format!( "Timed out while waiting for a concurrency slot for subagent '{}'", @@ -2499,7 +2502,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } } - (None, None) => semaphore.acquire_owned().await?, + (None, None) => semaphore + .acquire_owned() + .await + .map_err(|error| BitFunError::Semaphore(error.to_string()))?, }; let wait_ms = started_waiting.elapsed().as_millis(); @@ -3404,6 +3410,176 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } +#[async_trait::async_trait] +impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { + async fn create_session( + &self, + request: bitfun_runtime_ports::AgentSessionCreateRequest, + ) -> bitfun_runtime_ports::PortResult { + let workspace_path = request.workspace_path.clone().ok_or_else(|| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::InvalidRequest, + "workspace_path is required to create an agent session", + ) + })?; + + let session = self + .create_session_with_workspace( + None, + request.session_name, + request.agent_type, + SessionConfig { + workspace_path: Some(workspace_path.clone()), + ..Default::default() + }, + workspace_path, + ) + .await + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + })?; + + Ok(bitfun_runtime_ports::AgentSessionCreateResult { + session_id: session.session_id, + agent_type: session.agent_type, + }) + } + + async fn submit_message( + &self, + request: bitfun_runtime_ports::AgentSubmissionRequest, + ) -> bitfun_runtime_ports::PortResult { + if !request.attachments.is_empty() { + return Err(bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::InvalidRequest, + "agent submission port does not yet accept generic attachments", + )); + } + + let session = self + .get_session_manager() + .get_session(&request.session_id) + .ok_or_else(|| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::NotFound, + format!("session not found: {}", request.session_id), + ) + })?; + + let turn_id = request + .metadata + .get("turnId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + + let trigger_source = match request + .source + .unwrap_or(bitfun_runtime_ports::AgentSubmissionSource::Bot) + { + bitfun_runtime_ports::AgentSubmissionSource::DesktopUi => { + DialogTriggerSource::DesktopUi + } + bitfun_runtime_ports::AgentSubmissionSource::DesktopApi => { + DialogTriggerSource::DesktopApi + } + bitfun_runtime_ports::AgentSubmissionSource::AgentSession => { + DialogTriggerSource::AgentSession + } + bitfun_runtime_ports::AgentSubmissionSource::ScheduledJob => { + DialogTriggerSource::ScheduledJob + } + bitfun_runtime_ports::AgentSubmissionSource::RemoteRelay => { + DialogTriggerSource::RemoteRelay + } + bitfun_runtime_ports::AgentSubmissionSource::Bot => DialogTriggerSource::Bot, + bitfun_runtime_ports::AgentSubmissionSource::Cli => DialogTriggerSource::Cli, + }; + + self.start_dialog_turn( + request.session_id, + request.message.clone(), + Some(request.message), + Some(turn_id.clone()), + session.agent_type.clone(), + session.config.workspace_path.clone(), + DialogSubmissionPolicy::for_source(trigger_source), + ) + .await + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + })?; + + Ok(bitfun_runtime_ports::AgentSubmissionResult { + turn_id, + accepted: true, + }) + } + + async fn resolve_session_agent_type( + &self, + session_id: &str, + ) -> bitfun_runtime_ports::PortResult> { + Ok(self + .get_session_manager() + .get_session(session_id) + .map(|session| session.agent_type.clone())) + } +} + +#[async_trait::async_trait] +impl bitfun_runtime_ports::SessionTranscriptReader for ConversationCoordinator { + async fn read_session_transcript( + &self, + request: bitfun_runtime_ports::SessionTranscriptRequest, + ) -> bitfun_runtime_ports::PortResult { + let messages = self + .get_messages(&request.session_id) + .await + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + })?; + + let messages = messages + .into_iter() + .filter(|message| match request.turn_id.as_ref() { + Some(turn_id) => message.metadata.turn_id.as_ref() == Some(turn_id), + None => true, + }) + .map(|message| { + let role = match message.role { + crate::agentic::core::MessageRole::User => "user", + crate::agentic::core::MessageRole::Assistant => "assistant", + crate::agentic::core::MessageRole::Tool => "tool", + crate::agentic::core::MessageRole::System => "system", + } + .to_string(); + + bitfun_runtime_ports::TranscriptMessage { + role, + turn_id: message.metadata.turn_id, + content: serde_json::to_value(message.content).unwrap_or_default(), + } + }) + .collect(); + + Ok(bitfun_runtime_ports::SessionTranscript { + session_id: request.session_id, + messages, + }) + } +} + async fn is_ai_session_title_generation_enabled() -> bool { match crate::service::config::get_global_config_service().await { Ok(service) => service diff --git a/src/crates/core/src/agentic/core/message.rs b/src/crates/core/src/agentic/core/message.rs index 387bd1b68..017cc98bc 100644 --- a/src/crates/core/src/agentic/core/message.rs +++ b/src/crates/core/src/agentic/core/message.rs @@ -650,6 +650,19 @@ impl ToolCall { } } +impl From for ToolCall { + fn from(tool_call: bitfun_agent_stream::ToolCall) -> Self { + Self { + tool_id: tool_call.tool_id, + tool_name: tool_call.tool_name, + arguments: tool_call.arguments, + raw_arguments: tool_call.raw_arguments, + is_error: tool_call.is_error, + recovered_from_truncation: tool_call.recovered_from_truncation, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolResult { pub tool_id: String, diff --git a/src/crates/core/src/agentic/events/queue.rs b/src/crates/core/src/agentic/events/queue.rs index 928ea9214..200aa7179 100644 --- a/src/crates/core/src/agentic/events/queue.rs +++ b/src/crates/core/src/agentic/events/queue.rs @@ -4,6 +4,7 @@ use super::types::{AgenticEvent, EventEnvelope, EventPriority}; use crate::util::errors::BitFunResult; +use bitfun_agent_stream::StreamEventSink; use log::{debug, trace, warn}; use std::collections::BinaryHeap; use std::sync::Arc; @@ -231,3 +232,10 @@ impl EventQueue { self.queue.lock().await.is_empty() } } + +#[async_trait::async_trait] +impl StreamEventSink for EventQueue { + async fn enqueue(&self, event: AgenticEvent, priority: Option) { + let _ = EventQueue::enqueue(self, event, priority).await; + } +} diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index c0822b919..1b69922d2 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -1,1266 +1,110 @@ -//! Stream Processor -//! -//! Processes AI streaming responses, supports tool pre-detection and parameter streaming +//! Compatibility wrapper for the extracted agent stream processor. use crate::agentic::core::ToolCall; -use crate::agentic::events::{ - AgenticEvent, EventPriority, EventQueue, SubagentParentInfo as EventSubagentParentInfo, - ToolEventData, -}; +use crate::agentic::events::EventQueue; use crate::agentic::tools::SubagentParentInfo; -use crate::infrastructure::ai::ai_stream_handlers::UnifiedResponse; -use crate::infrastructure::ai::tool_call_accumulator::{ - FinalizedToolCall, PendingToolCalls, ToolCallBoundary, ToolCallStreamKey, -}; -use crate::util::elapsed_ms_u64; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; -use futures::{Stream, StreamExt}; -use log::{debug, error, trace}; +use futures::stream::BoxStream; use serde_json::Value; use std::sync::Arc; -use std::time::Instant; +use std::time::Duration; use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; -//============================================================================== -// SSE Log Collector - Outputs raw SSE data on error -//============================================================================== +pub use bitfun_agent_stream::{StreamProcessorError, ToolCall as StreamToolCall}; -/// SSE log collector configuration -#[derive(Debug, Clone, Default)] -pub struct SseLogConfig { - /// Maximum number of SSE data entries to output on error, None means unlimited - pub max_output: Option, -} - -/// SSE log collector - Collects raw SSE data, outputs only on error -pub struct SseLogCollector { - buffer: Vec, - config: SseLogConfig, -} - -impl SseLogCollector { - pub fn new(config: SseLogConfig) -> Self { - Self { - buffer: Vec::new(), - config, - } - } - - /// Push one SSE data entry - pub fn push(&mut self, data: String) { - self.buffer.push(data); - } - - /// Get number of collected data entries - pub fn len(&self) -> usize { - self.buffer.len() - } - - /// Check if empty - pub fn is_empty(&self) -> bool { - self.buffer.is_empty() - } - - /// Flush all SSE data to log on error - pub fn flush_on_error(&self, error_context: &str) { - if self.buffer.is_empty() { - error!("SSE Error: {} (no SSE data collected)", error_context); - return; - } - - error!("SSE Error: {}", error_context); - let mut sse_msg = format!("SSE history ({} events):\n", self.buffer.len()); - - match self.config.max_output { - None => { - // No limit, output all - for (i, data) in self.buffer.iter().enumerate() { - sse_msg.push_str(&format!("{:>6}: {}\n", i, data)); - } - } - Some(max) if self.buffer.len() <= max => { - // Within limit, output all - for (i, data) in self.buffer.iter().enumerate() { - sse_msg.push_str(&format!("{:>6}: {}\n", i, data)); - } - } - Some(max) => { - // Exceeds limit, smart truncation: output beginning + end - let head = 50.min(max / 2); - let tail = max - head; - let total = self.buffer.len(); - - for (i, data) in self.buffer.iter().take(head).enumerate() { - sse_msg.push_str(&format!("{:>6}: {}\n", i, data)); - } - sse_msg.push_str(&format!("... ({} events omitted) ...\n", total - max)); - for (i, data) in self.buffer.iter().skip(total - tail).enumerate() { - sse_msg.push_str(&format!("{:>6}: {}\n", total - tail + i, data)); - } - } - } - - error!("{}", sse_msg); - } -} - -/// Placeholder name for tool calls whose name was not received before the stream terminated. -const UNKNOWN_TOOL_PLACEHOLDER: &str = "unknown_tool"; - -/// Stream processing result +/// Stream processing result exposed through bitfun-core compatibility types. #[derive(Debug, Clone)] pub struct StreamResult { pub full_thinking: String, - /// Whether the provider emitted a reasoning/thinking field even if its content was empty. pub reasoning_content_present: bool, - /// Signature of Anthropic extended thinking (passed back in multi-turn conversations) pub thinking_signature: Option, pub full_text: String, pub tool_calls: Vec, - /// Token usage statistics (from model response) pub usage: Option, - /// Provider-specific metadata captured from the stream tail. pub provider_metadata: Option, - /// Whether this stream produced any user-visible output (text/thinking/tool events) pub has_effective_output: bool, - /// Milliseconds from stream processing start to the first upstream response item. pub first_chunk_ms: Option, - /// Milliseconds from stream processing start to the first event visible to the UI. pub first_visible_output_ms: Option, - /// When set, the stream terminated abnormally but was recovered with partial output. - /// Contains a human-readable reason (e.g. "Stream processing error: ..." or - /// "Stream processor watchdog timeout ..."). pub partial_recovery_reason: Option, } -/// Stream processing error with output diagnostics. -#[derive(Debug)] -pub struct StreamProcessError { - pub error: BitFunError, - pub has_effective_output: bool, -} - -impl StreamProcessError { - fn new(error: BitFunError, has_effective_output: bool) -> Self { +impl From for StreamResult { + fn from(result: bitfun_agent_stream::StreamResult) -> Self { Self { - error, - has_effective_output, + full_thinking: result.full_thinking, + reasoning_content_present: result.reasoning_content_present, + thinking_signature: result.thinking_signature, + full_text: result.full_text, + tool_calls: result.tool_calls.into_iter().map(Into::into).collect(), + usage: result.usage, + provider_metadata: result.provider_metadata, + has_effective_output: result.has_effective_output, + first_chunk_ms: result.first_chunk_ms, + first_visible_output_ms: result.first_visible_output_ms, + partial_recovery_reason: result.partial_recovery_reason, } } } -/// Stream processing context, encapsulates state during stream processing -struct StreamContext { - session_id: String, - dialog_turn_id: String, - round_id: String, - event_subagent_parent_info: Option, - subagent_parent_info: Option, - - // Accumulated results - full_thinking: String, - reasoning_content_present: bool, - /// Signature of Anthropic extended thinking (passed back in multi-turn conversations) - thinking_signature: Option, - full_text: String, - tool_calls: Vec, - usage: Option, - provider_metadata: Option, - - // Current tool call state - pending_tool_calls: PendingToolCalls, - - // Counters and flags - stream_started_at: Instant, - first_chunk_ms: Option, - first_visible_output_ms: Option, - text_chunks_count: usize, - thinking_chunks_count: usize, - thinking_completed_sent: bool, - has_effective_output: bool, - partial_recovery_reason: Option, +/// Stream processing error exposed through bitfun-core compatibility errors. +#[derive(Debug)] +pub struct StreamProcessError { + pub error: BitFunError, + pub has_effective_output: bool, } -impl StreamContext { - fn new( - session_id: String, - dialog_turn_id: String, - round_id: String, - subagent_parent_info: Option, - ) -> Self { - let event_subagent_parent_info = subagent_parent_info.clone().map(|info| info.into()); +impl From for StreamProcessError { + fn from(error: bitfun_agent_stream::StreamProcessError) -> Self { Self { - session_id, - dialog_turn_id, - round_id, - event_subagent_parent_info, - subagent_parent_info, - full_thinking: String::new(), - reasoning_content_present: false, - thinking_signature: None, - full_text: String::new(), - tool_calls: Vec::new(), - usage: None, - provider_metadata: None, - pending_tool_calls: PendingToolCalls::default(), - stream_started_at: Instant::now(), - first_chunk_ms: None, - first_visible_output_ms: None, - text_chunks_count: 0, - thinking_chunks_count: 0, - thinking_completed_sent: false, - has_effective_output: false, - partial_recovery_reason: None, - } - } - - fn into_result(self) -> StreamResult { - StreamResult { - full_thinking: self.full_thinking, - reasoning_content_present: self.reasoning_content_present, - thinking_signature: self.thinking_signature, - full_text: self.full_text, - tool_calls: self.tool_calls, - usage: self.usage, - provider_metadata: self.provider_metadata, - has_effective_output: self.has_effective_output, - first_chunk_ms: self.first_chunk_ms, - first_visible_output_ms: self.first_visible_output_ms, - partial_recovery_reason: self.partial_recovery_reason, - } - } - - fn mark_first_stream_chunk(&mut self) { - if self.first_chunk_ms.is_none() { - self.first_chunk_ms = Some(elapsed_ms_u64(self.stream_started_at)); - } - } - - fn mark_first_visible_output(&mut self) { - if self.first_visible_output_ms.is_none() { - self.first_visible_output_ms = Some(elapsed_ms_u64(self.stream_started_at)); + error: error.error.into(), + has_effective_output: error.has_effective_output, } } - - fn can_recover_as_partial_result(&self) -> bool { - self.has_effective_output - } - - fn record_finalized_tool_call(&mut self, finalized: &FinalizedToolCall) { - let tool_name = if finalized.tool_name.is_empty() { - UNKNOWN_TOOL_PLACEHOLDER.to_string() - } else { - finalized.tool_name.clone() - }; - let tool_id = if finalized.tool_id.is_empty() { - uuid::Uuid::new_v4().to_string() - } else { - finalized.tool_id.clone() - }; - self.tool_calls.push(ToolCall { - tool_id, - tool_name, - arguments: finalized.arguments.clone(), - raw_arguments: (!finalized.raw_arguments.is_empty()) - .then_some(finalized.raw_arguments.clone()), - is_error: finalized.is_error, - recovered_from_truncation: finalized.recovered_from_truncation, - }); - } - - fn finalize_all_pending_tool_calls( - &mut self, - boundary: ToolCallBoundary, - ) -> Vec { - let finalized = self.pending_tool_calls.finalize_all(boundary); - for tool_call in &finalized { - self.record_finalized_tool_call(tool_call); - } - finalized - } - - /// Force finish pending tool calls, used when the stream is shutting down before a natural tool boundary. - fn force_finish_pending_tool_calls(&mut self) { - for finalized in self.finalize_all_pending_tool_calls(ToolCallBoundary::GracefulShutdown) { - error!( - "force finish pending tool call: tool_id={}, tool_name={}, raw_len={}, is_error={}", - finalized.tool_id, - finalized.tool_name, - finalized.raw_arguments.len(), - finalized.is_error - ); - } - } -} - -enum TimedStreamItem { - Item(T), - End, - TimedOut, } -async fn next_stream_item( - stream: &mut S, - watchdog_timeout: Option, -) -> TimedStreamItem -where - S: Stream + Unpin, -{ - match watchdog_timeout { - Some(timeout) => match tokio::time::timeout(timeout, stream.next()).await { - Ok(Some(item)) => TimedStreamItem::Item(item), - Ok(None) => TimedStreamItem::End, - Err(_) => TimedStreamItem::TimedOut, - }, - None => match stream.next().await { - Some(item) => TimedStreamItem::Item(item), - None => TimedStreamItem::End, - }, - } -} - -/// Stream processor +/// Core-facing stream processor wrapper. pub struct StreamProcessor { - event_queue: Arc, + inner: bitfun_agent_stream::StreamProcessor, } impl StreamProcessor { - const WATCHDOG_GRACE_SECS: u64 = 5; - pub fn new(event_queue: Arc) -> Self { - Self { event_queue } - } - - pub fn derive_watchdog_timeout( - stream_idle_timeout: Option, - ) -> Option { - stream_idle_timeout.map(|timeout| { - timeout - .checked_add(std::time::Duration::from_secs(Self::WATCHDOG_GRACE_SECS)) - .unwrap_or(std::time::Duration::MAX) - }) - } - - fn merge_json_value(target: &mut Value, overlay: Value) { - match (target, overlay) { - (Value::Object(target_map), Value::Object(overlay_map)) => { - for (key, value) in overlay_map { - let entry = target_map.entry(key).or_insert(Value::Null); - Self::merge_json_value(entry, value); - } - } - (target_slot, overlay_value) => { - *target_slot = overlay_value; - } - } - } - - // ==================== Helper Methods ==================== - - /// Send thinking end event (if needed) - async fn send_thinking_end_if_needed(&self, ctx: &mut StreamContext) { - if ctx.thinking_chunks_count > 0 && !ctx.thinking_completed_sent { - ctx.thinking_completed_sent = true; - debug!("Thinking process ended, sending ThinkingChunk end event"); - let _ = self - .event_queue - .enqueue( - AgenticEvent::ThinkingChunk { - session_id: ctx.session_id.clone(), - turn_id: ctx.dialog_turn_id.clone(), - round_id: ctx.round_id.clone(), - content: String::new(), - is_end: true, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), - }, - Some(EventPriority::Normal), - ) - .await; - } - } - - /// Check cancellation and execute graceful shutdown, returns Some(Err) if processing needs to be interrupted - async fn check_cancellation( - &self, - ctx: &mut StreamContext, - cancellation_token: &tokio_util::sync::CancellationToken, - location: &str, - ) -> Option> { - if cancellation_token.is_cancelled() { - debug!( - "Cancellation detected at {}: location={}", - location, location - ); - self.graceful_shutdown_from_ctx(ctx, "User cancelled stream processing".to_string()) - .await; - Some(Err(StreamProcessError::new( - BitFunError::Cancelled("Stream processing cancelled".to_string()), - ctx.has_effective_output, - ))) - } else { - None - } - } - - /// Execute graceful shutdown from context - async fn graceful_shutdown_from_ctx(&self, ctx: &mut StreamContext, reason: String) { - ctx.force_finish_pending_tool_calls(); - self.graceful_shutdown( - ctx.session_id.clone(), - ctx.dialog_turn_id.clone(), - ctx.tool_calls.clone(), - reason, - ctx.subagent_parent_info.clone(), - ) - .await; - } - - /// Graceful shutdown: cleanup all unfinished tool states and notify frontend - async fn graceful_shutdown( - &self, - session_id: String, - turn_id: String, - tool_calls: Vec, - reason: String, - subagent_parent_info: Option, - ) { - debug!( - "Starting graceful shutdown: session_id={}, reason={}", - session_id, reason - ); - - let is_user_cancellation = reason.contains("cancelled") || reason.contains("cancelled"); - let tool_call_count = tool_calls.len(); - let event_subagent_parent_info = subagent_parent_info.map(|info| info.clone().into()); - - // 1. Cleanup all tool calls - for tool_call in tool_calls { - trace!( - "Cleaning up tool: {} ({})", - tool_call.tool_name, - tool_call.tool_id - ); - - let tool_event = if is_user_cancellation { - ToolEventData::Cancelled { - tool_id: tool_call.tool_id, - tool_name: tool_call.tool_name, - reason: reason.clone(), - duration_ms: None, - queue_wait_ms: None, - preflight_ms: None, - confirmation_wait_ms: None, - execution_ms: None, - } - } else { - ToolEventData::Failed { - tool_id: tool_call.tool_id, - tool_name: tool_call.tool_name, - error: reason.clone(), - duration_ms: None, - queue_wait_ms: None, - preflight_ms: None, - confirmation_wait_ms: None, - execution_ms: None, - } - }; - - let _ = self - .event_queue - .enqueue( - AgenticEvent::ToolEvent { - session_id: session_id.clone(), - turn_id: turn_id.clone(), - tool_event, - subagent_parent_info: event_subagent_parent_info.clone(), - }, - Some(EventPriority::High), - ) - .await; - } - - // 2. Send dialog turn status update (if tools were cleaned up) - if tool_call_count > 0 { - let event = if is_user_cancellation { - AgenticEvent::DialogTurnCancelled { - session_id: session_id.clone(), - turn_id: turn_id.clone(), - subagent_parent_info: event_subagent_parent_info.clone(), - } - } else { - AgenticEvent::DialogTurnFailed { - session_id: session_id.clone(), - turn_id: turn_id.clone(), - error: reason, - error_category: None, - error_detail: None, - subagent_parent_info: event_subagent_parent_info.clone(), - } - }; - let _ = self - .event_queue - .enqueue(event, Some(EventPriority::Critical)) - .await; - } - - debug!( - "Graceful shutdown completed: cleaned up {} tools", - tool_call_count - ); - } - - /// Handle usage statistics - fn handle_usage( - &self, - ctx: &mut StreamContext, - response_usage: &crate::infrastructure::ai::ai_stream_handlers::UnifiedTokenUsage, - ) { - ctx.usage = Some(GeminiUsage { - prompt_token_count: response_usage.prompt_token_count, - candidates_token_count: response_usage.candidates_token_count, - total_token_count: response_usage.total_token_count, - reasoning_token_count: response_usage.reasoning_token_count, - cached_content_token_count: response_usage.cached_content_token_count, - }); - debug!( - "Received token usage stats: input={}, output={}, total={}", - response_usage.prompt_token_count, - response_usage.candidates_token_count, - response_usage.total_token_count - ); - } - - /// Handle tool call chunk - async fn handle_tool_call_chunk( - &self, - ctx: &mut StreamContext, - tool_call: crate::infrastructure::ai::ai_stream_handlers::UnifiedToolCall, - ) { - let crate::infrastructure::ai::ai_stream_handlers::UnifiedToolCall { - tool_call_index, - id, - name, - arguments, - arguments_is_snapshot, - } = tool_call; - let outcome = ctx.pending_tool_calls.apply_delta( - ToolCallStreamKey::from(tool_call_index), - id, - name, - arguments, - arguments_is_snapshot, - ); - - if let Some(finalized) = outcome.finalized_previous { - ctx.record_finalized_tool_call(&finalized); - } - - if let Some(early_detected) = outcome.early_detected { - ctx.has_effective_output = true; - ctx.mark_first_visible_output(); - debug!("Tool detected: {}", early_detected.tool_name); - let _ = self - .event_queue - .enqueue( - AgenticEvent::ToolEvent { - session_id: ctx.session_id.clone(), - turn_id: ctx.dialog_turn_id.clone(), - tool_event: ToolEventData::EarlyDetected { - tool_id: early_detected.tool_id, - tool_name: early_detected.tool_name, - }, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), - }, - None, - ) - .await; - } - - if let Some(params_partial) = outcome.params_partial { - ctx.has_effective_output = true; - ctx.mark_first_visible_output(); - let _ = self - .event_queue - .enqueue( - AgenticEvent::ToolEvent { - session_id: ctx.session_id.clone(), - turn_id: ctx.dialog_turn_id.clone(), - tool_event: ToolEventData::ParamsPartial { - tool_id: params_partial.tool_id, - tool_name: params_partial.tool_name, - params: params_partial.params_chunk, - }, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), - }, - None, - ) - .await; - } - } - - /// Handle text chunk - async fn handle_text_chunk(&self, ctx: &mut StreamContext, text: String) { - if !text.trim().is_empty() { - ctx.has_effective_output = true; - ctx.mark_first_visible_output(); + Self { + inner: bitfun_agent_stream::StreamProcessor::new(event_queue), } - ctx.full_text.push_str(&text); - ctx.text_chunks_count += 1; - - // Send streaming text event - let _ = self - .event_queue - .enqueue( - AgenticEvent::TextChunk { - session_id: ctx.session_id.clone(), - turn_id: ctx.dialog_turn_id.clone(), - round_id: ctx.round_id.clone(), - text, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), - }, - None, - ) - .await; } - /// Handle thinking chunk - async fn handle_thinking_chunk(&self, ctx: &mut StreamContext, thinking_content: String) { - // Thinking-only output does NOT count as "effective" for retry purposes: - // if the stream fails after producing only thinking (no text/tool calls), - // it is safe to retry because the model will re-think from scratch. - ctx.full_thinking.push_str(&thinking_content); - ctx.mark_first_visible_output(); - ctx.thinking_chunks_count += 1; - - // Send thinking chunk event - let _ = self - .event_queue - .enqueue( - AgenticEvent::ThinkingChunk { - session_id: ctx.session_id.clone(), - turn_id: ctx.dialog_turn_id.clone(), - round_id: ctx.round_id.clone(), - content: thinking_content, - is_end: false, - subagent_parent_info: ctx.event_subagent_parent_info.clone(), - }, - None, - ) - .await; + pub fn derive_watchdog_timeout(stream_idle_timeout: Option) -> Option { + bitfun_agent_stream::StreamProcessor::derive_watchdog_timeout(stream_idle_timeout) } - /// Print stream processing end log - fn log_stream_result(&self, ctx: &StreamContext) { - debug!( - "Stream loop ended: text_chunks={}, thinking_chunks={}, tool_calls({}), first_chunk_ms={:?}, first_visible_output_ms={:?}: {}", - ctx.text_chunks_count, - ctx.thinking_chunks_count, - ctx.tool_calls.len(), - ctx.first_chunk_ms, - ctx.first_visible_output_ms, - ctx.tool_calls - .iter() - .map(|tc| tc.tool_name.as_str()) - .collect::>() - .join(", ") - ); - - if log::log_enabled!(log::Level::Debug) { - if !ctx.full_thinking.is_empty() { - debug!(target: "ai::stream_processor", "Full thinking content: \n{}", ctx.full_thinking); - } - if !ctx.full_text.is_empty() { - debug!(target: "ai::stream_processor", "Full text content: \n{}", ctx.full_text); - } - if !ctx.tool_calls.is_empty() { - let log_str: String = ctx - .tool_calls - .iter() - .map(|tc| { - format!( - "Tool name: {}, arguments: {}\n", - tc.tool_name, - serde_json::to_string(&tc.arguments) - .unwrap_or_else(|_| "Serialization failed".to_string()) - ) - }) - .collect(); - debug!(target: "ai::stream_processor", "Tool call details: \n{}", log_str); - } - } - - trace!( - "Returning StreamResult: thinking_len={}, text_len={}, tool_calls={}, has_usage={}, has_effective_output={}", - ctx.full_thinking.len(), - ctx.full_text.len(), - ctx.tool_calls.len(), - ctx.usage.is_some(), - ctx.has_effective_output - ); - } - - // ==================== Main Processing Methods ==================== - - /// Process AI streaming response - /// - /// # Arguments - /// * `stream` - Parsed response stream - /// * `raw_sse_rx` - Optional raw SSE data receiver (for collecting raw data during error diagnosis) - /// * `session_id` - Session ID - /// * `dialog_turn_id` - Dialog turn ID - /// * `round_id` - Model round ID - /// * `subagent_parent_info` - Subagent parent info - /// * `cancellation_token` - Cancellation token #[allow(clippy::too_many_arguments)] pub async fn process_stream( &self, - mut stream: futures::stream::BoxStream<'static, Result>, - watchdog_timeout: Option, + stream: BoxStream<'static, Result>, + watchdog_timeout: Option, raw_sse_rx: Option>, session_id: String, dialog_turn_id: String, round_id: String, subagent_parent_info: Option, - cancellation_token: &tokio_util::sync::CancellationToken, + cancellation_token: &CancellationToken, ) -> Result { - let mut ctx = - StreamContext::new(session_id, dialog_turn_id, round_id, subagent_parent_info); - // Start SSE log collector (if raw_sse_rx is provided) - let sse_collector = if let Some(mut rx) = raw_sse_rx { - let collector = Arc::new(tokio::sync::Mutex::new(SseLogCollector::new( - SseLogConfig::default(), // No limit for now - ))); - let collector_clone = collector.clone(); - - // Start background task to collect SSE data - tokio::spawn(async move { - while let Some(data) = rx.recv().await { - collector_clone.lock().await.push(data); - } - }); - - Some(collector) - } else { - None - }; - - // Define a helper closure to flush SSE logs on error - let flush_sse_on_error = |collector: &Option>>, - error_context: &str| { - let collector = collector.clone(); - let error_context = error_context.to_string(); - async move { - if let Some(c) = collector { - // Wait a short time for background task to finish collecting data - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - c.lock().await.flush_on_error(&error_context); - } - } - }; - - loop { - tokio::select! { - // Check cancellation token - _ = cancellation_token.cancelled() => { - debug!("Cancel token detected, stopping stream processing: session_id={}", ctx.session_id); - self.graceful_shutdown_from_ctx(&mut ctx, "User cancelled stream processing".to_string()).await; - return Err(StreamProcessError::new( - BitFunError::Cancelled("Stream processing cancelled".to_string()), - ctx.has_effective_output, - )); - } - - // Watch the adapter -> processor stream only when the upstream stream idle timeout is configured. - next_result = next_stream_item(&mut stream, watchdog_timeout) => { - let response = match next_result { - TimedStreamItem::Item(Ok(response)) => response, - TimedStreamItem::End => { - debug!("Stream ended normally (no more data)"); - break; - } - TimedStreamItem::Item(Err(e)) => { - let error_msg = format!("Stream processing error: {}", e); - error!("{}", error_msg); - let non_recoverable_stream_error = - error_msg.contains("SSE Parsing Error"); - if !non_recoverable_stream_error && ctx.can_recover_as_partial_result() - { - flush_sse_on_error(&sse_collector, &error_msg).await; - self.send_thinking_end_if_needed(&mut ctx).await; - ctx.force_finish_pending_tool_calls(); - ctx.partial_recovery_reason = Some(error_msg.clone()); - self.log_stream_result(&ctx); - break; - } - // log SSE for network errors - flush_sse_on_error(&sse_collector, &error_msg).await; - self.graceful_shutdown_from_ctx(&mut ctx, error_msg.clone()).await; - return Err(StreamProcessError::new( - BitFunError::AIClient(error_msg), - ctx.has_effective_output, - )); - } - TimedStreamItem::TimedOut => { - let timeout_secs = - watchdog_timeout.map(|timeout| timeout.as_secs()).unwrap_or(0); - let error_msg = format!( - "Stream processor watchdog timeout (no data received for {} seconds)", - timeout_secs - ); - error!( - "Stream processor watchdog timeout ({} seconds), forcing termination", - timeout_secs - ); - // log SSE for timeout errors - flush_sse_on_error(&sse_collector, &error_msg).await; - if ctx.can_recover_as_partial_result() { - self.send_thinking_end_if_needed(&mut ctx).await; - ctx.force_finish_pending_tool_calls(); - ctx.partial_recovery_reason = Some(error_msg.clone()); - self.log_stream_result(&ctx); - break; - } - self.graceful_shutdown_from_ctx(&mut ctx, error_msg.clone()).await; - return Err(StreamProcessError::new( - BitFunError::AIClient(error_msg), - ctx.has_effective_output, - )); - } - }; - - let UnifiedResponse { - text, - reasoning_content, - thinking_signature, - tool_call, - usage, - finish_reason, - provider_metadata, - } = response; - ctx.mark_first_stream_chunk(); - - // Handle thinking_signature - if let Some(signature) = thinking_signature { - if !signature.is_empty() { - ctx.reasoning_content_present = true; - ctx.thinking_signature = Some(signature); - trace!("Received thinking_signature"); - } - } - - // Handle different types of response content - // Normalize empty strings to None - // (some models send empty text alongside reasoning content) - let text = text.filter(|t| !t.is_empty()); - - if let Some(thinking_content) = reasoning_content { - ctx.reasoning_content_present = true; - if !thinking_content.is_empty() { - self.handle_thinking_chunk(&mut ctx, thinking_content).await; - if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing thinking chunk").await { - return err; - } - } - } - - if let Some(text) = text { - self.send_thinking_end_if_needed(&mut ctx).await; - self.handle_text_chunk(&mut ctx, text).await; - if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing text chunk").await { - return err; - } - } - - if let Some(tool_call) = tool_call { - self.send_thinking_end_if_needed(&mut ctx).await; - self.handle_tool_call_chunk(&mut ctx, tool_call).await; - if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing tool call").await { - return err; - } - } - - if let Some(ref response_usage) = usage { - self.handle_usage(&mut ctx, response_usage); - } - - if let Some(provider_metadata) = provider_metadata { - match ctx.provider_metadata.as_mut() { - Some(existing) => Self::merge_json_value(existing, provider_metadata), - None => ctx.provider_metadata = Some(provider_metadata), - } - } - - if finish_reason.is_some() { - let _ = ctx.finalize_all_pending_tool_calls(ToolCallBoundary::FinishReason); - } - } - } - } - - // Ensure thinking end marker is sent - self.send_thinking_end_if_needed(&mut ctx).await; - - let _ = ctx.finalize_all_pending_tool_calls(ToolCallBoundary::StreamEnd); - - // Invalid tool payloads that survive to finalization still need detailed SSE logs for diagnosis. - if ctx.tool_calls.iter().any(|tc| !tc.is_valid()) { - flush_sse_on_error(&sse_collector, "Has invalid tool calls").await; - } - - self.log_stream_result(&ctx); - - Ok(ctx.into_result()) - } -} - -#[cfg(test)] -mod tests { - use super::StreamProcessor; - use crate::agentic::events::{EventQueue, EventQueueConfig}; - use crate::infrastructure::ai::ai_stream_handlers::{ - UnifiedResponse, UnifiedTokenUsage, UnifiedToolCall, - }; - use futures::StreamExt; - use serde_json::json; - use std::sync::Arc; - use std::time::Duration; - use tokio_stream::iter; - use tokio_util::sync::CancellationToken; - - fn build_processor() -> StreamProcessor { - StreamProcessor::new(Arc::new(EventQueue::new(EventQueueConfig::default()))) - } - - #[test] - fn derives_watchdog_timeout_from_stream_idle_timeout() { - assert_eq!(StreamProcessor::derive_watchdog_timeout(None), None); - assert_eq!( - StreamProcessor::derive_watchdog_timeout(Some(Duration::from_secs(10))), - Some(Duration::from_secs(15)) - ); - } - - fn sample_usage(total_tokens: u32) -> UnifiedTokenUsage { - UnifiedTokenUsage { - prompt_token_count: 1, - candidates_token_count: total_tokens.saturating_sub(1), - total_token_count: total_tokens, - reasoning_token_count: None, - cached_content_token_count: None, - } - } - - #[tokio::test] - async fn keeps_collecting_tool_args_across_usage_chunks() { - let processor = build_processor(); - let stream = iter(vec![ - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: None, - id: Some("call_1".to_string()), - name: Some("tool_a".to_string()), - arguments: Some("{\"a\":".to_string()), - arguments_is_snapshot: false, - }), - usage: Some(sample_usage(5)), - ..Default::default() - }), - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: None, - id: None, - name: None, - arguments: Some("1}".to_string()), - arguments_is_snapshot: false, - }), - usage: Some(sample_usage(7)), - ..Default::default() - }), - ]) - .boxed(); - - let result = processor - .process_stream( - stream, - None, - None, - "session_1".to_string(), - "turn_1".to_string(), - "round_1".to_string(), - None, - &CancellationToken::new(), - ) - .await - .expect("stream result"); - - assert_eq!(result.tool_calls.len(), 1); - assert_eq!(result.tool_calls[0].tool_id, "call_1"); - assert_eq!(result.tool_calls[0].tool_name, "tool_a"); - assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); - assert_eq!( - result.tool_calls[0].raw_arguments.as_deref(), - Some("{\"a\":1}") - ); - assert!(!result.tool_calls[0].is_error); - assert_eq!(result.usage.as_ref().map(|u| u.total_token_count), Some(7)); - } - - #[tokio::test] - async fn whitespace_only_text_is_not_effective_output() { - let processor = build_processor(); - let stream = iter(vec![Ok(UnifiedResponse { - text: Some("\n\n ".to_string()), - ..Default::default() - })]) - .boxed(); - - let result = processor - .process_stream( - stream, - None, - None, - "session_1".to_string(), - "turn_1".to_string(), - "round_1".to_string(), - None, - &CancellationToken::new(), - ) - .await - .expect("stream result"); - - assert_eq!(result.full_text, "\n\n "); - assert!(!result.has_effective_output); - assert_eq!(result.first_visible_output_ms, None); - } - - #[tokio::test] - async fn finalizes_tool_after_same_chunk_finish_reason() { - let processor = build_processor(); - let stream = iter(vec![Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: None, - id: Some("call_1".to_string()), - name: Some("tool_a".to_string()), - arguments: Some("{\"a\":1}".to_string()), - arguments_is_snapshot: false, - }), - usage: Some(sample_usage(9)), - finish_reason: Some("tool_calls".to_string()), - ..Default::default() - })]) - .boxed(); - - let result = processor - .process_stream( - stream, - None, - None, - "session_1".to_string(), - "turn_1".to_string(), - "round_1".to_string(), - None, - &CancellationToken::new(), - ) - .await - .expect("stream result"); - - assert_eq!(result.tool_calls.len(), 1); - assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); - assert_eq!(result.usage.as_ref().map(|u| u.total_token_count), Some(9)); - } - - #[tokio::test] - async fn does_not_repair_tool_args_with_one_extra_trailing_right_brace() { - let processor = build_processor(); - let stream = iter(vec![Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: None, - id: Some("call_1".to_string()), - name: Some("tool_a".to_string()), - arguments: Some("{\"a\":1}}".to_string()), - arguments_is_snapshot: false, - }), - finish_reason: Some("tool_calls".to_string()), - ..Default::default() - })]) - .boxed(); - - let result = processor + self.inner .process_stream( stream, - None, - None, - "session_1".to_string(), - "turn_1".to_string(), - "round_1".to_string(), - None, - &CancellationToken::new(), + watchdog_timeout, + raw_sse_rx, + session_id, + dialog_turn_id, + round_id, + subagent_parent_info.map(Into::into), + cancellation_token, ) .await - .expect("stream result"); - - assert_eq!(result.tool_calls.len(), 1); - assert_eq!(result.tool_calls[0].tool_id, "call_1"); - assert_eq!(result.tool_calls[0].tool_name, "tool_a"); - assert_eq!(result.tool_calls[0].arguments, json!({})); - assert_eq!( - result.tool_calls[0].raw_arguments.as_deref(), - Some("{\"a\":1}}") - ); - assert!(result.tool_calls[0].is_error); - } - - #[tokio::test] - async fn replaces_tool_args_when_snapshot_chunk_arrives() { - let processor = build_processor(); - let stream = iter(vec![ - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: None, - id: Some("call_1".to_string()), - name: Some("tool_a".to_string()), - arguments: Some("{\"city\":\"Bei".to_string()), - arguments_is_snapshot: false, - }), - ..Default::default() - }), - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: None, - id: None, - name: None, - arguments: Some("{\"city\":\"Beijing\"}".to_string()), - arguments_is_snapshot: true, - }), - finish_reason: Some("tool_calls".to_string()), - ..Default::default() - }), - ]) - .boxed(); - - let result = processor - .process_stream( - stream, - None, - None, - "session_1".to_string(), - "turn_1".to_string(), - "round_1".to_string(), - None, - &CancellationToken::new(), - ) - .await - .expect("stream result"); - - assert_eq!(result.tool_calls.len(), 1); - assert_eq!(result.tool_calls[0].tool_id, "call_1"); - assert_eq!(result.tool_calls[0].tool_name, "tool_a"); - assert_eq!(result.tool_calls[0].arguments, json!({"city": "Beijing"})); - assert_eq!( - result.tool_calls[0].raw_arguments.as_deref(), - Some("{\"city\":\"Beijing\"}") - ); - assert!(!result.tool_calls[0].is_error); - } - - #[tokio::test] - async fn keeps_interleaved_indexed_tool_calls_separate() { - let processor = build_processor(); - let stream = iter(vec![ - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: Some(0), - id: Some("call_0".to_string()), - name: Some("tool_a".to_string()), - arguments: None, - arguments_is_snapshot: false, - }), - ..Default::default() - }), - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: Some(1), - id: Some("call_1".to_string()), - name: Some("tool_b".to_string()), - arguments: None, - arguments_is_snapshot: false, - }), - ..Default::default() - }), - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: Some(0), - id: None, - name: None, - arguments: Some("{\"a\":1}".to_string()), - arguments_is_snapshot: false, - }), - ..Default::default() - }), - Ok(UnifiedResponse { - tool_call: Some(UnifiedToolCall { - tool_call_index: Some(1), - id: None, - name: None, - arguments: Some("{\"b\":2}".to_string()), - arguments_is_snapshot: false, - }), - finish_reason: Some("tool_calls".to_string()), - ..Default::default() - }), - ]) - .boxed(); - - let result = processor - .process_stream( - stream, - None, - None, - "session_1".to_string(), - "turn_1".to_string(), - "round_1".to_string(), - None, - &CancellationToken::new(), - ) - .await - .expect("stream result"); - - assert_eq!(result.tool_calls.len(), 2); - assert_eq!(result.tool_calls[0].tool_id, "call_0"); - assert_eq!(result.tool_calls[0].tool_name, "tool_a"); - assert_eq!(result.tool_calls[0].arguments, json!({"a": 1})); - assert_eq!(result.tool_calls[1].tool_id, "call_1"); - assert_eq!(result.tool_calls[1].tool_name, "tool_b"); - assert_eq!(result.tool_calls[1].arguments, json!({"b": 2})); - } - - #[tokio::test] - async fn preserves_empty_reasoning_presence_for_replay() { - let processor = build_processor(); - let stream = iter(vec![Ok(UnifiedResponse { - reasoning_content: Some(String::new()), - finish_reason: Some("stop".to_string()), - ..Default::default() - })]) - .boxed(); - - let result = processor - .process_stream( - stream, - None, - None, - "session_1".to_string(), - "turn_1".to_string(), - "round_1".to_string(), - None, - &CancellationToken::new(), - ) - .await - .expect("stream result"); - - assert!(result.reasoning_content_present); - assert!(result.full_thinking.is_empty()); - assert!(!result.has_effective_output); + .map(Into::into) + .map_err(Into::into) } } diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 244760bad..98396784f 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -50,6 +50,16 @@ impl From for EventSubagentParentInfo { } } +impl From for bitfun_agent_stream::SubagentParentInfo { + fn from(info: SubagentParentInfo) -> Self { + Self { + tool_call_id: info.tool_call_id, + session_id: info.session_id, + dialog_turn_id: info.dialog_turn_id, + } + } +} + /// Tool execution context #[derive(Debug, Clone)] pub struct ToolExecutionContext { diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 57583f400..9a0f0dede 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -3,13 +3,26 @@ use crate::agentic::tools::framework::Tool; use crate::agentic::tools::implementations::*; use crate::util::errors::BitFunResult; +use bitfun_runtime_ports::{DynamicToolDescriptor, DynamicToolProvider, ToolDecorator}; use indexmap::IndexMap; use log::{debug, info, trace, warn}; use std::sync::Arc; +type ToolRef = Arc; +type ToolDecoratorRef = Arc>; + +struct SnapshotToolDecorator; + +impl ToolDecorator for SnapshotToolDecorator { + fn decorate(&self, tool: ToolRef) -> ToolRef { + crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool) + } +} + /// Tool registry - manages all available tools (using IndexMap to maintain registration order) pub struct ToolRegistry { - tools: IndexMap>, + tools: IndexMap, + tool_decorator: ToolDecoratorRef, } impl Default for ToolRegistry { @@ -21,8 +34,18 @@ impl Default for ToolRegistry { impl ToolRegistry { /// Create a new tool registry pub fn new() -> Self { + Self::with_tool_decorator(Arc::new(SnapshotToolDecorator)) + } + + /// Create a registry with an injected decoration boundary. + /// + /// The default production decorator preserves snapshot-aware wrapping while + /// allowing future owner crates to replace this concrete service coupling + /// through the `bitfun-runtime-ports` interface. + pub fn with_tool_decorator(tool_decorator: ToolDecoratorRef) -> Self { let mut registry = Self { tools: IndexMap::new(), + tool_decorator, }; // Register all tools @@ -31,7 +54,7 @@ impl ToolRegistry { } /// Dynamically register MCP tools - pub fn register_mcp_tools(&mut self, tools: Vec>) { + pub fn register_mcp_tools(&mut self, tools: Vec) { let tool_count = tools.len(); info!("Registering MCP tools: count={}", tool_count); @@ -162,10 +185,10 @@ impl ToolRegistry { } /// Register a single tool - pub fn register_tool(&mut self, tool: Arc) { + pub fn register_tool(&mut self, tool: ToolRef) { // Snapshot-aware wrapping happens once at registration time so every // subsequent lookup returns the same runtime implementation. - let tool = crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool); + let tool = self.tool_decorator.decorate(tool); let name = tool.name().to_string(); self.tools.insert(name, tool); } @@ -188,11 +211,48 @@ impl ToolRegistry { ); self.tools.values().cloned().collect() } + + fn mcp_server_id_from_tool_name(tool_name: &str) -> Option { + let rest = tool_name.strip_prefix("mcp__")?; + let (server_id, _) = rest.split_once("__")?; + (!server_id.is_empty()).then(|| server_id.to_string()) + } +} + +#[async_trait::async_trait] +impl DynamicToolProvider for ToolRegistry { + async fn list_dynamic_tools( + &self, + ) -> bitfun_runtime_ports::PortResult> { + let mut descriptors = Vec::new(); + + for (name, tool) in self.tools.iter() { + let Some(server_id) = Self::mcp_server_id_from_tool_name(name) else { + continue; + }; + let description = tool.description().await.map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + })?; + + descriptors.push(DynamicToolDescriptor { + name: tool.name().to_string(), + description, + input_schema: tool.input_schema_for_model().await, + provider_id: Some(server_id), + }); + } + + Ok(descriptors) + } } #[cfg(test)] mod tests { use super::create_tool_registry; + use super::ToolRegistry; use serde_json::json; #[test] @@ -207,6 +267,19 @@ mod tests { assert!(registry.get_tool("Cron").is_some()); } + #[test] + fn parses_mcp_dynamic_tool_provider_id() { + assert_eq!( + ToolRegistry::mcp_server_id_from_tool_name("mcp__server_1__search").as_deref(), + Some("server_1") + ); + assert_eq!(ToolRegistry::mcp_server_id_from_tool_name("WebFetch"), None); + assert_eq!( + ToolRegistry::mcp_server_id_from_tool_name("mcp____search"), + None + ); + } + #[test] fn registry_exposes_controlhub_and_computer_use() { let registry = create_tool_registry(); diff --git a/src/crates/core/src/lib.rs b/src/crates/core/src/lib.rs index 63bc5584c..4a7d59cba 100644 --- a/src/crates/core/src/lib.rs +++ b/src/crates/core/src/lib.rs @@ -13,6 +13,7 @@ pub mod util; // Utility layer - General types, errors, helper functions // Mini pub use infrastructure::debug_log as debug; // Export main types +pub use bitfun_runtime_ports as runtime_ports; pub use util::errors::*; pub use util::types::*; diff --git a/src/crates/core/src/service/config/service.rs b/src/crates/core/src/service/config/service.rs index f57fd8588..064d5a8c8 100644 --- a/src/crates/core/src/service/config/service.rs +++ b/src/crates/core/src/service/config/service.rs @@ -537,6 +537,24 @@ impl ConfigService { } } +#[async_trait::async_trait] +impl bitfun_runtime_ports::ConfigReadPort for ConfigService { + async fn get_config_value( + &self, + key: &str, + ) -> bitfun_runtime_ports::PortResult> { + self.get_config::(Some(key)) + .await + .map(Some) + .map_err(|error| { + bitfun_runtime_ports::PortError::new( + bitfun_runtime_ports::PortErrorKind::Backend, + error.to_string(), + ) + }) + } +} + /// Outcome of [`ConfigService::reconcile_models`]. #[derive(Debug, Clone, Default)] pub struct ReconcileModelsReport { diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 9777ddbce..5122afdcd 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -27,7 +27,8 @@ pub mod token_usage; // Token usage tracking pub mod workspace; // Workspace management // Diff calculation and merge service pub mod workspace_runtime; // Workspace runtime layout / migration / initialization -// Terminal is a standalone crate; re-export it here. +// Terminal is implemented in the workspace-level `terminal-core` crate. +// This re-export preserves the legacy `bitfun_core::service::terminal` path. pub use terminal_core as terminal; // Re-export main components. diff --git a/src/crates/core/src/util/errors.rs b/src/crates/core/src/util/errors.rs index b9fd73b3c..3448c6bf5 100644 --- a/src/crates/core/src/util/errors.rs +++ b/src/crates/core/src/util/errors.rs @@ -2,7 +2,9 @@ //! //! Provide unified error types and handling for the whole application -use bitfun_events::agentic::{AiErrorDetail, ErrorCategory}; +use bitfun_core_types::errors::{ + ai_error_detail_from_message, classify_ai_error_message, AiErrorDetail, ErrorCategory, +}; use serde::Serialize; use thiserror::Error; @@ -39,8 +41,7 @@ pub enum BitFunError { Serialization(#[from] serde_json::Error), #[error("HTTP error: {0}")] - #[serde(serialize_with = "serialize_reqwest_error")] - Http(#[from] reqwest::Error), + Http(String), #[error("Other error: {0}")] #[serde(serialize_with = "serialize_anyhow_error")] @@ -91,13 +92,6 @@ where serializer.serialize_str(&err.to_string()) } -fn serialize_reqwest_error(err: &reqwest::Error, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_str(&err.to_string()) -} - fn serialize_anyhow_error(err: &anyhow::Error, serializer: S) -> Result where S: serde::Serializer, @@ -130,6 +124,10 @@ impl BitFunError { Self::AIClient(msg.into()) } + pub fn http>(msg: T) -> Self { + Self::Http(msg.into()) + } + pub fn parse>(msg: T) -> Self { Self::Deserialization(msg.into()) } @@ -157,7 +155,7 @@ impl BitFunError { /// Infer an error category from this error for frontend-friendly classification. pub fn error_category(&self) -> ErrorCategory { match self { - BitFunError::AIClient(msg) => classify_ai_error(msg), + BitFunError::AIClient(msg) => classify_ai_error_message(msg), BitFunError::Timeout(_) => ErrorCategory::Timeout, BitFunError::Cancelled(_) => ErrorCategory::Unknown, _ => ErrorCategory::Unknown, @@ -168,247 +166,17 @@ impl BitFunError { pub fn error_detail(&self) -> AiErrorDetail { let category = self.error_category(); let message = self.to_string(); - AiErrorDetail { - category: category.clone(), - provider: extract_error_field(&message, "provider"), - provider_code: extract_error_field(&message, "code"), - provider_message: extract_error_field(&message, "message"), - request_id: extract_error_field(&message, "request_id"), - http_status: extract_http_status(&message), - retryable: Some(is_retryable_category(&category)), - action_hints: action_hints_for_category(&category), - } - } -} - -/// Classify an AI client error message into a structured category. -fn classify_ai_error(msg: &str) -> ErrorCategory { - let m = msg.to_lowercase(); - if contains_any( - &m, - &[ - "code=1113", - "\"code\":\"1113\"", - "insufficient_quota", - "insufficient quota", - "insufficient balance", - "not_enough_balance", - "not enough balance", - "exceeded_current_quota_error", - "exceeded current quota", - "you exceeded your current quota", - "no available resource package", - "无可用资源包", - "余额不足", - "账户已欠费", - "account has exceeded", - "http 402", - "error 402", - "402 - insufficient balance", - ], - ) { - ErrorCategory::ProviderQuota - } else if contains_any( - &m, - &[ - "billing", - "membership expired", - "subscription expired", - "plan expired", - "套餐已到期", - "1309", - ], - ) { - ErrorCategory::ProviderBilling - } else if contains_any( - &m, - &[ - "overloaded_error", - "server overloaded", - "temporarily overloaded", - "provider unavailable", - "service unavailable", - "http 503", - "error 503", - "http 529", - "error 529", - "1305", - ], - ) { - ErrorCategory::ProviderUnavailable - } else if contains_any( - &m, - &[ - "content policy", - "policy blocked", - "safety", - "sensitive", - "content_filter", - "1301", - "api 调用被策略阻止", - ], - ) { - ErrorCategory::ContentPolicy - } else if m.contains("rate limit") - || m.contains("429") - || m.contains("too many requests") - || m.contains("1302") - || m.contains("concurrency") - || m.contains("请求并发超额") - { - ErrorCategory::RateLimit - } else if m.contains("authentication") - || m.contains("401") - || m.contains("invalid api key") - || m.contains("incorrect api key") - || m.contains("unauthorized") - || m.contains("1000") - || m.contains("1002") - { - ErrorCategory::Auth - } else if contains_any( - &m, - &[ - "permission_error", - "permission denied", - "forbidden", - "not authorized", - "no permission", - "无权访问", - "1220", - ], - ) { - ErrorCategory::Permission - } else if m.contains("context window") - || m.contains("token limit") - || m.contains("max_tokens") - || m.contains("context length") - { - ErrorCategory::ContextOverflow - } else if contains_any( - &m, - &[ - "invalid_request_error", - "invalid request", - "bad request", - "invalid format", - "invalid parameter", - "model not found", - "unsupported model", - "request too large", - "http 400", - "error 400", - "http 413", - "error 413", - "http 422", - "error 422", - "1210", - "1211", - "435", - ], - ) { - ErrorCategory::InvalidRequest - } else if m.contains("timeout") || m.contains("timed out") { - ErrorCategory::Timeout - } else if m.contains("stream closed") - || m.contains("sse error") - || m.contains("connection reset") - || m.contains("broken pipe") - { - ErrorCategory::Network - } else { - ErrorCategory::ModelError + ai_error_detail_from_message(&message, category) } } -fn contains_any(value: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| value.contains(needle)) -} - -fn is_retryable_category(category: &ErrorCategory) -> bool { - matches!( - category, - ErrorCategory::Network - | ErrorCategory::RateLimit - | ErrorCategory::Timeout - | ErrorCategory::ProviderUnavailable - ) -} - -fn action_hints_for_category(category: &ErrorCategory) -> Vec { - let hints: &[&str] = match category { - ErrorCategory::ProviderQuota | ErrorCategory::ProviderBilling => { - &["open_model_settings", "switch_model", "copy_diagnostics"] - } - ErrorCategory::Auth | ErrorCategory::Permission => { - &["open_model_settings", "copy_diagnostics"] - } - ErrorCategory::RateLimit | ErrorCategory::ProviderUnavailable => { - &["wait_and_retry", "switch_model", "copy_diagnostics"] - } - ErrorCategory::ContextOverflow => &["compress_context", "start_new_chat"], - ErrorCategory::Network | ErrorCategory::Timeout => { - &["retry", "switch_model", "copy_diagnostics"] - } - ErrorCategory::ContentPolicy | ErrorCategory::InvalidRequest => &["copy_diagnostics"], - ErrorCategory::ModelError | ErrorCategory::Unknown => { - &["retry", "switch_model", "copy_diagnostics"] - } - }; - - hints.iter().map(|hint| (*hint).to_string()).collect() -} - -fn extract_error_field(message: &str, field: &str) -> Option { - let key = format!("{field}="); - if let Some(start) = message.find(&key) { - let value_start = start + key.len(); - let value = message[value_start..] - .split([',', ';']) - .next() - .unwrap_or_default() - .trim() - .trim_matches('"'); - if !value.is_empty() { - return Some(value.to_string()); +impl From for BitFunError { + fn from(error: bitfun_agent_stream::StreamProcessorError) -> Self { + match error { + bitfun_agent_stream::StreamProcessorError::AiClient(msg) => Self::AIClient(msg), + bitfun_agent_stream::StreamProcessorError::Cancelled(msg) => Self::Cancelled(msg), } } - - let json_key = format!("\"{field}\""); - if let Some(start) = message.find(&json_key) { - let after_key = &message[start + json_key.len()..]; - if let Some(colon_pos) = after_key.find(':') { - let after_colon = after_key[colon_pos + 1..].trim_start(); - let value = after_colon - .trim_start_matches('"') - .split(['"', ',', '}']) - .next() - .unwrap_or_default() - .trim(); - if !value.is_empty() { - return Some(value.to_string()); - } - } - } - - None -} - -fn extract_http_status(message: &str) -> Option { - let m = message.to_lowercase(); - for marker in ["http ", "error ", "status "] { - if let Some(start) = m.find(marker) { - let digits = m[start + marker.len()..] - .chars() - .take_while(|ch| ch.is_ascii_digit()) - .collect::(); - if let Ok(status) = digits.parse::() { - return Some(status); - } - } - } - - None } impl From for String { @@ -428,44 +196,3 @@ impl From<&str> for BitFunError { BitFunError::Service(error.to_string()) } } - -impl From for BitFunError { - fn from(error: tokio::sync::AcquireError) -> Self { - BitFunError::Semaphore(error.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::BitFunError; - use bitfun_events::agentic::ErrorCategory; - - #[test] - fn classifies_glm_quota_error_as_provider_quota() { - let err = BitFunError::AIClient( - r#"Provider error: provider=glm, code=1113, message=余额不足或无可用资源包,请充值。, request_id=20260425142416"#.to_string(), - ); - - assert_eq!(err.error_category(), ErrorCategory::ProviderQuota); - } - - #[test] - fn classifies_deepseek_insufficient_balance_as_provider_quota() { - let err = BitFunError::AIClient( - "DeepSeek API error 402 - Insufficient Balance: You have run out of balance" - .to_string(), - ); - - assert_eq!(err.error_category(), ErrorCategory::ProviderQuota); - } - - #[test] - fn classifies_anthropic_overload_as_provider_unavailable() { - let err = BitFunError::AIClient( - "Anthropic API error 529: overloaded_error: Anthropic API is temporarily overloaded" - .to_string(), - ); - - assert_eq!(err.error_category(), ErrorCategory::ProviderUnavailable); - } -} diff --git a/src/crates/events/Cargo.toml b/src/crates/events/Cargo.toml index 4182158c7..bcfe79c49 100644 --- a/src/crates/events/Cargo.toml +++ b/src/crates/events/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +bitfun-core-types = { path = "../core-types" } async-trait = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/events/src/agentic.rs b/src/crates/events/src/agentic.rs index c947c0166..2f576a301 100644 --- a/src/crates/events/src/agentic.rs +++ b/src/crates/events/src/agentic.rs @@ -1,64 +1,8 @@ //! Agentic Events Definition +pub use bitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; use serde::{Deserialize, Serialize}; use std::time::SystemTime; -/// Error category for classifying dialog turn failures. -/// Used by the frontend to show user-friendly error messages without string matching. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ErrorCategory { - /// Network interruption, SSE stream closed, connection reset - Network, - /// API authentication failure, invalid/expired key - Auth, - /// Rate limit exceeded - RateLimit, - /// Conversation exceeds model context window - ContextOverflow, - /// Model response timed out - Timeout, - /// Provider/account quota, balance, or resource package is exhausted - ProviderQuota, - /// Provider billing plan, subscription, or package is invalid or expired - ProviderBilling, - /// Provider service is overloaded or temporarily unavailable - ProviderUnavailable, - /// API key is valid but does not have access to the requested resource - Permission, - /// Request format, parameters, model name, or payload size is invalid - InvalidRequest, - /// Provider policy or content safety system blocked the request - ContentPolicy, - /// Model returned an error - ModelError, - /// Unclassified error - Unknown, -} - -/// Structured AI error details for user-facing recovery and diagnostics. -/// -/// Keep this shape provider-agnostic: stable categories drive UI behavior while -/// provider-specific codes/messages remain optional metadata for diagnostics. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiErrorDetail { - pub category: ErrorCategory, - #[serde(skip_serializing_if = "Option::is_none")] - pub provider: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub provider_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub provider_message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub http_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub retryable: Option, - #[serde(skip_serializing_if = "Vec::is_empty", default)] - pub action_hints: Vec, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] pub enum AgenticEventPriority { Critical = 0, // Immediately send (error, cancellation) diff --git a/src/crates/runtime-ports/Cargo.toml b/src/crates/runtime-ports/Cargo.toml new file mode 100644 index 000000000..4f773ccc0 --- /dev/null +++ b/src/crates/runtime-ports/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "bitfun-runtime-ports" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Thin runtime ports for BitFun core decomposition" + +[lib] +name = "bitfun_runtime_ports" +crate-type = ["rlib"] + +[dependencies] +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/src/crates/runtime-ports/src/lib.rs b/src/crates/runtime-ports/src/lib.rs new file mode 100644 index 000000000..c723529b2 --- /dev/null +++ b/src/crates/runtime-ports/src/lib.rs @@ -0,0 +1,241 @@ +//! Thin runtime ports for boundaries that currently cross service and agentic +//! concrete implementations. +//! +//! This crate intentionally contains only DTOs and traits. It must not depend +//! on concrete managers, platform adapters, `bitfun-core`, or app crates. + +use serde::{Deserialize, Serialize}; + +pub type PortResult = Result; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PortErrorKind { + NotAvailable, + NotFound, + InvalidRequest, + PermissionDenied, + Cancelled, + Timeout, + Backend, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PortError { + pub kind: PortErrorKind, + pub message: String, +} + +impl PortError { + pub fn new(kind: PortErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl std::fmt::Display for PortError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for PortError {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionCreateRequest { + pub session_name: String, + pub agent_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub metadata: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionCreateResult { + pub session_id: String, + pub agent_type: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSubmissionRequest { + pub session_id: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub attachments: Vec, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub metadata: serde_json::Map, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgentSubmissionSource { + DesktopUi, + DesktopApi, + AgentSession, + ScheduledJob, + RemoteRelay, + Bot, + Cli, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentInputAttachment { + pub kind: String, + pub id: String, + #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")] + pub metadata: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSubmissionResult { + pub turn_id: String, + #[serde(default)] + pub accepted: bool, +} + +#[async_trait::async_trait] +pub trait AgentSubmissionPort: Send + Sync { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult; + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult; + + async fn resolve_session_agent_type(&self, session_id: &str) -> PortResult>; +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DynamicToolDescriptor { + pub name: String, + pub description: String, + pub input_schema: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_id: Option, +} + +#[async_trait::async_trait] +pub trait DynamicToolProvider: Send + Sync { + async fn list_dynamic_tools(&self) -> PortResult>; +} + +pub trait ToolDecorator: Send + Sync { + fn decorate(&self, tool: Tool) -> Tool; +} + +#[async_trait::async_trait] +pub trait ConfigReadPort: Send + Sync { + async fn get_config_value(&self, key: &str) -> PortResult>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTranscriptRequest { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTranscript { + pub session_id: String, + #[serde(default)] + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TranscriptMessage { + pub role: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default)] + pub content: serde_json::Value, +} + +#[async_trait::async_trait] +pub trait SessionTranscriptReader: Send + Sync { + async fn read_session_transcript( + &self, + request: SessionTranscriptRequest, + ) -> PortResult; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_error_display_keeps_kind_and_message() { + let error = PortError::new(PortErrorKind::NotAvailable, "coordinator missing"); + + assert_eq!( + error.to_string(), + "NotAvailable: coordinator missing".to_string() + ); + } + + #[test] + fn agent_submission_request_serializes_with_stable_camel_case() { + let request = AgentSubmissionRequest { + session_id: "session_1".to_string(), + message: "hello".to_string(), + source: None, + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }; + + let json = serde_json::to_value(request).expect("serialize request"); + + assert_eq!(json["sessionId"], "session_1"); + assert_eq!(json["message"], "hello"); + assert!(json.get("source").is_none()); + assert!(json.get("attachments").is_none()); + } + + #[test] + fn agent_submission_request_serializes_source_without_changing_field_case() { + let request = AgentSubmissionRequest { + session_id: "session_1".to_string(), + message: "hello".to_string(), + source: Some(AgentSubmissionSource::RemoteRelay), + attachments: Vec::new(), + metadata: serde_json::Map::new(), + }; + + let json = serde_json::to_value(request).expect("serialize request"); + + assert_eq!(json["source"], "remote_relay"); + assert!(json.get("turnId").is_none()); + } + + #[test] + fn session_transcript_request_serializes_turn_id_contract() { + let request = SessionTranscriptRequest { + session_id: "session_1".to_string(), + turn_id: Some("turn_1".to_string()), + }; + + let json = serde_json::to_value(request).expect("serialize transcript request"); + + assert_eq!(json["sessionId"], "session_1"); + assert_eq!(json["turnId"], "turn_1"); + assert!(json.get("fromTurnId").is_none()); + } +} diff --git a/src/crates/core/src/service/terminal/Cargo.toml b/src/crates/terminal/Cargo.toml similarity index 100% rename from src/crates/core/src/service/terminal/Cargo.toml rename to src/crates/terminal/Cargo.toml diff --git a/src/crates/core/src/service/terminal/docs/STREAMING_OUTPUT_COLLECTION.md b/src/crates/terminal/docs/STREAMING_OUTPUT_COLLECTION.md similarity index 100% rename from src/crates/core/src/service/terminal/docs/STREAMING_OUTPUT_COLLECTION.md rename to src/crates/terminal/docs/STREAMING_OUTPUT_COLLECTION.md diff --git a/src/crates/core/src/service/terminal/src/api.rs b/src/crates/terminal/src/api.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/api.rs rename to src/crates/terminal/src/api.rs diff --git a/src/crates/core/src/service/terminal/src/config/mod.rs b/src/crates/terminal/src/config/mod.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/config/mod.rs rename to src/crates/terminal/src/config/mod.rs diff --git a/src/crates/core/src/service/terminal/src/config/types.rs b/src/crates/terminal/src/config/types.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/config/types.rs rename to src/crates/terminal/src/config/types.rs diff --git a/src/crates/core/src/service/terminal/src/events.rs b/src/crates/terminal/src/events.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/events.rs rename to src/crates/terminal/src/events.rs diff --git a/src/crates/core/src/service/terminal/src/lib.rs b/src/crates/terminal/src/lib.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/lib.rs rename to src/crates/terminal/src/lib.rs diff --git a/src/crates/core/src/service/terminal/src/pty/data_bufferer.rs b/src/crates/terminal/src/pty/data_bufferer.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/pty/data_bufferer.rs rename to src/crates/terminal/src/pty/data_bufferer.rs diff --git a/src/crates/core/src/service/terminal/src/pty/mod.rs b/src/crates/terminal/src/pty/mod.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/pty/mod.rs rename to src/crates/terminal/src/pty/mod.rs diff --git a/src/crates/core/src/service/terminal/src/pty/process.rs b/src/crates/terminal/src/pty/process.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/pty/process.rs rename to src/crates/terminal/src/pty/process.rs diff --git a/src/crates/core/src/service/terminal/src/pty/service.rs b/src/crates/terminal/src/pty/service.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/pty/service.rs rename to src/crates/terminal/src/pty/service.rs diff --git a/src/crates/core/src/service/terminal/src/session/binding.rs b/src/crates/terminal/src/session/binding.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/session/binding.rs rename to src/crates/terminal/src/session/binding.rs diff --git a/src/crates/core/src/service/terminal/src/session/manager.rs b/src/crates/terminal/src/session/manager.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/session/manager.rs rename to src/crates/terminal/src/session/manager.rs diff --git a/src/crates/core/src/service/terminal/src/session/mod.rs b/src/crates/terminal/src/session/mod.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/session/mod.rs rename to src/crates/terminal/src/session/mod.rs diff --git a/src/crates/core/src/service/terminal/src/session/persistent.rs b/src/crates/terminal/src/session/persistent.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/session/persistent.rs rename to src/crates/terminal/src/session/persistent.rs diff --git a/src/crates/core/src/service/terminal/src/session/serializer.rs b/src/crates/terminal/src/session/serializer.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/session/serializer.rs rename to src/crates/terminal/src/session/serializer.rs diff --git a/src/crates/core/src/service/terminal/src/session/singleton.rs b/src/crates/terminal/src/session/singleton.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/session/singleton.rs rename to src/crates/terminal/src/session/singleton.rs diff --git a/src/crates/core/src/service/terminal/src/shell/detection.rs b/src/crates/terminal/src/shell/detection.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/detection.rs rename to src/crates/terminal/src/shell/detection.rs diff --git a/src/crates/core/src/service/terminal/src/shell/integration.rs b/src/crates/terminal/src/shell/integration.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/integration.rs rename to src/crates/terminal/src/shell/integration.rs diff --git a/src/crates/core/src/service/terminal/src/shell/mod.rs b/src/crates/terminal/src/shell/mod.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/mod.rs rename to src/crates/terminal/src/shell/mod.rs diff --git a/src/crates/core/src/service/terminal/src/shell/profiles.rs b/src/crates/terminal/src/shell/profiles.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/profiles.rs rename to src/crates/terminal/src/shell/profiles.rs diff --git a/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration-bash.sh b/src/crates/terminal/src/shell/scripts/shellIntegration-bash.sh similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration-bash.sh rename to src/crates/terminal/src/shell/scripts/shellIntegration-bash.sh diff --git a/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration-rc.zsh b/src/crates/terminal/src/shell/scripts/shellIntegration-rc.zsh similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration-rc.zsh rename to src/crates/terminal/src/shell/scripts/shellIntegration-rc.zsh diff --git a/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.fish b/src/crates/terminal/src/shell/scripts/shellIntegration.fish similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.fish rename to src/crates/terminal/src/shell/scripts/shellIntegration.fish diff --git a/src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.ps1 b/src/crates/terminal/src/shell/scripts/shellIntegration.ps1 similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/scripts/shellIntegration.ps1 rename to src/crates/terminal/src/shell/scripts/shellIntegration.ps1 diff --git a/src/crates/core/src/service/terminal/src/shell/scripts_manager.rs b/src/crates/terminal/src/shell/scripts_manager.rs similarity index 100% rename from src/crates/core/src/service/terminal/src/shell/scripts_manager.rs rename to src/crates/terminal/src/shell/scripts_manager.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/Cargo.toml b/src/crates/tool-runtime/Cargo.toml similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/Cargo.toml rename to src/crates/tool-runtime/Cargo.toml diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/backend.rs b/src/crates/tool-runtime/src/fs/backend.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/backend.rs rename to src/crates/tool-runtime/src/fs/backend.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/edit_file.rs b/src/crates/tool-runtime/src/fs/edit_file.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/edit_file.rs rename to src/crates/tool-runtime/src/fs/edit_file.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs b/src/crates/tool-runtime/src/fs/mod.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs rename to src/crates/tool-runtime/src/fs/mod.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/read_file.rs b/src/crates/tool-runtime/src/fs/read_file.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/read_file.rs rename to src/crates/tool-runtime/src/fs/read_file.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/lib.rs b/src/crates/tool-runtime/src/lib.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/lib.rs rename to src/crates/tool-runtime/src/lib.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/search/grep_search.rs b/src/crates/tool-runtime/src/search/grep_search.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/search/grep_search.rs rename to src/crates/tool-runtime/src/search/grep_search.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/search/mod.rs b/src/crates/tool-runtime/src/search/mod.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/search/mod.rs rename to src/crates/tool-runtime/src/search/mod.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/ansi_cleaner.rs b/src/crates/tool-runtime/src/util/ansi_cleaner.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/ansi_cleaner.rs rename to src/crates/tool-runtime/src/util/ansi_cleaner.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/mod.rs b/src/crates/tool-runtime/src/util/mod.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/mod.rs rename to src/crates/tool-runtime/src/util/mod.rs diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs b/src/crates/tool-runtime/src/util/string.rs similarity index 100% rename from src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs rename to src/crates/tool-runtime/src/util/string.rs diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index f8640c43a..711f2570f 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -190,6 +190,9 @@ vi.mock('react-i18next', () => ({ 'usage.slowestKinds.turn': 'Turn', 'usage.slowestLabels.modelCall': 'Turn {{turn}} model call', 'usage.slowestLabels.modelCallUnknown': 'Model call', + 'usage.table.rowLimitSummary': 'Showing {{visible}} of {{total}} rows', + 'usage.table.showAllRows': 'Show all {{count}} rows', + 'usage.table.showFewerRows': 'Show first {{count}} rows', }; return interpolate(labels[key] ?? key, options); }, @@ -637,12 +640,18 @@ describe('Session usage report UI components', () => { it('switches panel sections and keeps raw sensitive details redacted', () => { render(); + const tablist = container.querySelector('[role="tablist"]'); + expect(tablist?.getAttribute('aria-label')).toBe('Usage report sections'); + expect(container.querySelector('[role="tabpanel"]')?.getAttribute('aria-labelledby')) + .toBe('session-usage-tab-overview'); + for (const tab of ['Models', 'Tools', 'Files', 'Errors']) { - const tabButton = Array.from(container.querySelectorAll('.session-usage-panel__tab')) + const tabButton = Array.from(container.querySelectorAll('[role="tab"]')) .find(button => button.textContent === tab); act(() => { tabButton?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); + expect(tabButton?.getAttribute('aria-selected')).toBe('true'); expect(container.textContent).toContain(tab); } @@ -718,6 +727,69 @@ describe('Session usage report UI components', () => { expect(Array.from(missingValues).every(value => value.textContent === 'Timing not recorded')).toBe(true); }); + it('supports standard keyboard navigation across usage panel tabs', () => { + render(); + + const overviewTab = container.querySelector('#session-usage-tab-overview'); + act(() => { + overviewTab?.focus(); + overviewTab?.dispatchEvent(new dom.window.KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + })); + }); + + const slowestTab = container.querySelector('#session-usage-tab-slowest'); + expect(slowestTab?.getAttribute('aria-selected')).toBe('true'); + expect(dom.window.document.activeElement).toBe(slowestTab); + + act(() => { + slowestTab?.dispatchEvent(new dom.window.KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + })); + }); + + const errorsTab = container.querySelector('#session-usage-tab-errors'); + expect(errorsTab?.getAttribute('aria-selected')).toBe('true'); + expect(dom.window.document.activeElement).toBe(errorsTab); + }); + + it('caps long usage tables and allows explicit expansion', () => { + const tools = Array.from({ length: 55 }, (_, index) => ({ + toolName: `Tool ${index + 1}`, + category: 'other' as const, + callCount: 1, + successCount: 1, + errorCount: 0, + durationMs: 1000, + p95DurationMs: 1000, + executionMs: 1000, + redacted: false, + })); + + render(); + + const toolsTab = Array.from(container.querySelectorAll('[role="tab"]')) + .find(button => button.textContent === 'Tools'); + act(() => { + toolsTab?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelectorAll('tbody tr')).toHaveLength(50); + expect(container.textContent).toContain('Showing 50 of 55 rows'); + expect(container.textContent).not.toContain('Tool 55'); + + const showAll = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent === 'Show all 55 rows'); + act(() => { + showAll?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + + expect(container.querySelectorAll('tbody tr')).toHaveLength(55); + expect(container.textContent).toContain('Tool 55'); + }); + it('opens the detail panel on a requested usage tab', () => { render(); diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss index 6a41450c4..b3d5356ae 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.scss @@ -511,6 +511,30 @@ } } + &__table-footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + color: var(--color-text-muted); + font-size: 11px; + } + + &__table-expand { + padding: 0; + border: 0; + background: transparent; + color: var(--accent-primary); + font: inherit; + cursor: pointer; + + &:hover { + color: var(--accent-primary-hover); + text-decoration: underline; + text-underline-offset: 3px; + } + } + &__empty { display: flex; flex-direction: column; diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx index aa70bb597..2c6286410 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Activity, @@ -54,6 +54,15 @@ interface SessionUsagePanelProps { } const TABS: SessionUsagePanelTab[] = ['overview', 'models', 'tools', 'files', 'errors', 'slowest']; +const MAX_USAGE_TABLE_ROWS = 50; + +function tabId(tab: SessionUsagePanelTab): string { + return `session-usage-tab-${tab}`; +} + +function tabPanelId(tab: SessionUsagePanelTab): string { + return `session-usage-panel-${tab}`; +} export const SessionUsagePanel: React.FC = ({ report, @@ -66,6 +75,7 @@ export const SessionUsagePanel: React.FC = ({ const [activeTab, setActiveTab] = useState(initialTab ?? 'overview'); const [copied, setCopied] = useState(false); const [copiedMeta, setCopiedMeta] = useState<'session' | 'workspace' | null>(null); + const tabRefs = useRef>>({}); useEffect(() => { if (initialTab) { @@ -96,6 +106,33 @@ export const SessionUsagePanel: React.FC = ({ } }, []); + const handleTabKeyDown = useCallback(( + event: React.KeyboardEvent, + currentTab: SessionUsagePanelTab + ) => { + const currentIndex = TABS.indexOf(currentTab); + let nextIndex: number | null = null; + + if (event.key === 'ArrowRight') { + nextIndex = (currentIndex + 1) % TABS.length; + } else if (event.key === 'ArrowLeft') { + nextIndex = (currentIndex - 1 + TABS.length) % TABS.length; + } else if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = TABS.length - 1; + } + + if (nextIndex === null) { + return; + } + + event.preventDefault(); + const nextTab = TABS[nextIndex]; + setActiveTab(nextTab); + tabRefs.current[nextTab]?.focus(); + }, []); + if (!report) { return (
@@ -176,20 +213,39 @@ export const SessionUsagePanel: React.FC = ({
-