diff --git a/Cargo.toml b/Cargo.toml index 26978bf528..df69639753 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ indexmap = "2" include_dir = "0.7" # HTTP client -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls-native-roots", "json", "stream", "multipart"] } +reqwest = { version = "0.12", default-features = false, features = ["native-tls", "rustls-tls-native-roots", "json", "stream", "multipart"] } # Debug Log HTTP Server axum = { version = "0.7", features = ["json", "ws"] } diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index ef4b2dfbad..e5680eaab4 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -66,10 +66,10 @@ Rust 编译和链接面。 | `bitfun-agent-stream` | Stream 聚合和 stream-focused 测试 | done:stream 聚合已独立 | | `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | partial:DTO/trait-only 边界已建立,包含 agent submission/transcript/cancel、remote state、runtime event 与 remote image attachment 契约;不拥有 runtime 实现 | | `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | target:crate 尚不存在,agent runtime 仍在 core | -| `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、`GetToolSpec` Tool impl 和 concrete tools 仍在 core;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade 与 readonly/enabled 过滤规则已委托给 `bitfun-agent-tools` | +| `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、host path normalization / runtime artifact URI / remote POSIX path pure contract、allowed-list / collapsed-tool execution gate policy、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、`GetToolSpec` Tool impl 和 concrete tools 仍在 core,并由 core `tools/product_runtime.rs` 作为单一 product runtime owner 组装;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade 与 readonly/enabled 过滤规则已委托给 `bitfun-agent-tools` | | `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | | `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;config/workspace/filesystem runtime 多数仍在 core | -| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle 与 tracker event reduction,dispatcher/product execution 仍在 core | +| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、file IO/path resolution helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、response wrapping 与 product execution 仍在 core | | `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、storage/builtin contract 可迁入;IO、worker、built-in asset seeding、Git/AI service runtime 仍在 core | | `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | done:已在 workspace 顶层 | | `tool-runtime` | 已有 tool runtime,移动到 workspace 顶层路径 | done:已在 workspace 顶层 | @@ -100,15 +100,18 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate - remote-connect command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、 tracker state / registry lifecycle、remote tool preview slimming、legacy image context fallback / - preference、restore target decision、cancel decision 与 remote file transfer + preference、restore target decision、cancel decision、RemoteRelay/Bot dialog submission + orchestration port/provider、remote workspace path/MIME/full-read/chunk/info helper 与 remote file transfer size/chunk/name policy 可由 `bitfun-services-integrations` 拥有;core 只保留 tracker host adapter、 - global dispatcher、session restore 执行、terminal pre-warm、`ImageContextData` adapter - 、file IO/path resolution 和实际 dialog submission routing。不要把 tracker state - 、wire DTO 或纯策略 helper 回写到 core。 + global dispatcher compatibility wrapper、session restore 执行、terminal pre-warm adapter、 + concrete scheduler submit adapter、workspace-root source、response/base64 wrapping 与 + `ImageContextData` concrete adapter implementation。 + 不要把 tracker state、wire DTO、dialog orchestration 或纯策略 helper 回写到 core。 - remote-connect runtime owner 进一步外移前必须保持迁移前快照:remote command/response shape、restore target、active-turn poll snapshot、cancel decision、image context fallback - / preference、tracker fanout、file transfer 与 RemoteRelay/Bot queue policy。 + / preference、tracker fanout、file transfer、RemoteRelay/Bot queue policy,以及 + restore -> terminal pre-warm -> scheduler submit 的 dialog orchestration 顺序。 - `bitfun-core-types` 不得依赖 runtime manager、service crate、agent runtime、 app crate、Tauri、network client、process execution,或 `git2`、`rmcp`、`image`、 `tokio-tungstenite` 等重集成依赖。 @@ -147,9 +150,8 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate 与 desktop/MCP/ACP tool catalog 等价。 - 当前 tool runtime 外移的低风险入口是 `StaticToolProvider` / `install_static_provider` / `ToolRuntimeAssembly` 合约归属 `bitfun-agent-tools`,并让 core 通过 - `runtime_assembly.rs` + `static_providers.rs` - 将内置工具列表收敛为 `core.basic`、`core.agent`、`core.session`、`core.integration` - provider group。 + `tools/product_runtime.rs` 将内置工具列表收敛为 + `core.basic`、`core.agent`、`core.session`、`core.integration` provider group。 这不代表 concrete tools、`ToolUseContext`、product registry snapshot adapter 或 `GetToolSpecTool` 执行已经迁移。 - `ToolContextFacts` 只记录 tool call、agent/session/turn、workspace kind/root @@ -158,6 +160,13 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate 可执行 service handle;workspace root 使用 session identity 的 logical path (remote 为 normalized remote root)。`PortableToolContextProvider` 只是只读 facts provider 合约,当前由 core `ToolUseContext` 实现;`ToolUseContext` 本体仍归 core 拥有。 +- host path normalization、runtime artifact URI 与 remote POSIX path containment + 现在是 `bitfun-agent-tools` 的纯路径契约;core `workspace_paths` / `restrictions` + 只保留 `BitFunError` 映射、workspace runtime-root lookup 与 `ToolUseContext` 集成。 +- tool allowed-list 与 collapsed tool 的直接执行 gate policy 现在由 + `bitfun-agent-tools` 作为纯契约持有;core pipeline 仍保存 + `ToolUseContext.unlocked_collapsed_tools`,负责失败状态更新与 `BitFunError` + 映射,不改变 `GetToolSpecTool` 执行、runtime restriction 顺序或 unlock state 生命周期。 - 最新主干的 remote workspace guard 和 search fallback/context 修复提高了 workspace/search 迁移门槛。后续迁移 workspace 或 search runtime 时,必须保留 remote workspace metadata、 startup runtime ensure、remote flashgrep fallback、preview mapping 和 local/remote fallback 语义。 @@ -241,15 +250,16 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate Tool impl、collapsed unlock state source、product snapshot wrapper adapter、product registry snapshot access 与 concrete tools。 已合入 PR #803 把 core `Tool` 到 provider-neutral contract 的 adapter 收敛到 - `tool_adapter.rs`,把 product catalog / manifest / GetToolSpec catalog-detail provider - 收敛到 `catalog_provider.rs`;本阶段把 provider-neutral GetToolSpec static tool surface + `tool_adapter.rs`;HR1 本轮把 product catalog / manifest / GetToolSpec catalog-detail + provider、static provider materialization 和 snapshot wrapper 注入收敛到 + `product_runtime.rs`;本阶段把 provider-neutral GetToolSpec static tool surface (name / description / schema / readonly / concurrency / permission / validation / tool-use message)、 execution plan / result assembly 与 provider-backed execution result helper 收敛到 `bitfun-agent-tools`;本阶段继续把 provider-backed visible-tools / manifest / readonly catalog 查询收敛到 `ToolCatalogRuntime`,core 只保留 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter;本阶段继续把 static-provider 安装 assembly 委托到 `ToolRuntimeAssembly`,并把 product provider group plan 迁入 `bitfun-tool-packs`; - core 只保留 concrete tool materialization、product snapshot wrapper adapter、product provider/context 注入、 + core 只在 `product_runtime.rs` 保留 concrete tool materialization、product snapshot wrapper adapter、product provider/context 注入、 `GetToolSpecTool` Tool impl、unlock state source 和错误映射;本阶段也把 decorator reference contract、generic snapshot decorator adapter、 GetToolSpec runtime facade、Tool-result vector adapter 与 readonly enabled filtering 的通用规则委托给 `bitfun-agent-tools`,不改变工具行为。 @@ -257,15 +267,48 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate facade 已外移;本阶段进一步把 function-agent prompt template、AI response JSON extraction 与 domain error mapping 策略迁入 `product-domains`。filesystem IO、 worker process、host dispatch、built-in asset seeding / marker IO 与 Git/AI 调用仍显式 - core-owned。 - - H3:remaining service/runtime owner。remote-connect 当前只迁移 tracker / wire / - pure policy / registry lifecycle;dialog submission、terminal pre-warm、file IO/path - resolution、`ImageContextData` adapter、remote-SSH runtime、agent registry/scheduler - 等必须另起 port/provider 设计和等价评审。 - - H4:facade and boundary finalization。`bitfun-core` 只在 runtime owner 迁移确认后 - 收敛为 legacy facade + product runtime assembly。 + core-owned;HR2 进一步将这些 core-owned product-domain runtime 绑定收敛到 + `src/crates/core/src/product_domain_runtime.rs`,不改变实际执行路径。 + - H3:remaining service/runtime owner。remote-connect 已把 dialog submission + orchestration、terminal pre-warm decision、remote workspace file IO/path helper 与 + image-context adapter contract 收敛到 owner crate port/provider; + concrete scheduler/session restore/terminal adapter、workspace-root source、 + response wrapping、remote-SSH runtime、agent registry/scheduler 等仍必须 + 另起 port/provider 设计和等价评审;HR3 进一步把这些仍 core-owned 的 + service/agent runtime 绑定入口集中到 `src/crates/core/src/service_agent_runtime.rs`, + 不改变 remote-connect、remote-SSH 或 scheduler 执行路径。 + - H4:facade and boundary finalization。当前以 boundary script / AGENTS / architecture + docs 一致性闭环为准,确认 `bitfun-core` 继续作为 legacy facade + full product + runtime assembly;未完成等价评审的 runtime owner 继续显式 core-owned。 - H5:optional feature/build-benefit evaluation。`bitfun-core default = []`、per-product feature matrix、依赖版本收敛和构建收益评估只能在 H1-H4 后独立进行。 +- H4 之后的剩余工作口径必须区分“当前闭环必需项”和“后续深度 runtime 迁移”: + 当前 H1-H4 主线闭环后,不再把 deferred/core-owned runtime 当作当前 PR 漏项; + 若继续外移高风险 owner,最多按 tool runtime、product-domain runtime、 + service/agent runtime 这 3 个条件性大型 PR 重新评审;这些 HR 项不是 H5 + 之后的必做项。H5 仍是单独且可选的 feature/build-benefit evaluation, + 只能在选择继续外移的 HR 项完成或明确 defer 后评估。 +- HR1-HR3 的共同底线是功能影响范围可控、无性能劣化且不改变产品发布形态: + 不修改 default feature、产品 crate feature set、CI/release 覆盖、desktop/installer + build scripts 或任一 surface command/UI 语义;不得新增无界锁、重复 registry / + manifest materialization、额外 network/process startup 或反向 runtime 依赖。HR1 + 重点保护 tool visibility / manifest / unlock / snapshot / Deep Review tool flow; + HR2 重点保护 MiniApp IO/worker/asset seed 与 function-agent Git/AI 时序; + HR3 重点保护 remote workspace/SSH/terminal、scheduler/registry、subagent visibility + 和 DeepResearch post-turn hook。 +- HR1 当前已完成 core 内部 owner closure:`tools/product_runtime.rs` 统一承接 + product provider plan materialization、product registry snapshot/catalog facade、 + manifest / GetToolSpec facade 与 snapshot wrapper 注入。该收口不改变工具执行路径, + 也不声明 `ToolUseContext`、collapsed unlock state、`GetToolSpecTool` Tool impl、 + snapshot runtime 或 concrete tools 已迁出 core。 +- HR1 后续收口进一步把 `ToolUseContext` 的 workspace service accessor、runtime artifact + lookup、path policy enforcement、tool pipeline/description/preflight context + materialization、tool-call cancellation/post-call hook wrapper 和 Deep Review light checkpoint 绑定集中到 + core-owned `tools/tool_context_runtime.rs`;`framework.rs` 只保留 context shape、 + portable facts projection 与 `Tool` trait。该调整仍不迁移 `ToolUseContext` + 本体、runtime service handles 或 concrete tool behavior,并通过 remote workspace + containment、runtime URI scope、path policy、task/description/preflight context materialization + 与 cancellation hook 回归测试保护现有工具语义。 - 已完成的 MCP runtime/dynamic tools、remote-connect tracker/wire/pure policy、 semantic baseline、product-domain port/facade 与 tool contract/helper 外移不得重复规划; 如果后续发现这些已完成项存在实现错误,应在对应 H 阶段记录问题、风险和修复方案, @@ -291,7 +334,9 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate core 仍持有 MiniApp filesystem IO、compiler 调度、worker process、host dispatch、 built-in asset include / seed / marker IO / recompile,以及 function-agent Git/AI service adapter 和 AI client 调用;`product-domains` 现在承接 function-agent prompt template、AI response JSON - extraction、domain error mapping 与 JSON-to-domain DTO parsing policy。 + extraction、domain error mapping 与 JSON-to-domain DTO parsing policy。本轮 HR2 只把 + core-owned MiniApp/function-agent runtime 绑定集中到 `product_domain_runtime.rs`,用于 + 统一审查和边界检测;MiniApp IO/worker/host/builtin seed 与 Git/AI 调用没有外移。 ## 产品表面边界(Product Surface Boundary) @@ -323,6 +368,42 @@ BitFun 的重构目标不是把 Desktop、CLI、Remote、Server 和 ACP 强行 产品形态时,`product-full` 才可以包含它们。 - 最终要么让 `bitfun-core/product-full` 显式聚合已经验证过的 owner crate capability feature, 要么持续声明它不是完整能力矩阵;不得用它证明未迁移 runtime 已经完成 owner 化。 +- H5 已启动的第一步只允许补齐现有 optional feature 的编译边界。当前先让 + `cargo check -p bitfun-core --no-default-features` 通过,并在 `ssh-remote` 关闭时保留 + remote workspace identity/helper、让实际 SSH/SFTP/terminal/search runtime 返回明确 + unsupported;这不是 remote-SSH runtime owner 迁移,也不改变 `product-full` 或产品发布形态。 +- H5 后续推进只允许把 owner crate feature 传播显式化,保持 + `default = ["product-full"]`、产品 crate `features = ["product-full"]` 和 release/CI + 形态不变。no-default core 可以收敛为 runtime-surface-light facade;`tool-packs` 和 + `product-domains` 可由 core feature 显式启用为 optional dependency,但这不代表整体 + dependency graph 或构建收益已经完成优化。agentic runtime、MiniApp/function-agent、 + Git/MCP/remote-connect/review-platform 等完整产品入口必须继续由 `product-full` 或对应 + owner feature 打开。 +- H5 direct-dependency profile 只能把源码已经由 `product-full`、`service-integrations` + 或 `ssh-remote` 门禁保护的 runtime 依赖改成 optional,并由完整产品 feature 显式启用。 + 当前 no-default 直连层已移除 tool/runtime、snapshot/cron、Git/MCP/remote-connect、 + image/browser-control 和 relay 相关 product-only 依赖,但 `reqwest`、`axum`、 + `tower-http`、`terminal-core`、`zip`、`notify` 等仍因 AI/debug-log/terminal/LSP/search + 等 no-default facade 保留;不得据此声明 `default = []`、per-product feature matrix + 或构建收益已经完成。 +- Boundary check 必须同时保护两个层面:这些 product/runtime 依赖不得回流为 + non-optional dependency,也必须继续由 `product-full`、`service-integrations`、 + `ssh-remote`、`tool-packs` 或 `product-domains` 这些明确 feature owner 显式启用。 + 后续新增 optional dependency 时,必须同步更新 owner feature 规则,避免出现隐式或孤儿 + feature 引用。 +- Boundary check 也必须保护产品入口的完整能力装配:Desktop、CLI、ACP 对 + `bitfun-core` 的依赖必须保持 `default-features = false` 且显式启用 `product-full`, + 避免产品完整 runtime 退回到隐式默认 feature;脚本会扫描产品入口范围内新增的 + `bitfun-core` 依赖,防止遗漏显式装配规则。 +- 在单独完成产品矩阵评审前,Boundary check 必须继续锁定 + `bitfun-core default = ["product-full"]`,不得把默认 feature 变轻作为依赖裁剪的副作用。 +- Boundary check 还必须锁定 owner crate 的 feature graph:`tool-packs`、 + `services-integrations`、`product-domains` 的 `default` 保持空,`product-full` + 只显式聚合当前 owner crate 已声明的 feature group。 +- Core 的 `service-integrations` feature 当前仍是完整 `product-full` runtime assembly 的一部分, + 不是可单独发布或单独验证的产品形态;MCP/remote-connect/review-platform 仍引用 agentic、 + snapshot 或 product execution owner。若未来要让该 feature 独立可编译,必须先做 + port/provider 设计和等价测试,而不能只补 manifest 依赖。 - 拆解完成后不要自动移除或减轻 `product-full`。如果未来要用 per-product explicit feature set 替代它,必须作为 P3 之后的独立评估,并且先通过完整产品矩阵。 - 不要把 feature 默认值变更和模块移动放在同一个变更中。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 1919c308ae..791488d27a 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -991,14 +991,15 @@ product-full = ["git", "mcp", "remote-ssh", "remote-connect", "announcement", "f - [x] 已补齐 remote runtime 迁移前的第一层 port baseline:`SessionTranscriptReader`、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink` 与 remote image attachment/request DTO;完整 `remote-connect` runtime 仍需后续单独迁移并补 queue/event/image 行为等价测试。 - [x] `RemoteSessionStateTracker`、`TrackerEvent`、tracker registry lifecycle 与 remote tool preview slimming helper 已迁入 `bitfun-services-integrations`;core 只保留 tracker host adapter、dispatcher、session restore、terminal pre-warm 与实际 dialog submission routing。 - [x] 已补齐 remote-connect runtime 迁移前快照:remote command/response wire shape、session restore target、active turn poll snapshot、cancel decision、legacy image fallback / unified image context preference、tracker completion/fanout 与 RemoteRelay/Bot queue policy 均有 focused regression。 -- [x] 已将 remote-connect wire / poll 边界与纯运行时策略 helper 迁入 `bitfun-services-integrations`:command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、legacy image context fallback / explicit context preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 由 owner crate 提供;core 仅保留 `ImageContextData` adapter、dispatcher、session restore 执行、file IO/path resolution、terminal pre-warm 与实际 dialog submission routing。 +- [x] 已将 remote-connect wire / poll 边界与纯运行时策略 helper 迁入 `bitfun-services-integrations`:command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、legacy image context fallback / explicit context preference、restore target decision、cancel decision、remote workspace file IO/path helper、image-context adapter contract 与 remote file transfer size/chunk/name policy 由 owner crate 提供;core 仅保留 dispatcher、session restore 执行、workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、terminal pre-warm adapter 与实际 dialog submission routing。 +- [x] H3 remote-connect closure:RemoteRelay/Bot dialog submission orchestration、agent type normalization、turn id resolution、restore decision、terminal pre-warm decision、queue policy、remote workspace file IO/path helper 与 image-context adapter contract 归属 `bitfun-services-integrations`;core 继续作为 concrete scheduler/session restore/terminal adapter、workspace-root source 与 response wrapper,不改变产品行为。 - [x] 已迁移的集成能力保持 core 旧路径 re-export。 - [x] 产品完整 runtime 通过 `services-integrations/product-full` 启用已迁移集成能力。 **当前安全迁移状态(2026-05-15):** - 已迁移到 `bitfun-services-integrations`:`service::file_watch`,通过 `file-watch` / `product-full` feature 启用,并保持 `core::service::file_watch` 旧路径。 -- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout 与 queue policy 迁移前快照;但远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool runtime manifest / `GetToolSpec` 执行 owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 +- `git` 已完成 DTO/params/graph/raw command output/text parser/arg builder、`GitError`、`GitService` runtime implementation 与 git utils 迁移;`bitfun-core::service::git::*` 仅保留 legacy facade re-export。`remote-ssh` 已迁移纯 contract/type、workspace path/identity helper 与 unresolved-session-key helper;SSH runtime manager / fs / terminal、password vault 与 PathManager-backed session mirror assembly 仍保留在 core。`mcp` 已迁移 tool-name / tool-info / protocol types / config location / server type-status、server config、cursor-format、JSON-RPC request builder、JSON config format/validation helper、config merge / remote authorization helper、OAuth credential vault / authorization bootstrap contract、remote auth error classifier、legacy remote header fallback helper、transport Authorization 归一化 helper、remote client capability helper、rmcp 到 BitFun protocol 的纯映射 helper、resource/prompt adapter、catalog cache、list-changed/reconnect policy、config service save-load orchestration、server process / local-remote transport lifecycle、dynamic tool descriptor / provider / result rendering helper,并用 owner crate contract test 锁定 wire shape、transport default、validation message、Cursor 兼容格式、config precedence / dedup 语义、OAuth vault 存储路径注入、NeedsAuth 分类、旧 env Authorization fallback、remote client capabilities、remote result metadata / structured content 映射、config load/save/delete contract、unsupported remote transport contract、context resource selection 和 dynamic manifest;`bitfun-core` 继续负责 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和全局 tool registry / manifest 组装。`announcement` 仅迁移了纯 types contract,scheduler / state store / content loader / remote fetch 仍保留在 core;`remote-connect` 已完成 contract/request-builder slice,补齐 cancellation/state/event/image 第一层 port baseline,迁出 command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、tracker state / registry lifecycle / tracker event reduction / remote tool preview slimming helper、legacy image context fallback / preference、restore target decision、cancel decision、remote workspace file IO/path helper 与 remote file transfer size/chunk/name policy,并补齐 remote command/response、restore、active turn、cancel、image context、tracker fanout、queue policy 与 dialog orchestration 顺序快照;H3 已把远程消息提交编排、terminal pre-warm decision 与 image-context adapter contract 迁入 `bitfun-services-integrations` port/provider。workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、concrete terminal pre-warm adapter、concrete scheduler/session restore 执行仍保留在 core。它们涉及 SSH runtime、remote agent submission runtime、product tool runtime manifest / `GetToolSpec` 执行 owner 化与 announcement config/path 边界,继续前需要单独确认端口方案与等价性测试。 - 最新主干的 Deep Review capacity / cost / queue、context profile、evidence ledger、session manifest、stream dedupe、search remote/fallback 与 session rollback persistence 仍属于 core runtime 或对应产品 runtime,不在本轮 `services-integrations` 迁移范围内;如果后续迁移 remote-connect / MCP / search / session,需要先定义运行状态 port 合约和等价测试。 **验证:** @@ -1033,7 +1034,7 @@ cargo check -p bitfun-cli - [x] 抽出 tool result、validation、dynamic metadata、runtime restriction、path resolution DTO,以及 generic registry / dynamic provider container 到 `agent-tools`。 - [x] 抽出纯 manifest/exposure / GetToolSpec presentation 契约到 `agent-tools`:`ToolExposure`、`GetToolSpec` 名称、纯 manifest policy、collapsed prompt stub、prompt-visible ordering、GetToolSpec prompt description / input schema / validation / assistant-detail rendering / collapsed summary-detail / duplicate-load hint;core 继续拥有 runtime assembly 和执行 owner。 -- [x] 抽出 static tool provider 安装合约到 `agent-tools`,并将 core 内置工具列表收敛到 `static_providers.rs` 的 core-owned provider groups;不迁移 concrete tool implementation。 +- [x] 抽出 static tool provider 安装合约到 `agent-tools`,并将 core 内置工具列表收敛到 `product_runtime.rs` 的 core-owned provider groups;不迁移 concrete tool implementation。 - [x] 抽出 `ToolContextFacts` / `ToolWorkspaceKind` 轻量上下文事实契约,并由 core `ToolUseContext` 提供只读投影;workspace root fact 使用 session identity 的 logical path,remote 场景输出 normalized remote root;不迁移 collapsed unlock state、runtime handles、workspace services 或 cancellation token。 - [x] 增加 `PortableToolContextProvider` 只读 facts provider 合约,并由 core `ToolUseContext` 兼容实现;该合约不暴露 workspace services、cancellation token、computer-use host 或 collapsed unlock state。 - [x] LR1 已锁定 tool runtime port/provider 设计前置条件:`PortableToolContextProvider` @@ -1088,16 +1089,16 @@ pub fn create_tool_registry() -> ToolRegistry { **当前安全迁移状态(2026-05-21):** -- 已迁移到 `bitfun-agent-tools`:`ToolResult`、`ValidationResult`、`InputValidator`、dynamic tool metadata、tool render options、runtime restriction DTO、path resolution DTO、`ToolContextFacts` / `ToolWorkspaceKind` 轻量上下文事实、`PortableToolContextProvider` 只读 facts provider、不依赖 core service 的 `ToolRegistry` / `ToolRegistryItem` generic registry container、`StaticToolProvider` / `install_static_provider` 安装合约、generic decorator reference / snapshot decorator adapter / static-provider `ToolRuntimeAssembly` container、generic readonly/enabled registry snapshot filter、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、registry snapshot 到 manifest policy input 的纯 helper、generic collapsed exposure 查询、`GetToolSpec` load observation 到 collapsed 工具名集合的纯收集 helper、prompt-visible manifest definition 的纯组装 helper、generic contextual prompt-manifest resolver,以及 `GetToolSpec` catalog / detail / static metadata / tool-use message / execution-plan / result assembly 的 provider-neutral 组装 helper、provider-backed execution result helper、runtime facade 和 Tool-result vector adapter。dynamic tool provider / decorator contract 已通过 `agent-tools` 提供兼容 re-export,原 `runtime-ports` 路径保持可用;core 旧路径继续 re-export,并只保留 `BitFunError` 映射、路径 containment helper、`ToolUseContext` 到 facts 的只读投影和 runtime unlock state。 -- `bitfun-core::agentic::tools` 现在保留 `ProductToolRuntimeAssembly` product snapshot wrapper 注入、旧构造函数、`dyn Tool` 到 generic registry / catalog snapshot provider / GetToolSpec catalog provider 的适配、`ToolUseContext` runtime handle / service owner,以及 product registry snapshot access / agent policy / `GetToolSpecTool` Tool impl;core 静态 provider 组顺序和工具名来自 `bitfun-tool-packs` provider plan,core 只负责 concrete tool materialization。dynamic metadata map、tool map、dynamic descriptor assembly、static provider 安装合约、generic decorator reference / snapshot decorator adapter / provider-install assembly、generic readonly/enabled filtering、portable context facts、纯 manifest/exposure 契约、generic catalog snapshot provider、generic contextual prompt-manifest resolver、provider-backed catalog runtime facade、GetToolSpec presentation/schema/detail/static metadata/tool-use message 纯 helper、provider-neutral execution-plan、provider-backed execution result helper、runtime facade、Tool-result vector adapter 和 result assembly helper 由 `bitfun-agent-tools` 拥有。 +- 已迁移到 `bitfun-agent-tools`:`ToolResult`、`ValidationResult`、`InputValidator`、dynamic tool metadata、tool render options、runtime restriction DTO、path resolution DTO、host path normalization / runtime artifact URI / remote POSIX path pure contract、allowed-list / collapsed-tool execution gate policy、`ToolContextFacts` / `ToolWorkspaceKind` 轻量上下文事实、`PortableToolContextProvider` 只读 facts provider、不依赖 core service 的 `ToolRegistry` / `ToolRegistryItem` generic registry container、`StaticToolProvider` / `install_static_provider` 安装合约、generic decorator reference / snapshot decorator adapter / static-provider `ToolRuntimeAssembly` container、generic readonly/enabled registry snapshot filter、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、registry snapshot 到 manifest policy input 的纯 helper、generic collapsed exposure 查询、`GetToolSpec` load observation 到 collapsed 工具名集合的纯收集 helper、prompt-visible manifest definition 的纯组装 helper、generic contextual prompt-manifest resolver,以及 `GetToolSpec` catalog / detail / static metadata / tool-use message / execution-plan / result assembly 的 provider-neutral 组装 helper、provider-backed execution result helper、runtime facade 和 Tool-result vector adapter。dynamic tool provider / decorator contract 已通过 `agent-tools` 提供兼容 re-export,原 `runtime-ports` 路径保持可用;core 旧路径继续 re-export,并只保留 `BitFunError` 映射、workspace runtime-root lookup、`ToolUseContext` 到 facts 的只读投影和 runtime unlock state。 +- `bitfun-core::agentic::tools` 现在通过 `product_runtime.rs` 统一保留 product snapshot wrapper 注入、旧构造函数、`dyn Tool` 到 generic registry / catalog snapshot provider / GetToolSpec catalog provider 的适配、`ToolUseContext` runtime handle / service owner,以及 product registry snapshot access / agent policy / `GetToolSpecTool` Tool impl;core 静态 provider 组顺序和工具名来自 `bitfun-tool-packs` provider plan,core 只负责 concrete tool materialization。dynamic metadata map、tool map、dynamic descriptor assembly、static provider 安装合约、generic decorator reference / snapshot decorator adapter / provider-install assembly、generic readonly/enabled filtering、portable context facts、纯 manifest/exposure 契约、generic catalog snapshot provider、generic contextual prompt-manifest resolver、provider-backed catalog runtime facade、GetToolSpec presentation/schema/detail/static metadata/tool-use message 纯 helper、provider-neutral execution-plan、provider-backed execution result helper、runtime facade、Tool-result vector adapter 和 result assembly helper 由 `bitfun-agent-tools` 拥有。 - `bitfun-tool-packs` 默认 feature 为空,`product-full` 只聚合 feature;当前提供 `ToolPackFeatureGroup` / `all_feature_groups` / `enabled_feature_groups` 元数据和 `product_tool_provider_group_plan`,不注册或迁移任何工具实现。 - 已通过 boundary check 锁定 `agent-tools` / `tool-packs` 暂不拥有 full product tool runtime assembly、`GetToolSpecTool` Tool impl、collapsed-tool unlock state owner 或 concrete tools;`tool-packs` 也不得拥有 manifest/exposure 契约。`agent-tools` 只允许拥有纯 manifest/exposure helper、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、generic contextual manifest resolver、generic readonly/enabled filter、generic decorator reference / snapshot decorator adapter、GetToolSpec presentation/schema/detail/static metadata/tool-use message/execution-plan/result assembly helper、provider-backed execution result helper / runtime facade / Tool-result vector adapter 和不依赖具体工具的 provider 安装 / runtime assembly 合约,core product tool runtime 继续负责产品 registry snapshot、agent policy、concrete tool materialization、product snapshot wrapper adapter、`dyn Tool` / `ToolUseContext` adapter、unlock state source 和执行路径。 -- boundary check 也已补充 core owner anchor:要求产品工具注册、expanded/collapsed manifest、`GetToolSpec` duplicate-load guard、`ToolUseContext.unlocked_collapsed_tools`、执行管线 gating 与 execution unlock collector 仍保留在 core。后续若迁移这些 owner,必须先更新 port/provider 设计、等价测试与该脚本,而不能只删除 core 侧实现。 -- `Tool` trait、`ToolUseContext` 和具体工具实现仍在 core;它们直接连接 workspace service、snapshot wrapper、computer-use host、cancellation token 与 Deep Review checkpoint hook。`ToolContextFacts` / `PortableToolContextProvider` 只能作为只读事实投影,继续迁移前必须先确认 service port 方案,并补工具清单等价性测试。 +- boundary check 也已补充 core owner anchor:要求产品工具注册、expanded/collapsed manifest、`GetToolSpec` duplicate-load guard、`ToolUseContext.unlocked_collapsed_tools` 与 execution unlock collector 仍保留在 core;allowed-list / collapsed-tool 直接执行 gate 的纯 policy 已委托给 `bitfun-agent-tools`,core pipeline 仍负责 unlock state 传递、失败状态更新、runtime restriction 顺序和错误映射。后续若迁移这些 owner,必须先更新 port/provider 设计、等价测试与该脚本,而不能只删除 core 侧实现。 +- `Tool` trait、`ToolUseContext` 和具体工具实现仍在 core;它们直接连接 workspace service、snapshot wrapper、computer-use host、cancellation token 与 Deep Review checkpoint hook。`ToolContextFacts` / `PortableToolContextProvider` 只能作为只读事实投影;当前只迁移 allowed-list / collapsed-tool gate 的纯判断规则,继续迁移前必须先确认 service port 方案,并补工具清单等价性测试。 - 最新主干新增的 Deep Review shared-context / evidence-ledger checkpoint hook 仍保留在 core 的 `ToolUseContext` 中;在设计独立 tool context / event port 前,不应把 `ToolUseContext` 或 concrete tool implementation 继续外移。 - 最新主干新增 on-demand tool spec discovery:`ToolExposure`、`GetToolSpec` 名称、collapsed prompt stub、manifest ordering、generic collapsed exposure 查询、generic catalog snapshot provider、generic GetToolSpec catalog provider、generic contextual prompt-manifest resolver 与 GetToolSpec presentation/schema/detail 的纯契约已可由 `bitfun-agent-tools` 承载;product registry snapshot、product collapsed-tool catalog source、core `dyn Tool` / `ToolUseContext` adapter、context-aware `description_with_context` / `input_schema_for_model_with_context` 的实际调用、`GetToolSpecTool` Tool impl / `BitFunError` 映射以及 `ToolUseContext.unlocked_collapsed_tools` 仍会影响模型可见工具集合。该变化不推翻 PR4 的低风险结论,但把后续 tool/provider 迁移提升为高风险项,不能在 product-domain runtime 收尾中顺带执行。 - H1 start(2026-05-19):`StaticToolProviderGroup` 通用容器已迁入 - `bitfun-agent-tools`,core 的 `static_providers.rs` 只负责实例化 concrete tools 并按既有 + `bitfun-agent-tools`,core 的 `product_runtime.rs` 只负责实例化 concrete tools 并按既有 provider group 顺序装配。该切片不移动 concrete tool implementation、`ToolUseContext`、 runtime manifest assembly 或 `GetToolSpec` 执行;provider id、工具顺序与 manifest 快照由 `bitfun-agent-tools` contract test、core registry snapshot 和 boundary check 共同保护。 @@ -1109,7 +1110,7 @@ pub fn create_tool_registry() -> ToolRegistry { 才进入 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` 执行或 concrete tools 的单一 owner 设计与等价性证明。 - H2 本轮完成(2026-05-19):已先在 core 内部收敛 product tool runtime assembly owner。 - `ProductToolRuntimeAssembly` 负责安装 core-owned static provider groups 与 product snapshot wrapper adapter, + `ProductToolRuntime` 负责安装 core-owned static provider groups 与 product snapshot wrapper adapter, `ToolRegistry` 只保留对 `bitfun-agent-tools` generic registry 的兼容容器和动态工具入口。 该切片仍不迁移 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` 执行、 product collapsed-tool catalog 或 concrete tool implementation。 @@ -1135,7 +1136,7 @@ pub fn create_tool_registry() -> ToolRegistry { 执行、collapsed unlock state 或 snapshot decorator。 - 已合入 PR #803(2026-05-20):收敛 H1 前的 core product tool owner 边界,而不迁移 runtime owner。 `tool_adapter.rs` 只承接 core `Tool` 到 `bitfun-agent-tools` provider-neutral contract 的 - adapter;`catalog_provider.rs` 只承接 product registry snapshot、contextual catalog、 + adapter;`product_runtime.rs` 只承接 product registry snapshot、contextual catalog、 manifest 和 GetToolSpec catalog/detail provider facade;`manifest_resolver` 与 `GetToolSpecTool` 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 和 assistant result rendering。该切片仍不迁移 `ToolUseContext`、runtime manifest assembly、`GetToolSpecTool` @@ -1151,7 +1152,7 @@ pub fn create_tool_registry() -> ToolRegistry { - H1 generic runtime assembly slice(2026-05-20):将 static-provider 安装 assembly 的通用 顺序与 decorator 应用规则收敛为 `bitfun-agent-tools::ToolRuntimeAssembly`,并从 `bitfun-agent-tools` 导出 `ToolDecoratorRef` 作为通用 decorator reference contract;core - `ProductToolRuntimeAssembly` 只保留 concrete provider group 来源、product snapshot wrapper adapter 注入和 + `ProductToolRuntime` 只保留 concrete provider group 来源、product snapshot wrapper adapter 注入和 旧路径 `ToolRegistry` wrapper。该切片不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、 runtime manifest facade、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 @@ -1167,13 +1168,13 @@ pub fn create_tool_registry() -> ToolRegistry { Tool impl。 - H1 GetToolSpec runtime facade slice(2026-05-20):将 provider-backed catalog description 与 execution result 入口收敛为 `bitfun-agent-tools::GetToolSpecRuntime`; - core `catalog_provider.rs` 只负责 product provider / context / unlock-state 注入, + core `product_runtime.rs` 只负责 product provider / context / unlock-state 注入, `GetToolSpecTool` 仍保留 Tool impl、`BitFunError` 映射和 assistant rendering 旧路径兼容。 该切片不迁移 `ToolUseContext`、runtime manifest facade、collapsed unlock state owner 或 concrete tools。 - H1 tool catalog runtime facade slice(2026-05-20):将 provider-backed visible-tools、 prompt-visible manifest 和 readonly enabled catalog 查询收敛为 - `bitfun-agent-tools::ToolCatalogRuntime`;core `catalog_provider.rs` 只负责 product registry + `bitfun-agent-tools::ToolCatalogRuntime`;core `product_runtime.rs` 只负责 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter 和 product facade,`ToolRegistry` 只通过 product facade 查询 readonly tools。该切片不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 @@ -1675,7 +1676,7 @@ cargo check --workspace - 未声明完成的 P2/后续剩余部分:remote-ssh runtime、remote-connect 等重 service 迁移、`ToolUseContext` 外移、runtime manifest assembly / `GetToolSpec` 执行 owner 化、concrete tool implementation 迁移、product registry / provider assembly、miniapp/function-agent 运行逻辑迁移。这些会触碰 `PathManager`、`ToolUseContext`、workspace service、snapshot wrapper、prompt-visible tool catalog、`AgentSubmissionPort` 或 AI service 边界,需要在继续前显式确认。 - 本次 rebase 后重新核对最新主干 Deep Review capacity/cost/queue、context profile、evidence ledger 与 session manifest 变更:当前 PR 已完成 Git feature group 的 owner crate 归属迁移,但未改动这些 Deep Review 行为路径;后续迁移必须补端口设计和等价测试后再推进。 - 本次 rebase 后重新核对最新主干 tool 变更:on-demand tool spec discovery 新增 collapsed/expanded manifest、`GetToolSpec`、context-aware schema/description 与 unlock state。这不要求回退当前 P2 已完成内容,但要求后续 tool/provider 迁移先补 manifest / catalog / unlock 等价保护,且不得和 PR5 product-domain runtime 收口混合。 -- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、built-in bundle/hash/marker/source payload seed-decision contract、built-in seed plan / marker wire helper、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / JSON-string parsing helper / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper。H2 进一步将 function-agent prompt template、AI response JSON extraction 与 domain error mapping policy 迁入 product-domain;core 只保留 AI client 调用、Git service adapter、AI transport/provider-acquisition error mapping 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试,并补齐 MiniApp manager import/sync/recompile/rollback/deps state、built-in asset seeding decision 等价测试与 function-agent response policy 等价快照。PathManager、Git/AI service、builtin asset includes / seed / marker IO / recompile、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 +- PR5 已先推进低风险 product-domain slice:MiniApp 纯 compiler、export/runtime/worker DTO、runtime search plan、worker install 命令选择、package.json storage-shape helper、import layout / fallback payload contract、lifecycle / revision helper、manager 纯状态转换 helper、host routing string / allowlist policy helper、customization metadata / permission diff、built-in bundle/hash/marker/source payload seed-decision contract、built-in seed plan / marker wire helper、runtime/storage port contract,以及 git/startchat function-agent 纯 utils / commit summary / message assembly / prompt format / commit prompt preparation / AI response parsing policy / JSON-string parsing helper / action normalization / git porcelain / diff combine / time-of-day / Git/AI port contract / project context analyzer 已移入 `bitfun-product-domains`,core 保留原路径兼容 wrapper。H2 进一步将 function-agent prompt template、AI response JSON extraction 与 domain error mapping policy 迁入 product-domain;HR2 将 core-owned product-domain runtime 绑定收敛到 `src/crates/core/src/product_domain_runtime.rs`。core 继续保留 AI client 调用、Git service adapter、AI transport/provider-acquisition error mapping 和原路径 facade。已新增 core-owned Git snapshot、MiniApp storage/runtime port adapter 等价测试,并补齐 MiniApp manager import/sync/recompile/rollback/deps state、built-in asset seeding decision 等价测试与 function-agent response policy 等价快照。PathManager、Git/AI service、builtin asset includes / seed / marker IO / recompile、host dispatch 执行、customization draft 存储 / 应用、worker pool / storage IO 执行逻辑和任何 tool runtime 仍未迁移。 - 本次 P2 后续复核结论:上述高耦合剩余项不是纯文件搬迁;若继续迁移会改变依赖方向或需要新增 port/provider 行为合约。因此当前 PR 将它们显式保留为 core-owned runtime,只完成低风险 owner container 化,并通过 boundary check 防止已拆 owner crate 回流依赖 core。 **后续风险重排(2026-05-13):** @@ -1756,8 +1757,8 @@ P2 后产品表面契约轨道(contract-only): - 保留边界:`bitfun-core` 只保留 core `ConfigService` store adapter、OAuth data-dir 注入、`BitFunError` 映射、legacy facade 和与全局 tool registry / manifest 的组装调用;配置写入、OAuth、SSE/session 与 registry / manifest 行为不得在本 PR 中改变。 - 后续切片:MCP concrete tool integration / product registry / manifest assembly 继续保留 dynamic provider metadata、工具清单顺序、expanded/collapsed exposure 和 snapshot wrapper 等价测试。 - 文档校正:P2 后补充文档中的 MCP runtime step 已由本 PR2 闭环;后续 MCP 相关工作只保留 concrete tool implementation 迁移或 product registry / manifest assembly,不再重复迁移 config/process/transport lifecycle。 -3. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO、remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink`、`RemoteSessionStateTracker`、`RemoteSessionTrackerRegistry`、`TrackerEvent`、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已具备 owner/port 契约;core 仍保留 tracker host adapter、`ImageContextData` adapter、file IO/path resolution、dispatcher/product execution。 - - 本轮收口:remote-connect 在当前批次以 tracker / wire / pure policy / registry lifecycle 归 owner crate、dispatcher / product execution 显式保留 core-owned 闭环;若未来继续迁移完整 dialog submission、terminal pre-warm、file IO/path resolution 或 `ImageContextData` adapter,必须另起 port/provider 设计与行为等价评审,不得混入 tool/provider owner 化。 +3. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO、remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、`AgentTurnCancellationPort`、`RemoteControlStatePort`、`RuntimeEventSink`、`RemoteSessionStateTracker`、`RemoteSessionTrackerRegistry`、`TrackerEvent`、legacy image context fallback / preference、restore target decision、cancel decision、remote workspace file IO/path helper、image-context adapter contract 与 remote file transfer size/chunk/name policy 已具备 owner/port 契约;core 仍保留 tracker host adapter、dispatcher/product execution、workspace-root source、response/base64 wrapping 与 `ImageContextData` concrete impl。 + - 本轮收口:remote-connect 在当前批次以 tracker / wire / pure policy / registry lifecycle / dialog orchestration / file helper / image-context adapter contract 归 owner crate、dispatcher / product execution 显式保留 core-owned 闭环;若未来继续迁移 concrete scheduler/session restore/terminal adapter、remote-SSH runtime 或 agent registry/scheduler,必须另起 port/provider 设计与行为等价评审,不得混入 tool/provider owner 化。 4. 已完成本轮可提交闭环:agent tools + `tool-packs` owner 化低风险部分。纯 tool contract/provider metadata、runtime restriction DTO、path resolution DTO、generic tool registry / static-provider / dynamic-provider container、generic catalog snapshot provider、generic GetToolSpec catalog provider、provider-backed `ToolCatalogRuntime`、`PortableToolContextProvider` 只读 facts provider、纯 manifest/exposure 契约、generic contextual manifest resolver,以及 GetToolSpec presentation/schema/detail/runtime facade helper 已迁入 `bitfun-agent-tools`,并为 dynamic provider contract 提供 `agent-tools` 兼容 re-export;`tool-packs` 现在提供 feature-group 元数据和 product provider group plan,但不注册或迁移具体工具;core tool runtime 保留 concrete tool materialization、product snapshot wrapper adapter、`dyn Tool` / `ToolUseContext` 适配、product registry snapshot access、agent policy 和 `GetToolSpec` 执行。`ToolUseContext`、`GetToolSpec` 执行与 concrete tool implementation 按 feature group 外移需要新的 service port/provider 设计,必须保持 builtin/readonly/dynamic manifest、expanded/collapsed exposure、prompt stub、unlock state、snapshot wrapping、runtime restrictions、cancellation 与 Deep Review tool flow 等价,作为后续高风险迁移单独审视。 5. `product-domains` runtime + core facade finalization:迁移 miniapp runtime/compiler/builtin 与 function-agent 运行逻辑,最后把 `bitfun-core` 收敛为 facade + product runtime assembly;不在本 PR 中修改 `bitfun-core default = []` 或 per-product feature matrix。 @@ -1775,14 +1776,222 @@ P2 后产品表面契约轨道(contract-only): | LR1 | low-risk closure before runtime migration | `BitFunError` 剩余 concrete wrapper 处理决策、后续 shared DTO 归属校准、services-core/services-integrations 悬空 port/call-site 状态复核、tool runtime port/provider 设计与 manifest 等价基线、MiniApp/function-agent runtime 迁移前 owner 审视、P3 facade/boundary 文档与 AGENTS 校准 | `ToolUseContext` / concrete tools / MiniApp IO / function-agent Git/AI / remote-connect dialog 等 runtime owner 外移、`default = []`、feature matrix、构建收益宣传 | 文档中 Plan 3/5/6/7/8/9 的未完成项要么完成,要么显式标为 deferred/core-owned;boundary check、diff check 和对应最小 Rust check 通过 | | H1 | high-risk tool runtime migration | `ToolUseContext`、runtime manifest assembly / `GetToolSpec` 执行、concrete tool implementation 或 product registry / provider assembly 的单一 owner 迁移 | MiniApp/function-agent runtime、remote-connect runtime、feature matrix | 完整产品 tool registry、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapper 与 Deep Review tool flow 等价可证明 | | H2 | high-risk product-domain runtime migration | MiniApp runtime/manager/host/exporter/builtin 或 function-agent runtime 的单一 owner 迁移;不能安全外移的路径必须显式保留 core-owned runtime | tool runtime、remote-connect runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;IO/process/Git/AI 边界清晰;MiniApp/function-agent focused regression 通过 | -| H3 | high-risk service/runtime migration, only if still required | remote-connect dialog submission、terminal pre-warm、file IO/path resolution、`ImageContextData` adapter、remote-SSH runtime 或 agent registry/scheduler 等剩余 runtime owner,按单一 owner 独立评审 | tool/product-domain runtime、feature matrix、产品逻辑变更 | 有 port/provider 设计、旧路径兼容和产品等价测试;若决定保留 core-owned runtime,文档必须闭环 | -| H4 | facade and boundary finalization | `bitfun-core` 收敛为 legacy facade + full product runtime assembly;boundary script、AGENTS、architecture/plan docs 与当前代码一致;最终确认没有悬空的“已完成错觉” | 新 runtime 外移、默认 feature 改动 | 文档主线 checklist 闭环;所有 deferred/core-owned 项有明确 owner、测试或后续评估入口 | +| H3 | high-risk service/runtime migration, only if still required | remote-connect dialog submission orchestration / terminal pre-warm decision、remote workspace file IO/path helper 与 image-context adapter contract 已按单一 owner 迁入 `bitfun-services-integrations` port/provider;workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、remote-SSH runtime 或 agent registry/scheduler 等剩余 runtime owner 仍需单独评审 | tool/product-domain runtime、feature matrix、产品逻辑变更 | 有 port/provider 设计、旧路径兼容和产品等价测试;若决定保留 core-owned runtime,文档必须闭环 | +| H4 | facade and boundary finalization | 已完成当前批次收口:`bitfun-core` 继续作为 legacy facade + full product runtime assembly;boundary script 自检、AGENTS、architecture/plan docs 与当前代码一致,未迁移 runtime 均显式 core-owned/deferred | 新 runtime 外移、默认 feature 改动 | boundary check self-test、boundary check、diff check 和 workspace Rust 验证通过;所有 deferred/core-owned 项有明确 owner、测试或后续评估入口 | | H5 | optional default feature / build-benefit evaluation | 仅在 LR1 与必要的 H1-H4 后评估 `bitfun-core default = []`、per-product feature set、依赖版本收敛和构建收益 | 任何 runtime owner 迁移或产品逻辑变更 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据;可选择不执行 | -**H2 closure(2026-05-21):** H2 以 function-agent prompt/response policy 作为单一 owner -迁移主题闭环;MiniApp filesystem IO、worker process、host dispatch、built-in asset seeding / -marker IO / recompile,以及 function-agent Git service / AI client 调用仍显式 core-owned。 -这些路径不是“未注意到”,而是因行为边界和外部副作用风险保留到后续单独评审。 +**H2 / HR2 closure(2026-05-21):** H2 以 function-agent prompt/response policy +作为单一 owner 迁移主题闭环;HR2 进一步把 core-owned MiniApp/function-agent runtime +绑定集中到 `src/crates/core/src/product_domain_runtime.rs`,并通过 boundary check 锁定 +MiniApp facade、function-agent facade 与 core-owned Git/AI adapter 的路由入口。MiniApp +filesystem IO、worker process、host dispatch、built-in asset seeding / marker IO / recompile, +以及 function-agent Git service / AI client 调用仍显式 core-owned。这些路径不是“未注意到”, +而是因行为边界和外部副作用风险保留到后续单独评审。 + +**H3 remote-connect closure(2026-05-21):** 本轮迁移 RemoteRelay/Bot +dialog submission 的编排所有权以及可独立验证的 file/image 边界:tracker ensure、 +workspace binding lookup、restore decision、terminal pre-warm decision、agent type +normalization、turn id resolution、queue policy、submit handoff 顺序、remote workspace +path/MIME/full-read/chunk/info helper 与 image-context adapter contract 由 +`bitfun-services-integrations` 拥有,并用 focused regression 锁定。core 仍负责 +concrete scheduler submit、session restore 执行、terminal binding adapter、workspace-root +source、response/base64 wrapping 与 `ImageContextData` concrete impl;这些属于运行时副作用 +或产品封装边界,不得在没有新等价测试和端口设计时继续移动。 + +**HR3 closure update(2026-05-21):** HR3 now also centralizes the still +core-owned service/agent runtime bindings in +`src/crates/core/src/service_agent_runtime.rs`: remote dialog host factory, +remote image-context conversion, and `ConversationCoordinator` runtime-port +adapter binding. This does not move remote-SSH runtime, workspace-root source, +response/base64 wrapping, concrete scheduler/session restore/terminal adapters, +`ImageContextData` concrete ownership, or agent registry/scheduler behavior out +of core; those remain high-risk owner topics requiring a separate port/provider +design and equivalence tests before any deeper migration. + +**H4 facade / boundary closure(2026-05-21):** 本轮不继续移动新的 runtime owner, +而是把 H1-H3 的结果收敛为可审核的边界状态:`scripts/check-core-boundaries.mjs` +新增 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与 +自检覆盖;`AGENTS.md` / `AGENTS-CN.md` 校正 function-agent 与 remote-connect 的当前 +归属;`services-integrations` 文档不再把 remote-SSH runtime 表述为 H3 内自动迁移。 +`bitfun-core default = []`、per-product feature matrix、依赖版本收敛和构建收益声明仍保留到 +H5 独立评估,不属于当前 H4。 + +**H4 后剩余工作审查(2026-05-21):** + +当前 H1-H4 主线已经把已迁移 owner、core-owned runtime 与 deferred 项分清。若以 +“本轮 core decomposition closure”为目标,提交 H4 前不再需要新增 runtime 迁移 PR; +后续只剩 `H5` 这个可选的 feature/build-benefit evaluation,且它可以选择不执行。 +`HR1`-`HR3` 不是 `H5` 之后的必做项;它们只在决定继续外移当前显式 +core-owned 的高风险 runtime 时才成立。若选择继续外移,应先完成或明确 defer +对应 `HR` 评审,再进入任何会改变 feature/default/build-benefit 口径的 `H5`。 + +若后续决定继续把当前显式 core-owned 的高风险 runtime 进一步外移,必须重新进入 +owner-by-owner 迁移队列,不得把它们当作 H4 漏项补进当前 PR。建议最多收敛为 3 个 +大型 runtime PR,外加 1 个可选 H5 评估 PR: + +| 后续项 | 性质 | 范围 | 不允许混入 | 合入门禁 | +|---|---|---|---|---| +| HR1:tool runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `product_runtime.rs` 单一 owner 收口;后续若继续深迁移,只允许在单独评审后移动 `ToolUseContext`、runtime manifest execution owner、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools,也可以明确继续 core-owned | MiniApp/function-agent runtime、remote-connect / remote-SSH runtime、default feature 或构建收益声明 | 先补 port/provider 设计;证明 builtin/readonly/dynamic manifest、expanded/collapsed exposure、unlock state、dynamic provider metadata、snapshot wrapping、runtime restrictions、cancellation 和 Deep Review tool flow 等价 | +| HR2:product-domain runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `product_domain_runtime.rs` 单一 owner 收口;MiniApp filesystem IO、worker process execution、host dispatch、built-in asset include / seed / marker IO / recompile,以及 function-agent Git/AI service adapter / AI client call 继续显式 core-owned;后续若继续深迁移,只允许在单独评审后移动一个 owner 主题 | tool runtime、remote-connect / remote-SSH runtime、CLI/Desktop/Remote/ACP surface 行为变更 | `product-domains` 不依赖 core;PathManager、process execution、permission policy、Git/AI error/transport mapping 和 focused MiniApp/function-agent regression 已作为后续迁移门禁 | +| HR3:service / agent runtime deep owner migration | 条件性高风险 PR | 当前已完成 core 内部 `service_agent_runtime.rs` 单一 owner 收口;remote-SSH manager / remote FS / terminal、remote-connect workspace-root source / response wrapping / `ImageContextData` concrete impl / concrete scheduler-session-restore-terminal adapter、agent registry / scheduler 继续显式 core-owned;后续若继续深迁移,只允许在单独评审后移动一个 owner 主题 | tool runtime、product-domain runtime、feature matrix 或产品逻辑变更 | 有 port/provider 设计、旧路径兼容、mode-scoped subagent visibility / background delivery / remote-connect dialog order / remote workspace guard / DeepResearch post-turn hook 等行为等价测试 | +| H5:feature/build-benefit evaluation | 可选评估 PR | `bitfun-core default = []`、per-product explicit feature set、依赖版本收敛、构建收益数据记录 | 任何 runtime owner 迁移、产品逻辑变更或构建脚本改造 | 有 feature graph baseline、`cargo check -p bitfun-core`、workspace check、目标 crate check 和必要 product check 的前后数据;若收益不清晰则不执行 | + +**H5 start(2026-05-21):** 本轮先完成 feature graph baseline 的第一道编译门禁: +`cargo check -p bitfun-core --no-default-features`。当前结论是 `bitfun-core` +已有 `ssh-remote` optional dependency 边界,但源码仍曾无条件编译 remote-SSH +runtime;H5 的第一步只补齐 `ssh-remote` source gate 和 disabled diagnostic surface, +不迁移 remote-SSH runtime owner,不改变 `product-full`、产品 feature set、CI/release +脚本或任何产品行为。per-product feature matrix 仍需要在 no-default 编译闭环稳定后 +单独评估。 + +**H5 follow-up(2026-05-21):** 在 PR #824 合入后,继续把 `bitfun-core/product-full` +改为显式聚合 owner crate feature group:`tool-packs`、`services-integrations` 和 +`product-domains` 不再通过 dependency declaration 强制启用 `product-full`,其中 +`tool-packs` 与 `product-domains` 在 core 中改为 optional dependency,由 +`bitfun-core` 的 feature graph 显式启用。`services-integrations` 仍因 remote workspace +identity/helper 的纯 helper 需求保留 no-default 编译面,只启用 `remote-ssh` owner feature, +不启用完整 product-full。`default = ["product-full"]`、desktop/CLI/ACP +等产品 crate 的 `features = ["product-full"]`、release/CI 脚本和用户可见能力保持不变。 +no-default core 当前只承诺 runtime-surface-light facade 可编译,不声明 dependency graph +或构建收益已经变轻:agentic runtime、MiniApp/function-agent、 +Git/MCP/remote-connect/review-platform、snapshot/token/runtime usage 等完整产品入口继续由 +`product-full` 或对应 owner feature 打开。remote workspace identity/helper 因为 +session/workspace 路径稳定性仍保留在 no-default 编译面;russh-backed SSH/SFTP/terminal/search +runtime 仍由 `ssh-remote` 控制。 + +**H5 direct-dependency profile(2026-05-21):** 本轮继续把已经由源码 cfg 门禁保护的 +product/runtime 依赖改为 optional,并由 `product-full`、`service-integrations` 或 +`ssh-remote` 显式启用,以保持默认完整产品构建能力不变。当前 no-default 直连依赖层 +不再强制包含 `git2`、`rmcp`、`image`、`tokio-tungstenite`、`tool-runtime`、 +`bitfun-relay-server`、remote-connect 设备/加密/QR 依赖,以及 snapshot/cron/tool +相关 product-only 依赖;`scripts/check-core-boundaries.mjs` 已加入 core +no-default dependency profile 保护,防止这些依赖回流为 non-optional dependency。 +同一脚本也解析 `bitfun-core` 的 `[features]`,要求上述 optional dependency 继续由 +明确 owner feature 显式启用;新增 optional runtime dependency 时必须同时补 +feature-owner 规则和自测,避免 manifest 里出现孤儿 optional dependency 或错挂到 +不属于该能力边界的 feature。 +同一边界脚本也检查 Desktop、CLI、ACP 对 `bitfun-core` 的依赖必须保持 +`default-features = false` 且显式启用 `product-full`,确保完整产品 runtime 装配仍由 +产品入口声明,而不是依赖 core 的默认 feature。脚本会扫描产品入口范围内新增的 +`bitfun-core` 依赖并要求补齐显式装配规则,同时继续锁定 +`bitfun-core default = ["product-full"]`,避免本轮依赖裁剪意外变成默认能力裁剪。 +同一脚本也把 `tool-packs`、`services-integrations`、`product-domains` 纳入 +owner crate feature graph 门禁:这些 owner crate 的 `default` 必须保持空,`product-full` +只能显式聚合当前已声明的 owner feature group,不能借完整产品构建把未迁移 runtime +伪装成已迁入能力。 +该步骤仍不是 runtime owner 深迁移,不改变产品 crate feature set、默认 feature、 +CI/release 脚本或用户可见行为。`reqwest`、`axum`、`tower-http`、`terminal-core`、 +`zip`、`notify` 等仍因 AI/debug-log/terminal/LSP/search 等 no-default facade 保留; +后续若继续评估 `default = []` 或 per-product feature matrix,必须另起完整产品矩阵 +和构建收益数据评审。 +Core `service-integrations` feature 当前仍不是独立可编译产品形态;MCP/remote-connect/ +review-platform 仍引用 agentic、snapshot 或 product execution owner,因此只作为 +`product-full` runtime assembly 的组成部分验证。若未来要让它单独成立,需要先补 +port/provider 设计和行为等价测试,而不是只补 manifest 依赖。 + +**HR 风险与优化清单:** + +所有 HR PR 都必须满足以下共同约束: + +- 功能影响范围:只能移动 owner 或引入 port/provider adapter;不得改变用户可见命令、 + 默认权限、remote/session 生命周期、tool 可见性、MiniApp/function-agent 输出、CLI/Desktop/ACP/Server + 交互语义。 +- 产品发布形态:不得修改 `bitfun-core` default feature、产品 crate feature set、 + `package.json`、desktop/installer build scripts、release/fast build 脚本或 CI 覆盖范围。 + 若某项迁移必须改变这些内容,必须从 HR PR 中拆出并先单独评审。 +- 性能门禁:不得新增无界全局锁、阻塞 IO、重复 registry rebuild、重复 manifest + materialization、额外 network/process startup 或跨 crate 的重依赖反向引入。PR 如果声明 + build/check 收益,必须记录迁移前后数据;如果不声明收益,也至少不能让 workspace + check/test 或关键产品 check 明显劣化。 +- 依赖边界:owner crate 不得依赖回 `bitfun-core`;contract crate 不得吸收 + Tauri、CLI/TUI presentation、network client、process execution、`git2`、`rmcp`、 + `image`、`tokio-tungstenite` 等 concrete runtime 依赖。 +- 回滚边界:每个 HR PR 必须保留旧路径 facade 或 adapter,使失败时可以把新 owner + 路径回退到 core-owned adapter,而不需要同步修改产品 surface。 + +HR1:tool runtime deep owner migration 的主要风险和控制点: + +- 当前已完成的低侵入部分:`product_runtime.rs` 统一承接 product provider plan + materialization、product registry snapshot/catalog facade、manifest / GetToolSpec facade 和 + snapshot wrapper 注入;这只是 core 内部 owner closure,不改变工具执行路径。 +- 风险:`ToolUseContext` 携带 workspace services、cancellation、computer-use host、 + custom data、Deep Review checkpoint hook 与 collapsed unlock state;若直接移动, + 可能改变工具可见性、权限、snapshot wrapper、Deep Review tool flow 或取消语义。 +- 风险:manifest / `GetToolSpec` / catalog 组装若重复计算,可能增加每轮 agent + prompt 构建成本;若 dynamic metadata 顺序或去重语义漂移,可能改变模型看到的工具集合。 +- 可优化点:只先抽 `ToolUseContext` 的 capability/read-only projection 或小型 + service port;concrete tools 仍按 feature group 分批评审,避免一次性迁移全部 IO 工具。 +- 可优化点:把 manifest/catalog 快照缓存边界显式化,避免迁移后每次 prompt + resolution 都重建 registry;保留现有 provider order 和 dynamic provider metadata order。 +- 必须新增或复用的保护:builtin tool list、provider group order、readonly/enabled + filtering、expanded/collapsed exposure、`GetToolSpec` duplicate-load/unlock state、 + snapshot wrapper、runtime restriction、cancellation 和 Deep Review tool flow regression。 +- 产品形态门禁:Desktop MCP catalog、ACP catalog、CLI agent tool surface、Deep Review + tool flow 必须继续使用同一行为矩阵;不得为了 tool owner 外移改变任何 surface command。 + +HR2:product-domain runtime deep owner migration 的主要风险和控制点: + +- 当前 HR2 结论:本轮只完成 core 内部 owner closure。`CoreProductDomainRuntime` + 集中创建 MiniApp runtime-state facade、function-agent Git/AI adapters 和 + function-agent runtime facade;这让后续迁移审查有唯一入口,但不改变 MiniApp + filesystem IO、worker process、host dispatch、built-in asset seed / marker IO / + recompile,或 function-agent Git/AI service call 的 owner。 +- 风险:MiniApp filesystem IO、worker process、host dispatch、builtin asset seed / + marker IO / recompile 都有外部副作用;迁移不当会改变用户数据目录、更新标记、 + rollback、dependency state 或编译/运行顺序。 +- 风险:function-agent Git/AI 调用涉及 provider acquisition、transport error mapping、 + prompt 输入、JSON extraction/repair 和 `analyzed_at` 时序;移动过深可能改变 commit + message、Startchat work-state 或非 Git workspace fallback。 +- 可优化点:优先抽 storage/process/Git/AI 的最小 port contract,让 + `product-domains` 拥有纯 orchestration,core 继续注入 PathManager、process runner、 + Git/AI adapter 和 asset source。 +- 可优化点:把 MiniApp import/sync/recompile/rollback/deps state 的快照基线作为 + 迁移前后对比入口;对 function-agent 保留 no-HEAD diff fallback、非 Git 空状态、 + `analyze_git=false` time-info 和 post-analysis `analyzed_at` 赋值语义。 +- 必须新增或复用的保护:MiniApp import/sync/recompile/rollback/deps state focused + tests、builtin seed marker round-trip、customized update metadata、function-agent + prompt/response policy、Git/AI adapter error mapping 和 Startchat work-state regression。 +- 产品形态门禁:Desktop MiniApp、server/remote workspace、CLI function-agent 路径和 + packaged built-in MiniApp asset 必须继续组装;不得改变 installer、desktop release 或 + user-data seed 产物。 + +HR3:service / agent runtime deep owner migration 的主要风险和控制点: + +- 当前 HR3 结论:本轮只完成 core 内部 owner closure。`CoreServiceAgentRuntime` + 集中创建 remote dialog host、remote image context adapter 和 + `ConversationCoordinator` 的 runtime-port binding;这让后续 service/agent + runtime 深迁移有唯一审查入口,但不改变 remote-connect / remote-SSH / + scheduler / registry 的实际执行路径。 +- 风险:remote-SSH manager / remote FS / terminal 与 remote-connect workspace-root + source、response wrapping、`ImageContextData` concrete impl 都连接实际远端执行环境; + 迁移不当会破坏 remote workspace guard、terminal pre-warm、base64 response shape、 + image fallback 或 file chunk range 行为。 +- 风险:agent registry / scheduler 现在承载 mode-scoped subagent visibility、 + `Multitask` / `GeneralPurpose` registration、background result delivery、running-turn + injection 和 idle-session follow-up;迁移不当会改变 subagent 可见性、排队、确认边界 + 或 DeepResearch post-turn hook。 +- 可优化点:先把 scheduler/registry 的 observable facts、queue policy decision、 + runtime event fact 与 remote workspace identity 抽成只读 contract;concrete + execution、session restore、terminal binding 和 response wrapping 继续由 core adapter 注入。 +- 可优化点:对 remote-connect 保持 owner crate 只管 orchestration policy, + core 继续拥有 workspace-root source、response/base64 wrapper 和 concrete scheduler submit, + 直到有端到端 remote product regression。 +- 必须新增或复用的保护:remote command/response wire、restore -> terminal pre-warm -> + scheduler submit 顺序、file full/chunk/info、image context fallback/preference、 + mode-scoped subagent availability、background delivery、DeepResearch citation + renumber hook、queue/confirmation boundary 和 remote workspace startup guard regression。 +- 产品形态门禁:Desktop remote connect、relay/bot、server, ACP remote config reuse、 + CLI subagent management 和 Review Team 可见性必须继续按当前产品差异运行;不得为了 + service/agent owner 外移统一 surface presentation 或命令语义。 + +因此,当前文档口径下的剩余数量是: + +- 必需主线:0 个新增 runtime 迁移 PR;H4 本身完成后即可进入提交审查。 +- 可选评估:1 个 H5 PR。 +- 若继续追求更深的 owner 化:最多 3 个条件性高风险 runtime PR + 1 个 H5 PR。 + +不计入上述数量:缺陷修复、行为变更、冗余清理和构建脚本调整。它们必须独立评估, +不能伪装成 core decomposition 的剩余里程碑。 本节解释“为什么统计总是 4-5 个”:之前把低风险准备事项拆成 R1-R4,导致每次都看起来还剩 4 个小 PR;这些准备事项已统一为 `LR1` 并在 2026-05-19 闭环。默认回答必须是: @@ -1795,13 +2004,15 @@ marker IO / recompile,以及 function-agent Git service / AI client 调用仍 必须单独选择字符串化 wrapper 或给 `core-types` 引入轻量 error 依赖,并先补兼容测试。 - 后续 shared DTO 不做批量移动;只能按单个 owner/DTO 在对应 PR 中确认依赖方向。 - `services-core` / `services-integrations` 的剩余 search、lsp、cron、snapshot、 - remote-connect dialog 与 remote-SSH runtime 均显式 deferred/core-owned;MCP runtime - 已完成的部分不再重复规划。 + remote-connect concrete scheduler/session restore/terminal adapter、workspace-root + source、response wrapping 与 remote-SSH runtime 均显式 deferred/core-owned;MCP + runtime 已完成的部分不再重复规划。 - `agent-tools` / `tool-packs` 只承载纯契约、provider metadata、generic/static/dynamic provider container 与 feature-group scaffold;`ToolUseContext`、runtime manifest assembly、`GetToolSpec` 执行、collapsed unlock state 和 concrete tools 进入 H1。 - MiniApp 与 function-agent 的纯 DTO/helper/port facade 已归属 `product-domains`; - H2 已迁移 function-agent prompt template、JSON extraction 与 domain error mapping policy。 + H2 已迁移 function-agent prompt template、JSON extraction 与 domain error mapping policy; + HR2 已将 core-owned product-domain runtime 绑定集中到 `CoreProductDomainRuntime`。 filesystem IO、process/Git/AI 调用、host dispatch、built-in asset seeding/marker IO 与 recompile orchestration 仍显式保留 core-owned,后续只允许按单一 owner 重新评审。 - `bitfun-core` 依赖裁剪、`default = []`、per-product feature matrix 与构建收益宣传 @@ -1913,26 +2124,39 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts 11. 已提交:PR 2 `Services/Product Runtime Owner Closure`,收口 remote-SSH session identity / mirror path / unresolved-session layout 与 MiniApp storage file layout owner;core 保留 `PathManager` 注入、SSH manager、remote FS / terminal、MiniApp filesystem IO 和 worker runtime。 12. 历史已完成:MCP runtime 与 dynamic tools;已迁移 config service orchestration、server process / transport lifecycle、adapter、dynamic tool/resource/prompt provider,core 保留 ConfigService store adapter、OAuth data-dir 注入、BitFunError 映射、legacy facade 和 product registry / manifest assembly。 13. P2 后前置轨道:产品表面 contract-only 补强,可在后续 PR 第一组提交中处理;只允许 DTO/port、round-trip/no-op tests 和 boundary check,不实现 CLI/Desktop/Remote/ACP UI 或命令变更。 -14. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO 已以 contract-only 方式进入 `bitfun-core-types`;`bitfun-services-integrations` 的 `remote-connect` feature 拥有 remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、tracker state / registry lifecycle、tracker event reduction、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy;relay/bot 创建 session 通过 `AgentSubmissionPort`,取消、远程状态读取和事件事实已有 `runtime-ports` 契约。远程消息执行、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行仍保留在 `bitfun-core` product runtime assembly。 -15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、portable context facts/provider、generic registry / static provider installation / dynamic provider container 已归属 `bitfun-agent-tools`,`tool-packs` 提供 feature-group scaffold 和 product provider group plan,core 保留 concrete tool materialization、product snapshot wrapper adapter、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 service port/provider 设计。 +14. 已完成:remote-connect tracker / wire / pure policy owner slice:产品表面 DTO 已以 contract-only 方式进入 `bitfun-core-types`;`bitfun-services-integrations` 的 `remote-connect` feature 拥有 remote command/response wire DTO、remote model catalog DTO、poll response assembly / model catalog poll delta、remote chat/image/tool/session wire DTO、relay/bot session/submission request builder、remote image attachment/request DTO、tracker state / registry lifecycle、tracker event reduction、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy;relay/bot 创建 session 通过 `AgentSubmissionPort`,取消、远程状态读取和事件事实已有 `runtime-ports` 契约。H3 进一步把远程消息提交编排、terminal pre-warm decision、remote workspace file IO/path helper 与 image-context adapter contract 迁入 `bitfun-services-integrations` port/provider;concrete terminal adapter、workspace/session restore 执行、workspace-root source、response/base64 wrapping 与 `ImageContextData` concrete impl 仍保留在 `bitfun-core` product runtime assembly。 +15. 已完成:agent tools + `tool-packs` owner 化低风险闭环;tool contract / DTO、runtime restriction、path resolution、host path normalization / runtime artifact URI / remote POSIX path pure contract、allowed-list / collapsed-tool execution gate policy、portable context facts/provider、generic registry / static provider installation / dynamic provider container 已归属 `bitfun-agent-tools`,`tool-packs` 提供 feature-group scaffold 和 product provider group plan,core 保留 concrete tool materialization、product snapshot wrapper adapter、`ToolUseContext` 和 concrete tool implementation,后续外移需单独 service port/provider 设计。 16. 已完成:关键语义回归 baseline,不移动 runtime owner。覆盖 MCP config failure / catalog invalidation / 既有 list-changed helper / dynamic manifest、tool manifest / `GetToolSpec`、product-domains adapter equivalence、remote workspace search fallback 的 focused tests 或 snapshots。 -17. 已完成:remote-connect runtime 当前批次收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision 与 remote file transfer size/chunk/name policy 已迁入 `bitfun-services-integrations`。dispatcher / product execution、`ImageContextData` adapter、file IO/path resolution、terminal pre-warm 与 workspace/session restore 执行显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 -18. 已完成:`product-domains` runtime port/facade closure。已迁入 MiniApp storage-backed runtime-state facade、built-in seed plan / marker wire helper 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup。Git commit-message 与 Startchat work-state 产品路径已通过 core-owned Git/AI adapter 接入 function-agent facade;H2 已将 function-agent prompt template、AI response JSON extraction、JSON repair、domain error mapping 与 JSON-to-domain parsing policy 迁入 product-domain,core 继续保留 Git/AI service adapter、AI client 调用、provider acquisition 与 AI transport error mapping。Startchat 接线已用 no-HEAD diff fallback、非 Git 目录空状态和 `analyze_git=false` time-info 保护旧行为,`analyzed_at` 仍由 core 在 AI 分析完成后赋值。 +17. 已完成:remote-connect runtime 当前批次收口与 HR3 core owner 收口。已基于当前 port baseline 记录 remote command/response、remote model catalog、poll response、model catalog delta、session restore、active turn、cancel、image context、tracker event、queue/event fanout 与 dialog orchestration 顺序的输入输出和验证命令;tracker state / registry lifecycle、legacy image context fallback / preference、restore target decision、cancel decision、remote file transfer size/chunk/name policy、remote workspace file IO/path helper、image-context adapter contract 与 RemoteRelay/Bot dialog submission orchestration 已迁入 `bitfun-services-integrations`。HR3 进一步用 `CoreServiceAgentRuntime` 集中 dispatcher compatibility wrapper 所需的 remote dialog host、remote image context conversion 和 `ConversationCoordinator` runtime-port binding;product execution、workspace-root source、response/base64 wrapping、`ImageContextData` concrete impl、concrete terminal pre-warm adapter、workspace/session restore 执行、remote-SSH runtime 与 agent registry/scheduler 显式保留在 core-owned runtime;后续只有在另起 port/provider 设计且 focused regression 继续通过时才允许继续移动这些 runtime owner,不能把 generic attachment guard 当作已接入多模态行为。 +18. 已完成:`product-domains` runtime port/facade closure 与 HR2 core owner 收口。已迁入 MiniApp storage-backed runtime-state facade、built-in seed plan / marker wire helper 与 function-agent Git/AI port-backed runtime facade,并补充 focused contract tests;core 只对 MiniApp deps/restart/recompile/sync/rollback 的状态持久化委托 facade,仍保留 `PathManager` 注入、filesystem IO、worker process execution、host dispatch 执行、built-in asset seeding/source-hash lookup。Git commit-message 与 Startchat work-state 产品路径已通过 core-owned Git/AI adapter 接入 function-agent facade;H2 已将 function-agent prompt template、AI response JSON extraction、JSON repair、domain error mapping 与 JSON-to-domain parsing policy 迁入 product-domain,HR2 进一步用 `CoreProductDomainRuntime` 集中 core-owned MiniApp/function-agent runtime 绑定,core 继续保留 Git/AI service adapter、AI client 调用、provider acquisition 与 AI transport error mapping。Startchat 接线已用 no-HEAD diff fallback、非 Git 目录空状态和 `analyze_git=false` time-info 保护旧行为,`analyzed_at` 仍由 core 在 AI 分析完成后赋值。 19. 已合入:tool runtime owner 迁移前置基线。纯 helper 已从 runtime owner 中剥离到 `bitfun-agent-tools`:`StaticToolProviderGroup`、registry snapshot 到 manifest policy input、generic collapsed exposure 查询、`GetToolSpec` collapsed-load 纯收集规则、prompt-visible manifest definition 组装规则和 `GetToolSpec` catalog/detail helper;完整 collapsed 工具清单、runtime context 传递和 portable facts 边界回归已作为迁移前基线。 -20. 已合入:core-owned tool runtime assembly closure。`ProductToolRuntimeAssembly` 作为 core 内部单一 owner 收敛 static provider 安装和 product snapshot wrapper adapter,并保持 legacy `create_tool_registry()`、global registry、dynamic MCP tools、manifest resolver、`GetToolSpecTool` 执行、`ToolUseContext` 和 concrete tools 的行为边界不变。 +20. 已合入:core-owned tool runtime assembly closure。`ProductToolRuntime` 作为 core 内部单一 owner 收敛 static provider 安装和 product snapshot wrapper adapter,并保持 legacy `create_tool_registry()`、global registry、dynamic MCP tools、manifest resolver、`GetToolSpecTool` 执行、`ToolUseContext` 和 concrete tools 的行为边界不变。 21. 已合入:provider/assembly equivalence guards。custom decorator、provider install、collapsed catalog、manifest / `GetToolSpec` unlock surface 已有等价保护;这些是 behavior-locking tests,不是 runtime owner 迁移。 22. 已合入:`ToolUseContext` portable facts owner guard。可外移 facts 与 runtime-only fields 已分界;collapsed unlock state、custom data、workspace services、cancellation token、computer-use host 与 Deep Review checkpoint hook 继续 core-owned。 23. 已合入:`GetToolSpec` unlock state guard。execution 侧只接受成功的 `GetToolSpec` 结果、只输出 collapsed 白名单工具,并保持去重和过滤语义;`GetToolSpecTool` 执行、runtime manifest assembly、`ToolUseContext` 和 concrete tools 不迁移。 24. 已合入:contextual manifest owner migration。context-aware prompt manifest / visible-tools resolution 通用算法、generic catalog snapshot provider、generic GetToolSpec catalog provider 和 `GetToolSpec` collapsed summary/detail helper 已迁入 `bitfun-agent-tools`;core 仍保留 product registry snapshot access、product collapsed catalog source、core `Tool` / `ToolUseContext` adapter 与旧路径返回类型。 -25. 已合入:product tool adapter/catalog facade closure。`tool_adapter.rs` 承接 core `Tool` 到 provider-neutral contract 的 adapter,`catalog_provider.rs` 承接 product registry snapshot、contextual catalog、manifest、GetToolSpec catalog-description/detail provider facade;`manifest_resolver` 和 `GetToolSpecTool` 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 与 assistant result rendering。 +25. 已合入:product tool adapter/catalog facade closure。`tool_adapter.rs` 承接 core `Tool` 到 provider-neutral contract 的 adapter,`product_runtime.rs` 承接 product registry snapshot、contextual catalog、manifest、GetToolSpec catalog-description/detail provider facade;`manifest_resolver` 和 `GetToolSpecTool` 只保留旧路径 result type 转换、execution wrapper、duplicate-load guard 与 assistant result rendering。 26. 已完成:H1 GetToolSpec runtime facade owner slice。`bitfun-agent-tools` 接管 static tool surface(name / description / schema / readonly / concurrency / permission / validation / tool-use message)、input extraction、duplicate-load planning、duplicate-load result、provider-backed detail lookup 到 result assembly 的组装规则和 typed execution error;core 继续持有 `GetToolSpecTool` Tool impl、`ToolUseContext.unlocked_collapsed_tools` 状态来源、product provider/context 注入和 `BitFunError` 映射;该阶段不迁移 runtime manifest assembly、unlock state owner、snapshot decorator 或 concrete tools。 -27. 已完成:H1 generic runtime assembly owner slice。`bitfun-agent-tools` 接管 static-provider 安装 assembly 的通用顺序、decorator reference contract、generic snapshot decorator adapter 与 decorator 应用规则;core `ProductToolRuntimeAssembly` 继续持有 concrete provider group 来源、product snapshot wrapper adapter 注入、旧路径 `ToolRegistry` wrapper、product registry snapshot access 和 dynamic MCP tool entry,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、runtime manifest facade、unlock state owner、snapshot wrapper implementation 或 concrete tools。 +27. 已完成:H1 generic runtime assembly owner slice。`bitfun-agent-tools` 接管 static-provider 安装 assembly 的通用顺序、decorator reference contract、generic snapshot decorator adapter 与 decorator 应用规则;core `ProductToolRuntime` 继续持有 concrete provider group 来源、product snapshot wrapper adapter 注入、旧路径 `ToolRegistry` wrapper、product registry snapshot access 和 dynamic MCP tool entry,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、runtime manifest facade、unlock state owner、snapshot wrapper implementation 或 concrete tools。 28. 已完成:H1 readonly filter owner slice。`bitfun-agent-tools` 接管 registry snapshot 上 readonly + enabled 过滤的通用规则;core 继续持有 product snapshot access、`dyn Tool` adapter 和各 concrete tool 的 readonly/enabled 判定,不迁移 `ToolUseContext`、runtime manifest facade、`GetToolSpecTool`、snapshot wrapper implementation 或 concrete tools。 29. 已完成:H1 tool catalog runtime facade slice。`bitfun-agent-tools::ToolCatalogRuntime` 接管 provider-backed visible-tools、prompt-visible manifest 与 readonly enabled catalog 查询入口;core 继续持有 product registry snapshot、agent policy、`dyn Tool` / `ToolUseContext` adapter 和 product facade,不迁移 `ToolUseContext`、`GetToolSpecTool` Tool impl、collapsed unlock state、snapshot wrapper implementation 或 concrete tools。 -30. 已完成:H1 GetToolSpec Tool adapter facade slice。`bitfun-agent-tools::GetToolSpecRuntime::call_results` 接管单次执行结果到 `Vec` 的通用适配形状,core `catalog_provider.rs` 暴露 product `resolve_product_get_tool_spec_results`,`GetToolSpecTool::call_impl` 只保留 product facade 委托和 `BitFunError` 映射;不迁移 `ToolUseContext`、runtime manifest assembly、unlock state owner、assistant rendering 语义或 concrete tools。 -31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `static_providers.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 -32. H1 闭环状态:工具 runtime 的 provider-neutral contract、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 -33. 后续独立评估:`bitfun-core default = []`、per-product feature set、依赖版本收敛或构建收益优化;任何收益声明都需要记录 `cargo check -p bitfun-core`、workspace check 和目标 crate check 的前后数据。 +30. 已完成:H1 GetToolSpec Tool adapter facade slice。`bitfun-agent-tools::GetToolSpecRuntime::call_results` 接管单次执行结果到 `Vec` 的通用适配形状,core `product_runtime.rs` 暴露 product `resolve_product_get_tool_spec_results`,`GetToolSpecTool::call_impl` 只保留 product facade 委托和 `BitFunError` 映射;不迁移 `ToolUseContext`、runtime manifest assembly、unlock state owner、assistant rendering 语义或 concrete tools。 +31. 已完成:H1 product provider plan closure。`bitfun-tool-packs::product_tool_provider_group_plan` 接管 product provider group id / feature group / tool-name order 计划,core `product_runtime.rs` 只按该计划物化 concrete tools 并继续注入 snapshot wrapper;不迁移 concrete tool implementation、`ToolUseContext`、runtime service handles 或 tool behavior。 +32. HR1 当前闭环状态:工具 runtime 的 provider-neutral contract、host path normalization / runtime artifact URI / remote POSIX path pure contract、allowed-list / collapsed-tool execution gate policy、manifest/catalog runtime facade、GetToolSpec facade、static-provider assembly、readonly filtering、provider plan 与 core product adapter 已收敛;core 内部 product runtime adapter 已统一到 `product_runtime.rs`。本轮进一步把 `ToolUseContext` 上的 workspace service accessor、runtime artifact lookup、path policy enforcement、tool pipeline/description/preflight context materialization、tool-call cancellation/post-call hook wrapper 和 Deep Review light checkpoint 绑定集中到 `tool_context_runtime.rs`,作为 core-owned runtime binding owner,并补齐 remote workspace containment、runtime URI scope、path policy、task/description/preflight context materialization 与 cancellation hook 回归测试;`framework.rs` 只保留 context shape、portable facts projection 和 `Tool` trait。`ToolUseContext` 本体和 concrete tools 仍显式 core-owned;继续外移会触碰 workspace services、cancellation、Deep Review hooks 或具体工具 IO,必须作为后续高风险 owner 迁移单独确认。 +33. H4 已完成:facade / boundary finalization。`scripts/check-core-boundaries.mjs` 的 regular check 和 self-test 已覆盖 remote-connect file/image/dialog owner anchor、core adapter/deferred owner anchor 与既有 duplicate-path required rule;root / core / services-integrations 文档与当前 H1-H3 代码状态一致,不声明 remote-SSH runtime、agent registry/scheduler、default feature 或构建收益已完成。 +34. H5 已启动并完成当前闭环:第一步建立 `bitfun-core --no-default-features` 编译闭环, + 证明 `ssh-remote` 关闭时不再编译 russh-backed runtime,并通过 disabled surface + 返回明确 unsupported 诊断;第二步把 `bitfun-core/product-full` 改为显式聚合 + `tool-packs`、`services-integrations`、`product-domains` 的 owner feature group; + 其中 `tool-packs` 与 `product-domains` 已成为 core optional dependency, + 第三步把源码已由 feature 门禁保护的 product/runtime 直连依赖改为 optional, + 并由完整产品 feature 显式启用;boundary check 同步覆盖 no-default + non-optional 回流、optional dependency feature-owner 映射、产品入口显式 + `product-full` 装配、产品入口新增 core 依赖覆盖扫描和 `default = ["product-full"]` + 保留,以及 owner crate default-light / `product-full` 显式 feature group 组装,同时保持产品 crate feature set、 + release/CI 脚本和用户可见能力不变。no-default core 当前只作为 + runtime-surface-light facade,已减少 direct dependency profile,但不声明 + per-product feature matrix、构建收益或 runtime owner 深迁移已完成。 冗余清理 PR 不进入上述主线序号。只有在满足 `0A.6` 的绝对等价要求时,才可以插入到相邻里程碑之间,并且不得与主线拆分 PR 混合。 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 0b686659cf..0b655a90c1 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -112,6 +112,49 @@ const lightweightBoundaryRules = [ ]; const dependencyProfileRules = [ + { + crateName: 'core', + profileName: 'no-default runtime-surface-light profile', + reason: + 'bitfun-core no-default profile must not force product/runtime integration dependencies', + forbiddenNonOptionalDeps: [ + 'aes', + 'aes-gcm', + 'bitfun-product-domains', + 'bitfun-relay-server', + 'bitfun-tool-packs', + 'chrono-tz', + 'cron', + 'dashmap', + 'eventsource-stream', + 'filetime', + 'flate2', + 'fs2', + 'git2', + 'glob', + 'globset', + 'hostname', + 'image', + 'include_dir', + 'indexmap', + 'local-ip-address', + 'mac_address', + 'md5', + 'qrcode', + 'rand', + 'rmcp', + 'russh', + 'russh-keys', + 'russh-sftp', + 'shellexpand', + 'sse-stream', + 'ssh_config', + 'similar', + 'tool-runtime', + 'tokio-tungstenite', + 'x25519-dalek', + ], + }, { crateName: 'core-types', profileName: 'default DTO profile', @@ -239,6 +282,108 @@ const dependencyProfileRules = [ }, ]; +const optionalDependencyFeatureOwnerRules = [ + { + crateName: 'core', + reason: + 'bitfun-core product/runtime optional dependencies must stay owned by explicit feature gates', + dependencies: [ + { depName: 'aes', ownerFeatures: ['service-integrations'] }, + { depName: 'aes-gcm', ownerFeatures: ['service-integrations', 'ssh-remote'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['product-domains'] }, + { depName: 'bitfun-relay-server', ownerFeatures: ['service-integrations'] }, + { depName: 'bitfun-tool-packs', ownerFeatures: ['tool-packs'] }, + { depName: 'chrono-tz', ownerFeatures: ['product-full'] }, + { depName: 'cron', ownerFeatures: ['product-full'] }, + { depName: 'dashmap', ownerFeatures: ['product-full'] }, + { depName: 'eventsource-stream', ownerFeatures: ['product-full'] }, + { depName: 'filetime', ownerFeatures: ['product-full'] }, + { depName: 'flate2', ownerFeatures: ['product-full'] }, + { depName: 'fs2', ownerFeatures: ['product-full'] }, + { depName: 'git2', ownerFeatures: ['service-integrations'] }, + { depName: 'glob', ownerFeatures: ['product-full'] }, + { depName: 'globset', ownerFeatures: ['product-full'] }, + { depName: 'hostname', ownerFeatures: ['service-integrations'] }, + { depName: 'image', ownerFeatures: ['service-integrations'] }, + { depName: 'include_dir', ownerFeatures: ['product-full'] }, + { depName: 'indexmap', ownerFeatures: ['product-full'] }, + { depName: 'local-ip-address', ownerFeatures: ['service-integrations'] }, + { depName: 'mac_address', ownerFeatures: ['service-integrations'] }, + { depName: 'md5', ownerFeatures: ['product-full', 'service-integrations'] }, + { depName: 'qrcode', ownerFeatures: ['service-integrations'] }, + { depName: 'rand', ownerFeatures: ['service-integrations', 'ssh-remote'] }, + { depName: 'rmcp', ownerFeatures: ['service-integrations'] }, + { depName: 'russh', ownerFeatures: ['ssh-remote'] }, + { depName: 'russh-keys', ownerFeatures: ['ssh-remote'] }, + { depName: 'russh-sftp', ownerFeatures: ['ssh-remote'] }, + { depName: 'shellexpand', ownerFeatures: ['ssh-remote'] }, + { depName: 'similar', ownerFeatures: ['product-full'] }, + { depName: 'sse-stream', ownerFeatures: ['service-integrations'] }, + { depName: 'ssh_config', ownerFeatures: ['ssh-remote'] }, + { depName: 'tokio-tungstenite', ownerFeatures: ['service-integrations'] }, + { depName: 'tool-runtime', ownerFeatures: ['product-full'] }, + { depName: 'x25519-dalek', ownerFeatures: ['service-integrations'] }, + ], + }, +]; + +const productCoreFeatureAssemblyRules = [ + { + manifestPath: 'src/apps/desktop/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'desktop must explicitly assemble the full bitfun-core product runtime', + }, + { + manifestPath: 'src/apps/cli/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'CLI must explicitly assemble the full bitfun-core product runtime', + }, + { + manifestPath: 'src/crates/acp/Cargo.toml', + dependencyName: 'bitfun-core', + requiredFeatures: ['product-full'], + reason: 'ACP must explicitly assemble the full bitfun-core product runtime', + }, +]; + +const productCoreFeatureAssemblyScanRoots = ['src/apps', 'src/crates/acp']; + +const ownerCrateFeatureAssemblyRules = [ + { + manifestPath: 'src/crates/tool-packs/Cargo.toml', + reason: 'tool-packs must keep product feature groups explicit and default-light', + requiredProductFullFeatures: [ + 'basic', + 'git', + 'mcp', + 'browser-web', + 'computer-use', + 'image-analysis', + 'miniapp', + 'agent-control', + ], + }, + { + manifestPath: 'src/crates/services-integrations/Cargo.toml', + reason: 'services-integrations must keep integration feature groups explicit and default-light', + requiredProductFullFeatures: [ + 'announcement', + 'file-watch', + 'git', + 'mcp', + 'remote-connect', + 'remote-ssh', + ], + }, + { + manifestPath: 'src/crates/product-domains/Cargo.toml', + reason: 'product-domains must keep product domain feature groups explicit and default-light', + requiredProductFullFeatures: ['miniapp', 'function-agents'], + }, +]; + const facadeOnlyFiles = [ { path: 'src/crates/core/src/service/git/git_service.rs', @@ -349,6 +494,36 @@ const forbiddenContentRules = [ regex: /\bpub enum ToolWorkspaceKind\b/, message: 'core tool framework must not redefine ToolWorkspaceKind; use bitfun-agent-tools', }, + { + regex: /\bget_global_coordinator\b/, + message: + 'core tool framework must not own runtime checkpoint coordination; keep it in tool_context_runtime', + }, + { + regex: /\bGitService\b/, + message: + 'core tool framework must not own git-backed checkpoint runtime; keep it in tool_context_runtime', + }, + { + regex: /\bget_workspace_runtime_service_arc\b/, + message: + 'core tool framework must not own workspace runtime lookup; keep it in tool_context_runtime', + }, + { + regex: /\bremote_workspace_runtime_root\b/, + message: + 'core tool framework must not own remote runtime-root lookup; keep it in tool_context_runtime', + }, + { + regex: /\bget_path_manager_arc\b/, + message: + 'core tool framework must not own host runtime-root lookup; keep it in tool_context_runtime', + }, + { + regex: /\bpost_call_hooks::record_successful_tool_call\b/, + message: + 'core tool framework must not own post-call runtime hooks; keep them in tool_context_runtime', + }, ], }, { @@ -367,6 +542,36 @@ const forbiddenContentRules = [ message: 'core tool restrictions must not redefine ToolRuntimeRestrictions; use bitfun-agent-tools', }, + { + regex: /\bfn\s+normalize_absolute_posix_path\b/, + message: + 'core tool restrictions must not redefine remote POSIX path normalization; use bitfun-agent-tools', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/workspace_paths.rs', + patterns: [ + { + regex: /\bpub const BITFUN_RUNTIME_URI_PREFIX\b/, + message: + 'core workspace path facade must not redefine the runtime URI prefix; use bitfun-agent-tools', + }, + { + regex: /\bpub struct ParsedBitFunRuntimeUri\b/, + message: + 'core workspace path facade must not redefine ParsedBitFunRuntimeUri; use bitfun-agent-tools', + }, + { + regex: /\bfn\s+posix_normalize_components\b/, + message: + 'core workspace path facade must not redefine remote POSIX path normalization; use bitfun-agent-tools', + }, + { + regex: /Component::ParentDir/, + message: + 'core workspace path facade must not redefine host path normalization; use bitfun-agent-tools', + }, ], }, { @@ -505,12 +710,12 @@ const forbiddenContentRules = [ { regex: /\bGitService::get_status\b/, message: - 'Git function-agent commit generator must use CoreFunctionAgentGitAdapter through FunctionAgentRuntimeFacade', + 'Git function-agent commit generator must use CoreProductDomainRuntime for Git adapter wiring', }, { regex: /\bAIAnalysisService::new_with_agent_config\b/, message: - 'Git function-agent commit generator must use CoreFunctionAgentAiAdapter through FunctionAgentRuntimeFacade', + 'Git function-agent commit generator must use CoreProductDomainRuntime for AI adapter wiring', }, { regex: /\bto_string_lossy\b/, @@ -525,12 +730,12 @@ const forbiddenContentRules = [ { regex: /\bAIWorkStateService::new_with_agent_config\b/, message: - 'Startchat work-state analyzer must use CoreFunctionAgentAiAdapter through FunctionAgentRuntimeFacade', + 'Startchat work-state analyzer must use CoreProductDomainRuntime for AI adapter wiring', }, { regex: /\bcreate_command\("git"\)/, message: - 'Startchat work-state analyzer must use CoreFunctionAgentGitAdapter through FunctionAgentRuntimeFacade', + 'Startchat work-state analyzer must use CoreProductDomainRuntime for Git adapter wiring', }, ], }, @@ -929,6 +1134,10 @@ const forbiddenContentRules = [ regex: /\bfn resolve_remote_execution_image_contexts\b/, message: 'core remote-connect server must not own image-context preference policy; use the integrations helper', }, + { + regex: /\btrait RemoteImageContextAdapter\b/, + message: 'core remote-connect server must not own image-context adapter contracts; use the integrations contract', + }, { regex: /\bconst MAX_SIZE\b/, message: 'core remote-connect server must not own remote file max-read policy; use the integrations helper', @@ -941,6 +1150,30 @@ const forbiddenContentRules = [ regex: /unwrap_or\("file"\)/, message: 'core remote-connect server must not own remote file display-name fallback; use the integrations helper', }, + { + regex: /\bresolve_workspace_path\b/, + message: 'core remote-connect server must not own workspace file path resolution; use the integrations helper', + }, + { + regex: /\bdetect_mime_type\b/, + message: 'core remote-connect server must not own workspace file MIME detection; use the integrations helper', + }, + { + regex: /\bread_workspace_file\b/, + message: 'core remote-connect server must not own workspace file read helpers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file\b/, + message: 'core remote-connect server must not redefine remote workspace full-file readers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file_chunk\b/, + message: 'core remote-connect server must not redefine remote workspace chunk readers; use the integrations helper', + }, + { + regex: /\bfn read_remote_workspace_file_info\b/, + message: 'core remote-connect server must not redefine remote workspace file-info readers; use the integrations helper', + }, { regex: /\bfn should_send_remote_model_catalog\b/, message: 'core remote-connect server must not own poll model-catalog policy; use the integrations helper', @@ -963,6 +1196,27 @@ const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/core/src/service/remote_connect/bot/mod.rs', + patterns: [ + { + regex: /\bfn strip_workspace_path_prefix\b/, + message: 'core remote-connect bot facade must not own workspace path prefix stripping; use the integrations helper', + }, + { + regex: /\bfn is_absolute_workspace_path\b/, + message: 'core remote-connect bot facade must not own workspace path absolute detection; use the integrations helper', + }, + { + regex: /\bmatch ext\.as_str\(\)/, + message: 'core remote-connect bot facade must not own workspace file MIME mapping; use the integrations helper', + }, + { + regex: /\btokio::fs::read\(&abs_path\)/, + message: 'core remote-connect bot facade must not own workspace file reads; use the integrations helper', + }, + ], + }, { path: 'src/crates/core/src/service/announcement/state_store.rs', patterns: [ @@ -1097,6 +1351,191 @@ const forbiddenContentUnderRules = [ ]; const requiredContentRules = [ + { + path: 'src/crates/core/Cargo.toml', + reason: + 'bitfun-core product-full must explicitly aggregate owner crate feature groups instead of forcing them through dependency declarations', + patterns: [ + { + regex: + /bitfun-tool-packs = \{ path = "\.\.\/tool-packs", default-features = false, optional = true \}/, + message: 'bitfun-tool-packs dependency must stay optional and not force product-full outside the core feature graph', + }, + { + regex: + /bitfun-services-integrations = \{ path = "\.\.\/services-integrations", default-features = false, features = \["remote-ssh"\] \}/, + message: + 'bitfun-services-integrations dependency may keep remote workspace identity helpers but must not force product-full outside the core feature graph', + }, + { + regex: + /bitfun-product-domains = \{ path = "\.\.\/product-domains", default-features = false, optional = true \}/, + message: + 'bitfun-product-domains dependency must stay optional and not force product-full outside the core feature graph', + }, + { + regex: /"dep:bitfun-tool-packs"/, + message: 'core tool-packs feature must explicitly enable the optional dependency', + }, + { + regex: /"bitfun-tool-packs\/product-full"/, + message: 'core product-full must explicitly enable tool pack product features', + }, + { + regex: /"bitfun-services-integrations\/product-full"/, + message: 'core product-full must explicitly enable integration product features', + }, + { + regex: /"dep:bitfun-product-domains"/, + message: 'core product-domains feature must explicitly enable the optional dependency', + }, + { + regex: /"bitfun-product-domains\/product-full"/, + message: 'core product-full must explicitly enable product-domain features', + }, + ], + }, + { + path: 'src/crates/core/src/lib.rs', + reason: + 'no-default bitfun-core must keep product runtime surfaces behind explicit features', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod agentic\b/s, + message: 'agentic runtime must stay behind product-full for no-default builds', + }, + { + regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod function_agents\b/s, + message: 'function-agent product domain facade must stay behind product-domains', + }, + { + regex: /#\[cfg\(feature = "product-domains"\)\]\s*pub mod miniapp\b/s, + message: 'MiniApp product domain facade must stay behind product-domains', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub\(crate\) mod service_agent_runtime\b/s, + message: 'service agent runtime owner assembly must stay behind service-integrations', + }, + ], + }, + { + path: 'src/crates/core/src/service/mod.rs', + reason: + 'service integration and agent-runtime surfaces must not compile in no-default core builds', + patterns: [ + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod git\b/s, + message: 'git service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod mcp\b/s, + message: 'MCP service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod remote_connect\b/s, + message: 'remote-connect service facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*pub mod review_platform\b/s, + message: 'review platform facade must stay behind service-integrations', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod snapshot\b/s, + message: 'snapshot service must stay behind product-full until tool-runtime ownership is split', + }, + ], + }, + { + path: 'src/crates/core/src/service/config/mod.rs', + reason: + 'mode config canonicalization depends on product agent/tool registries and must stay out of no-default builds', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub mod mode_config_canonicalizer\b/s, + message: 'mode config canonicalizer must stay behind product-full', + }, + ], + }, + { + path: 'src/crates/core/src/service/workspace/manager.rs', + reason: + 'workspace metadata may omit git worktree enrichment when service integrations are disabled', + patterns: [ + { + regex: /#\[cfg\(feature = "service-integrations"\)\]\s*use crate::service::git::GitService\b/s, + message: 'GitService import must stay gated for no-default builds', + }, + { + regex: /#\[cfg\(not\(feature = "service-integrations"\)\)\]\s*\{\s*let _ = workspace_root;\s*return None;\s*\}/s, + message: 'no-default worktree enrichment fallback must remain explicit', + }, + ], + }, + { + path: 'src/crates/core/src/service/workspace_runtime/service.rs', + reason: + 'workspace runtime binding helpers may depend on agentic runtime only in full product builds', + patterns: [ + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*use crate::agentic::WorkspaceBinding\b/s, + message: 'WorkspaceBinding import must stay gated for no-default builds', + }, + { + regex: /#\[cfg\(feature = "product-full"\)\]\s*pub async fn ensure_runtime_for_workspace_binding\b/s, + message: 'WorkspaceBinding runtime helper must stay behind product-full', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_ssh/mod.rs', + reason: + 'core remote SSH runtime must keep concrete SSH dependencies behind the ssh-remote feature while preserving lightweight workspace identity helpers', + patterns: [ + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod disabled\b/s, + message: 'missing disabled remote SSH runtime surface for no-default builds', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod manager\b/s, + message: 'remote SSH manager must stay gated behind ssh-remote', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_fs\b/s, + message: 'remote SSH filesystem runtime must stay gated behind ssh-remote', + }, + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*pub mod remote_terminal\b/s, + message: 'remote SSH terminal runtime must stay gated behind ssh-remote', + }, + { + regex: /\bpub mod workspace_state\b/, + message: 'remote workspace identity helpers must remain available without ssh-remote', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_ssh/disabled.rs', + reason: + 'no-default core builds must expose explicit unsupported remote SSH stubs instead of compiling russh-backed runtime code', + patterns: [ + { + regex: /Remote SSH support is disabled; enable the `ssh-remote` feature/, + message: 'missing explicit disabled remote SSH diagnostic', + }, + { + regex: /\bpub struct SSHConnectionManager\b/, + message: 'missing disabled SSH manager compatibility surface', + }, + { + regex: /\bpub struct RemoteFileService\b/, + message: 'missing disabled remote file compatibility surface', + }, + { + regex: /\bpub struct RemoteTerminalManager\b/, + message: 'missing disabled remote terminal compatibility surface', + }, + ], + }, { path: 'src/crates/runtime-ports/src/lib.rs', reason: @@ -1123,7 +1562,7 @@ const requiredContentRules = [ { path: 'src/crates/agent-tools/src/framework.rs', reason: - 'agent-tools may own pure and generic prompt-visible tool manifest contracts without owning product registry or execution', + 'agent-tools may own pure and generic prompt-visible tool contracts and provider-neutral execution gate policy without owning product registry or concrete execution', patterns: [ { regex: /\bpub const GET_TOOL_SPEC_TOOL_NAME\b/, @@ -1301,6 +1740,22 @@ const requiredContentRules = [ regex: /\bpub fn collect_loaded_collapsed_tool_names\b/, message: 'missing pure collapsed-tool load collection contract', }, + { + regex: /\bpub enum CollapsedToolUsageError\b/, + message: 'missing collapsed-tool execution gate error contract', + }, + { + regex: /\bpub enum ToolExecutionAccessError\b/, + message: 'missing tool execution allowed-list gate error contract', + }, + { + regex: /\bpub fn validate_tool_allowed_by_list\b/, + message: 'missing tool execution allowed-list gate policy', + }, + { + regex: /\bpub fn validate_collapsed_tool_usage\b/, + message: 'missing collapsed-tool execution gate policy', + }, { regex: /\bpub fn sort_tool_manifest_definitions\b/, message: 'missing prompt-visible manifest ordering helper', @@ -1369,8 +1824,401 @@ const requiredContentRules = [ message: 'missing remote control state port adapter', }, { - regex: /agent submission port does not yet accept generic attachments/, - message: 'missing generic attachment guard on agent submission port', + regex: /agent submission port does not yet accept generic attachments/, + message: 'missing generic attachment guard on agent submission port', + }, + ], + }, + { + path: 'src/crates/core/src/service_agent_runtime.rs', + reason: + 'core service/agent runtime owner must centralize concrete remote-connect and agent runtime port bindings without moving runtime behavior', + patterns: [ + { + regex: /\bpub\(crate\) struct CoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner type', + }, + { + regex: /\bfn remote_dialog_host\b/, + message: 'missing remote dialog host owner factory', + }, + { + regex: /\bfn remote_image_context\b/, + message: 'missing remote image context owner adapter', + }, + { + regex: /\bfn agent_submission_port\b/, + message: 'missing agent submission port owner binding', + }, + { + regex: /\bCoreRemoteDialogRuntimeHost\b/, + message: 'missing core remote dialog host binding', + }, + { + regex: /\bRemoteExecutionDispatcher\b/, + message: 'missing remote execution dispatcher binding', + }, + { + regex: /\bImageContextData\b/, + message: 'missing core image context binding', + }, + { + regex: /\bRemoteImageContextAdapter\b/, + message: 'missing remote image context adapter implementation', + }, + { + regex: /\bAgentSubmissionPort\b/, + message: 'missing agent submission port binding', + }, + { + regex: /\bAgentTurnCancellationPort\b/, + message: 'missing agent turn cancellation port contract guard', + }, + { + regex: /\bRemoteControlStatePort\b/, + message: 'missing remote control state port contract guard', + }, + { + regex: /\bSessionTranscriptReader\b/, + message: 'missing session transcript reader contract guard', + }, + { + regex: /\bcore_service_agent_runtime_owner_keeps_coordinator_port_contracts\b/, + message: 'missing coordinator runtime port contract regression', + }, + ], + }, + { + path: 'src/crates/services-integrations/src/remote_connect.rs', + reason: + 'services-integrations must own remote-connect wire, tracker, dialog, file, and image adapter contracts', + patterns: [ + { + regex: /\bpub struct RemoteSessionStateTracker\b/, + message: 'missing remote session state tracker owner', + }, + { + regex: /\bpub enum TrackerEvent\b/, + message: 'missing remote tracker event owner', + }, + { + regex: /\bpub trait RemoteSessionTrackerHost\b/, + message: 'missing remote tracker host port', + }, + { + regex: /\bpub struct RemoteSessionTrackerRegistry\b/, + message: 'missing remote tracker registry owner', + }, + { + regex: /\bpub fn make_slim_tool_params\b/, + message: 'missing remote tool preview slimming helper', + }, + { + regex: /\bfn handle_agentic_event\b/, + message: 'missing tracker event reducer', + }, + { + regex: /\bpub fn resolve_remote_agent_type\b/, + message: 'missing remote agent type helper', + }, + { + regex: /\bpub struct RemoteImageContext\b/, + message: 'missing portable remote image context contract', + }, + { + regex: /\bpub trait RemoteImageContextAdapter\b/, + message: 'missing remote image context adapter contract', + }, + { + regex: /\bpub fn build_remote_image_contexts\b/, + message: 'missing legacy remote image context builder', + }, + { + regex: /\bpub fn resolve_remote_execution_image_contexts\b/, + message: 'missing remote image context preference helper', + }, + { + regex: /\bpub fn remote_session_restore_target\b/, + message: 'missing remote restore-target helper', + }, + { + regex: /\bpub enum RemoteCancelDecision\b/, + message: 'missing remote cancel decision contract', + }, + { + regex: /\bpub fn resolve_remote_cancel_decision\b/, + message: 'missing remote cancel decision resolver', + }, + { + regex: /\bpub trait RemoteDialogRuntimeHost\b/, + message: 'missing remote dialog runtime host port', + }, + { + regex: /\bpub async fn submit_remote_dialog\b/, + message: 'missing remote dialog orchestration owner', + }, + { + regex: /\bpub const REMOTE_FILE_MAX_READ_BYTES\b/, + message: 'missing remote file max-read policy', + }, + { + regex: /\bpub const REMOTE_FILE_MAX_CHUNK_BYTES\b/, + message: 'missing remote file chunk policy', + }, + { + regex: /\bpub fn resolve_remote_file_chunk_range\b/, + message: 'missing remote file chunk range helper', + }, + { + regex: /\bpub fn remote_file_display_name\b/, + message: 'missing remote file display-name fallback', + }, + { + regex: /\bpub fn resolve_remote_workspace_path\b/, + message: 'missing remote workspace path resolver', + }, + { + regex: /\bpub fn detect_remote_mime_type\b/, + message: 'missing remote MIME detector', + }, + { + regex: /\bpub async fn read_remote_workspace_file\b/, + message: 'missing remote workspace full-file reader', + }, + { + regex: /\bpub async fn read_remote_workspace_file_chunk\b/, + message: 'missing remote workspace chunk reader', + }, + { + regex: /\bpub async fn read_remote_workspace_file_info\b/, + message: 'missing remote workspace file-info reader', + }, + { + regex: /\bpub struct RemoteDefaultModelsConfig\b/, + message: 'missing remote model default DTO', + }, + { + regex: /\bpub struct RemoteModelConfig\b/, + message: 'missing remote model DTO', + }, + { + regex: /\bpub struct RemoteModelCatalog\b/, + message: 'missing remote model catalog DTO', + }, + { + regex: /\bpub struct RemoteModelCatalogPollDelta\b/, + message: 'missing remote model catalog poll delta', + }, + { + regex: /\bpub enum RemoteCommand\b/, + message: 'missing remote command wire contract', + }, + { + regex: /\bpub enum RemoteResponse\b/, + message: 'missing remote response wire contract', + }, + { + regex: /\bpub fn should_send_remote_model_catalog\b/, + message: 'missing remote model catalog poll policy', + }, + { + regex: /\bpub fn remote_model_catalog_poll_delta\b/, + message: 'missing remote model catalog poll delta helper', + }, + { + regex: /\bpub fn remote_no_change_poll_response\b/, + message: 'missing remote no-change poll response helper', + }, + { + regex: /\bpub fn remote_snapshot_poll_response\b/, + message: 'missing remote snapshot poll response helper', + }, + { + regex: /\bpub fn remote_persisted_poll_response\b/, + message: 'missing remote persisted poll response helper', + }, + ], + }, + { + path: 'src/crates/services-integrations/tests/remote_connect_contracts.rs', + reason: 'remote-connect owner crate must keep focused behavior contracts', + patterns: [ + { + regex: /\bremote_connect_command_wire_shape_lives_in_owner_contract\b/, + message: 'missing remote command wire contract test', + }, + { + regex: /\bremote_connect_response_wire_shape_lives_in_owner_contract\b/, + message: 'missing remote response wire contract test', + }, + { + regex: /\bremote_connect_model_catalog_delta_preserves_poll_invalidation_policy\b/, + message: 'missing remote model catalog delta contract test', + }, + { + regex: /\bremote_connect_poll_helpers_preserve_delta_and_completion_policy\b/, + message: 'missing remote poll helper contract test', + }, + { + regex: /\bremote_connect_image_context_policy_preserves_legacy_fallback_shape\b/, + message: 'missing legacy image context fallback test', + }, + { + regex: /\bremote_connect_image_context_policy_prefers_explicit_contexts\b/, + message: 'missing explicit image context preference test', + }, + { + regex: /\bremote_connect_image_context_adapter_owns_portable_conversion_shape\b/, + message: 'missing image context adapter contract test', + }, + { + regex: /\bremote_connect_cancel_and_restore_policy_preserve_runtime_decisions\b/, + message: 'missing cancel/restore policy test', + }, + { + regex: /\bremote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order\b/, + message: 'missing dialog runtime order test', + }, + { + regex: /\bremote_connect_dialog_runtime_preserves_explicit_turn_without_restore\b/, + message: 'missing dialog explicit-turn test', + }, + { + regex: /\bremote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance\b/, + message: 'missing restore failure tolerance test', + }, + { + regex: /\bremote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges\b/, + message: 'missing remote file transfer policy test', + }, + { + regex: /\bremote_connect_file_transfer_policy_preserves_name_fallback\b/, + message: 'missing remote file display-name test', + }, + { + regex: /\bremote_connect_file_path_resolution_stays_within_workspace_root\b/, + message: 'missing remote file path resolution test', + }, + { + regex: /\bremote_connect_file_read_helpers_preserve_current_wire_inputs\b/, + message: 'missing remote full-read helper test', + }, + { + regex: /\bremote_connect_file_chunk_and_info_helpers_preserve_response_facts\b/, + message: 'missing remote chunk/info helper test', + }, + { + regex: /\bremote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finalizes\b/, + message: 'missing tracker completion contract test', + }, + { + regex: /\bremote_connect_tracker_registry_owns_lifecycle_without_core_state\b/, + message: 'missing tracker registry owner test', + }, + { + regex: /\bremote_connect_tracker_ignores_unrelated_direct_session_events\b/, + message: 'missing tracker unrelated-event guard test', + }, + { + regex: /\bremote_connect_tool_preview_slimming_keeps_short_fields_and_drops_large_strings\b/, + message: 'missing remote tool preview slimming test', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_connect/remote_server.rs', + reason: + 'core remote-connect server must remain a product runtime adapter around integrations-owned contracts', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner routing', + }, + { + regex: /\bstruct CoreRemoteDialogRuntimeHost\b/, + message: 'missing core remote dialog runtime adapter', + }, + { + regex: /\bimpl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost\b/, + message: 'missing integrations dialog host adapter implementation', + }, + { + regex: /\bsubmit_remote_dialog\b/, + message: 'missing remote dialog owner orchestration delegation', + }, + { + regex: /\bread_remote_workspace_file\b/, + message: 'missing remote file full-read helper delegation', + }, + { + regex: /\bread_remote_workspace_file_chunk\b/, + message: 'missing remote file chunk helper delegation', + }, + { + regex: /\bread_remote_workspace_file_info\b/, + message: 'missing remote file info helper delegation', + }, + { + regex: /\bremote_image_context\b/, + message: 'missing image context adapter contract delegation', + }, + { + regex: /\bcore_service_agent_runtime_owner_maps_remote_image_context\b/, + message: 'missing core service/agent image-context owner regression', + }, + { + regex: /\bremote_execution_prefers_unified_image_contexts_over_legacy_images\b/, + message: 'missing unified image context preference regression', + }, + { + regex: /\bremote_execution_falls_back_to_legacy_images_as_image_contexts\b/, + message: 'missing legacy image context fallback regression', + }, + { + regex: /\bremote_cancel_decision_preserves_current_turn_boundaries\b/, + message: 'missing remote cancel boundary regression', + }, + { + regex: /\bremote_restore_target_only_restores_cold_sessions_with_workspace_binding\b/, + message: 'missing remote restore target regression', + }, + { + regex: /\bremote_command_snapshot_covers_execution_poll_and_cancel_surfaces\b/, + message: 'missing remote command snapshot regression', + }, + { + regex: /\bremote_response_snapshot_preserves_active_turn_and_result_shapes\b/, + message: 'missing remote response snapshot regression', + }, + ], + }, + { + path: 'src/crates/core/src/service/remote_connect/bot/command_router.rs', + reason: + 'remote-connect bot must route concrete agent runtime port bindings through the core service/agent runtime owner', + patterns: [ + { + regex: /\bCoreServiceAgentRuntime\b/, + message: 'missing core service/agent runtime owner routing', + }, + { + regex: /\bagent_submission_port\b/, + message: 'missing agent submission port owner binding', + }, + { + regex: /\bbuild_remote_session_create_request\b/, + message: 'missing integrations-owned remote session create request builder', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + reason: + 'core scheduler keeps remote queue policy semantics until agent-runtime migration is reviewed', + patterns: [ + { + regex: /\bremote_queue_policy_preserves_interactive_preempt_and_confirmation_boundary\b/, + message: 'missing remote queue policy regression', }, ], }, @@ -1388,8 +2236,8 @@ const requiredContentRules = [ message: 'missing product decorator ref alias using agent-tools contract', }, { - regex: /\bProductToolRuntimeAssembly\b/, - message: 'missing product tool runtime assembly delegation', + regex: /\bProductToolRuntime\b/, + message: 'missing product tool runtime owner delegation', }, { regex: /\bget_collapsed_tool_names\b/, @@ -1400,7 +2248,7 @@ const requiredContentRules = [ message: 'missing product tool catalog readonly facade delegation', }, { - regex: /\bregistry_preserves_collapsed_tool_manifest_for_owner_migration\b/, + regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, message: 'missing collapsed-tool manifest migration baseline', }, { @@ -1410,13 +2258,13 @@ const requiredContentRules = [ ], }, { - path: 'src/crates/core/src/agentic/tools/runtime_assembly.rs', + path: 'src/crates/core/src/agentic/tools/product_runtime.rs', reason: - 'core must keep product tool runtime assembly explicit until ToolUseContext and concrete tool-pack migration are reviewed', + 'core product tool runtime owner keeps registry assembly, static tool materialization, catalog manifests, and GetToolSpec facades explicit until concrete tools migrate', patterns: [ { - regex: /\bProductToolRuntimeAssembly\b/, - message: 'missing core product tool runtime assembly owner', + regex: /\bProductToolRuntime\b/, + message: 'missing core product tool runtime owner', }, { regex: /\bSnapshotToolDecorator\b/, @@ -1430,29 +2278,6 @@ const requiredContentRules = [ regex: /\bbuiltin_static_tool_providers\b/, message: 'missing builtin provider assembly input', }, - { - regex: /\bToolRuntimeAssembly\b/, - message: 'missing generic agent-tools runtime assembly delegation', - }, - { - regex: /\bcreate_registry_from_static_providers\b/, - message: 'missing generic static provider assembly delegation', - }, - { - regex: /\bwrap_tool_for_snapshot_tracking\b/, - message: 'missing snapshot wrapper boundary', - }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/static_providers.rs', - reason: - 'core materializes concrete tools from the tool-pack provider plan until concrete tools migrate', - patterns: [ - { - regex: /\bbuiltin_static_tool_providers\b/, - message: 'missing builtin static tool provider owner', - }, { regex: /\bStaticToolProviderGroup\b/, message: 'missing generic static provider group contract use', @@ -1469,48 +2294,18 @@ const requiredContentRules = [ regex: /\bGetToolSpecTool::new\(\)/, message: 'missing GetToolSpec registration anchor', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', - reason: - 'core must keep the product Tool-to-agent-tools adapters explicit until ToolUseContext and concrete tools migrate', - patterns: [ - { - regex: /\bimpl ToolRegistryItem for dyn Tool\b/, - message: 'missing core Tool registry adapter', - }, - { - regex: /\bimpl ContextualToolManifestItem for dyn Tool\b/, - message: 'missing core ToolUseContext contextual manifest adapter', - }, - { - regex: /\bTool::dynamic_tool_info\b/, - message: 'missing dynamic tool metadata adapter', - }, - { - regex: /\bTool::is_readonly\b/, - message: 'missing readonly metadata adapter', - }, { - regex: /\bTool::is_enabled\b/, - message: 'missing enabled-state metadata adapter', + regex: /\bToolRuntimeAssembly\b/, + message: 'missing generic agent-tools runtime assembly delegation', }, { - regex: /\bTool::description_with_context\b/, - message: 'missing context-aware tool description adapter', + regex: /\bcreate_registry_from_static_providers\b/, + message: 'missing generic static provider assembly delegation', }, { - regex: /\bTool::input_schema_for_model_with_context\b/, - message: 'missing context-aware tool schema adapter', + regex: /\bwrap_tool_for_snapshot_tracking\b/, + message: 'missing snapshot wrapper boundary', }, - ], - }, - { - path: 'src/crates/core/src/agentic/tools/catalog_provider.rs', - reason: - 'core must keep the product tool catalog provider explicit until ToolUseContext, manifest assembly, and GetToolSpec execution migration are reviewed', - patterns: [ { regex: /\bProductToolCatalogProvider\b/, message: 'missing core product tool catalog provider owner', @@ -1567,12 +2362,51 @@ const requiredContentRules = [ regex: /\bproduct_catalog_provider_default_get_tool_spec_catalog_matches_registry\b/, message: 'missing product catalog provider collapsed catalog regression', }, + { + regex: /\bproduct_tool_runtime_owner_preserves_registry_contract\b/, + message: 'missing product runtime owner registry equivalence regression', + }, { regex: /\bGetToolSpec requires agent type context\b/, message: 'missing contextual GetToolSpec catalog validation boundary', }, ], }, + { + path: 'src/crates/core/src/agentic/tools/tool_adapter.rs', + reason: + 'core must keep the product Tool-to-agent-tools adapters explicit until ToolUseContext and concrete tools migrate', + patterns: [ + { + regex: /\bimpl ToolRegistryItem for dyn Tool\b/, + message: 'missing core Tool registry adapter', + }, + { + regex: /\bimpl ContextualToolManifestItem for dyn Tool\b/, + message: 'missing core ToolUseContext contextual manifest adapter', + }, + { + regex: /\bTool::dynamic_tool_info\b/, + message: 'missing dynamic tool metadata adapter', + }, + { + regex: /\bTool::is_readonly\b/, + message: 'missing readonly metadata adapter', + }, + { + regex: /\bTool::is_enabled\b/, + message: 'missing enabled-state metadata adapter', + }, + { + regex: /\bTool::description_with_context\b/, + message: 'missing context-aware tool description adapter', + }, + { + regex: /\bTool::input_schema_for_model_with_context\b/, + message: 'missing context-aware tool schema adapter', + }, + ], + }, { path: 'src/crates/agent-tools/src/framework.rs', reason: 'agent-tools owns portable tool facts plus generic registry and provider contracts', @@ -1763,14 +2597,85 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/agentic/tools/tool_context_runtime.rs', + reason: + 'core must keep ToolUseContext runtime/service bindings centralized while ToolUseContext and concrete tools remain core-owned', + patterns: [ + { + regex: /\bimpl ToolUseContext\b/, + message: 'missing ToolUseContext runtime binding owner impl', + }, + { + regex: /\brecord_light_checkpoint\b/, + message: 'missing Deep Review checkpoint binding', + }, + { + regex: /\bcall_with_tool_runtime_hooks\b/, + message: 'missing tool-call cancellation/post-call hook binding', + }, + { + regex: /\bbuild_tool_use_context_for_task\b/, + message: 'missing tool pipeline context materialization binding', + }, + { + regex: /\bbuild_tool_description_context\b/, + message: 'missing tool manifest description context materialization binding', + }, + { + regex: /\bbuild_write_preflight_context\b/, + message: 'missing write preflight context materialization binding', + }, + { + regex: /\bensure_current_workspace_runtime\b/, + message: 'missing workspace runtime ensure binding', + }, + { + regex: /\bresolve_tool_path\b/, + message: 'missing tool path resolution binding', + }, + { + regex: /\benforce_path_operation\b/, + message: 'missing runtime path policy binding', + }, + { + regex: /\bworkspace_path_resolution_rejects_absolute_paths_outside_remote_workspace\b/, + message: 'missing remote workspace containment regression', + }, + { + regex: /\bruntime_uri_resolution_rejects_different_workspace_scope\b/, + message: 'missing runtime artifact scope regression', + }, + { + regex: /\bpath_policy_allows_only_configured_local_roots\b/, + message: 'missing path policy enforcement regression', + }, + { + regex: /\btool_call_runtime_hook_returns_cancelled_before_impl_completes\b/, + message: 'missing tool-call cancellation regression', + }, + { + regex: /\btool_task_context_materialization_preserves_runtime_fields\b/, + message: 'missing tool task context materialization regression', + }, + { + regex: /\btool_description_context_preserves_manifest_custom_data_shape\b/, + message: 'missing tool description context regression', + }, + { + regex: /\bwrite_preflight_context_preserves_minimal_runtime_fields\b/, + message: 'missing write preflight context regression', + }, + ], + }, { path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', reason: - 'core must continue owning collapsed-tool execution gating until manifest/runtime migration is reviewed', + 'core must continue carrying collapsed-tool unlock state while delegating provider-neutral execution gate policy to agent-tools', patterns: [ { regex: /\bfn validate_collapsed_tool_usage\b/, - message: 'missing collapsed-tool execution gate', + message: 'missing collapsed-tool execution gate compatibility wrapper', }, { regex: /\bunlocked_collapsed_tools\b/, @@ -1941,11 +2846,11 @@ const requiredContentRules = [ 'core scheduler must continue owning background subagent result delivery until running-turn and idle-session routing equivalence tests exist', patterns: [ { - regex: /\bdeliver_background_subagent_result\b/, + regex: /\bdeliver_background_result\b/, message: 'missing background subagent delivery entry point', }, { - regex: /RoundInjectionKind::BackgroundSubagentResult/, + regex: /RoundInjectionKind::BackgroundResult/, message: 'missing running-turn background result injection', }, { @@ -2104,6 +3009,44 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/service/search/mod.rs', + reason: + 'remote workspace search must route to the real implementation only when ssh-remote is enabled', + patterns: [ + { + regex: /#\[cfg\(feature = "ssh-remote"\)\]\s*mod remote\b/s, + message: 'missing ssh-remote gate for real remote search implementation', + }, + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*mod remote_disabled\b/s, + message: 'missing disabled remote search implementation for no-default builds', + }, + { + regex: /#\[cfg\(not\(feature = "ssh-remote"\)\)\]\s*pub use remote_disabled/s, + message: 'missing disabled remote search export', + }, + ], + }, + { + path: 'src/crates/core/src/service/search/remote_disabled.rs', + reason: + 'no-default core builds must keep remote search unavailable with an explicit diagnostic', + patterns: [ + { + regex: /Remote SSH search is disabled; enable the `ssh-remote` feature/, + message: 'missing explicit disabled remote search diagnostic', + }, + { + regex: /\bpub struct RemoteWorkspaceSearchService\b/, + message: 'missing disabled remote workspace search service surface', + }, + { + regex: /\bremote_workspace_search_service_for_path\b/, + message: 'missing disabled remote workspace search resolver', + }, + ], + }, { path: 'src/crates/acp/src/client/manager.rs', reason: @@ -2623,6 +3566,10 @@ const requiredContentRules = [ regex: /\bMiniAppRuntimeFacade\b/, message: 'missing product-domain MiniApp runtime-state facade use', }, + { + regex: /\bCoreProductDomainRuntime\b/, + message: 'missing core-owned product-domain runtime owner delegation', + }, { regex: /\bpersist_sync_from_fs_result_for_app\b/, message: 'missing product-domain MiniApp sync-from-fs facade delegation', @@ -2737,19 +3684,23 @@ const requiredContentRules = [ { path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', reason: - 'Git function-agent commit generation must route through the product-domain runtime facade while core keeps concrete adapters', + 'Git function-agent commit generation must route through the core product-domain runtime owner while core keeps concrete adapters', patterns: [ { - regex: /\bFunctionAgentRuntimeFacade\b/, - message: 'missing product-domain function-agent runtime facade routing', + regex: /\bCoreProductDomainRuntime\b/, + message: 'missing core product-domain runtime owner routing', }, { - regex: /\bCoreFunctionAgentGitAdapter\b/, - message: 'missing core-owned Git adapter wiring', + regex: /\bfunction_agent_git_adapter\b/, + message: 'missing core-owned Git adapter factory wiring', }, { - regex: /\bCoreFunctionAgentAiAdapter\b/, - message: 'missing core-owned AI adapter wiring', + regex: /\bfunction_agent_ai_adapter\b/, + message: 'missing core-owned AI adapter factory wiring', + }, + { + regex: /\bfunction_agent_runtime_facade\b/, + message: 'missing product-domain function-agent runtime facade owner routing', }, ], }, @@ -2823,19 +3774,23 @@ const requiredContentRules = [ { path: 'src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', reason: - 'Startchat work-state analysis must route through the product-domain runtime facade while core keeps concrete adapters', + 'Startchat work-state analysis must route through the core product-domain runtime owner while core keeps concrete adapters', patterns: [ { - regex: /\bFunctionAgentRuntimeFacade\b/, - message: 'missing product-domain function-agent runtime facade routing', + regex: /\bCoreProductDomainRuntime\b/, + message: 'missing core product-domain runtime owner routing', }, { - regex: /\bCoreFunctionAgentGitAdapter\b/, - message: 'missing core-owned Git adapter wiring', + regex: /\bfunction_agent_git_adapter\b/, + message: 'missing core-owned Git adapter factory wiring', }, { - regex: /\bCoreFunctionAgentAiAdapter\b/, - message: 'missing core-owned AI adapter wiring', + regex: /\bfunction_agent_ai_adapter\b/, + message: 'missing core-owned AI adapter factory wiring', + }, + { + regex: /\bfunction_agent_runtime_facade\b/, + message: 'missing product-domain function-agent runtime facade owner routing', }, ], }, @@ -3024,6 +3979,61 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/product_domain_runtime.rs', + reason: + 'core product-domain runtime owner must centralize concrete MiniApp and function-agent runtime port bindings without moving runtime behavior', + patterns: [ + { + regex: /\bpub\(crate\) struct CoreProductDomainRuntime\b/, + message: 'missing core product-domain runtime owner type', + }, + { + regex: /\bfn miniapp_runtime_facade\b/, + message: 'missing MiniApp runtime facade owner factory', + }, + { + regex: /\bfn function_agent_git_adapter\b/, + message: 'missing function-agent Git adapter owner factory', + }, + { + regex: /\bfn function_agent_ai_adapter\b/, + message: 'missing function-agent AI adapter owner factory', + }, + { + regex: /\bfn function_agent_runtime_facade\b/, + message: 'missing function-agent runtime facade owner factory', + }, + { + regex: /\bCoreFunctionAgentGitAdapter\b/, + message: 'missing core-owned Git adapter binding', + }, + { + regex: /\bCoreFunctionAgentAiAdapter\b/, + message: 'missing core-owned AI adapter binding', + }, + { + regex: /\bMiniAppRuntimeFacade\b/, + message: 'missing MiniApp product-domain facade binding', + }, + { + regex: /\bMiniAppStoragePort\b/, + message: 'missing MiniApp storage port owner binding', + }, + { + regex: /\bFunctionAgentRuntimeFacade\b/, + message: 'missing function-agent product-domain facade binding', + }, + { + regex: /\bFunctionAgentGitPort\b/, + message: 'missing function-agent Git port owner binding', + }, + { + regex: /\bFunctionAgentAiPort\b/, + message: 'missing function-agent AI port owner binding', + }, + ], + }, ]; const failures = []; @@ -3088,6 +4098,7 @@ function parseManifestDependencies(lines) { } if (currentInline) { + currentInline.text.push(trimmed); if (/\boptional\s*=\s*true\b/.test(trimmed)) { currentInline.optional = true; } @@ -3107,6 +4118,7 @@ function parseManifestDependencies(lines) { name: depName, line: index + 1, optional: false, + text: [trimmed], }; deps.push(currentTable); break; @@ -3115,32 +4127,137 @@ function parseManifestDependencies(lines) { return; } - if (currentTable && /\boptional\s*=\s*true\b/.test(trimmed)) { - currentTable.optional = true; + if (currentTable) { + currentTable.text.push(trimmed); + if (/\boptional\s*=\s*true\b/.test(trimmed)) { + currentTable.optional = true; + } + return; + } + + if (!inDependencyList) { + return; + } + + const inlineMatch = trimmed.match(/^([A-Za-z0-9_-]+|"[A-Za-z0-9_-]+")\s*=/); + if (inlineMatch) { + const name = inlineMatch[1].replace(/^"|"$/g, ''); + deps.push({ + name, + line: index + 1, + optional: /\boptional\s*=\s*true\b/.test(trimmed), + text: [trimmed], + }); + if (trimmed.includes('{') && !trimmed.includes('}')) { + currentInline = deps[deps.length - 1]; + } + return; + } + + }); + + return deps; +} + +function manifestDependencyText(dep) { + return dep?.text?.join('\n') ?? ''; +} + +function manifestDependencyDisablesDefaultFeatures(dep) { + return /\bdefault-features\s*=\s*false\b/.test(manifestDependencyText(dep)); +} + +function parseManifestDependencyFeatureNames(dep) { + const features = new Set(); + const text = manifestDependencyText(dep); + for (const match of text.matchAll(/\bfeatures\s*=\s*\[([\s\S]*?)\]/g)) { + for (const featureMatch of match[1].matchAll(/"([^"]+)"/g)) { + features.add(featureMatch[1]); + } + } + return features; +} + +function collectProductCoreDependencyManifestPaths(manifestEntries) { + return manifestEntries + .filter((entry) => { + const deps = parseManifestDependencies(entry.text.split(/\r?\n/)); + return deps.some((dep) => dep.name === 'bitfun-core'); + }) + .map((entry) => entry.manifestPath) + .sort(); +} + +function collectProductCoreDependencyManifests(scanRoots = productCoreFeatureAssemblyScanRoots) { + const manifestEntries = []; + for (const repoDir of scanRoots) { + const dir = join(ROOT, ...repoDir.split('/')); + walkFiles(dir, (path) => { + if (!path.endsWith('Cargo.toml')) { + return; + } + manifestEntries.push({ + manifestPath: toRepoPath(path), + text: readText(path), + }); + }); + } + return collectProductCoreDependencyManifestPaths(manifestEntries); +} + +function parseManifestFeatures(lines) { + const features = new Map(); + let inFeatures = false; + let currentFeature = null; + + const appendRefs = (feature, text) => { + const refs = [...text.matchAll(/"([^"]+)"/g)].map((match) => match[1]); + feature.refs.push(...refs); + }; + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || trimmed === '') { + return; + } + + const headerMatch = trimmed.match(/^\[(.+)]$/); + if (headerMatch) { + inFeatures = trimmed === '[features]'; + currentFeature = null; return; } - if (!inDependencyList) { + if (!inFeatures) { return; } - const inlineMatch = trimmed.match(/^([A-Za-z0-9_-]+|"[A-Za-z0-9_-]+")\s*=/); - if (inlineMatch) { - const name = inlineMatch[1].replace(/^"|"$/g, ''); - deps.push({ - name, - line: index + 1, - optional: /\boptional\s*=\s*true\b/.test(trimmed), - }); - if (trimmed.includes('{') && !trimmed.includes('}')) { - currentInline = deps[deps.length - 1]; + if (currentFeature) { + appendRefs(currentFeature, trimmed); + if (trimmed.includes(']')) { + currentFeature = null; } return; } + const featureMatch = trimmed.match(/^([A-Za-z0-9_-]+)\s*=\s*(.*)$/); + if (!featureMatch) { + return; + } + + const feature = { + name: featureMatch[1], + line: index + 1, + refs: [], + }; + appendRefs(feature, featureMatch[2]); + features.set(feature.name, feature); + if (featureMatch[2].includes('[') && !featureMatch[2].includes(']')) { + currentFeature = feature; + } }); - return deps; + return features; } function collectKnownDependencyNames() { @@ -3149,6 +4266,10 @@ function collectKnownDependencyNames() { 'bitfun-core', ...lightweightBoundaryRules.flatMap((rule) => rule.forbiddenDeps), ...dependencyProfileRules.flatMap((rule) => rule.forbiddenNonOptionalDeps), + ...optionalDependencyFeatureOwnerRules.flatMap((rule) => + rule.dependencies.map((dependency) => dependency.depName), + ), + ...productCoreFeatureAssemblyRules.map((rule) => rule.dependencyName), ]), ); } @@ -3186,6 +4307,7 @@ function runManifestParserSelfTest() { 'rmcp = { version = "0.12.0", default-features = false, features = [', ' "auth",', '], optional = true }', + 'bitfun-core = { path = "../core", default-features = false, features = ["product-full"] }', '[dependencies.git2]', 'workspace = true', 'optional = true', @@ -3210,10 +4332,111 @@ function runManifestParserSelfTest() { if (parsedByName.get('bitfun-cli')?.optional !== false) { throw new Error('dependency profile parser must detect non-optional target dependency tables'); } + const parsedCoreDep = parsedByName.get('bitfun-core'); + if (!manifestDependencyDisablesDefaultFeatures(parsedCoreDep)) { + throw new Error('dependency profile parser must detect default-features = false'); + } + if (!parseManifestDependencyFeatureNames(parsedCoreDep).has('product-full')) { + throw new Error('dependency profile parser must detect inline dependency features'); + } + const parsedCoreTableDeps = parseManifestDependencies([ + '[dependencies."bitfun-core"]', + 'path = "../core"', + 'default-features = false', + 'features = [', + ' "product-full",', + ' "ssh-remote",', + ']', + ]); + const parsedCoreTableDep = parsedCoreTableDeps.find((dep) => dep.name === 'bitfun-core'); + if (!manifestDependencyDisablesDefaultFeatures(parsedCoreTableDep)) { + throw new Error('dependency profile parser must detect table default-features = false'); + } + if (!parseManifestDependencyFeatureNames(parsedCoreTableDep).has('ssh-remote')) { + throw new Error('dependency profile parser must detect table dependency features'); + } if (parsedByName.has('image')) { throw new Error('dependency profile parser must ignore feature entries named like dependencies'); } + const productCoreRulePaths = new Set( + productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath), + ); + for (const manifestPath of [ + 'src/apps/desktop/Cargo.toml', + 'src/apps/cli/Cargo.toml', + 'src/crates/acp/Cargo.toml', + ]) { + if (!productCoreRulePaths.has(manifestPath)) { + throw new Error(`product core feature assembly rule must cover ${manifestPath}`); + } + } + for (const rule of productCoreFeatureAssemblyRules) { + if (!rule.requiredFeatures.includes('product-full')) { + throw new Error(`${rule.manifestPath} must require bitfun-core product-full`); + } + } + const discoveredProductCoreManifests = collectProductCoreDependencyManifestPaths([ + { + manifestPath: 'src/apps/desktop/Cargo.toml', + text: + '[dependencies]\nbitfun-core = { path = "../../crates/core", default-features = false, features = ["product-full"] }', + }, + { + manifestPath: 'src/apps/server/Cargo.toml', + text: '[dependencies]\naxum = { workspace = true }', + }, + { + manifestPath: 'src/crates/acp/Cargo.toml', + text: '[dependencies."bitfun-core"]\npath = "../core"\ndefault-features = false\nfeatures = ["product-full"]', + }, + ]); + if (discoveredProductCoreManifests.join(',') !== 'src/apps/desktop/Cargo.toml,src/crates/acp/Cargo.toml') { + throw new Error('product core dependency scanner must discover only manifests that depend on bitfun-core'); + } + const ownerFeatureRulePaths = new Set( + ownerCrateFeatureAssemblyRules.map((rule) => rule.manifestPath), + ); + for (const manifestPath of [ + 'src/crates/tool-packs/Cargo.toml', + 'src/crates/services-integrations/Cargo.toml', + 'src/crates/product-domains/Cargo.toml', + ]) { + if (!ownerFeatureRulePaths.has(manifestPath)) { + throw new Error(`owner crate feature assembly rule must cover ${manifestPath}`); + } + } + + const parsedFeatures = parseManifestFeatures([ + '[package]', + 'name = "example"', + '[features]', + 'default = ["product-full"]', + 'product-full = [', + ' "dep:tool-runtime",', + ' "service-integrations",', + ']', + 'service-integrations = ["dep:git2", "dep:rmcp"]', + 'ssh-remote = [', + ' "russh",', + ' "russh-sftp",', + ']', + '[dependencies]', + 'git2 = { workspace = true, optional = true }', + ]); + if (!parsedFeatures.get('default')?.refs.includes('product-full')) { + throw new Error('feature parser must detect inline feature references'); + } + if (!parsedFeatures.get('product-full')?.refs.includes('dep:tool-runtime')) { + throw new Error('feature parser must detect multiline dependency feature references'); + } + if (!parsedFeatures.get('service-integrations')?.refs.includes('dep:rmcp')) { + throw new Error('feature parser must detect inline dependency feature references'); + } + if (!parsedFeatures.get('ssh-remote')?.refs.includes('russh-sftp')) { + throw new Error('feature parser must detect implicit optional dependency feature references'); + } + const acceptsGitFacadeLine = createFacadeLineChecker('bitfun_services_integrations::git'); const facadePositiveCases = [ '', @@ -3270,6 +4493,12 @@ function runManifestParserSelfTest() { 'ToolRenderOptions', 'ToolPathBackend', 'ToolPathResolution', + 'get_global_coordinator', + 'GitService', + 'get_workspace_runtime_service_arc', + 'remote_workspace_runtime_root', + 'get_path_manager_arc', + 'post_call_hooks::record_successful_tool_call', ]; const coreToolFrameworkRuleText = coreToolFrameworkRule.patterns .map((pattern) => pattern.regex.source) @@ -3289,6 +4518,7 @@ function runManifestParserSelfTest() { 'ToolPathOperation', 'ToolPathPolicy', 'ToolRuntimeRestrictions', + 'normalize_absolute_posix_path', ]; const coreToolRestrictionRuleText = coreToolRestrictionRule.patterns .map((pattern) => pattern.regex.source) @@ -3298,6 +4528,26 @@ function runManifestParserSelfTest() { throw new Error(`core tool restrictions boundary rule must forbid contract: ${contract}`); } } + const coreWorkspacePathRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/agentic/tools/workspace_paths.rs', + ); + if (!coreWorkspacePathRule) { + throw new Error('missing core workspace path boundary rule'); + } + const coreWorkspacePathContracts = [ + 'BITFUN_RUNTIME_URI_PREFIX', + 'ParsedBitFunRuntimeUri', + 'posix_normalize_components', + 'Component::ParentDir', + ]; + const coreWorkspacePathRuleText = coreWorkspacePathRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of coreWorkspacePathContracts) { + if (!coreWorkspacePathRuleText.includes(contract)) { + throw new Error(`core workspace path boundary rule must forbid contract: ${contract}`); + } + } const coreToolRegistryRule = forbiddenContentRules.find( (rule) => rule.path === 'src/crates/core/src/agentic/tools/registry.rs', ); @@ -3324,6 +4574,26 @@ function runManifestParserSelfTest() { if (!productDomainProfile?.forbiddenNonOptionalDeps.includes('dirs')) { throw new Error('product-domains default profile must forbid non-optional dirs'); } + const coreProfile = dependencyProfileRules.find((rule) => rule.crateName === 'core'); + for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + if (!coreProfile?.forbiddenNonOptionalDeps.includes(dep)) { + throw new Error(`core no-default profile must forbid non-optional ${dep}`); + } + } + const coreOptionalOwnerRule = optionalDependencyFeatureOwnerRules.find( + (rule) => rule.crateName === 'core', + ); + for (const dep of ['git2', 'rmcp', 'image', 'tool-runtime', 'bitfun-relay-server']) { + if (!coreOptionalOwnerRule?.dependencies.some((dependency) => dependency.depName === dep)) { + throw new Error(`core optional dependency owner rule must cover ${dep}`); + } + } + const coreGit2Owner = coreOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === 'git2', + ); + if (!coreGit2Owner?.ownerFeatures.includes('service-integrations')) { + throw new Error('core optional dependency owner rule must keep git2 under service-integrations'); + } const productDomainRuntimeRule = forbiddenContentUnderRules.find( (rule) => rule.path === 'src/crates/product-domains/src', ); @@ -3454,6 +4724,10 @@ function runManifestParserSelfTest() { 'call_results', 'GetToolSpecLoadObservation', 'collect_loaded_collapsed_tool_names', + 'CollapsedToolUsageError', + 'ToolExecutionAccessError', + 'validate_tool_allowed_by_list', + 'validate_collapsed_tool_usage', 'sort_tool_manifest_definitions', 'is_tool_collapsed', 'get_collapsed_tool_names', @@ -3469,6 +4743,24 @@ function runManifestParserSelfTest() { 'generic attachments', ], }, + { + path: 'src/crates/core/src/service_agent_runtime.rs', + contracts: [ + 'CoreServiceAgentRuntime', + 'remote_dialog_host', + 'remote_image_context', + 'agent_submission_port', + 'CoreRemoteDialogRuntimeHost', + 'RemoteExecutionDispatcher', + 'ImageContextData', + 'RemoteImageContextAdapter', + 'AgentSubmissionPort', + 'AgentTurnCancellationPort', + 'RemoteControlStatePort', + 'SessionTranscriptReader', + 'core_service_agent_runtime_owner_keeps_coordinator_port_contracts', + ], + }, { path: 'src/crates/services-integrations/src/remote_connect.rs', contracts: [ @@ -3523,6 +4815,9 @@ function runManifestParserSelfTest() { { path: 'src/crates/core/src/service/remote_connect/remote_server.rs', contracts: [ + 'CoreServiceAgentRuntime', + 'remote_image_context', + 'core_service_agent_runtime_owner_maps_remote_image_context', 'remote_execution_prefers_unified_image_contexts_over_legacy_images', 'remote_cancel_decision_preserves_current_turn_boundaries', 'remote_restore_target_only_restores_cold_sessions_with_workspace_binding', @@ -3539,31 +4834,41 @@ function runManifestParserSelfTest() { contracts: [ 'from_inner', 'ProductToolDecoratorRef', - 'ProductToolRuntimeAssembly', + 'ProductToolRuntime', 'get_collapsed_tool_names', 'resolve_product_readonly_enabled_tools', ], }, { - path: 'src/crates/core/src/agentic/tools/runtime_assembly.rs', + path: 'src/crates/core/src/agentic/tools/product_runtime.rs', contracts: [ - 'ProductToolRuntimeAssembly', + 'ProductToolRuntime', 'SnapshotToolDecorator', 'ProductSnapshotToolWrapper', - 'builtin_static_tool_providers', - 'ToolRuntimeAssembly', - 'create_registry_from_static_providers', - 'wrap_tool_for_snapshot_tracking', - ], - }, - { - path: 'src/crates/core/src/agentic/tools/static_providers.rs', - contracts: [ 'builtin_static_tool_providers', 'StaticToolProviderGroup', 'product_tool_provider_group_plan', 'materialize_tool', 'GetToolSpecTool', + 'ToolRuntimeAssembly', + 'create_registry_from_static_providers', + 'wrap_tool_for_snapshot_tracking', + 'ProductToolCatalogProvider', + 'ToolCatalogSnapshotProvider', + 'GetToolSpecCatalogProvider', + 'get_global_tool_registry', + 'get_agent_registry', + 'ToolCatalogRuntime', + 'product_tool_catalog_runtime', + 'GetToolSpecRuntime', + 'product_get_tool_spec_runtime', + 'resolve_product_tool_manifest', + 'resolve_product_readonly_enabled_tools', + 'resolve_product_get_tool_spec_results', + 'unlocked_collapsed_tools', + 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', + 'product_tool_runtime_owner_preserves_registry_contract', + 'GetToolSpec requires agent type context', ], }, { @@ -3612,26 +4917,6 @@ function runManifestParserSelfTest() { 'Tool::input_schema_for_model_with_context', ], }, - { - path: 'src/crates/core/src/agentic/tools/catalog_provider.rs', - contracts: [ - 'ProductToolCatalogProvider', - 'ToolCatalogSnapshotProvider', - 'GetToolSpecCatalogProvider', - 'get_global_tool_registry', - 'get_agent_registry', - 'ToolCatalogRuntime', - 'product_tool_catalog_runtime', - 'GetToolSpecRuntime', - 'product_get_tool_spec_runtime', - 'resolve_product_tool_manifest', - 'resolve_product_readonly_enabled_tools', - 'resolve_product_get_tool_spec_results', - 'unlocked_collapsed_tools', - 'product_catalog_provider_default_get_tool_spec_catalog_matches_registry', - 'GetToolSpec requires agent type context', - ], - }, { path: 'src/crates/core/src/agentic/tools/manifest_resolver.rs', contracts: [ @@ -3665,6 +4950,27 @@ function runManifestParserSelfTest() { 'unlocked_collapsed_tools', ], }, + { + path: 'src/crates/core/src/agentic/tools/tool_context_runtime.rs', + contracts: [ + 'impl ToolUseContext', + 'record_light_checkpoint', + 'call_with_tool_runtime_hooks', + 'build_tool_use_context_for_task', + 'build_tool_description_context', + 'build_write_preflight_context', + 'ensure_current_workspace_runtime', + 'resolve_tool_path', + 'enforce_path_operation', + 'workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace', + 'runtime_uri_resolution_rejects_different_workspace_scope', + 'path_policy_allows_only_configured_local_roots', + 'tool_call_runtime_hook_returns_cancelled_before_impl_completes', + 'tool_task_context_materialization_preserves_runtime_fields', + 'tool_description_context_preserves_manifest_custom_data_shape', + 'write_preflight_context_preserves_minimal_runtime_fields', + ], + }, { path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', contracts: ['validate_collapsed_tool_usage', 'unlocked_collapsed_tools', 'GetToolSpec'], @@ -3706,8 +5012,8 @@ function runManifestParserSelfTest() { { path: 'src/crates/core/src/agentic/coordination/scheduler.rs', contracts: [ - 'deliver_background_subagent_result', - 'BackgroundSubagentResult', + 'deliver_background_result', + 'BackgroundResult', 'CurrentRunningTurn', 'AgentSession', ], @@ -3748,6 +5054,63 @@ function runManifestParserSelfTest() { path: 'src/crates/core/src/service/search/remote.rs', contracts: ['remote_workspace_search_service_for_path', 'lookup_remote_connection_with_hint', 'allow_scan_fallback', 'fallback_query'], }, + { + path: 'src/crates/core/src/service/search/mod.rs', + contracts: ['mod remote_disabled', 'feature = "ssh-remote"', 'pub use remote_disabled'], + }, + { + path: 'src/crates/core/src/service/search/remote_disabled.rs', + contracts: ['Remote SSH search is disabled', 'RemoteWorkspaceSearchService', 'remote_workspace_search_service_for_path'], + }, + { + path: 'src/crates/core/Cargo.toml', + contracts: [ + 'bitfun-tool-packs = \\{ path = "\\.\\.\\/tool-packs", default-features = false, optional = true \\}', + 'bitfun-services-integrations = \\{ path = "\\.\\.\\/services-integrations", default-features = false, features = \\["remote-ssh"\\] \\}', + 'bitfun-product-domains = \\{ path = "\\.\\.\\/product-domains", default-features = false, optional = true \\}', + 'dep:bitfun-tool-packs', + 'bitfun-tool-packs\\/product-full', + 'bitfun-services-integrations\\/product-full', + 'dep:bitfun-product-domains', + 'bitfun-product-domains\\/product-full', + ], + }, + { + path: 'src/crates/core/src/lib.rs', + contracts: [ + 'feature = "product-full"', + 'pub mod agentic', + 'feature = "product-domains"', + 'pub mod function_agents', + 'pub mod miniapp', + 'feature = "service-integrations"', + 'service_agent_runtime', + ], + }, + { + path: 'src/crates/core/src/service/mod.rs', + contracts: [ + 'feature = "service-integrations"', + 'pub mod git', + 'pub mod mcp', + 'pub mod remote_connect', + 'pub mod review_platform', + 'feature = "product-full"', + 'pub mod snapshot', + ], + }, + { + path: 'src/crates/core/src/service/config/mod.rs', + contracts: ['feature = "product-full"', 'mode_config_canonicalizer'], + }, + { + path: 'src/crates/core/src/service/workspace/manager.rs', + contracts: ['feature = "service-integrations"', 'GitService', 'return None'], + }, + { + path: 'src/crates/core/src/service/workspace_runtime/service.rs', + contracts: ['feature = "product-full"', 'WorkspaceBinding', 'ensure_runtime_for_workspace_binding'], + }, { path: 'src/crates/acp/src/client/manager.rs', contracts: ['CLIENT_STARTUP_TIMEOUT_SECS', 'startup_timeout_error_message', 'formats_startup_timeout_error_message'], @@ -3860,6 +5223,39 @@ function runManifestParserSelfTest() { 'git_adapter_commit_snapshot_keeps_staged_diff_and_unstaged_count_separate', ], }, + { + path: 'src/crates/core/src/service/remote_connect/bot/command_router.rs', + contracts: [ + 'CoreServiceAgentRuntime', + 'agent_submission_port', + 'build_remote_session_create_request', + ], + }, + { + path: 'src/crates/core/src/product_domain_runtime.rs', + contracts: [ + 'CoreProductDomainRuntime', + 'miniapp_runtime_facade', + 'function_agent_git_adapter', + 'function_agent_ai_adapter', + 'function_agent_runtime_facade', + 'CoreFunctionAgentGitAdapter', + 'CoreFunctionAgentAiAdapter', + 'MiniAppRuntimeFacade', + 'MiniAppStoragePort', + 'FunctionAgentRuntimeFacade', + 'FunctionAgentGitPort', + 'FunctionAgentAiPort', + ], + }, + { + path: 'src/crates/core/src/service/remote_ssh/mod.rs', + contracts: ['mod disabled', 'pub mod manager', 'pub mod remote_fs', 'pub mod remote_terminal', 'pub mod workspace_state'], + }, + { + path: 'src/crates/core/src/service/remote_ssh/disabled.rs', + contracts: ['Remote SSH support is disabled', 'SSHConnectionManager', 'RemoteFileService', 'RemoteTerminalManager'], + }, { path: 'src/crates/services-integrations/src/remote_ssh/paths.rs', contracts: [ @@ -3942,6 +5338,7 @@ function runManifestParserSelfTest() { 'mark_builtin_update_available_metadata', 'decline_builtin_update_metadata', 'storage.load_customization_metadata', + 'CoreProductDomainRuntime', 'MiniAppRuntimeFacade', 'persist_sync_from_fs_result_for_app', 'compile_source', @@ -3976,9 +5373,19 @@ function runManifestParserSelfTest() { { path: 'src/crates/core/src/function_agents/git-func-agent/commit_generator.rs', contracts: [ - 'FunctionAgentRuntimeFacade', - 'CoreFunctionAgentGitAdapter', - 'CoreFunctionAgentAiAdapter', + 'CoreProductDomainRuntime', + 'function_agent_git_adapter', + 'function_agent_ai_adapter', + 'function_agent_runtime_facade', + ], + }, + { + path: 'src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs', + contracts: [ + 'CoreProductDomainRuntime', + 'function_agent_git_adapter', + 'function_agent_ai_adapter', + 'function_agent_runtime_facade', ], }, { @@ -4025,13 +5432,16 @@ function runManifestParserSelfTest() { }, ]; for (const { path, contracts } of requiredContentContracts) { - const rule = requiredContentRules.find((rule) => rule.path === path); - if (!rule) { + const matchingRules = requiredContentRules.filter((rule) => rule.path === path); + if (matchingRules.length === 0) { throw new Error(`missing owner content anchor rule for ${path}`); } - const ruleText = rule.patterns.map((pattern) => pattern.regex.source).join('\n'); + const ruleText = matchingRules + .flatMap((rule) => rule.patterns) + .map((pattern) => pattern.regex.source) + .join('\n'); for (const contract of contracts) { - if (!ruleText.includes(contract)) { + if (!ruleText.includes(contract) && !ruleText.includes(escapeRegex(contract))) { throw new Error(`owner content anchor rule for ${path} must require: ${contract}`); } } @@ -4376,9 +5786,16 @@ function runManifestParserSelfTest() { 'resolve_remote_cancel_decision', 'remote_session_restore_target', 'resolve_remote_execution_image_contexts', + 'RemoteImageContextAdapter', 'MAX_SIZE', 'MAX_CHUNK', 'unwrap_or\\("file"\\)', + 'resolve_workspace_path', + 'detect_mime_type', + 'read_workspace_file', + 'read_remote_workspace_file', + 'read_remote_workspace_file_chunk', + 'read_remote_workspace_file_info', 'should_send_remote_model_catalog', 'remote_model_catalog_poll_delta', 'remote_no_change_poll_response', @@ -4446,6 +5863,156 @@ function checkForbiddenNonOptionalManifestDeps(crateDir, forbiddenDeps, messageF } } +function featureReferencesDependency(feature, depName) { + if (!feature) { + return false; + } + return feature.refs.includes(`dep:${depName}`) || feature.refs.includes(depName); +} + +function featureReferencesFeature(feature, featureName) { + if (!feature) { + return false; + } + return feature.refs.includes(featureName); +} + +function checkOptionalDependencyFeatureOwners(crateDir, rule) { + const manifestPath = join(crateDir, 'Cargo.toml'); + const lines = readText(manifestPath).split(/\r?\n/); + const deps = parseManifestDependencies(lines); + const depsByName = new Map(deps.map((dep) => [dep.name, dep])); + const features = parseManifestFeatures(lines); + + for (const dependency of rule.dependencies) { + const dep = depsByName.get(dependency.depName); + if (!dep) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing optional dependency: ${dependency.depName}`, + }); + continue; + } + if (!dep.optional) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; dependency must be optional: ${dependency.depName}`, + }); + } + for (const featureName of dependency.ownerFeatures) { + const feature = features.get(featureName); + if (!featureReferencesDependency(feature, dependency.depName)) { + failures.push({ + path: manifestPath, + line: feature?.line ?? dep.line, + message: `${rule.reason}; ${featureName} must explicitly enable ${dependency.depName}`, + }); + } + } + } +} + +function checkProductCoreFeatureAssembly(rule) { + const manifestPath = join(ROOT, ...rule.manifestPath.split('/')); + const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); + const dep = deps.find((candidate) => candidate.name === rule.dependencyName); + if (!dep) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing dependency: ${rule.dependencyName}`, + }); + return; + } + + if (!manifestDependencyDisablesDefaultFeatures(dep)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; ${rule.dependencyName} must set default-features = false`, + }); + } + + const enabledFeatures = parseManifestDependencyFeatureNames(dep); + for (const featureName of rule.requiredFeatures) { + if (!enabledFeatures.has(featureName)) { + failures.push({ + path: manifestPath, + line: dep.line, + message: `${rule.reason}; ${rule.dependencyName} must enable feature ${featureName}`, + }); + } + } +} + +function checkProductCoreFeatureAssemblyCoverage() { + const rulePaths = new Set(productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath)); + for (const manifestPath of collectProductCoreDependencyManifests()) { + if (!rulePaths.has(manifestPath)) { + failures.push({ + path: join(ROOT, ...manifestPath.split('/')), + line: 1, + message: + 'product entry crate depends on bitfun-core but is not covered by product-full assembly rules', + }); + } + } +} + +function checkCoreDefaultProductFullFeature() { + const manifestPath = join(ROOT, 'src', 'crates', 'core', 'Cargo.toml'); + const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); + if (!featureReferencesFeature(features.get('default'), 'product-full')) { + failures.push({ + path: manifestPath, + line: features.get('default')?.line ?? 1, + message: + 'bitfun-core default feature must remain product-full until a separate product matrix review changes it', + }); + } +} + +function checkOwnerCrateFeatureAssembly(rule) { + const manifestPath = join(ROOT, ...rule.manifestPath.split('/')); + const features = parseManifestFeatures(readText(manifestPath).split(/\r?\n/)); + const defaultFeature = features.get('default'); + if (!defaultFeature) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing default feature declaration`, + }); + } else if (defaultFeature.refs.length > 0) { + failures.push({ + path: manifestPath, + line: defaultFeature.line, + message: `${rule.reason}; default feature must remain empty`, + }); + } + + const productFull = features.get('product-full'); + if (!productFull) { + failures.push({ + path: manifestPath, + line: 1, + message: `${rule.reason}; missing product-full feature declaration`, + }); + return; + } + + for (const featureName of rule.requiredProductFullFeatures) { + if (!featureReferencesFeature(productFull, featureName)) { + failures.push({ + path: manifestPath, + line: productFull.line, + message: `${rule.reason}; product-full must explicitly enable ${featureName}`, + }); + } + } +} + function checkRustImports(crateDir) { const srcDir = join(crateDir, 'src'); try { @@ -4657,6 +6224,20 @@ for (const rule of dependencyProfileRules) { checkForbiddenNonOptionalManifestDeps(crateDir, rule.forbiddenNonOptionalDeps, messageForDep); } +for (const rule of optionalDependencyFeatureOwnerRules) { + const crateDir = join(ROOT, 'src', 'crates', rule.crateName); + checkOptionalDependencyFeatureOwners(crateDir, rule); +} + +for (const rule of productCoreFeatureAssemblyRules) { + checkProductCoreFeatureAssembly(rule); +} +checkProductCoreFeatureAssemblyCoverage(); +checkCoreDefaultProductFullFeature(); +for (const rule of ownerCrateFeatureAssemblyRules) { + checkOwnerCrateFeatureAssembly(rule); +} + for (const facade of facadeOnlyFiles) { checkFacadeOnlyFile(facade.path, facade.importPrefix, facade.reason); } diff --git a/src/apps/cli/src/agent/agentic_system.rs b/src/apps/cli/src/agent/agentic_system.rs index a8053f0036..76b7ca8524 100644 --- a/src/apps/cli/src/agent/agentic_system.rs +++ b/src/apps/cli/src/agent/agentic_system.rs @@ -1 +1,18 @@ +use anyhow::{Context, Result}; + +use bitfun_core::infrastructure::ai::AIClientFactory; +use bitfun_core::service::config::initialize_global_config; + pub use bitfun_core::agentic::system::{init_agentic_system, AgenticSystem}; + +pub async fn init_agentic_system_for_cli() -> Result { + initialize_global_config() + .await + .context("Failed to initialize global config service")?; + AIClientFactory::initialize_global() + .await + .context("Failed to initialize global AIClientFactory")?; + init_agentic_system() + .await + .context("Failed to initialize agentic system") +} diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 1e5cbd8074..d5f5626540 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -148,6 +148,33 @@ impl CoreAgentAdapter { } } } + + pub async fn create_session_with_id( + &self, + session_id: String, + agent_type: &str, + ) -> Result { + let mut session_id_guard = self.session_id.lock().await; + + let session = self + .coordinator + .create_session_with_id( + Some(session_id.clone()), + Self::build_default_session_name(), + agent_type.to_string(), + SessionConfig { + workspace_path: Some(self.workspace_path_string()), + ..Default::default() + }, + ) + .await?; + + let id = session.session_id.clone(); + *session_id_guard = Some(id.clone()); + tracing::info!("Created core session with fixed id: {}", id); + + Ok(id) + } } #[async_trait::async_trait] diff --git a/src/apps/cli/src/chat_state.rs b/src/apps/cli/src/chat_state.rs index 2d83329f6b..4a154a3697 100644 --- a/src/apps/cli/src/chat_state.rs +++ b/src/apps/cli/src/chat_state.rs @@ -859,6 +859,21 @@ impl ChatState { }); } + /// Add a local assistant message (for rendered reports and other UI-only content). + pub fn add_assistant_message(&mut self, content: String) { + self.messages.push(ChatMessage { + id: uuid::Uuid::new_v4().to_string(), + role: MessageRole::Assistant, + timestamp: SystemTime::now(), + flow_items: vec![FlowItem::Text { + content, + is_streaming: false, + }], + is_streaming: false, + version: 0, + }); + } + /// Clear all messages (for /clear command) pub fn clear_messages(&mut self) { self.messages.clear(); diff --git a/src/apps/cli/src/commands.rs b/src/apps/cli/src/commands.rs index 71b8f952fd..9f571b0dda 100644 --- a/src/apps/cli/src/commands.rs +++ b/src/apps/cli/src/commands.rs @@ -64,6 +64,10 @@ pub const COMMAND_SPECS: &[CommandSpec] = &[ name: "/history", description: "Show history", }, + CommandSpec { + name: "/usage", + description: "Generate session usage report", + }, CommandSpec { name: "/exit", description: "Exit the app", @@ -116,6 +120,10 @@ pub const STARTUP_COMMAND_SPECS: &[CommandSpec] = &[ name: "/init", description: "Explore repo and generate AGENTS.md", }, + CommandSpec { + name: "/usage", + description: "Generate session usage report", + }, CommandSpec { name: "/exit", description: "Exit the app", diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 471ec21473..098833d2cc 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -10,18 +10,21 @@ mod agent; mod chat_state; mod commands; mod config; +mod management; mod modes; mod prompts; +mod root_handlers; mod ui; -use anyhow::{Context, Result}; +use anyhow::Result; use clap::{Parser, Subcommand}; +use std::path::PathBuf; use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::OnceLock; use config::CliConfig; use modes::chat::ChatMode; -use modes::exec::ExecMode; +use modes::exec::ExecOutputFormat; // ======================== Global MCP Service ======================== @@ -77,13 +80,37 @@ enum Commands { /// Execute single command Exec { - /// User message - message: String, + /// User message. If omitted, stdin is used when piped. + message: Option, /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, + /// Continue the most recent session in the current workspace + #[arg(short = 'c', long = "continue")] + continue_last: bool, + + /// Resume a session by ID, or use "last" for the most recent session + #[arg(short = 'r', long)] + resume: Option, + + /// Alias for --resume, compatible with opencode-style CLIs + #[arg(short = 's', long)] + session: Option, + + /// Create a new session with a fixed session ID + #[arg(long)] + session_id: Option, + + /// Fork the resumed session before executing the prompt + #[arg(long = "fork-session")] + fork_session: bool, + + /// Output format for automation + #[arg(long, value_enum, default_value_t = ExecOutputFormat::Text)] + output_format: ExecOutputFormat, + /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file /// Example: --output-patch or --output-patch ./result.patch @@ -101,6 +128,30 @@ enum Commands { action: SessionAction, }, + /// Agent management + Agents, + + /// Model management + Models { + #[command(subcommand)] + action: Option, + }, + + /// MCP server management + Mcp { + #[command(subcommand)] + action: Option, + }, + + /// Usage reporting + Usage { + /// Session ID to inspect; defaults to the most recent session in the current workspace + session_id: Option, + }, + + /// Diagnostic check + Doctor, + /// Configuration management Config { #[command(subcommand)] @@ -117,6 +168,37 @@ enum Commands { }, } +#[derive(Subcommand)] +enum ModelAction { + /// List configured models + List, + /// Set the default model for all modes + SetDefault { + /// Model id + model_id: String, + }, +} + +#[derive(Subcommand)] +enum McpAction { + /// List configured MCP servers + List, + /// Check MCP readiness + Doctor, + /// Enable an MCP server by id + Enable { + /// MCP server id + server_id: String, + }, + /// Disable an MCP server by id + Disable { + /// MCP server id + server_id: String, + }, + /// Print the stored MCP JSON config + Config, +} + #[derive(Subcommand)] enum AcpAction { /// Start the ACP server over stdio @@ -210,6 +292,21 @@ enum SessionAction { /// Session ID id: String, }, + /// Resume a session in the interactive TUI + Resume { + /// Session ID (or "last" for the most recent) + id: String, + }, + /// Continue the most recent session in the interactive TUI + Continue, + /// Fork a session at the latest persisted turn + Fork { + /// Session ID (or "last" for the most recent) + id: String, + /// Print only the new session ID + #[arg(long)] + id_only: bool, + }, } #[derive(Subcommand)] @@ -433,22 +530,12 @@ async fn main() -> Result<()> { let cli = Cli::parse(); let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); - let is_acp_command = matches!(cli.command, Some(Commands::Acp { .. })); - let is_acp_serve = matches!( - cli.command, - Some(Commands::Acp { action: None }) - | Some(Commands::Acp { - action: Some(AcpAction::Serve), - }) - ); let log_level = if cli.verbose { tracing::Level::DEBUG - } else if is_acp_serve { - tracing::Level::WARN - } else if is_acp_command { - tracing::Level::ERROR - } else { + } else if is_tui_mode { tracing::Level::INFO + } else { + tracing::Level::ERROR }; if is_tui_mode { @@ -475,18 +562,13 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - } else if is_acp_command { + } else { tracing_subscriber::fmt() .with_max_level(log_level) .with_writer(std::io::stderr) .with_ansi(false) .with_target(false) .init(); - } else { - tracing_subscriber::fmt() - .with_max_level(log_level) - .with_target(false) - .init(); } let config = CliConfig::load().unwrap_or_else(|e| { @@ -506,73 +588,91 @@ async fn main() -> Result<()> { Some(Commands::Exec { message, agent, + continue_last, + resume, + session, + session_id, + fork_session, + output_format, output_patch, confirm, }) => { - let workspace_path_resolved = std::env::current_dir().ok(); + root_handlers::handle_exec_command( + config, + root_handlers::ExecCommandArgs { + message, + agent, + continue_last, + resume, + session, + session_id, + fork_session, + output_format, + output_patch, + confirm, + }, + ) + .await?; + } - if let Some(ref ws_path) = workspace_path_resolved { - tracing::info!("Workspace path set: {:?}", ws_path); + Some(Commands::Sessions { action }) => { + if let Some(session_id) = root_handlers::handle_session_action(action).await? { + run_interactive_with_session(config, session_id).await?; } + } - let skip_confirmation = !confirm; - let (agentic_system, original_skip_confirmation) = - initialize_core_services(skip_confirmation).await?; + Some(Commands::Agents) => { + let workspace = std::env::current_dir()?; + management::print_agents(Some(workspace.as_path())).await?; + } - let mut exec_mode = ExecMode::new( - config, - message, - agent, - &agentic_system, - workspace_path_resolved, - output_patch, - ); - let run_result = exec_mode.run().await; + Some(Commands::Models { action }) => match action { + None | Some(ModelAction::List) => management::print_models().await?, + Some(ModelAction::SetDefault { model_id }) => { + management::set_default_model(&model_id).await?; + } + }, - shutdown_mcp_servers().await; - restore_tool_confirmation(original_skip_confirmation).await; + Some(Commands::Mcp { action }) => match action { + None | Some(McpAction::List) => management::print_mcp_servers().await?, + Some(McpAction::Doctor) => { + if !management::print_doctor().await? { + std::process::exit(1); + } + } + Some(McpAction::Enable { server_id }) => { + management::set_mcp_server_enabled(&server_id, true).await?; + } + Some(McpAction::Disable { server_id }) => { + management::set_mcp_server_enabled(&server_id, false).await?; + } + Some(McpAction::Config) => { + management::print_mcp_json_config().await?; + } + }, - run_result?; + Some(Commands::Usage { session_id }) => { + management::print_usage_report(session_id.as_deref()).await?; } - Some(Commands::Sessions { action }) => { - handle_session_action(action).await?; + Some(Commands::Doctor) => { + if !management::print_doctor().await? { + std::process::exit(1); + } } Some(Commands::Config { action }) => { - handle_config_action(action, &config)?; + root_handlers::handle_config_action(action, &config)?; } Some(Commands::Health) => { - println!("BitFun CLI is running normally"); - println!("Version: {}", env!("CARGO_PKG_VERSION")); - println!("Config directory: {:?}", CliConfig::config_dir()?); + root_handlers::handle_health_command()?; } Some(Commands::Acp { action: None | Some(AcpAction::Serve), }) => { - setup_workspace(); - - bitfun_core::service::config::initialize_global_config() - .await - .context("Failed to initialize global config service")?; - tracing::info!("Global config service initialized"); - - use bitfun_core::infrastructure::ai::AIClientFactory; - AIClientFactory::initialize_global() - .await - .context("Failed to initialize global AIClientFactory")?; - tracing::info!("Global AI client factory initialized"); - - initialize_terminal_service().await; - - let agentic_system = agent::agentic_system::init_agentic_system() - .await - .context("Failed to initialize agentic system")?; - tracing::info!("Agentic system initialized"); - - bitfun_acp::BitfunAcpRuntime::serve_stdio(agentic_system).await?; + root_handlers::serve_acp_stdio().await?; } Some(Commands::Acp { @@ -638,178 +738,31 @@ async fn main() -> Result<()> { Ok(()) } -async fn handle_session_action(action: SessionAction) -> Result<()> { - // Initialize core services for session management - bitfun_core::service::config::initialize_global_config() - .await - .expect("Failed to initialize global config service"); - - let agentic_system = agent::agentic_system::init_agentic_system() - .await - .expect("Failed to initialize agentic system"); - - let coordinator = agentic_system.coordinator.clone(); - let workspace_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - - match action { - SessionAction::List => { - let sessions = coordinator.list_sessions(&workspace_path).await?; - - if sessions.is_empty() { - println!( - "No history sessions for current project: {}", - workspace_path.display() - ); - return Ok(()); - } - - println!( - "History sessions for current project (total {})", - sessions.len() - ); - println!("Project: {}\n", workspace_path.display()); - - for (i, info) in sessions.iter().enumerate() { - let last_updated = { - let duration = info - .last_activity_at - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let secs = duration.as_secs() as i64; - chrono::DateTime::from_timestamp(secs, 0) - .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) - .unwrap_or_else(|| "unknown".to_string()) - }; - - println!("{}. {} (ID: {})", i + 1, info.session_name, info.session_id); - println!( - " Agent: {} | Turns: {} | Updated: {}", - info.agent_type, info.turn_count, last_updated - ); - println!(); - } - } +async fn run_interactive_with_session(config: CliConfig, session_id: String) -> Result<()> { + let mut terminal = ui::init_terminal()?; + ui::render_loading(&mut terminal, "Initializing system, please wait...")?; - SessionAction::Show { id } => { - let sessions = coordinator.list_sessions(&workspace_path).await?; - - let session_id = if id == "last" { - sessions - .first() - .map(|s| s.session_id.clone()) - .ok_or_else(|| anyhow::anyhow!("No history sessions"))? - } else { - id - }; - - // Restore and show session details - let session = coordinator - .restore_session(&workspace_path, &session_id) - .await?; - let messages = coordinator.get_messages(&session_id).await?; - - println!("Session Details\n"); - println!("Name: {}", session.session_name); - println!("ID: {}", session.session_id); - println!("Agent: {}", session.agent_type); - println!("State: {:?}", session.state); - println!("Messages: {}", messages.len()); - println!(); - - if !messages.is_empty() { - println!("Recent messages:"); - let recent: Vec<_> = messages.iter().rev().take(5).collect(); - for msg in recent.iter().rev() { - let role = format!("{:?}", msg.role); - let content_preview = match &msg.content { - bitfun_core::agentic::core::message::MessageContent::Text(text) => { - text.lines().next().unwrap_or("").to_string() - } - bitfun_core::agentic::core::message::MessageContent::Multimodal { - text, - images, - } => { - if text.is_empty() { - format!("[{} images]", images.len()) - } else { - text.lines().next().unwrap_or("").to_string() - } - } - bitfun_core::agentic::core::message::MessageContent::Mixed { - text, - tool_calls, - .. - } => { - if text.is_empty() { - format!("[{} tool calls]", tool_calls.len()) - } else { - text.lines().next().unwrap_or("").to_string() - } - } - bitfun_core::agentic::core::message::MessageContent::ToolResult { - tool_name, - .. - } => { - format!("[Tool result: {}]", tool_name) - } - }; - let preview = if content_preview.len() > 80 { - format!("{}...", &content_preview[..77]) - } else { - content_preview - }; - println!(" [{}] {}", role, preview); - } - } - } + let workspace = setup_workspace(); + let (agentic_system, original_skip_confirmation) = initialize_core_services(true).await?; + let workspace_path = workspace + .clone() + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + let session = agentic_system + .coordinator + .restore_session(&workspace_path, &session_id) + .await?; + + let mut chat_mode = ChatMode::new(config, session.agent_type, workspace, &agentic_system) + .with_restore_session(session_id); + let run_result = chat_mode.run(Some(terminal)); - SessionAction::Delete { id } => { - coordinator.delete_session(&workspace_path, &id).await?; - println!("Deleted session from current project: {}", id); - } - } + shutdown_mcp_servers().await; + restore_tool_confirmation(original_skip_confirmation).await; + println!("Goodbye!"); + run_result?; Ok(()) } -fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { - match action { - ConfigAction::Show => { - println!("Current Configuration\n"); - println!("Note: AI model configuration is now managed via GlobalConfig"); - println!("View and manage at: Main Menu -> Settings -> AI Model Configuration"); - println!(); - println!("UI Configuration:"); - println!(" Appearance: {}", config.ui.theme); - println!(" Theme ID: {}", config.ui.theme_id); - println!(" Color scheme: {}", config.ui.color_scheme); - println!(" Show tips: {}", config.ui.show_tips); - println!(" Animation: {}", config.ui.animation); - println!(); - println!("Behavior Configuration:"); - println!(" Auto save: {}", config.behavior.auto_save); - println!(" Confirm dangerous: {}", config.behavior.confirm_dangerous); - println!(" Default Agent: {}", config.behavior.default_agent); - println!(); - println!("Config file: {:?}", CliConfig::config_path()?); - } - - ConfigAction::Edit => { - let config_path = CliConfig::config_path()?; - println!("Config file location: {:?}", config_path); - println!(); - println!("Please use a text editor to edit the config file:"); - println!(" vi {:?}", config_path); - println!(" or"); - println!(" code {:?}", config_path); - } - - ConfigAction::Reset => { - let default_config = CliConfig::default(); - default_config.save()?; - println!("Reset to default configuration"); - } - } - - Ok(()) -} diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs new file mode 100644 index 0000000000..24495e6d97 --- /dev/null +++ b/src/apps/cli/src/management.rs @@ -0,0 +1,269 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::Path; +use std::time::Duration; + +use bitfun_core::agentic::get_agent_registry; +use bitfun_core::agentic::persistence::PersistenceManager; +use bitfun_core::infrastructure::try_get_path_manager_arc; +use bitfun_core::service::config::initialize_global_config; +use bitfun_core::service::session_usage::{ + generate_session_usage_report, render_usage_report_markdown, SessionUsageReportRequest, +}; + + +async fn ensure_global_config_service() -> Result< + std::sync::Arc, +> { + initialize_global_config() + .await + .context("Failed to initialize global config service")?; + bitfun_core::service::config::get_global_config_service() + .await + .context("Failed to get global config service") +} + +pub async fn print_agents(workspace: Option<&Path>) -> Result<()> { + let registry = get_agent_registry(); + let modes = registry.get_modes_info().await; + let subagents = registry.get_subagents_info(workspace).await; + + println!("Agent modes"); + println!(); + if modes.is_empty() { + println!("No agent modes found."); + } else { + for agent in modes { + println!( + "- {}: {} (tools: {}, readonly: {}, review: {})", + agent.id, agent.name, agent.tool_count, agent.is_readonly, agent.is_review + ); + if !agent.description.is_empty() { + println!(" {}", agent.description); + } + } + } + + println!(); + println!("Subagents"); + println!(); + if subagents.is_empty() { + println!("No subagents found for the current workspace."); + } else { + for agent in subagents { + println!( + "- {}: {} (tools: {}, enabled: {}, readonly: {}, review: {})", + agent.id, + agent.name, + agent.tool_count, + agent.effective_enabled, + agent.is_readonly, + agent.is_review + ); + if !agent.description.is_empty() { + println!(" {}", agent.description); + } + } + } + + Ok(()) +} + +pub async fn print_models() -> Result<()> { + let config_service = ensure_global_config_service().await?; + let models = config_service.get_ai_models().await?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await?; + + let primary_model_id = global_config.ai.default_models.primary.clone(); + + println!("AI models"); + println!(); + if models.is_empty() { + println!("No AI models configured."); + return Ok(()); + } + + for model in models { + let is_primary = primary_model_id.as_deref() == Some(model.id.as_str()); + let current_modes: Vec = global_config + .ai + .agent_models + .iter() + .filter_map(|(mode, model_id)| (model_id == &model.id).then_some(mode.clone())) + .collect(); + + println!( + "- {}{} ({})", + if is_primary { "* " } else { " " }, + model.id, + if model.enabled { "enabled" } else { "disabled" } + ); + println!(" Name: {}", model.name); + println!(" Provider: {}", model.provider); + println!(" Model: {}", model.model_name); + if !current_modes.is_empty() { + println!(" Used by modes: {}", current_modes.join(", ")); + } + } + + Ok(()) +} + +pub async fn print_mcp_servers() -> Result<()> { + let config_service = ensure_global_config_service().await?; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let configs = mcp_service.config_service().load_all_configs().await?; + + println!("MCP servers"); + println!(); + if configs.is_empty() { + println!("No MCP servers configured."); + return Ok(()); + } + + for config in configs { + let status = if config.enabled { + match tokio::time::timeout( + Duration::from_millis(30), + mcp_service.server_manager().get_server_status(&config.id), + ) + .await + { + Ok(Ok(status)) => format!("{:?}", status), + Ok(Err(_)) => "Unknown".to_string(), + Err(_) => "Starting".to_string(), + } + } else { + "Disabled".to_string() + }; + + let endpoint = match config.server_type { + bitfun_core::service::mcp::server::MCPServerType::Local => config + .command + .as_ref() + .map(|cmd| format!("{} {}", cmd, config.args.join(" "))) + .unwrap_or_else(|| "".to_string()), + bitfun_core::service::mcp::server::MCPServerType::Remote => { + config.url.clone().unwrap_or_else(|| "".to_string()) + } + }; + + println!("- {} ({:?})", config.id, config.server_type); + println!(" Name: {}", config.name); + println!(" Status: {}", status); + println!(" Enabled: {}", config.enabled); + println!(" Endpoint: {}", endpoint); + } + + Ok(()) +} + +pub async fn set_default_model(model_id: &str) -> Result<()> { + let config_service = ensure_global_config_service().await?; + let agent_registry = get_agent_registry(); + let modes = agent_registry.get_modes_info().await; + + config_service + .set_config("ai.default_models.primary", model_id) + .await?; + for mode in modes { + let path = format!("ai.agent_models.{}", mode.id); + config_service.set_config(&path, model_id).await?; + } + + println!("Default model set to: {}", model_id); + Ok(()) +} + +pub async fn set_mcp_server_enabled(server_id: &str, enabled: bool) -> Result<()> { + let config_service = ensure_global_config_service().await?; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let mut config = mcp_service + .config_service() + .get_server_config(server_id) + .await? + .ok_or_else(|| anyhow!("MCP server not found: {}", server_id))?; + config.enabled = enabled; + mcp_service.config_service().save_server_config(&config).await?; + + println!( + "MCP server {} {}.", + server_id, + if enabled { "enabled" } else { "disabled" } + ); + Ok(()) +} + +pub async fn print_mcp_json_config() -> Result<()> { + let config_service = ensure_global_config_service().await?; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let json = mcp_service + .config_service() + .load_mcp_json_config() + .await?; + println!("{}", json); + Ok(()) +} + +pub async fn print_usage_report(session_id: Option<&str>) -> Result<()> { + let agentic_system = crate::agent::agentic_system::init_agentic_system_for_cli().await?; + let path_manager = try_get_path_manager_arc().map_err(|error| anyhow!(error.to_string()))?; + let persistence_manager = PersistenceManager::new(path_manager) + .map_err(|error| anyhow!(error.to_string()))?; + let workspace_path = std::env::current_dir().context("Failed to resolve current directory")?; + let coordinator = agentic_system.coordinator.clone(); + let resolved_session_id = match session_id { + Some(session_id) if !session_id.trim().is_empty() => session_id.to_string(), + _ => coordinator + .list_sessions(&workspace_path) + .await? + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow!("No history sessions for current project"))?, + }; + + let report = generate_session_usage_report( + &persistence_manager, + Some(agentic_system.token_usage_service.as_ref()), + SessionUsageReportRequest { + session_id: resolved_session_id, + workspace_path: Some(workspace_path.to_string_lossy().to_string()), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: true, + }, + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + println!("{}", render_usage_report_markdown(&report)); + Ok(()) +} + +pub async fn print_doctor() -> Result { + let workspace = std::env::current_dir().context("Failed to resolve current directory")?; + let config_dir = crate::config::CliConfig::config_dir()?; + let config_service = ensure_global_config_service().await?; + let models = config_service.get_ai_models().await?; + let agent_registry = get_agent_registry(); + let modes = agent_registry.get_modes_info().await; + let subagents = agent_registry.get_subagents_info(Some(workspace.as_path())).await; + let mcp_service = bitfun_core::service::mcp::MCPService::new(config_service.clone()) + .map_err(|error| anyhow!(error.to_string()))?; + let mcp_configs = mcp_service.config_service().load_all_configs().await?; + + println!("BitFun CLI doctor"); + println!(); + println!("[ok] Workspace: {}", workspace.display()); + println!("[ok] Config directory: {}", config_dir.display()); + println!("[ok] Agent modes: {}", modes.len()); + println!("[ok] Subagents: {}", subagents.len()); + println!("[ok] AI models: {} total, {} enabled", models.len(), models.iter().filter(|m| m.enabled).count()); + println!("[ok] MCP servers: {}", mcp_configs.len()); + println!(); + println!("Doctor checks passed."); + Ok(true) +} diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index cdd51b94b9..b51fb02803 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -2,7 +2,7 @@ /// /// Interactive chat mode with TUI interface. /// Events are consumed directly from core's EventQueue. -use anyhow::Result; +use anyhow::{anyhow, Result}; use arboard::Clipboard; use crossterm::event::{ Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEventKind, @@ -42,6 +42,7 @@ use crate::ui::{init_terminal, restore_terminal}; use bitfun_core::agentic::agents::{ get_agent_registry, AgentInfo, SubAgentSource, SubagentListScope, SubagentQueryContext, }; +use bitfun_core::agentic::persistence::PersistenceManager; use bitfun_core::agentic::tools::implementations::skills::{ mode_overrides::{ load_project_mode_skills_document_local, save_project_mode_skills_document_local, @@ -51,6 +52,10 @@ use bitfun_core::agentic::tools::implementations::skills::{ ModeSkillInfo, SkillInfo, }; use bitfun_core::service::config::GlobalConfigManager; +use bitfun_core::service::session_usage::{ + generate_session_usage_report, render_usage_report_markdown, SessionUsageReportRequest, +}; +use bitfun_core::service::token_usage::TokenUsageService; /// Keyboard shortcuts help text const KEYBOARD_SHORTCUTS_HELP: &str = "\ @@ -119,6 +124,7 @@ pub struct ChatMode { agent_type: String, workspace: Option, agent: Arc, + token_usage_service: Arc, /// If set, restore this existing session instead of creating a new one restore_session_id: Option, /// If set, send this prompt automatically when the session starts @@ -155,6 +161,7 @@ impl ChatMode { agent_type, workspace, agent, + token_usage_service: agentic_system.token_usage_service.clone(), restore_session_id: None, initial_prompt: None, pending_mcp_op: None, @@ -1573,6 +1580,9 @@ impl ChatMode { } self.show_session_selector(chat_view, chat_state, rt_handle); } + "usage" => { + self.show_usage_report(chat_view, chat_state, rt_handle); + } // Prompt group "skills" => { self.show_skill_selector(chat_view, chat_state, rt_handle); @@ -1699,6 +1709,9 @@ impl ChatMode { "ACP setup added to the conversation. You can keep typing.".to_string(), )); } + "/usage" => { + self.show_usage_report(chat_view, chat_state, rt_handle); + } "/init" => match crate::prompts::get_cli_prompt("init") { Some(prompt) => { self.send_message_to_agent( @@ -1757,6 +1770,100 @@ impl ChatMode { Ok(None) } + fn show_usage_report( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if chat_state.is_processing { + chat_view.set_status(Some( + "Wait until the session is idle before using /usage.".to_string(), + )); + return; + } + + let session_id = chat_state.core_session_id.clone(); + let workspace_path = chat_state + .workspace + .clone() + .or_else(|| self.workspace.clone()) + .or_else(|| Some(self.agent.workspace_path_string())); + let token_usage_service = self.token_usage_service.clone(); + let session_manager = self.agent.coordinator().get_session_manager(); + + let report_result: Result = + tokio::task::block_in_place(|| { + let session_id = session_id.clone(); + let workspace_path = workspace_path.clone(); + let token_usage_service = token_usage_service.clone(); + let session_manager = session_manager.clone(); + rt_handle.block_on(async move { + let workspace_path = workspace_path + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; + + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow!(error.to_string()))?; + let persistence_manager = PersistenceManager::new(path_manager) + .map_err(|error| anyhow!(error.to_string()))?; + + let report = generate_session_usage_report( + &persistence_manager, + Some(token_usage_service.as_ref()), + SessionUsageReportRequest { + session_id: session_id.clone(), + workspace_path: Some(workspace_path), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: true, + }, + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + let markdown = render_usage_report_markdown(&report); + let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); + let usage_report = serde_json::to_value(&report) + .map_err(|error| anyhow!("Failed to serialize usage report: {}", error))?; + let metadata = serde_json::json!({ + "localCommandKind": "usage_report", + "reportId": report.report_id.clone(), + "schemaVersion": report.schema_version, + "generatedAt": report.generated_at, + "modelVisible": false, + "usageReport": usage_report, + "usageReportStatus": "completed", + }); + + session_manager + .append_completed_local_command_turn( + &session_id, + markdown, + Some(format!("local-usage-{}", report.report_id)), + Some(generated_at), + Some(metadata), + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + Ok(report) + }) + }); + + match report_result { + Ok(report) => { + let markdown = render_usage_report_markdown(&report); + chat_state.add_assistant_message(markdown); + chat_view.set_status(Some("Usage report added to conversation".to_string())); + } + Err(error) => { + chat_state + .add_system_message(format!("Failed to generate usage report: {}", error)); + } + } + } + fn list_available_themes(&self) -> Vec { let mut themes = Vec::new(); for id in builtin_theme_ids() { diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 1980896e24..34b68f5634 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -3,6 +3,8 @@ /// Single command execution mode (non-interactive). /// Consumes core events directly from EventQueue. use anyhow::Result; +use clap::ValueEnum; +use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -12,6 +14,21 @@ use bitfun_events::AgenticEvent; use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +pub enum ExecOutputFormat { + Text, + Json, + StreamJson, +} + +#[derive(Debug, Clone, Default)] +pub struct ExecSessionOptions { + pub resume: Option, + pub continue_last: bool, + pub session_id: Option, + pub fork_session: bool, +} + pub struct ExecMode { #[allow(dead_code)] config: CliConfig, @@ -21,6 +38,8 @@ pub struct ExecMode { workspace_path: Option, /// None: no patch output, Some("-"): output to stdout, Some(path): save to file output_patch: Option, + output_format: ExecOutputFormat, + session_options: ExecSessionOptions, } impl ExecMode { @@ -31,6 +50,8 @@ impl ExecMode { agentic_system: &AgenticSystem, workspace_path: Option, output_patch: Option, + output_format: ExecOutputFormat, + session_options: ExecSessionOptions, ) -> Self { let agent = Arc::new(CoreAgentAdapter::new( agentic_system.coordinator.clone(), @@ -45,6 +66,8 @@ impl ExecMode { agent, workspace_path, output_patch, + output_format, + session_options, } } @@ -78,14 +101,20 @@ impl ExecMode { self.message ); - println!("Executing: {}", self.message); - println!(); - - // Ensure session and send message - let session_id = self.agent.ensure_session(&self.agent_type).await?; + let session_id = self.prepare_session().await?; let event_queue = self.agent.event_queue().clone(); - println!("Thinking..."); + self.emit(json!({ + "type": "session", + "session_id": session_id, + "agent": self.agent_type, + }))?; + self.print_text(|| { + println!("Executing: {}", self.message); + println!(); + println!("Session: {}", session_id); + println!("Thinking..."); + }); let _turn_id = self .agent @@ -125,24 +154,50 @@ impl ExecMode { if parent_session_id.map(String::as_str) == Some(session_id.as_str()) { use bitfun_events::ToolEventData; match tool_event { - ToolEventData::Started { tool_name, .. } => { - println!(" [subagent] {}", tool_name); + ToolEventData::Started { tool_name, tool_id, .. } => { + self.emit(json!({ + "type": "subagent_tool_start", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + }))?; + self.print_text(|| println!(" [subagent] {}", tool_name)); } ToolEventData::Completed { tool_name, + tool_id, result_for_assistant, result, + duration_ms, .. } => { let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); - println!(" [subagent] {} ✓ {}", tool_name, summary); + self.emit(json!({ + "type": "subagent_tool_result", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "duration_ms": duration_ms, + "result": result, + "summary": summary, + }))?; + self.print_text(|| { + println!(" [subagent] {} completed: {}", tool_name, summary) + }); } ToolEventData::Failed { - tool_name, error, .. + tool_name, tool_id, error, .. } => { - println!(" [subagent] {} ✗ {}", tool_name, error); + self.emit(json!({ + "type": "subagent_tool_error", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "error": error, + }))?; + self.print_text(|| println!(" [subagent] {} failed: {}", tool_name, error)); } _ => {} } @@ -153,30 +208,69 @@ impl ExecMode { match event { AgenticEvent::TextChunk { text, .. } => { - print!("{}", text); - use std::io::Write; - std::io::stdout().flush().ok(); + self.emit(json!({ + "type": "text", + "session_id": session_id, + "text": text, + }))?; + self.print_text(|| { + print!("{}", text); + use std::io::Write; + std::io::stdout().flush().ok(); + }); } AgenticEvent::ThinkingChunk { content, .. } => { - // Show thinking in exec mode as dimmed text - print!("\x1b[2m{}\x1b[0m", content); - use std::io::Write; - std::io::stdout().flush().ok(); + self.emit(json!({ + "type": "thinking", + "session_id": session_id, + "text": content, + }))?; + self.print_text(|| { + print!("\x1b[2m{}\x1b[0m", content); + use std::io::Write; + std::io::stdout().flush().ok(); + }); } AgenticEvent::ToolEvent { tool_event, .. } => { use bitfun_events::ToolEventData; match tool_event { - ToolEventData::Started { tool_name, .. } => { - println!("\nTool call: {}", tool_name); + ToolEventData::Started { + tool_name, + tool_id, + params, + .. + } => { + self.emit(json!({ + "type": "tool_start", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "input": params, + }))?; + self.print_text(|| println!("\nTool call: {}", tool_name)); total_tool_calls += 1; } - ToolEventData::Progress { message, .. } => { - println!(" In progress: {}", message); + ToolEventData::Progress { + tool_name, + tool_id, + message, + percentage, + } => { + self.emit(json!({ + "type": "tool_progress", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "message": message, + "percentage": percentage, + }))?; + self.print_text(|| println!(" In progress: {}", message)); } ToolEventData::Completed { tool_name, + tool_id, result_for_assistant, result, duration_ms, @@ -185,42 +279,86 @@ impl ExecMode { let summary = result_for_assistant .clone() .unwrap_or_else(|| result.to_string()); - println!(" [+] {} ({}ms): {}", tool_name, duration_ms, summary); + self.emit(json!({ + "type": "tool_result", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "duration_ms": duration_ms, + "result": result, + "summary": summary, + }))?; + self.print_text(|| { + println!(" [+] {} ({}ms): {}", tool_name, duration_ms, summary) + }); } ToolEventData::Failed { - tool_name, error, .. + tool_name, + tool_id, + error, + .. } => { - println!(" [x] {}: {}", tool_name, error); + self.emit(json!({ + "type": "tool_error", + "session_id": session_id, + "tool_id": tool_id, + "tool_name": tool_name, + "error": error, + }))?; + self.print_text(|| println!(" [x] {}: {}", tool_name, error)); } _ => {} } } AgenticEvent::DialogTurnCompleted { .. } => { - println!("\n"); - println!("Execution complete"); - if total_tool_calls > 0 { - println!("\nTool call statistics: {} tools invoked", total_tool_calls); - } - // Break out of the event loop + self.emit(json!({ + "type": "done", + "session_id": session_id, + "status": "completed", + "tool_calls": total_tool_calls, + }))?; + self.print_text(|| { + println!("\n"); + println!("Execution complete"); + if total_tool_calls > 0 { + println!("\nTool call statistics: {} tools invoked", total_tool_calls); + } + }); self.output_patch_if_needed(); return Ok(()); } AgenticEvent::DialogTurnFailed { error, .. } => { - eprintln!("\nExecution failed: {}", error); + self.emit(json!({ + "type": "error", + "session_id": session_id, + "message": error, + }))?; + self.print_text(|| eprintln!("\nExecution failed: {}", error)); self.output_patch_if_needed(); return Err(anyhow::anyhow!("Execution failed: {}", error)); } AgenticEvent::DialogTurnCancelled { .. } => { - println!("\nExecution cancelled"); + self.emit(json!({ + "type": "done", + "session_id": session_id, + "status": "cancelled", + "tool_calls": total_tool_calls, + }))?; + self.print_text(|| println!("\nExecution cancelled")); self.output_patch_if_needed(); return Ok(()); } AgenticEvent::SystemError { error, .. } => { - eprintln!("\nSystem error: {}", error); + self.emit(json!({ + "type": "error", + "session_id": session_id, + "message": error, + }))?; + self.print_text(|| eprintln!("\nSystem error: {}", error)); self.output_patch_if_needed(); return Err(anyhow::anyhow!("System error: {}", error)); } @@ -231,10 +369,125 @@ impl ExecMode { } } + async fn prepare_session(&self) -> Result { + let resume_id = self.session_options.resume.as_deref(); + let workspace = self + .workspace_path + .clone() + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + let resolved_resume = if self.session_options.continue_last || resume_id == Some("last") { + let sessions = self.agent.coordinator().list_sessions(&workspace).await?; + Some( + sessions + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions for current project"))?, + ) + } else { + resume_id.map(ToString::to_string) + }; + + if self.session_options.fork_session { + let source_session_id = resolved_resume + .clone() + .or_else(|| self.session_options.session_id.clone()) + .ok_or_else(|| { + anyhow::anyhow!("--fork-session requires --continue, --resume, or --session") + })?; + let (_session, turns) = self + .agent + .coordinator() + .restore_session_view(&workspace, &source_session_id) + .await?; + let source_turn_id = turns + .last() + .map(|turn| turn.turn_id.clone()) + .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let persistence_manager = bitfun_core::agentic::persistence::PersistenceManager::new( + path_manager, + ) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let result = persistence_manager + .branch_session( + &workspace, + &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { + source_session_id: source_session_id.clone(), + source_turn_id, + }, + ) + .await?; + self.agent.restore_session(&result.session_id).await?; + return Ok(result.session_id); + } + + if let Some(session_id) = resolved_resume.as_deref() { + self.agent.restore_session(session_id).await?; + return Ok(session_id.to_string()); + } + + if let Some(session_id) = &self.session_options.session_id { + return self + .agent + .create_session_with_id(session_id.clone(), &self.agent_type) + .await; + } + + self.agent.ensure_session(&self.agent_type).await + } + + fn emit(&self, value: serde_json::Value) -> Result<()> { + match self.output_format { + ExecOutputFormat::Text => {} + ExecOutputFormat::StreamJson => { + println!("{}", serde_json::to_string(&value)?); + } + ExecOutputFormat::Json => { + println!("{}", serde_json::to_string_pretty(&value)?); + } + } + Ok(()) + } + + fn print_text(&self, f: impl FnOnce()) { + if self.output_format == ExecOutputFormat::Text { + f(); + } + } + fn output_patch_if_needed(&self) { if let Some(ref output_target) = self.output_patch { - println!("\n--- Generating Patch ---"); if let Some(patch) = self.get_git_diff() { + let status = if patch.trim().is_empty() { + "empty" + } else { + "generated" + }; + let patch_value = json!({ + "type": "patch", + "target": output_target, + "status": status, + "patch": if output_target == "-" { Some(patch.as_str()) } else { None }, + "bytes": patch.len(), + }); + + if self.emit(patch_value).is_err() { + eprintln!("Failed to emit patch event"); + } + + if self.output_format != ExecOutputFormat::Text { + if output_target != "-" && !patch.trim().is_empty() { + if let Err(e) = std::fs::write(output_target, &patch) { + eprintln!("Failed to save patch: {}", e); + } + } + return; + } + + println!("\n--- Generating Patch ---"); if patch.trim().is_empty() { println!("(No file modifications)"); } else if output_target == "-" { @@ -256,7 +509,15 @@ impl ExecMode { } } } else { - println!("(Unable to generate patch)"); + let value = json!({ + "type": "patch", + "target": output_target, + "status": "unavailable", + }); + if self.emit(value).is_err() { + eprintln!("Failed to emit patch event"); + } + self.print_text(|| println!("(Unable to generate patch)")); } } } diff --git a/src/apps/cli/src/root_handlers.rs b/src/apps/cli/src/root_handlers.rs new file mode 100644 index 0000000000..c80ae51b59 --- /dev/null +++ b/src/apps/cli/src/root_handlers.rs @@ -0,0 +1,356 @@ +use anyhow::{Context, Result}; + +use std::io::IsTerminal; +use std::path::Path; + +use crate::{ + config::CliConfig, + modes::exec::{ExecMode, ExecOutputFormat, ExecSessionOptions}, + ConfigAction, SessionAction, +}; + +pub struct ExecCommandArgs { + pub message: Option, + pub agent: String, + pub continue_last: bool, + pub resume: Option, + pub session: Option, + pub session_id: Option, + pub fork_session: bool, + pub output_format: ExecOutputFormat, + pub output_patch: Option, + pub confirm: bool, +} + +pub async fn handle_exec_command(config: CliConfig, args: ExecCommandArgs) -> Result<()> { + let workspace_path_resolved = std::env::current_dir().ok(); + + if let Some(ref ws_path) = workspace_path_resolved { + tracing::info!("Workspace path set: {:?}", ws_path); + } + + let message = resolve_exec_message(args.message)?; + let resume = match (args.resume, args.session) { + (Some(_), Some(_)) => { + anyhow::bail!("Use only one of --resume or --session"); + } + (Some(value), None) | (None, Some(value)) => Some(value), + (None, None) => None, + }; + if args.session_id.is_some() && (args.continue_last || resume.is_some()) { + anyhow::bail!("--session-id cannot be combined with --continue, --resume, or --session"); + } + if args.fork_session && args.session_id.is_some() { + anyhow::bail!("--fork-session cannot be combined with --session-id"); + } + + let skip_confirmation = !args.confirm; + let (agentic_system, original_skip_confirmation) = + crate::initialize_core_services(skip_confirmation).await?; + + let mut exec_mode = ExecMode::new( + config, + message, + args.agent, + &agentic_system, + workspace_path_resolved, + args.output_patch, + args.output_format, + ExecSessionOptions { + resume, + continue_last: args.continue_last, + session_id: args.session_id, + fork_session: args.fork_session, + }, + ); + let run_result = exec_mode.run().await; + + crate::shutdown_mcp_servers().await; + crate::restore_tool_confirmation(original_skip_confirmation).await; + + run_result +} + +fn resolve_exec_message(message: Option) -> Result { + let mut combined = message.unwrap_or_default(); + if !std::io::stdin().is_terminal() { + use std::io::Read; + let mut stdin_content = String::new(); + std::io::stdin().read_to_string(&mut stdin_content)?; + let stdin_content = stdin_content.trim_end().to_string(); + if !stdin_content.is_empty() { + if combined.is_empty() { + combined = stdin_content; + } else { + combined.push('\n'); + combined.push_str(&stdin_content); + } + } + } + + let message = combined.trim().to_string(); + if message.is_empty() { + anyhow::bail!("Prompt cannot be empty"); + } + + Ok(message) +} + +pub async fn handle_session_action(action: SessionAction) -> Result> { + let agentic_system = crate::agent::agentic_system::init_agentic_system_for_cli().await?; + + let coordinator = agentic_system.coordinator.clone(); + let workspace_path = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + + match action { + SessionAction::List => { + let sessions = coordinator.list_sessions(&workspace_path).await?; + + if sessions.is_empty() { + println!( + "No history sessions for current project: {}", + workspace_path.display() + ); + return Ok(None); + } + + println!( + "History sessions for current project (total {})", + sessions.len() + ); + println!("Project: {}\n", workspace_path.display()); + + for (i, info) in sessions.iter().enumerate() { + let last_updated = { + let duration = info + .last_activity_at + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = duration.as_secs() as i64; + chrono::DateTime::from_timestamp(secs, 0) + .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "unknown".to_string()) + }; + + println!("{}. {} (ID: {})", i + 1, info.session_name, info.session_id); + println!( + " Agent: {} | Turns: {} | Updated: {}", + info.agent_type, info.turn_count, last_updated + ); + println!(); + } + } + + SessionAction::Show { id } => { + let sessions = coordinator.list_sessions(&workspace_path).await?; + + let session_id = if id == "last" { + sessions + .first() + .map(|s| s.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions"))? + } else { + id + }; + + let session = coordinator + .restore_session(&workspace_path, &session_id) + .await?; + let messages = coordinator.get_messages(&session_id).await?; + + println!("Session Details\n"); + println!("Name: {}", session.session_name); + println!("ID: {}", session.session_id); + println!("Agent: {}", session.agent_type); + println!("State: {:?}", session.state); + println!("Messages: {}", messages.len()); + println!(); + + if !messages.is_empty() { + println!("Recent messages:"); + let recent: Vec<_> = messages.iter().rev().take(5).collect(); + for msg in recent.iter().rev() { + let role = format!("{:?}", msg.role); + let content_preview = match &msg.content { + bitfun_core::agentic::core::message::MessageContent::Text(text) => { + text.lines().next().unwrap_or("").to_string() + } + bitfun_core::agentic::core::message::MessageContent::Multimodal { + text, + images, + } => { + if text.is_empty() { + format!("[{} images]", images.len()) + } else { + text.lines().next().unwrap_or("").to_string() + } + } + bitfun_core::agentic::core::message::MessageContent::Mixed { + text, + tool_calls, + .. + } => { + if text.is_empty() { + format!("[{} tool calls]", tool_calls.len()) + } else { + text.lines().next().unwrap_or("").to_string() + } + } + bitfun_core::agentic::core::message::MessageContent::ToolResult { + tool_name, + .. + } => format!("[Tool result: {}]", tool_name), + }; + let preview = if content_preview.len() > 80 { + format!("{}...", &content_preview[..77]) + } else { + content_preview + }; + println!(" [{}] {}", role, preview); + } + } + } + + SessionAction::Delete { id } => { + coordinator.delete_session(&workspace_path, &id).await?; + println!("Deleted session from current project: {}", id); + } + + SessionAction::Resume { id } => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; + return Ok(Some(session_id)); + } + + SessionAction::Continue => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, "last").await?; + return Ok(Some(session_id)); + } + + SessionAction::Fork { id, id_only } => { + let session_id = resolve_cli_session_id(&coordinator, &workspace_path, &id).await?; + let (_session, turns) = coordinator + .restore_session_view(&workspace_path, &session_id) + .await?; + let source_turn_id = turns + .last() + .map(|turn| turn.turn_id.clone()) + .ok_or_else(|| anyhow::anyhow!("Session has no persisted turns to fork"))?; + let path_manager = bitfun_core::infrastructure::try_get_path_manager_arc() + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let persistence_manager = bitfun_core::agentic::persistence::PersistenceManager::new( + path_manager, + ) + .map_err(|error| anyhow::anyhow!(error.to_string()))?; + let result = persistence_manager + .branch_session( + &workspace_path, + &bitfun_core::agentic::persistence::session_branch::SessionBranchRequest { + source_session_id: session_id.clone(), + source_turn_id, + }, + ) + .await?; + + if id_only { + println!("{}", result.session_id); + } else { + println!("Forked session"); + println!("Source ID: {}", session_id); + println!("New ID: {}", result.session_id); + println!("Name: {}", result.session_name); + println!("Agent: {}", result.agent_type); + } + } + } + + Ok(None) +} + +async fn resolve_cli_session_id( + coordinator: &std::sync::Arc, + workspace_path: &Path, + id: &str, +) -> Result { + if id == "last" { + let sessions = coordinator.list_sessions(workspace_path).await?; + return sessions + .first() + .map(|session| session.session_id.clone()) + .ok_or_else(|| anyhow::anyhow!("No history sessions")); + } + + Ok(id.to_string()) +} + +pub fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> { + match action { + ConfigAction::Show => { + println!("Current Configuration\n"); + println!("Note: AI model configuration is managed via GlobalConfig"); + println!(); + println!("UI Configuration:"); + println!(" Appearance: {}", config.ui.theme); + println!(" Theme ID: {}", config.ui.theme_id); + println!(" Color scheme: {}", config.ui.color_scheme); + println!(" Show tips: {}", config.ui.show_tips); + println!(" Animation: {}", config.ui.animation); + println!(); + println!("Behavior Configuration:"); + println!(" Auto save: {}", config.behavior.auto_save); + println!(" Confirm dangerous: {}", config.behavior.confirm_dangerous); + println!(" Default Agent: {}", config.behavior.default_agent); + println!(); + println!("Config file: {:?}", CliConfig::config_path()?); + } + + ConfigAction::Edit => { + let config_path = CliConfig::config_path()?; + println!("Config file location: {:?}", config_path); + println!(); + println!("Please use a text editor to edit the config file:"); + println!(" vi {:?}", config_path); + println!(" or"); + println!(" code {:?}", config_path); + } + + ConfigAction::Reset => { + let default_config = CliConfig::default(); + default_config.save()?; + println!("Reset to default configuration"); + } + } + + Ok(()) +} + +pub fn handle_health_command() -> Result<()> { + println!("BitFun CLI is running normally"); + println!("Version: {}", env!("CARGO_PKG_VERSION")); + println!("Config directory: {:?}", CliConfig::config_dir()?); + Ok(()) +} + +pub async fn serve_acp_stdio() -> Result<()> { + crate::setup_workspace(); + + bitfun_core::service::config::initialize_global_config() + .await + .context("Failed to initialize global config service")?; + tracing::info!("Global config service initialized"); + + use bitfun_core::infrastructure::ai::AIClientFactory; + AIClientFactory::initialize_global() + .await + .context("Failed to initialize global AIClientFactory")?; + tracing::info!("Global AI client factory initialized"); + + crate::initialize_terminal_service().await; + + let agentic_system = crate::agent::agentic_system::init_agentic_system() + .await + .context("Failed to initialize agentic system")?; + tracing::info!("Agentic system initialized"); + + bitfun_acp::BitfunAcpRuntime::serve_stdio(agentic_system).await?; + Ok(()) +} diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 61a100e31b..404f0347b0 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -53,6 +53,12 @@ pub fn default_palette_items() -> Vec { description: "Browse and switch sessions".into(), group: "Session".into(), }, + PaletteItem { + id: "usage".into(), + label: "Usage report".into(), + description: "Generate a usage report for the current session".into(), + group: "Session".into(), + }, // Prompt group PaletteItem { id: "skills".into(), @@ -143,6 +149,12 @@ fn build_suggested_items() -> Vec { description: "Start a new conversation".into(), group: "Suggested".into(), }, + PaletteItem { + id: "usage".into(), + label: "Usage report".into(), + description: "Generate a usage report for the current session".into(), + group: "Suggested".into(), + }, ] } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 7aecf1b881..4d136095c7 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -135,6 +135,7 @@ const TIPS: &[&str] = &[ "Use /sessions to list and continue previous conversations", "Press Ctrl+O to expand/collapse tool output", "Use /skills to browse and execute available skills", + "Use /usage inside a session to generate a usage report", "Use /theme to switch the CLI theme", "Use /acp to copy editor setup commands for ACP hosts", "Press Up/Down to cycle through input history", @@ -1048,6 +1049,9 @@ impl StartupPage { "sessions" => { self.show_session_selector(); } + "usage" => { + self.status = Some("No active session for /usage.".to_string()); + } // Prompt group "skills" => { self.show_skill_selector(); @@ -1139,6 +1143,9 @@ impl StartupPage { prompt: Some("/acp".to_string()), }); } + "/usage" => { + self.status = Some("No active session for /usage.".to_string()); + } "/init" => match crate::prompts::get_cli_prompt("init") { Some(prompt) => { return Some(StartupResult::NewSession { diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index 044e2c6130..2973f25356 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,10 +1,10 @@ +use once_cell::sync::Lazy; /// Theme and style definitions use std::collections::{HashMap, HashSet}; use std::io::IsTerminal; use std::path::Path; use std::time::{Duration, Instant}; -use once_cell::sync::Lazy; use ratatui::style::{Color, Modifier, Style}; #[cfg(unix)] diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index d16b2cf2b0..4681848bd3 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -359,6 +359,24 @@ pub async fn start_acp_dialog_turn( bitfun_core::util::errors::BitFunError::service(e.to_string()) })?; } + AcpClientStreamEvent::ContextUsageUpdated(usage) => { + app_handle + .emit( + "agentic://acp-context-usage-updated", + serde_json::json!({ + "sessionId": request.session_id, + "turnId": request.turn_id, + "clientId": request.client_id, + "used": usage.used, + "size": usage.size, + "cost": usage.cost, + "subagentParentInfo": null, + }), + ) + .map_err(|e| { + bitfun_core::util::errors::BitFunError::service(e.to_string()) + })?; + } AcpClientStreamEvent::Completed => { app_handle .emit( diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index d1a91df128..5e926d9e07 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -34,6 +34,8 @@ pub struct CreateSessionRequest { pub agent_type: String, pub workspace_path: String, #[serde(default)] + pub workspace_id: Option, + #[serde(default)] pub session_kind: Option, #[serde(default)] pub remote_connection_id: Option, @@ -123,6 +125,29 @@ pub struct CompactSessionRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivateSessionGoalRequest { + pub session_id: String, + #[serde(default)] + pub user_hint: Option, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivateSessionGoalResponse { + pub success: bool, + pub goal_text: String, + pub success_criteria: Vec, + pub kickoff_message: String, + pub display_message: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EnsureCoordinatorSessionRequest { @@ -545,12 +570,14 @@ pub async fn create_session( enable_context_compression: c.enable_context_compression.unwrap_or(true), compression_threshold: c.compression_threshold.unwrap_or(0.8), workspace_path: Some(request.workspace_path.clone()), + workspace_id: request.workspace_id.clone(), remote_connection_id: remote_conn.clone(), remote_ssh_host: remote_ssh_host.clone(), model_id: c.model_name, }) .unwrap_or(SessionConfig { workspace_path: Some(request.workspace_path.clone()), + workspace_id: request.workspace_id.clone(), remote_connection_id: remote_conn.clone(), remote_ssh_host: remote_ssh_host.clone(), ..Default::default() @@ -801,6 +828,64 @@ pub async fn compact_session( }) } +#[tauri::command] +pub async fn activate_session_goal( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: ActivateSessionGoalRequest, +) -> Result { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + + if coordinator + .get_session_manager() + .get_session(session_id) + .is_none() + { + let workspace_path = request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "workspace_path is required when the session is not loaded".to_string() + })?; + let effective = desktop_effective_session_storage_path( + &app_state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + coordinator + .restore_session(&effective, session_id) + .await + .map_err(|e| format!("Failed to restore session before activating goal mode: {e}"))?; + } + + let user_hint = request + .user_hint + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let activation = coordinator + .activate_session_goal(session_id.to_string(), user_hint) + .await + .map_err(|error| error.to_string())?; + + Ok(ActivateSessionGoalResponse { + success: true, + goal_text: activation.goal_text, + success_criteria: activation.success_criteria, + kickoff_message: activation.kickoff_message, + display_message: activation.display_message, + }) +} + #[tauri::command] pub async fn ensure_assistant_bootstrap( coordinator: State<'_, Arc>, diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index c60dce7460..7208d39738 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -30,6 +30,79 @@ pub struct FailedFile { pub error: String, } +fn normalize_decoded_file_path(mut path: String) -> String { + path = path.replace('\\', "/"); + + while path.starts_with("//") { + path = path[1..].to_string(); + } + + if let Some(rest) = path.strip_prefix('/') { + if rest.len() >= 2 { + let bytes = rest.as_bytes(); + if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + path = rest.to_string(); + } + } + } + + if path.len() >= 2 { + let bytes = path.as_bytes(); + if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + path = format!("{}{}", bytes[0].to_ascii_uppercase() as char, &path[1..]); + } + } + + path +} + +fn decode_file_uri(uri: &str) -> Option { + let trimmed = uri.trim(); + if !trimmed.starts_with("file://") { + return None; + } + + let rest = trimmed.strip_prefix("file://")?; + let path_part = if rest.starts_with('/') { + rest.to_string() + } else if let Some(slash_idx) = rest.find('/') { + let host = &rest[..slash_idx]; + if host.eq_ignore_ascii_case("localhost") { + rest[slash_idx..].to_string() + } else { + return None; + } + } else { + return None; + }; + + let decoded = urlencoding::decode(&path_part) + .map(|value| value.into_owned()) + .unwrap_or(path_part); + + Some(normalize_decoded_file_path(decoded)) +} + +#[allow(dead_code)] +fn parse_uri_list(content: &str) -> Vec { + content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(decode_file_uri) + .collect() +} + +fn parse_clipboard_path_segments(content: &str) -> Vec { + content + .split(|c| c == '\n' || c == '\r') + .flat_map(|segment| segment.split(',')) + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .map(|segment| decode_file_uri(segment).unwrap_or_else(|| segment.to_string())) + .collect() +} + #[cfg(target_os = "windows")] mod windows_clipboard { use std::ffi::OsString; @@ -98,9 +171,7 @@ mod windows_clipboard { if actual_len > 0 { let path = OsString::from_wide(&buffer[..actual_len as usize]); - if let Some(path_str) = path.to_str() { - files.push(path_str.to_string()); - } + files.push(path.to_string_lossy().into_owned()); } } @@ -111,28 +182,31 @@ mod windows_clipboard { #[cfg(target_os = "macos")] mod macos_clipboard { - pub fn get_clipboard_files() -> Result, String> { - use std::process::Command; + use super::parse_clipboard_path_segments; + use std::process::Command; + pub fn get_clipboard_files() -> Result, String> { let output = Command::new("osascript") .args(&[ "-e", r#" set theFiles to {} + set linefeed to ASCII character 10 + set output to "" try set theClip to the clipboard as «class furl» - set end of theFiles to POSIX path of theClip + set output to (POSIX path of theClip) & linefeed on error try set theClip to the clipboard as list repeat with aFile in theClip try - set end of theFiles to POSIX path of (aFile as alias) + set output to output & (POSIX path of (aFile as alias)) & linefeed end try end repeat end try end try - return theFiles as text + return output "#, ]) .output() @@ -140,12 +214,7 @@ mod macos_clipboard { if output.status.success() { let paths_str = String::from_utf8_lossy(&output.stdout); - let files: Vec = paths_str - .lines() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - Ok(files) + Ok(parse_clipboard_path_segments(&paths_str)) } else { Ok(Vec::new()) } @@ -154,31 +223,42 @@ mod macos_clipboard { #[cfg(target_os = "linux")] mod linux_clipboard { - pub fn get_clipboard_files() -> Result, String> { - use std::process::Command; + use super::parse_uri_list; + use std::process::Command; + fn read_xclip_uri_list() -> Option { let output = Command::new("xclip") - .args(&["-selection", "clipboard", "-t", "text/uri-list", "-o"]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let content = String::from_utf8_lossy(&output.stdout); - let files: Vec = content - .lines() - .filter(|line| line.starts_with("file://")) - .map(|line| { - let path = line.trim_start_matches("file://"); - urlencoding::decode(path) - .map(|s| s.into_owned()) - .unwrap_or_else(|_| path.to_string()) - }) - .collect(); - Ok(files) - } - _ => Ok(Vec::new()), + .args(["-selection", "clipboard", "-t", "text/uri-list", "-o"]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + None + } + } + + fn read_wl_paste_uri_list() -> Option { + let output = Command::new("wl-paste") + .args(["-t", "text/uri-list"]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + None } } + + pub fn get_clipboard_files() -> Result, String> { + let content = read_xclip_uri_list() + .or_else(read_wl_paste_uri_list) + .unwrap_or_default(); + + Ok(parse_uri_list(&content)) + } } fn get_clipboard_files_internal() -> Result, String> { @@ -306,14 +386,17 @@ pub async fn paste_files(request: PasteFilesRequest) -> Result std::path::PathBuf { - let parent = path.parent().unwrap_or(Path::new("")); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let extension = path.extension().and_then(|s| s.to_str()); let mut counter = 1; loop { let new_name = if let Some(ext) = extension { - format!("{} ({}). {}", stem, counter, ext) + format!("{} ({}).{}", stem, counter, ext) } else { format!("{} ({})", stem, counter) }; @@ -346,3 +429,79 @@ fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(), String> Ok(()) } + +#[cfg(test)] +mod tests { + use super::{decode_file_uri, generate_unique_path, parse_clipboard_path_segments, parse_uri_list}; + use std::path::Path; + + #[test] + fn decode_unix_file_uri() { + assert_eq!( + decode_file_uri("file:///tmp/example.txt").as_deref(), + Some("/tmp/example.txt") + ); + } + + #[test] + fn decode_localhost_file_uri() { + assert_eq!( + decode_file_uri("file://localhost/home/user/example.txt").as_deref(), + Some("/home/user/example.txt") + ); + } + + #[test] + fn decode_windows_file_uri() { + assert_eq!( + decode_file_uri("file:///C:/Users/dev/example.txt").as_deref(), + Some("C:/Users/dev/example.txt") + ); + } + + #[test] + fn decode_windows_file_uri_lowercases_drive_letter() { + assert_eq!( + decode_file_uri("file:///c:/Users/dev/example.txt").as_deref(), + Some("C:/Users/dev/example.txt") + ); + } + + #[test] + fn parse_clipboard_path_segments_handles_posix_paths() { + assert_eq!( + parse_clipboard_path_segments("/tmp/a.txt\n/tmp/b.txt"), + vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()] + ); + } + + #[test] + fn parse_clipboard_path_segments_handles_comma_separated_paths() { + assert_eq!( + parse_clipboard_path_segments("/tmp/a.txt,/tmp/b.txt"), + vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()] + ); + } + + #[test] + fn parse_clipboard_path_segments_decodes_file_uris() { + assert_eq!( + parse_clipboard_path_segments("file:///tmp/a.txt\r\nfile:///tmp/b.txt"), + vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()] + ); + } + + #[test] + fn generate_unique_path_uses_current_dir_when_parent_missing() { + let unique = generate_unique_path(Path::new("example.txt")); + assert_eq!(unique.file_name(), Some(std::ffi::OsStr::new("example (1).txt"))); + } + + #[test] + fn parse_uri_list_ignores_comments_and_blank_lines() { + let files = parse_uri_list( + "# comment\n\nfile:///tmp/a.txt\r\nfile://localhost/tmp/b.txt\n", + ); + assert_eq!(files, vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]); + } +} diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 03b2786755..4b956094bb 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -404,6 +404,17 @@ pub struct ReorderOpenedWorkspacesRequest { pub workspace_ids: Vec, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateWorkspaceInfoRequest { + pub workspace_id: String, + pub name: Option, + pub description: Option, + pub tags: Option>, + #[serde(default)] + pub related_paths: Option>, +} + #[derive(Debug, Deserialize)] pub struct TestAIConfigConnectionRequest { pub config: bitfun_core::service::config::types::AIModelConfig, @@ -1733,6 +1744,51 @@ pub async fn reorder_opened_workspaces( } } +#[tauri::command] +pub async fn update_workspace_info( + state: State<'_, AppState>, + app: tauri::AppHandle, + request: UpdateWorkspaceInfoRequest, +) -> Result { + let updates = bitfun_core::service::workspace::WorkspaceInfoUpdates { + name: request.name, + description: request.description, + tags: request.tags, + related_paths: request.related_paths, + }; + + match state + .workspace_service + .update_workspace_info(&request.workspace_id, updates) + .await + { + Ok(workspace_info) => { + let is_active_workspace = state + .workspace_service + .get_current_workspace() + .await + .map(|workspace| workspace.id == workspace_info.id) + .unwrap_or(false); + + if is_active_workspace { + apply_active_workspace_context(&state, &app, &workspace_info).await; + } + + info!( + "Workspace info updated: workspace_id={}, path={}", + workspace_info.id, + workspace_info.root_path.display() + ); + + Ok(WorkspaceInfoDto::from_workspace_info(&workspace_info)) + } + Err(error) => { + error!("Failed to update workspace info: {}", error); + Err(format!("Failed to update workspace info: {}", error)) + } + } +} + #[tauri::command] pub async fn get_current_workspace( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index 9ea691126c..0dc040ba8e 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -51,6 +51,14 @@ pub struct WorkspaceWorktreeInfoDto { pub is_main: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RelatedPathDto { + pub path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceInfoDto { @@ -69,6 +77,8 @@ pub struct WorkspaceInfoDto { pub identity: Option, #[serde(skip_serializing_if = "Option::is_none")] pub worktree: Option, + #[serde(default)] + pub related_paths: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub connection_id: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -134,6 +144,11 @@ impl WorkspaceInfoDto { .worktree .as_ref() .map(WorkspaceWorktreeInfoDto::from_workspace_worktree_info), + related_paths: info + .related_paths + .iter() + .map(RelatedPathDto::from_related_path) + .collect(), connection_id, connection_name, ssh_host, @@ -167,6 +182,15 @@ impl WorkspaceWorktreeInfoDto { } } +impl RelatedPathDto { + pub fn from_related_path(path: &bitfun_core::service::workspace::RelatedPath) -> Self { + Self { + path: path.path.clone(), + description: path.description.clone(), + } + } +} + impl WorkspaceTypeDto { pub fn from_workspace_type( workspace_type: &bitfun_core::service::workspace::manager::WorkspaceType, diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 9311a83a92..926e373760 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -28,6 +28,7 @@ pub mod miniapp_api; pub mod path_target; pub mod project_context_api; pub mod remote_connect_api; +pub mod review_platform_api; pub mod runtime_api; pub mod search_api; pub mod session_api; diff --git a/src/apps/desktop/src/api/review_platform_api.rs b/src/apps/desktop/src/api/review_platform_api.rs new file mode 100644 index 0000000000..4ac268432a --- /dev/null +++ b/src/apps/desktop/src/api/review_platform_api.rs @@ -0,0 +1,201 @@ +//! Review platform Tauri commands. + +use crate::api::app_state::AppState; +use bitfun_core::service::review_platform::{ + ReviewPlatformCiLog, ReviewPlatformDetailSection, ReviewPlatformKind, + ReviewPlatformPullRequestDetail, ReviewPlatformPullRequestDetailPage, ReviewPlatformService, + ReviewPlatformWorkspaceSnapshot, +}; +use log::error; +use serde::Deserialize; +use tauri::State; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformWorkspaceSnapshotRequest { + pub repository_path: String, + pub remote_id: Option, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailPageRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub section: ReviewPlatformDetailSection, + pub page: Option, + pub per_page: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestCiLogRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub ci_item_id: String, + pub ci_item_name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformUpdateAuthTokenRequest { + pub platform: ReviewPlatformKind, + pub host: String, + pub token: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformClearAuthTokenRequest { + pub platform: ReviewPlatformKind, + pub host: String, +} + +#[tauri::command] +pub async fn review_platform_get_workspace_snapshot( + _state: State<'_, AppState>, + request: ReviewPlatformWorkspaceSnapshotRequest, +) -> Result { + ReviewPlatformService::workspace_snapshot( + &request.repository_path, + request.remote_id.as_deref(), + request.page, + request.per_page, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform workspace snapshot: path={}, remote_id={:?}, error={}", + request.repository_path, request.remote_id, error + ); + format!( + "Failed to get review platform workspace snapshot: {}", + error + ) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_detail( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestDetailRequest, +) -> Result { + ReviewPlatformService::pull_request_detail( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform pull request detail: path={}, remote_id={}, pull_request_id={}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + error + ); + format!("Failed to get review platform pull request detail: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_detail_page( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestDetailPageRequest, +) -> Result { + ReviewPlatformService::pull_request_detail_page( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + request.section, + request.page, + request.per_page, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform pull request detail page: path={}, remote_id={}, pull_request_id={}, section={:?}, page={:?}, per_page={:?}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + request.section, + request.page, + request.per_page, + error + ); + format!( + "Failed to get review platform pull request detail page: {}", + error + ) + }) +} + +#[tauri::command] +pub async fn review_platform_get_pull_request_ci_log( + _state: State<'_, AppState>, + request: ReviewPlatformPullRequestCiLogRequest, +) -> Result { + ReviewPlatformService::pull_request_ci_log( + &request.repository_path, + &request.remote_id, + &request.pull_request_id, + &request.ci_item_id, + &request.ci_item_name, + ) + .await + .map_err(|error| { + error!( + "Failed to get review platform CI log: path={}, remote_id={}, pull_request_id={}, ci_item_id={}, error={}", + request.repository_path, + request.remote_id, + request.pull_request_id, + request.ci_item_id, + error + ); + format!("Failed to get review platform CI log: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_update_auth_token( + _state: State<'_, AppState>, + request: ReviewPlatformUpdateAuthTokenRequest, +) -> Result<(), String> { + ReviewPlatformService::update_auth_token(request.platform, &request.host, &request.token) + .await + .map_err(|error| { + error!( + "Failed to update review platform auth token: platform={:?}, host={}, error={}", + request.platform, request.host, error + ); + format!("Failed to update review platform auth token: {}", error) + }) +} + +#[tauri::command] +pub async fn review_platform_clear_auth_token( + _state: State<'_, AppState>, + request: ReviewPlatformClearAuthTokenRequest, +) -> Result<(), String> { + ReviewPlatformService::clear_auth_token(request.platform, &request.host) + .await + .map_err(|error| { + error!( + "Failed to clear review platform auth token: platform={:?}, host={}, error={}", + request.platform, request.host, error + ); + format!("Failed to clear review platform auth token: {}", error) + }) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 61f5b5acdf..cdbf0d9bfe 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -46,6 +46,7 @@ use api::i18n_api::*; use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; +use api::review_platform_api::*; use api::runtime_api::*; use api::search_api::*; use api::session_api::*; @@ -598,6 +599,7 @@ pub async fn _run() { api::agentic_api::ensure_coordinator_session, api::agentic_api::start_dialog_turn, api::agentic_api::compact_session, + api::agentic_api::activate_session_goal, api::agentic_api::ensure_assistant_bootstrap, api::agentic_api::cancel_dialog_turn, api::agentic_api::steer_dialog_turn, @@ -642,6 +644,7 @@ pub async fn _run() { refresh_model_client, get_app_state, update_app_status, + update_workspace_info, theme::show_agent_companion_desktop_pet, theme::hide_agent_companion_desktop_pet, theme::resize_agent_companion_desktop_pet, @@ -725,6 +728,12 @@ pub async fn _run() { git_is_repository, git_get_repository_basic, git_get_repository, + review_platform_get_workspace_snapshot, + review_platform_get_pull_request_detail, + review_platform_get_pull_request_detail_page, + review_platform_get_pull_request_ci_log, + review_platform_update_auth_token, + review_platform_clear_auth_token, git_get_status, git_get_branches, git_get_enhanced_branches, diff --git a/src/crates/acp/src/client/manager.rs b/src/crates/acp/src/client/manager.rs index f93d74e7ee..d22b5d1545 100644 --- a/src/crates/acp/src/client/manager.rs +++ b/src/crates/acp/src/client/manager.rs @@ -46,7 +46,9 @@ use super::requirements::{ install_remote_npm_cli_package, predownload_npm_adapter, probe_executable, probe_npm_adapter, probe_remote_executable, probe_remote_npx_adapter, resolve_configured_command, }; -use super::session_options::{model_config_id, session_options_from_state, AcpSessionOptions}; +use super::session_options::{ + model_config_id, session_options_from_state, AcpSessionContextUsage, AcpSessionOptions, +}; use super::session_persistence::AcpSessionPersistence; pub use super::session_persistence::CreateAcpFlowSessionRecordResponse; use super::stream::{ @@ -127,6 +129,7 @@ struct AcpRemoteSession { active: Option>, models: Option, config_options: Vec, + context_usage: Option, discard_pending_updates_before_next_prompt: bool, } @@ -154,6 +157,7 @@ impl AcpRemoteSession { active: None, models: None, config_options: Vec::new(), + context_usage: None, discard_pending_updates_before_next_prompt: false, } } @@ -859,6 +863,7 @@ impl AcpClientService { Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )) } @@ -920,6 +925,7 @@ impl AcpClientService { return Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )); } Err(error) => { @@ -947,6 +953,7 @@ impl AcpClientService { return Ok(session_options_from_state( session.models.as_ref(), &session.config_options, + session.context_usage.as_ref(), )); } @@ -1045,23 +1052,34 @@ impl AcpClientService { .await?; discard_pending_session_updates_if_needed(&mut session).await; - let active = session - .active - .as_mut() - .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; - active.send_prompt(prompt).map_err(protocol_error)?; + { + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.send_prompt(prompt).map_err(protocol_error)?; + } let mut round_tracker = AcpStreamRoundTracker::new(); let mut tool_call_tracker = AcpToolCallTracker::new(); loop { - match active.read_update().await.map_err(protocol_error)? { + let message = { + let active = session + .active + .as_mut() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + active.read_update().await.map_err(protocol_error)? + }; + + match message { SessionMessage::SessionMessage(dispatch) => { - for event in acp_dispatch_to_stream_events_with_tracker( + let events = acp_dispatch_to_stream_events_with_tracker( dispatch, &mut tool_call_tracker, ) - .await? - { + .await?; + update_session_context_usage(&mut session, &events); + for event in events { for event in round_tracker.apply(event) { on_event(event)?; } @@ -1991,14 +2009,29 @@ async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSessio } session.discard_pending_updates_before_next_prompt = false; - let Some(active) = session.active.as_mut() else { - return; - }; - let started_at = Instant::now(); let mut discarded_count = 0usize; while started_at.elapsed() < LOAD_REPLAY_DRAIN_MAX_DURATION { - match tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await { + let update = { + let Some(active) = session.active.as_mut() else { + return; + }; + tokio::time::timeout(LOAD_REPLAY_DRAIN_QUIET_WINDOW, active.read_update()).await + }; + + match update { + Ok(Ok(SessionMessage::SessionMessage(dispatch))) => { + let mut tracker = AcpToolCallTracker::new(); + if let Ok(events) = + acp_dispatch_to_stream_events_with_tracker(dispatch, &mut tracker).await + { + update_session_context_usage(session, &events); + } + discarded_count += 1; + } + Ok(Ok(SessionMessage::StopReason(_))) => { + discarded_count += 1; + } Ok(Ok(_)) => { discarded_count += 1; } @@ -2021,6 +2054,17 @@ async fn discard_pending_session_updates_if_needed(session: &mut AcpRemoteSessio } } +fn update_session_context_usage(session: &mut AcpRemoteSession, events: &[AcpClientStreamEvent]) { + let Some(usage) = events.iter().rev().find_map(|event| match event { + AcpClientStreamEvent::ContextUsageUpdated(usage) => Some(usage.clone()), + _ => None, + }) else { + return; + }; + + session.context_usage = Some(usage); +} + fn protocol_error(error: impl std::fmt::Display) -> BitFunError { BitFunError::service(format!("ACP protocol error: {}", error)) } diff --git a/src/crates/acp/src/client/mod.rs b/src/crates/acp/src/client/mod.rs index 58959c3e9d..3b26d9a12b 100644 --- a/src/crates/acp/src/client/mod.rs +++ b/src/crates/acp/src/client/mod.rs @@ -20,5 +20,5 @@ pub use manager::{ AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; -pub use session_options::{AcpSessionModelOption, AcpSessionOptions}; +pub use session_options::{AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions}; pub use stream::AcpClientStreamEvent; diff --git a/src/crates/acp/src/client/session_options.rs b/src/crates/acp/src/client/session_options.rs index 3f6ea88b57..c6fc11638b 100644 --- a/src/crates/acp/src/client/session_options.rs +++ b/src/crates/acp/src/client/session_options.rs @@ -1,9 +1,28 @@ use agent_client_protocol::schema::{ - ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, + Cost, ModelInfo, SessionConfigKind, SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOptions, SessionModelState, }; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionContextUsage { + pub used: u64, + pub size: u64, + #[serde(default)] + pub cost: Option, +} + +impl From for AcpSessionContextUsage { + fn from(update: agent_client_protocol::schema::UsageUpdate) -> Self { + Self { + used: update.used, + size: update.size, + cost: update.cost, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct AcpSessionOptions { @@ -13,6 +32,8 @@ pub struct AcpSessionOptions { pub available_models: Vec, #[serde(default)] pub model_config_id: Option, + #[serde(default)] + pub context_usage: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -27,7 +48,9 @@ pub struct AcpSessionModelOption { pub(super) fn session_options_from_state( models: Option<&SessionModelState>, config_options: &[SessionConfigOption], + context_usage: Option<&AcpSessionContextUsage>, ) -> AcpSessionOptions { + let context_usage = context_usage.cloned(); if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { return AcpSessionOptions { current_model_id: Some(models.current_model_id.to_string()), @@ -37,19 +60,24 @@ pub(super) fn session_options_from_state( .map(model_option_from_model_info) .collect(), model_config_id: None, + context_usage, }; } - model_config_option(config_options) - .map(|option| { - let (current_model_id, available_models) = select_model_values(option); - AcpSessionOptions { - current_model_id, - available_models, - model_config_id: Some(option.id.to_string()), - } - }) - .unwrap_or_default() + if let Some(option) = model_config_option(config_options) { + let (current_model_id, available_models) = select_model_values(option); + return AcpSessionOptions { + current_model_id, + available_models, + model_config_id: Some(option.id.to_string()), + context_usage, + }; + } + + AcpSessionOptions { + context_usage, + ..Default::default() + } } pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { @@ -119,7 +147,7 @@ mod tests { fn converts_native_model_state() { let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); - let options = session_options_from_state(Some(&state), &[]); + let options = session_options_from_state(Some(&state), &[], None); assert_eq!(options.current_model_id.as_deref(), Some("gpt-5.4")); assert_eq!(options.available_models.len(), 1); @@ -140,11 +168,34 @@ mod tests { ) .category(SessionConfigOptionCategory::Model); - let options = session_options_from_state(None, &[config]); + let options = session_options_from_state(None, &[config], None); assert_eq!(options.current_model_id.as_deref(), Some("fast")); assert_eq!(options.model_config_id.as_deref(), Some("model")); assert_eq!(options.available_models.len(), 2); assert_eq!(options.available_models[1].id, "smart"); } + + #[test] + fn includes_context_usage() { + let state = SessionModelState::new("gpt-5.4", vec![ModelInfo::new("gpt-5.4", "GPT 5.4")]); + let usage = AcpSessionContextUsage { + used: 42_000, + size: 128_000, + cost: Some(agent_client_protocol::schema::Cost::new(0.12, "USD")), + }; + + let options = session_options_from_state(Some(&state), &[], Some(&usage)); + + let context_usage = options.context_usage.expect("context usage"); + assert_eq!(context_usage.used, 42_000); + assert_eq!(context_usage.size, 128_000); + assert_eq!( + context_usage + .cost + .as_ref() + .map(|cost| cost.currency.as_str()), + Some("USD") + ); + } } diff --git a/src/crates/acp/src/client/stream.rs b/src/crates/acp/src/client/stream.rs index 024fcfb204..8a8b2c8eaa 100644 --- a/src/crates/acp/src/client/stream.rs +++ b/src/crates/acp/src/client/stream.rs @@ -8,6 +8,7 @@ use agent_client_protocol::util::MatchDispatch; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use bitfun_events::ToolEventData; +use super::session_options::AcpSessionContextUsage; use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; #[derive(Debug, Clone)] @@ -20,6 +21,7 @@ pub enum AcpClientStreamEvent { AgentText(String), AgentThought(String), ToolEvent(ToolEventData), + ContextUsageUpdated(AcpSessionContextUsage), Completed, Cancelled, } @@ -77,6 +79,7 @@ impl AcpStreamRoundTracker { events } AcpClientStreamEvent::ModelRoundStarted { .. } + | AcpClientStreamEvent::ContextUsageUpdated(_) | AcpClientStreamEvent::Completed | AcpClientStreamEvent::Cancelled => vec![event], } @@ -121,6 +124,11 @@ pub(super) async fn acp_dispatch_to_stream_events_with_tracker( SessionUpdate::ToolCallUpdate(tool_call_update) => { events.extend(acp_tool_call_update_events(tool_call_update, tracker)); } + SessionUpdate::UsageUpdate(usage_update) => { + events.push(AcpClientStreamEvent::ContextUsageUpdated( + AcpSessionContextUsage::from(usage_update), + )); + } _ => {} } Ok(()) @@ -423,12 +431,42 @@ mod tests { AcpClientStreamEvent::AgentText(_) => "text", AcpClientStreamEvent::AgentThought(_) => "thought", AcpClientStreamEvent::ToolEvent(_) => "tool", + AcpClientStreamEvent::ContextUsageUpdated(_) => "usage", AcpClientStreamEvent::Completed => "completed", AcpClientStreamEvent::Cancelled => "cancelled", }) .collect() } + #[test] + fn exposes_context_usage_updates() { + use agent_client_protocol::JsonRpcMessage; + + let mut tracker = AcpToolCallTracker::new(); + let notification = SessionNotification::new( + "session-1", + SessionUpdate::UsageUpdate(agent_client_protocol::schema::UsageUpdate::new( + 1_000, 4_000, + )), + ) + .to_untyped_message() + .expect("notification"); + let dispatch = agent_client_protocol::Dispatch::Notification(notification); + + let events = tokio::runtime::Runtime::new() + .expect("runtime") + .block_on(acp_dispatch_to_stream_events_with_tracker( + dispatch, + &mut tracker, + )) + .expect("dispatch"); + + assert!(matches!( + events.as_slice(), + [AcpClientStreamEvent::ContextUsageUpdated(usage)] if usage.used == 1_000 && usage.size == 4_000 + )); + } + #[test] fn starts_new_round_for_text_after_tool() { let mut tracker = AcpStreamRoundTracker::new(); diff --git a/src/crates/agent-tools/AGENTS.md b/src/crates/agent-tools/AGENTS.md index 02cfeb694a..a29106b0d3 100644 --- a/src/crates/agent-tools/AGENTS.md +++ b/src/crates/agent-tools/AGENTS.md @@ -10,7 +10,9 @@ the product tool runtime. - Do not depend on `bitfun-core`, concrete service crates, `tool-packs`, app crates, Tauri, Git, MCP, network clients, or CLI UI dependencies. - This crate may own `ToolResult`, validation DTOs, runtime restriction DTOs, - path-resolution DTOs, generic/static/dynamic provider contracts, pure + path-resolution DTOs, host path normalization, runtime artifact URI, + remote POSIX path pure contracts, allowed-list / collapsed-tool execution gate policy, + generic/static/dynamic provider contracts, pure manifest/exposure helpers, generic contextual prompt-manifest resolver contracts, generic catalog snapshot provider contracts, generic GetToolSpec catalog provider/detail/summary helpers, provider-backed GetToolSpec runtime diff --git a/src/crates/agent-tools/src/framework.rs b/src/crates/agent-tools/src/framework.rs index 17042906d9..721ba8df51 100644 --- a/src/crates/agent-tools/src/framework.rs +++ b/src/crates/agent-tools/src/framework.rs @@ -64,6 +64,98 @@ pub trait PortableToolContextProvider: Send + Sync { pub const GET_TOOL_SPEC_TOOL_NAME: &str = "GetToolSpec"; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CollapsedToolUsageError { + RequiresGetToolSpec { + tool_name: String, + get_tool_spec_tool_name: String, + }, +} + +impl fmt::Display for CollapsedToolUsageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::RequiresGetToolSpec { + tool_name, + get_tool_spec_tool_name, + } => write!( + formatter, + "Tool '{tool_name}' is collapsed. Call {get_tool_spec_tool_name} first with {{\"tool_name\":\"{tool_name}\"}} to read its full usage instructions and input schema, then try again." + ), + } + } +} + +impl std::error::Error for CollapsedToolUsageError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolExecutionAccessError { + NotInAllowedList { + tool_name: String, + allowed_tools: Vec, + }, +} + +impl fmt::Display for ToolExecutionAccessError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotInAllowedList { + tool_name, + allowed_tools, + } => write!( + formatter, + "Tool '{tool_name}' is not in the allowed list: {allowed_tools:?}" + ), + } + } +} + +impl std::error::Error for ToolExecutionAccessError {} + +pub fn validate_tool_allowed_by_list( + tool_name: &str, + allowed_tools: &[String], +) -> Result<(), ToolExecutionAccessError> { + if allowed_tools.is_empty() || allowed_tools.iter().any(|allowed| allowed == tool_name) { + return Ok(()); + } + + Err(ToolExecutionAccessError::NotInAllowedList { + tool_name: tool_name.to_string(), + allowed_tools: allowed_tools.to_vec(), + }) +} + +pub fn validate_collapsed_tool_usage( + tool_name: &str, + collapsed_tools: &[String], + loaded_collapsed_tools: &[String], + get_tool_spec_tool_name: &str, +) -> Result<(), CollapsedToolUsageError> { + if tool_name == get_tool_spec_tool_name { + return Ok(()); + } + + if !collapsed_tools + .iter() + .any(|collapsed_tool| collapsed_tool == tool_name) + { + return Ok(()); + } + + if loaded_collapsed_tools + .iter() + .any(|loaded_tool| loaded_tool == tool_name) + { + return Ok(()); + } + + Err(CollapsedToolUsageError::RequiresGetToolSpec { + tool_name: tool_name.to_string(), + get_tool_spec_tool_name: get_tool_spec_tool_name.to_string(), + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ToolExposure { Expanded, @@ -1330,44 +1422,267 @@ impl ToolPathResolution { let root = self.runtime_root.as_ref()?; let relative = absolute_child_path.strip_prefix(root).ok()?; let relative_str = relative.to_string_lossy().replace('\\', "/"); - build_bitfun_runtime_uri(scope, &relative_str) + build_bitfun_runtime_uri(scope, &relative_str).ok() } } -fn build_bitfun_runtime_uri(workspace_scope: &str, relative_path: &str) -> Option { - let scope = workspace_scope.trim(); - if scope.is_empty() { - return None; +pub const BITFUN_RUNTIME_URI_PREFIX: &str = "bitfun://runtime/"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedBitFunRuntimeUri { + pub workspace_scope: String, + pub relative_path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolPathContractError { + EmptyRuntimeArtifactPath, + RuntimeArtifactPathEscapesRoot, + UnsupportedRuntimeUri { uri: String }, + MissingRuntimeUriWorkspaceScope, + MissingRuntimeUriArtifactPath, + EmptyRuntimeWorkspaceScope, + EmptyPath, + MissingWorkspaceRoot { path: String }, +} + +impl fmt::Display for ToolPathContractError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyRuntimeArtifactPath => { + write!(formatter, "Runtime artifact path cannot be empty") + } + Self::RuntimeArtifactPathEscapesRoot => { + write!(formatter, "Runtime artifact path cannot escape its root") + } + Self::UnsupportedRuntimeUri { uri } => { + write!(formatter, "Unsupported runtime URI: {uri}") + } + Self::MissingRuntimeUriWorkspaceScope => { + write!(formatter, "Runtime URI is missing workspace scope") + } + Self::MissingRuntimeUriArtifactPath => { + write!(formatter, "Runtime URI is missing artifact path") + } + Self::EmptyRuntimeWorkspaceScope => { + write!(formatter, "Runtime URI workspace scope cannot be empty") + } + Self::EmptyPath => write!(formatter, "path cannot be empty"), + Self::MissingWorkspaceRoot { path } => { + write!( + formatter, + "A workspace path is required to resolve relative path: {path}" + ) + } + } } +} - Some(format!( - "bitfun://runtime/{}/{}", - scope, - normalize_runtime_relative_path(relative_path)? - )) +impl std::error::Error for ToolPathContractError {} + +pub fn is_bitfun_runtime_uri(path: &str) -> bool { + path.trim().starts_with(BITFUN_RUNTIME_URI_PREFIX) +} + +pub fn normalize_host_path(path: &str) -> String { + let path = Path::new(path); + let mut components = Vec::new(); + for component in path.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if !components.is_empty() { + components.pop(); + } + } + component => components.push(component), + } + } + components + .iter() + .collect::() + .to_string_lossy() + .to_string() +} + +pub fn resolve_host_path_with_workspace( + path: &str, + workspace_root: Option<&Path>, +) -> Result { + if Path::new(path).is_absolute() { + Ok(normalize_host_path(path)) + } else { + let base_path = + workspace_root.ok_or_else(|| ToolPathContractError::MissingWorkspaceRoot { + path: path.to_string(), + })?; + + Ok(normalize_host_path( + base_path.join(path).to_string_lossy().as_ref(), + )) + } } -fn normalize_runtime_relative_path(path: &str) -> Option { +pub fn resolve_host_path(path: &str) -> Result { + resolve_host_path_with_workspace(path, None) +} + +pub fn resolve_workspace_tool_path( + path: &str, + workspace_root: Option<&str>, + workspace_is_remote: bool, +) -> Result { + if workspace_is_remote { + posix_resolve_path_with_workspace(path, workspace_root) + } else { + resolve_host_path_with_workspace(path, workspace_root.map(Path::new)) + } +} + +pub fn normalize_runtime_relative_path(path: &str) -> Result { let normalized = path.trim().replace('\\', "/"); let trimmed = normalized.trim_matches('/'); if trimmed.is_empty() { - return None; + return Err(ToolPathContractError::EmptyRuntimeArtifactPath); } let mut segments = Vec::new(); for part in trimmed.split('/') { match part { "" | "." => continue, - ".." => return None, + ".." => return Err(ToolPathContractError::RuntimeArtifactPathEscapesRoot), value => segments.push(value.to_string()), } } if segments.is_empty() { - return None; + return Err(ToolPathContractError::EmptyRuntimeArtifactPath); + } + + Ok(segments.join("/")) +} + +pub fn parse_bitfun_runtime_uri( + path: &str, +) -> Result { + let trimmed = path.trim(); + let suffix = trimmed + .strip_prefix(BITFUN_RUNTIME_URI_PREFIX) + .ok_or_else(|| ToolPathContractError::UnsupportedRuntimeUri { + uri: path.to_string(), + })?; + + let mut parts = suffix.splitn(2, '/'); + let workspace_scope = parts + .next() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or(ToolPathContractError::MissingRuntimeUriWorkspaceScope)? + .to_string(); + let relative_path = parts + .next() + .ok_or(ToolPathContractError::MissingRuntimeUriArtifactPath)?; + + Ok(ParsedBitFunRuntimeUri { + workspace_scope, + relative_path: normalize_runtime_relative_path(relative_path)?, + }) +} + +pub fn build_bitfun_runtime_uri( + workspace_scope: &str, + relative_path: &str, +) -> Result { + let scope = workspace_scope.trim(); + if scope.is_empty() { + return Err(ToolPathContractError::EmptyRuntimeWorkspaceScope); } - Some(segments.join("/")) + Ok(format!( + "{}{}/{}", + BITFUN_RUNTIME_URI_PREFIX, + scope, + normalize_runtime_relative_path(relative_path)? + )) +} + +pub fn posix_style_path_is_absolute(path: &str) -> bool { + let path = path.trim().replace('\\', "/"); + path.starts_with('/') +} + +pub fn normalize_absolute_posix_path(path: &str) -> String { + let normalized = path.trim().replace('\\', "/"); + let is_absolute = normalized.starts_with('/'); + let mut segments = Vec::new(); + + for segment in normalized.split('/') { + match segment { + "" | "." => {} + ".." => { + if !segments.is_empty() { + segments.pop(); + } + } + value => segments.push(value.to_string()), + } + } + + let body = segments.join("/"); + if is_absolute { + if body.is_empty() { + "/".to_string() + } else { + format!("/{}", body) + } + } else { + body + } +} + +pub fn is_remote_posix_path_within_root(path: &str, root: &str) -> bool { + let normalized_path = normalize_absolute_posix_path(path); + let normalized_root = normalize_absolute_posix_path(root); + + if !normalized_path.starts_with('/') || !normalized_root.starts_with('/') { + return false; + } + + if normalized_root == "/" { + return true; + } + + normalized_path == normalized_root + || normalized_path + .strip_prefix(&normalized_root) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +pub fn posix_resolve_path_with_workspace( + path: &str, + workspace_root: Option<&str>, +) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(ToolPathContractError::EmptyPath); + } + + let normalized_input = path.replace('\\', "/"); + + let combined = if posix_style_path_is_absolute(&normalized_input) { + normalized_input + } else { + let base = workspace_root + .ok_or_else(|| ToolPathContractError::MissingWorkspaceRoot { + path: path.to_string(), + })? + .trim() + .replace('\\', "/"); + let base = base.trim_end_matches('/'); + format!("{}/{}", base, normalized_input) + }; + + Ok(normalize_absolute_posix_path(&combined)) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/src/crates/agent-tools/src/lib.rs b/src/crates/agent-tools/src/lib.rs index 10c46fd228..b12d4cb0dc 100644 --- a/src/crates/agent-tools/src/lib.rs +++ b/src/crates/agent-tools/src/lib.rs @@ -11,6 +11,19 @@ pub use bitfun_runtime_ports::{ DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, }; pub use framework::{ + BITFUN_RUNTIME_URI_PREFIX, CollapsedToolUsageError, ContextualToolManifest, + ContextualToolManifestItem, ContextualVisibleTools, DynamicMcpToolInfo, DynamicToolInfo, + GET_TOOL_SPEC_TOOL_NAME, GetToolSpecCatalogProvider, GetToolSpecCollapsedToolSummary, + GetToolSpecDetail, GetToolSpecExecutionError, GetToolSpecExecutionPlan, + GetToolSpecLoadObservation, GetToolSpecRuntime, ParsedBitFunRuntimeUri, + PortableToolContextProvider, PromptVisibleToolManifestItem, SnapshotToolDecorator, + SnapshotToolWrapper, SnapshotToolWrapperRef, StaticToolProvider, StaticToolProviderGroup, + ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef, + ToolExecutionAccessError, ToolExposure, ToolManifestDefinition, ToolManifestPolicyResolution, + ToolManifestPolicyTool, ToolPathBackend, ToolPathContractError, ToolPathOperation, + ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, ToolRenderOptions, + ToolRestrictionError, ToolResult, ToolRuntimeAssembly, ToolRuntimeRestrictions, + ToolWorkspaceKind, ValidationResult, build_bitfun_runtime_uri, build_collapsed_tool_stub_definition, build_get_tool_spec_assistant_detail, build_get_tool_spec_catalog_description, build_get_tool_spec_catalog_description_from_provider, build_get_tool_spec_collapsed_tool_entry, build_get_tool_spec_description, @@ -18,24 +31,17 @@ pub use framework::{ build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, build_tool_manifest_policy_tools, collect_loaded_collapsed_tool_names, get_tool_spec_input_schema, get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, - get_tool_spec_needs_permissions, get_tool_spec_short_description, - render_get_tool_spec_tool_use_message, resolve_contextual_tool_manifest, - resolve_contextual_tool_manifest_from_provider, resolve_contextual_visible_tools, - resolve_contextual_visible_tools_from_provider, resolve_get_tool_spec_detail, - resolve_get_tool_spec_detail_from_provider, resolve_get_tool_spec_execution_plan, - resolve_get_tool_spec_execution_result_from_provider, resolve_readonly_enabled_tools, - resolve_tool_manifest_policy, sort_tool_manifest_definitions, - summarize_get_tool_spec_collapsed_tools, tool_manifest_sort_rank, validate_get_tool_spec_input, - ContextualToolManifest, ContextualToolManifestItem, ContextualVisibleTools, DynamicMcpToolInfo, - DynamicToolInfo, GetToolSpecCatalogProvider, GetToolSpecCollapsedToolSummary, - GetToolSpecDetail, GetToolSpecExecutionError, GetToolSpecExecutionPlan, - GetToolSpecLoadObservation, GetToolSpecRuntime, PortableToolContextProvider, - PromptVisibleToolManifestItem, SnapshotToolDecorator, SnapshotToolWrapper, - SnapshotToolWrapperRef, StaticToolProvider, StaticToolProviderGroup, ToolCatalogRuntime, - ToolCatalogSnapshotProvider, ToolContextFacts, ToolDecoratorRef, ToolExposure, - ToolManifestDefinition, ToolManifestPolicyResolution, ToolManifestPolicyTool, ToolPathBackend, - ToolPathOperation, ToolPathPolicy, ToolPathResolution, ToolRef, ToolRegistry, ToolRegistryItem, - ToolRenderOptions, ToolRestrictionError, ToolResult, ToolRuntimeAssembly, - ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult, GET_TOOL_SPEC_TOOL_NAME, + get_tool_spec_needs_permissions, get_tool_spec_short_description, is_bitfun_runtime_uri, + is_remote_posix_path_within_root, normalize_absolute_posix_path, normalize_host_path, + normalize_runtime_relative_path, parse_bitfun_runtime_uri, posix_resolve_path_with_workspace, + posix_style_path_is_absolute, render_get_tool_spec_tool_use_message, + resolve_contextual_tool_manifest, resolve_contextual_tool_manifest_from_provider, + resolve_contextual_visible_tools, resolve_contextual_visible_tools_from_provider, + resolve_get_tool_spec_detail, resolve_get_tool_spec_detail_from_provider, + resolve_get_tool_spec_execution_plan, resolve_get_tool_spec_execution_result_from_provider, + resolve_host_path, resolve_host_path_with_workspace, resolve_readonly_enabled_tools, + resolve_tool_manifest_policy, resolve_workspace_tool_path, sort_tool_manifest_definitions, + summarize_get_tool_spec_collapsed_tools, tool_manifest_sort_rank, + validate_collapsed_tool_usage, validate_get_tool_spec_input, validate_tool_allowed_by_list, }; pub use input_validator::InputValidator; diff --git a/src/crates/agent-tools/tests/tool_contracts.rs b/src/crates/agent-tools/tests/tool_contracts.rs index 1d260c320a..20a95bb562 100644 --- a/src/crates/agent-tools/tests/tool_contracts.rs +++ b/src/crates/agent-tools/tests/tool_contracts.rs @@ -1,4 +1,16 @@ use bitfun_agent_tools::{ + ContextualToolManifestItem, DynamicToolDescriptor, DynamicToolProvider, + GetToolSpecCatalogProvider, PortResult, PortableToolContextProvider, StaticToolProvider, + StaticToolProviderGroup, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolDecorator, + ToolDecoratorRef, ToolRegistry, ToolRegistryItem, ToolRuntimeAssembly, +}; +use bitfun_agent_tools::{ + DynamicMcpToolInfo, DynamicToolInfo, GET_TOOL_SPEC_TOOL_NAME, GetToolSpecCollapsedToolSummary, + GetToolSpecExecutionError, GetToolSpecExecutionPlan, GetToolSpecLoadObservation, + GetToolSpecRuntime, InputValidator, PromptVisibleToolManifestItem, ToolContextFacts, + ToolExposure, ToolImageAttachment, ToolManifestDefinition, ToolManifestPolicyTool, + ToolPathBackend, ToolPathResolution, ToolRenderOptions, ToolResult, ToolRuntimeRestrictions, + ToolWorkspaceKind, ValidationResult, build_bitfun_runtime_uri, build_collapsed_tool_stub_definition, build_get_tool_spec_assistant_detail, build_get_tool_spec_catalog_description, build_get_tool_spec_catalog_description_from_provider, build_get_tool_spec_collapsed_tool_entry, build_get_tool_spec_description, @@ -6,24 +18,16 @@ use bitfun_agent_tools::{ build_get_tool_spec_duplicate_load_result, build_prompt_visible_tool_manifest_definitions, collect_loaded_collapsed_tool_names, get_tool_spec_input_schema, get_tool_spec_is_concurrency_safe, get_tool_spec_is_readonly, get_tool_spec_needs_permissions, - get_tool_spec_short_description, render_get_tool_spec_tool_use_message, - resolve_contextual_tool_manifest, resolve_contextual_tool_manifest_from_provider, - resolve_get_tool_spec_detail, resolve_get_tool_spec_detail_from_provider, - resolve_get_tool_spec_execution_result_from_provider, resolve_readonly_enabled_tools, - resolve_tool_manifest_policy, sort_tool_manifest_definitions, - summarize_get_tool_spec_collapsed_tools, validate_get_tool_spec_input, DynamicMcpToolInfo, - DynamicToolInfo, GetToolSpecCollapsedToolSummary, GetToolSpecExecutionError, - GetToolSpecExecutionPlan, GetToolSpecLoadObservation, GetToolSpecRuntime, InputValidator, - PromptVisibleToolManifestItem, ToolContextFacts, ToolExposure, ToolImageAttachment, - ToolManifestDefinition, ToolManifestPolicyTool, ToolPathBackend, ToolPathResolution, - ToolRenderOptions, ToolResult, ToolRuntimeRestrictions, ToolWorkspaceKind, ValidationResult, - GET_TOOL_SPEC_TOOL_NAME, -}; -use bitfun_agent_tools::{ - ContextualToolManifestItem, DynamicToolDescriptor, DynamicToolProvider, - GetToolSpecCatalogProvider, PortResult, PortableToolContextProvider, StaticToolProvider, - StaticToolProviderGroup, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolDecorator, - ToolDecoratorRef, ToolRegistry, ToolRegistryItem, ToolRuntimeAssembly, + get_tool_spec_short_description, is_bitfun_runtime_uri, is_remote_posix_path_within_root, + normalize_host_path, normalize_runtime_relative_path, parse_bitfun_runtime_uri, + posix_resolve_path_with_workspace, posix_style_path_is_absolute, + render_get_tool_spec_tool_use_message, resolve_contextual_tool_manifest, + resolve_contextual_tool_manifest_from_provider, resolve_get_tool_spec_detail, + resolve_get_tool_spec_detail_from_provider, + resolve_get_tool_spec_execution_result_from_provider, resolve_host_path_with_workspace, + resolve_readonly_enabled_tools, resolve_tool_manifest_policy, resolve_workspace_tool_path, + sort_tool_manifest_definitions, summarize_get_tool_spec_collapsed_tools, + validate_collapsed_tool_usage, validate_get_tool_spec_input, validate_tool_allowed_by_list, }; use serde_json::json; use std::path::PathBuf; @@ -282,6 +286,155 @@ fn path_resolution_contract_keeps_backend_and_runtime_helpers() { ); } +#[test] +fn runtime_uri_contract_is_provider_neutral_and_normalized() { + let uri = build_bitfun_runtime_uri("workspace-123", r"plans\demo.plan.md") + .expect("runtime URI should build"); + + assert_eq!(uri, "bitfun://runtime/workspace-123/plans/demo.plan.md"); + assert!(is_bitfun_runtime_uri(&uri)); + + let parsed = parse_bitfun_runtime_uri(&uri).expect("runtime URI should parse"); + assert_eq!(parsed.workspace_scope, "workspace-123"); + assert_eq!(parsed.relative_path, "plans/demo.plan.md"); + assert_eq!( + normalize_runtime_relative_path("/sessions/turn-1/result.json") + .expect("relative path should normalize"), + "sessions/turn-1/result.json" + ); +} + +#[test] +fn runtime_uri_contract_rejects_escape_and_invalid_scope() { + let escape = build_bitfun_runtime_uri("workspace-123", "../secret.txt") + .expect_err("runtime URI should reject parent directory escape"); + assert_eq!( + escape.to_string(), + "Runtime artifact path cannot escape its root" + ); + + let empty_scope = + build_bitfun_runtime_uri(" ", "logs/tool.txt").expect_err("scope should be required"); + assert_eq!( + empty_scope.to_string(), + "Runtime URI workspace scope cannot be empty" + ); + + let unsupported = + parse_bitfun_runtime_uri("/tmp/result.txt").expect_err("non-runtime URI should fail"); + assert_eq!( + unsupported.to_string(), + "Unsupported runtime URI: /tmp/result.txt" + ); +} + +#[test] +fn collapsed_tool_usage_gate_preserves_get_tool_spec_unlock_contract() { + let collapsed_tools = vec!["WebFetch".to_string()]; + let loaded_collapsed_tools = Vec::new(); + + let err = validate_collapsed_tool_usage( + "WebFetch", + &collapsed_tools, + &loaded_collapsed_tools, + GET_TOOL_SPEC_TOOL_NAME, + ) + .expect_err("collapsed tool should require GetToolSpec unlock"); + assert_eq!( + err.to_string(), + "Tool 'WebFetch' is collapsed. Call GetToolSpec first with {\"tool_name\":\"WebFetch\"} to read its full usage instructions and input schema, then try again." + ); + + let loaded_collapsed_tools = vec!["WebFetch".to_string()]; + validate_collapsed_tool_usage( + "WebFetch", + &collapsed_tools, + &loaded_collapsed_tools, + GET_TOOL_SPEC_TOOL_NAME, + ) + .expect("loaded collapsed tool should be executable"); + + validate_collapsed_tool_usage( + GET_TOOL_SPEC_TOOL_NAME, + &collapsed_tools, + &[], + GET_TOOL_SPEC_TOOL_NAME, + ) + .expect("GetToolSpec itself is the unlock path"); +} + +#[test] +fn tool_allowed_list_gate_preserves_pipeline_rejection_contract() { + validate_tool_allowed_by_list("Read", &[]) + .expect("empty allowed-list should preserve allow-all behavior"); + + let allowed_tools = vec!["Read".to_string(), "GetToolSpec".to_string()]; + validate_tool_allowed_by_list("Read", &allowed_tools).expect("listed tool should be allowed"); + + let err = validate_tool_allowed_by_list("Bash", &allowed_tools) + .expect_err("unlisted tool should be rejected"); + assert_eq!( + err.to_string(), + "Tool 'Bash' is not in the allowed list: [\"Read\", \"GetToolSpec\"]" + ); +} + +#[test] +fn remote_posix_path_contract_keeps_workspace_containment_semantics() { + assert!(posix_style_path_is_absolute(r"\home\workspace")); + assert_eq!( + posix_resolve_path_with_workspace(r"src\lib.rs", Some("/home/project")) + .expect("relative remote path should resolve"), + "/home/project/src/lib.rs" + ); + assert!(is_remote_posix_path_within_root( + "/home/project/src/lib.rs", + "/home/project" + )); + assert!(!is_remote_posix_path_within_root( + "/home/project2/src/lib.rs", + "/home/project" + )); +} + +#[test] +fn host_path_contract_keeps_local_workspace_resolution_semantics() { + let normalized = normalize_host_path("repo/./src/../README.md"); + assert_eq!( + PathBuf::from(normalized), + PathBuf::from("repo").join("README.md") + ); + + let workspace = PathBuf::from("/repo/project"); + let resolved = resolve_host_path_with_workspace("src/main.rs", Some(workspace.as_path())) + .expect("relative local path should resolve from workspace"); + assert_eq!( + PathBuf::from(resolved), + workspace.join("src").join("main.rs") + ); + + let missing = resolve_host_path_with_workspace("src/main.rs", None) + .expect_err("relative local path should require a workspace"); + assert_eq!( + missing.to_string(), + "A workspace path is required to resolve relative path: src/main.rs" + ); +} + +#[test] +fn unified_tool_path_contract_selects_host_or_remote_semantics() { + let local = resolve_workspace_tool_path("src/lib.rs", Some("/repo/project"), false) + .expect("local path should resolve"); + assert_eq!( + PathBuf::from(local), + PathBuf::from("/repo/project/src/lib.rs") + ); + + let remote = resolve_workspace_tool_path("src/lib.rs", Some("/home/project"), true) + .expect("remote path should resolve"); + assert_eq!(remote, "/home/project/src/lib.rs"); +} + #[test] fn dynamic_tool_provider_contract_is_available_from_agent_tools_boundary() { fn assert_provider_contract() {} @@ -481,9 +634,10 @@ fn collapsed_tool_stub_definition_preserves_prompt_visible_guardrail() { assert_eq!(stub.name, "WebFetch"); assert!(stub.description.contains("Fetch a URL")); - assert!(stub - .description - .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); + assert!( + stub.description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}") + ); assert_eq!( stub.parameters, json!({ @@ -551,9 +705,11 @@ fn prompt_visible_manifest_builder_preserves_expanded_and_collapsed_contract() { definitions[0].parameters["properties"]["command"]["type"], json!("string") ); - assert!(definitions[2] - .description - .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); + assert!( + definitions[2] + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}") + ); } #[test] @@ -564,10 +720,12 @@ fn get_tool_spec_contract_preserves_input_schema_and_validation() { assert_eq!(schema["additionalProperties"], false); assert_eq!(schema["required"], json!(["tool_name"])); assert_eq!(schema["properties"]["tool_name"]["type"], "string"); - assert!(schema["properties"]["tool_name"]["description"] - .as_str() - .unwrap_or_default() - .contains("canonical casing")); + assert!( + schema["properties"]["tool_name"]["description"] + .as_str() + .unwrap_or_default() + .contains("canonical casing") + ); let missing = validate_get_tool_spec_input(&json!({})); assert!(!missing.result); @@ -691,7 +849,9 @@ fn get_tool_spec_contract_builds_duplicate_load_result() { assert_eq!(data["already_loaded"], true); assert_eq!( result_for_assistant.as_deref(), - Some("Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly.") + Some( + "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly." + ) ); assert_eq!(image_attachments, None); } @@ -755,10 +915,12 @@ fn get_tool_spec_contract_plans_duplicate_load_without_core_context() { assert_eq!(data["tool_name"], "WebFetch"); assert_eq!(data["already_loaded"], true); - assert!(result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("already loaded in the current conversation")); + assert!( + result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("already loaded in the current conversation") + ); assert_eq!(image_attachments, None); } @@ -1325,9 +1487,11 @@ async fn contextual_manifest_resolver_preserves_runtime_visible_manifest_contrac .iter() .find(|tool| tool.name == "WebFetch") .expect("collapsed WebFetch stub"); - assert!(web_fetch - .description - .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); + assert!( + web_fetch + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}") + ); assert_eq!(web_fetch.parameters["additionalProperties"], false); } @@ -1617,10 +1781,12 @@ async fn get_tool_spec_provider_execution_returns_duplicate_result_without_detai assert_eq!(data["tool_name"], "WebFetch"); assert_eq!(data["already_loaded"], true); - assert!(result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("already loaded in the current conversation")); + assert!( + result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("already loaded in the current conversation") + ); assert_eq!(image_attachments, None); } @@ -1733,9 +1899,11 @@ async fn get_tool_spec_runtime_facade_owns_tool_result_vector_adapter_shape() { panic!("expected normal detail result"); }; assert_eq!(data["tool_name"], "WebFetch"); - assert!(result_for_assistant - .expect("assistant detail") - .contains("\nWebFetch description for agentic")); + assert!( + result_for_assistant + .expect("assistant detail") + .contains("\nWebFetch description for agentic") + ); assert_eq!(image_attachments, None); let duplicate_runtime = @@ -1764,7 +1932,9 @@ async fn get_tool_spec_runtime_facade_owns_tool_result_vector_adapter_shape() { assert_eq!(data["tool_name"], "WebFetch"); assert_eq!( result_for_assistant.as_deref(), - Some("Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly.") + Some( + "Tool 'WebFetch' is already loaded in the current conversation. Do not call GetToolSpec again for it. Use 'WebFetch' directly." + ) ); assert!(image_attachments.is_none()); } diff --git a/src/crates/ai-adapters/src/client/http.rs b/src/crates/ai-adapters/src/client/http.rs index 545e3f0661..826ac82b57 100644 --- a/src/crates/ai-adapters/src/client/http.rs +++ b/src/crates/ai-adapters/src/client/http.rs @@ -9,6 +9,7 @@ pub(crate) fn create_http_client( skip_ssl_verify: bool, ) -> Client { let mut builder = Client::builder() + .use_rustls_tls() .connect_timeout(std::time::Duration::from_secs( AIClient::STREAM_CONNECT_TIMEOUT_SECS, )) diff --git a/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs b/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs index f61bcbc82f..ea2847ef15 100644 --- a/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs +++ b/src/crates/ai-adapters/src/providers/anthropic/message_converter.rs @@ -41,7 +41,8 @@ impl AnthropicMessageConverter { } // Anthropic requires user/assistant messages to alternate - let merged_messages = Self::merge_consecutive_messages(anthropic_messages); + let mut merged_messages = Self::merge_consecutive_messages(anthropic_messages); + Self::trim_final_assistant_trailing_whitespace(&mut merged_messages); (system_message, merged_messages) } @@ -107,6 +108,36 @@ impl AnthropicMessageConverter { merged } + fn trim_final_assistant_trailing_whitespace(messages: &mut [Value]) { + // Anthropic allows assistant prefill, but rejects final assistant text that ends in whitespace. + let Some(last) = messages.last_mut() else { + return; + }; + if last.get("role").and_then(|role| role.as_str()) != Some("assistant") { + return; + } + + match last.get_mut("content") { + Some(Value::String(text)) => { + let trimmed_len = text.trim_end().len(); + text.truncate(trimmed_len); + } + Some(Value::Array(blocks)) => { + let Some(Value::Object(block)) = blocks.last_mut() else { + return; + }; + if block.get("type").and_then(|value| value.as_str()) != Some("text") { + return; + } + if let Some(Value::String(text)) = block.get_mut("text") { + let trimmed_len = text.trim_end().len(); + text.truncate(trimmed_len); + } + } + _ => {} + } + } + fn convert_user_message(msg: Message) -> Value { let content = msg.content.unwrap_or_default(); @@ -257,4 +288,22 @@ mod tests { assert_eq!(content[0]["thinking"], json!("")); assert_eq!(content[0]["signature"], json!("sig_1")); } + + #[test] + fn trims_trailing_whitespace_from_final_assistant_prefill() { + let (_, messages) = AnthropicMessageConverter::convert_messages(vec![ + Message::user("Generate the file content.".to_string()), + Message::assistant("\n".to_string()), + ]); + + let content = messages + .last() + .expect("final assistant message") + .get("content") + .and_then(|value| value.as_array()) + .expect("assistant content"); + + assert_eq!(content[0]["type"], json!("text")); + assert_eq!(content[0]["text"], json!("")); + } } diff --git a/src/crates/ai-adapters/src/stream/types/openai.rs b/src/crates/ai-adapters/src/stream/types/openai.rs index cbdcff3fe7..a541e77998 100644 --- a/src/crates/ai-adapters/src/stream/types/openai.rs +++ b/src/crates/ai-adapters/src/stream/types/openai.rs @@ -124,8 +124,9 @@ struct FunctionCall { pub struct OpenAISSEData { #[allow(dead_code)] id: String, + #[serde(default)] #[allow(dead_code)] - created: u64, + created: Option, #[allow(dead_code)] model: String, choices: Vec, @@ -438,6 +439,41 @@ mod tests { assert_eq!(responses[0].finish_reason.as_deref(), Some("stop")); } + #[test] + fn handles_missing_created_in_openai_compatible_chunk() { + let raw = r#"{ + "id": "chatcmpl_test", + "object": "chat.completion.chunk", + "model": "compatible-model", + "choices": [{ + "index": 0, + "delta": { + "content": "hello" + }, + "finish_reason": null + }], + "usage": { + "prompt_tokens": 2, + "completion_tokens": 1, + "total_tokens": 3 + } + }"#; + + let sse_data: OpenAISSEData = + serde_json::from_str(raw).expect("compatible openai sse data"); + let responses = sse_data.into_unified_responses(); + + assert_eq!(responses.len(), 1); + assert_eq!(responses[0].text.as_deref(), Some("hello")); + assert_eq!( + responses[0] + .usage + .as_ref() + .map(|usage| usage.total_token_count), + Some(3) + ); + } + #[test] fn handles_empty_choices_with_usage_chunk() { let raw = r#"{ diff --git a/src/crates/core/AGENTS-CN.md b/src/crates/core/AGENTS-CN.md index cbde21600f..7c94631458 100644 --- a/src/crates/core/AGENTS-CN.md +++ b/src/crates/core/AGENTS-CN.md @@ -29,19 +29,24 @@ SessionManager → Session → DialogTurn → ModelRound - 使用 `bitfun_events::EventEmitter` 等共享抽象 - 桌面端专属集成应放在 `src/apps/desktop`,再通过 transport / API layer 连接回来 - core 拆解期间,`bitfun-core` 是兼容 facade 与完整产品 runtime assembly 点;新模块优先放到 `docs/architecture/core-decomposition.md` 指定的 owner crate。 -- Tool 相关轻量 contract、portable tool context facts/provider、纯 manifest/exposure contract 与 generic registry / static-provider / dynamic-provider container 归属 `bitfun-agent-tools`;core tool runtime 当前负责产品工具组装、`dyn Tool` 适配、snapshot decoration、runtime manifest assembly / context filtering,以及按需工具说明发现(`GetToolSpec`)执行。 +- Tool 相关轻量 contract、portable tool context facts/provider、纯 manifest/exposure contract 与 generic registry / static-provider / dynamic-provider container 归属 `bitfun-agent-tools`;core tool runtime 通过 `product_runtime.rs` 统一负责产品工具组装、`dyn Tool` 适配、snapshot decoration、runtime manifest assembly / context filtering,以及按需工具说明发现(`GetToolSpec`)执行。 - `ToolUseContext` 与具体工具实现继续留在 core,除非已有评审过的 port/provider 方案和等价测试。 - Tool 迁移必须保持 expanded/collapsed exposure、prompt 可见 manifest、`ToolUseContext.unlocked_collapsed_tools`,以及 desktop/MCP/ACP tool catalog 行为等价。 - 不要把 OpenAI Responses / Codex ChatGPT flat tool schema 等 provider-specific 序列化行为写进 core tool contract;AI adapter 负责 provider 序列化,core 保持 provider-neutral manifest。 - 调整 session/token usage 路径时,`cached_content_token_count` 必须继续表示 cache reads/hits,`cache_creation_token_count` 必须作为独立 provider fact 保留。 - Function-agent commit-message 与 Startchat work-state orchestration 可以经由 - `bitfun-product-domains`;Git/AI service adapter、prompt template、JSON - extraction 和 error mapping 仍由 core 拥有。JSON extraction 之后的 JSON string - parser 可以放在 `bitfun-product-domains`。 + `bitfun-product-domains`;Git/AI service adapter、provider 获取、AI client + 调用和 transport error mapping 仍由 core 拥有。prompt template、JSON + extraction/repair、domain error mapping 与 domain JSON parsing policy 可以放在 + `bitfun-product-domains`。 - MiniApp built-in bundle/hash/marker seed plan 与 marker wire helper 可以放在 `bitfun-product-domains`;bundled asset include、filesystem writes、marker IO、 customization metadata IO、recompile orchestration、worker process runtime 和 host dispatch execution 仍由 core 拥有,直到有评审过的迁移和等价测试。 +- Remote-connect wire/tracker/dialog orchestration 与 portable file/image contract + 可以放在 `bitfun-services-integrations`;workspace-root source selection、 + response wrapping、concrete scheduler/session restore、terminal pre-warm adapter + 和 product execution 仍由 core 拥有,直到有评审过的迁移和等价测试。 - 不要在没有小型 port/interface 边界的情况下新增 `service` 到 `agentic` 的跨层引用。 - 不要在 core 拆解中把平台专属逻辑、构建脚本行为或产品能力选择下沉到 shared core。 diff --git a/src/crates/core/AGENTS.md b/src/crates/core/AGENTS.md index 4747bd07a6..9b97984c65 100644 --- a/src/crates/core/AGENTS.md +++ b/src/crates/core/AGENTS.md @@ -40,18 +40,30 @@ SessionManager → Session → DialogTurn → ModelRound generic decorator references, snapshot decorator adapters, static-provider runtime assembly, and readonly/enabled registry-snapshot filtering belong in `bitfun-agent-tools`; core tool runtime should materialize concrete tools from the `bitfun-tool-packs` - provider group plan and keep product snapshot wrapper adapter injection in - `runtime_assembly.rs` + `static_providers.rs`, adapt core `Tool` into - provider-neutral contracts through `tool_adapter.rs`, keep product catalog - access and product manifest / GetToolSpec facade wiring in - `catalog_provider.rs`, apply snapshot decoration, and own on-demand spec - discovery Tool impl and unlock-state source for now. + provider group plan through `product_runtime.rs`, adapt core `Tool` into + provider-neutral contracts through `tool_adapter.rs`, and keep product + registry snapshot access, product manifest / GetToolSpec facade wiring, + product snapshot wrapper adapter injection, on-demand spec discovery Tool + impl, and unlock-state source in that product runtime owner for now. `bitfun-tool-packs` may expose planned feature-group scaffold metadata, but it must not own concrete tools yet. - Keep `ToolUseContext` and concrete tool implementations in core unless a reviewed port/provider plan and equivalence tests exist. `ToolContextFacts` / `PortableToolContextProvider` are only portable projections; they must not carry runtime handles, workspace services, or cancellation tokens. +- Keep `ToolUseContext` runtime/service bindings centralized in + `src/agentic/tools/tool_context_runtime.rs`. `framework.rs` should define the + context shape, portable facts projection, and tool trait, not own workspace + runtime lookup, path enforcement, pipeline/description/preflight context + materialization, cancellation wrapping, post-call hooks, or checkpoint + collection. +- Host path normalization, runtime artifact URI parsing/building, and remote + POSIX path containment are portable `bitfun-agent-tools` contracts. Core + keeps compatibility wrappers for `BitFunError`, workspace runtime-root + lookup, and `ToolUseContext` integration. +- Tool allowed-list and collapsed-tool direct execution gating delegate to + `bitfun-agent-tools`; core still owns the unlock-state source and maps gate + results into pipeline failure state. - Any tool migration must preserve expanded/collapsed exposure, prompt-visible manifests, `ToolUseContext.unlocked_collapsed_tools`, and desktop/MCP/ACP tool catalog behavior. @@ -62,14 +74,48 @@ SessionManager → Session → DialogTurn → ModelRound as cache reads/hits and `cache_creation_token_count` as a separate provider fact. - Function-agent commit-message and Startchat work-state orchestration may - route through `bitfun-product-domains`; keep Git/AI service adapters, prompt - templates, JSON extraction, and error mapping core-owned. JSON-string parsing - helpers may live in `bitfun-product-domains` after extraction has happened. + route through `bitfun-product-domains`. Keep Git/AI service adapters, + provider acquisition, AI client calls, and transport error mapping core-owned; + prompt templates, JSON extraction/repair, domain error mapping, and domain + JSON parsing policy may live in `bitfun-product-domains`. - MiniApp built-in bundle/hash/marker seed-plan and marker wire helpers may live in `bitfun-product-domains`; keep bundled asset includes, filesystem writes, marker IO, customization metadata IO, recompile orchestration, worker process runtime, and host dispatch execution core-owned until a reviewed migration proves equivalence. +- Remote-connect wire/tracker/dialog orchestration and portable file/image + contracts may live in `bitfun-services-integrations`; keep workspace-root + source selection, response wrapping, concrete scheduler/session restore, + terminal pre-warm adapters, and product execution core-owned until a reviewed + migration proves equivalence. Core service/agent runtime bindings are + centralized in `src/crates/core/src/service_agent_runtime.rs`. +- Keep concrete remote SSH runtime code behind `ssh-remote`. No-default builds + may keep workspace identity helpers and explicit unsupported stubs, but must + not compile russh-backed SSH/SFTP/terminal/search runtime modules. +- Keep no-default `bitfun-core` as a runtime-surface-light facade, not a + claimed dependency-light build. Full product runtime modules such as agentic, + MiniApp/function-agent, Git/MCP, remote-connect, review-platform, snapshot, + token usage, and mode canonicalization stay behind `product-full` or their + owner feature group. +- Product/runtime dependencies that are only used behind those feature gates + should stay optional in `bitfun-core` and be enabled by `product-full`, + `service-integrations`, or `ssh-remote`; do not treat that as permission to + lighten defaults or change product crate feature sets. Keep + `scripts/check-core-boundaries.mjs` updated so each optional runtime + dependency has an explicit feature owner. +- Product entry crates that depend on `bitfun-core` must keep + `default-features = false` and explicitly enable `product-full`; keep this + wired through product manifests rather than relying on core defaults. The + boundary script scans product entry manifests for new direct `bitfun-core` + dependencies and requires matching assembly rules. +- Keep `default = ["product-full"]` until a separate product matrix review + explicitly changes default capability selection. +- Owner crate feature graph guards keep `tool-packs`, `services-integrations`, + and `product-domains` default-light while allowing `product-full` to + explicitly aggregate current owner feature groups. +- `service-integrations` is not a standalone product shape in core yet; MCP, + remote-connect, and review-platform still depend on agentic/product runtime + owners through `product-full`. - 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 diff --git a/src/crates/core/Cargo.toml b/src/crates/core/Cargo.toml index 7b6acc2099..232008a713 100644 --- a/src/crates/core/Cargo.toml +++ b/src/crates/core/Cargo.toml @@ -30,16 +30,16 @@ log = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } -chrono-tz = { workspace = true } -cron = { workspace = true } +chrono-tz = { workspace = true, optional = true } +cron = { workspace = true, optional = true } regex = { workspace = true } base64 = { workspace = true } -image = { workspace = true } -md5 = { workspace = true } -aes = "0.8" +image = { workspace = true, optional = true } +md5 = { workspace = true, optional = true } +aes = { version = "0.8", optional = true } hex = "0.4" -dashmap = { workspace = true } -indexmap = { workspace = true } +dashmap = { workspace = true, optional = true } +indexmap = { workspace = true, optional = true } lazy_static = "1.4" once_cell = "1.19" parking_lot = "0.12" @@ -49,29 +49,29 @@ reqwest = { workspace = true } axum = { workspace = true } tower-http = { workspace = true } -glob = { workspace = true } +glob = { workspace = true, optional = true } ignore = { workspace = true } notify = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } -filetime = { workspace = true } -fs2 = { workspace = true } +filetime = { workspace = true, optional = true } +fs2 = { workspace = true, optional = true } zip = { workspace = true } -flate2 = { workspace = true } -include_dir = { workspace = true } +flate2 = { workspace = true, optional = true } +include_dir = { workspace = true, optional = true } -git2 = { workspace = true } +git2 = { workspace = true, optional = true } # Command detection (cross-platform) which = { workspace = true } -similar = { workspace = true } +similar = { workspace = true, optional = true } urlencoding = { workspace = true } grep-searcher = { workspace = true } grep-regex = { workspace = true } -globset = { workspace = true } +globset = { workspace = true, optional = true } -eventsource-stream = { workspace = true } +eventsource-stream = { workspace = true, optional = true } # MCP Streamable HTTP client (official rust-sdk used by Codex) rmcp = { version = "0.12.0", default-features = false, features = [ @@ -82,8 +82,8 @@ rmcp = { version = "0.12.0", default-features = false, features = [ "schemars", "server", "transport-streamable-http-client-reqwest", -] } -sse-stream = "0.2.1" +], optional = true } +sse-stream = { version = "0.2.1", optional = true } # Shared AI protocol adapters bitfun-ai-adapters = { path = "../ai-adapters" } @@ -95,19 +95,19 @@ bitfun-agent-stream = { path = "../agent-stream" } bitfun-agent-tools = { path = "../agent-tools" } # Tool pack provider plan -bitfun-tool-packs = { path = "../tool-packs", features = ["product-full"] } +bitfun-tool-packs = { path = "../tool-packs", default-features = false, optional = true } # Core service owner crate bitfun-services-core = { path = "../services-core" } # Integration service owner crate -bitfun-services-integrations = { path = "../services-integrations", features = ["product-full"] } +bitfun-services-integrations = { path = "../services-integrations", default-features = false, features = ["remote-ssh"] } # Product domain owner crate -bitfun-product-domains = { path = "../product-domains", features = ["product-full"] } +bitfun-product-domains = { path = "../product-domains", default-features = false, optional = true } # Tool runtime -tool-runtime = { path = "../tool-runtime" } +tool-runtime = { path = "../tool-runtime", optional = true } # terminal terminal-core = { path = "../terminal" } @@ -117,20 +117,20 @@ fluent-bundle = { workspace = true } unic-langid = { workspace = true } # Encryption (Remote Connect E2E) -x25519-dalek = { workspace = true } -aes-gcm = { workspace = true } +x25519-dalek = { workspace = true, optional = true } +aes-gcm = { workspace = true, optional = true } sha2 = { workspace = true } -rand = { workspace = true } +rand = { workspace = true, optional = true } # Device/Network info (Remote Connect) -local-ip-address = { workspace = true } -hostname = { workspace = true } +local-ip-address = { workspace = true, optional = true } +hostname = { workspace = true, optional = true } # QR code generation -qrcode = { workspace = true } +qrcode = { workspace = true, optional = true } # WebSocket client -tokio-tungstenite = { workspace = true } +tokio-tungstenite = { workspace = true, optional = true } # SSH - Remote SSH support (optional feature) russh = { version = "0.45", optional = true } @@ -140,7 +140,7 @@ shellexpand = { version = "3", optional = true } ssh_config = { version = "0.1", optional = true } # Relay server shared library (embedded relay reuses standalone relay logic) -bitfun-relay-server = { path = "../../apps/relay-server" } +bitfun-relay-server = { path = "../../apps/relay-server", optional = true } # Event layer dependency (lowest layer) bitfun-core-types = { path = "../core-types" } @@ -155,7 +155,7 @@ tauri = { workspace = true } # Non-Windows: vendored OpenSSL for libgit2 (no system install). [target.'cfg(not(windows))'.dependencies] -git2 = { workspace = true, features = ["vendored-openssl"] } +git2 = { workspace = true, features = ["vendored-openssl"], optional = true } [target.'cfg(windows)'.dependencies] win32job = { workspace = true } @@ -167,9 +167,55 @@ schannel = "0.1" # 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"] +product-full = [ + "dep:chrono-tz", + "dep:cron", + "dep:dashmap", + "dep:eventsource-stream", + "dep:filetime", + "dep:flate2", + "dep:fs2", + "dep:glob", + "dep:globset", + "dep:include_dir", + "dep:indexmap", + "dep:md5", + "dep:similar", + "dep:tool-runtime", + "ssh-remote", + "product-domains", + "service-integrations", + "tool-packs", +] +product-domains = ["dep:bitfun-product-domains", "bitfun-product-domains/product-full"] +service-integrations = [ + "dep:aes", + "dep:aes-gcm", + "dep:bitfun-relay-server", + "dep:git2", + "dep:hostname", + "dep:image", + "dep:local-ip-address", + "dep:md5", + "dep:qrcode", + "dep:rand", + "dep:rmcp", + "dep:sse-stream", + "dep:tokio-tungstenite", + "dep:x25519-dalek", + "bitfun-services-integrations/product-full", +] +tool-packs = ["dep:bitfun-tool-packs", "bitfun-tool-packs/product-full"] tauri-support = [] # Optional tauri support -ssh-remote = ["russh", "russh-sftp", "russh-keys", "shellexpand", "ssh_config"] # russh-keys pure-Rust crypto backend (no openssl) +ssh-remote = [ + "dep:aes-gcm", + "dep:rand", + "russh", + "russh-sftp", + "russh-keys", + "shellexpand", + "ssh_config", +] # russh-keys pure-Rust crypto backend (no openssl) [build-dependencies] sha2 = { workspace = true } diff --git a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs b/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs index 87f93ff0cc..f58f34d522 100644 --- a/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs +++ b/src/crates/core/src/agentic/agents/definitions/custom/subagent.rs @@ -1,5 +1,5 @@ use crate::agentic::agents::Agent; -use crate::agentic::agents::{PromptBuilder, PromptBuilderContext}; +use crate::agentic::agents::{PromptBuilder, PromptBuilderContext, RequestContextPolicy}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::FrontMatterMarkdown; use async_trait::async_trait; @@ -63,6 +63,13 @@ impl Agent for CustomSubagent { self.tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() + } + fn is_readonly(&self) -> bool { self.readonly } diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/code_review.rs b/src/crates/core/src/agentic/agents/definitions/hidden/code_review.rs index a5af8c175b..5794be1bf2 100644 --- a/src/crates/core/src/agentic/agents/definitions/hidden/code_review.rs +++ b/src/crates/core/src/agentic/agents/definitions/hidden/code_review.rs @@ -3,7 +3,7 @@ //! This agent can use Read/Grep/Glob/LS tools to gather context before //! submitting a code review, reducing false positives from missing context. -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; pub struct CodeReviewAgent { @@ -68,6 +68,13 @@ impl Agent for CodeReviewAgent { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false // Code review agent can remediate only after explicit user approval } diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs b/src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs index 23a370b96a..6a300e082b 100644 --- a/src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs +++ b/src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs @@ -1,4 +1,4 @@ -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; pub struct DeepReviewAgent { @@ -59,6 +59,13 @@ impl Agent for DeepReviewAgent { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/generate_doc.rs b/src/crates/core/src/agentic/agents/definitions/hidden/generate_doc.rs index 970bd83813..214a4945d1 100644 --- a/src/crates/core/src/agentic/agents/definitions/hidden/generate_doc.rs +++ b/src/crates/core/src/agentic/agents/definitions/hidden/generate_doc.rs @@ -1,4 +1,4 @@ -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; pub struct GenerateDocAgent { @@ -50,6 +50,13 @@ impl Agent for GenerateDocAgent { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/hidden/init.rs b/src/crates/core/src/agentic/agents/definitions/hidden/init.rs index 00ba94a1ff..09255cb50d 100644 --- a/src/crates/core/src/agentic/agents/definitions/hidden/init.rs +++ b/src/crates/core/src/agentic/agents/definitions/hidden/init.rs @@ -1,4 +1,4 @@ -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; pub struct InitAgent { @@ -54,6 +54,14 @@ impl Agent for InitAgent { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_memory_files() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs index 9a8b6fe742..c45a6d143d 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/agentic.rs @@ -1,7 +1,7 @@ //! Agentic Mode use crate::agentic::agents::{ - shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, + shared_coding_mode_tools, Agent, RequestContextPolicy, SHARED_CODING_MODE_PROMPT_TEMPLATE, }; use async_trait::async_trait; pub struct AgenticMode { @@ -48,6 +48,14 @@ impl Agent for AgenticMode { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_memory_files() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/core/src/agentic/agents/definitions/modes/claw.rs index a04edad3ae..c2c060cd81 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/claw.rs @@ -68,7 +68,10 @@ impl Agent for ClawMode { } fn request_context_policy(&self) -> RequestContextPolicy { - RequestContextPolicy::full_without_layout() + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_memory_files() } fn is_readonly(&self) -> bool { diff --git a/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs index 8c6bf2490f..75abe31864 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/cowork.rs @@ -2,7 +2,7 @@ //! //! A collaborative mode that prioritizes early clarification and lightweight progress tracking. -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; pub struct CoworkMode { @@ -70,6 +70,13 @@ impl Agent for CoworkMode { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs b/src/crates/core/src/agentic/agents/definitions/modes/debug.rs index 6716aacda4..9bd831f8bc 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/debug.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/debug.rs @@ -1,6 +1,8 @@ //! Debug Mode - Evidence-driven debugging mode -use crate::agentic::agents::{get_embedded_prompt, Agent, PromptBuilder, PromptBuilderContext}; +use crate::agentic::agents::{ + get_embedded_prompt, Agent, PromptBuilder, PromptBuilderContext, RequestContextPolicy, +}; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo}; @@ -282,6 +284,14 @@ impl Agent for DebugMode { DEBUG_MODE_PROMPT_TEMPLATE } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_memory_files() + .with_project_layout() + } + async fn build_prompt(&self, context: &PromptBuilderContext) -> BitFunResult { let workspace_path = context.workspace_path.as_str(); let prompt_components = PromptBuilder::new(context.clone()); diff --git a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs index 6628eb4f63..d3a9ef466b 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -1,4 +1,4 @@ -use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides, RequestContextPolicy}; use crate::agentic::tools::framework::ToolExposure; use async_trait::async_trait; @@ -70,6 +70,13 @@ impl Agent for DeepResearchMode { &self.tool_exposure_overrides } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs index 10af4cb979..2b91323948 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/multitask.rs @@ -1,7 +1,8 @@ //! Multitask Mode use crate::agentic::agents::{ - shared_coding_mode_tools, Agent, SHARED_CODING_MODE_PROMPT_TEMPLATE, + get_embedded_prompt, shared_coding_mode_tools, Agent, RequestContextPolicy, + SHARED_CODING_MODE_PROMPT_TEMPLATE, }; use async_trait::async_trait; @@ -9,6 +10,9 @@ pub struct MultitaskMode { default_tools: Vec, } +const MULTITASK_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "multitask_mode_first_entry_reminder"; +const MULTITASK_MODE_ONGOING_REMINDER_TEMPLATE: &str = "multitask_mode_ongoing_reminder"; + impl Default for MultitaskMode { fn default() -> Self { Self::new() @@ -22,27 +26,18 @@ impl MultitaskMode { } } - fn build_first_entry_reminder(&self) -> String { - r#"You are now in Multitask mode. - -Treat the task as a parallel work orchestration problem whenever it is beneficial. First decompose the work into orthogonal subtasks or an explicit DAG with clear dependency edges. Then use subagents proactively to execute independent branches in parallel. - -Prefer: -- independent subtasks with minimal overlap -- clear ownership and deliverables per subagent -- parallel execution for non-blocking branches -- background execution for independent subagents whenever possible; prefer the Task tool's `run_in_background` mode instead of blocking on each branch -- local execution only for the immediate critical path - -Do not force parallelism when the task is tiny or tightly coupled, but default to decomposition-first thinking in this mode."# - .to_string() - } - - fn build_ongoing_reminder(&self) -> String { - r#"You are still in Multitask mode. - -Continue working with a parallel-first mindset. Prefer orthogonal decomposition, preserve clear dependency edges, and keep using subagents proactively for independent branches whenever parallel execution is beneficial."# - .to_string() + fn load_reminder_template( + &self, + template_name: &str, + ) -> crate::util::errors::BitFunResult { + get_embedded_prompt(template_name) + .map(str::to_string) + .ok_or_else(|| { + crate::util::errors::BitFunError::Agent(format!( + "{} not found in embedded files", + template_name + )) + }) } } @@ -68,15 +63,23 @@ impl Agent for MultitaskMode { SHARED_CODING_MODE_PROMPT_TEMPLATE } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_memory_files() + .with_project_layout() + } + async fn get_system_reminder( &self, previous_agent_type: Option<&str>, _workspace: Option<&crate::agentic::WorkspaceBinding>, ) -> crate::util::errors::BitFunResult { if previous_agent_type == Some(self.id()) { - Ok(self.build_ongoing_reminder()) + self.load_reminder_template(MULTITASK_MODE_ONGOING_REMINDER_TEMPLATE) } else { - Ok(self.build_first_entry_reminder()) + self.load_reminder_template(MULTITASK_MODE_FIRST_ENTRY_REMINDER_TEMPLATE) } } diff --git a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs index ad7f557540..5bed406985 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/plan.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/plan.rs @@ -32,7 +32,6 @@ impl PlanMode { ], } } - } #[async_trait] @@ -62,7 +61,11 @@ impl Agent for PlanMode { } fn request_context_policy(&self) -> RequestContextPolicy { - RequestContextPolicy::instructions_and_layout() + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_memory_files() + .with_project_layout() } fn is_readonly(&self) -> bool { diff --git a/src/crates/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/core/src/agentic/agents/definitions/modes/team.rs index b6c55e5b53..acfb7b5634 100644 --- a/src/crates/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/core/src/agentic/agents/definitions/modes/team.rs @@ -3,7 +3,7 @@ //! Orchestrates a full software development sprint through specialized roles: //! Think → Plan → Build → Review → Test → Ship -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, RequestContextPolicy}; use async_trait::async_trait; pub struct TeamMode { @@ -68,6 +68,13 @@ impl Agent for TeamMode { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs b/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs index 23a924f38d..90e8979aa0 100644 --- a/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs +++ b/src/crates/core/src/agentic/agents/definitions/review/review_fixer.rs @@ -63,7 +63,7 @@ impl Agent for ReviewFixerAgent { } fn request_context_policy(&self) -> RequestContextPolicy { - RequestContextPolicy::instructions_only() + RequestContextPolicy::empty().with_workspace_instructions() } fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { @@ -87,7 +87,7 @@ mod tests { assert_eq!( agent.request_context_policy(), - RequestContextPolicy::instructions_only() + RequestContextPolicy::empty().with_workspace_instructions() ); assert!(tools.contains(&"Edit".to_string())); assert!(tools.contains(&"Write".to_string())); diff --git a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs index 8f79bc0198..0d6c0fabca 100644 --- a/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs +++ b/src/crates/core/src/agentic/agents/definitions/review/review_specialists.rs @@ -96,7 +96,7 @@ mod tests { for agent in agents { assert_eq!( agent.request_context_policy(), - RequestContextPolicy::instructions_only() + RequestContextPolicy::empty().with_workspace_instructions() ); assert!(agent.is_readonly()); assert!(agent.default_tools().contains(&"GetFileDiff".to_string())); diff --git a/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs b/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs index 9de4a69984..4902e47404 100644 --- a/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs +++ b/src/crates/core/src/agentic/agents/definitions/shared/readonly.rs @@ -10,6 +10,7 @@ pub struct ReadonlySubagent { prompt_template: &'static str, default_tools: &'static [&'static str], tool_exposure_overrides: AgentToolPolicyOverrides, + request_context_policy: RequestContextPolicy, } impl ReadonlySubagent { @@ -45,6 +46,26 @@ impl ReadonlySubagent { prompt_template, default_tools, tool_exposure_overrides, + request_context_policy: RequestContextPolicy::empty().with_workspace_instructions(), + } + } + + pub fn with_policy( + id: &'static str, + name: &'static str, + description: &'static str, + prompt_template: &'static str, + default_tools: &'static [&'static str], + request_context_policy: RequestContextPolicy, + ) -> Self { + Self { + id, + name, + description, + prompt_template, + default_tools, + tool_exposure_overrides: AgentToolPolicyOverrides::default(), + request_context_policy, } } } @@ -76,7 +97,7 @@ impl Agent for ReadonlySubagent { } fn request_context_policy(&self) -> RequestContextPolicy { - RequestContextPolicy::instructions_only() + self.request_context_policy.clone() } fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { @@ -88,6 +109,85 @@ impl Agent for ReadonlySubagent { } } +#[macro_export] +macro_rules! define_readonly_subagent_with_context_policy { + ( + $struct_name:ident, + $id:expr, + $name:literal, + $description:literal, + $prompt:literal, + $tools:expr, + $request_context_policy:expr + ) => { + pub struct $struct_name { + inner: $crate::agentic::agents::ReadonlySubagent, + } + + impl Default for $struct_name { + fn default() -> Self { + Self::new() + } + } + + impl $struct_name { + pub fn new() -> Self { + Self { + inner: $crate::agentic::agents::ReadonlySubagent::with_policy( + $id, + $name, + $description, + $prompt, + $tools, + $request_context_policy, + ), + } + } + } + + #[async_trait::async_trait] + impl $crate::agentic::agents::Agent for $struct_name { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + self.inner.id() + } + + fn name(&self) -> &str { + self.inner.name() + } + + fn description(&self) -> &str { + self.inner.description() + } + + fn prompt_template_name(&self, model_name: Option<&str>) -> &str { + self.inner.prompt_template_name(model_name) + } + + fn default_tools(&self) -> Vec { + self.inner.default_tools() + } + + fn request_context_policy(&self) -> $crate::agentic::agents::RequestContextPolicy { + self.inner.request_context_policy() + } + + fn tool_exposure_overrides( + &self, + ) -> &$crate::agentic::agents::AgentToolPolicyOverrides { + self.inner.tool_exposure_overrides() + } + + fn is_readonly(&self) -> bool { + self.inner.is_readonly() + } + } + }; +} + /// Define a read-only subagent struct and its `Agent` implementation /// by delegating to an inner `ReadonlySubagent`. #[macro_export] diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs b/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs index 80306d8746..3c23e324b7 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/computer_use.rs @@ -2,7 +2,7 @@ //! //! Dedicated agent for perceiving and operating the user's local computer. -use crate::agentic::agents::{Agent, AgentToolPolicyOverrides}; +use crate::agentic::agents::{Agent, AgentToolPolicyOverrides, RequestContextPolicy}; use crate::agentic::tools::framework::ToolExposure; use async_trait::async_trait; @@ -63,6 +63,12 @@ impl Agent for ComputerUseMode { self.default_tools.clone() } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + .with_workspace_instructions() + .with_project_layout() + } + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { &self.tool_exposure_overrides } diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs index 048612640a..faf9fa0418 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/general_purpose.rs @@ -21,6 +21,8 @@ impl GeneralPurposeAgent { "Grep".to_string(), "Write".to_string(), "Edit".to_string(), + "Delete".to_string(), + "TerminalControl".to_string(), "WebSearch".to_string(), "WebFetch".to_string(), ], @@ -55,7 +57,10 @@ impl Agent for GeneralPurposeAgent { } fn request_context_policy(&self) -> RequestContextPolicy { - RequestContextPolicy::instructions_only() + RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_project_layout() } fn is_readonly(&self) -> bool { diff --git a/src/crates/core/src/agentic/agents/definitions/subagents/research_specialist.rs b/src/crates/core/src/agentic/agents/definitions/subagents/research_specialist.rs index d48284429b..75fd61b145 100644 --- a/src/crates/core/src/agentic/agents/definitions/subagents/research_specialist.rs +++ b/src/crates/core/src/agentic/agents/definitions/subagents/research_specialist.rs @@ -1,12 +1,15 @@ -use crate::define_readonly_subagent; +use crate::define_readonly_subagent_with_context_policy; -define_readonly_subagent!( +define_readonly_subagent_with_context_policy!( ResearchSpecialistAgent, "ResearchSpecialist", "Research Specialist", r#"Read-only subagent for **web research**. Has WebSearch (Exa) and WebFetch tools. Use to delegate one focused research role (primary sources, news/timeline, expert analysis, counter-evidence, or competitor profile) so multiple roles can run in parallel without polluting the parent context. The specialist runs 3–5 searches, fetches the most relevant pages, and returns a structured markdown report with claim / URL / direct-quote / authority for each finding. The parent agent is responsible for any file writes — specialists return findings via the Task tool result, they do not write to disk."#, "research_specialist_agent", - &["WebSearch", "WebFetch", "Read"] + &["WebSearch", "WebFetch", "Read"], + crate::agentic::agents::RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() ); #[cfg(test)] diff --git a/src/crates/core/src/agentic/agents/mod.rs b/src/crates/core/src/agentic/agents/mod.rs index 203fe81d28..5493f6a605 100644 --- a/src/crates/core/src/agentic/agents/mod.rs +++ b/src/crates/core/src/agentic/agents/mod.rs @@ -31,7 +31,7 @@ pub use definitions::subagents::{ use indexmap::IndexMap; pub use prompt_builder::{ PromptBuilder, PromptBuilderContext, RemoteExecutionHints, RequestContextPolicy, - RequestContextSection, + RequestContextSection, RequestContextToolSections, }; pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; pub use registry::types::{ @@ -99,9 +99,7 @@ pub trait Agent: Send + Sync + 'static { None // by default, no system reminder } - fn request_context_policy(&self) -> RequestContextPolicy { - RequestContextPolicy::default() - } + fn request_context_policy(&self) -> RequestContextPolicy; /// Build the system prompt for this agent async fn build_prompt(&self, context: &PromptBuilderContext) -> BitFunResult { diff --git a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs index 18b2295528..3d67c49d70 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs @@ -1,5 +1,7 @@ mod prompt_builder_impl; mod request_context; -pub use prompt_builder_impl::{PromptBuilder, PromptBuilderContext, RemoteExecutionHints}; +pub use prompt_builder_impl::{ + PromptBuilder, PromptBuilderContext, RemoteExecutionHints, RequestContextToolSections, +}; pub use request_context::{RequestContextPolicy, RequestContextSection}; diff --git a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index c9b92e6be2..a5b35a1a90 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -9,6 +9,7 @@ use crate::service::config::get_app_language_code; use crate::service::config::global::GlobalConfigManager; use crate::service::filesystem::get_formatted_directory_listing; use crate::service::i18n::LocaleId; +use crate::service::workspace::RelatedPath; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, warn}; use std::path::Path; @@ -21,14 +22,73 @@ const PLACEHOLDER_AGENT_MEMORY: &str = "{AGENT_MEMORY}"; const PLACEHOLDER_CLAW_WORKSPACE: &str = "{CLAW_WORKSPACE}"; const PLACEHOLDER_VISUAL_MODE: &str = "{VISUAL_MODE}"; const PLACEHOLDER_SESSION_ID: &str = "{SESSION_ID}"; -const ADDITIONAL_TOOLS_PROMPT: &str = r#"# Additional Tools - -Some tools in the tool list are intentionally collapsed. +const ADDITIONAL_CONTEXT_PROMPT: &str = + "As you answer the user's questions, you can use the following context"; +const SKILL_TOOL_CONTEXT_TITLE: &str = "## Skills Available via Skill Tool"; +const TASK_TOOL_CONTEXT_TITLE: &str = "## Subagents Available via Task Tool"; +const GET_TOOL_SPEC_CONTEXT_TITLE: &str = + "## Collapsed Tools (load full definition via GetToolSpec tool)"; +const ADDITIONAL_TOOLS_PROMPT: &str = r#"Some tools in the tool list are intentionally collapsed. Their listed descriptions are short summaries rather than full usage instructions. Before calling a collapsed tool, call `GetToolSpec` with its exact tool name to read its full definition and input schema. After reading the returned spec, call the real tool directly by its own name. -If a tool spec is already available in the current conversation, do not call `GetToolSpec` for it again. -"#; +If a tool spec is already available in the current conversation, do not call `GetToolSpec` for it again."#; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RequestContextToolSections { + pub available_skills: Option, + pub available_agents: Option, + pub collapsed_tools: Option, +} + +impl RequestContextToolSections { + pub fn is_empty(&self) -> bool { + self.available_skills.is_none() + && self.available_agents.is_none() + && self.collapsed_tools.is_none() + } + + fn render(&self) -> Option { + if self.is_empty() { + return None; + } + + let mut sections = Vec::new(); + if let Some(available_skills) = self.available_skills.as_deref() { + sections.push(Self::render_section( + SKILL_TOOL_CONTEXT_TITLE, + available_skills, + None, + )); + } + if let Some(available_agents) = self.available_agents.as_deref() { + sections.push(Self::render_section( + TASK_TOOL_CONTEXT_TITLE, + available_agents, + None, + )); + } + if let Some(collapsed_tools) = self.collapsed_tools.as_deref() { + sections.push(Self::render_section( + GET_TOOL_SPEC_CONTEXT_TITLE, + collapsed_tools, + Some(ADDITIONAL_TOOLS_PROMPT), + )); + } + + Some(format!( + "# Available Tool Context\n{}", + sections.join("\n\n") + )) + } + + fn render_section(title: &str, body: &str, description: Option<&str>) -> String { + match description { + Some(description) => format!("{}\n{}\n\n{}", title, description, body.trim()), + None => format!("{}\n{}", title, body.trim()), + } + } +} /// SSH remote host facts for system prompt (workspace tools run here, not on the local client). #[derive(Debug, Clone)] @@ -41,6 +101,7 @@ pub struct RemoteExecutionHints { #[derive(Debug, Clone)] pub struct PromptBuilderContext { pub workspace_path: String, + pub related_paths: Vec, pub session_id: Option, pub model_name: Option, /// When set, file/shell tools target this remote environment; OS and path instructions follow it. @@ -49,8 +110,8 @@ pub struct PromptBuilderContext { pub remote_project_layout: Option, /// When `Some(false)`, system prompt append Computer use text-only guidance (no screenshot tool output). pub supports_image_understanding: Option, - /// When true, append a static reminder that additional collapsed tools exist behind GetToolSpec. - pub has_additional_tools: bool, + /// Dynamic tool catalogs that are injected through request context instead of tool descriptions. + pub request_context_tools: RequestContextToolSections, } impl PromptBuilderContext { @@ -61,12 +122,13 @@ impl PromptBuilderContext { ) -> Self { Self { workspace_path: workspace_path.into().replace("\\", "/"), + related_paths: Vec::new(), session_id, model_name, remote_execution: None, remote_project_layout: None, supports_image_understanding: None, - has_additional_tools: false, + request_context_tools: RequestContextToolSections::default(), } } @@ -75,8 +137,13 @@ impl PromptBuilderContext { self } - pub fn with_additional_tools_hint(mut self, has_additional_tools: bool) -> Self { - self.has_additional_tools = has_additional_tools; + pub fn with_request_context_tools(mut self, tools: RequestContextToolSections) -> Self { + self.request_context_tools = tools; + self + } + + pub fn with_related_paths(mut self, related_paths: Vec) -> Self { + self.related_paths = related_paths; self } @@ -110,9 +177,6 @@ impl PromptBuilder { let host_family = std::env::consts::FAMILY; let host_arch = std::env::consts::ARCH; - let now = chrono::Local::now(); - let current_date = now.format("%Y-%m-%d").to_string(); - let computer_use_keys = match host_os { "macos" => "Computer use / `key_chord`: the **local BitFun desktop** is **macOS** — use `command`, `option`, `control`, `shift` (not Win/Linux modifier names). **ACTION PRIORITY:** 1) Terminal/CLI/system commands (use Bash tool for `osascript`, AppleScript, shell scripts) 2) Keyboard shortcuts: command+a/c/x/v (clipboard), command+space (Spotlight), command+tab (switch app) 3) UI control (AX/OCR/mouse) only when above fail.", "windows" => "Computer use / `key_chord`: the **local BitFun desktop** is **Windows** — use `meta`/`super` for Windows key, `alt`, `control`, `shift`. **ACTION PRIORITY:** 1) Terminal/CLI/system commands (use Bash tool for PowerShell, cmd, scripts) 2) Keyboard shortcuts: control+a/c/x/v (clipboard), meta (Start menu), Alt+Tab (switch) 3) UI control only when above fail.", @@ -120,49 +184,94 @@ impl PromptBuilder { _ => "Computer use / `key_chord`: match modifier names to the **local BitFun desktop** OS below. **ACTION PRIORITY:** 1) Terminal/CLI/system commands first 2) Keyboard shortcuts second 3) UI control (mouse/OCR) last resort.", }; - if let Some(remote) = &self.context.remote_execution { + if self.context.remote_execution.is_some() { format!( r#"# Environment Information -- Workspace root (file tools, Glob, LS, Bash on workspace): {} -- Execution environment: **Remote SSH** — connection "{}". -- Remote host: {} (uname/kernel: {}) -- **Paths and shell:** POSIX on the remote server — use forward slashes and Unix shell syntax (bash/sh). Do **not** use PowerShell, `cmd.exe`, or Windows-style paths for workspace operations. -- Local BitFun client OS: {} ({}) — applies to Computer use / UI automation on this machine only, not to workspace file or terminal tools. +- Local BitFun client OS: {} ({}) — applies to Computer use / UI automation on this machine only. - Local client architecture: {} -- Current Date: {} - {} "#, - self.context.workspace_path, - remote.connection_display_name.replace('"', "'"), - remote.hostname.replace('"', "'"), - remote.kernel_name.replace('"', "'"), - host_os, - host_family, - host_arch, - current_date, - computer_use_keys + host_os, host_family, host_arch, computer_use_keys ) } else { format!( r#"# Environment Information -- Current Working Directory: {} - Operating System: {} ({}) - Architecture: {} -- Current Date: {} - {} +"#, + host_os, host_family, host_arch, computer_use_keys + ) + } + } + + /// Get workspace context that is intentionally injected outside the system prompt cache. + pub fn get_workspace_context(&self) -> String { + let related_paths_section = if self.context.related_paths.is_empty() { + String::new() + } else { + let items = self + .context + .related_paths + .iter() + .map(|related_path| { + let path = related_path.path.replace("\\", "/"); + match related_path.description.as_deref().map(str::trim) { + Some(description) if !description.is_empty() => { + format!(" - {} — {}", path, description) + } + _ => format!(" - {}", path), + } + }) + .collect::>() + .join("\n"); + format!( + "- Related directories (user-specified directories related to this workspace):\n{}", + items + ) + }; + + if let Some(remote) = &self.context.remote_execution { + format!( + r#"## Workspace Context + +- Workspace root (file tools, Glob, LS, Bash on workspace): {} +{} +- Execution environment: **Remote SSH** — connection "{}". +- Remote host: {} (uname/kernel: {}) +- **Paths and shell:** POSIX on the remote server — use forward slashes and Unix shell syntax (bash/sh). Do **not** use PowerShell, `cmd.exe`, or Windows-style paths for workspace operations. + "#, self.context.workspace_path, - host_os, - host_family, - host_arch, - current_date, - computer_use_keys + if related_paths_section.is_empty() { + String::new() + } else { + format!("{}\n", related_paths_section) + }, + remote.connection_display_name.replace('"', "'"), + remote.hostname.replace('"', "'"), + remote.kernel_name.replace('"', "'"), + ) + } else { + format!( + r#"## Workspace Context + +- Current Working Directory: {} +{} + +"#, + self.context.workspace_path, + if related_paths_section.is_empty() { + String::new() + } else { + format!("\n{}", related_paths_section) + } ) } } @@ -170,7 +279,7 @@ impl PromptBuilder { /// Get workspace file list pub fn get_project_layout(&self) -> String { if let Some(remote_layout) = &self.context.remote_project_layout { - let mut project_layout = "# Workspace Layout\n\n".to_string(); + let mut project_layout = "## Workspace Layout\n\n".to_string(); project_layout.push_str( "Below is a snapshot of the current workspace's file structure on the **remote** host.\n\n", ); @@ -187,7 +296,7 @@ impl PromptBuilder { reached_limit: false, text: format!("Error listing directory: {}", e), }); - let mut project_layout = "# Workspace Layout\n\n".to_string(); + let mut project_layout = "## Workspace Layout\n\n".to_string(); if formatted_listing.reached_limit { project_layout.push_str(&format!("Below is a snapshot of the current workspace's file structure (showing up to {} entries).\n\n", self.file_tree_max_entries)); } else { @@ -204,15 +313,21 @@ impl PromptBuilder { policy: &RequestContextPolicy, ) -> Option { let mut sections = Vec::new(); - let mut instruction_sections = Vec::new(); - let mut override_sections = Vec::new(); - let mut trailing_sections = Vec::new(); + let mut additional_sections = Vec::new(); + + if let Some(tool_context) = self.context.request_context_tools.render() { + sections.push(tool_context); + } + + if policy.includes(RequestContextSection::WorkspaceContext) { + additional_sections.push(self.get_workspace_context()); + } if self.context.remote_execution.is_none() { let workspace = Path::new(&self.context.workspace_path); if policy.includes(RequestContextSection::WorkspaceInstructions) { match build_workspace_instruction_files_context(workspace).await { - Ok(Some(prompt)) => instruction_sections.push(prompt), + Ok(Some(prompt)) => additional_sections.push(prompt), Ok(None) => {} Err(e) => warn!( "Failed to build workspace instruction context: path={} error={}", @@ -223,7 +338,7 @@ impl PromptBuilder { } if policy.includes(RequestContextSection::WorkspaceMemoryFiles) { match build_workspace_memory_files_context(workspace).await { - Ok(Some(prompt)) => override_sections.push(prompt), + Ok(Some(prompt)) => additional_sections.push(prompt), Ok(None) => {} Err(e) => warn!( "Failed to build workspace memory context: path={} error={}", @@ -235,18 +350,21 @@ impl PromptBuilder { } if policy.includes(RequestContextSection::ProjectLayout) { - trailing_sections.push(self.get_project_layout()); + additional_sections.push(self.get_project_layout()); } - sections.extend(instruction_sections); - - if policy.has_override_sections() && !override_sections.is_empty() { - sections.push("Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.".to_string()); - sections.extend(override_sections); + if !additional_sections.is_empty() { + sections.push(format!( + "# Additional Context\n{}\n\n{}", + ADDITIONAL_CONTEXT_PROMPT, + additional_sections + .into_iter() + .map(|section| section.trim().to_string()) + .collect::>() + .join("\n\n") + )); } - sections.extend(trailing_sections); - if sections.is_empty() { None } else { @@ -304,14 +422,12 @@ Output Mermaid in fenced code blocks (```mermaid) so the UI can render them. /// Get Claw-specific workspace boundary instruction fn get_claw_workspace_instruction(&self) -> String { - format!( - "# Workspace -Your dedicated operating space is `{}`. + "# Workspace +Your dedicated operating space is the workspace root shown in the current request context. Prefer doing work inside this workspace and keep it well organized with clear structure, sensible filenames, and minimal clutter. Do not read from, modify, create, move, or delete files outside this workspace unless the user has explicitly granted permission for that external action. -", - self.context.workspace_path - ) +" + .to_string() } /// Build prompt from template, automatically fill content based on placeholders @@ -416,12 +532,6 @@ The configured **primary model does not accept image inputs**. When using **`Com ); } - if self.context.has_additional_tools { - result.push_str("\n\n"); - result.push_str(ADDITIONAL_TOOLS_PROMPT); - result.push('\n'); - } - Ok(result.trim().to_string()) } } @@ -430,29 +540,94 @@ The configured **primary model does not accept image inputs**. When using **`Com mod tests { use super::PromptBuilder; use super::PromptBuilderContext; + use super::RequestContextToolSections; + use super::{ + ADDITIONAL_CONTEXT_PROMPT, GET_TOOL_SPEC_CONTEXT_TITLE, SKILL_TOOL_CONTEXT_TITLE, + TASK_TOOL_CONTEXT_TITLE, + }; + use crate::agentic::agents::RequestContextPolicy; + use crate::service::workspace::RelatedPath; #[tokio::test] - async fn appends_additional_tools_section_when_hint_is_enabled() { - let context = - PromptBuilderContext::new("E:/workspace", None, None).with_additional_tools_hint(true); + async fn renders_available_tool_context_before_additional_context() { + let tool_sections = RequestContextToolSections { + available_skills: Some("\n- pdf\n".to_string()), + available_agents: Some( + "\n- Explore\n".to_string(), + ), + collapsed_tools: Some( + "\n- WebFetch: Fetch readable web content.\n" + .to_string(), + ), + }; + let context = PromptBuilderContext::new("E:/workspace", None, None) + .with_request_context_tools(tool_sections); let prompt = PromptBuilder::new(context) - .build_prompt_from_template("Base prompt") + .build_request_context_reminder( + &RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions(), + ) .await - .expect("prompt should build"); - - assert!(prompt.contains("# Additional Tools")); - assert!(prompt.contains("short summaries rather than full usage instructions")); - assert!(prompt.contains("call `GetToolSpec` with its exact tool name")); + .expect("request context should build"); + + assert!(prompt.contains("# Available Tool Context")); + assert!(prompt.contains(SKILL_TOOL_CONTEXT_TITLE)); + assert!(prompt.contains(TASK_TOOL_CONTEXT_TITLE)); + assert!(prompt.contains(GET_TOOL_SPEC_CONTEXT_TITLE)); + assert!(prompt.contains("")); + assert!(prompt.contains("# Additional Context")); + assert!(prompt.contains(ADDITIONAL_CONTEXT_PROMPT)); + assert!(prompt.find("# Available Tool Context") < prompt.find("# Additional Context")); + assert!(prompt.contains("Current Working Directory: E:/workspace")); } #[tokio::test] - async fn omits_additional_tools_section_when_hint_is_disabled() { + async fn omits_request_context_when_policy_and_tool_sections_are_empty() { let context = PromptBuilderContext::new("E:/workspace", None, None); let prompt = PromptBuilder::new(context) - .build_prompt_from_template("Base prompt") - .await - .expect("prompt should build"); + .build_request_context_reminder(&RequestContextPolicy::empty()) + .await; + + assert!(prompt.is_none()); + } - assert!(!prompt.contains("# Additional Tools")); + #[test] + fn workspace_context_renders_related_directories() { + let context = + PromptBuilderContext::new("E:/workspace", None, None).with_related_paths(vec![ + RelatedPath { + path: r"E:\legacy-ts".to_string(), + description: Some("Legacy TypeScript implementation".to_string()), + }, + RelatedPath { + path: r"E:\monorepo\billing".to_string(), + description: Some("Billing package".to_string()), + }, + ]); + + let workspace_context = PromptBuilder::new(context).get_workspace_context(); + + assert!(workspace_context.contains("Related directories")); + assert!(workspace_context.contains("E:/legacy-ts")); + assert!(workspace_context.contains("Legacy TypeScript implementation")); + assert!(workspace_context.contains("E:/monorepo/billing")); + } + + #[test] + fn workspace_context_renders_related_directories_without_description() { + let context = + PromptBuilderContext::new("E:/workspace", None, None).with_related_paths(vec![ + RelatedPath { + path: r"E:\monorepo\packages\payments".to_string(), + description: None, + }, + ]); + + let workspace_context = PromptBuilder::new(context).get_workspace_context(); + + assert!(workspace_context.contains("Related directories")); + assert!(workspace_context.contains(" - E:/monorepo/packages/payments")); + assert!(!workspace_context.contains("payments —")); } } diff --git a/src/crates/core/src/agentic/agents/prompt_builder/request_context.rs b/src/crates/core/src/agentic/agents/prompt_builder/request_context.rs index 4ad1ad7925..584edff968 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/request_context.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/request_context.rs @@ -1,5 +1,6 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RequestContextSection { + WorkspaceContext, WorkspaceInstructions, WorkspaceMemoryFiles, ProjectLayout, @@ -11,47 +12,77 @@ pub struct RequestContextPolicy { } impl RequestContextPolicy { - pub fn new(sections: Vec) -> Self { - Self { sections } + pub fn empty() -> Self { + Self { + sections: Vec::new(), + } } - pub fn full() -> Self { - Self::new(vec![ - RequestContextSection::WorkspaceInstructions, - RequestContextSection::WorkspaceMemoryFiles, - RequestContextSection::ProjectLayout, - ]) + pub fn with_section(mut self, section: RequestContextSection) -> Self { + if !self.includes(section) { + self.sections.push(section); + } + self } - pub fn full_without_layout() -> Self { - Self::new(vec![ - RequestContextSection::WorkspaceInstructions, - RequestContextSection::WorkspaceMemoryFiles, - ]) + pub fn without_section(mut self, section: RequestContextSection) -> Self { + self.sections.retain(|existing| *existing != section); + self } - pub fn instructions_only() -> Self { - Self::new(vec![RequestContextSection::WorkspaceInstructions]) + pub fn with_workspace_context(self) -> Self { + self.with_section(RequestContextSection::WorkspaceContext) } - pub fn instructions_and_layout() -> Self { - Self::new(vec![ - RequestContextSection::WorkspaceInstructions, - RequestContextSection::ProjectLayout, - ]) + pub fn with_workspace_instructions(self) -> Self { + self.with_section(RequestContextSection::WorkspaceInstructions) } - pub fn includes(&self, section: RequestContextSection) -> bool { - self.sections.contains(§ion) + pub fn with_workspace_memory_files(self) -> Self { + self.with_section(RequestContextSection::WorkspaceMemoryFiles) } - pub fn has_override_sections(&self) -> bool { - self.includes(RequestContextSection::WorkspaceMemoryFiles) + pub fn with_project_layout(self) -> Self { + self.with_section(RequestContextSection::ProjectLayout) + } + + pub fn includes(&self, section: RequestContextSection) -> bool { + self.sections.contains(§ion) } } impl Default for RequestContextPolicy { fn default() -> Self { - Self::full() + Self::empty() + } +} + +#[cfg(test)] +mod tests { + use super::{RequestContextPolicy, RequestContextSection}; + + #[test] + fn chain_builder_preserves_order_and_dedupes_sections() { + let policy = RequestContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + .with_workspace_context() + .with_project_layout() + .without_section(RequestContextSection::ProjectLayout) + .with_workspace_memory_files(); + + assert_eq!( + policy.sections, + vec![ + RequestContextSection::WorkspaceContext, + RequestContextSection::WorkspaceInstructions, + RequestContextSection::WorkspaceMemoryFiles, + ] + ); + } + + #[test] + fn default_policy_is_empty() { + assert!(RequestContextPolicy::default().sections.is_empty()); } } diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md index 21586729bd..5e573ff7e6 100644 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md @@ -61,12 +61,14 @@ The user will primarily request you perform software engineering tasks. This inc - Use specialized tools for file reads, edits, searches, and deletions because they preserve workspace context and permissions. Use Bash for commands that genuinely need a shell. Do not use shell commands only to communicate with the user. - For security-sensitive tasks, support defensive analysis and remediation only. Refuse malicious code, exploit workflows, credential harvesting, or instructions that would facilitate abuse. - Edit reliability discipline: - - Base `old_string` on the latest Read result for that file or exact content produced by a successful prior tool call. - - Treat Read output as stale after a successful edit to the same file; avoid parallel Edit calls against the same file unless the edits are independent and based on non-overlapping current content. - - Copy current file content exactly, excluding Read line-number prefixes. - - Add stable surrounding context from the same block when a snippet may appear multiple times. + - Read a file in this session before Edit. Partial range reads are allowed, but the Read range must include every line you will copy into `old_string`. + - Base `old_string` on the latest Read result for that file (or exact content from a successful prior Edit/Write on the same file). + - Read output uses cat -n format: spaces, line number, tab, then file content. Copy only the text after the tab into `old_string` and `new_string`. + - Do not reformat HTML/CSS/JS when constructing Edit strings; match indentation and blank lines exactly. + - Treat Read output as stale after a successful edit to the same file; re-read before the next Edit unless you are continuing from the updated content in the prior tool result. + - Use 2-4 adjacent lines with stable surrounding context when that is enough to make `old_string` unique. - Use `replace_all` only when every occurrence should change. - - If an edit fails because the text was not found or matched multiple locations, read the target area again before retrying rather than adjusting the failed string from memory. + - If Edit fails because text was not found or matched multiple locations, Read the target lines again and retry with freshly copied text — do not adjust the failed string from memory. - Subagent delegation: use Explore, FileFinder, or other Task subagents when their specialized focus, separate context, or autonomy is likely to improve coverage. For simple known-path, single-symbol, or one-file questions, direct tools are usually enough. user: Give me a high-level map of how authentication flows through this monorepo diff --git a/src/crates/core/src/agentic/agents/prompts/deep_research_agent.md b/src/crates/core/src/agentic/agents/prompts/deep_research_agent.md index e3bfedca1e..8d4033bba2 100644 --- a/src/crates/core/src/agentic/agents/prompts/deep_research_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/deep_research_agent.md @@ -78,12 +78,12 @@ Build these constants for the whole pipeline: ``` SESSION_ID = {SESSION_ID} -TODAY = today's date in YYYY-MM-DD from ENV_INFO +TODAY = current calendar date in YYYY-MM-DD when available from runtime/request context; ask before making date-sensitive claims if it is not available WORK_DIR = /.bitfun/sessions/{SESSION_ID}/research REPORT_PATH = /report.md ``` -`{SESSION_ID}` above is replaced at prompt build time with the current session's ID. `` is the *Current Working Directory* shown in ENV_INFO — use it verbatim. +`{SESSION_ID}` above is replaced at prompt build time with the current session's ID. `` is the workspace root shown in additional context — use it verbatim. **File-layout convention.** Everything for this research session lives under `WORK_DIR`: diff --git a/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md b/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md index 16ee626dd7..6d36e1c81e 100644 --- a/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/general_purpose_agent.md @@ -16,6 +16,7 @@ You are a general-purpose agent for BitFun, a desktop AI IDE and agent runtime. - Use `Read` when you know the path or have narrowed the candidate set enough that reading is justified. - Read before you edit. Do not propose or apply changes to code you have not inspected. - Prefer focused edits to existing files over broad rewrites. +- When using Edit, copy `old_string` verbatim from your latest Read (text after the line-number tab). Do not reformat HTML, CSS, or indentation. - Do not create new files unless they are clearly necessary for completing the requested task. - Do not proactively create documentation files such as `README` or `*.md` unless the user explicitly asks for them. diff --git a/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md b/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md new file mode 100644 index 0000000000..f5f0b57201 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/multitask_mode_first_entry_reminder.md @@ -0,0 +1,75 @@ +You are now in Multitask mode. + +Treat the task as a parallel work orchestration problem whenever it is beneficial. First decompose the work into orthogonal subtasks. Then use background subagents proactively to execute independent branches in parallel. + +# Subagent Delegation Guide + +- Prefer background subagents whenever the branch is independent and does not block your immediate next step. Use `run_in_background: true` on the Task call to launch it in the background. +- Background subagent results are delivered back to you automatically when they finish. Do not poll or repeatedly check on background work just for status updates. If your current path is blocked on a background result and there is no other productive local work to do, it is fine to end the current turn instead of waiting idly. +- Keep yourself on the critical path. Handle decomposition, dependency management, interface alignment, integration, and final verification yourself. +- Use `FileFinder` when the subtask is primarily about locating relevant files, entry points, symbols, or ownership boundaries. +- Use `Explore` when the subtask is read-only investigation, codebase understanding, or evidence gathering that should not modify files. +- Use `GeneralPurpose` when the subtask is implementation work that is likely to modify files, such as editing code, wiring features, fixing tests, or updating configurations. +- Give each subagent a clear scope, expected output, and ownership boundary so parallel branches do not overlap unnecessarily. +- Do not spawn subagents for tiny or tightly coupled tasks where delegation overhead is higher than the benefit. + +# Notes + +- Parallel `Write` or `Edit` calls are not true parallel execution. File-modifying tools are serialized by the system. +- Do not claim you are doing parallel implementation work if you are only issuing multiple file modification calls yourself. +- If the work should happen in parallel, use subagents to execute independent branches. Do not try to simulate parallelism by batching your own file writes. + +# Examples + + +Example 1: the user gives one feature request, and you proactively decompose it. + +"Add an export report feature. Users should be able to click Export in the UI, the backend should generate the report, and we should have reasonable test coverage." + + +- Identify separate branches such as contract design, backend export logic, frontend entry point, and verification. +- Keep the immediate coordination path local: define or confirm the interface between frontend and backend first if needed. +- Then dispatch independent work in parallel, for example: + - one subagent owns backend export implementation + - one subagent owns frontend wiring and UX states + - one subagent prepares or updates tests that can be written against the agreed contract +- Integrate the results yourself, resolve mismatches, and run the final verification yourself. + + + + +Example 2: the user already provides a numbered task list, and you still reason about dependency edges instead of blindly doing 1, 2, 3 in order. + +"Please do these three things: +1. Update the settings page copy for the new sync behavior. +2. Add a CLI flag for forcing sync. +3. Add tests for the change." + + +- Do not assume the numbered list is the execution order. +- Check whether item 1 and item 2 are orthogonal enough to run in parallel. +- Split item 3 by dependency if needed: some tests may be prepared in parallel, while integration or end-to-end verification may need to wait for the implementation branches to land. +- Dispatch multiple subagents when the branches are truly independent, then merge and verify the combined result yourself. + + + + +Counterexample: claiming parallelism while only issuing your own file edits. + +"Add the backend endpoint, wire the UI button, and update tests." + + +- "I will do these in parallel" and then directly issue multiple `Write` or `Edit` calls yourself. +- Treat multiple file modification calls as if they were equivalent to multiple background subagents. +- Skip subagent delegation even though the branches are independent enough to split. + + +- Parallel file modification calls are serialized by the system, so this is not real parallel execution. +- The behavior misses the point of Multitask mode, which is to delegate independent branches to subagents when parallel work is beneficial. + + +- Keep coordination and integration work yourself. +- Delegate the backend implementation, UI wiring, and test updates to separate subagents when the branches are independent enough. +- Merge and verify the results after the subagents return. + + diff --git a/src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md b/src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md new file mode 100644 index 0000000000..275bf7a1d9 --- /dev/null +++ b/src/crates/core/src/agentic/agents/prompts/multitask_mode_ongoing_reminder.md @@ -0,0 +1,3 @@ +You are still in Multitask mode. + +Continue working with a parallel-first mindset. Prefer orthogonal decomposition and keep using subagents proactively for independent branches whenever parallel execution is beneficial. diff --git a/src/crates/core/src/agentic/agents/registry/tests.rs b/src/crates/core/src/agentic/agents/registry/tests.rs index bd6c45ac01..6420b6748e 100644 --- a/src/crates/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/core/src/agentic/agents/registry/tests.rs @@ -9,7 +9,7 @@ use crate::agentic::agents::registry::types::{ use crate::agentic::agents::registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, }; -use crate::agentic::agents::Agent; +use crate::agentic::agents::{Agent, RequestContextPolicy}; use crate::service::config::types::AgentSubagentOverrideState; use async_trait::async_trait; use std::collections::HashMap; @@ -42,6 +42,10 @@ impl Agent for TestAgent { "test_agent" } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + } + fn default_tools(&self) -> Vec { vec!["Read".to_string()] } @@ -122,10 +126,7 @@ fn non_deep_review_builtin_subagents_default_to_primary() { #[test] fn general_purpose_builtin_subagent_defaults_to_fast() { - assert_eq!( - default_model_id_for_builtin_agent("GeneralPurpose"), - "fast" - ); + assert_eq!(default_model_id_for_builtin_agent("GeneralPurpose"), "fast"); } #[test] diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index d1968da19b..759bb06373 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -16,6 +16,13 @@ use crate::agentic::execution::{ContextCompactionOutcome, ExecutionContext, Exec use crate::agentic::fork_agent::{ ForkAgentContextSnapshot, ForkAgentExecutionRequest, ForkAgentExecutionResult, }; +use crate::agentic::goal_mode::{ + build_goal_kickoff_messages, build_goal_continuation_plan, clear_goal_mode_patch, + generate_goal_from_context, goal_mode_from_custom_metadata, goal_mode_patch, + should_skip_goal_verification_for_turn, user_facing_goal_mode_error, + verify_goal_achievement, wrap_user_input_with_goal_reminder, GoalActivationResult, + GoalContinuationPlan, GoalModeState, MAX_GOAL_CONTINUATIONS, now_ms, +}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::{DialogRoundInjectionSource, DialogRoundPreemptSource}; use crate::agentic::session::SessionManager; @@ -26,8 +33,9 @@ use crate::agentic::WorkspaceBinding; use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; -use crate::service::session::{SessionRelationship, SessionRelationshipKind}; use crate::service::config::global::GlobalConfigManager; +use crate::service::remote_ssh::normalize_remote_workspace_path; +use crate::service::session::{SessionRelationship, SessionRelationshipKind}; use crate::service::workspace::{ get_global_workspace_service, WorkspaceCreateOptions, WorkspaceKind, }; @@ -220,6 +228,88 @@ impl Drop for CancelTokenGuard { } } +#[derive(Clone)] +struct ActiveSubagentExecution { + parent_session_id: String, + parent_dialog_turn_id: String, + subagent_session_id: String, + subagent_dialog_turn_id: String, + cancel_token: CancellationToken, + abort_handle: tokio::task::AbortHandle, +} + +/// Ensures orphaned subagent work is stopped when the parent tool await is dropped. +struct SubagentExecutionScope { + execution_engine: Arc, + tool_pipeline: Arc, + session_manager: Arc, + active_subagent_executions: Arc>, + subagent_session_id: String, + subagent_dialog_turn_id: String, + subagent_cancel_token: CancellationToken, + abort_handle: tokio::task::AbortHandle, + disarmed: bool, +} + +impl SubagentExecutionScope { + fn disarm(&mut self) { + self.disarmed = true; + self.active_subagent_executions + .remove(&self.subagent_session_id); + } +} + +impl Drop for SubagentExecutionScope { + fn drop(&mut self) { + if self.disarmed { + return; + } + + warn!( + "Subagent execution scope dropped without normal completion; stopping orphaned subagent: session_id={}, dialog_turn_id={}", + self.subagent_session_id, self.subagent_dialog_turn_id + ); + + self.subagent_cancel_token.cancel(); + self.abort_handle.abort(); + self.active_subagent_executions + .remove(&self.subagent_session_id); + + let execution_engine = self.execution_engine.clone(); + let tool_pipeline = self.tool_pipeline.clone(); + let session_manager = self.session_manager.clone(); + let subagent_session_id = self.subagent_session_id.clone(); + let subagent_dialog_turn_id = self.subagent_dialog_turn_id.clone(); + + tokio::spawn(async move { + if let Err(error) = execution_engine + .cancel_dialog_turn(&subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel orphaned subagent dialog turn: session_id={}, dialog_turn_id={}, error={}", + subagent_session_id, subagent_dialog_turn_id, error + ); + } + + if let Err(error) = tool_pipeline + .cancel_dialog_turn_tools(&subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel orphaned subagent tools: session_id={}, dialog_turn_id={}, error={}", + subagent_session_id, subagent_dialog_turn_id, error + ); + } + + session_manager.reset_session_state_if_processing( + &subagent_session_id, + &subagent_dialog_turn_id, + ); + }); + } +} + #[derive(Clone)] struct SubagentConcurrencyLimiter { semaphore: Arc, @@ -348,6 +438,8 @@ pub struct ConversationCoordinator { subagent_profile_concurrency_limiters: Arc>>, /// Registry for dynamically adjusting subagent timeouts. subagent_timeout_registry: Arc>>>, + /// Active subagent executions keyed by subagent session id. + active_subagent_executions: Arc>, /// Notifies DialogScheduler of turn outcomes; injected after construction scheduler_notify_tx: OnceLock>, /// Round-boundary yield (same source as scheduler's yield flags); injected after construction @@ -363,6 +455,73 @@ pub struct ConversationCoordinator { } impl ConversationCoordinator { + async fn resolve_workspace_id_for_config(config: &SessionConfig) -> Option { + let explicit = config + .workspace_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if explicit.is_some() { + return explicit; + } + + let workspace_path = config.workspace_path.as_deref()?; + let workspace_service = get_global_workspace_service()?; + + if config.remote_connection_id.is_some() || config.remote_ssh_host.is_some() { + let normalized_path = normalize_remote_workspace_path(workspace_path); + let desired_connection_id = config + .remote_connection_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let desired_ssh_host = config + .remote_ssh_host + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + return workspace_service + .list_workspace_infos() + .await + .into_iter() + .find(|workspace| { + if workspace.workspace_kind != WorkspaceKind::Remote { + return false; + } + if normalize_remote_workspace_path(&workspace.root_path.to_string_lossy()) + != normalized_path + { + return false; + } + if let Some(connection_id) = desired_connection_id { + if workspace.remote_ssh_connection_id() != Some(connection_id) { + return false; + } + } + if let Some(ssh_host) = desired_ssh_host { + let workspace_ssh_host = workspace + .metadata + .get("sshHost") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()); + if workspace_ssh_host != Some(ssh_host) { + return false; + } + } + true + }) + .map(|workspace| workspace.id); + } + + workspace_service + .get_workspace_by_path(Path::new(workspace_path)) + .await + .map(|workspace| workspace.id) + } + async fn track_session_workspace_activity_best_effort(config: &SessionConfig, reason: &str) { let Some(workspace_path) = config.workspace_path.as_ref() else { return; @@ -407,6 +566,7 @@ impl ConversationCoordinator { async fn build_workspace_binding(config: &SessionConfig) -> Option { let workspace_path = config.workspace_path.as_ref()?; let path_buf = PathBuf::from(workspace_path); + let workspace_id = Self::resolve_workspace_id_for_config(config).await; let identity = crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( @@ -467,7 +627,7 @@ impl ConversationCoordinator { .unwrap_or(identity); let binding = WorkspaceBinding::new_remote( - None, + workspace_id.clone(), path_buf, effective_rid, connection_name, @@ -477,7 +637,7 @@ impl ConversationCoordinator { return Some(binding); } - let binding = WorkspaceBinding::new(None, path_buf); + let binding = WorkspaceBinding::new(workspace_id, path_buf); Some(binding) } @@ -801,6 +961,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet subagent_concurrency_limiter: Arc::new(RwLock::new(None)), subagent_profile_concurrency_limiters: Arc::new(RwLock::new(HashMap::new())), subagent_timeout_registry: Arc::new(RwLock::new(HashMap::new())), + active_subagent_executions: Arc::new(DashMap::new()), scheduler_notify_tx: OnceLock::new(), round_preempt_source: OnceLock::new(), round_injection_source: OnceLock::new(), @@ -950,6 +1111,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Persist the workspace binding inside the session config so execution can // consistently restore the correct workspace regardless of the entry point. config.workspace_path = Some(workspace_path.clone()); + config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; let agent_type = Self::normalize_agent_type(&agent_type); let session = self .session_manager @@ -993,6 +1155,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet created_by: Option, ) -> BitFunResult { config.workspace_path = Some(workspace_path); + config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; let agent_type = Self::normalize_agent_type(&agent_type); self.create_hidden_subagent_session( session_id, @@ -1400,6 +1563,178 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + async fn load_active_goal_mode(&self, session_id: &str) -> BitFunResult> { + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + let workspace_path = session.config.workspace_path.as_deref().ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {session_id}" + )) + })?; + let metadata = self + .session_manager + .load_session_metadata(Path::new(workspace_path), session_id) + .await?; + Ok( + goal_mode_from_custom_metadata(metadata.as_ref().and_then(|value| value.custom_metadata.as_ref())) + .filter(GoalModeState::is_active), + ) + } + + /// Activate `/goal` mode for a session by synthesizing a goal from context. + pub async fn activate_session_goal( + &self, + session_id: String, + user_hint: Option, + ) -> BitFunResult { + let session = self + .session_manager + .get_session(&session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + + if matches!(session.kind, SessionKind::Subagent | SessionKind::EphemeralChild) { + return Err(BitFunError::Validation( + "Goal mode is only available for main sessions".to_string(), + )); + } + + let context_messages = self + .session_manager + .get_context_messages(&session_id) + .await?; + let trimmed_hint = user_hint + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + let generation = generate_goal_from_context(&context_messages, trimmed_hint) + .await + .map_err(user_facing_goal_mode_error)?; + let activation = build_goal_kickoff_messages(&generation, trimmed_hint); + + let state = GoalModeState { + active: true, + goal_text: activation.goal_text.clone(), + success_criteria: activation.success_criteria.clone(), + user_hint: trimmed_hint.map(str::to_string), + activated_at_ms: now_ms(), + continuation_count: 0, + }; + + self.session_manager + .merge_session_custom_metadata(&session_id, goal_mode_patch(&state)) + .await?; + + info!( + "Session goal mode activated: session_id={}, goal={}", + session_id, activation.goal_text + ); + + Ok(activation) + } + + /// Verify the active session goal after a dialog turn completes. + pub async fn prepare_goal_continuation_after_turn( + &self, + session_id: &str, + source_turn_id: &str, + user_input: &str, + user_message_metadata: Option<&serde_json::Value>, + _final_response: &str, + ) -> BitFunResult> { + if should_skip_goal_verification_for_turn(user_input, user_message_metadata) { + return Ok(None); + } + + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + if matches!(session.kind, SessionKind::Subagent | SessionKind::EphemeralChild) { + return Ok(None); + } + + let Some(mut goal_state) = self.load_active_goal_mode(session_id).await? else { + return Ok(None); + }; + + if goal_state.continuation_count >= MAX_GOAL_CONTINUATIONS { + warn!( + "Session goal continuation limit reached; stopping auto-continue: session_id={}, goal={}", + session_id, goal_state.goal_text + ); + self.session_manager + .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) + .await?; + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "limit_reached".to_string(), + }) + .await; + return Ok(None); + } + + self.emit_event(AgenticEvent::GoalVerificationStarted { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + }) + .await; + + let context_messages = self + .session_manager + .get_context_messages(session_id) + .await?; + let verification = match verify_goal_achievement(&goal_state, &context_messages).await { + Ok(result) => result, + Err(error) => { + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "failed".to_string(), + }) + .await; + return Err(user_facing_goal_mode_error(error)); + } + }; + + if verification.achieved { + info!( + "Session goal achieved: session_id={}, goal={}", + session_id, goal_state.goal_text + ); + self.session_manager + .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) + .await?; + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "achieved".to_string(), + }) + .await; + return Ok(None); + } + + goal_state.continuation_count = goal_state.continuation_count.saturating_add(1); + self.session_manager + .merge_session_custom_metadata(session_id, goal_mode_patch(&goal_state)) + .await?; + + self.emit_event(AgenticEvent::GoalVerificationFinished { + session_id: session_id.to_string(), + source_turn_id: source_turn_id.to_string(), + outcome: "continuing".to_string(), + }) + .await; + + Ok(Some(build_goal_continuation_plan( + &goal_state, + &verification, + ))) + } + /// Compact the active session context as a persisted maintenance turn. pub async fn compact_session_manually(&self, session_id: String) -> BitFunResult<()> { let session = self @@ -1623,8 +1958,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; let effective_agent_type = Self::normalize_agent_type(&provisional_agent_type); - Self::track_session_workspace_activity_best_effort(&session.config, "dialog_started") - .await; + Self::track_session_workspace_activity_best_effort(&session.config, "dialog_started").await; debug!( "Resolved dialog turn agent type: session_id={}, turn_id={}, requested_agent_type={}, session_agent_type={}, effective_agent_type={}, trigger_source={:?}, queue_priority={:?}, skip_tool_confirmation={}", @@ -1847,7 +2181,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } ); - let wrapped_user_input = self + let mut wrapped_user_input = self .wrap_user_input( &effective_agent_type, previous_agent_type @@ -1859,6 +2193,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; + if let Ok(Some(goal_state)) = self.load_active_goal_mode(&session_id).await { + if !should_skip_goal_verification_for_turn( + &original_user_input, + user_message_metadata.as_ref(), + ) { + wrapped_user_input = + wrap_user_input_with_goal_reminder(wrapped_user_input, &goal_state); + } + } + if original_user_input != wrapped_user_input { let mut metadata = Self::ensure_user_message_metadata_object(user_message_metadata.take()); @@ -2411,6 +2755,137 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } + async fn cancel_active_subagents_for_parent_turn( + &self, + parent_session_id: &str, + parent_dialog_turn_id: &str, + ) { + let active_subagents: Vec = self + .active_subagent_executions + .iter() + .filter(|entry| { + entry.parent_session_id == parent_session_id + && entry.parent_dialog_turn_id == parent_dialog_turn_id + }) + .map(|entry| entry.value().clone()) + .collect(); + + if active_subagents.is_empty() { + return; + } + + info!( + "Cancelling {} active subagent execution(s) for parent turn: parent_session_id={}, parent_dialog_turn_id={}", + active_subagents.len(), + parent_session_id, + parent_dialog_turn_id + ); + + for active in active_subagents { + self.stop_active_subagent_execution(&active, "Parent dialog turn cancelled") + .await; + } + } + + async fn stop_active_subagent_execution( + &self, + active: &ActiveSubagentExecution, + reason: &str, + ) { + debug!( + "Stopping active subagent execution: subagent_session_id={}, subagent_dialog_turn_id={}, parent_session_id={}, parent_dialog_turn_id={}, reason={}", + active.subagent_session_id, + active.subagent_dialog_turn_id, + active.parent_session_id, + active.parent_dialog_turn_id, + reason + ); + + active.cancel_token.cancel(); + active.abort_handle.abort(); + + if let Err(error) = self + .execution_engine + .cancel_dialog_turn(&active.subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel active subagent dialog turn: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + + if let Err(error) = self + .tool_pipeline + .cancel_dialog_turn_tools(&active.subagent_dialog_turn_id) + .await + { + warn!( + "Failed to cancel active subagent tools: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + + match self + .session_manager + .update_session_state_for_turn_if_processing( + &active.subagent_session_id, + &active.subagent_dialog_turn_id, + SessionState::Idle, + ) + .await + { + Ok(true) => { + self.emit_event(AgenticEvent::SessionStateChanged { + session_id: active.subagent_session_id.clone(), + new_state: "idle".to_string(), + }) + .await; + if let Err(error) = self + .event_queue + .enqueue( + AgenticEvent::DialogTurnCancelled { + session_id: active.subagent_session_id.clone(), + turn_id: active.subagent_dialog_turn_id.clone(), + }, + Some(EventPriority::Critical), + ) + .await + { + warn!( + "Failed to emit subagent DialogTurnCancelled event: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + } + Ok(false) => {} + Err(error) => { + warn!( + "Failed to set subagent session Idle after stop: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + } + + if let Err(error) = self.session_manager.cancel_dialog_turn( + &active.subagent_session_id, + &active.subagent_dialog_turn_id, + ).await { + warn!( + "Failed to persist subagent turn cancellation: subagent_session_id={}, subagent_dialog_turn_id={}, error={}", + active.subagent_session_id, active.subagent_dialog_turn_id, error + ); + } + + self.session_manager.reset_session_state_if_processing( + &active.subagent_session_id, + &active.subagent_dialog_turn_id, + ); + + self.active_subagent_executions + .remove(&active.subagent_session_id); + } + /// Cancel dialog turn execution /// Immediately set state to Idle to allow new dialog, old turn ends naturally via cancel token pub async fn cancel_dialog_turn( @@ -2486,6 +2961,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet warn!("Failed to cancel tool execution: {}", e); } + self.cancel_active_subagents_for_parent_turn(session_id, dialog_turn_id) + .await; + // Step 4: Wait briefly for the spawn task that owns this turn to drain // its in-memory message writes before returning. Capped so the RPC // never blocks longer than ~1.5s — beyond that we let the new turn @@ -2663,7 +3141,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager.list_sessions(workspace_path).await } - pub async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + pub async fn resolve_session_workspace_path( + &self, + session_id: &str, + ) -> Option { self.session_manager .resolve_session_workspace_path(session_id) .await @@ -3075,10 +3556,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.session_manager .persist_session_lineage( &session_id, - build_subagent_session_relationship( - subagent_parent_info.as_ref(), - &agent_type, - ), + build_subagent_session_relationship(subagent_parent_info.as_ref(), &agent_type), ) .await?; @@ -3231,6 +3709,33 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await }); + let abort_handle = execution_task.abort_handle(); + + if subagent_parent_info.is_some() { + self.active_subagent_executions.insert( + session_id.clone(), + ActiveSubagentExecution { + parent_session_id: parent_session_id.to_string(), + parent_dialog_turn_id: parent_dialog_turn_id.to_string(), + subagent_session_id: session_id.clone(), + subagent_dialog_turn_id: dialog_turn_id.clone(), + cancel_token: subagent_cancel_token.clone(), + abort_handle: abort_handle.clone(), + }, + ); + } + + let mut execution_scope = SubagentExecutionScope { + execution_engine: self.execution_engine.clone(), + tool_pipeline: self.tool_pipeline.clone(), + session_manager: self.session_manager.clone(), + active_subagent_executions: self.active_subagent_executions.clone(), + subagent_session_id: session_id.clone(), + subagent_dialog_turn_id: dialog_turn_id.clone(), + subagent_cancel_token: subagent_cancel_token.clone(), + abort_handle, + disarmed: false, + }; enum SubagentExecutionOutcome { Completed(T), @@ -3322,6 +3827,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(BitFunError::tool(format!( "Subagent '{}' failed to join: {}", agent_type, error @@ -3384,6 +3890,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(BitFunError::Cancelled( "Subagent task has been cancelled".to_string(), )); @@ -3488,6 +3995,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Ok(partial_result); } @@ -3500,6 +4008,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(BitFunError::Timeout(timeout_error_message.clone())); } }; @@ -3528,6 +4037,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let mut registry = self.subagent_timeout_registry.write().await; registry.remove(&session_id); + execution_scope.disarm(); return Err(e); } }; @@ -3596,6 +4106,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet response_text.len(), subagent_started_at.elapsed().as_millis() ); + execution_scope.disarm(); Ok(SubagentResult::completed(response_text)) } @@ -3861,13 +4372,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet context: context.unwrap_or_default(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), }; - let coordinator = get_global_coordinator().ok_or_else(|| { - BitFunError::service("Coordinator not initialized".to_string()) - })?; + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::service("Coordinator not initialized".to_string()))?; + let parent_cancel_token = self + .execution_engine + .cancel_token_for_dialog_turn(&subagent_parent_info.dialog_turn_id) + .map(|token| token.child_token()); tokio::spawn(async move { let delivery_text = match coordinator - .execute_hidden_subagent_internal(request, None, timeout_seconds) + .execute_hidden_subagent_internal( + request, + parent_cancel_token.as_ref(), + timeout_seconds, + ) .await { Ok(result) => format_background_subagent_delivery_text( @@ -3883,7 +4401,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }; let metadata = serde_json::json!({ - "kind": "background_subagent_result", + "kind": "background_result", + "sourceKind": "subagent", "backgroundTaskId": background_task_id_for_delivery, "subagentType": agent_type, "taskDescription": task_description, @@ -3891,7 +4410,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet if let Some(scheduler) = super::scheduler::get_global_scheduler() { if let Err(error) = scheduler - .deliver_background_subagent_result( + .deliver_background_result( subagent_parent_info.session_id.clone(), parent_agent_type, parent_workspace_path, @@ -4479,10 +4998,8 @@ mod tests { assert!(completed_text.contains("\n")); assert!(!completed_text.contains("background_task_id=\"bg-subagent-123\"")); - let partial = super::SubagentResult::partial_timeout( - "partial".to_string(), - "timeout".to_string(), - ); + let partial = + super::SubagentResult::partial_timeout("partial".to_string(), "timeout".to_string()); let partial_text = super::format_background_subagent_delivery_text( "bg-subagent-456", "GeneralPurpose", diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 724d19d736..ed975d39f0 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -15,9 +15,8 @@ use super::turn_outcome::{TurnOutcome, TurnOutcomeQueueAction, TurnOutcomeStatus use crate::agentic::core::{PromptEnvelope, SessionState}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::{ - DialogRoundInjectionSource, DialogRoundPreemptSource, RoundInjection, - RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, - SessionRoundYieldFlags, + DialogRoundInjectionSource, DialogRoundPreemptSource, RoundInjection, RoundInjectionKind, + RoundInjectionTarget, SessionRoundInjectionBuffer, SessionRoundYieldFlags, }; use crate::agentic::session::SessionManager; use dashmap::DashMap; @@ -101,6 +100,9 @@ pub struct AgentSessionReplyRoute { struct ActiveTurn { turn_id: String, workspace_path: Option, + agent_type: String, + user_input: String, + user_message_metadata: Option, policy: DialogSubmissionPolicy, reply_route: Option, } @@ -110,6 +112,12 @@ impl ActiveTurn { Self { turn_id, workspace_path: turn.workspace_path.clone(), + agent_type: turn.agent_type.clone(), + user_input: turn + .original_user_input + .clone() + .unwrap_or_else(|| turn.user_input.clone()), + user_message_metadata: turn.user_message_metadata.clone(), policy: turn.policy, reply_route: turn.reply_route.clone(), } @@ -291,12 +299,12 @@ impl DialogScheduler { }) } - /// Deliver a completed background subagent result back to the parent - /// session. If the session is currently processing, inject the result into - /// the running turn at the next model-round boundary. Otherwise, start a - /// new turn immediately so the result is handled without waiting for an + /// Deliver a completed background result back to the parent session. + /// If the session is currently processing, inject the result into the + /// running turn at the next model-round boundary. Otherwise, start a new + /// turn immediately so the result is handled without waiting for an /// unrelated future message. - pub async fn deliver_background_subagent_result( + pub async fn deliver_background_result( &self, session_id: String, agent_type: String, @@ -318,7 +326,7 @@ impl DialogScheduler { &session_id, RoundInjection { id: injection_id, - kind: RoundInjectionKind::BackgroundSubagentResult, + kind: RoundInjectionKind::BackgroundResult, target: RoundInjectionTarget::CurrentRunningTurn, content, display_content: display, @@ -327,8 +335,8 @@ impl DialogScheduler { ); Ok(()) } - _ => { - self.submit( + _ => self + .submit( session_id, content, Some(display), @@ -341,8 +349,7 @@ impl DialogScheduler { None, ) .await - .map(|_| ()) - } + .map(|_| ()), } } @@ -791,6 +798,54 @@ Status: {status}" } } + if let (Some(active_turn), TurnOutcome::Completed { final_response, .. }) = + (active_turn.as_ref(), &outcome) + { + match self + .coordinator + .prepare_goal_continuation_after_turn( + &session_id, + &outcome.turn_id(), + &active_turn.user_input, + active_turn.user_message_metadata.as_ref(), + final_response, + ) + .await + { + Ok(Some(plan)) => { + if let Err(error) = self + .submit( + session_id.clone(), + plan.wrapped_message, + Some(plan.display_message), + None, + active_turn.agent_type.clone(), + active_turn.workspace_path.clone(), + DialogSubmissionPolicy::for_source( + DialogTriggerSource::AgentSession, + ), + None, + Some(plan.user_message_metadata), + None, + ) + .await + { + warn!( + "Failed to submit goal continuation turn: session_id={}, error={}", + session_id, error + ); + } + } + Ok(None) => {} + Err(error) => { + warn!( + "Goal verification failed after turn completion: session_id={}, error={}", + session_id, error + ); + } + } + } + let status = outcome.status(); match outcome.queue_action() { TurnOutcomeQueueAction::DispatchNext => { @@ -837,6 +892,9 @@ mod tests { ActiveTurn { turn_id: "turn_1".to_string(), workspace_path: Some("/workspace".to_string()), + agent_type: "agentic".to_string(), + user_input: "hello".to_string(), + user_message_metadata: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), reply_route: Some(AgentSessionReplyRoute { source_session_id: source_session_id.to_string(), diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index 2539c11757..05fa12896a 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -134,6 +134,9 @@ pub struct SessionConfig { /// without changing the desktop's foreground workspace. #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// Stable workspace id for resolving workspace-scoped metadata such as related directories. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, /// SSH workspace: required for remote tool I/O (file/shell). When set, `workspace_path` is /// interpreted as the path on that host; when unset, the workspace is always local regardless /// of string shape (avoids inferring remote from path alone). Also disambiguates the same @@ -159,6 +162,7 @@ impl Default for SessionConfig { enable_context_compression: true, compression_threshold: 0.8, // 80% workspace_path: None, + workspace_id: None, remote_connection_id: None, remote_ssh_host: None, model_id: None, diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 83ab84014f..1666001a57 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -5,36 +5,39 @@ use super::round_executor::RoundExecutor; use super::types::{ExecutionContext, ExecutionResult, RoundContext, RoundResult}; use crate::agentic::agents::{ - PromptBuilder, PromptBuilderContext, RemoteExecutionHints, get_agent_registry, + get_agent_registry, PromptBuilder, PromptBuilderContext, RemoteExecutionHints, + RequestContextToolSections, }; use crate::agentic::context_profile::{ContextProfilePolicy, ModelCapabilityProfile}; use crate::agentic::core::{ - Message, MessageContent, MessageHelper, MessageRole, MessageSemanticKind, - RequestReasoningTokenPolicy, Session, render_system_reminder, + render_system_reminder, Message, MessageContent, MessageHelper, MessageRole, + MessageSemanticKind, RequestReasoningTokenPolicy, Session, }; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue}; use crate::agentic::execution::types::FinishReason; use crate::agentic::image_analysis::{ - ImageContextData, ImageLimits, build_multimodal_message_with_images, - process_image_contexts_for_provider, + build_multimodal_message_with_images, process_image_contexts_for_provider, ImageContextData, + ImageLimits, }; use crate::agentic::round_preempt::RoundInjectionKind; use crate::agentic::session::{CompressionTailPolicy, ContextCompressor, SessionManager}; +use crate::agentic::tools::implementations::{GetToolSpecTool, SkillTool, TaskTool}; use crate::agentic::tools::{ - ResolvedToolManifest, SubagentParentInfo, ToolRuntimeRestrictions, resolve_tool_manifest, + resolve_tool_manifest, tool_context_runtime, ResolvedToolManifest, SubagentParentInfo, }; use crate::agentic::util::build_remote_workspace_layout_preview; use crate::agentic::{WorkspaceBackend, WorkspaceBinding}; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; -use crate::service::config::types::{ModelCapability, ModelCategory}; +use crate::service::config::types::{ModelCapability, ModelCategory, WriteToolMode}; use crate::service::remote_ssh::workspace_state::get_remote_workspace_manager; +use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::token_counter::TokenCounter; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; -use bitfun_agent_tools::{GetToolSpecLoadObservation, collect_loaded_collapsed_tool_names}; +use bitfun_agent_tools::{collect_loaded_collapsed_tool_names, GetToolSpecLoadObservation}; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; @@ -263,6 +266,31 @@ impl ExecutionEngine { ) } + /// Estimate how full the mutable conversation portion is for compression decisions. + /// + /// System prompt and tool definitions are fixed per dialog turn and should not + /// count against the auto-compression threshold the same way tool results do. + fn estimate_auto_compression_pressure( + messages: &[Message], + tools: Option<&[ToolDefinition]>, + context_window: usize, + ) -> (usize, usize, f32) { + let total_tokens = Self::estimate_request_tokens_internal(messages, tools); + let system_tokens = messages + .first() + .filter(|message| message.role == MessageRole::System) + .map(|message| message.estimate_tokens_with_reasoning(false)) + .unwrap_or(0); + let tool_tokens = tools + .map(TokenCounter::estimate_tool_definitions_tokens) + .unwrap_or(0); + let reserved_overhead = system_tokens.saturating_add(tool_tokens); + let conversation_tokens = total_tokens.saturating_sub(reserved_overhead); + let conversation_budget = context_window.saturating_sub(reserved_overhead).max(1); + let usage_ratio = conversation_tokens as f32 / conversation_budget as f32; + (total_tokens, conversation_tokens, usage_ratio) + } + fn tool_signature_args_summary(args_str: &str) -> String { if args_str.len() <= 128 { return args_str.to_string(); @@ -530,24 +558,75 @@ impl ExecutionEngine { ) } + async fn build_request_context_tool_sections( + manifest: &ResolvedToolManifest, + tool_context: &crate::agentic::tools::framework::ToolUseContext, + ) -> RequestContextToolSections { + let has_tool_definition = |tool_name: &str| { + manifest + .tool_definitions + .iter() + .any(|definition| definition.name == tool_name) + }; + + RequestContextToolSections { + available_skills: if has_tool_definition("Skill") { + SkillTool::build_available_skills_context_section(Some(tool_context)).await + } else { + None + }, + available_agents: if has_tool_definition("Task") { + TaskTool::build_available_agents_context_section(Some(tool_context)).await + } else { + None + }, + collapsed_tools: if has_tool_definition("GetToolSpec") { + GetToolSpecTool::build_collapsed_tools_context_section( + &manifest.collapsed_tool_summaries, + ) + } else { + None + }, + } + } + async fn build_prompt_context( context: &ExecutionContext, model_name: &str, supports_image_understanding: bool, - has_additional_tools: bool, + request_context_tools: RequestContextToolSections, ) -> Option { let workspace_path = context .workspace .as_ref() .map(|workspace| workspace.root_path_string())?; + let related_paths = if let Some(workspace_id) = context + .workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.as_deref()) + { + if let Some(workspace_service) = get_global_workspace_service() { + workspace_service + .get_workspace(workspace_id) + .await + .map(|workspace| workspace.related_paths) + .unwrap_or_default() + } else { + Vec::new() + } + } else { + Vec::new() + }; + let base = PromptBuilderContext::new( workspace_path.clone(), Some(context.session_id.clone()), Some(model_name.to_string()), ) + .with_related_paths(related_paths) .with_supports_image_understanding(supports_image_understanding) - .with_additional_tools_hint(has_additional_tools); + .with_request_context_tools(request_context_tools); let Some(workspace) = context.workspace.as_ref() else { return Some(base); @@ -1432,7 +1511,11 @@ impl ExecutionEngine { })?; // Primary model vision capability (tools + system prompt appendix; also used below for API message stripping). - let (resolved_primary_model_id, primary_supports_image_understanding) = { + let ( + resolved_primary_model_id, + primary_supports_image_understanding, + configured_write_tool_mode, + ) = { let config_service = get_global_config_service().await.ok(); if let Some(service) = config_service { let ai_config: crate::service::config::types::AIConfig = @@ -1465,12 +1548,12 @@ impl ExecutionEngine { || matches!(m.category, ModelCategory::Multimodal) }); - (resolved_id, supports) + (resolved_id, supports, ai_config.write_tool_mode) } else { warn!( "Config service unavailable, assuming primary model is text-only for image input gating" ); - (model_id.clone(), false) + (model_id.clone(), false, WriteToolMode::default()) } }; @@ -1524,6 +1607,30 @@ impl ExecutionEngine { .get("enable_tools") .and_then(|v| v.parse::().ok()) .unwrap_or(true); + let write_tool_mode = if context + .context + .get("acp_transport") + .is_some_and(|value| value == "true") + { + WriteToolMode::InlineContent + } else { + configured_write_tool_mode + }; + + let mut tool_manifest_context_vars = context.context.clone(); + tool_manifest_context_vars.insert( + "write_tool_mode".to_string(), + write_tool_mode.as_str().to_string(), + ); + + let tool_description_context = tool_context_runtime::build_tool_description_context( + &agent_type, + context.workspace.as_ref(), + context.workspace_services.as_ref(), + primary_supports_image_understanding, + &tool_manifest_context_vars, + ); + let tool_manifest = if enable_tools { debug!( "Agent tools: agent={}, tool_count={}", @@ -1531,14 +1638,10 @@ impl ExecutionEngine { allowed_tools.len() ); Some( - self.get_available_tools_and_definitions( + resolve_tool_manifest( &allowed_tools, &tool_policy.exposure_overrides, - context.workspace.as_ref(), - context.workspace_services.as_ref(), - &agent_type, - primary_supports_image_understanding, - &context.context, + &tool_description_context, ) .await, ) @@ -1549,7 +1652,11 @@ impl ExecutionEngine { .as_ref() .map(|manifest| manifest.collapsed_tool_names.clone()) .unwrap_or_default(); - let has_additional_tools = !collapsed_tools.is_empty(); + let request_context_tools = if let Some(manifest) = tool_manifest.as_ref() { + Self::build_request_context_tool_sections(manifest, &tool_description_context).await + } else { + RequestContextToolSections::default() + }; let (available_tools, tool_definitions) = if let Some(manifest) = tool_manifest { (manifest.allowed_tool_names, Some(manifest.tool_definitions)) } else { @@ -1566,7 +1673,7 @@ impl ExecutionEngine { &context, &ai_client.config.model, primary_supports_image_understanding, - has_additional_tools, + request_context_tools, ) .await; let request_context_reminder = if let Some(prompt_context) = prompt_context.as_ref() { @@ -1655,6 +1762,10 @@ impl ExecutionEngine { "primary_model_supports_image_understanding".to_string(), primary_supports_image_understanding.to_string(), ); + execution_context_vars.insert( + "write_tool_mode".to_string(), + write_tool_mode.as_str().to_string(), + ); execution_context_vars.insert("turn_index".to_string(), context.turn_index.to_string()); // If the primary model is text-only, do not send image payloads to the provider. @@ -1701,17 +1812,22 @@ impl ExecutionEngine { // - L1: AI-summary based full compression (preserves semantics). // - L2: Emergency truncation (only if tokens still exceed the // provider context window after L1). - let current_tokens = - Self::estimate_request_tokens_internal(&messages, tool_definitions.as_deref()); + let (current_tokens, conversation_tokens, token_usage_ratio) = + Self::estimate_auto_compression_pressure( + &messages, + tool_definitions.as_deref(), + context_window, + ); debug!( - "Round {} token usage before send: {} / {} tokens ({:.1}%)", + "Round {} token usage before send: total={} / {}, conversation={} / {}, usage={:.1}%", round_index, current_tokens, context_window, - (current_tokens as f32 / context_window as f32) * 100.0 + conversation_tokens, + context_window, + token_usage_ratio * 100.0 ); - let token_usage_ratio = current_tokens as f32 / context_window as f32; let should_compress = enable_context_compression && token_usage_ratio >= compression_threshold; @@ -2103,8 +2219,8 @@ impl ExecutionEngine { "\nThe user sent a new message while this turn was running. You have just finished the previous atomic action; handle this new user message now as the current direction, while preserving the existing conversation and task context. Do not ignore it or wait for a separate future turn.\n\nNew user message:\n{}\n", injection.content ), - RoundInjectionKind::BackgroundSubagentResult => format!( - "\nA background subagent has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground subagent result:\n{}\n", + RoundInjectionKind::BackgroundResult => format!( + "\nA background task has finished and returned new information while this turn was running. Incorporate it into your current work immediately when relevant. Do not wait for a separate future turn.\n\nBackground result:\n{}\n", injection.content ), }; @@ -2468,6 +2584,12 @@ impl ExecutionEngine { .register_cancel_token(dialog_turn_id, token) } + /// Return a clone of the cancellation token registered for a dialog turn. + pub fn cancel_token_for_dialog_turn(&self, dialog_turn_id: &str) -> Option { + self.round_executor + .cancel_token_for_dialog_turn(dialog_turn_id) + } + /// Cleanup cancellation token (for external calls) pub async fn cleanup_cancel_token(&self, dialog_turn_id: &str) { self.round_executor @@ -2475,41 +2597,6 @@ impl ExecutionEngine { .await } - /// Get available tool names and definitions: 1. Tool itself is enabled 2. Explicitly allowed in mode config - async fn get_available_tools_and_definitions( - &self, - allowed_tools: &[String], - exposure_overrides: &crate::agentic::agents::AgentToolPolicyOverrides, - workspace: Option<&crate::agentic::WorkspaceBinding>, - workspace_services: Option<&crate::agentic::workspace::WorkspaceServices>, - agent_type: &str, - primary_supports_image_understanding: bool, - context_vars: &HashMap, - ) -> ResolvedToolManifest { - let mut tool_opts_custom = HashMap::new(); - tool_opts_custom.insert( - "primary_model_supports_image_understanding".to_string(), - serde_json::Value::Bool(primary_supports_image_understanding), - ); - for (key, value) in context_vars { - tool_opts_custom.insert(key.clone(), serde_json::Value::String(value.clone())); - } - let description_context = crate::agentic::tools::framework::ToolUseContext { - tool_call_id: None, - agent_type: Some(agent_type.to_string()), - session_id: None, - dialog_turn_id: None, - workspace: workspace.cloned(), - unlocked_collapsed_tools: Vec::new(), - custom_data: tool_opts_custom, - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: workspace_services.cloned(), - }; - resolve_tool_manifest(allowed_tools, exposure_overrides, &description_context).await - } - /// Emit event async fn emit_event(&self, event: AgenticEvent, priority: EventPriority) { let _ = self.event_queue.enqueue(event, Some(priority)).await; @@ -2519,9 +2606,10 @@ impl ExecutionEngine { #[cfg(test)] mod tests { use super::{ContextHealthSnapshot, ExecutionEngine}; - use crate::agentic::core::{Message, ToolCall, ToolResult}; + use crate::agentic::core::{Message, MessageRole, ToolCall, ToolResult}; use crate::service::config::types::AIConfig; use crate::service::config::types::AIModelConfig; + use crate::util::types::ToolDefinition; use serde_json::json; use sha2::{Digest, Sha256}; @@ -2568,6 +2656,26 @@ mod tests { ); } + #[test] + fn auto_compression_pressure_excludes_system_and_tool_overhead() { + let messages = vec![ + Message::system("system prompt".repeat(10_000)), + Message::user("hello".to_string()), + ]; + let tools = vec![ToolDefinition { + name: "Read".to_string(), + description: "Read files".repeat(5_000), + parameters: json!({"type": "object"}), + }]; + + let (total_tokens, conversation_tokens, usage_ratio) = + ExecutionEngine::estimate_auto_compression_pressure(&messages, Some(&tools), 128_000); + + assert!(total_tokens > conversation_tokens); + assert!(usage_ratio < total_tokens as f32 / 128_000_f32); + assert_eq!(messages[1].role, MessageRole::User); + } + #[test] fn tool_signature_args_summary_truncates_on_utf8_boundary() { let args = format!("{}{}", "a".repeat(62), "案".repeat(30)); diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 3dd1b712dd..91b6ec1027 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -7,13 +7,17 @@ use super::types::{FinishReason, RoundContext, RoundResult}; use crate::agentic::core::{Message, ToolCall}; use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; -use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; -use crate::agentic::tools::implementations::file_write_tool::FileWriteTool; +use crate::agentic::tools::framework::ToolUseContext; +use crate::agentic::tools::implementations::file_write_tool::{ + FileWriteTool, WRITE_TOOL_MODE_CONTEXT_KEY, +}; use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolExecutionOptions, ToolPipeline}; use crate::agentic::tools::registry::get_global_tool_registry; -use crate::agentic::tools::ToolPathOperation; +use crate::agentic::tools::tool_context_runtime; +use crate::agentic::tools::tool_result_storage; use crate::agentic::MessageContent; use crate::infrastructure::ai::AIClient; +use crate::service::config::types::WriteToolMode; use crate::service::config::GlobalConfigManager; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; @@ -21,7 +25,6 @@ use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; use dashmap::DashMap; use log::{debug, error, info, warn}; -use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -44,6 +47,15 @@ impl RoundExecutor { !text.trim().is_empty() } + fn write_tool_mode(context: &RoundContext) -> WriteToolMode { + WriteToolMode::from_context_var( + context + .context_vars + .get(WRITE_TOOL_MODE_CONTEXT_KEY) + .map(String::as_str), + ) + } + pub fn new( stream_processor: Arc, event_queue: Arc, @@ -555,8 +567,11 @@ impl RoundExecutor { // model emit large file contents inside JSON tool-call arguments, which // is a major source of JSON parse failures. let tool_calls = stream_result.tool_calls.clone(); - let tool_calls = self - .generate_write_tool_contents( + let tool_calls = if matches!( + Self::write_tool_mode(&context), + WriteToolMode::PlaintextFollowup + ) { + self.generate_write_tool_contents( ai_client.clone(), &context, &round_id, @@ -564,7 +579,10 @@ impl RoundExecutor { tool_calls, &cancel_token, ) - .await?; + .await? + } else { + tool_calls + }; // Execute tool calls debug!( @@ -651,6 +669,14 @@ impl RoundExecutor { ..ToolExecutionOptions::default() }; + let storage_context = + tool_context_runtime::build_tool_use_context_for_execution_context( + &tool_context, + Some(format!("round-budget-{}", round_id)), + self.computer_use_host(), + CancellationToken::new(), + ); + // Execute tools — convert pipeline-level Err into per-tool error results // so the model always receives a tool_result for every tool_call. let execution_results = match tool_pipeline @@ -687,8 +713,10 @@ impl RoundExecutor { } }; - // Convert to ToolResult - execution_results.into_iter().map(|r| r.result).collect() + // Convert to ToolResult, then enforce the aggregate budget for this model round. + let tool_results = execution_results.into_iter().map(|r| r.result).collect(); + tool_result_storage::apply_round_tool_result_budget(tool_results, &storage_context) + .await } else { vec![] }; @@ -797,6 +825,13 @@ impl RoundExecutor { .insert(dialog_turn_id.to_string(), token); } + /// Return a clone of the cancellation token registered for a dialog turn. + pub fn cancel_token_for_dialog_turn(&self, dialog_turn_id: &str) -> Option { + self.cancellation_tokens + .get(dialog_turn_id) + .map(|entry| entry.clone()) + } + /// Cancel dialog turn (using dialog_turn_id) pub async fn cancel_dialog_turn(&self, dialog_turn_id: &str) -> BitFunResult<()> { debug!("Cancelling dialog turn: dialog_turn_id={}", dialog_turn_id); @@ -878,16 +913,31 @@ impl RoundExecutor { .to_string(); let tool_id = tc.tool_id.clone(); - let target_has_prior_delete = - Self::write_target_has_prior_delete(context, &tool_calls, *idx, &file_path).await; - if let Some(error) = - Self::write_content_preflight_error(context, &file_path, target_has_prior_delete) - .await + if let Some(error) = Self::write_content_preflight_error(context, &file_path).await { debug!( "Skipping Write content generation after preflight failure: file_path={}, error={}", file_path, error ); + self.emit_event( + AgenticEvent::ToolEvent { + session_id: context.session_id.clone(), + turn_id: context.dialog_turn_id.clone(), + round_id: round_id.to_string(), + tool_event: ToolEventData::Failed { + tool_id: tool_id.clone(), + tool_name: "Write".to_string(), + error, + duration_ms: None, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + }, + }, + EventPriority::High, + ) + .await; continue; } @@ -981,10 +1031,10 @@ impl RoundExecutor { Ok(None) => break, Err(_) => { return Err(BitFunError::Timeout(format!( - "Write content generation timed out for {} after {} seconds without stream progress", - file_path, - watchdog_timeout.as_secs() - ))); + "Write content generation timed out for {} after {} seconds without stream progress", + file_path, + watchdog_timeout.as_secs() + ))); } }; @@ -1098,88 +1148,21 @@ impl RoundExecutor { async fn write_content_preflight_error( context: &RoundContext, file_path: &str, - target_has_prior_delete: bool, ) -> Option { let tool_context = Self::build_write_preflight_context(context); - let resolved = match tool_context.resolve_tool_path(file_path) { - Ok(resolved) => resolved, - Err(error) => return Some(error.to_string()), - }; - - if let Err(error) = tool_context.enforce_path_operation(ToolPathOperation::Write, &resolved) - { - return Some(error.to_string()); - } - - if target_has_prior_delete { - return None; - } - - FileWriteTool::existing_file_error(&tool_context, &resolved).await - } - - async fn write_target_has_prior_delete( - context: &RoundContext, - tool_calls: &[ToolCall], - write_idx: usize, - file_path: &str, - ) -> bool { - let tool_context = Self::build_write_preflight_context(context); - let write_resolved = match tool_context.resolve_tool_path(file_path) { - Ok(resolved) => resolved, - Err(_) => return false, - }; - - for prior_call in tool_calls.iter().take(write_idx) { - if prior_call.tool_name != "Delete" { - continue; - } - - let Some(delete_path) = prior_call.arguments.get("path").and_then(|v| v.as_str()) - else { - continue; - }; - - let delete_resolved = match tool_context.resolve_tool_path(delete_path) { - Ok(resolved) => resolved, - Err(_) => continue, - }; - - if tool_context - .enforce_path_operation(ToolPathOperation::Delete, &delete_resolved) - .is_err() - { - continue; - } - - let recursive = prior_call - .arguments - .get("recursive") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - if delete_covers_write_target(&delete_resolved, &write_resolved, recursive) { - return true; - } - } - - false + FileWriteTool::preflight_write_error(&tool_context, file_path).await } fn build_write_preflight_context(context: &RoundContext) -> ToolUseContext { - ToolUseContext { - tool_call_id: None, - agent_type: Some(context.agent_type.clone()), - session_id: Some(context.session_id.clone()), - dialog_turn_id: Some(context.dialog_turn_id.clone()), - workspace: context.workspace.clone(), - unlocked_collapsed_tools: context.unlocked_collapsed_tools.clone(), - custom_data: HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), - workspace_services: context.workspace_services.clone(), - } + tool_context_runtime::build_write_preflight_context( + &context.agent_type, + &context.session_id, + &context.dialog_turn_id, + context.workspace.clone(), + context.unlocked_collapsed_tools.clone(), + context.runtime_tool_restrictions.clone(), + context.workspace_services.clone(), + ) } /// Emit event @@ -1364,36 +1347,6 @@ fn token_details_from_usage( (!details.is_empty()).then_some(serde_json::Value::Object(details)) } -fn delete_covers_write_target( - delete_target: &ToolPathResolution, - write_target: &ToolPathResolution, - recursive: bool, -) -> bool { - if delete_target.backend != write_target.backend { - return false; - } - - if delete_target.resolved_path == write_target.resolved_path { - return true; - } - - if !recursive { - return false; - } - - if delete_target.uses_remote_workspace_backend() { - let delete_prefix = delete_target.resolved_path.trim_end_matches('/'); - let write_path = write_target.resolved_path.as_str(); - return !delete_prefix.is_empty() - && write_path.len() > delete_prefix.len() - && write_path.starts_with(delete_prefix) - && write_path.as_bytes().get(delete_prefix.len()) == Some(&b'/'); - } - - std::path::Path::new(&write_target.resolved_path) - .starts_with(std::path::Path::new(&delete_target.resolved_path)) -} - /// Extract content from `...` tags. /// /// If the tags are present, returns the text between them (trimmed). @@ -1607,7 +1560,6 @@ fn detect_placeholder_patterns(content: &str) -> Option<&'static str> { #[cfg(test)] mod tests { use super::{extract_bitfun_contents, RoundExecutor, StreamProcessor}; - use crate::agentic::core::ToolCall; use crate::agentic::events::{EventQueue, EventQueueConfig}; use crate::agentic::execution::types::RoundContext; use crate::agentic::tools::ToolRuntimeRestrictions; @@ -1651,15 +1603,14 @@ mod tests { } } - fn tool_call(tool_id: &str, tool_name: &str, arguments: serde_json::Value) -> ToolCall { - ToolCall { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments, - raw_arguments: None, - is_error: false, - recovered_from_truncation: false, - } + #[tokio::test] + async fn cancel_token_for_dialog_turn_returns_registered_token() { + let executor = test_round_executor(); + let token = CancellationToken::new(); + executor.register_cancel_token("turn-1", token.clone()); + + assert!(executor.cancel_token_for_dialog_turn("turn-1").is_some()); + assert!(executor.cancel_token_for_dialog_turn("missing").is_none()); } #[tokio::test] @@ -1683,54 +1634,31 @@ mod tests { } #[tokio::test] - async fn write_preflight_rejects_existing_file_without_prior_delete() { + async fn write_preflight_allows_new_file_target() { let root = std::env::temp_dir().join(format!("bitfun-write-preflight-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); - std::fs::write(root.join("target.txt"), "old").expect("create target file"); let context = test_round_context(root.clone()); - let error = - RoundExecutor::write_content_preflight_error(&context, "target.txt", false).await; + let error = RoundExecutor::write_content_preflight_error(&context, "target.txt").await; let _ = std::fs::remove_dir_all(&root); - assert!(error - .as_deref() - .unwrap_or_default() - .contains("already exists")); + assert_eq!(error, None); } #[tokio::test] - async fn write_preflight_allows_existing_file_when_prior_delete_targets_same_path() { + async fn write_preflight_allows_existing_file_without_read_state_tracking() { let root = std::env::temp_dir().join(format!("bitfun-write-preflight-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); std::fs::write(root.join("target.txt"), "old").expect("create target file"); let context = test_round_context(root.clone()); - let tool_calls = vec![ - tool_call( - "delete-1", - "Delete", - serde_json::json!({"path": "target.txt"}), - ), - tool_call( - "write-1", - "Write", - serde_json::json!({"file_path": "target.txt"}), - ), - ]; - let has_prior_delete = - RoundExecutor::write_target_has_prior_delete(&context, &tool_calls, 1, "target.txt") - .await; - let error = - RoundExecutor::write_content_preflight_error(&context, "target.txt", has_prior_delete) - .await; + let error = RoundExecutor::write_content_preflight_error(&context, "target.txt").await; let _ = std::fs::remove_dir_all(&root); - assert!(has_prior_delete); assert_eq!(error, None); } @@ -1990,8 +1918,18 @@ mod tests { cache_creation_token_count: Some(20), }; let details = super::token_details_from_usage(&usage).expect("details"); - assert_eq!(details.get("cachedContentTokenCount").and_then(|v| v.as_u64()), Some(30)); - assert_eq!(details.get("cacheCreationTokenCount").and_then(|v| v.as_u64()), Some(20)); + assert_eq!( + details + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()), + Some(30) + ); + assert_eq!( + details + .get("cacheCreationTokenCount") + .and_then(|v| v.as_u64()), + Some(20) + ); } #[test] @@ -2006,7 +1944,12 @@ mod tests { cache_creation_token_count: None, }; let details = super::token_details_from_usage(&usage).expect("details"); - assert_eq!(details.get("cachedContentTokenCount").and_then(|v| v.as_u64()), Some(30)); + assert_eq!( + details + .get("cachedContentTokenCount") + .and_then(|v| v.as_u64()), + Some(30) + ); assert!(details.get("cacheCreationTokenCount").is_none()); } diff --git a/src/crates/core/src/agentic/goal_mode/mod.rs b/src/crates/core/src/agentic/goal_mode/mod.rs new file mode 100644 index 0000000000..907e637b0b --- /dev/null +++ b/src/crates/core/src/agentic/goal_mode/mod.rs @@ -0,0 +1,547 @@ +//! Session goal mode: `/goal` command support with AI goal synthesis and +//! post-turn achievement verification. + +mod types; + +pub use types::*; + +use crate::agentic::core::{ + Message, MessageContent, MessageRole, MessageSemanticKind, PromptEnvelope, +}; +use crate::service::config::{get_app_language_code, short_model_user_language_instruction}; +use crate::util::errors::{BitFunError, BitFunResult}; +use crate::util::extract_json_from_ai_response; +use crate::util::sanitize_plain_model_output; +use crate::util::types::Message as AIMessage; +use log::warn; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn goal_mode_from_custom_metadata( + custom_metadata: Option<&serde_json::Value>, +) -> Option { + let value = custom_metadata?.get(GOAL_MODE_METADATA_KEY)?; + serde_json::from_value(value.clone()).ok() +} + +pub fn goal_mode_patch(state: &GoalModeState) -> serde_json::Value { + serde_json::json!({ + GOAL_MODE_METADATA_KEY: state, + }) +} + +pub fn clear_goal_mode_patch() -> serde_json::Value { + serde_json::json!({ + GOAL_MODE_METADATA_KEY: serde_json::Value::Null, + }) +} + +pub fn message_text(message: &Message) -> Option { + match &message.content { + MessageContent::Text(text) => Some(text.clone()), + MessageContent::Multimodal { text, .. } => Some(text.clone()), + MessageContent::Mixed { text, .. } if !text.trim().is_empty() => Some(text.clone()), + _ => None, + } +} + +/// Convert the full in-memory session transcript into provider messages, using +/// the same omission rules as normal model sends for UI-only computer-use frames. +pub fn build_goal_context_ai_messages(messages: &[Message]) -> Vec { + messages + .iter() + .filter(|message| !should_skip_message_for_goal_context(message)) + .map(AIMessage::from) + .collect() +} + +fn should_skip_message_for_goal_context(message: &Message) -> bool { + matches!( + message.metadata.semantic_kind.as_ref(), + Some(MessageSemanticKind::ComputerUseVerificationScreenshot) + | Some(MessageSemanticKind::ComputerUsePostActionSnapshot) + ) +} + +pub fn last_assistant_message_text(messages: &[Message]) -> Option { + messages + .iter() + .rev() + .filter(|message| message.role == MessageRole::Assistant) + .find_map(message_text) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +pub fn user_facing_goal_mode_error(error: BitFunError) -> BitFunError { + match error { + BitFunError::Validation(_) | BitFunError::NotFound(_) => error, + other => { + warn!("Goal mode AI call failed: {other}"); + BitFunError::Validation( + "Goal mode AI request failed. Check model configuration and try again." + .to_string(), + ) + } + } +} + +pub fn build_goal_system_reminder(state: &GoalModeState) -> String { + let criteria = if state.success_criteria.is_empty() { + "- Use your best judgment to decide when the goal is fully complete.".to_string() + } else { + state + .success_criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + }; + + format!( + "Active session goal mode is ON.\n\ +Goal: {}\n\ +Success criteria:\n{}\n\ +Keep working toward this goal. Do not declare the task finished until every criterion is truly satisfied.", + state.goal_text.trim(), + criteria + ) +} + +pub fn wrap_user_input_with_goal_reminder(user_input: String, state: &GoalModeState) -> String { + if has_prompt_markup(&user_input) { + return user_input; + } + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(build_goal_system_reminder(state)); + envelope.push_user_query(user_input); + envelope.render() +} + +fn has_prompt_markup(text: &str) -> bool { + crate::agentic::core::has_prompt_markup(text) +} + +pub fn build_goal_kickoff_messages( + generation: &GoalGenerationResult, + user_hint: Option<&str>, +) -> GoalActivationResult { + let goal_text = generation.goal_text.trim().to_string(); + let criteria = generation + .success_criteria + .iter() + .map(|item| item.trim()) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect::>(); + + let criteria_block = if criteria.is_empty() { + String::new() + } else { + format!( + "\nSuccess criteria:\n{}", + criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + ) + }; + + let hint_line = user_hint + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("\nUser-provided focus: {value}")) + .unwrap_or_default(); + + let display_message = format!("/goal {goal_text}"); + let kickoff_message = format!( + "Work toward this session goal until it is fully achieved.{hint_line}\n\nGoal: {goal_text}{criteria_block}\n\nStart executing now. Verify your work before stopping." + ); + + GoalActivationResult { + goal_text: goal_text.clone(), + success_criteria: criteria, + kickoff_message, + display_message, + } +} + +pub fn build_goal_continuation_plan( + state: &GoalModeState, + verification: &GoalVerificationResult, +) -> GoalContinuationPlan { + let gaps = if verification.gaps.is_empty() { + "- The goal is not fully complete yet.".to_string() + } else { + verification + .gaps + .iter() + .map(|gap| format!("- {gap}")) + .collect::>() + .join("\n") + }; + + let guidance = verification.guidance.trim(); + let guidance_block = if guidance.is_empty() { + "Continue working on the remaining gaps before stopping.".to_string() + } else { + guidance.to_string() + }; + + let display_message = format!( + "Goal not yet achieved — continuing work on: {}", + state.goal_text + ); + + let wrapped_message = { + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(format!( + "Goal verification found the active session goal is NOT yet achieved.\n\ +Goal: {}\n\ +Remaining gaps:\n{gaps}\n\ +Next steps:\n{guidance_block}\n\ +Continue working until the goal is fully satisfied. Do not stop early.", + state.goal_text.trim() + )); + envelope.push_user_query(format!( + "Continue working toward the session goal. Address the remaining gaps and complete the goal before stopping.\n\nGoal: {}", + state.goal_text.trim() + )); + envelope.render() + }; + + GoalContinuationPlan { + wrapped_message, + display_message, + user_message_metadata: serde_json::json!({ + "goalModeContinuation": true, + "goalText": state.goal_text, + }), + } +} + +pub fn should_skip_goal_verification_for_turn( + user_input: &str, + user_message_metadata: Option<&serde_json::Value>, +) -> bool { + let trimmed = user_input.trim(); + if trimmed.eq_ignore_ascii_case("/compact") + || trimmed.starts_with("/usage") + || trimmed.starts_with("/btw") + { + return true; + } + if user_message_metadata + .and_then(|metadata| metadata.get("maintenanceTurn")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return true; + } + false +} + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +async fn call_goal_func_agent_with_context( + system_prompt: String, + context_messages: &[Message], + final_user_prompt: String, +) -> BitFunResult { + let mut messages = Vec::with_capacity(context_messages.len() + 2); + messages.push(AIMessage { + role: "system".to_string(), + content: Some(system_prompt), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }); + messages.extend(build_goal_context_ai_messages(context_messages)); + messages.push(AIMessage { + role: "user".to_string(), + content: Some(final_user_prompt), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }); + + let ai_client_factory = crate::infrastructure::ai::get_global_ai_client_factory() + .await + .map_err(|error| { + user_facing_goal_mode_error(BitFunError::AIClient(format!( + "Failed to get AI client factory: {error}" + ))) + })?; + + let ai_client = ai_client_factory + .get_client_by_func_agent(GOAL_MODE_FUNC_AGENT) + .await + .map_err(|error| { + user_facing_goal_mode_error(BitFunError::AIClient(format!( + "Failed to get goal func agent client: {error}" + ))) + })?; + + let response = ai_client + .send_message(messages, None) + .await + .map_err(|error| { + user_facing_goal_mode_error(BitFunError::ai(format!( + "Goal func agent call failed: {error}" + ))) + })?; + + Ok(sanitize_plain_model_output(&response.text)) +} + +pub async fn generate_goal_from_context( + context_messages: &[Message], + user_hint: Option<&str>, +) -> BitFunResult { + let lang_code = get_app_language_code().await; + let language_instruction = short_model_user_language_instruction(lang_code.as_str()); + + let hint_block = user_hint + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("\nUser-provided goal focus: {value}")) + .unwrap_or_default(); + + let latest_assistant_note = last_assistant_message_text(context_messages) + .map(|_| { + "\nUse the full conversation above, paying special attention to the latest assistant message." + .to_string() + }) + .unwrap_or_default(); + + let system_prompt = format!( + "You synthesize a single actionable session goal from the conversation transcript above.\n\ +Return ONLY valid JSON with this shape:\n\ +{{\"goalText\":\"...\",\"successCriteria\":[\"...\",\"...\"]}}\n\ +Requirements:\n\ +- {language_instruction}\n\ +- goalText must be concrete and verifiable\n\ +- successCriteria must list 2-5 objective completion checks\n\ +- Do not include markdown or commentary" + ); + + let final_user_prompt = format!( + "Based on the full conversation above,{latest_assistant_note}{hint_block}\n\n\ +Synthesize the session goal JSON:" + ); + + let raw = call_goal_func_agent_with_context( + system_prompt, + context_messages, + final_user_prompt, + ) + .await?; + parse_goal_generation(&raw) +} + +pub async fn verify_goal_achievement( + state: &GoalModeState, + context_messages: &[Message], +) -> BitFunResult { + let criteria = if state.success_criteria.is_empty() { + "- Use the goal text itself as the completion standard.".to_string() + } else { + state + .success_criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + }; + + let system_prompt = format!( + "You verify whether a coding-agent session goal has truly been achieved.\n\ +Active goal: {}\n\ +Success criteria:\n{criteria}\n\ +Use the full conversation transcript above, especially the latest assistant work.\n\ +Return ONLY valid JSON with this shape:\n\ +{{\"achieved\":true|false,\"confidence\":0.0,\"gaps\":[\"...\"],\"guidance\":\"...\"}}\n\ +Rules:\n\ +- achieved=true ONLY when every success criterion is objectively satisfied in the actual work done\n\ +- Be strict: partial progress, plans, or explanations without completed work means achieved=false\n\ +- gaps must list concrete missing items when achieved=false\n\ +- guidance must be actionable next steps for the agent\n\ +- Do not include markdown or commentary", + state.goal_text.trim() + ); + + let final_user_prompt = + "Verify whether the active session goal has been fully achieved. Return the JSON verdict." + .to_string(); + + let raw = call_goal_func_agent_with_context( + system_prompt, + context_messages, + final_user_prompt, + ) + .await?; + parse_goal_verification(&raw) +} + +fn parse_goal_generation(raw: &str) -> BitFunResult { + let json = extract_json_from_ai_response(raw).ok_or_else(|| { + BitFunError::Validation("Goal generation returned an unreadable model response.".to_string()) + })?; + let mut parsed: GoalGenerationResult = serde_json::from_str(&json).map_err(|error| { + BitFunError::Validation(format!("Failed to parse goal generation JSON: {error}")) + })?; + parsed.goal_text = parsed.goal_text.trim().to_string(); + parsed.success_criteria = parsed + .success_criteria + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect(); + if parsed.goal_text.is_empty() { + return Err(BitFunError::Validation( + "Goal generation returned an empty goal".to_string(), + )); + } + Ok(parsed) +} + +fn parse_goal_verification(raw: &str) -> BitFunResult { + let json = extract_json_from_ai_response(raw).ok_or_else(|| { + BitFunError::Validation( + "Goal verification returned an unreadable model response.".to_string(), + ) + })?; + let mut parsed: GoalVerificationResult = serde_json::from_str(&json).map_err(|error| { + BitFunError::Validation(format!("Failed to parse goal verification JSON: {error}")) + })?; + parsed.guidance = parsed.guidance.trim().to_string(); + parsed.gaps = parsed + .gaps + .into_iter() + .map(|gap| gap.trim().to_string()) + .filter(|gap| !gap.is_empty()) + .collect(); + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::core::Message; + + #[test] + fn goal_mode_patch_round_trips() { + let state = GoalModeState { + active: true, + goal_text: "Fix login".to_string(), + success_criteria: vec!["Tests pass".to_string()], + user_hint: None, + activated_at_ms: 1, + continuation_count: 0, + }; + let patch = goal_mode_patch(&state); + let parsed = goal_mode_from_custom_metadata(Some(&patch)).expect("goal mode"); + assert_eq!(parsed, state); + } + + #[test] + fn build_goal_context_ai_messages_keeps_full_user_and_assistant_messages() { + let long_assistant = format!("{}END", "x".repeat(1200)); + let messages = vec![ + Message::user("Implement /goal".to_string()), + Message::assistant(long_assistant.clone()), + ]; + let converted = build_goal_context_ai_messages(&messages); + assert_eq!(converted.len(), 2); + assert_eq!(converted[0].content.as_deref(), Some("Implement /goal")); + assert_eq!(converted[1].content.as_deref(), Some(long_assistant.as_str())); + } + + #[test] + fn last_assistant_message_text_returns_latest_assistant() { + let messages = vec![ + Message::assistant("older".to_string()), + Message::user("follow up".to_string()), + Message::assistant("latest".to_string()), + ]; + assert_eq!( + last_assistant_message_text(&messages).as_deref(), + Some("latest") + ); + } + + #[test] + fn skip_verification_for_maintenance_commands() { + assert!(should_skip_goal_verification_for_turn("/compact", None)); + assert!(should_skip_goal_verification_for_turn("/usage", None)); + assert!(!should_skip_goal_verification_for_turn("fix bug", None)); + } + + #[test] + fn user_facing_goal_mode_error_hides_ai_client_details() { + let mapped = user_facing_goal_mode_error(BitFunError::AIClient( + "provider timeout".to_string(), + )); + match mapped { + BitFunError::Validation(message) => { + assert!(!message.contains("provider timeout")); + assert!(message.contains("Goal mode AI request failed")); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn continuation_plan_includes_goal_text() { + let state = GoalModeState { + active: true, + goal_text: "Ship feature".to_string(), + success_criteria: vec![], + user_hint: None, + activated_at_ms: 0, + continuation_count: 1, + }; + let verification = GoalVerificationResult { + achieved: false, + confidence: 0.2, + gaps: vec!["Missing tests".to_string()], + guidance: "Add tests".to_string(), + }; + let plan = build_goal_continuation_plan(&state, &verification); + assert!(plan.wrapped_message.contains("Ship feature")); + assert!(plan.display_message.contains("Ship feature")); + } + + #[test] + fn parse_goal_generation_accepts_json() { + let parsed = parse_goal_generation( + r#"{"goalText":"Fix bug","successCriteria":["Tests pass"]}"#, + ) + .expect("parsed"); + assert_eq!(parsed.goal_text, "Fix bug"); + assert_eq!(parsed.success_criteria, vec!["Tests pass".to_string()]); + } + + #[test] + fn parse_goal_verification_accepts_json() { + let parsed = parse_goal_verification( + r#"{"achieved":false,"confidence":0.4,"gaps":["Need tests"],"guidance":"Add tests"}"#, + ) + .expect("parsed"); + assert!(!parsed.achieved); + assert_eq!(parsed.guidance, "Add tests"); + } +} diff --git a/src/crates/core/src/agentic/goal_mode/types.rs b/src/crates/core/src/agentic/goal_mode/types.rs new file mode 100644 index 0000000000..b33dddbff6 --- /dev/null +++ b/src/crates/core/src/agentic/goal_mode/types.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +pub const GOAL_MODE_METADATA_KEY: &str = "goal_mode"; +pub const GOAL_MODE_FUNC_AGENT: &str = "session-title-func-agent"; +pub const MAX_GOAL_CONTINUATIONS: u32 = 100; +pub const MAX_CONTEXT_SUMMARY_CHARS: usize = 12_000; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GoalModeState { + pub active: bool, + pub goal_text: String, + #[serde(default)] + pub success_criteria: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_hint: Option, + #[serde(default)] + pub activated_at_ms: u64, + #[serde(default)] + pub continuation_count: u32, +} + +impl GoalModeState { + pub fn is_active(&self) -> bool { + self.active && !self.goal_text.trim().is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GoalGenerationResult { + pub goal_text: String, + #[serde(default)] + pub success_criteria: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GoalVerificationResult { + pub achieved: bool, + #[serde(default)] + pub confidence: f32, + #[serde(default)] + pub gaps: Vec, + #[serde(default)] + pub guidance: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoalActivationResult { + pub goal_text: String, + pub success_criteria: Vec, + pub kickoff_message: String, + pub display_message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoalContinuationPlan { + pub wrapped_message: String, + pub display_message: String, + pub user_message_metadata: serde_json::Value, +} diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index 698c163f50..9704a1c23d 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -35,6 +35,9 @@ pub mod image_analysis; // Ephemeral side-question module (used by desktop /btw overlay) pub mod side_question; + +// Session goal mode (/goal command) +pub mod goal_mode; pub mod system; // Agents module @@ -53,13 +56,13 @@ pub use core::*; pub use events::{queue, router, types as event_types}; pub use execution::*; pub use fork_agent::*; +pub use goal_mode::*; pub use image_analysis::{ImageAnalyzer, MessageEnhancer}; pub use persistence::PersistenceManager; pub use round_preempt::{ DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, NoopDialogRoundInjectionSource, NoopDialogRoundPreemptSource, RoundInjection, - RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, - SessionRoundYieldFlags, + RoundInjectionKind, RoundInjectionTarget, SessionRoundInjectionBuffer, SessionRoundYieldFlags, }; pub use session::*; pub use side_question::*; diff --git a/src/crates/core/src/agentic/round_preempt.rs b/src/crates/core/src/agentic/round_preempt.rs index 936a5d9a6e..bb7bedfc78 100644 --- a/src/crates/core/src/agentic/round_preempt.rs +++ b/src/crates/core/src/agentic/round_preempt.rs @@ -70,7 +70,7 @@ impl DialogRoundPreemptSource for SessionRoundYieldFlags { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RoundInjectionKind { UserSteering, - BackgroundSubagentResult, + BackgroundResult, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -245,7 +245,7 @@ mod steering_tests { fn current_turn_msg(content: &str) -> RoundInjection { RoundInjection { id: uuid::Uuid::new_v4().to_string(), - kind: RoundInjectionKind::BackgroundSubagentResult, + kind: RoundInjectionKind::BackgroundResult, target: RoundInjectionTarget::CurrentRunningTurn, content: content.to_string(), display_content: content.to_string(), diff --git a/src/crates/core/src/agentic/session/compression/compressor.rs b/src/crates/core/src/agentic/session/compression/compressor.rs index 1c706261fe..64e83f846b 100644 --- a/src/crates/core/src/agentic/session/compression/compressor.rs +++ b/src/crates/core/src/agentic/session/compression/compressor.rs @@ -162,6 +162,16 @@ impl ContextCompressor { let turns_count = turns.len(); let turns_tokens: Vec = turns.iter().map(|turn| turn.tokens).collect(); + // Auto-compression should not collapse the only active dialog turn mid-flight. + // Within-turn pressure is handled by tool-result budgeting and emergency truncation. + if turns_count == 1 { + debug!( + "Single-turn session skipped for auto compression: session_id={}", + session_id + ); + return Ok((0, turns)); + } + let token_limit_keep_turns = (context_window as f32 * self.config.keep_turns_ratio) as usize; let mut turn_index_to_keep = @@ -974,6 +984,24 @@ mod tests { assert_eq!(normalized, None); } + #[tokio::test] + async fn preprocess_turns_skips_single_active_turn() { + let compressor = ContextCompressor::new(Default::default()); + let messages = vec![ + Message::system("system".to_string()), + Message::user("First request".to_string()), + Message::assistant("First reply".to_string()), + ]; + + let (turn_index, turns) = compressor + .preprocess_turns("session", 8_000, messages) + .await + .expect("preprocessing succeeds"); + + assert_eq!(turn_index, 0); + assert_eq!(turns.len(), 1); + } + #[tokio::test] async fn manual_compaction_turn_collection_includes_all_non_system_turns() { let compressor = ContextCompressor::new(Default::default()); diff --git a/src/crates/core/src/agentic/session/file_read_state.rs b/src/crates/core/src/agentic/session/file_read_state.rs new file mode 100644 index 0000000000..f6bf81e262 --- /dev/null +++ b/src/crates/core/src/agentic/session/file_read_state.rs @@ -0,0 +1,122 @@ +//! Session-scoped cache of files the agent has read, used to gate Edit/Write reliability. + +use dashmap::DashMap; +use log::debug; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileReadState { + /// Raw file content without Read-tool line-number prefixes (LF-normalized view). + pub content: String, + /// File mtime in milliseconds since UNIX epoch when recorded, if known. + pub timestamp_ms: u64, + pub start_line: usize, + pub end_line: usize, + pub total_lines: usize, + /// True when this entry was populated by auto-injection and the model has + /// not explicitly read the file. Range reads from the Read tool do not set this. + pub is_partial_view: bool, +} + +impl FileReadState { + pub fn is_full_file_read(&self) -> bool { + if self.is_partial_view { + return false; + } + + if self.total_lines == 0 { + return self.start_line == 0 && self.end_line == 0; + } + + self.start_line == 1 && self.end_line >= self.total_lines + } +} + +#[cfg(test)] +mod tests { + use super::FileReadState; + + fn sample_state( + start_line: usize, + end_line: usize, + total_lines: usize, + is_partial_view: bool, + ) -> FileReadState { + FileReadState { + content: String::new(), + timestamp_ms: 0, + start_line, + end_line, + total_lines, + is_partial_view, + } + } + + #[test] + fn is_full_file_read_accepts_nonempty_whole_file() { + let state = sample_state(1, 10, 10, false); + assert!(state.is_full_file_read()); + } + + #[test] + fn is_full_file_read_rejects_partial_view() { + let state = sample_state(1, 10, 10, true); + assert!(!state.is_full_file_read()); + } + + #[test] + fn is_full_file_read_accepts_empty_file_from_read_tool() { + let state = sample_state(0, 0, 0, false); + assert!(state.is_full_file_read()); + } + + #[test] + fn is_full_file_read_rejects_empty_file_with_one_based_range() { + let state = sample_state(1, 0, 0, false); + assert!(!state.is_full_file_read()); + } +} + +#[derive(Default)] +pub struct FileReadStateStore { + session_states: Arc>>, +} + +impl FileReadStateStore { + pub fn new() -> Self { + Self::default() + } + + pub fn create_session(&self, session_id: &str) { + self.session_states + .entry(session_id.to_string()) + .or_insert_with(DashMap::new); + debug!("Created file read state cache: session_id={}", session_id); + } + + pub fn delete_session(&self, session_id: &str) { + self.session_states.remove(session_id); + debug!("Deleted file read state cache: session_id={}", session_id); + } + + pub fn clear_session(&self, session_id: &str) { + if let Some(states) = self.session_states.get(session_id) { + states.clear(); + debug!("Cleared file read state cache: session_id={}", session_id); + } + } + + pub fn set(&self, session_id: &str, logical_path: &str, state: FileReadState) { + let session_states = self + .session_states + .entry(session_id.to_string()) + .or_insert_with(DashMap::new); + session_states.insert(logical_path.to_string(), state); + } + + pub fn get(&self, session_id: &str, logical_path: &str) -> Option { + self.session_states + .get(session_id) + .and_then(|states| states.get(logical_path).map(|entry| entry.clone())) + } +} diff --git a/src/crates/core/src/agentic/session/mod.rs b/src/crates/core/src/agentic/session/mod.rs index 54578fb87f..8a39dc7d0b 100644 --- a/src/crates/core/src/agentic/session/mod.rs +++ b/src/crates/core/src/agentic/session/mod.rs @@ -3,11 +3,13 @@ //! Provides session lifecycle management and context management. pub mod compression; +pub mod file_read_state; pub mod context_store; pub mod evidence_ledger; pub mod session_manager; pub use compression::*; +pub use file_read_state::*; pub use context_store::*; pub use evidence_ledger::*; pub use session_manager::*; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 96fbbb3ae7..5a5bcfa02b 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -10,7 +10,8 @@ use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::{ EvidenceLedgerCheckpoint, EvidenceLedgerEvent, EvidenceLedgerEventStatus, - EvidenceLedgerSummary, EvidenceLedgerTargetKind, SessionContextStore, SessionEvidenceLedger, + EvidenceLedgerSummary, EvidenceLedgerTargetKind, FileReadState, FileReadStateStore, + SessionContextStore, SessionEvidenceLedger, }; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::{ @@ -90,6 +91,7 @@ pub struct SessionManager { /// Sub-components context_store: Arc, + file_read_state_store: Arc, evidence_ledger: Arc, persistence_manager: Arc, @@ -113,17 +115,28 @@ struct SessionCleanupCandidate { } impl SessionManager { - async fn resolve_model_context_window(model_id: &str) -> Option { + async fn load_ai_config_for_model_resolution() + -> Option + { + let config_service = get_global_config_service().await.ok()?; + config_service.get_config(Some("ai")).await.ok() + } + + fn is_auto_model_selector(model_id: &str) -> bool { + let trimmed = model_id.trim(); + trimmed.is_empty() || trimmed == "auto" || trimmed == "default" + } + + fn context_window_for_model_selection( + ai_config: &crate::service::config::types::AIConfig, + model_id: &str, + ) -> Option { let trimmed = model_id.trim(); - if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { + if Self::is_auto_model_selector(trimmed) { return None; } - let config_service = get_global_config_service().await.ok()?; - let ai_config: crate::service::config::types::AIConfig = - config_service.get_config(Some("ai")).await.ok()?; let resolved_model_id = ai_config.resolve_model_selection(trimmed)?; - ai_config .models .iter() @@ -132,6 +145,43 @@ impl SessionManager { .map(|tokens| tokens as usize) } + fn session_context_window_from_ai_config( + session: &Session, + ai_config: &crate::service::config::types::AIConfig, + ) -> Option { + let configured_model_id = session + .config + .model_id + .as_deref() + .map(str::trim) + .filter(|model_id| !model_id.is_empty()) + .unwrap_or("auto"); + + if !Self::is_auto_model_selector(configured_model_id) { + return Self::context_window_for_model_selection(ai_config, configured_model_id); + } + + let agent_model_id = ai_config + .agent_models + .get(&session.agent_type) + .map(String::as_str) + .map(str::trim) + .filter(|model_id| !Self::is_auto_model_selector(model_id)); + + agent_model_id + .and_then(|model_id| Self::context_window_for_model_selection(ai_config, model_id)) + .or_else(|| Self::context_window_for_model_selection(ai_config, "primary")) + } + + fn sync_session_context_window_from_ai_config( + session: &mut Session, + ai_config: &crate::service::config::types::AIConfig, + ) -> Option { + let context_window = Self::session_context_window_from_ai_config(session, ai_config)?; + session.config.max_context_tokens = context_window; + Some(context_window) + } + fn normalize_session_title_input(title: &str) -> BitFunResult { let trimmed = title.trim(); if trimmed.is_empty() { @@ -638,6 +688,7 @@ impl SessionManager { sessions: Arc::new(DashMap::new()), session_workspace_index: Arc::new(DashMap::new()), context_store, + file_read_state_store: Arc::new(FileReadStateStore::new()), evidence_ledger: Arc::new(SessionEvidenceLedger::new()), persistence_manager, config, @@ -823,6 +874,7 @@ impl SessionManager { let sessions = self.sessions.clone(); let session_workspace_index = self.session_workspace_index.clone(); let context_store = self.context_store.clone(); + let file_read_state_store = self.file_read_state_store.clone(); let evidence_ledger = self.evidence_ledger.clone(); let persistence_manager = self.persistence_manager.clone(); let manager_config = self.config.clone(); @@ -842,6 +894,7 @@ impl SessionManager { sessions, session_workspace_index, context_store, + file_read_state_store, evidence_ledger, persistence_manager, config: manager_config, @@ -980,6 +1033,7 @@ impl SessionManager { // 2. Initialize the in-memory context cache. self.context_store.create_session(&session_id); + self.file_read_state_store.create_session(&session_id); // 3. Persist to local path (handles remote workspaces correctly) // Use the local `session` directly -- no need to re-fetch from DashMap, @@ -1276,7 +1330,8 @@ impl SessionManager { session_id: &str, model_id: &str, ) -> BitFunResult<()> { - let resolved_context_window = Self::resolve_model_context_window(model_id).await; + let ai_config = Self::load_ai_config_for_model_resolution().await; + let mut resolved_context_window = None; // If the session was evicted from memory (idle > 1h), try to restore it // using the workspace path recorded when it was first created/restored. @@ -1296,8 +1351,9 @@ impl SessionManager { if let Some(mut session) = self.sessions.get_mut(session_id) { session.config.model_id = Some(model_id.to_string()); - if let Some(context_window) = resolved_context_window { - session.config.max_context_tokens = context_window; + if let Some(ai_config) = ai_config.as_ref() { + resolved_context_window = + Self::sync_session_context_window_from_ai_config(&mut session, ai_config); } session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); @@ -1378,6 +1434,7 @@ impl SessionManager { session_id ); self.context_store.delete_session(session_id); + self.file_read_state_store.delete_session(session_id); debug!( "Session deletion stage completed: session_id={}, stage=context_store_delete, duration_ms={}", session_id, @@ -1697,6 +1754,9 @@ impl SessionManager { elapsed_ms_u64(session_started_at) ); + let ai_config_for_restore = Self::load_ai_config_for_model_resolution().await; + let mut should_persist_restored_session = false; + // Lazy migration: if the persisted model_id is no longer usable // (model deleted or disabled while the session was on disk), repoint // it to "auto" before the session re-enters memory. The next request @@ -1705,14 +1765,8 @@ impl SessionManager { let trimmed = persisted_model_id.trim(); let needs_migration = if trimmed.is_empty() { false - } else if let Ok(config_service) = get_global_config_service().await { - match config_service - .get_config::(Some("ai")) - .await - { - Ok(ai_config) => !Self::is_session_model_id_usable(&ai_config, trimmed), - Err(_) => false, - } + } else if let Some(ai_config) = ai_config_for_restore.as_ref() { + !Self::is_session_model_id_usable(ai_config, trimmed) } else { false }; @@ -1724,6 +1778,7 @@ impl SessionManager { ); let previous_model_id = trimmed.to_string(); session.config.model_id = Some("auto".to_string()); + should_persist_restored_session = true; if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { coordinator @@ -1738,6 +1793,21 @@ impl SessionManager { } } + if let Some(ai_config) = ai_config_for_restore.as_ref() { + let previous_max_context_tokens = session.config.max_context_tokens; + if let Some(context_window) = + Self::sync_session_context_window_from_ai_config(&mut session, ai_config) + { + if context_window != previous_max_context_tokens { + should_persist_restored_session = true; + debug!( + "Session context window refreshed during restore: session_id={}, previous={}, resolved={}", + session_id, previous_max_context_tokens, context_window + ); + } + } + } + // Reset session state to Idle // After application restart, previous Processing state is invalid and must be reset let previous_state_was_not_idle = !matches!(session.state, SessionState::Idle); @@ -1811,6 +1881,7 @@ impl SessionManager { // If session already exists, delete old one first then create (ensure clean state) if session_already_in_memory { self.context_store.delete_session(session_id); + self.file_read_state_store.delete_session(session_id); } let context_replace_started_at = Instant::now(); @@ -1880,6 +1951,12 @@ impl SessionManager { // as completed would surface misleading unread indicators after an upgrade. // Unread completion is now written only by runtime completion/persist paths. + if should_persist_restored_session && self.should_persist_session_id(session_id) { + self.persistence_manager + .save_session(&session_storage_path, &session) + .await?; + } + // 4. Add to memory (will overwrite if already exists) self.sessions .insert(session_id.to_string(), session.clone()); @@ -2485,6 +2562,102 @@ impl SessionManager { Ok(turn_id) } + /// Append a completed local command turn that should be persisted in user-facing + /// history without entering model-visible runtime context. + pub async fn append_completed_local_command_turn( + &self, + session_id: &str, + content: String, + turn_id: Option, + timestamp_ms: Option, + user_message_metadata: Option, + ) -> BitFunResult { + let session = self + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + let workspace_path = Self::effective_workspace_path_from_config(&session.config) + .await + .ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; + + let turn_id = new_turn_id(turn_id); + let turn_index = session + .dialog_turn_ids + .iter() + .position(|existing| existing == &turn_id) + .unwrap_or(session.dialog_turn_ids.len()); + let timestamp = timestamp_ms.unwrap_or_else(|| { + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + }); + let mut turn = DialogTurnData::new_with_kind( + DialogTurnKind::LocalCommand, + turn_id.clone(), + turn_index, + session_id.to_string(), + None, + UserMessageData { + id: format!("{}-user", turn_id), + content, + timestamp, + metadata: user_message_metadata, + }, + ); + turn.timestamp = timestamp; + turn.start_time = timestamp; + turn.end_time = Some(timestamp); + turn.duration_ms = Some(0); + turn.status = TurnStatus::Completed; + + if self.config.enable_persistence && Self::should_persist_session(&session) { + self.persistence_manager + .save_dialog_turn(&workspace_path, &turn) + .await?; + } + + let session_snapshot = if let Some(mut session) = self.sessions.get_mut(session_id) { + if !session + .dialog_turn_ids + .iter() + .any(|existing| existing == &turn_id) + { + session.dialog_turn_ids.push(turn_id); + } + session.state = SessionState::Idle; + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + + if self.config.enable_persistence && Self::should_persist_session(&session) { + Some(session.clone()) + } else { + None + } + } else { + None + }; + + if let Some(session) = session_snapshot { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + + self.persist_context_snapshot_for_turn_best_effort( + session_id, + turn_index, + "local_command_turn_persisted", + ) + .await; + + Ok(turn) + } + /// Complete dialog turn pub async fn complete_dialog_turn( &self, @@ -3051,10 +3224,29 @@ impl SessionManager { /// snapshot. This is primarily used after compression rewrites the model-visible context. pub async fn replace_context_messages(&self, session_id: &str, messages: Vec) { self.context_store.replace_context(session_id, messages); + self.file_read_state_store.clear_session(session_id); self.persist_current_turn_context_snapshot_best_effort(session_id, "context_replaced") .await; } + pub fn set_file_read_state( + &self, + session_id: &str, + logical_path: &str, + state: FileReadState, + ) { + self.file_read_state_store + .set(session_id, logical_path, state); + } + + pub fn get_file_read_state( + &self, + session_id: &str, + logical_path: &str, + ) -> Option { + self.file_read_state_store.get(session_id, logical_path) + } + /// Get dialog turn count pub fn get_turn_count(&self, session_id: &str) -> usize { self.sessions @@ -3290,6 +3482,7 @@ impl SessionManager { let persistence = self.persistence_manager.clone(); let enable_persistence = self.config.enable_persistence; let context_store = self.context_store.clone(); + let file_read_state_store = self.file_read_state_store.clone(); tokio::spawn(async move { let mut ticker = time::interval(Duration::from_secs(60)); @@ -3346,6 +3539,7 @@ impl SessionManager { .is_some() { context_store.delete_session(&candidate.session_id); + file_read_state_store.delete_session(&candidate.session_id); } } } @@ -3401,10 +3595,14 @@ mod tests { use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::SessionContextStore; use crate::infrastructure::PathManager; + use crate::service::config::types::{ + AIConfig as ServiceAIConfig, AIModelConfig as ServiceAIModelConfig, + }; use crate::service::remote_ssh::workspace_state::local_workspace_roots_equal; use crate::service::session::{ - DialogTurnData, ModelRoundData, SessionKind, SessionMetadata, SessionRelationship, - SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, UserMessageData, + DialogTurnData, DialogTurnKind, ModelRoundData, SessionKind, SessionMetadata, + SessionRelationship, SessionRelationshipKind, ToolCallData, ToolItemData, ToolResultData, + TurnStatus, UserMessageData, }; use dashmap::try_result::TryResult; use serde_json::json; @@ -3473,6 +3671,79 @@ mod tests { ) } + fn test_model(id: &str, context_window: u32) -> ServiceAIModelConfig { + ServiceAIModelConfig { + id: id.to_string(), + name: id.to_string(), + model_name: id.to_string(), + enabled: true, + context_window: Some(context_window), + ..Default::default() + } + } + + #[test] + fn sync_session_context_window_refreshes_stale_explicit_model_window() { + let mut ai_config = ServiceAIConfig::default(); + ai_config.models = vec![test_model("deepseek-v4-pro", 1_000_000)]; + + let mut session = Session::new_with_id( + "session-804".to_string(), + "DeepSeek session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("deepseek-v4-pro".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + } + + #[test] + fn sync_session_context_window_resolves_auto_through_agent_model_then_primary() { + let mut ai_config = ServiceAIConfig::default(); + ai_config.models = vec![ + test_model("primary-model", 512_000), + test_model("agent-model", 1_000_000), + ]; + ai_config.default_models.primary = Some("primary-model".to_string()); + ai_config + .agent_models + .insert("agentic".to_string(), "agent-model".to_string()); + + let mut session = Session::new_with_id( + "session-auto".to_string(), + "Auto session".to_string(), + "agentic".to_string(), + SessionConfig { + model_id: Some("auto".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(1_000_000)); + assert_eq!(session.config.max_context_tokens, 1_000_000); + + ai_config.agent_models.clear(); + session.config.max_context_tokens = 256_000; + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(512_000)); + assert_eq!(session.config.max_context_tokens, 512_000); + } + #[tokio::test] async fn auto_save_interval_waits_before_first_tick() { let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); @@ -3627,6 +3898,67 @@ mod tests { assert!(matches!(session.state, SessionState::Idle)); } + #[tokio::test] + async fn append_completed_local_command_turn_persists_without_model_context() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session = manager + .create_session( + "Usage session".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + let turn = manager + .append_completed_local_command_turn( + &session.session_id, + "# Session Usage Report".to_string(), + Some("local-usage-1".to_string()), + Some(42), + Some(json!({ + "localCommandKind": "usage_report", + "modelVisible": false, + })), + ) + .await + .expect("local command turn should persist"); + + assert_eq!(turn.kind, DialogTurnKind::LocalCommand); + assert_eq!(turn.status, TurnStatus::Completed); + + let active = manager + .get_session(&session.session_id) + .expect("session should remain active"); + assert_eq!(active.dialog_turn_ids, vec!["local-usage-1".to_string()]); + assert!(manager + .context_store + .get_context_messages(&session.session_id) + .is_empty()); + + let persisted_turns = persistence_manager + .load_session_turns(workspace.path(), &session.session_id) + .await + .expect("turns should load"); + assert_eq!(persisted_turns.len(), 1); + assert_eq!(persisted_turns[0].kind, DialogTurnKind::LocalCommand); + assert!(SessionManager::build_messages_from_turns(&persisted_turns).is_empty()); + + let metadata = persistence_manager + .load_session_metadata(workspace.path(), &session.session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 1); + } + #[tokio::test] async fn restore_session_resets_processing_state_without_marking_unread_completion() { let workspace = TestWorkspace::new(); diff --git a/src/crates/core/src/agentic/system.rs b/src/crates/core/src/agentic/system.rs index 7fedf901fb..1d5ab16773 100644 --- a/src/crates/core/src/agentic/system.rs +++ b/src/crates/core/src/agentic/system.rs @@ -13,12 +13,14 @@ use crate::agentic::session; use crate::agentic::tools; use crate::infrastructure::ai::AIClientFactory; use crate::infrastructure::try_get_path_manager_arc; +use crate::service::token_usage::{TokenUsageService, TokenUsageSubscriber}; /// Agentic runtime state shared by host adapters. #[derive(Clone)] pub struct AgenticSystem { pub coordinator: Arc, pub event_queue: Arc, + pub token_usage_service: Arc, } /// Initialize the agentic runtime and register the global coordinator. @@ -32,6 +34,9 @@ pub async fn init_agentic_system() -> Result { let path_manager = try_get_path_manager_arc()?; let persistence_manager = Arc::new(persistence::PersistenceManager::new(path_manager.clone())?); + let token_usage_service = Arc::new(TokenUsageService::new(path_manager.clone()).await?); + let token_usage_subscriber = Arc::new(TokenUsageSubscriber::new(token_usage_service.clone())); + event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); let context_store = Arc::new(session::SessionContextStore::new()); let context_compressor = Arc::new(session::ContextCompressor::new(Default::default())); @@ -70,14 +75,34 @@ pub async fn init_agentic_system() -> Result { execution_engine, tool_pipeline, event_queue.clone(), - event_router, + event_router.clone(), )); coordination::ConversationCoordinator::set_global(coordinator.clone()); + + let mut internal_event_rx = event_queue.subscribe(); + let internal_event_router = event_router.clone(); + tokio::spawn(async move { + loop { + match internal_event_rx.recv().await { + Ok(envelope) => { + if let Err(error) = internal_event_router.route(envelope).await { + log::warn!("Internal agentic event routing failed: {}", error); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + log::warn!("Internal agentic event router lagged by {} events", skipped); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }); + info!("Agentic system initialization complete"); Ok(AgenticSystem { coordinator, event_queue, + token_usage_service, }) } diff --git a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs new file mode 100644 index 0000000000..d169b61a31 --- /dev/null +++ b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs @@ -0,0 +1,577 @@ +//! Runtime helpers for session-scoped file read state used by Read/Edit/Write tools. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::session::FileReadState; +use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; +use crate::util::errors::BitFunResult; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use tool_runtime::fs::read_file::ReadFileResult; +use tool_runtime::util::read_line_prefix::read_tool_output_to_file_content; +use tool_runtime::util::string::normalize_string; + +pub const FILE_UNEXPECTEDLY_MODIFIED_ERROR: &str = + "File has been unexpectedly modified. Read it again before attempting to write it."; + +pub fn validate_write_has_prior_read( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + let Some(read_state) = coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path) + else { + return Some(format!( + "Use Read to load the current contents of {} before calling Write on it.", + resolved.logical_path + )); + }; + + if read_state.is_partial_view { + return Some(format!( + "Use Read to load the full contents of {} before calling Write on it.", + resolved.logical_path + )); + } + + None +} + +pub fn read_state_tracking_enabled(context: &ToolUseContext) -> bool { + context.session_id.is_some() && get_global_coordinator().is_some() +} + +pub fn record_file_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, + read_result: &ReadFileResult, + timestamp_ms: u64, +) { + let Some(session_id) = context.session_id.as_deref() else { + return; + }; + let Some(coordinator) = get_global_coordinator() else { + return; + }; + + // `is_partial_view` is reserved for auto-injected content the model has not + // explicitly read (see Claude Code's FileState.isPartialView). Normal Read + // tool calls with offset/limit still count as a valid read for Edit/Write. + let state = FileReadState { + content: read_tool_output_to_file_content(&read_result.content), + timestamp_ms, + start_line: read_result.start_line, + end_line: read_result.end_line, + total_lines: read_result.total_lines, + is_partial_view: false, + }; + + coordinator.get_session_manager().set_file_read_state( + session_id, + &resolved.logical_path, + state, + ); +} + +pub fn get_stored_file_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path) +} + +pub fn content_unchanged_since_full_read( + read_state: &FileReadState, + current_content: &str, +) -> bool { + read_state.is_full_file_read() + && normalize_string(current_content) == normalize_string(&read_state.content) +} + +pub fn assert_file_not_unexpectedly_modified( + read_state: Option<&FileReadState>, + current_content: &str, + current_mtime_ms: Option, +) -> Result<(), String> { + let Some(read_state) = read_state else { + return Err(FILE_UNEXPECTEDLY_MODIFIED_ERROR.to_string()); + }; + + if let Some(current_mtime_ms) = current_mtime_ms { + if current_mtime_ms > read_state.timestamp_ms { + if content_unchanged_since_full_read(read_state, current_content) { + return Ok(()); + } + return Err(FILE_UNEXPECTEDLY_MODIFIED_ERROR.to_string()); + } + return Ok(()); + } + + if read_state.is_full_file_read() + && normalize_string(current_content) != normalize_string(&read_state.content) + { + return Err(FILE_UNEXPECTEDLY_MODIFIED_ERROR.to_string()); + } + + Ok(()) +} + +pub async fn validate_edit_against_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + let read_state = coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path)?; + + let current_content = match read_current_file_content(context, resolved).await { + Ok(content) => content, + Err(error) => { + return Some(format!( + "File {} could not be re-read before editing ({}). Read it again when the workspace is available.", + resolved.logical_path, error + )); + } + }; + let current_mtime_ms = file_modification_time_ms(context, resolved).await; + + validate_edit_content_freshness_against_read_state( + &resolved.logical_path, + &read_state, + ¤t_content, + current_mtime_ms, + ) +} + +pub async fn validate_write_against_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let read_state = get_stored_file_read_state(context, resolved)?; + + if let Some(current_mtime_ms) = file_modification_time_ms(context, resolved).await { + if current_mtime_ms > read_state.timestamp_ms { + return Some(format!( + "The file {} changed after it was last read. Use Read again, then retry Write.", + resolved.logical_path + )); + } + return None; + } + + let current_content = read_current_file_content(context, resolved).await.ok()?; + if read_state.is_full_file_read() + && normalize_string(¤t_content) != normalize_string(&read_state.content) + { + return Some(format!( + "The file {} no longer matches the last Read result. Use Read again, then retry Write.", + resolved.logical_path + )); + } + + None +} + +pub async fn validate_existing_file_read_before_write( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + if let Some(message) = validate_write_has_prior_read(context, resolved) { + return Some(message); + } + + validate_write_against_read_state(context, resolved).await +} + +fn validate_edit_content_freshness_against_read_state( + logical_path: &str, + read_state: &FileReadState, + current_content: &str, + current_mtime_ms: Option, +) -> Option { + if let Some(current_mtime_ms) = current_mtime_ms { + if current_mtime_ms > read_state.timestamp_ms { + if read_state.is_full_file_read() + && normalize_string(current_content) == normalize_string(&read_state.content) + { + return None; + } + + return Some(format!( + "The file {} changed after it was last read. Use Read again, then retry Edit.", + logical_path + )); + } + } else if read_state.is_full_file_read() + && normalize_string(current_content) != normalize_string(&read_state.content) + { + return Some(format!( + "The file {} no longer matches the last Read result. Use Read again, then retry Edit.", + logical_path + )); + } + + None +} + +pub fn validate_edit_has_prior_read( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + let Some(read_state) = coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path) + else { + return Some(format!( + "Use Read to load the current contents of {} before calling Edit on it.", + resolved.logical_path + )); + }; + + if read_state.is_partial_view { + return Some(format!( + "Use Read to load the full contents of {} before calling Edit on it.", + resolved.logical_path + )); + } + + None +} + +pub fn update_file_read_state_after_mutation( + context: &ToolUseContext, + resolved: &ToolPathResolution, + content: &str, + timestamp_ms: u64, +) { + let Some(session_id) = context.session_id.as_deref() else { + return; + }; + let Some(coordinator) = get_global_coordinator() else { + return; + }; + + let line_count = content.lines().count(); + let (start_line, end_line) = if line_count == 0 { + (0, 0) + } else { + (1, line_count) + }; + let state = FileReadState { + content: content.to_string(), + timestamp_ms, + start_line, + end_line, + total_lines: line_count, + is_partial_view: false, + }; + + coordinator.get_session_manager().set_file_read_state( + session_id, + &resolved.logical_path, + state, + ); +} + +pub async fn read_current_file_content( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> BitFunResult { + if resolved.uses_remote_workspace_backend() { + let ws_fs = context.ws_fs().ok_or_else(|| { + crate::util::errors::BitFunError::tool( + "Remote workspace file system is unavailable".to_string(), + ) + })?; + ws_fs + .read_file_text(&resolved.resolved_path) + .await + .map_err(|error| { + crate::util::errors::BitFunError::tool(format!("Failed to read file: {}", error)) + }) + } else { + std::fs::read_to_string(&resolved.resolved_path).map_err(|error| { + crate::util::errors::BitFunError::tool(format!( + "Failed to read file {}: {}", + resolved.logical_path, error + )) + }) + } +} + +async fn file_modification_time_ms( + _context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + if resolved.uses_remote_workspace_backend() { + return None; + } + + let metadata = std::fs::metadata(&resolved.resolved_path).ok()?; + let modified = metadata.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) +} + +pub async fn file_mutation_timestamp_ms( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> u64 { + if let Some(timestamp_ms) = file_modification_time_ms(context, resolved).await { + return timestamp_ms; + } + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +pub fn local_file_modification_time_ms(path: &Path) -> u64 { + std::fs::metadata(path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::session::FileReadState; + use crate::agentic::tools::framework::{ToolPathBackend, ToolUseContext}; + use crate::agentic::WorkspaceBinding; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_context(session_id: Option<&str>, root: PathBuf) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: session_id.map(str::to_string), + dialog_turn_id: Some("turn-1".to_string()), + workspace: Some(WorkspaceBinding::new(None, root)), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: Default::default(), + workspace_services: None, + } + } + + #[test] + fn validate_edit_has_prior_read_skips_without_session_id() { + let context = test_context(None, PathBuf::from("/tmp")); + + assert!(validate_edit_has_prior_read( + &context, + &ToolPathResolution { + logical_path: "src/main.rs".to_string(), + resolved_path: "src/main.rs".to_string(), + requested_path: "src/main.rs".to_string(), + backend: ToolPathBackend::Local, + runtime_root: None, + runtime_scope: None, + } + ) + .is_none()); + } + + #[test] + fn validate_edit_has_prior_read_skips_without_coordinator() { + let context = test_context(Some("session-1"), PathBuf::from("/tmp")); + + assert!(validate_edit_has_prior_read( + &context, + &ToolPathResolution { + logical_path: "src/main.rs".to_string(), + resolved_path: "src/main.rs".to_string(), + requested_path: "src/main.rs".to_string(), + backend: ToolPathBackend::Local, + runtime_root: None, + runtime_scope: None, + } + ) + .is_none()); + } + + #[test] + fn validate_edit_has_prior_read_rejects_auto_injected_partial_view() { + let context = test_context(Some("session-1"), PathBuf::from("/tmp")); + let resolution = ToolPathResolution { + logical_path: "src/main.rs".to_string(), + resolved_path: "src/main.rs".to_string(), + requested_path: "src/main.rs".to_string(), + backend: ToolPathBackend::Local, + runtime_root: None, + runtime_scope: None, + }; + + // Without a coordinator this stays permissive in unit tests. + assert!(validate_edit_has_prior_read(&context, &resolution).is_none()); + } + + #[test] + fn validate_content_freshness_allows_partial_read_range_without_full_file() { + let state = FileReadState { + content: "middle\n".to_string(), + timestamp_ms: 100, + start_line: 50, + end_line: 100, + total_lines: 556, + is_partial_view: false, + }; + + assert!(validate_edit_content_freshness_against_read_state( + "src/state.js", + &state, + "different full file\n", + Some(200), + ) + .is_some()); + } + + #[test] + fn assert_file_not_unexpectedly_modified_allows_matching_full_read_after_newer_mtime() { + let state = FileReadState { + content: "alpha\n".to_string(), + timestamp_ms: 100, + start_line: 1, + end_line: 1, + total_lines: 1, + is_partial_view: false, + }; + + assert!(assert_file_not_unexpectedly_modified(Some(&state), "alpha\n", Some(200)).is_ok()); + } + + #[test] + fn assert_file_not_unexpectedly_modified_rejects_changed_full_read_after_newer_mtime() { + let state = FileReadState { + content: "alpha\n".to_string(), + timestamp_ms: 100, + start_line: 1, + end_line: 1, + total_lines: 1, + is_partial_view: false, + }; + + assert!(assert_file_not_unexpectedly_modified(Some(&state), "beta\n", Some(200)).is_err()); + } + + #[test] + fn assert_file_not_unexpectedly_modified_rejects_partial_read_after_newer_mtime() { + let state = FileReadState { + content: "middle\n".to_string(), + timestamp_ms: 100, + start_line: 50, + end_line: 100, + total_lines: 556, + is_partial_view: false, + }; + + assert!( + assert_file_not_unexpectedly_modified(Some(&state), "full file\n", Some(200)).is_err() + ); + } + + fn read_state(content: &str, timestamp_ms: u64) -> FileReadState { + FileReadState { + content: content.to_string(), + timestamp_ms, + start_line: 1, + end_line: 1, + total_lines: 1, + is_partial_view: false, + } + } + + #[test] + fn validate_content_freshness_allows_matching_remote_content_without_mtime() { + let state = read_state("alpha\n", 100); + + assert!(validate_edit_content_freshness_against_read_state( + "src/main.rs", + &state, + "alpha\n", + None, + ) + .is_none()); + } + + #[test] + fn validate_content_freshness_rejects_changed_remote_content_without_mtime() { + let state = read_state("alpha\n", 100); + + assert_eq!( + validate_edit_content_freshness_against_read_state( + "src/main.rs", + &state, + "beta\n", + None, + ) + .as_deref(), + Some( + "The file src/main.rs no longer matches the last Read result. Use Read again, then retry Edit." + ) + ); + } + + #[test] + fn validate_content_freshness_allows_newer_mtime_when_full_read_content_matches() { + let state = read_state("alpha\n", 100); + + assert!(validate_edit_content_freshness_against_read_state( + "src/main.rs", + &state, + "alpha\n", + Some(200), + ) + .is_none()); + } + + #[test] + fn validate_content_freshness_rejects_newer_mtime_when_content_differs() { + let state = read_state("alpha\n", 100); + + assert!(validate_edit_content_freshness_against_read_state( + "src/main.rs", + &state, + "beta\n", + Some(200), + ) + .is_some()); + } + + #[test] + fn validate_content_freshness_ignores_older_mtime_even_when_content_differs() { + let state = read_state("alpha\n", 200); + + assert!(validate_edit_content_freshness_against_read_state( + "src/main.rs", + &state, + "beta\n", + Some(100), + ) + .is_none()); + } +} diff --git a/src/crates/core/src/agentic/tools/file_tool_guidance.rs b/src/crates/core/src/agentic/tools/file_tool_guidance.rs new file mode 100644 index 0000000000..1245dc2afa --- /dev/null +++ b/src/crates/core/src/agentic/tools/file_tool_guidance.rs @@ -0,0 +1,11 @@ +//! Shared guidance markers for file Write/Edit tool guardrails shown to users as hints. + +pub const FILE_TOOL_GUIDANCE_PREFIX: &str = "[guidance] "; + +pub fn file_tool_guidance_message(message: impl Into) -> String { + format!("{FILE_TOOL_GUIDANCE_PREFIX}{}", message.into()) +} + +pub fn is_file_tool_guidance_message(message: &str) -> bool { + message.starts_with(FILE_TOOL_GUIDANCE_PREFIX) +} diff --git a/src/crates/core/src/agentic/tools/framework.rs b/src/crates/core/src/agentic/tools/framework.rs index 22a5d9a9be..d4611f1cda 100644 --- a/src/crates/core/src/agentic/tools/framework.rs +++ b/src/crates/core/src/agentic/tools/framework.rs @@ -1,21 +1,7 @@ //! Tool framework - Tool interface definition and execution context use crate::agentic::WorkspaceBinding; -use crate::agentic::coordination::get_global_coordinator; -use crate::agentic::session::EvidenceLedgerCheckpoint; -use crate::agentic::tools::post_call_hooks; -use crate::agentic::tools::restrictions::{ - ToolPathOperation, ToolRuntimeRestrictions, is_local_path_within_root, - is_remote_posix_path_within_root, -}; -use crate::agentic::tools::workspace_paths::{ - build_bitfun_runtime_uri, is_bitfun_runtime_uri, normalize_runtime_relative_path, - parse_bitfun_runtime_uri, -}; +use crate::agentic::tools::restrictions::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; -use crate::infrastructure::get_path_manager_arc; -use crate::service::git::{GitDiffParams, GitService}; -use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; -use crate::service::{WorkspaceRuntimeContext, get_workspace_runtime_service_arc}; use crate::util::errors::BitFunResult; use async_trait::async_trait; pub use bitfun_agent_tools::{ @@ -23,11 +9,9 @@ pub use bitfun_agent_tools::{ ToolExposure, ToolPathBackend, ToolPathResolution, ToolRenderOptions, ToolResult, ToolWorkspaceKind, ValidationResult, }; -use log::warn; use serde_json::Value; -use sha2::{Digest, Sha256}; use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::path::Path; use tokio_util::sync::CancellationToken; /// Tool use context @@ -88,173 +72,6 @@ impl ToolUseContext { } } - pub fn ws_fs(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceFileSystem> { - self.workspace_services.as_ref().map(|s| s.fs.as_ref()) - } - - pub fn ws_shell(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceShell> { - self.workspace_services.as_ref().map(|s| s.shell.as_ref()) - } - - pub async fn record_light_checkpoint( - &self, - tool_name: &str, - target: &str, - touched_files: Vec, - ) { - let Some(session_id) = self.session_id.as_deref() else { - return; - }; - let Some(turn_id) = self.dialog_turn_id.as_deref() else { - return; - }; - let Some(coordinator) = get_global_coordinator() else { - return; - }; - - let checkpoint = self.build_light_checkpoint(touched_files).await; - coordinator - .get_session_manager() - .record_checkpoint_created(session_id, turn_id, tool_name, target, checkpoint); - } - - async fn build_light_checkpoint(&self, touched_files: Vec) -> EvidenceLedgerCheckpoint { - let mut checkpoint = EvidenceLedgerCheckpoint { - current_branch: None, - dirty_state_summary: "workspace_unavailable".to_string(), - touched_files, - diff_hash: None, - }; - - if self.is_remote() { - checkpoint.dirty_state_summary = - "remote_workspace_git_metadata_unavailable".to_string(); - return checkpoint; - } - - let Some(workspace_root) = self.workspace_root() else { - return checkpoint; - }; - - match GitService::get_status(workspace_root).await { - Ok(status) => { - checkpoint.current_branch = Some(status.current_branch); - checkpoint.dirty_state_summary = format!( - "staged={}, unstaged={}, untracked={}", - status.staged.len(), - status.unstaged.len(), - status.untracked.len() - ); - } - Err(error) => { - checkpoint.dirty_state_summary = format!("git_status_unavailable: {}", error); - } - } - - checkpoint.diff_hash = self - .checkpoint_diff_hash(workspace_root, &checkpoint.touched_files) - .await; - checkpoint - } - - async fn checkpoint_diff_hash( - &self, - workspace_root: &Path, - touched_files: &[String], - ) -> Option { - let files = touched_files - .iter() - .filter_map(|file| git_relative_path(workspace_root, file)) - .collect::>(); - - if files.is_empty() { - return None; - } - - let mut diff = String::new(); - for staged in [false, true] { - let params = GitDiffParams { - files: Some(files.clone()), - staged: Some(staged), - ..Default::default() - }; - match GitService::get_diff(workspace_root, ¶ms).await { - Ok(part) => diff.push_str(&part), - Err(error) => { - warn!( - "Failed to collect checkpoint diff hash: staged={}, error={}", - staged, error - ); - return None; - } - } - } - - if diff.is_empty() { - return None; - } - - Some(hex::encode(Sha256::digest(diff.as_bytes()))) - } - - pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { - self.runtime_tool_restrictions - .ensure_tool_allowed(tool_name) - .map_err(Into::into) - } - - pub fn enforce_path_operation( - &self, - operation: ToolPathOperation, - resolution: &ToolPathResolution, - ) -> BitFunResult<()> { - let allowed_roots = self - .runtime_tool_restrictions - .path_policy - .roots_for(operation); - if allowed_roots.is_empty() { - return Ok(()); - } - - let mut resolved_roots = Vec::with_capacity(allowed_roots.len()); - for root in allowed_roots { - resolved_roots.push(self.resolve_tool_path(root)?); - } - - let mut is_allowed = false; - for root in &resolved_roots { - if root.backend != resolution.backend { - continue; - } - - let matches_root = match resolution.backend { - ToolPathBackend::Local => is_local_path_within_root( - Path::new(&resolution.resolved_path), - Path::new(&root.resolved_path), - )?, - ToolPathBackend::RemoteWorkspace => { - is_remote_posix_path_within_root(&resolution.resolved_path, &root.resolved_path) - } - }; - - if matches_root { - is_allowed = true; - break; - } - } - - if is_allowed { - return Ok(()); - } - - Err(crate::util::errors::BitFunError::validation(format!( - "Path '{}' is not allowed for {}. Allowed roots: {}", - resolution.logical_path, - operation.verb(), - allowed_roots.join(", ") - ))) - } - /// Whether the session primary model accepts image inputs (from tool-definition / pipeline context). /// Defaults to **true** when unset (e.g. API listings without model metadata). pub fn primary_model_supports_image_understanding(&self) -> bool { @@ -263,200 +80,6 @@ impl ToolUseContext { .and_then(|v| v.as_bool()) .unwrap_or(true) } - - /// Resolve a user or model-supplied path for file/shell tools. Uses POSIX semantics when the - /// workspace is remote SSH so Windows-hosted clients still resolve `/home/...` correctly. - pub fn resolve_workspace_tool_path(&self, path: &str) -> BitFunResult { - let workspace_root_owned = self - .workspace - .as_ref() - .map(|w| w.root_path_string()) - .ok_or_else(|| { - crate::util::errors::BitFunError::tool(format!( - "A workspace path is required to resolve tool path: {}", - path - )) - })?; - let resolved_path = crate::agentic::tools::workspace_paths::resolve_workspace_tool_path( - path, - Some(workspace_root_owned.as_str()), - self.is_remote(), - )?; - - // Remote SSH workspaces stay contained to the opened project tree. Local desktop - // sessions may use any host path the OS user can access (Bash already has the same - // reach); optional `path_policy` roots still apply via `enforce_path_operation`. - if self.is_remote() - && !is_remote_posix_path_within_root(&resolved_path, &workspace_root_owned) - { - return Err(crate::util::errors::BitFunError::tool(format!( - "Path '{}' resolves outside current workspace '{}': {}", - path, workspace_root_owned, resolved_path - ))); - } - - Ok(resolved_path) - } - - pub fn current_workspace_runtime_root(&self) -> BitFunResult { - let workspace = self.workspace.as_ref().ok_or_else(|| { - crate::util::errors::BitFunError::tool( - "A workspace is required to resolve runtime artifacts".to_string(), - ) - })?; - - if workspace.is_remote() { - let identity = &workspace.session_identity; - Ok(remote_workspace_runtime_root( - &identity.hostname, - identity.logical_workspace_path(), - )) - } else { - Ok(get_path_manager_arc().project_runtime_root(workspace.root_path())) - } - } - - pub fn current_workspace_scope(&self) -> Option { - self.workspace - .as_ref() - .and_then(|workspace| workspace.workspace_id.clone()) - } - - pub async fn ensure_current_workspace_runtime(&self) -> BitFunResult { - let workspace = self.workspace.as_ref().ok_or_else(|| { - crate::util::errors::BitFunError::tool( - "A workspace is required to ensure runtime artifacts".to_string(), - ) - })?; - - let runtime_service = get_workspace_runtime_service_arc(); - Ok(runtime_service - .ensure_runtime_for_workspace_binding(workspace) - .await? - .context) - } - - pub fn should_emit_runtime_uri(&self) -> bool { - self.is_remote() - } - - pub fn build_runtime_uri(&self, relative_path: &str) -> BitFunResult { - let scope = self - .current_workspace_scope() - .unwrap_or_else(|| "current".to_string()); - build_bitfun_runtime_uri(&scope, &normalize_runtime_relative_path(relative_path)?) - } - - pub fn build_runtime_artifact_reference(&self, relative_path: &str) -> BitFunResult { - let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; - if self.should_emit_runtime_uri() { - return self.build_runtime_uri(&normalized_relative_path); - } - - let mut resolved_path = self.current_workspace_runtime_root()?; - for segment in normalized_relative_path.split('/') { - resolved_path.push(segment); - } - - Ok(resolved_path.to_string_lossy().to_string()) - } - - pub fn build_session_runtime_artifact_reference( - &self, - session_id: &str, - relative_path: &str, - ) -> BitFunResult { - let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; - self.build_runtime_artifact_reference(&format!( - "sessions/{}/{}", - session_id, normalized_relative_path - )) - } - - pub fn current_workspace_session_dir(&self, session_id: &str) -> BitFunResult { - Ok(self - .current_workspace_runtime_root()? - .join("sessions") - .join(session_id)) - } - - pub fn current_workspace_session_tool_results_dir( - &self, - session_id: &str, - ) -> BitFunResult { - Ok(self - .current_workspace_session_dir(session_id)? - .join("tool-results")) - } - - pub fn current_workspace_session_tool_result_path( - &self, - session_id: &str, - file_name: &str, - ) -> BitFunResult { - Ok(self - .current_workspace_session_tool_results_dir(session_id)? - .join(file_name)) - } - - pub fn resolve_tool_path(&self, path: &str) -> BitFunResult { - if is_bitfun_runtime_uri(path) { - let parsed = parse_bitfun_runtime_uri(path)?; - let workspace_scope = self.current_workspace_scope(); - let scope_matches = parsed.workspace_scope == "current" - || workspace_scope.as_deref() == Some(parsed.workspace_scope.as_str()); - if !scope_matches { - return Err(crate::util::errors::BitFunError::tool(format!( - "Runtime URI scope '{}' does not match the current workspace", - parsed.workspace_scope - ))); - } - - let runtime_root = self.current_workspace_runtime_root()?; - let mut resolved_path = runtime_root.clone(); - for segment in parsed.relative_path.split('/') { - resolved_path.push(segment); - } - - let effective_scope = workspace_scope.unwrap_or_else(|| parsed.workspace_scope.clone()); - let logical_path = build_bitfun_runtime_uri(&effective_scope, &parsed.relative_path)?; - - return Ok(ToolPathResolution { - requested_path: path.to_string(), - logical_path, - resolved_path: resolved_path.to_string_lossy().to_string(), - backend: ToolPathBackend::Local, - runtime_scope: Some(effective_scope), - runtime_root: Some(runtime_root), - }); - } - - let resolved_path = self.resolve_workspace_tool_path(path)?; - Ok(ToolPathResolution { - requested_path: path.to_string(), - logical_path: resolved_path.clone(), - resolved_path, - backend: if self.is_remote() { - ToolPathBackend::RemoteWorkspace - } else { - ToolPathBackend::Local - }, - runtime_scope: None, - runtime_root: None, - }) - } - - /// Whether `path` is absolute for the active workspace (POSIX `/` for remote SSH). - pub fn workspace_path_is_effectively_absolute(&self, path: &str) -> bool { - if is_bitfun_runtime_uri(path) { - return true; - } - if self.is_remote() { - crate::agentic::tools::workspace_paths::posix_style_path_is_absolute(path) - } else { - Path::new(path).is_absolute() - } - } } impl PortableToolContextProvider for ToolUseContext { @@ -466,7 +89,7 @@ impl PortableToolContextProvider for ToolUseContext { } #[cfg(test)] -mod path_resolution_tests { +mod context_facts_tests { use super::ToolUseContext; use crate::agentic::WorkspaceBinding; use crate::agentic::tools::{ @@ -492,22 +115,6 @@ mod path_resolution_tests { } } - fn context_without_workspace() -> ToolUseContext { - ToolUseContext { - tool_call_id: None, - agent_type: None, - session_id: None, - dialog_turn_id: None, - workspace: None, - unlocked_collapsed_tools: Vec::new(), - custom_data: HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, - } - } - #[test] fn tool_context_facts_preserve_portable_fields_without_runtime_handles() { let context = ToolUseContext { @@ -654,89 +261,6 @@ mod path_resolution_tests { assert_eq!(facts.workspace_kind, Some(ToolWorkspaceKind::Local)); assert_eq!(facts.workspace_root.as_deref(), Some("/repo/project")); } - - #[test] - fn workspace_path_resolution_allows_absolute_paths_outside_local_workspace() { - let context = local_context("/repo/project"); - - let resolved = context - .resolve_workspace_tool_path("/tmp/pr_body.md") - .expect("local sessions may resolve paths outside the workspace root"); - - assert_eq!(PathBuf::from(resolved), PathBuf::from("/tmp/pr_body.md")); - } - - #[test] - fn workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace() { - let session_identity = - workspace_session_identity("/home/wsp/projects/test", Some("conn-1"), Some("ssh.dev")) - .expect("remote identity"); - let context = ToolUseContext { - tool_call_id: None, - agent_type: None, - session_id: None, - dialog_turn_id: None, - workspace: Some(WorkspaceBinding::new_remote( - None, - PathBuf::from("/home/wsp/projects/test"), - "conn-1".to_string(), - "Dev SSH".to_string(), - session_identity, - )), - unlocked_collapsed_tools: Vec::new(), - custom_data: HashMap::new(), - computer_use_host: None, - cancellation_token: None, - runtime_tool_restrictions: ToolRuntimeRestrictions::default(), - workspace_services: None, - }; - - let err = context - .resolve_workspace_tool_path("/tmp/pr_body.md") - .expect_err("remote sessions must stay within the workspace root"); - - assert!(err.to_string().contains("outside current workspace")); - } - - #[test] - fn workspace_path_resolution_rejects_root_without_workspace() { - let context = context_without_workspace(); - - let err = context - .resolve_workspace_tool_path("/") - .expect_err("workspace tools must not scan the host root without a workspace"); - - assert!(err.to_string().contains("workspace path is required")); - } - - #[test] - fn workspace_path_resolution_allows_paths_inside_local_workspace() { - let context = local_context("/repo/project"); - - let resolved = context - .resolve_workspace_tool_path("/repo/project/src/main.rs") - .expect("absolute paths inside the workspace remain valid"); - - assert_eq!( - PathBuf::from(resolved), - PathBuf::from("/repo/project/src/main.rs") - ); - } -} - -fn git_relative_path(workspace_root: &Path, path: &str) -> Option { - if is_bitfun_runtime_uri(path) { - return None; - } - - let path = Path::new(path); - let relative = if path.is_absolute() { - path.strip_prefix(workspace_root).ok()? - } else { - path - }; - - Some(relative.to_string_lossy().replace('\\', "/")) } /// Tool trait @@ -896,23 +420,13 @@ pub trait Tool: Send + Sync { /// execution to [`call_impl`], so most tools should override `call_impl` /// instead of overriding this method directly. async fn call(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { - let result = if let Some(cancellation_token) = context.cancellation_token.as_ref() { - tokio::select! { - result = self.call_impl(input, context) => { - result - } - - _ = cancellation_token.cancelled() => { - Err(crate::util::errors::BitFunError::Cancelled("Tool execution cancelled".to_string())) - } - } - } else { - self.call_impl(input, context).await - }; - if result.is_ok() { - post_call_hooks::record_successful_tool_call(self.name(), input, context); - } - result + crate::agentic::tools::tool_context_runtime::call_with_tool_runtime_hooks( + self.name(), + input, + context, + self.call_impl(input, context), + ) + .await } } diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index 5b9dcfc758..6a6b840584 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -1,3 +1,4 @@ +use crate::agentic::coordination::scheduler::get_global_scheduler; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -19,12 +20,15 @@ use std::time::{Duration, Instant}; use terminal_core::session::SessionSource; use terminal_core::shell::{ShellDetector, ShellType}; use terminal_core::{ - CommandCompletionReason, CommandStreamEvent, ExecuteCommandRequest, SendCommandRequest, - SignalRequest, TerminalApi, TerminalBindingOptions, TerminalSessionBinding, + CommandCompletionReason, CommandStreamEvent, ExecuteCommandRequest, SignalRequest, TerminalApi, + TerminalBindingOptions, TerminalSessionBinding, }; use tokio::io::AsyncWriteExt; use tool_runtime::util::ansi_cleaner::strip_ansi; +// Inline rendering budget for Bash's own result formatter. When the shared +// oversized tool-result pipeline can persist the result, it stores the raw +// `output` field instead of this rendered/truncated fallback. const MAX_OUTPUT_LENGTH: usize = 30000; const INTERRUPT_OUTPUT_DRAIN_MS: u64 = 500; @@ -328,6 +332,87 @@ impl BashTool { result_string } + fn render_output_block_with_limit( + tag: &str, + output_text: &str, + max_chars: usize, + ) -> Option { + if output_text.is_empty() { + return None; + } + + let cleaned_output = strip_ansi(output_text); + let output_len = cleaned_output.chars().count(); + if max_chars == 0 { + Some(format!( + "<{tag} truncated=\"true\">... [truncated, no budget remaining] ..." + )) + } else if output_len > max_chars { + let truncated = truncate_output_preserving_tail(&cleaned_output, max_chars); + Some(format!("<{tag} truncated=\"true\">{}", truncated)) + } else { + Some(format!("<{tag}>{}", cleaned_output)) + } + } + + fn remote_stream_budgets(stdout: &str, stderr: &str) -> (usize, usize) { + let stdout_len = strip_ansi(stdout).chars().count(); + let stderr_len = strip_ansi(stderr).chars().count(); + + if stderr_len >= MAX_OUTPUT_LENGTH { + return (0, MAX_OUTPUT_LENGTH); + } + + let stderr_budget = stderr_len; + let stdout_budget = MAX_OUTPUT_LENGTH.saturating_sub(stderr_budget); + (stdout_budget.min(stdout_len), stderr_budget) + } + + fn render_remote_result( + &self, + working_directory: &str, + stdout: &str, + stderr: &str, + interrupted: bool, + timed_out: bool, + exit_code: i32, + ) -> String { + let mut result_string = String::new(); + result_string.push_str("true"); + result_string.push_str(&format!("{}", exit_code)); + if !working_directory.is_empty() { + result_string.push_str(&format!( + "{}", + working_directory + )); + } + + let (stdout_budget, stderr_budget) = Self::remote_stream_budgets(stdout, stderr); + + if let Some(stdout_block) = + Self::render_output_block_with_limit("stdout", stdout, stdout_budget) + { + result_string.push_str(&stdout_block); + } + if let Some(stderr_block) = + Self::render_output_block_with_limit("stderr", stderr, stderr_budget) + { + result_string.push_str(&stderr_block); + } + + if timed_out { + result_string.push_str( + "Command timed out before completion. Partial stdout/stderr, if any, is included above.", + ); + } else if interrupted { + result_string.push_str( + "Command was canceled before completion. ASK THE USER what they would like to do next.", + ); + } + + result_string + } + fn emit_terminal_ready_event(tool_use_id: &str, terminal_session_id: &str) { let event = ToolTerminalReady(ToolTerminalReadyInfo { tool_use_id: tool_use_id.to_string(), @@ -354,6 +439,91 @@ impl BashTool { fn cancellation_error(stage: &str) -> BitFunError { BitFunError::cancelled(format!("Bash tool execution cancelled {}", stage)) } + + fn background_output_file_reference( + context: &ToolUseContext, + chat_session_id: &str, + tool_use_id: &str, + output_file_path: &Path, + ) -> String { + context + .build_session_runtime_artifact_reference( + chat_session_id, + &format!("tool-results/{}.txt", tool_use_id), + ) + .unwrap_or_else(|_| output_file_path.display().to_string()) + } + + fn format_background_command_delivery_text( + command: &str, + terminal_session_id: &str, + working_directory: &str, + exit_code: Option, + timed_out: bool, + interrupted: bool, + output_file_reference: &str, + output_persist_error: Option<&str>, + ) -> String { + let (status, summary) = if timed_out { + ("timeout", "Background Bash command timed out.") + } else if interrupted { + ("interrupted", "Background Bash command was interrupted.") + } else if exit_code == Some(0) { + ( + "completed", + "Background Bash command completed successfully.", + ) + } else { + ( + "failed", + "Background Bash command completed with a non-zero exit code.", + ) + }; + let exit_code_attr = exit_code + .map(|code| format!(" exit_code=\"{}\"", code)) + .unwrap_or_default(); + let persistence_line = output_persist_error.map_or_else( + || format!("Full output was saved to: {}", output_file_reference), + |error| { + format!( + "Output persistence encountered an error while writing {}: {}", + output_file_reference, error + ) + }, + ); + + format!( + "{summary}\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\n" + ) + } + + fn format_background_command_error_text( + command: &str, + terminal_session_id: &str, + working_directory: &str, + output_file_reference: &str, + error: &str, + output_persist_error: Option<&str>, + ) -> String { + let persistence_line = output_persist_error.map_or_else( + || { + format!( + "Any captured output was saved to: {}", + output_file_reference + ) + }, + |persist_error| { + format!( + "Output persistence encountered an error while writing {}: {}", + output_file_reference, persist_error + ) + }, + ); + + format!( + "Background Bash command failed before producing a final completion result.\n\nCommand: {command}\nWorking directory: {working_directory}\n{persistence_line}\nError: {error}\n" + ) + } } #[async_trait] @@ -394,7 +564,7 @@ Usage notes: - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 120000ms (2 minutes). - It is very helpful if you write a clear, concise description of what this command does. For simple commands, keep it brief (5-10 words). For complex commands (piped commands, obscure flags, or anything hard to understand at a glance), add enough context to clarify what it does. - If the output exceeds {MAX_OUTPUT_LENGTH} characters, output will be truncated before being returned to you, with the tail of the output preserved because the ending is usually more important. - - You can use the `run_in_background` parameter to run the command in a new dedicated background terminal session. The tool returns the background session ID immediately without waiting for the command to finish. Only use this for long-running processes (e.g., dev servers, watchers) where you don't need the output right away. You do not need to append '&' to the command. NOTE: `timeout_ms` is ignored when `run_in_background` is true. + - You can use the `run_in_background` parameter to run the command in a new dedicated background terminal session. The tool returns immediately without waiting for the command to finish. The final completion result will be delivered back to you automatically when it is done, and the full output will be saved to a session runtime file instead of being pasted back into chat. Only use this for long-running processes (e.g., dev servers, watchers) where you do not need the output right away. You do not need to append '&' to the command. NOTE: `timeout_ms` is ignored when `run_in_background` is true. - Each result includes a `` tag identifying the terminal session. The persistent shell session ID remains constant throughout the entire conversation; background sessions each have their own unique ID. - The output may include the command echo and/or the shell prompt (e.g., `PS C:\path>`). Do not treat these as part of the command's actual result. - Avoid interactive commands that may block waiting for user input or open a pager/editor. Prefer non-interactive variants and explicit flags. For example, use `git --no-pager diff` instead of `git diff`, and avoid commands that prompt for confirmation unless the User explicitly asks for them. @@ -454,7 +624,7 @@ Usage notes: }, "run_in_background": { "type": "boolean", - "description": "If true, runs the command in a new dedicated background terminal session and returns the session ID immediately without waiting for completion. Useful for long-running processes like dev servers or file watchers. timeout_ms is ignored when this is true." + "description": "If true, runs the command in a new dedicated background terminal session and returns immediately. The final completion result is delivered back automatically when the command finishes, and the full output is saved to a session runtime file instead of being injected into chat. Useful for long-running processes like dev servers or file watchers. timeout_ms is ignored when this is true." }, "working_directory": { "type": "string", @@ -743,6 +913,14 @@ Usage notes: .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); let working_directory = requested_working_directory.unwrap_or(working_directory); + let result_for_assistant = self.render_remote_result( + &working_directory, + &exec_result.stdout, + &exec_result.stderr, + exec_result.interrupted, + exec_result.timed_out, + exec_result.exit_code, + ); let result = ToolResult::Result { data: json!({ @@ -759,22 +937,7 @@ Usage notes: "duration_ms": execution_time_ms, "is_remote": true }), - result_for_assistant: Some(if exec_result.timed_out { - format!( - "[Remote SSH] Command timed out on remote server in {}:\n{}\n\nExit code: {}", - working_directory, output, exec_result.exit_code - ) - } else if exec_result.interrupted { - format!( - "[Remote SSH] Command was cancelled on remote server in {}:\n{}\n\nExit code: {}", - working_directory, output, exec_result.exit_code - ) - } else { - format!( - "[Remote SSH] Command executed on remote server in {}:\n{}\n\nExit code: {}", - working_directory, output, exec_result.exit_code - ) - }), + result_for_assistant: Some(result_for_assistant), image_attachments: None, }; return Ok(vec![result]); @@ -842,7 +1005,6 @@ Usage notes: &initial_cwd, context, shell_type, - &terminal_api, &binding, start_time, ) @@ -1147,7 +1309,6 @@ impl BashTool { initial_cwd: &str, context: &ToolUseContext, shell_type: Option, - terminal_api: &TerminalApi, binding: &TerminalSessionBinding, start_time: Instant, ) -> BitFunResult> { @@ -1190,10 +1351,9 @@ impl BashTool { .unwrap_or_else(|| format!("bash_{}", uuid::Uuid::new_v4())); Self::emit_terminal_ready_event(&tool_use_id, &bg_session_id); - // Subscribe to session output before sending the command so no data is missed - let mut output_rx = terminal_api.subscribe_session_output(&bg_session_id); - if Self::cancellation_requested(context) { + let terminal_api = TerminalApi::from_singleton() + .map_err(|e| BitFunError::tool(format!("Terminal not initialized: {}", e)))?; let _ = terminal_api .close_session(terminal_core::CloseSessionRequest { session_id: bg_session_id.clone(), @@ -1205,98 +1365,287 @@ impl BashTool { )); } - // Fire-and-forget: write the command to the PTY without waiting for completion - terminal_api - .send_command(SendCommandRequest { - session_id: bg_session_id.clone(), - command: command_str.to_string(), - }) + // Store background output under the session-scoped runtime tool-results tree: + // local: ~/.bitfun/projects//sessions//tool-results/.txt + // remote: ~/.bitfun/remote_ssh///sessions//tool-results/.txt + let output_file_path = + Self::background_output_file_path(context, chat_session_id, &tool_use_id).ok_or_else( + || { + BitFunError::tool( + "Failed to prepare a background output file for Bash tool".to_string(), + ) + }, + )?; + if let Some(parent) = output_file_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::tool(format!( + "Failed to create background output directory: {}", + e + )) + })?; + } + let output_file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&output_file_path) .await - .map_err(|e| BitFunError::tool(format!("Failed to send background command: {}", e)))?; + .map_err(|e| { + BitFunError::tool(format!("Failed to open background output file: {}", e)) + })?; + let output_file_reference = Self::background_output_file_reference( + context, + chat_session_id, + &tool_use_id, + &output_file_path, + ); debug!( "Background command started, session_id: {}, owner: {}", bg_session_id, chat_session_id ); - // Store background output under the session-scoped runtime tool-results tree: - // local: ~/.bitfun/projects//sessions//tool-results/.txt - // remote: ~/.bitfun/remote_ssh///sessions//tool-results/.txt - let output_file_path = - Self::background_output_file_path(context, chat_session_id, &tool_use_id); - - // Spawn task: write PTY output to file, delete when session ends - if let Some(file_path) = output_file_path.clone() { - let bg_id_for_log = bg_session_id.clone(); - tokio::spawn(async move { - if let Some(parent) = file_path.parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - error!( - "Failed to create tool-results output dir for bg session {}: {}", - bg_id_for_log, e - ); - return; - } + let parent_session_id = chat_session_id.to_string(); + let parent_agent_type = context + .agent_type + .clone() + .unwrap_or_else(|| "Agentic".to_string()); + let parent_workspace_path = context + .workspace_root() + .map(|path| path.to_string_lossy().to_string()); + let command = command_str.to_string(); + let working_directory = initial_cwd.to_string(); + let terminal_session_id = bg_session_id.clone(); + let output_file_reference_for_task = output_file_reference.clone(); + let tool_use_id_for_task = tool_use_id.clone(); + + tokio::spawn(async move { + let mut writer = tokio::io::BufWriter::new(output_file); + let mut output_persist_error: Option = None; + let mut saw_output_event = false; + let mut saw_completion = false; + let mut delivery_sent = false; + + let terminal_api = match TerminalApi::from_singleton() { + Ok(api) => api, + Err(error) => { + error!( + "Background Bash command could not access terminal singleton: session_id={}, error={}", + terminal_session_id, error + ); + return; } + }; - let file = match tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(&file_path) - .await - { - Ok(f) => f, - Err(e) => { - error!( - "Failed to open output file for bg session {}: {}", - bg_id_for_log, e + let mut stream = terminal_api.execute_command_stream(ExecuteCommandRequest { + session_id: terminal_session_id.clone(), + command: command.clone(), + timeout_ms: None, + prevent_history: Some(true), + }); + + while let Some(event) = stream.next().await { + match event { + CommandStreamEvent::Started { command_id } => { + debug!( + "Background Bash command started execution, session_id={}, command_id={}", + terminal_session_id, command_id ); - return; } - }; - - let mut writer = tokio::io::BufWriter::new(file); - - while let Some(data) = output_rx.recv().await { - if let Err(e) = writer.write_all(data.as_bytes()).await { - error!( - "Failed to write output for bg session {}: {}", - bg_id_for_log, e + CommandStreamEvent::Output { data } => { + saw_output_event = true; + if output_persist_error.is_none() { + if let Err(error) = writer.write_all(data.as_bytes()).await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to write background Bash output: session_id={}, error={}", + terminal_session_id, error + ); + } else if let Err(error) = writer.flush().await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to flush background Bash output: session_id={}, error={}", + terminal_session_id, error + ); + } + } + } + CommandStreamEvent::Completed { + exit_code, + total_output, + completion_reason, + shell_state: _, + } => { + saw_completion = true; + + if !saw_output_event + && !total_output.is_empty() + && output_persist_error.is_none() + { + if let Err(error) = writer.write_all(total_output.as_bytes()).await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to persist background Bash completion output: session_id={}, error={}", + terminal_session_id, error + ); + } else if let Err(error) = writer.flush().await { + output_persist_error = Some(error.to_string()); + error!( + "Failed to flush background Bash completion output: session_id={}, error={}", + terminal_session_id, error + ); + } + } + + let timed_out = completion_reason == CommandCompletionReason::TimedOut; + let interrupted = + !timed_out && matches!(exit_code, Some(130) | Some(-1073741510)); + let delivery_text = Self::format_background_command_delivery_text( + &command, + &terminal_session_id, + &working_directory, + exit_code, + timed_out, + interrupted, + &output_file_reference_for_task, + output_persist_error.as_deref(), + ); + let metadata = json!({ + "kind": "background_result", + "sourceKind": "bash_command", + "toolName": "Bash", + "toolCallId": tool_use_id_for_task.clone(), + "terminalSessionId": terminal_session_id.clone(), + "command": command.clone(), + "workingDirectory": working_directory.clone(), + "outputFile": output_file_reference_for_task.clone(), + }); + + if let Some(scheduler) = get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + None, + Some(metadata), + ) + .await + { + error!( + "Failed to deliver background Bash result: session_id={}, terminal_session_id={}, error={}", + parent_session_id, terminal_session_id, error + ); + } + } else { + error!( + "Scheduler not initialized; background Bash result dropped: session_id={}, terminal_session_id={}", + parent_session_id, terminal_session_id + ); + } + delivery_sent = true; + break; + } + CommandStreamEvent::Error { message } => { + let delivery_text = Self::format_background_command_error_text( + &command, + &terminal_session_id, + &working_directory, + &output_file_reference_for_task, + &message, + output_persist_error.as_deref(), ); + let metadata = json!({ + "kind": "background_result", + "sourceKind": "bash_command", + "toolName": "Bash", + "toolCallId": tool_use_id_for_task.clone(), + "terminalSessionId": terminal_session_id.clone(), + "command": command.clone(), + "workingDirectory": working_directory.clone(), + "outputFile": output_file_reference_for_task.clone(), + "error": message.clone(), + }); + + if let Some(scheduler) = get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + None, + Some(metadata), + ) + .await + { + error!( + "Failed to deliver background Bash error result: session_id={}, terminal_session_id={}, error={}", + parent_session_id, terminal_session_id, error + ); + } + } else { + error!( + "Scheduler not initialized; background Bash error result dropped: session_id={}, terminal_session_id={}", + parent_session_id, terminal_session_id + ); + } + delivery_sent = true; break; } - let _ = writer.flush().await; } + } - // Channel closed means session was destroyed - delete the log file - drop(writer); - if let Err(e) = tokio::fs::remove_file(&file_path).await { - debug!( - "Could not remove output file for bg session {} (may already be gone): {}", - bg_id_for_log, e - ); + if !saw_completion && !delivery_sent { + let delivery_text = Self::format_background_command_error_text( + &command, + &terminal_session_id, + &working_directory, + &output_file_reference_for_task, + "Background Bash command stream ended without a completion event.", + output_persist_error.as_deref(), + ); + let metadata = json!({ + "kind": "background_result", + "sourceKind": "bash_command", + "toolName": "Bash", + "toolCallId": tool_use_id_for_task, + "terminalSessionId": terminal_session_id.clone(), + "command": command.clone(), + "workingDirectory": working_directory.clone(), + "outputFile": output_file_reference_for_task.clone(), + "error": "stream_ended_without_completion", + }); + + if let Some(scheduler) = get_global_scheduler() { + if let Err(error) = scheduler + .deliver_background_result( + parent_session_id.clone(), + parent_agent_type.clone(), + parent_workspace_path.clone(), + delivery_text, + None, + Some(metadata), + ) + .await + { + error!( + "Failed to deliver background Bash terminal stream-end result: session_id={}, terminal_session_id={}, error={}", + parent_session_id, terminal_session_id, error + ); + } } else { - debug!("Removed output file for bg session {}", bg_id_for_log); + error!( + "Scheduler not initialized; background Bash stream-end result dropped: session_id={}, terminal_session_id={}", + parent_session_id, terminal_session_id + ); } - }); - } + } + }); let execution_time_ms = elapsed_ms_u64(start_time); - - let output_file_str = output_file_path.as_deref().map(|p| p.display().to_string()); - let output_file_reference = context - .build_session_runtime_artifact_reference( - chat_session_id, - &format!("tool-results/{}.txt", tool_use_id), - ) - .ok() - .or_else(|| output_file_str.clone()); - - let output_file_note = output_file_reference - .as_deref() - .map(|s| format!("\nOutput is being written to: {}", s)) - .unwrap_or_default(); + let output_file_note = format!("\nFull output will be saved to: {}", output_file_reference); let result_data = json!({ "success": true, @@ -1308,10 +1657,11 @@ impl BashTool { "execution_time_ms": execution_time_ms, "terminal_session_id": bg_session_id, "output_file": output_file_reference, + "run_in_background": true, }); let result_for_assistant = format!( - "Command started in background terminal session (id: {}). Working directory: {}.{}", + "Command started in background terminal session (id: {}). Working directory: {}.{} Its final result will be delivered back automatically when it finishes. Do not poll for status updates. If your current path is blocked on this result and there is no other useful local work to do, it is fine to end the current turn.", bg_session_id, initial_cwd, output_file_note ); @@ -1406,6 +1756,53 @@ mod tests { assert!(rendered.contains("1")); } + #[test] + fn render_remote_result_keeps_stdout_and_stderr_separate() { + let tool = BashTool::new(); + + let rendered = + tool.render_remote_result("/repo", "stdout text", "stderr text", false, false, 2); + + assert!(rendered.contains("true")); + assert!(rendered.contains("2")); + assert!(rendered.contains("stdout text")); + assert!(rendered.contains("stderr text")); + assert!(!rendered.contains("")); + } + + #[test] + fn render_remote_result_uses_shared_budget_with_stderr_priority() { + let tool = BashTool::new(); + let long_stdout = + "prefix\n".to_string() + &"x".repeat(MAX_OUTPUT_LENGTH + 100) + "\nstdout-tail"; + let long_stderr = + "prefix\n".to_string() + &"z".repeat(MAX_OUTPUT_LENGTH / 2) + "\nstderr-tail"; + + let rendered = + tool.render_remote_result("/repo", &long_stdout, &long_stderr, false, false, 1); + + assert!(rendered.contains("")); + assert!(rendered.contains("stdout-tail")); + assert!(!rendered.contains("")); + assert!(rendered.contains("stderr-tail")); + } + + #[test] + fn render_remote_result_gives_all_budget_to_oversized_stderr() { + let tool = BashTool::new(); + let long_stderr = + "prefix\n".to_string() + &"z".repeat(MAX_OUTPUT_LENGTH + 100) + "\nremote-final-error"; + + let rendered = + tool.render_remote_result("/repo", "stdout text", &long_stderr, false, false, 1); + + assert!(rendered.contains("")); + assert!(rendered.contains("no budget remaining")); + assert!(rendered.contains("")); + assert!(rendered.contains("tail preserved")); + assert!(rendered.contains("remote-final-error")); + } + #[test] fn input_schema_accepts_working_directory() { let tool = BashTool::new(); @@ -1449,4 +1846,25 @@ mod tests { assert!(rendered.contains("/private/tmp")); assert!(rendered.contains("ERR_PNPM_NO_PKG_MANIFEST")); } + + #[test] + fn background_delivery_text_points_to_saved_output_file() { + let rendered = BashTool::format_background_command_delivery_text( + "pnpm test", + "bg-session-1", + "/repo", + Some(0), + false, + false, + "/runtime/sessions/session/tool-results/bash_123.txt", + None, + ); + + assert!(rendered.contains("Background Bash command completed successfully.")); + assert!(rendered.contains("status=\"completed\"")); + assert!(rendered.contains("terminal_session_id=\"bg-session-1\"")); + assert!(rendered.contains( + "Full output was saved to: /runtime/sessions/session/tool-results/bash_123.txt" + )); + } } diff --git a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs index 768640e51e..bae17671c8 100644 --- a/src/crates/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/cron_tool.rs @@ -1230,12 +1230,10 @@ mod tests { .await; assert!(!validation.result); - assert!( - validation - .message - .as_deref() - .unwrap_or_default() - .contains("unknown field") - ); + assert!(validation + .message + .as_deref() + .unwrap_or_default() + .contains("unknown field")); } } diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index b6a1732b01..efe773c7de 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,15 +1,36 @@ -use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::agentic::tools::file_read_state_runtime::{ + assert_file_not_unexpectedly_modified, file_mutation_timestamp_ms, + get_stored_file_read_state, local_file_modification_time_ms, + read_current_file_content, read_state_tracking_enabled, update_file_read_state_after_mutation, + validate_edit_against_read_state, validate_edit_has_prior_read, FILE_UNEXPECTEDLY_MODIFIED_ERROR, +}; +use crate::agentic::tools::file_tool_guidance::file_tool_guidance_message; +use crate::agentic::tools::framework::{ + Tool, ToolPathResolution, ToolResult, ToolUseContext, ValidationResult, +}; use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; -use tool_runtime::fs::edit_file::{apply_edit_to_content, edit_file}; +use std::path::Path; +use tool_runtime::fs::edit_file::apply_edit_to_content; pub struct FileEditTool; -const LARGE_EDIT_SOFT_LINE_LIMIT: usize = 200; -const LARGE_EDIT_SOFT_BYTE_LIMIT: usize = 20 * 1024; -const EDIT_RETRY_GUIDANCE: &str = "Common causes: stale Read output after another edit, copied line-number prefixes, changed whitespace, or an old_string that is too broad. Recovery: read the current target area again, copy the exact current text after any line-number prefix, and retry with a uniquely matching old_string. If several edits target the same file, apply them sequentially from fresh content or replace one stable enclosing block. If the text appears more than once, include more surrounding context or set replace_all only when every occurrence should change."; +const EDIT_TOOL_PROMPT: &str = r#"Performs exact string replacements in files. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- The `file_path` parameter must be a workspace-relative path, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. +- Copy `old_string` verbatim from your latest Read of this file. Do not reformat HTML/CSS/JS, do not normalize indentation, and do not reconstruct the block from memory. +- Use the smallest `old_string` that is clearly unique — usually 2-4 adjacent lines with stable surrounding context is sufficient. +- If Read output was truncated or used start_line/limit, re-read until the full target block is visible before editing. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. +- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. +- If an edit fails because the text was not found, call Read again on the target lines and retry with a freshly copied `old_string`."#; impl Default for FileEditTool { fn default() -> Self { @@ -22,17 +43,72 @@ impl FileEditTool { Self } - fn enhance_edit_error(file_path: &str, error: String) -> String { - if error.contains("old_string not found in file") || error.contains("`old_string` appears") - { + fn guidance_failure(message: String) -> ValidationResult { + ValidationResult { + result: false, + message: Some(file_tool_guidance_message(message)), + error_code: Some(400), + meta: Some(json!({ "failure_kind": "guidance" })), + } + } + + fn format_edit_freshness_guidance(logical_path: &str, error: String) -> String { + if error == FILE_UNEXPECTEDLY_MODIFIED_ERROR || error.contains("unexpectedly modified") { format!( - "Edit failed for {}: {}\n{}", - file_path, error, EDIT_RETRY_GUIDANCE + "The file {} changed since it was last read. Use Read again, then retry Edit.", + logical_path ) } else { error } } + + fn is_edit_content_guardrail_error(error: &str) -> bool { + error.contains("old_string not found in file") + || error.contains("`old_string` appears") + } + + async fn edit_read_state_guardrail_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if let Some(message) = validate_edit_has_prior_read(context, resolved) { + return Some(message); + } + + validate_edit_against_read_state(context, resolved).await + } + + fn assert_atomic_edit_freshness( + context: &ToolUseContext, + resolved: &ToolPathResolution, + content: &str, + ) -> BitFunResult<()> { + if !read_state_tracking_enabled(context) { + return Ok(()); + } + + let read_state = get_stored_file_read_state(context, resolved); + let current_mtime_ms = if resolved.uses_remote_workspace_backend() { + None + } else { + Some(local_file_modification_time_ms(Path::new(&resolved.resolved_path))) + }; + + if let Some(error) = assert_file_not_unexpectedly_modified( + read_state.as_ref(), + content, + current_mtime_ms, + ) + .err() + { + return Err(BitFunError::tool(file_tool_guidance_message( + Self::format_edit_freshness_guidance(&resolved.logical_path, error), + ))); + } + + Ok(()) + } } #[async_trait] @@ -42,25 +118,11 @@ impl Tool for FileEditTool { } async fn description(&self) -> BitFunResult { - Ok(r#"Performs exact string replacements in files. - -Usage: -- Use the Read tool before editing so `old_string` is based on current file content. -- Treat Read output as stale after any successful edit to the same file. For multiple edits in one file, either apply them sequentially from fresh content or replace a stable enclosing block once. -- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. -- Build `old_string` from current file contents rather than from memory, an intended final version, or a guessed retry. -- When editing text from Read output, copy only the text after the line-number prefix and preserve indentation exactly. -- Prefer editing existing files in the codebase; create new files only when the task genuinely calls for a new artifact. -- Avoid adding emojis to files unless the user asks. -- The edit requires `old_string` to be unique unless `replace_all` is true. Add surrounding context from the same stable block when a snippet may appear more than once, or use `replace_all` when every occurrence should change. -- If an edit fails because `old_string` was not found or matched multiple places, read the current target area again before retrying. Do not retry by slightly modifying the failed `old_string` from memory. -- Keep edits focused. Large replacements are allowed when necessary, but staged section/function/component edits are usually more reliable than one huge replacement. -- Use `replace_all` for intentional file-wide replacements, such as renaming a variable."# - .to_string()) + Ok(EDIT_TOOL_PROMPT.to_string()) } fn short_description(&self) -> String { - "Apply exact string replacements to an existing file.".to_string() + "A tool for editing files".to_string() } fn input_schema(&self) -> Value { @@ -69,22 +131,20 @@ Usage: "properties": { "file_path": { "type": "string", - "description": "The file to modify. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun://runtime URI returned by another tool." + "description": "The path to the file to modify" }, "old_string": { "type": "string", - "minLength": 1, - "default": "", - "description": "The non-empty exact current text to replace. It must match the current file contents exactly, including whitespace and indentation, and must be unique unless replace_all is true. Copy it from a fresh Read result, excluding the line-number prefix. If this file was edited earlier in the turn, read the target area again before building old_string. Include stable surrounding context when a short snippet may appear multiple times." + "description": "Exact text to replace, copied verbatim from your latest Read of this file (content after the line-number tab only). Preserve indentation; do not reformat." }, "new_string": { "type": "string", - "description": "The replacement text. It must be different from old_string. Keep edits targeted. Large replacements are allowed when necessary; focused edits by section, function, or component are usually more reliable." + "description": "Replacement text with the same indentation style as old_string (must be different from old_string)" }, "replace_all": { "type": "boolean", "default": false, - "description": "Replace all occurrences of old_string (default false). Use only when every occurrence should change." + "description": "Replace all occurrences of old_string (default false)" } }, "required": ["file_path", "old_string", "new_string"], @@ -139,29 +199,6 @@ Usage: }; } - if let Some(ctx) = context { - let resolved = match ctx.resolve_tool_path(file_path) { - Ok(resolved) => resolved, - Err(err) => { - return ValidationResult { - result: false, - message: Some(err.to_string()), - error_code: Some(400), - meta: None, - }; - } - }; - - if let Err(err) = ctx.enforce_path_operation(ToolPathOperation::Edit, &resolved) { - return ValidationResult { - result: false, - message: Some(err.to_string()), - error_code: Some(400), - meta: None, - }; - } - } - let old_string = input .get("old_string") .and_then(|v| v.as_str()) @@ -170,6 +207,11 @@ Usage: .get("new_string") .and_then(|v| v.as_str()) .unwrap_or(""); + let replace_all = input + .get("replace_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if old_string.is_empty() { return ValidationResult { result: false, @@ -187,25 +229,61 @@ Usage: }; } - let largest_lines = old_string.lines().count().max(new_string.lines().count()); - let largest_bytes = old_string.len().max(new_string.len()); - if largest_lines > LARGE_EDIT_SOFT_LINE_LIMIT || largest_bytes > LARGE_EDIT_SOFT_BYTE_LIMIT - { - return ValidationResult { - result: true, - message: Some(format!( - "Large Edit payload: largest side is {} lines, {} bytes. This is allowed when necessary, but a staged approach is usually more reliable: edit one stable section, function, or component at a time, and refresh file context before additional edits to the same file.", - largest_lines, largest_bytes - )), - error_code: None, - meta: Some(json!({ - "large_edit": true, - "largest_line_count": largest_lines, - "largest_byte_count": largest_bytes, - "soft_line_limit": LARGE_EDIT_SOFT_LINE_LIMIT, - "soft_byte_limit": LARGE_EDIT_SOFT_BYTE_LIMIT - })), + if let Some(ctx) = context { + let resolved = match ctx.resolve_tool_path(file_path) { + Ok(resolved) => resolved, + Err(err) => { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + }; + } }; + + if let Err(err) = ctx.enforce_path_operation(ToolPathOperation::Edit, &resolved) { + return ValidationResult { + result: false, + message: Some(err.to_string()), + error_code: Some(400), + meta: None, + }; + } + + if let Some(message) = Self::edit_read_state_guardrail_error(ctx, &resolved).await { + return Self::guidance_failure(message); + } + + let file_content = match read_current_file_content(ctx, &resolved).await { + Ok(content) => content, + Err(error) => { + return ValidationResult { + result: false, + message: Some(format!( + "Failed to read file {}: {}", + resolved.logical_path, error + )), + error_code: Some(400), + meta: None, + }; + } + }; + + if let Err(error) = + apply_edit_to_content(&file_content, old_string, new_string, replace_all) + { + if Self::is_edit_content_guardrail_error(&error) { + return Self::guidance_failure(error); + } + + return ValidationResult { + result: false, + message: Some(error), + error_code: Some(400), + meta: None, + }; + } } ValidationResult::default() @@ -255,14 +333,29 @@ Usage: .read_file_text(&resolved.resolved_path) .await .map_err(|e| BitFunError::tool(format!("Failed to read file: {}", e)))?; + Self::assert_atomic_edit_freshness(context, &resolved, &content)?; let edit_result = apply_edit_to_content(&content, old_string, new_string, replace_all) - .map_err(|e| BitFunError::tool(Self::enhance_edit_error(file_path, e)))?; + .map_err(|error| { + if Self::is_edit_content_guardrail_error(&error) { + BitFunError::tool(file_tool_guidance_message(error)) + } else { + BitFunError::tool(error) + } + })?; ws_fs .write_file(&resolved.resolved_path, edit_result.new_content.as_bytes()) .await .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation( + context, + &resolved, + &edit_result.new_content, + timestamp_ms, + ); + let result = ToolResult::Result { data: json!({ "file_path": resolved.logical_path, @@ -283,9 +376,39 @@ Usage: return Ok(vec![result]); } - // Local: direct local edit via tool-runtime - let edit_result = edit_file(&resolved.resolved_path, old_string, new_string, replace_all) - .map_err(|e| BitFunError::tool(Self::enhance_edit_error(file_path, e)))?; + // Local: read → edit in memory → write back so failures can include current file context. + let content = std::fs::read_to_string(&resolved.resolved_path).map_err(|e| { + BitFunError::tool(format!( + "Failed to read file {}: {}", + resolved.logical_path, e + )) + })?; + Self::assert_atomic_edit_freshness(context, &resolved, &content)?; + let edit_result = apply_edit_to_content(&content, old_string, new_string, replace_all) + .map_err(|error| { + if Self::is_edit_content_guardrail_error(&error) { + BitFunError::tool(file_tool_guidance_message(error)) + } else { + BitFunError::tool(error) + } + })?; + + std::fs::write(&resolved.resolved_path, edit_result.new_content.as_bytes()).map_err( + |e| { + BitFunError::tool(format!( + "Failed to write file {}: {}", + resolved.logical_path, e + )) + }, + )?; + + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation( + context, + &resolved, + &edit_result.new_content, + timestamp_ms, + ); let result = ToolResult::Result { data: json!({ @@ -293,9 +416,10 @@ Usage: "old_string": old_string, "new_string": new_string, "success": true, - "start_line": edit_result.start_line, - "old_end_line": edit_result.old_end_line, - "new_end_line": edit_result.new_end_line, + "match_count": edit_result.match_count, + "start_line": edit_result.edit_result.start_line, + "old_end_line": edit_result.edit_result.old_end_line, + "new_end_line": edit_result.edit_result.new_end_line, }), result_for_assistant: Some(format!("Successfully edited {}", resolved.logical_path)), image_attachments: None, @@ -307,32 +431,82 @@ Usage: #[cfg(test)] mod tests { - use super::FileEditTool; + use super::{FileEditTool, EDIT_TOOL_PROMPT}; + use crate::agentic::tools::framework::Tool; + use serde_json::Value; + + #[tokio::test] + async fn edit_tool_prompt_matches_claude_style() { + let description = FileEditTool::new().description().await.expect("description"); + + assert_eq!(description, EDIT_TOOL_PROMPT); + assert!(description.contains("You must use your `Read` tool")); + assert!(description.contains("spaces + line number + tab")); + assert!(description.contains("verbatim from your latest Read")); + assert!(description.contains("NEVER write new files unless explicitly required")); + assert!(!description.contains("auto-strip")); + } #[test] - fn edit_not_found_error_includes_retry_guidance() { - let message = FileEditTool::enhance_edit_error( - "src/lib.rs", - "old_string not found in file.".to_string(), + fn edit_tool_schema_describes_exact_copy_from_read() { + let schema = FileEditTool::new().input_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("properties"); + + assert_eq!( + properties + .get("file_path") + .and_then(|value| value.get("description")) + .and_then(Value::as_str), + Some("The path to the file to modify") ); - - assert!(message.contains("Edit failed for src/lib.rs")); - assert!(message.contains("Common causes")); - assert!(message.contains("stale Read output")); - assert!(message.contains("read the current target area again")); + assert!( + properties + .get("old_string") + .and_then(|value| value.get("description")) + .and_then(Value::as_str) + .unwrap_or_default() + .contains("latest Read") + ); + assert!( + properties + .get("new_string") + .and_then(|value| value.get("description")) + .and_then(Value::as_str) + .unwrap_or_default() + .contains("indentation") + ); + assert_eq!( + properties + .get("replace_all") + .and_then(|value| value.get("description")) + .and_then(Value::as_str), + Some("Replace all occurrences of old_string (default false)") + ); + assert!(properties + .get("old_string") + .and_then(|value| value.get("minLength")) + .is_none()); } #[test] - fn edit_multiple_match_error_includes_unique_context_guidance() { - let message = FileEditTool::enhance_edit_error( - "src/lib.rs", - "`old_string` appears 2 times in file\nMatched contexts:\n[match 1 starts at line 4]" - .to_string(), + fn edit_tool_short_description_matches_claude_summary() { + assert_eq!( + FileEditTool::new().short_description(), + "A tool for editing files" ); + } - assert!(message.contains("old_string")); - assert!(message.contains("[match 1 starts at line 4]")); - assert!(message.contains("include more surrounding context")); - assert!(message.contains("replace_all only when every occurrence should change")); + #[test] + fn edit_content_guardrail_detection_matches_apply_edit_errors() { + assert!(FileEditTool::is_edit_content_guardrail_error( + "old_string not found in file." + )); + assert!(FileEditTool::is_edit_content_guardrail_error( + "`old_string` appears 2 times in file, either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.\n" + )); + assert!(!FileEditTool::is_edit_content_guardrail_error("Permission denied")); } } diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index fa2d7d4c43..05501800f3 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -1,3 +1,6 @@ +use crate::agentic::tools::file_read_state_runtime::{ + local_file_modification_time_ms, record_file_read_state, +}; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -17,6 +20,9 @@ pub struct FileReadTool { max_total_chars: usize, } +/// Default cap on characters returned by a single Read call (excluding wrapper text). +pub const DEFAULT_READ_MAX_TOTAL_CHARS: usize = 64_000; + impl Default for FileReadTool { fn default() -> Self { Self::new() @@ -28,7 +34,7 @@ impl FileReadTool { Self { default_max_lines_to_read: 2000, max_line_chars: 2000, - max_total_chars: 50_000, + max_total_chars: DEFAULT_READ_MAX_TOTAL_CHARS, } } @@ -208,14 +214,14 @@ impl Tool for FileReadTool { Usage: - The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. - Do not read host roots or placeholder paths such as `/workspace`. -- By default, it reads up to {} lines starting from the beginning of the file. -- You can optionally specify a start_line and limit. For large files, prefer reading targeted ranges instead of starting over from the beginning every time. +- By default, it reads up to {} lines starting from the beginning of the file. When you plan to Edit a file, prefer this default full read so you see the exact bytes you will need to match. +- You can optionally specify a start_line and limit. Use a range only when you already know the target lines; the range must include every line you will copy into Edit `old_string`. - Any lines longer than {} characters will be truncated. -- Total output is capped at {} characters. If that limit is hit, narrow the range with start_line and limit. +- Total output is capped at {} characters. If that limit is hit, continue with start_line/limit until the target lines are fully visible, then Edit using only text from those Read results. - Results are returned using cat -n format, with line numbers starting at 1. - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. - You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel. -- Avoid tiny repeated slices (e.g. 30-100 line chunks). If you need more context, read a larger window. +- Avoid tiny repeated slices (e.g. 30-100 line chunks). If you need more context, read a larger window that covers the whole block you will edit. "#, self.default_max_lines_to_read, self.max_line_chars, self.max_total_chars )) @@ -409,6 +415,16 @@ Usage: .map_err(BitFunError::tool)? }; + let timestamp_ms = if resolved.uses_remote_workspace_backend() { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) + } else { + local_file_modification_time_ms(Path::new(&resolved.resolved_path)) + }; + record_file_read_state(context, &resolved, &read_file_result, timestamp_ms); + let mut result_for_assistant = format!( "Read lines {}-{} from {} ({} total lines)\n\n{}\n", read_file_result.start_line, diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 720d0e66c9..a89ec9ab0b 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,7 +1,17 @@ +use crate::agentic::tools::file_read_state_runtime::{ + assert_file_not_unexpectedly_modified, file_mutation_timestamp_ms, + get_stored_file_read_state, local_file_modification_time_ms, read_current_file_content, + read_state_tracking_enabled, update_file_read_state_after_mutation, + validate_existing_file_read_before_write, FILE_UNEXPECTEDLY_MODIFIED_ERROR, +}; +use crate::agentic::tools::file_tool_guidance::{ + file_tool_guidance_message, is_file_tool_guidance_message, +}; use crate::agentic::tools::framework::{ Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::agentic::tools::ToolPathOperation; +use crate::service::config::types::WriteToolMode; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -10,6 +20,10 @@ use tokio::fs; pub struct FileWriteTool; +const LARGE_WRITE_SOFT_LINE_LIMIT: usize = 200; +const LARGE_WRITE_SOFT_BYTE_LIMIT: usize = 20 * 1024; +pub(crate) const WRITE_TOOL_MODE_CONTEXT_KEY: &str = "write_tool_mode"; + impl Default for FileWriteTool { fn default() -> Self { Self::new() @@ -21,20 +35,40 @@ impl FileWriteTool { Self } - pub(crate) async fn existing_file_error( - context: &ToolUseContext, - resolved: &ToolPathResolution, - ) -> Option { - let file_already_exists = Self::file_exists(context, resolved).await; + pub(crate) fn write_guidance_message(message: impl Into) -> String { + file_tool_guidance_message(message) + } + + pub(crate) fn is_write_guidance_message(message: &str) -> bool { + is_file_tool_guidance_message(message) + } - file_already_exists.then(|| { + fn format_write_freshness_guidance(logical_path: &str, error: String) -> String { + if error == FILE_UNEXPECTEDLY_MODIFIED_ERROR || error.contains("unexpectedly modified") { format!( - "File {} already exists. The Write tool is reserved for creating NEW files. \ - To modify the file, use the Edit tool. \ - To fully rewrite the file, first call the Delete tool on this path, then call Write again.", - resolved.logical_path + "The file {} changed since it was last read. Use Read again, then retry Write.", + logical_path ) - }) + } else if error.contains("modified since read") { + format!( + "The file {} changed after it was last read. Use Read again, then retry Write.", + logical_path + ) + } else { + error + } + } + + pub(crate) fn write_tool_mode(context: Option<&ToolUseContext>) -> WriteToolMode { + if Self::is_acp_context(context) { + return WriteToolMode::InlineContent; + } + + WriteToolMode::from_context_var( + context + .and_then(|ctx| ctx.custom_data.get(WRITE_TOOL_MODE_CONTEXT_KEY)) + .and_then(|value| value.as_str()), + ) } async fn file_exists(context: &ToolUseContext, resolved: &ToolPathResolution) -> bool { @@ -67,6 +101,82 @@ impl FileWriteTool { Some(existing == content.as_bytes()) } + async fn existing_file_write_freshness_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if !Self::file_exists(context, resolved).await { + return None; + } + if !read_state_tracking_enabled(context) { + return None; + } + + let current_content = match read_current_file_content(context, resolved).await { + Ok(content) => content, + Err(error) => return Some(error.to_string()), + }; + let read_state = get_stored_file_read_state(context, resolved); + let current_mtime_ms = if resolved.uses_remote_workspace_backend() { + None + } else { + Some(local_file_modification_time_ms(Path::new(&resolved.resolved_path))) + }; + + assert_file_not_unexpectedly_modified( + read_state.as_ref(), + ¤t_content, + current_mtime_ms, + ) + .err() + .map(|error| Self::format_write_freshness_guidance(&resolved.logical_path, error)) + } + + async fn assert_atomic_write_freshness_if_exists( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> BitFunResult<()> { + if let Some(error) = Self::existing_file_write_freshness_error(context, resolved).await { + return Err(BitFunError::tool(Self::write_guidance_message(error))); + } + + Ok(()) + } + + async fn write_guardrail_preflight_error( + context: &ToolUseContext, + resolved: &ToolPathResolution, + ) -> Option { + if !Self::file_exists(context, resolved).await { + return None; + } + + if let Some(message) = validate_existing_file_read_before_write(context, resolved).await + { + return Some(Self::write_guidance_message(message)); + } + + Self::existing_file_write_freshness_error(context, resolved) + .await + .map(Self::write_guidance_message) + } + + pub(crate) async fn preflight_write_error( + context: &ToolUseContext, + file_path: &str, + ) -> Option { + let resolved = match context.resolve_tool_path(file_path) { + Ok(resolved) => resolved, + Err(err) => return Some(err.to_string()), + }; + + if let Err(err) = context.enforce_path_operation(ToolPathOperation::Write, &resolved) { + return Some(err.to_string()); + } + + Self::write_guardrail_preflight_error(context, &resolved).await + } + fn write_success_result( logical_path: &str, bytes_written: usize, @@ -109,11 +219,225 @@ impl FileWriteTool { "additionalProperties": false }) } + + fn schema_without_content() -> Value { + json!({ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The file to write. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun://runtime URI returned by another tool." + } + }, + "required": ["file_path"], + "additionalProperties": false + }) + } + + fn inline_description() -> String { + r#"Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Keep writes focused. The 200-line / 20KB guideline is a soft reliability threshold, not a hard cap. If a task genuinely needs more content, preserve correctness and use a staged plan instead of truncating. +- For existing files, prefer Read + targeted Edit calls. For large new files or rewrites, write the stable scaffold first, then fill or revise sections with focused Edit calls. Do not replace an entire existing file just to change a few sections. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +- Include the complete file content in the `content` argument."# + .to_string() + } + + fn plaintext_followup_description() -> String { + r#"Writes a file to the local filesystem. + +Usage: +- This tool writes the COMPLETE file content and will overwrite the existing file if one already exists at the provided path. +- For partial changes to an existing file, use the Edit tool instead. Edit performs targeted string replacements; Write replaces the entire file. +- If this is an existing file, you MUST use the Read tool first to read the file's contents before calling Write. +- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Keep writes focused. For existing files, prefer Read + targeted Edit calls. Use Write only when you need to replace the entire file or create a new one. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +- Do NOT include the file content in the tool call arguments. Only provide file_path. The system will prompt you separately to output the file content as plain text."# + .to_string() + } + + async fn call_inline_content_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let file_path = input + .get("file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; + + let resolved = context.resolve_tool_path(file_path)?; + context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; + context + .record_light_checkpoint( + "Write", + &resolved.logical_path, + vec![resolved.logical_path.clone()], + ) + .await; + + let content = input + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; + + Self::assert_atomic_write_freshness_if_exists(context, &resolved).await?; + + if resolved.uses_remote_workspace_backend() { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Remote workspace file system is unavailable".to_string()) + })?; + ws_fs + .write_file(&resolved.resolved_path, content.as_bytes()) + .await + .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; + } else { + if let Some(parent) = Path::new(&resolved.resolved_path).parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| BitFunError::tool(format!("Failed to create directory: {}", e)))?; + } + fs::write(&resolved.resolved_path, content) + .await + .map_err(|e| { + BitFunError::tool(format!( + "Failed to write file {}: {}", + resolved.logical_path, e + )) + })?; + } + + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation(context, &resolved, content, timestamp_ms); + + let result = ToolResult::Result { + data: json!({ + "file_path": resolved.logical_path, + "bytes_written": content.len(), + "success": true + }), + result_for_assistant: Some(format!("Successfully wrote to {}", resolved.logical_path)), + image_attachments: None, + }; + + Ok(vec![result]) + } + + async fn call_plaintext_followup_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let file_path = input + .get("file_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; + + let resolved = context.resolve_tool_path(file_path)?; + context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; + context + .record_light_checkpoint( + "Write", + &resolved.logical_path, + vec![resolved.logical_path.clone()], + ) + .await; + + let content = input + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; + + let file_already_exists = Self::file_exists(context, &resolved).await; + if file_already_exists + && Self::existing_file_matches_content(context, &resolved, content).await == Some(true) + { + let result = Self::write_success_result( + &resolved.logical_path, + 0, + "already_exists_same_content", + format!( + "Write skipped because {} already exists with identical content.", + resolved.logical_path + ), + ); + return Ok(vec![result]); + } + + Self::assert_atomic_write_freshness_if_exists(context, &resolved).await?; + + if resolved.uses_remote_workspace_backend() { + let ws_fs = context.ws_fs().ok_or_else(|| { + BitFunError::tool("Remote workspace file system is unavailable".to_string()) + })?; + ws_fs + .write_file(&resolved.resolved_path, content.as_bytes()) + .await + .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; + } else { + if let Some(parent) = Path::new(&resolved.resolved_path).parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| BitFunError::tool(format!("Failed to create directory: {}", e)))?; + } + fs::write(&resolved.resolved_path, content) + .await + .map_err(|e| { + BitFunError::tool(format!( + "Failed to write file {}: {}", + resolved.logical_path, e + )) + })?; + } + + let (status, assistant_message) = if file_already_exists { + ( + "overwritten", + format!( + "Successfully overwrote {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ) + } else { + ( + "created", + format!( + "Successfully created {} ({} bytes).", + resolved.logical_path, + content.len() + ), + ) + }; + + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation(context, &resolved, content, timestamp_ms); + + let result = Self::write_success_result( + &resolved.logical_path, + content.len(), + status, + assistant_message, + ); + + Ok(vec![result]) + } } #[cfg(test)] mod tests { - use super::FileWriteTool; + use super::{FileWriteTool, WRITE_TOOL_MODE_CONTEXT_KEY}; + use crate::agentic::tools::file_tool_guidance::FILE_TOOL_GUIDANCE_PREFIX; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::WorkspaceBinding; @@ -137,6 +461,25 @@ mod tests { } } + fn local_context_with_custom_data( + root: PathBuf, + custom_data: HashMap, + ) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, root)), + unlocked_collapsed_tools: Vec::new(), + custom_data, + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + fn context_with_custom_data(custom_data: HashMap) -> ToolUseContext { ToolUseContext { tool_call_id: None, @@ -153,27 +496,41 @@ mod tests { } } + #[test] + fn write_guidance_prefix_helpers_round_trip() { + let message = FileWriteTool::write_guidance_message("Use Read first."); + assert!(FileWriteTool::is_write_guidance_message(&message)); + assert_eq!( + message.strip_prefix(FILE_TOOL_GUIDANCE_PREFIX).unwrap(), + "Use Read first." + ); + } + #[tokio::test] - async fn validate_input_rejects_existing_file_before_content_generation() { + async fn preflight_write_error_allows_new_file_target() { let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); - let existing_file = root.join("existing.md"); - std::fs::write(&existing_file, "already here").expect("create existing file"); - let tool = FileWriteTool::new(); - let validation = tool - .validate_input( - &json!({ "file_path": "existing.md" }), - Some(&local_context(root.clone())), - ) + let error = FileWriteTool::preflight_write_error(&local_context(root.clone()), "new.txt") .await; let _ = std::fs::remove_dir_all(&root); - assert!(!validation.result); - let message = validation.message.unwrap_or_default(); - assert!(message.contains("already exists")); - assert!(message.contains("Edit tool")); + assert!(error.is_none()); + } + + #[tokio::test] + async fn preflight_write_error_allows_existing_file_without_read_state_tracking() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "already here").expect("create existing file"); + + let error = + FileWriteTool::preflight_write_error(&local_context(root.clone()), "existing.md").await; + + let _ = std::fs::remove_dir_all(&root); + + assert!(error.is_none()); } #[tokio::test] @@ -207,28 +564,33 @@ mod tests { assert!(result_for_assistant .as_deref() .unwrap_or_default() - .contains("do not call Write for this path again")); + .contains("identical content")); } #[tokio::test] - async fn call_impl_rejects_different_existing_content() { + async fn call_impl_overwrites_different_existing_content() { let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&root).expect("create temp workspace"); std::fs::write(root.join("existing.md"), "old content").expect("create existing file"); let tool = FileWriteTool::new(); - let error = tool + let results = tool .call( &json!({ "file_path": "existing.md", "content": "new content" }), &local_context(root.clone()), ) .await - .expect_err("different content must not overwrite existing files"); + .expect("plaintext followup should overwrite existing files"); + let written = std::fs::read_to_string(root.join("existing.md")).expect("read file"); let _ = std::fs::remove_dir_all(&root); - assert!(error.to_string().contains("already exists")); - assert!(error.to_string().contains("Edit tool")); + assert_eq!(written, "new content"); + + let ToolResult::Result { data, .. } = &results[0] else { + panic!("expected result"); + }; + assert_eq!(data["status"], "overwritten"); } #[tokio::test] @@ -264,6 +626,70 @@ mod tests { assert_eq!(schema["required"], serde_json::json!(["file_path"])); assert!(schema["properties"].get("content").is_none()); } + + #[tokio::test] + async fn inline_mode_schema_requires_content() { + let tool = FileWriteTool::new(); + let mut custom_data = HashMap::new(); + custom_data.insert( + WRITE_TOOL_MODE_CONTEXT_KEY.to_string(), + serde_json::Value::String("inline_content".to_string()), + ); + let context = context_with_custom_data(custom_data); + + let schema = tool + .input_schema_for_model_with_context(Some(&context)) + .await; + + assert_eq!( + schema["required"], + serde_json::json!(["file_path", "content"]) + ); + } + + #[tokio::test] + async fn inline_mode_requires_content_during_validation() { + let tool = FileWriteTool::new(); + let mut custom_data = HashMap::new(); + custom_data.insert( + WRITE_TOOL_MODE_CONTEXT_KEY.to_string(), + serde_json::Value::String("inline_content".to_string()), + ); + let context = context_with_custom_data(custom_data); + + let validation = tool + .validate_input(&json!({ "file_path": "new.txt" }), Some(&context)) + .await; + + assert!(!validation.result); + assert_eq!(validation.message.as_deref(), Some("content is required")); + } + + #[tokio::test] + async fn inline_mode_overwrites_existing_file() { + let root = std::env::temp_dir().join(format!("bitfun-write-test-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("create temp workspace"); + std::fs::write(root.join("existing.md"), "old content").expect("create existing file"); + + let mut custom_data = HashMap::new(); + custom_data.insert( + WRITE_TOOL_MODE_CONTEXT_KEY.to_string(), + serde_json::Value::String("inline_content".to_string()), + ); + + let tool = FileWriteTool::new(); + tool.call( + &json!({ "file_path": "existing.md", "content": "new content" }), + &local_context_with_custom_data(root.clone(), custom_data), + ) + .await + .expect("inline mode should overwrite existing files"); + + let written = std::fs::read_to_string(root.join("existing.md")).expect("read file"); + let _ = std::fs::remove_dir_all(&root); + + assert_eq!(written, "new content"); + } } #[async_trait] @@ -273,64 +699,31 @@ impl Tool for FileWriteTool { } async fn description(&self) -> BitFunResult { - Ok(r#"Writes a file to the local filesystem. - -Usage: -- This tool is for creating NEW files only. Calling Write on a path that already exists will be REJECTED with an error. -- To MODIFY an existing file, use the Edit tool — it is the correct choice in almost every case. -- To FULLY REWRITE an existing file (e.g. regenerate a generated file, replace a template), first call the Delete tool on that path, then call Write to create the new version. Do not try to "overwrite" via Write directly. -- After Write succeeds for a path, do not call Write for that path again in later rounds. Use Edit for any additional changes. -- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. -- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. -- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. -- Do NOT include the file content in the tool call arguments. Only provide file_path. The system will prompt you separately to output the file content as plain text."#.to_string()) + Ok(Self::plaintext_followup_description()) } fn short_description(&self) -> String { - "Write a new file.".to_string() + "Write or overwrite a file.".to_string() } async fn description_with_context( &self, context: Option<&ToolUseContext>, ) -> BitFunResult { - if Self::is_acp_context(context) { - return Ok(r#"Writes a file to the local filesystem. - -Usage: -- This tool is for creating NEW files only. Calling Write on a path that already exists will be REJECTED with an error unless the existing content is identical, in which case the retry is treated as already successful. -- To MODIFY an existing file, use the Edit tool. To fully rewrite an existing file, first call the Delete tool on that path, then call Write to create the new version. -- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. -- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. -- For new files, preserve correctness and provide the complete intended file content when this tool is appropriate. -- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. -- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. -- Include the complete file content in the `content` argument."#.to_string()); + match Self::write_tool_mode(context) { + WriteToolMode::InlineContent => Ok(Self::inline_description()), + WriteToolMode::PlaintextFollowup => Ok(Self::plaintext_followup_description()), } - - self.description().await } fn input_schema(&self) -> Value { - json!({ - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "The file to write. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun://runtime URI returned by another tool." - } - }, - "required": ["file_path"], - "additionalProperties": false - }) + Self::schema_without_content() } async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { - if Self::is_acp_context(context) { - Self::schema_with_content() - } else { - self.input_schema() + match Self::write_tool_mode(context) { + WriteToolMode::InlineContent => Self::schema_with_content(), + WriteToolMode::PlaintextFollowup => self.input_schema(), } } @@ -363,44 +756,63 @@ Usage: } }; - if let Some(ctx) = context { - let resolved = match ctx.resolve_tool_path(file_path) { - Ok(resolved) => resolved, - Err(err) => { - return ValidationResult { - result: false, - message: Some(err.to_string()), - error_code: Some(400), - meta: None, - }; - } + let mode = Self::write_tool_mode(context); + if matches!(mode, WriteToolMode::InlineContent) && input.get("content").is_none() { + return ValidationResult { + result: false, + message: Some("content is required".to_string()), + error_code: Some(400), + meta: None, }; + } + + let large_write_warning = if matches!(mode, WriteToolMode::InlineContent) { + input + .get("content") + .and_then(|v| v.as_str()) + .and_then(|content| { + let line_count = content.lines().count(); + let byte_count = content.len(); + if line_count > LARGE_WRITE_SOFT_LINE_LIMIT + || byte_count > LARGE_WRITE_SOFT_BYTE_LIMIT + { + Some((line_count, byte_count)) + } else { + None + } + }) + } else { + None + }; - if let Err(err) = ctx.enforce_path_operation(ToolPathOperation::Write, &resolved) { + if let Some(ctx) = context { + if let Some(message) = Self::preflight_write_error(ctx, file_path).await { + let is_guidance = Self::is_write_guidance_message(&message); return ValidationResult { result: false, - message: Some(err.to_string()), + message: Some(message), error_code: Some(400), - meta: None, + meta: is_guidance.then(|| json!({ "failure_kind": "guidance" })), }; } + } - // If content is absent, RoundExecutor would otherwise launch a - // second model request to generate the full file. Reject existing - // targets here so we do not spend tokens producing content that - // Write must reject anyway. If a model already supplied content - // despite the public schema, defer to call_impl so identical - // retries can be treated as idempotent success. - if input.get("content").is_none() { - if let Some(error) = Self::existing_file_error(ctx, &resolved).await { - return ValidationResult { - result: false, - message: Some(error), - error_code: Some(400), - meta: None, - }; - } - } + if let Some((line_count, byte_count)) = large_write_warning { + return ValidationResult { + result: true, + message: Some(format!( + "Large Write payload: {} lines, {} bytes. This is allowed when necessary, but prefer a staged approach: for existing files use Read + focused Edit calls; for large new files write a stable scaffold first, then add sections in follow-up edits unless a complete initial body is required.", + line_count, byte_count + )), + error_code: None, + meta: Some(json!({ + "large_write": true, + "line_count": line_count, + "byte_count": byte_count, + "soft_line_limit": LARGE_WRITE_SOFT_LINE_LIMIT, + "soft_byte_limit": LARGE_WRITE_SOFT_BYTE_LIMIT + })), + }; } ValidationResult::default() @@ -428,79 +840,11 @@ Usage: input: &Value, context: &ToolUseContext, ) -> BitFunResult> { - let file_path = input - .get("file_path") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("file_path is required".to_string()))?; - - let resolved = context.resolve_tool_path(file_path)?; - context.enforce_path_operation(ToolPathOperation::Write, &resolved)?; - context - .record_light_checkpoint( - "Write", - &resolved.logical_path, - vec![resolved.logical_path.clone()], - ) - .await; - - let content = input - .get("content") - .and_then(|v| v.as_str()) - .ok_or_else(|| BitFunError::tool("content is required".to_string()))?; - - if let Some(error) = Self::existing_file_error(context, &resolved).await { - if Self::existing_file_matches_content(context, &resolved, content).await == Some(true) - { - let result = Self::write_success_result( - &resolved.logical_path, - 0, - "already_exists_same_content", - format!( - "Write skipped because {} already exists with identical content. Treat this file as successfully created and do not call Write for this path again. Use Edit for any further changes.", - resolved.logical_path - ), - ); - return Ok(vec![result]); + match Self::write_tool_mode(Some(context)) { + WriteToolMode::InlineContent => self.call_inline_content_impl(input, context).await, + WriteToolMode::PlaintextFollowup => { + self.call_plaintext_followup_impl(input, context).await } - - return Err(BitFunError::tool(error)); } - - if resolved.uses_remote_workspace_backend() { - let ws_fs = context.ws_fs().ok_or_else(|| { - BitFunError::tool("Remote workspace file system is unavailable".to_string()) - })?; - ws_fs - .write_file(&resolved.resolved_path, content.as_bytes()) - .await - .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; - } else { - if let Some(parent) = Path::new(&resolved.resolved_path).parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| BitFunError::tool(format!("Failed to create directory: {}", e)))?; - } - fs::write(&resolved.resolved_path, content) - .await - .map_err(|e| { - BitFunError::tool(format!( - "Failed to write file {}: {}", - resolved.logical_path, e - )) - })?; - } - - let result = Self::write_success_result( - &resolved.logical_path, - content.len(), - "created", - format!( - "Successfully created {} ({} bytes). The file now exists; do not call Write for this path again. Use Edit for any further changes.", - resolved.logical_path, - content.len() - ), - ); - - Ok(vec![result]) } } diff --git a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs index d3d27818ba..211e94a3bb 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_tool_spec_tool.rs @@ -1,17 +1,28 @@ //! GetToolSpec tool implementation -use crate::agentic::tools::catalog_provider::{ - build_product_get_tool_spec_catalog_description, product_get_tool_spec_runtime, - resolve_product_get_tool_spec_results, ProductGetToolSpecRuntime, ProductToolCatalogProvider, -}; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::agentic::tools::product_runtime::{ + product_get_tool_spec_runtime, resolve_product_get_tool_spec_results, + ProductGetToolSpecRuntime, ProductToolCatalogProvider, +}; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use bitfun_agent_tools::{GetToolSpecExecutionError, GET_TOOL_SPEC_TOOL_NAME}; +use bitfun_agent_tools::{ + build_get_tool_spec_collapsed_tool_entry, GetToolSpecCollapsedToolSummary, + GetToolSpecExecutionError, GET_TOOL_SPEC_TOOL_NAME, +}; use serde_json::Value; +const GET_TOOL_SPEC_DESCRIPTION: &str = r#"Read usage instructions for additional tools. + +Some tools are collapsed: their names may appear in the tool list, but you must not call them directly until you have loaded their definition with GetToolSpec. + +When the current request context includes a section, use the exact tool names from that section. Before using one of those tools, first call GetToolSpec with its exact tool name to read its full description and input schema. After reading the returned definition, call the real tool directly using its own name. + +Do not call GetToolSpec again for a tool whose definition is already loaded in the current conversation."#; + pub struct GetToolSpecTool; impl GetToolSpecTool { @@ -19,8 +30,25 @@ impl GetToolSpecTool { Self } - async fn build_collapsed_tools_description(&self, context: Option<&ToolUseContext>) -> String { - build_product_get_tool_spec_catalog_description(context).await + pub(crate) fn build_collapsed_tools_context_section( + collapsed_tools: &[GetToolSpecCollapsedToolSummary], + ) -> Option { + if collapsed_tools.is_empty() { + return None; + } + + let collapsed_tools_list = collapsed_tools + .iter() + .map(|tool| { + build_get_tool_spec_collapsed_tool_entry(&tool.name, &tool.short_description) + }) + .collect::>() + .join("\n"); + + Some(format!( + "\n{}\n", + collapsed_tools_list + )) } } @@ -42,7 +70,7 @@ impl Tool for GetToolSpecTool { } async fn description(&self) -> BitFunResult { - Ok(self.build_collapsed_tools_description(None).await) + Ok(GET_TOOL_SPEC_DESCRIPTION.to_string()) } fn short_description(&self) -> String { @@ -51,9 +79,9 @@ impl Tool for GetToolSpecTool { async fn description_with_context( &self, - context: Option<&ToolUseContext>, + _context: Option<&ToolUseContext>, ) -> BitFunResult { - Ok(self.build_collapsed_tools_description(context).await) + Ok(GET_TOOL_SPEC_DESCRIPTION.to_string()) } fn input_schema(&self) -> Value { @@ -105,74 +133,21 @@ fn map_get_tool_spec_execution_error(error: GetToolSpecExecutionError) -> BitFun #[cfg(test)] mod tests { use super::GetToolSpecTool; - use crate::agentic::tools::framework::{ - Tool, ToolExposure, ToolResult, ToolUseContext, ValidationResult, - }; - use crate::agentic::tools::registry::get_global_tool_registry; + use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::ToolRuntimeRestrictions; - use crate::util::errors::BitFunResult; - use async_trait::async_trait; - use serde_json::{json, Value}; + use serde_json::json; use std::collections::HashMap; - use std::sync::Arc; - - struct CatalogDescriptionTestTool { - name: String, - } - - #[async_trait] - impl Tool for CatalogDescriptionTestTool { - fn name(&self) -> &str { - &self.name - } - - async fn description(&self) -> BitFunResult { - Ok("Verbose description first line.\nSecond line.".to_string()) - } - - fn short_description(&self) -> String { - "Concise catalog entry.".to_string() - } - - fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed - } - - fn input_schema(&self) -> Value { - json!({ "type": "object" }) - } - - async fn validate_input( - &self, - _input: &Value, - _context: Option<&ToolUseContext>, - ) -> ValidationResult { - ValidationResult::default() - } - - async fn call_impl( - &self, - _input: &Value, - _context: &ToolUseContext, - ) -> BitFunResult> { - Ok(Vec::new()) - } - } - #[tokio::test] - async fn get_tool_spec_uses_explicit_short_description() { + #[test] + fn collapsed_tools_context_uses_explicit_short_description() { let tool_name = format!("CatalogDescriptionTestTool_{}", uuid::Uuid::new_v4()); - let registry = get_global_tool_registry(); - { - let mut registry = registry.write().await; - registry.register_tool(Arc::new(CatalogDescriptionTestTool { + let description = GetToolSpecTool::build_collapsed_tools_context_section(&[ + bitfun_agent_tools::GetToolSpecCollapsedToolSummary { name: tool_name.clone(), - })); - } - - let description = GetToolSpecTool::new() - .build_collapsed_tools_description(None) - .await; + short_description: "Concise catalog entry.".to_string(), + }, + ]) + .expect("collapsed tools section"); assert!(description.contains(&format!("- {}: Concise catalog entry.", tool_name))); assert!(!description.contains(&format!("- {}: Verbose description first line.", tool_name))); diff --git a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs index 20e87ab9d1..ee7bd9b3cb 100644 --- a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -1,6 +1,6 @@ //! InitMiniApp tool — create a new MiniApp skeleton; AI then uses generic file tools to edit. -use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::infrastructure::events::{emit_global_event, BackendEvent}; use crate::miniapp::try_get_global_miniapp_manager; use crate::miniapp::types::{ @@ -56,6 +56,18 @@ impl Default for InitMiniAppTool { } } +#[cfg(test)] +mod tests { + use super::InitMiniAppTool; + use crate::agentic::tools::framework::{Tool, ToolExposure}; + + #[test] + fn init_miniapp_stays_expanded_for_assistant_creation() { + let tool = InitMiniAppTool::new(); + assert_eq!(tool.default_exposure(), ToolExposure::Expanded); + } +} + #[async_trait] impl Tool for InitMiniAppTool { fn name(&self) -> &str { @@ -77,10 +89,6 @@ Returns app_id and the app root directory. Use the root directory and file names "Create a new MiniApp skeleton in the Toolbox.".to_string() } - fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed - } - fn input_schema(&self) -> Value { json!({ "type": "object", @@ -196,6 +204,7 @@ Returns app_id and the app root directory. Use the root directory and file names "style": source_dir.join("style.css").to_string_lossy(), "html": source_dir.join("index.html").to_string_lossy(), "package": app_dir.join("package.json").to_string_lossy(), + "storage": app_dir.join("storage.json").to_string_lossy(), }); let _ = emit_global_event(BackendEvent::Custom { diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index 77aec9889e..1d3ce8a201 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -32,6 +32,7 @@ pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; pub mod playbook_tool; +pub mod review_platform_tool; pub mod session_control_tool; pub mod session_history_tool; pub mod session_message_tool; @@ -70,6 +71,7 @@ pub use mcp_tools::{ }; pub use miniapp_init_tool::InitMiniAppTool; pub use playbook_tool::PlaybookTool; +pub use review_platform_tool::ReviewPlatformTool; pub use session_control_tool::SessionControlTool; pub use session_history_tool::SessionHistoryTool; pub use session_message_tool::SessionMessageTool; diff --git a/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs new file mode 100644 index 0000000000..60211d1070 --- /dev/null +++ b/src/crates/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -0,0 +1,1256 @@ +//! Pull request / review platform tool. +//! +//! This tool exposes hosted review-platform operations to the agent while +//! keeping provider-specific HTTP behavior inside `ReviewPlatformService`. + +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service::review_platform::{ + ReviewPlatformApprovalRequest, ReviewPlatformCreatePullRequestRequest, + ReviewPlatformDetailSection, ReviewPlatformError, ReviewPlatformKind, ReviewPlatformRemote, + ReviewPlatformReplyToThreadRequest, ReviewPlatformRequestChangesRequest, + ReviewPlatformResolveThreadRequest, ReviewPlatformService, ReviewPlatformSubmitReviewRequest, + ReviewSubmitEvent, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +const ACTION_WORKSPACE_SNAPSHOT: &str = "get_workspace_snapshot"; +const ACTION_LIST_REMOTES: &str = "list_remotes"; +const ACTION_LIST: &str = "list_pull_requests"; +const ACTION_COUNT: &str = "count_pull_requests"; +const ACTION_GET: &str = "get_pull_request"; +const ACTION_GET_DETAIL_PAGE: &str = "get_pull_request_detail_page"; +const ACTION_GET_CI_LOG: &str = "get_pull_request_ci_log"; +const ACTION_CREATE: &str = "create_pull_request"; +const ACTION_REPLY: &str = "reply_to_thread"; +const ACTION_SUBMIT_REVIEW: &str = "submit_review"; +const ACTION_APPROVE: &str = "approve_pull_request"; +const ACTION_REVOKE_APPROVAL: &str = "revoke_approval"; +const ACTION_REQUEST_CHANGES: &str = "request_changes"; +const ACTION_RESOLVE: &str = "resolve_thread"; +const ACTION_UPDATE_AUTH_TOKEN: &str = "update_auth_token"; +const ACTION_CLEAR_AUTH_TOKEN: &str = "clear_auth_token"; + +const WRITE_ACTIONS: &[&str] = &[ + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN, +]; + +pub struct ReviewPlatformTool; + +impl ReviewPlatformTool { + pub fn new() -> Self { + Self + } + + fn repository_path(input: &Value, context: &ToolUseContext) -> BitFunResult { + let requested = input + .get("repository_path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let Some(path) = requested { + return context.resolve_workspace_tool_path(path); + } + + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path_string()) + .ok_or_else(|| BitFunError::tool("repository_path is required".to_string())) + } + + fn string_field(input: &Value, key: &str) -> BitFunResult { + input + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| BitFunError::tool(format!("{} is required", key))) + } + + fn optional_string_field(input: &Value, key: &str) -> Option { + input + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + } + + fn submit_event(input: &Value) -> BitFunResult { + match input + .get("event") + .and_then(Value::as_str) + .unwrap_or("comment") + { + "comment" => Ok(ReviewSubmitEvent::Comment), + "approve" => Ok(ReviewSubmitEvent::Approve), + "request_changes" => Ok(ReviewSubmitEvent::RequestChanges), + other => Err(BitFunError::tool(format!( + "Unsupported review event: {}", + other + ))), + } + } + + fn detail_section(input: &Value) -> BitFunResult { + match input + .get("section") + .and_then(Value::as_str) + .unwrap_or("overview") + { + "overview" => Ok(ReviewPlatformDetailSection::Overview), + "ci" => Ok(ReviewPlatformDetailSection::Ci), + "files" => Ok(ReviewPlatformDetailSection::Files), + "commits" => Ok(ReviewPlatformDetailSection::Commits), + "reviews" => Ok(ReviewPlatformDetailSection::Reviews), + other => Err(BitFunError::tool(format!( + "Unsupported pull request detail section: {}", + other + ))), + } + } + + fn platform_kind(input: &Value) -> BitFunResult { + match Self::string_field(input, "platform")?.as_str() { + "github" => Ok(ReviewPlatformKind::Github), + "gitlab" => Ok(ReviewPlatformKind::Gitlab), + "gitcode" => Ok(ReviewPlatformKind::Gitcode), + "unknown" => Ok(ReviewPlatformKind::Unknown), + other => Err(BitFunError::tool(format!( + "Unsupported review platform kind: {}", + other + ))), + } + } + + async fn resolve_remote_id(repository_path: &str, input: &Value) -> BitFunResult { + if let Some(remote_id) = Self::optional_string_field(input, "remote_id") { + return Ok(remote_id); + } + + let remotes = ReviewPlatformService::discover_remotes(repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let supported = supported_remotes(&remotes); + match supported.as_slice() { + [] => Err(BitFunError::tool( + "No supported review platform remote found".to_string(), + )), + [remote] => Ok(remote.id.clone()), + _ => Err(BitFunError::tool(remote_ambiguity_message(&supported))), + } + } + + async fn resolve_remote_id_for_list( + repository_path: &str, + input: &Value, + ) -> BitFunResult> { + if let Some(remote_id) = Self::optional_string_field(input, "remote_id") { + return Ok(Ok(remote_id)); + } + + let remotes = ReviewPlatformService::discover_remotes(repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let supported = supported_remotes(&remotes); + match supported.as_slice() { + [] => Err(BitFunError::tool( + "No supported review platform remote found".to_string(), + )), + [remote] => Ok(Ok(remote.id.clone())), + _ => Ok(Err(json!({ + "action": ACTION_LIST, + "repositoryPath": repository_path, + "status": "needs_remote_selection", + "message": "Multiple supported review platform remotes were found. Provide remote_id explicitly.", + "candidateRemotes": supported, + }))), + } + } + + fn action(input: &Value) -> Option<&str> { + input.get("action").and_then(Value::as_str) + } + + fn auth_required_result( + action: &str, + repository_path: &str, + remote_id: &str, + error: &ReviewPlatformError, + ) -> Option { + let status = match error { + ReviewPlatformError::Http { status, .. } if *status == 401 || *status == 403 => *status, + _ => return None, + }; + let state = if status == 403 { + "insufficient_scope" + } else { + "invalid" + }; + Some(json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": { + "state": state, + "message": if status == 403 { + "Review platform token is missing required permissions. Update the token in the pull request panel, then retry." + } else { + "Review platform authentication is required or the configured token was rejected. Add or update the token in the pull request panel, then retry." + }, + }, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + })) + } + + fn render_action_result(output: &Value) -> Option { + let result = output.get("result")?; + let message = result + .get("message") + .and_then(Value::as_str) + .unwrap_or("Review platform action completed"); + let web_url = result.get("webUrl").and_then(Value::as_str); + let pr = result.get("pullRequest"); + + let mut lines = vec![message.to_string()]; + if let Some(pr) = pr { + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Pull request"); + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let url = pr.get("webUrl").and_then(Value::as_str).or(web_url); + if let Some(url) = url { + lines.push(format!("[#{} {}]({})", number, title, url)); + } + } else if let Some(url) = web_url { + lines.push(url.to_string()); + } + Some(lines.join("\n")) + } +} + +#[async_trait] +impl Tool for ReviewPlatformTool { + fn name(&self) -> &str { + "ReviewPlatform" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Read and operate on hosted pull requests / merge requests. + +Use this for remote review-platform operations such as discovering remotes, loading the workspace PR snapshot, counting pull requests, listing pull requests, opening full or paginated pull request detail, loading CI logs, creating a pull request, replying to review threads, submitting a comment review, approving, revoking approval, requesting changes, or resolving a review thread. Use the Git tool for local repository state and branch/commit/push operations. + +Authentication-token actions are available only when the user explicitly provides a token or asks to clear a stored token. Never guess or expose token values. + +When returning pull request results to the user, include the provider web URL so the chat UI can open the pull request detail panel naturally."#.to_string()) + } + + fn short_description(&self) -> String { + "Inspect and operate on hosted pull requests / merge requests.".to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Collapsed + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + ACTION_WORKSPACE_SNAPSHOT, + ACTION_LIST_REMOTES, + ACTION_LIST, + ACTION_COUNT, + ACTION_GET, + ACTION_GET_DETAIL_PAGE, + ACTION_GET_CI_LOG, + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN + ], + "description": "Review platform action to perform." + }, + "repository_path": { + "type": "string", + "description": "Repository path. Omit to use the current workspace." + }, + "remote_id": { + "type": "string", + "description": "Review platform remote id. Omit to use the only supported remote; provide it explicitly when the repository has multiple supported review-platform remotes." + }, + "pull_request_id": { + "type": "string", + "description": "Pull request or merge request number/id." + }, + "page": { + "type": "integer", + "description": "Page number for list_pull_requests, get_workspace_snapshot, or get_pull_request_detail_page." + }, + "per_page": { + "type": "integer", + "description": "Page size for list_pull_requests, get_workspace_snapshot, or get_pull_request_detail_page." + }, + "section": { + "type": "string", + "enum": ["overview", "ci", "files", "commits", "reviews"], + "description": "Detail section for get_pull_request_detail_page." + }, + "ci_item_id": { + "type": "string", + "description": "CI item id for get_pull_request_ci_log." + }, + "ci_item_name": { + "type": "string", + "description": "CI item display name for get_pull_request_ci_log; used by providers that need a job name fallback." + }, + "platform": { + "type": "string", + "enum": ["github", "gitlab", "gitcode", "unknown"], + "description": "Review platform kind for update_auth_token or clear_auth_token." + }, + "host": { + "type": "string", + "description": "Review platform host for update_auth_token or clear_auth_token." + }, + "token": { + "type": "string", + "description": "Personal access token for update_auth_token. Only provide this when the user explicitly asks to store that token." + }, + "title": { + "type": "string", + "description": "Pull request title for create_pull_request." + }, + "source_branch": { + "type": "string", + "description": "Source/head branch for create_pull_request." + }, + "target_branch": { + "type": "string", + "description": "Target/base branch for create_pull_request." + }, + "body": { + "type": "string", + "description": "Pull request body, review body, or comment body depending on action." + }, + "draft": { + "type": "boolean", + "description": "Create a draft pull request when the provider supports it." + }, + "thread_id": { + "type": "string", + "description": "Thread id returned by get_pull_request for reply_to_thread or resolve_thread." + }, + "event": { + "type": "string", + "enum": ["comment", "approve", "request_changes"], + "description": "Review event for submit_review." + }, + "resolved": { + "type": "boolean", + "description": "Whether resolve_thread should mark the thread resolved or reopened." + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, input: Option<&Value>) -> bool { + input + .and_then(Self::action) + .is_some_and(|action| !WRITE_ACTIONS.contains(&action)) + } + + fn needs_permissions(&self, input: Option<&Value>) -> bool { + input + .and_then(Self::action) + .map(|action| WRITE_ACTIONS.contains(&action)) + .unwrap_or(true) + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + let Some(action) = Self::action(input) else { + return ValidationResult { + result: false, + message: Some("action is required".to_string()), + error_code: Some(400), + meta: None, + }; + }; + let valid = [ + ACTION_WORKSPACE_SNAPSHOT, + ACTION_LIST_REMOTES, + ACTION_LIST, + ACTION_COUNT, + ACTION_GET, + ACTION_GET_DETAIL_PAGE, + ACTION_GET_CI_LOG, + ACTION_CREATE, + ACTION_REPLY, + ACTION_SUBMIT_REVIEW, + ACTION_APPROVE, + ACTION_REVOKE_APPROVAL, + ACTION_REQUEST_CHANGES, + ACTION_RESOLVE, + ACTION_UPDATE_AUTH_TOKEN, + ACTION_CLEAR_AUTH_TOKEN, + ]; + if !valid.contains(&action) { + return ValidationResult { + result: false, + message: Some(format!("Unsupported ReviewPlatform action: {}", action)), + error_code: Some(400), + meta: None, + }; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = Self::action(input).unwrap_or("unknown"); + match action { + ACTION_WORKSPACE_SNAPSHOT => "Load review platform workspace snapshot".to_string(), + ACTION_LIST_REMOTES => "List review platform remotes".to_string(), + ACTION_LIST => "List pull requests".to_string(), + ACTION_COUNT => "Count pull requests".to_string(), + ACTION_GET => format!( + "Open pull request {}", + input + .get("pull_request_id") + .and_then(Value::as_str) + .unwrap_or("detail") + ), + ACTION_GET_DETAIL_PAGE => "Load pull request detail page".to_string(), + ACTION_GET_CI_LOG => "Load pull request CI log".to_string(), + ACTION_CREATE => "Create pull request".to_string(), + ACTION_REPLY => "Reply to pull request thread".to_string(), + ACTION_SUBMIT_REVIEW => "Submit pull request review".to_string(), + ACTION_APPROVE => "Approve pull request".to_string(), + ACTION_REVOKE_APPROVAL => "Revoke pull request approval".to_string(), + ACTION_REQUEST_CHANGES => "Request pull request changes".to_string(), + ACTION_RESOLVE => "Resolve pull request thread".to_string(), + ACTION_UPDATE_AUTH_TOKEN => "Update review platform auth token".to_string(), + ACTION_CLEAR_AUTH_TOKEN => "Clear review platform auth token".to_string(), + _ => format!("Review platform action: {}", action), + } + } + + fn render_result_for_assistant(&self, output: &Value) -> String { + let action = output.get("action").and_then(Value::as_str).unwrap_or(""); + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_auth") + { + let message = output + .pointer("/authChallenge/message") + .and_then(Value::as_str) + .unwrap_or("Review platform authentication is required."); + return format!("{} Ask the user to configure the token in the pull request panel, then retry this action.", message); + } + if let Some(action_result) = Self::render_action_result(output) { + return action_result; + } + + match action { + ACTION_LIST_REMOTES => { + let remotes = output + .get("remotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![format!("Found {} review platform remotes.", remotes.len())]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + lines.join("\n") + } + ACTION_WORKSPACE_SNAPSHOT => { + let snapshot = output.get("snapshot"); + let Some(snapshot) = snapshot else { + return "Review platform workspace snapshot loaded.".to_string(); + }; + let remotes = snapshot + .get("remotes") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let prs = snapshot + .get("pullRequests") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let selected = snapshot + .get("selectedRemoteId") + .and_then(Value::as_str) + .unwrap_or("none"); + let message = snapshot.get("message").and_then(Value::as_str); + match message { + Some(message) if !message.is_empty() => format!( + "Loaded review platform snapshot: selected remote {}, {} remotes, {} pull requests. {}", + selected, remotes, prs, message + ), + _ => format!( + "Loaded review platform snapshot: selected remote {}, {} remotes, {} pull requests.", + selected, remotes, prs + ), + } + } + ACTION_COUNT => { + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_remote_selection") + { + let remotes = output + .get("candidateRemotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![ + "Multiple review platform remotes were found. Ask the user which remote to use, then retry with remote_id.".to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + return lines.join("\n"); + } + + let remote_id = output.get("remoteId").and_then(Value::as_str).unwrap_or(""); + let total = output.get("total").and_then(Value::as_u64); + match total { + Some(total) => format!("Remote {} has {} pull requests.", remote_id, total), + None => format!( + "Remote {} did not return an exact pull request count.", + remote_id + ), + } + } + ACTION_LIST => { + if output + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| status == "needs_remote_selection") + { + let remotes = output + .get("candidateRemotes") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let mut lines = vec![ + "Multiple review platform remotes were found. Ask the user which remote to use, then retry with remote_id.".to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + let id = remote.get("id").and_then(Value::as_str).unwrap_or(""); + let name = remote.get("name").and_then(Value::as_str).unwrap_or(""); + let platform = remote.get("platform").and_then(Value::as_str).unwrap_or(""); + let project = remote + .get("projectPath") + .and_then(Value::as_str) + .unwrap_or(""); + let url = remote.get("webUrl").and_then(Value::as_str).unwrap_or(""); + format!( + "- remote_id: {} | name: {} | platform: {} | project: {} | url: {}", + id, name, platform, project, url + ) + })); + return lines.join("\n"); + } + + let prs = output + .pointer("/snapshot/pullRequests") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + let pagination = output + .get("snapshot") + .and_then(|snapshot| snapshot.get("pagination")); + let page = pagination + .and_then(|value| value.get("page")) + .and_then(Value::as_u64) + .unwrap_or(1); + let per_page = pagination + .and_then(|value| value.get("perPage")) + .and_then(Value::as_u64) + .unwrap_or(prs.len() as u64); + let total = pagination + .and_then(|value| value.get("total")) + .and_then(Value::as_u64); + let has_next = pagination + .and_then(|value| value.get("hasNext")) + .and_then(Value::as_bool) + .unwrap_or(false); + let remote_id = output.get("remoteId").and_then(Value::as_str).unwrap_or(""); + + let mut lines = vec![match total { + Some(total) => format!( + "Remote {} has {} pull requests. Showing {} from page {} (page size {}).", + remote_id, + total, + prs.len(), + page, + per_page + ), + None => format!( + "Remote {} returned {} pull requests on page {} (page size {}).{}", + remote_id, + prs.len(), + page, + per_page, + if has_next { + " More pages are available; this is not the total count." + } else { + "" + } + ), + }]; + if prs.is_empty() { + return lines.join("\n"); + } + lines.extend(prs.iter().take(10).map(|pr| { + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let state = pr.get("state").and_then(Value::as_str).unwrap_or("unknown"); + let url = pr.get("webUrl").and_then(Value::as_str).unwrap_or(""); + if url.is_empty() { + format!("#{} {} ({})", number, title, state) + } else { + format!("[#{} {}]({}) ({})", number, title, url, state) + } + })); + lines.join("\n") + } + ACTION_GET => { + let pr = output.get("pullRequest"); + let Some(pr) = pr else { + return "Pull request detail loaded.".to_string(); + }; + let number = pr.get("number").and_then(Value::as_i64).unwrap_or_default(); + let title = pr + .get("title") + .and_then(Value::as_str) + .unwrap_or("Untitled"); + let url = pr.get("webUrl").and_then(Value::as_str).unwrap_or(""); + let files = output + .get("files") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let threads = output + .get("threads") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + if url.is_empty() { + format!( + "Loaded PR #{} {} ({} files, {} threads)", + number, title, files, threads + ) + } else { + format!( + "Loaded [#{} {}]({}) ({} files, {} threads)", + number, title, url, files, threads + ) + } + } + ACTION_GET_DETAIL_PAGE => { + let section = output + .get("section") + .and_then(Value::as_str) + .unwrap_or("detail"); + let items = output + .get("items") + .and_then(Value::as_array) + .map(|items| items.len()) + .unwrap_or(0); + let pagination = output.get("pagination"); + let page = pagination + .and_then(|value| value.get("page")) + .and_then(Value::as_u64) + .unwrap_or(1); + let has_next = pagination + .and_then(|value| value.get("hasNext")) + .and_then(Value::as_bool) + .unwrap_or(false); + format!( + "Loaded pull request {} page {} with {} items.{}", + section, + page, + items, + if has_next { + " More pages are available." + } else { + "" + } + ) + } + ACTION_GET_CI_LOG => { + let ci_item_id = output.get("ciItemId").and_then(Value::as_str).unwrap_or(""); + let truncated = output + .get("truncated") + .and_then(Value::as_bool) + .unwrap_or(false); + let log_chars = output + .get("log") + .and_then(Value::as_str) + .map(str::len) + .unwrap_or(0); + format!( + "Loaded CI log for {} ({} characters).{}", + ci_item_id, + log_chars, + if truncated { + " The log was truncated." + } else { + "" + } + ) + } + ACTION_UPDATE_AUTH_TOKEN => "Review platform auth token updated.".to_string(), + ACTION_CLEAR_AUTH_TOKEN => "Review platform auth token cleared.".to_string(), + _ => "Review platform action completed.".to_string(), + } + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let action = Self::string_field(input, "action")?; + let repository_path = match action.as_str() { + ACTION_UPDATE_AUTH_TOKEN | ACTION_CLEAR_AUTH_TOKEN => { + Self::optional_string_field(input, "repository_path") + .map(|path| context.resolve_workspace_tool_path(&path)) + .transpose()? + .or_else(|| { + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path_string()) + }) + .unwrap_or_default() + } + _ => Self::repository_path(input, context)?, + }; + + let data = match action.as_str() { + ACTION_LIST_REMOTES => { + let remotes = ReviewPlatformService::discover_remotes(&repository_path) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "remotes": remotes, + }) + } + ACTION_WORKSPACE_SNAPSHOT => { + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let remote_id = Self::optional_string_field(input, "remote_id"); + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + remote_id.as_deref(), + page, + per_page, + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + let status = if snapshot.auth_challenge.is_some() { + "needs_auth" + } else { + "ok" + }; + let auth_challenge = snapshot.auth_challenge.clone(); + let selected_remote_id = snapshot.selected_remote_id.clone(); + let panel_remote_id = selected_remote_id.clone(); + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": selected_remote_id, + "status": status, + "authChallenge": auth_challenge, + "snapshot": snapshot, + "openPanel": if status == "needs_auth" { + json!({ + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": panel_remote_id, + }) + } else { + Value::Null + }, + }) + } + ACTION_COUNT => { + let remote_id = + match Self::resolve_remote_id_for_list(&repository_path, input).await? { + Ok(remote_id) => remote_id, + Err(mut selection_result) => { + if let Some(obj) = selection_result.as_object_mut() { + obj.insert("action".to_string(), json!(ACTION_COUNT)); + } + let result_for_assistant = + self.render_result_for_assistant(&selection_result); + return Ok(vec![ToolResult::Result { + data: selection_result, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + }; + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + Some(remote_id.as_str()), + Some(1), + Some(1), + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + if snapshot.auth_challenge.is_some() { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": snapshot.auth_challenge, + "snapshot": snapshot, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + }) + } else { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "total": snapshot.pagination.total, + "hasNext": snapshot.pagination.has_next, + }) + } + } + ACTION_LIST => { + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let remote_id = + match Self::resolve_remote_id_for_list(&repository_path, input).await? { + Ok(remote_id) => remote_id, + Err(selection_result) => { + let result_for_assistant = + self.render_result_for_assistant(&selection_result); + return Ok(vec![ToolResult::Result { + data: selection_result, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]); + } + }; + let snapshot = ReviewPlatformService::workspace_snapshot( + &repository_path, + Some(remote_id.as_str()), + page, + per_page, + ) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + if snapshot.auth_challenge.is_some() { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "status": "needs_auth", + "authChallenge": snapshot.auth_challenge, + "snapshot": snapshot, + "openPanel": { + "type": "review-platform-auth", + "workspacePath": repository_path, + "remoteId": remote_id, + }, + }) + } else { + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "snapshot": snapshot, + }) + } + } + ACTION_GET => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + match ReviewPlatformService::pull_request_detail( + &repository_path, + &remote_id, + &pull_request_id, + ) + .await + { + Ok(detail) => json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequest": detail.pull_request, + "body": detail.body, + "ci": detail.ci, + "files": detail.files, + "commits": detail.commits, + "threads": detail.threads, + }), + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_GET_DETAIL_PAGE => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let section = Self::detail_section(input)?; + let page = input + .get("page") + .and_then(Value::as_u64) + .map(|value| value as u32); + let per_page = input + .get("per_page") + .and_then(Value::as_u64) + .map(|value| value as u32); + match ReviewPlatformService::pull_request_detail_page( + &repository_path, + &remote_id, + &pull_request_id, + section, + page, + per_page, + ) + .await + { + Ok(detail) => { + let items = match section { + ReviewPlatformDetailSection::Overview => json!([]), + ReviewPlatformDetailSection::Ci => json!(detail.ci), + ReviewPlatformDetailSection::Files => json!(detail.files), + ReviewPlatformDetailSection::Commits => json!(detail.commits), + ReviewPlatformDetailSection::Reviews => json!(detail.threads), + }; + json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequest": detail.pull_request, + "body": detail.body, + "section": detail.section, + "pagination": detail.pagination, + "items": items, + "detailPage": detail, + }) + } + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_GET_CI_LOG => { + let pull_request_id = Self::string_field(input, "pull_request_id")?; + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let ci_item_id = Self::string_field(input, "ci_item_id")?; + let ci_item_name = Self::string_field(input, "ci_item_name")?; + match ReviewPlatformService::pull_request_ci_log( + &repository_path, + &remote_id, + &pull_request_id, + &ci_item_id, + &ci_item_name, + ) + .await + { + Ok(ci_log) => json!({ + "action": action, + "repositoryPath": repository_path, + "remoteId": remote_id, + "pullRequestId": pull_request_id, + "ciItemId": ci_log.ci_item_id, + "log": ci_log.log, + "truncated": ci_log.truncated, + "message": ci_log.message, + }), + Err(error) => { + if let Some(result) = Self::auth_required_result( + &action, + &repository_path, + &remote_id, + &error, + ) { + result + } else { + return Err(BitFunError::tool(error.to_string())); + } + } + } + } + ACTION_CREATE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformCreatePullRequestRequest { + repository_path: repository_path.clone(), + remote_id: Some(remote_id), + title: Self::string_field(input, "title")?, + source_branch: Self::string_field(input, "source_branch")?, + target_branch: Self::string_field(input, "target_branch")?, + body: Self::optional_string_field(input, "body"), + draft: input.get("draft").and_then(Value::as_bool), + }; + let result = ReviewPlatformService::create_pull_request(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REPLY => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformReplyToThreadRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + thread_id: Self::string_field(input, "thread_id")?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::reply_to_thread(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_SUBMIT_REVIEW => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformSubmitReviewRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + event: Self::submit_event(input)?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::submit_review(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_APPROVE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformApprovalRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: Self::optional_string_field(input, "body"), + }; + let result = ReviewPlatformService::approve_pull_request(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REVOKE_APPROVAL => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformApprovalRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: None, + }; + let result = ReviewPlatformService::revoke_approval(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_REQUEST_CHANGES => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformRequestChangesRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + body: Self::string_field(input, "body")?, + }; + let result = ReviewPlatformService::request_changes(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_RESOLVE => { + let remote_id = Self::resolve_remote_id(&repository_path, input).await?; + let request = ReviewPlatformResolveThreadRequest { + repository_path: repository_path.clone(), + remote_id, + pull_request_id: Self::string_field(input, "pull_request_id")?, + thread_id: Self::string_field(input, "thread_id")?, + resolved: input + .get("resolved") + .and_then(Value::as_bool) + .unwrap_or(true), + }; + let result = ReviewPlatformService::resolve_thread(request) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ "action": action, "result": result }) + } + ACTION_UPDATE_AUTH_TOKEN => { + let platform = Self::platform_kind(input)?; + let host = Self::string_field(input, "host")?; + let token = Self::string_field(input, "token")?; + ReviewPlatformService::update_auth_token(platform, &host, &token) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "platform": platform, + "host": host, + "status": "ok", + }) + } + ACTION_CLEAR_AUTH_TOKEN => { + let platform = Self::platform_kind(input)?; + let host = Self::string_field(input, "host")?; + ReviewPlatformService::clear_auth_token(platform, &host) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + json!({ + "action": action, + "repositoryPath": repository_path, + "platform": platform, + "host": host, + "status": "ok", + }) + } + _ => return Err(BitFunError::tool(format!("Unsupported action: {}", action))), + }; + + let result_for_assistant = self.render_result_for_assistant(&data); + Ok(vec![ToolResult::Result { + data, + result_for_assistant: Some(result_for_assistant), + image_attachments: None, + }]) + } +} + +impl Default for ReviewPlatformTool { + fn default() -> Self { + Self::new() + } +} + +fn supported_remotes(remotes: &[ReviewPlatformRemote]) -> Vec<&ReviewPlatformRemote> { + remotes.iter().filter(|remote| remote.supported).collect() +} + +fn remote_ambiguity_message(remotes: &[&ReviewPlatformRemote]) -> String { + let mut lines = vec![ + "Multiple supported review platform remotes were found. Provide remote_id explicitly." + .to_string(), + "Candidate remotes:".to_string(), + ]; + lines.extend(remotes.iter().map(|remote| { + format!( + "- remote_id: {} | name: {} | platform: {:?} | project: {} | url: {}", + remote.id, remote.name, remote.platform, remote.project_path, remote.web_url + ) + })); + lines.join("\n") +} diff --git a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs index b106b1ef0d..65f81801aa 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_control_tool.rs @@ -423,18 +423,16 @@ Arguments: // Ignore workspace for cancel/delete so callers cannot accidentally // scope these actions to the wrong workspace. return match parsed.action { - SessionControlAction::Cancel => self - .validate_mutating_action_target( - SessionControlAction::Cancel, - &parsed, - context, - ), - SessionControlAction::Delete => self - .validate_mutating_action_target( - SessionControlAction::Delete, - &parsed, - context, - ), + SessionControlAction::Cancel => self.validate_mutating_action_target( + SessionControlAction::Cancel, + &parsed, + context, + ), + SessionControlAction::Delete => self.validate_mutating_action_target( + SessionControlAction::Delete, + &parsed, + context, + ), _ => ValidationResult::default(), }; } @@ -564,13 +562,14 @@ Arguments: match params.action { SessionControlAction::Create => { - let workspace = self.resolve_effective_workspace( - SessionControlAction::Create, - None, - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Create, + None, + context, + &coordinator, + ) + .await?; let session_name = params .session_name .clone() @@ -624,13 +623,14 @@ Arguments: BitFunError::tool("session_id is required for cancel".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; - let workspace = self.resolve_effective_workspace( - SessionControlAction::Cancel, - Some(session_id), - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Cancel, + Some(session_id), + context, + &coordinator, + ) + .await?; let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( @@ -705,13 +705,14 @@ Arguments: BitFunError::tool("session_id is required for delete".to_string()) })?; Self::validate_session_id(session_id).map_err(BitFunError::tool)?; - let workspace = self.resolve_effective_workspace( - SessionControlAction::Delete, - Some(session_id), - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::Delete, + Some(session_id), + context, + &coordinator, + ) + .await?; let workspace_path = Path::new(&workspace); if self.current_workspace_session(context, &workspace) == Some(session_id) { return Err(BitFunError::tool( @@ -741,13 +742,14 @@ Arguments: }]) } SessionControlAction::List => { - let workspace = self.resolve_effective_workspace( - SessionControlAction::List, - None, - context, - &coordinator, - ) - .await?; + let workspace = self + .resolve_effective_workspace( + SessionControlAction::List, + None, + context, + &coordinator, + ) + .await?; let workspace_path = Path::new(&workspace); let sessions = coordinator.list_sessions(workspace_path).await?; let current_session_id = self.current_workspace_session(context, &workspace); diff --git a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs index 911f658ff8..b5e661b200 100644 --- a/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/session_message_tool.rs @@ -422,7 +422,10 @@ Allowed agent types when creating a session: .get("session_name") .and_then(|value| value.as_str()) .unwrap_or("new session"); - format!("Create session {} in {} and send message", session_name, workspace) + format!( + "Create session {} in {} and send message", + session_name, workspace + ) } } diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index f928700190..f6aee4cad2 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -22,18 +22,11 @@ impl SkillTool { Self } - fn render_description(&self, skills_list: String) -> String { - let skills_list = if skills_list.is_empty() { - "No skills available".to_string() - } else { - skills_list - }; - - format!( - r#"Execute a skill within the main conversation + fn render_description(&self) -> String { + r#"Execute a skill within the main conversation -When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. +When users ask you to perform tasks, check whether any skills listed in the current request context can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. How to use skills: - Invoke skills using this tool with the skill name only (no arguments) @@ -44,18 +37,15 @@ How to use skills: - `command: "ms-office-suite:pdf"` - invoke using fully qualified name Important: -- Only use skills listed in below +- Only use skills listed in the current request context's section - Do not invoke a skill that is already running - - - -{} -"#, - skills_list - ) +"# + .to_string() } - async fn build_description_for_context(&self, context: Option<&ToolUseContext>) -> String { + pub(crate) async fn resolved_skills_xml_for_context( + context: Option<&ToolUseContext>, + ) -> String { let registry = get_skill_registry(); let available_skills = match context { Some(ctx) if ctx.is_remote() => { @@ -93,7 +83,27 @@ Important: } }; - self.render_description(available_skills.join("\n")) + available_skills.join("\n") + } + + pub(crate) async fn build_available_skills_context_section( + context: Option<&ToolUseContext>, + ) -> Option { + let skills_list = Self::resolved_skills_xml_for_context(context).await; + let skills_list = skills_list.trim(); + if skills_list.is_empty() { + return None; + } + + let mut section = format!("\n{}\n", skills_list); + if context.map(|c| c.is_remote()).unwrap_or(false) + && context.and_then(|c| c.ws_fs()).is_none() + { + section.push_str( + "\n\nRemote workspace note: Project-level skills on the server could not be indexed because workspace I/O is unavailable. Only user-level skills are shown; BitFun will not fall back to scanning the remote path on the local filesystem.", + ); + } + Some(section) } } @@ -104,7 +114,7 @@ impl Tool for SkillTool { } async fn description(&self) -> BitFunResult { - Ok(self.build_description_for_context(None).await) + Ok(self.render_description()) } fn short_description(&self) -> String { @@ -113,17 +123,9 @@ impl Tool for SkillTool { async fn description_with_context( &self, - context: Option<&ToolUseContext>, + _context: Option<&ToolUseContext>, ) -> BitFunResult { - let mut s = self.build_description_for_context(context).await; - if context.map(|c| c.is_remote()).unwrap_or(false) - && context.and_then(|c| c.ws_fs()).is_none() - { - s.push_str( - "\n\n**Remote workspace:** Project-level skills on the server could not be indexed because workspace I/O is unavailable. Only user-level skills are shown; BitFun will not fall back to scanning the remote path on the local filesystem.", - ); - } - Ok(s) + Ok(self.render_description()) } fn input_schema(&self) -> Value { @@ -389,10 +391,9 @@ Use the remote project skill. }), }; - let description = SkillTool::new() - .description_with_context(Some(&context)) + let description = SkillTool::build_available_skills_context_section(Some(&context)) .await - .expect("description"); + .expect("available skills section"); assert!(description.contains("remote-only-skill-for-test")); assert!( diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 6e82b85487..ed895b0bda 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -22,6 +22,8 @@ use crate::agentic::tools::framework::{ }; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::tools::InputValidator; +use crate::service::config::global::GlobalConfigManager; +use crate::service::config::types::AIConfig; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::timing::elapsed_ms_u64; use async_trait::async_trait; @@ -57,6 +59,29 @@ impl TaskTool { ) } + async fn load_configured_tool_execution_timeout() -> Option { + let service = GlobalConfigManager::get_service().await.ok()?; + let ai_config: AIConfig = service.get_config(Some("ai")).await.ok()?; + ai_config + .tool_execution_timeout_secs + .filter(|seconds| *seconds > 0) + } + + fn resolve_subagent_timeout_seconds( + requested_timeout_seconds: Option, + configured_execution_timeout_secs: Option, + ) -> Option { + match ( + requested_timeout_seconds.filter(|seconds| *seconds > 0), + configured_execution_timeout_secs.filter(|seconds| *seconds > 0), + ) { + (Some(requested), Some(configured)) => Some(requested.max(configured)), + (Some(requested), None) => Some(requested), + (None, Some(configured)) => Some(configured), + (None, None) => None, + } + } + fn deep_review_launch_batch_for_task( subagent_type: &str, description: Option<&str>, @@ -373,7 +398,7 @@ impl TaskTool { } } - fn format_agent_descriptions(&self, agents: &[AgentInfo]) -> String { + fn format_agent_descriptions(agents: &[AgentInfo]) -> String { if agents.is_empty() { return String::new(); } @@ -390,20 +415,12 @@ impl TaskTool { out } - fn render_description(&self, agent_descriptions: String) -> String { - let agent_descriptions = if agent_descriptions.is_empty() { - "No agents available".to_string() - } else { - agent_descriptions - }; - - format!( - r#"Launch a new agent to handle complex, multi-step tasks autonomously. + fn render_description(&self) -> String { + r#"Launch a new agent to handle complex, multi-step tasks autonomously. The Task tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. -Available agents and the tools they have access to: -{} +The current request context includes an section when subagents are available. Use the exact `type` attribute from that section as `subagent_type`. When using the Task tool, you must specify `subagent_type` as a top-level tool argument to select which agent type to use. Do not put `subagent_type`, `description`, `workspace_path`, `model_id`, or `timeout_seconds` inside the prompt string. @@ -418,7 +435,7 @@ Usage notes: - If 'workspace_path' is omitted, the task inherits the current workspace by default. - Provide 'workspace_path' when the selected agent requires an explicit workspace, such as Explore or FileFinder. - Use 'model_id' when a caller needs a specific model or model slot for the subagent. Omit it to use the agent default. -- Use 'timeout_seconds' when you need a hard deadline for the subagent. Omit it or set it to 0 to disable the timeout. +- Use 'timeout_seconds' when you need a hard deadline for the subagent. When omitted, the session execution timeout from settings is used. When provided, the effective timeout is the larger of the requested value and the session execution timeout. Set it to 0 with no configured session execution timeout to disable the timeout. - For DeepReview only, set 'retry' to true when re-dispatching a reviewer after that same reviewer returned partial_timeout or an explicit transient capacity failure in the current turn. Retry calls must include retry_coverage with source_packet_id, source_status, covered_files, and a smaller retry_scope_files list. Do not set 'auto_retry' unless this is a backend-owned automatic retry admitted by Review Team settings; model-issued retry decisions should omit it or set it to false. Example retry_coverage: {{ "source_packet_id": "reviewer-123", "source_status": "partial_timeout", "covered_files": ["src/main.rs"], "retry_scope_files": ["src/parser.rs"] }}. - Launch independent agents concurrently when that improves coverage or latency; send parallel Task calls in a single assistant message. - When the agent is done, it will return a single message back to you. @@ -437,18 +454,23 @@ assistant: Uses the Task tool with subagent_type="Explore" because this is a bro user: "Find the files that implement export formatting" assistant: Uses the Task tool with subagent_type="FileFinder" because the exact filenames are unknown and semantic file discovery is useful. The parent agent reads the returned files before proposing edits. -"#, - agent_descriptions - ) +"# + .to_string() } - async fn build_description(&self, context: Option<&ToolUseContext>) -> String { - let agents = self.get_enabled_agents(context).await; - let agent_descriptions = self.format_agent_descriptions(&agents); - self.render_description(agent_descriptions) + pub(crate) async fn build_available_agents_context_section( + context: Option<&ToolUseContext>, + ) -> Option { + let agents = Self::get_enabled_agents(context).await; + let agent_descriptions = Self::format_agent_descriptions(&agents); + if agent_descriptions.trim().is_empty() { + None + } else { + Some(agent_descriptions) + } } - async fn get_enabled_agents(&self, context: Option<&ToolUseContext>) -> Vec { + async fn get_enabled_agents(context: Option<&ToolUseContext>) -> Vec { let registry = get_agent_registry(); let workspace_root = context.and_then(|ctx| ctx.workspace_root()); if let Some(workspace_root) = workspace_root { @@ -465,7 +487,7 @@ assistant: Uses the Task tool with subagent_type="FileFinder" because the exact } async fn get_agents_types(&self, context: Option<&ToolUseContext>) -> Vec { - self.get_enabled_agents(context) + Self::get_enabled_agents(context) .await .into_iter() .map(|agent| agent.id) @@ -480,7 +502,7 @@ impl Tool for TaskTool { } async fn description(&self) -> BitFunResult { - Ok(self.build_description(None).await) + Ok(self.render_description()) } fn short_description(&self) -> String { @@ -489,9 +511,9 @@ impl Tool for TaskTool { async fn description_with_context( &self, - context: Option<&ToolUseContext>, + _context: Option<&ToolUseContext>, ) -> BitFunResult { - Ok(self.build_description(context).await) + Ok(self.render_description()) } fn input_schema(&self) -> Value { @@ -521,7 +543,7 @@ impl Tool for TaskTool { "timeout_seconds": { "type": "integer", "minimum": 0, - "description": "Optional timeout for this subagent task in seconds. Use 0 or omit it to disable the timeout." + "description": "Optional timeout for this subagent task in seconds. When omitted, the session execution timeout from settings is used. When provided, the effective timeout is the larger of this value and the session execution timeout. Use 0 with no configured session execution timeout to disable the timeout." }, "run_in_background": { "type": "boolean", @@ -1094,6 +1116,12 @@ impl Tool for TaskTool { } } + if deep_review_subagent_role.is_none() { + let configured_timeout = Self::load_configured_tool_execution_timeout().await; + timeout_seconds = + Self::resolve_subagent_timeout_seconds(timeout_seconds, configured_timeout); + } + if let Some(retry_scope_files) = deep_review_retry_scope_files.as_ref() { prompt = Self::prompt_with_deep_review_retry_scope(&prompt, retry_scope_files); } @@ -1139,7 +1167,7 @@ impl Tool for TaskTool { "background_task_id": background_result.background_task_id, }), result_for_assistant: Some(format!( - "Background subagent '{}' started successfully.\nIts final result will be delivered back automatically to you when it is finished.", + "Background subagent '{}' started successfully.\nIts final result will be delivered back automatically to you when it is finished. Do not poll for status updates. If your current path is blocked on this result and there is no other useful local work to do, it is fine to end the current turn.", subagent_type, background_result.background_task_id )), image_attachments: None, @@ -1520,7 +1548,9 @@ impl Tool for TaskTool { mod tests { use super::TaskTool; use crate::agentic::agents::CustomSubagentConfig; - use crate::agentic::agents::{get_agent_registry, Agent, AgentCategory, SubAgentSource}; + use crate::agentic::agents::{ + get_agent_registry, Agent, AgentCategory, RequestContextPolicy, SubAgentSource, + }; use crate::agentic::deep_review::task_adapter as deep_review_task_adapter; use crate::agentic::deep_review_policy::{ DeepReviewBudgetTracker, DeepReviewExecutionPolicy, DeepReviewSubagentRole, @@ -1559,6 +1589,10 @@ mod tests { "test_prompt_order_agent" } + fn request_context_policy(&self) -> RequestContextPolicy { + RequestContextPolicy::empty() + } + fn default_tools(&self) -> Vec { vec!["Read".to_string()] } @@ -1644,6 +1678,27 @@ mod tests { assert!(policy.classify_subagent("DeepReview").is_err()); } + #[test] + fn resolve_subagent_timeout_uses_session_execution_timeout_as_floor() { + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(Some(300), Some(1200)), + Some(1200) + ); + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(None, Some(1200)), + Some(1200) + ); + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(Some(1800), Some(1200)), + Some(1800) + ); + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(Some(300), None), + Some(300) + ); + assert_eq!(TaskTool::resolve_subagent_timeout_seconds(None, None), None); + } + #[test] fn deep_review_policy_caps_reviewer_and_judge_timeouts() { let policy = DeepReviewExecutionPolicy::from_config_value(Some(&json!({ @@ -1694,7 +1749,6 @@ mod tests { #[tokio::test] async fn description_with_context_filters_restricted_subagents_by_parent_agent() { - let tool = TaskTool::new(); let agentic_context = ToolUseContext { tool_call_id: None, agent_type: Some("agentic".to_string()), @@ -1713,25 +1767,24 @@ mod tests { ..agentic_context.clone() }; - let agentic_description = tool - .description_with_context(Some(&agentic_context)) - .await - .expect("agentic description should render"); + let agentic_description = + TaskTool::build_available_agents_context_section(Some(&agentic_context)) + .await + .expect("agentic available agents should render"); assert!(agentic_description.contains("")); assert!(!agentic_description.contains("")); assert!(!agentic_description.contains("")); - let deep_review_description = tool - .description_with_context(Some(&deep_review_context)) - .await - .expect("deep review description should render"); + let deep_review_description = + TaskTool::build_available_agents_context_section(Some(&deep_review_context)) + .await + .expect("deep review available agents should render"); assert!(deep_review_description.contains("")); assert!(!deep_review_description.contains("")); } #[tokio::test] async fn prompt_stability_description_with_context_renders_available_agents_in_stable_order() { - let tool = TaskTool::new(); let context = ToolUseContext { tool_call_id: None, agent_type: Some("agentic".to_string()), @@ -1767,10 +1820,9 @@ mod tests { }), ); - let description = tool - .description_with_context(Some(&context)) + let description = TaskTool::build_available_agents_context_section(Some(&context)) .await - .expect("description should render"); + .expect("available agents should render"); let builtin_a_index = find_agent_block_index(&description, builtin_a); let builtin_z_index = find_agent_block_index(&description, builtin_z); diff --git a/src/crates/core/src/agentic/tools/manifest_resolver.rs b/src/crates/core/src/agentic/tools/manifest_resolver.rs index dd98ff0496..31aab0dd1f 100644 --- a/src/crates/core/src/agentic/tools/manifest_resolver.rs +++ b/src/crates/core/src/agentic/tools/manifest_resolver.rs @@ -1,10 +1,13 @@ use crate::agentic::agents::AgentToolPolicyOverrides; -use crate::agentic::tools::catalog_provider::{ +use crate::agentic::tools::framework::{Tool, ToolUseContext}; +use crate::agentic::tools::product_runtime::{ resolve_product_tool_manifest, resolve_product_visible_tools, }; -use crate::agentic::tools::framework::{Tool, ToolUseContext}; use crate::util::types::ToolDefinition; -use bitfun_agent_tools::{ContextualToolManifest, ContextualVisibleTools, ToolManifestDefinition}; +use bitfun_agent_tools::{ + ContextualToolManifest, ContextualVisibleTools, GetToolSpecCollapsedToolSummary, + ToolManifestDefinition, +}; use std::sync::Arc; #[derive(Debug, Clone)] @@ -12,6 +15,7 @@ pub struct ResolvedToolManifest { pub allowed_tool_names: Vec, pub tool_definitions: Vec, pub collapsed_tool_names: Vec, + pub collapsed_tool_summaries: Vec, } #[derive(Clone)] @@ -39,6 +43,15 @@ impl From> for ResolvedVisibleTools { impl From> for ResolvedToolManifest { fn from(value: ContextualToolManifest) -> Self { + let collapsed_tool_summaries = value + .collapsed_tools + .iter() + .map(|tool| GetToolSpecCollapsedToolSummary { + name: tool.name().to_string(), + short_description: tool.short_description(), + }) + .collect(); + Self { allowed_tool_names: value.allowed_tool_names, tool_definitions: value @@ -47,6 +60,7 @@ impl From> for ResolvedToolManifest { .map(to_core_tool_definition) .collect(), collapsed_tool_names: value.collapsed_tool_names, + collapsed_tool_summaries, } } } @@ -57,8 +71,8 @@ pub async fn resolve_visible_tools( context: &ToolUseContext, ) -> ResolvedVisibleTools { resolve_product_visible_tools(allowed_tools, exposure_overrides, context) - .await - .into() + .await + .into() } pub async fn resolve_tool_manifest( @@ -67,16 +81,16 @@ pub async fn resolve_tool_manifest( context: &ToolUseContext, ) -> ResolvedToolManifest { resolve_product_tool_manifest(allowed_tools, exposure_overrides, context) - .await - .into() + .await + .into() } #[cfg(test)] mod tests { use super::resolve_tool_manifest; use crate::agentic::agents::AgentToolPolicyOverrides; - use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::tools::framework::{ToolExposure, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; use bitfun_agent_tools::GET_TOOL_SPEC_TOOL_NAME; use serde_json::json; use std::collections::HashMap; @@ -110,12 +124,10 @@ mod tests { assert!(manifest.collapsed_tool_names.is_empty()); assert_eq!(manifest.allowed_tool_names, allowed_tools); - assert!( - !manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) - ); + assert!(!manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); } #[tokio::test] @@ -130,29 +142,26 @@ mod tests { .await; assert_eq!(manifest.collapsed_tool_names, vec!["WebFetch".to_string()]); - assert!( - manifest - .allowed_tool_names - .contains(&GET_TOOL_SPEC_TOOL_NAME.to_string()) - ); - assert!( - manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "Read") - ); - assert!( - manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "WebFetch") - ); - assert!( - manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) - ); + assert_eq!(manifest.collapsed_tool_summaries.len(), 1); + assert_eq!(manifest.collapsed_tool_summaries[0].name, "WebFetch"); + assert!(manifest.collapsed_tool_summaries[0] + .short_description + .contains("Fetch content from a URL")); + assert!(manifest + .allowed_tool_names + .contains(&GET_TOOL_SPEC_TOOL_NAME.to_string())); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "Read")); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch")); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); let stub = manifest .tool_definitions .iter() @@ -161,12 +170,10 @@ mod tests { assert!(stub.description.contains("First call `GetToolSpec`")); assert_eq!(stub.parameters["type"], json!("object")); assert_eq!(stub.parameters["additionalProperties"], json!(false)); - assert!( - stub.parameters["properties"]["tool_name"]["description"] - .as_str() - .unwrap() - .contains("{\"tool_name\":\"WebFetch\"}") - ); + assert!(stub.parameters["properties"]["tool_name"]["description"] + .as_str() + .unwrap() + .contains("{\"tool_name\":\"WebFetch\"}")); } #[tokio::test] @@ -216,11 +223,9 @@ mod tests { .iter() .find(|tool| tool.name == "WebFetch") .expect("collapsed WebFetch stub"); - assert!( - web_fetch - .description - .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}") - ); + assert!(web_fetch + .description + .contains("First call `GetToolSpec` with {\"tool_name\":\"WebFetch\"}")); assert_eq!( web_fetch.parameters, json!({ @@ -338,17 +343,13 @@ mod tests { let manifest = resolve_tool_manifest(&allowed_tools, &overrides, &tool_context()).await; assert!(manifest.collapsed_tool_names.is_empty()); - assert!( - manifest - .tool_definitions - .iter() - .any(|tool| tool.name == "WebFetch") - ); - assert!( - !manifest - .tool_definitions - .iter() - .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME) - ); + assert!(manifest + .tool_definitions + .iter() + .any(|tool| tool.name == "WebFetch")); + assert!(!manifest + .tool_definitions + .iter() + .any(|tool| tool.name == GET_TOOL_SPEC_TOOL_NAME)); } } diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index c0c0f6f788..3a6082f8a0 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -1,7 +1,8 @@ //! Tool system - includes Tool interface, tool registry and tool executor +pub mod file_read_state_runtime; +pub mod file_tool_guidance; pub mod browser_control; -pub(crate) mod catalog_provider; pub mod computer_use_capability; pub mod computer_use_host; pub mod computer_use_optimizer; @@ -12,11 +13,12 @@ pub mod implementations; pub mod manifest_resolver; pub mod pipeline; pub(crate) mod post_call_hooks; +pub(crate) mod product_runtime; pub mod registry; pub mod restrictions; -pub(crate) mod runtime_assembly; -pub(crate) mod static_providers; pub(crate) mod tool_adapter; +pub(crate) mod tool_context_runtime; +pub(crate) mod tool_result_storage; pub mod user_input_manager; pub mod workspace_paths; pub use bitfun_agent_tools::input_validator; diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 47f4d3da77..f642bcd153 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -6,21 +6,25 @@ use super::state_manager::ToolStateManager; use super::types::*; use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; -use crate::agentic::deep_review::tool_context; use crate::agentic::events::types::ToolEventData; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ToolResult as FrameworkToolResult, ToolUseContext}; use crate::agentic::tools::registry::ToolRegistry; +use crate::agentic::tools::tool_context_runtime; +use crate::agentic::tools::tool_result_storage; use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_agent_tools::{ + validate_collapsed_tool_usage, validate_tool_allowed_by_list, GET_TOOL_SPEC_TOOL_NAME, +}; use dashmap::DashMap; use futures::future::join_all; use log::{debug, error, info, warn}; -use std::collections::{HashMap, VecDeque}; +use std::collections::VecDeque; use std::sync::Arc; use std::time::{Instant, SystemTime}; -use tokio::sync::{RwLock as TokioRwLock, oneshot}; -use tokio::time::{Duration, timeout}; +use tokio::sync::{oneshot, RwLock as TokioRwLock}; +use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; /// A batch of tool tasks to execute together. @@ -314,33 +318,13 @@ pub struct ToolPipeline { impl ToolPipeline { fn validate_collapsed_tool_usage(task: &ToolTask) -> BitFunResult<()> { - let tool_name = task.tool_call.tool_name.as_str(); - if tool_name == "GetToolSpec" { - return Ok(()); - } - - if !task - .context - .collapsed_tools - .iter() - .any(|collapsed| collapsed == tool_name) - { - return Ok(()); - } - - if task - .context - .unlocked_collapsed_tools - .iter() - .any(|loaded| loaded == tool_name) - { - return Ok(()); - } - - Err(BitFunError::Validation(format!( - "Tool '{}' is collapsed. Call GetToolSpec first with {{\"tool_name\":\"{}\"}} to read its full usage instructions and input schema, then try again.", - tool_name, tool_name - ))) + validate_collapsed_tool_usage( + task.tool_call.tool_name.as_str(), + &task.context.collapsed_tools, + &task.context.unlocked_collapsed_tools, + GET_TOOL_SPEC_TOOL_NAME, + ) + .map_err(|error| BitFunError::Validation(error.to_string())) } pub fn new( @@ -755,15 +739,8 @@ impl ToolPipeline { return Err(BitFunError::Validation(error_msg)); } - // Security check: check if the tool is in the allowed list - // If allowed_tools is not empty, only allow execution of tools in the whitelist - if !task.context.allowed_tools.is_empty() - && !task.context.allowed_tools.contains(&tool_name) - { - let error_msg = format!( - "Tool '{}' is not in the allowed list: {:?}", - tool_name, task.context.allowed_tools - ); + if let Err(err) = validate_tool_allowed_by_list(&tool_name, &task.context.allowed_tools) { + let error_msg = err.to_string(); warn!("Tool not allowed: {}", error_msg); // Update state to failed @@ -1056,6 +1033,7 @@ impl ToolPipeline { } let execution_started_at = Instant::now(); + let tool_context = self.build_tool_use_context(&task, cancellation_token.clone()); let result = self .execute_with_retry(&task, cancellation_token.clone(), tool) .await; @@ -1066,7 +1044,11 @@ impl ToolPipeline { match result { Ok(tool_result) => { let duration_ms = elapsed_ms_u64(start_time); - let mut tool_result = tool_result; + let mut tool_result = tool_result_storage::maybe_persist_large_tool_result( + tool_result, + &tool_context, + ) + .await; tool_result.duration_ms = Some(duration_ms); // The tool call succeeded with arguments that we patched @@ -1283,64 +1265,11 @@ impl ToolPipeline { task: &ToolTask, cancellation_token: CancellationToken, ) -> ToolUseContext { - ToolUseContext { - tool_call_id: Some(task.tool_call.tool_id.clone()), - agent_type: Some(task.context.agent_type.clone()), - session_id: Some(task.context.session_id.clone()), - dialog_turn_id: Some(task.context.dialog_turn_id.clone()), - workspace: task.context.workspace.clone(), - unlocked_collapsed_tools: task.context.unlocked_collapsed_tools.clone(), - custom_data: { - let mut map = HashMap::new(); - - if let Some(turn_index) = task.context.context_vars.get("turn_index") { - if let Ok(n) = turn_index.parse::() { - map.insert("turn_index".to_string(), serde_json::json!(n)); - } - } - - if let Some(provider) = task.context.context_vars.get("primary_model_provider") { - if !provider.is_empty() { - map.insert( - "primary_model_provider".to_string(), - serde_json::json!(provider), - ); - } - } - if let Some(supports_images) = task - .context - .context_vars - .get("primary_model_supports_image_understanding") - { - if let Ok(flag) = supports_images.parse::() { - map.insert( - "primary_model_supports_image_understanding".to_string(), - serde_json::json!(flag), - ); - } - } - let deep_review_parent_context = - task.context - .subagent_parent_info - .as_ref() - .map(|parent_info| tool_context::DeepReviewToolParentContext { - tool_call_id: parent_info.tool_call_id.as_str(), - session_id: parent_info.session_id.as_str(), - dialog_turn_id: parent_info.dialog_turn_id.as_str(), - }); - tool_context::append_tool_use_context_data( - &task.context.context_vars, - deep_review_parent_context, - &mut map, - ); - - map - }, - computer_use_host: self.computer_use_host.clone(), - cancellation_token: Some(cancellation_token), - runtime_tool_restrictions: task.context.runtime_tool_restrictions.clone(), - workspace_services: task.context.workspace_services.clone(), - } + tool_context_runtime::build_tool_use_context_for_task( + task, + self.computer_use_host.clone(), + cancellation_token, + ) } /// Handle streaming results @@ -1652,14 +1581,12 @@ mod tests { result.result.result["provided_arguments"], serde_json::Value::String("{\"operation\":\"log\"".to_string()) ); - assert!( - result - .result - .result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("Provided arguments: {\"operation\":\"log\"") - ); + assert!(result + .result + .result_for_assistant + .as_deref() + .unwrap_or_default() + .contains("Provided arguments: {\"operation\":\"log\"")); } #[test] @@ -1700,6 +1627,12 @@ mod tests { "primary_model_supports_image_understanding".to_string(), "true".to_string(), ); + task.context + .context_vars + .insert("write_tool_mode".to_string(), "inline_content".to_string()); + task.context + .context_vars + .insert("acp_transport".to_string(), "true".to_string()); task.context.collapsed_tools = vec!["WebFetch".to_string()]; task.context.unlocked_collapsed_tools = vec!["WebFetch".to_string()]; task.context.runtime_tool_restrictions = ToolRuntimeRestrictions { @@ -1716,11 +1649,9 @@ mod tests { assert_eq!(context.dialog_turn_id.as_deref(), Some("turn_1")); assert_eq!(context.unlocked_collapsed_tools, vec!["WebFetch"]); assert!(context.cancellation_token.is_some()); - assert!( - context - .runtime_tool_restrictions - .is_tool_allowed("WebFetch") - ); + assert!(context + .runtime_tool_restrictions + .is_tool_allowed("WebFetch")); assert!(!context.runtime_tool_restrictions.is_tool_allowed("Bash")); assert_eq!(context.custom_data["turn_index"], json!(7)); assert_eq!( @@ -1731,6 +1662,11 @@ mod tests { context.custom_data["primary_model_supports_image_understanding"], json!(true) ); + assert_eq!( + context.custom_data["write_tool_mode"], + json!("inline_content") + ); + assert_eq!(context.custom_data["acp_transport"], json!(true)); let facts = context.to_tool_context_facts(); let value = serde_json::to_value(&facts).expect("serialize context facts"); @@ -1750,10 +1686,9 @@ mod tests { let err = ToolPipeline::validate_collapsed_tool_usage(&task) .expect_err("collapsed tool should require GetToolSpec unlock"); - assert!( - err.to_string() - .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}") - ); + assert!(err + .to_string() + .contains("Call GetToolSpec first with {\"tool_name\":\"WebFetch\"}")); } #[test] diff --git a/src/crates/core/src/agentic/tools/catalog_provider.rs b/src/crates/core/src/agentic/tools/product_runtime.rs similarity index 63% rename from src/crates/core/src/agentic/tools/catalog_provider.rs rename to src/crates/core/src/agentic/tools/product_runtime.rs index 5204105f0e..e1c387d700 100644 --- a/src/crates/core/src/agentic/tools/catalog_provider.rs +++ b/src/crates/core/src/agentic/tools/product_runtime.rs @@ -1,14 +1,142 @@ +//! Core-owned product tool runtime owner. +//! +//! This module is the single core-side owner for assembling product tool +//! registry adapters, catalog manifests, GetToolSpec lookup, and snapshot +//! decoration. Concrete tools and `ToolUseContext` stay in core so this owner +//! remains an equivalent structural boundary rather than a behavior migration. + use crate::agentic::agents::{get_agent_registry, AgentToolPolicyOverrides}; use crate::agentic::tools::framework::{Tool, ToolExposure, ToolResult, ToolUseContext}; -use crate::agentic::tools::registry::{get_global_tool_registry, GET_TOOL_SPEC_TOOL_NAME}; +use crate::agentic::tools::implementations::*; +use crate::agentic::tools::registry::{ + get_global_tool_registry, ProductToolDecoratorRef, ToolRef, ToolRegistry, +}; use crate::util::errors::{BitFunError, BitFunResult}; +#[cfg(test)] +use bitfun_agent_tools::StaticToolProvider; use bitfun_agent_tools::{ ContextualToolManifest, ContextualVisibleTools, GetToolSpecCatalogProvider, - GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, + GetToolSpecExecutionError, GetToolSpecRuntime, SnapshotToolDecorator, SnapshotToolWrapper, + StaticToolProviderGroup, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolRuntimeAssembly, + GET_TOOL_SPEC_TOOL_NAME, }; +use bitfun_tool_packs::product_tool_provider_group_plan; use serde_json::Value; use std::sync::Arc; +#[derive(Clone)] +pub(crate) struct ProductToolRuntime { + tool_decorator: ProductToolDecoratorRef, +} + +impl Default for ProductToolRuntime { + fn default() -> Self { + Self::new() + } +} + +impl ProductToolRuntime { + pub(crate) fn new() -> Self { + Self::with_tool_decorator(Arc::new(SnapshotToolDecorator::new(Arc::new( + ProductSnapshotToolWrapper, + )))) + } + + pub(crate) fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { + Self { tool_decorator } + } + + #[cfg(test)] + pub(in crate::agentic::tools) fn provider_group_ids(&self) -> Vec<&'static str> { + builtin_static_tool_providers() + .iter() + .map(|provider| provider.provider_id()) + .collect() + } + + #[cfg(test)] + pub(in crate::agentic::tools) fn provider_tool_names(&self) -> Vec { + builtin_static_tool_providers() + .into_iter() + .flat_map(|provider| provider.tools()) + .map(|tool| tool.name().to_string()) + .collect() + } + + pub(crate) fn create_registry(&self) -> ToolRegistry { + let providers = builtin_static_tool_providers(); + let inner = ToolRuntimeAssembly::with_tool_decorator(self.tool_decorator.clone()) + .create_registry_from_static_providers(&providers); + ToolRegistry::from_inner(inner) + } +} + +#[derive(Debug, Clone)] +struct ProductSnapshotToolWrapper; + +impl SnapshotToolWrapper for ProductSnapshotToolWrapper { + fn wrap_for_snapshot_tracking(&self, tool: ToolRef) -> ToolRef { + crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool) + } +} + +fn builtin_static_tool_providers() -> Vec> { + product_tool_provider_group_plan() + .iter() + .map(|group| { + StaticToolProviderGroup::new(group.provider_id(), materialize_tools(group.tool_names())) + }) + .collect() +} + +fn materialize_tools(tool_names: &[&str]) -> Vec> { + tool_names + .iter() + .map(|tool_name| materialize_tool(tool_name)) + .collect() +} + +fn materialize_tool(tool_name: &str) -> Arc { + match tool_name { + "LS" => Arc::new(LSTool::new()), + "Read" => Arc::new(FileReadTool::new()), + "Glob" => Arc::new(GlobTool::new()), + "Grep" => Arc::new(GrepTool::new()), + "Write" => Arc::new(FileWriteTool::new()), + "Edit" => Arc::new(FileEditTool::new()), + "Delete" => Arc::new(DeleteFileTool::new()), + "Bash" => Arc::new(BashTool::new()), + "Task" => Arc::new(TaskTool::new()), + "Skill" => Arc::new(SkillTool::new()), + "AskUserQuestion" => Arc::new(AskUserQuestionTool::new()), + "TodoWrite" => Arc::new(TodoWriteTool::new()), + "CreatePlan" => Arc::new(CreatePlanTool::new()), + "submit_code_review" => Arc::new(CodeReviewTool::new()), + "GetToolSpec" => Arc::new(GetToolSpecTool::new()), + "GetFileDiff" => Arc::new(GetFileDiffTool::new()), + "Log" => Arc::new(LogTool::new()), + "TerminalControl" => Arc::new(TerminalControlTool::new()), + "SessionControl" => Arc::new(SessionControlTool::new()), + "SessionMessage" => Arc::new(SessionMessageTool::new()), + "SessionHistory" => Arc::new(SessionHistoryTool::new()), + "Cron" => Arc::new(CronTool::new()), + "WebSearch" => Arc::new(WebSearchTool::new()), + "WebFetch" => Arc::new(WebFetchTool::new()), + "ListMCPResources" => Arc::new(ListMCPResourcesTool::new()), + "ReadMCPResource" => Arc::new(ReadMCPResourceTool::new()), + "ListMCPPrompts" => Arc::new(ListMCPPromptsTool::new()), + "GetMCPPrompt" => Arc::new(GetMCPPromptTool::new()), + "GenerativeUI" => Arc::new(GenerativeUITool::new()), + "Git" => Arc::new(GitTool::new()), + "ReviewPlatform" => Arc::new(ReviewPlatformTool::new()), + "InitMiniApp" => Arc::new(InitMiniAppTool::new()), + "ControlHub" => Arc::new(ControlHubTool::new()), + "ComputerUse" => Arc::new(ComputerUseTool::new()), + "Playbook" => Arc::new(PlaybookTool::new()), + _ => panic!("unknown product tool provider plan entry: {tool_name}"), + } +} + #[derive(Debug, Clone, Copy, Default)] pub(crate) struct ProductToolCatalogProvider; @@ -114,15 +242,6 @@ pub(crate) async fn resolve_product_readonly_enabled_tools() -> Vec, -) -> String { - let provider = ProductToolCatalogProvider; - product_get_tool_spec_runtime(&provider) - .catalog_description(context) - .await -} - pub(crate) async fn resolve_product_get_tool_spec_results( input: &Value, context: &ToolUseContext, @@ -138,7 +257,7 @@ pub(crate) async fn resolve_product_get_tool_spec_results( mod tests { use super::{ resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, - resolve_product_tool_manifest, ProductToolCatalogProvider, + resolve_product_tool_manifest, ProductToolCatalogProvider, ProductToolRuntime, }; use crate::agentic::agents::AgentToolPolicyOverrides; use crate::agentic::tools::framework::ToolUseContext; @@ -168,6 +287,24 @@ mod tests { tool_context(None) } + #[test] + fn product_tool_runtime_owner_preserves_registry_contract() { + let runtime = ProductToolRuntime::default(); + let owner_registry = runtime.create_registry(); + let compatibility_registry = create_tool_registry(); + + assert_eq!( + owner_registry.get_tool_names(), + compatibility_registry.get_tool_names(), + "product tool runtime owner must preserve legacy registry output" + ); + assert_eq!( + owner_registry.get_collapsed_tool_names(), + compatibility_registry.get_collapsed_tool_names(), + "product tool runtime owner must preserve collapsed-tool exposure" + ); + } + #[tokio::test] async fn product_catalog_provider_reads_global_registry_snapshot() { let provider = ProductToolCatalogProvider; diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 7aa456eab1..d741e077b0 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -1,7 +1,9 @@ //! Tool registry -use crate::agentic::tools::catalog_provider::resolve_product_readonly_enabled_tools; use crate::agentic::tools::framework::{DynamicToolInfo, Tool}; +use crate::agentic::tools::product_runtime::{ + resolve_product_readonly_enabled_tools, ProductToolRuntime, +}; use crate::util::errors::BitFunResult; use bitfun_agent_tools::{ DynamicToolDescriptor, DynamicToolProvider, PortResult, ToolDecoratorRef, @@ -29,8 +31,7 @@ impl Default for ToolRegistry { impl ToolRegistry { /// Create a new tool registry pub fn new() -> Self { - crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly::default() - .create_registry() + ProductToolRuntime::default().create_registry() } /// Create a registry with an injected decoration boundary. @@ -39,10 +40,7 @@ impl ToolRegistry { /// allowing future owner crates to replace this concrete service coupling /// through the `bitfun-runtime-ports` interface. pub fn with_tool_decorator(tool_decorator: ProductToolDecoratorRef) -> Self { - crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly::with_tool_decorator( - tool_decorator, - ) - .create_registry() + ProductToolRuntime::with_tool_decorator(tool_decorator).create_registry() } pub(in crate::agentic::tools) fn from_inner(inner: AgentToolRegistry) -> Self { @@ -174,10 +172,9 @@ mod tests { use crate::agentic::tools::framework::{ DynamicMcpToolInfo, DynamicToolInfo, Tool, ToolResult, ToolUseContext, ValidationResult, }; - use crate::agentic::tools::runtime_assembly::ProductToolRuntimeAssembly; - use crate::agentic::tools::static_providers::builtin_static_tool_providers; + use crate::agentic::tools::product_runtime::ProductToolRuntime; use async_trait::async_trait; - use bitfun_agent_tools::{DynamicToolProvider, StaticToolProvider, ToolDecorator}; + use bitfun_agent_tools::{DynamicToolProvider, ToolDecorator}; use serde_json::json; use serde_json::Value; use std::sync::Arc; @@ -369,6 +366,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", @@ -394,11 +392,7 @@ mod tests { #[test] fn builtin_static_tool_providers_cover_registry_manifest_in_order() { - let provider_tools = builtin_static_tool_providers() - .into_iter() - .flat_map(|provider| provider.tools()) - .map(|tool| tool.name().to_string()) - .collect::>(); + let provider_tools = ProductToolRuntime::default().provider_tool_names(); assert_eq!( provider_tools, @@ -409,10 +403,7 @@ mod tests { #[test] fn builtin_static_tool_providers_keep_owner_group_order() { - let provider_ids = builtin_static_tool_providers() - .into_iter() - .map(|provider| provider.provider_id()) - .collect::>(); + let provider_ids = ProductToolRuntime::default().provider_group_ids(); assert_eq!( provider_ids, @@ -428,10 +419,7 @@ mod tests { #[test] fn builtin_static_tool_providers_follow_tool_pack_group_plan() { - let provider_ids = builtin_static_tool_providers() - .into_iter() - .map(|provider| provider.provider_id()) - .collect::>(); + let provider_ids = ProductToolRuntime::default().provider_group_ids(); let planned_provider_ids = bitfun_tool_packs::product_tool_provider_group_plan() .iter() .map(|group| group.provider_id()) @@ -444,11 +432,11 @@ mod tests { } #[test] - fn product_tool_runtime_assembly_preserves_core_owned_registry_contract() { - let assembly = ProductToolRuntimeAssembly::default(); + fn product_tool_runtime_preserves_core_owned_registry_contract() { + let runtime = ProductToolRuntime::default(); assert_eq!( - assembly.provider_group_ids(), + runtime.provider_group_ids(), vec![ "core.basic", "core.agent", @@ -458,7 +446,7 @@ mod tests { "runtime assembly must keep core-owned provider group order explicit" ); - let assembled_registry = assembly.create_registry(); + let assembled_registry = runtime.create_registry(); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -489,10 +477,27 @@ mod tests { } #[test] - fn product_tool_runtime_assembly_keeps_custom_decorator_provider_contract() { - let registry = - ProductToolRuntimeAssembly::with_tool_decorator(Arc::new(MarkerToolDecorator)) - .create_registry(); + fn product_tool_runtime_owner_preserves_registry_contract() { + let runtime = ProductToolRuntime::default(); + let owner_registry = runtime.create_registry(); + let compatibility_registry = create_tool_registry(); + + assert_eq!( + owner_registry.get_tool_names(), + compatibility_registry.get_tool_names(), + "product tool runtime owner must preserve legacy registry output" + ); + assert_eq!( + owner_registry.get_collapsed_tool_names(), + compatibility_registry.get_collapsed_tool_names(), + "product tool runtime owner must preserve collapsed-tool exposure" + ); + } + + #[test] + fn product_tool_runtime_keeps_custom_decorator_provider_contract() { + let registry = ProductToolRuntime::with_tool_decorator(Arc::new(MarkerToolDecorator)) + .create_registry(); let compatibility_registry = create_tool_registry(); assert_eq!( @@ -526,6 +531,8 @@ mod tests { assert!(registry.is_tool_collapsed("GetFileDiff")); assert!(!registry.is_tool_collapsed("GetToolSpec")); assert!(registry.is_tool_collapsed("Git")); + assert!(registry.is_tool_collapsed("ReviewPlatform")); + assert!(!registry.is_tool_collapsed("InitMiniApp")); } #[test] @@ -550,7 +557,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", - "InitMiniApp", + "ReviewPlatform", "ControlHub", "ComputerUse", "Playbook", diff --git a/src/crates/core/src/agentic/tools/restrictions.rs b/src/crates/core/src/agentic/tools/restrictions.rs index e0b41fe3c6..471d20ed2e 100644 --- a/src/crates/core/src/agentic/tools/restrictions.rs +++ b/src/crates/core/src/agentic/tools/restrictions.rs @@ -1,6 +1,7 @@ use crate::util::errors::{BitFunError, BitFunResult}; pub use bitfun_agent_tools::{ ToolPathOperation, ToolPathPolicy, ToolRestrictionError, ToolRuntimeRestrictions, + is_remote_posix_path_within_root, }; use std::path::{Path, PathBuf}; @@ -16,24 +17,6 @@ pub fn is_local_path_within_root(path: &Path, root: &Path) -> BitFunResult Ok(canonical_path == canonical_root || canonical_path.starts_with(&canonical_root)) } -pub fn is_remote_posix_path_within_root(path: &str, root: &str) -> bool { - let normalized_path = normalize_absolute_posix_path(path); - let normalized_root = normalize_absolute_posix_path(root); - - if !normalized_path.starts_with('/') || !normalized_root.starts_with('/') { - return false; - } - - if normalized_root == "/" { - return true; - } - - normalized_path == normalized_root - || normalized_path - .strip_prefix(&normalized_root) - .is_some_and(|suffix| suffix.starts_with('/')) -} - fn canonicalize_best_effort(path: &Path) -> BitFunResult { if path.exists() { return dunce::canonicalize(path).map_err(|err| { @@ -82,35 +65,6 @@ fn canonicalize_best_effort(path: &Path) -> BitFunResult { } } -fn normalize_absolute_posix_path(path: &str) -> String { - let normalized = path.trim().replace('\\', "/"); - let is_absolute = normalized.starts_with('/'); - let mut segments = Vec::new(); - - for segment in normalized.split('/') { - match segment { - "" | "." => {} - ".." => { - if !segments.is_empty() { - segments.pop(); - } - } - value => segments.push(value.to_string()), - } - } - - let body = segments.join("/"); - if is_absolute { - if body.is_empty() { - "/".to_string() - } else { - format!("/{}", body) - } - } else { - body - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/crates/core/src/agentic/tools/runtime_assembly.rs b/src/crates/core/src/agentic/tools/runtime_assembly.rs deleted file mode 100644 index b3ac0ec8da..0000000000 --- a/src/crates/core/src/agentic/tools/runtime_assembly.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Core-owned product tool runtime assembly. -//! -//! This module is the single core-side owner for assembling the product tool -//! registry while concrete tools, `ToolUseContext`, runtime manifest assembly, -//! and snapshot decoration remain core-owned. - -use crate::agentic::tools::registry::{ProductToolDecoratorRef, ToolRef, ToolRegistry}; -use crate::agentic::tools::static_providers::builtin_static_tool_providers; -#[cfg(test)] -use bitfun_agent_tools::StaticToolProvider; -use bitfun_agent_tools::{SnapshotToolDecorator, SnapshotToolWrapper, ToolRuntimeAssembly}; -use std::sync::Arc; - -#[derive(Clone)] -pub(in crate::agentic::tools) struct ProductToolRuntimeAssembly { - tool_decorator: ProductToolDecoratorRef, -} - -impl Default for ProductToolRuntimeAssembly { - fn default() -> Self { - Self::new() - } -} - -impl ProductToolRuntimeAssembly { - pub(in crate::agentic::tools) fn new() -> Self { - Self::with_tool_decorator(Arc::new(SnapshotToolDecorator::new(Arc::new( - ProductSnapshotToolWrapper, - )))) - } - - pub(in crate::agentic::tools) fn with_tool_decorator( - tool_decorator: ProductToolDecoratorRef, - ) -> Self { - Self { tool_decorator } - } - - #[cfg(test)] - pub(in crate::agentic::tools) fn provider_group_ids(&self) -> Vec<&'static str> { - builtin_static_tool_providers() - .iter() - .map(|provider| provider.provider_id()) - .collect() - } - - pub(in crate::agentic::tools) fn create_registry(&self) -> ToolRegistry { - let providers = builtin_static_tool_providers(); - let inner = ToolRuntimeAssembly::with_tool_decorator(self.tool_decorator.clone()) - .create_registry_from_static_providers(&providers); - ToolRegistry::from_inner(inner) - } -} - -#[derive(Debug, Clone)] -struct ProductSnapshotToolWrapper; - -impl SnapshotToolWrapper - for ProductSnapshotToolWrapper -{ - fn wrap_for_snapshot_tracking(&self, tool: ToolRef) -> ToolRef { - crate::service::snapshot::wrap_tool_for_snapshot_tracking(tool) - } -} diff --git a/src/crates/core/src/agentic/tools/static_providers.rs b/src/crates/core/src/agentic/tools/static_providers.rs deleted file mode 100644 index ae22d2a653..0000000000 --- a/src/crates/core/src/agentic/tools/static_providers.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! Core-owned static tool provider assembly. - -use crate::agentic::tools::framework::Tool; -use crate::agentic::tools::implementations::*; -use bitfun_agent_tools::StaticToolProviderGroup; -use bitfun_tool_packs::product_tool_provider_group_plan; -use std::sync::Arc; - -pub(crate) fn builtin_static_tool_providers() -> Vec> { - product_tool_provider_group_plan() - .iter() - .map(|group| { - StaticToolProviderGroup::new(group.provider_id(), materialize_tools(group.tool_names())) - }) - .collect() -} - -fn materialize_tools(tool_names: &[&str]) -> Vec> { - tool_names - .iter() - .map(|tool_name| materialize_tool(tool_name)) - .collect() -} - -fn materialize_tool(tool_name: &str) -> Arc { - match tool_name { - "LS" => Arc::new(LSTool::new()), - "Read" => Arc::new(FileReadTool::new()), - "Glob" => Arc::new(GlobTool::new()), - "Grep" => Arc::new(GrepTool::new()), - "Write" => Arc::new(FileWriteTool::new()), - "Edit" => Arc::new(FileEditTool::new()), - "Delete" => Arc::new(DeleteFileTool::new()), - "Bash" => Arc::new(BashTool::new()), - "Task" => Arc::new(TaskTool::new()), - "Skill" => Arc::new(SkillTool::new()), - "AskUserQuestion" => Arc::new(AskUserQuestionTool::new()), - "TodoWrite" => Arc::new(TodoWriteTool::new()), - "CreatePlan" => Arc::new(CreatePlanTool::new()), - "submit_code_review" => Arc::new(CodeReviewTool::new()), - "GetToolSpec" => Arc::new(GetToolSpecTool::new()), - "GetFileDiff" => Arc::new(GetFileDiffTool::new()), - "Log" => Arc::new(LogTool::new()), - "TerminalControl" => Arc::new(TerminalControlTool::new()), - "SessionControl" => Arc::new(SessionControlTool::new()), - "SessionMessage" => Arc::new(SessionMessageTool::new()), - "SessionHistory" => Arc::new(SessionHistoryTool::new()), - "Cron" => Arc::new(CronTool::new()), - "WebSearch" => Arc::new(WebSearchTool::new()), - "WebFetch" => Arc::new(WebFetchTool::new()), - "ListMCPResources" => Arc::new(ListMCPResourcesTool::new()), - "ReadMCPResource" => Arc::new(ReadMCPResourceTool::new()), - "ListMCPPrompts" => Arc::new(ListMCPPromptsTool::new()), - "GetMCPPrompt" => Arc::new(GetMCPPromptTool::new()), - "GenerativeUI" => Arc::new(GenerativeUITool::new()), - "Git" => Arc::new(GitTool::new()), - "InitMiniApp" => Arc::new(InitMiniAppTool::new()), - "ControlHub" => Arc::new(ControlHubTool::new()), - "ComputerUse" => Arc::new(ComputerUseTool::new()), - "Playbook" => Arc::new(PlaybookTool::new()), - _ => panic!("unknown product tool provider plan entry: {tool_name}"), - } -} diff --git a/src/crates/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/core/src/agentic/tools/tool_context_runtime.rs new file mode 100644 index 0000000000..1d2fb7966f --- /dev/null +++ b/src/crates/core/src/agentic/tools/tool_context_runtime.rs @@ -0,0 +1,1055 @@ +//! Core-owned runtime bindings for `ToolUseContext`. +//! +//! This module intentionally keeps service handles, workspace runtime lookup, +//! path enforcement, cancellation/post-call hooks, and checkpoint recording in +//! core. The portable facts projection stays in `framework.rs` and +//! `bitfun-agent-tools`. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::deep_review::tool_context; +use crate::agentic::session::EvidenceLedgerCheckpoint; +use crate::agentic::tools::computer_use_host::ComputerUseHostRef; +use crate::agentic::tools::framework::{ + ToolPathBackend, ToolPathResolution, ToolResult, ToolUseContext, +}; +use crate::agentic::tools::pipeline::{ToolExecutionContext, ToolTask}; +use crate::agentic::tools::post_call_hooks; +use crate::agentic::tools::restrictions::{ + is_local_path_within_root, is_remote_posix_path_within_root, ToolPathOperation, +}; +use crate::agentic::tools::workspace_paths::{ + build_bitfun_runtime_uri, is_bitfun_runtime_uri, normalize_runtime_relative_path, + parse_bitfun_runtime_uri, +}; +use crate::agentic::tools::ToolRuntimeRestrictions; +use crate::agentic::workspace::WorkspaceServices; +use crate::agentic::WorkspaceBinding; +use crate::infrastructure::get_path_manager_arc; +use crate::service::git::{GitDiffParams, GitService}; +use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; +use crate::service::{get_workspace_runtime_service_arc, WorkspaceRuntimeContext}; +use crate::util::errors::{BitFunError, BitFunResult}; +use log::warn; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::future::Future; +use std::path::{Path, PathBuf}; +use tokio_util::sync::CancellationToken; + +pub(crate) async fn call_with_tool_runtime_hooks( + tool_name: &str, + input: &Value, + context: &ToolUseContext, + call_impl: impl Future>>, +) -> BitFunResult> { + let result = if let Some(cancellation_token) = context.cancellation_token.as_ref() { + tokio::select! { + result = call_impl => { + result + } + + _ = cancellation_token.cancelled() => { + Err(BitFunError::Cancelled("Tool execution cancelled".to_string())) + } + } + } else { + call_impl.await + }; + + if result.is_ok() { + post_call_hooks::record_successful_tool_call(tool_name, input, context); + } + + result +} + +pub(crate) fn build_tool_use_context_for_task( + task: &ToolTask, + computer_use_host: Option, + cancellation_token: CancellationToken, +) -> ToolUseContext { + build_tool_use_context_for_execution_context( + &task.context, + Some(task.tool_call.tool_id.clone()), + computer_use_host, + cancellation_token, + ) +} + +pub(crate) fn build_tool_use_context_for_execution_context( + context: &ToolExecutionContext, + tool_call_id: Option, + computer_use_host: Option, + cancellation_token: CancellationToken, +) -> ToolUseContext { + ToolUseContext { + tool_call_id, + agent_type: Some(context.agent_type.clone()), + session_id: Some(context.session_id.clone()), + dialog_turn_id: Some(context.dialog_turn_id.clone()), + workspace: context.workspace.clone(), + unlocked_collapsed_tools: context.unlocked_collapsed_tools.clone(), + custom_data: build_tool_context_custom_data(context), + computer_use_host, + cancellation_token: Some(cancellation_token), + runtime_tool_restrictions: context.runtime_tool_restrictions.clone(), + workspace_services: context.workspace_services.clone(), + } +} + +pub(crate) fn build_tool_description_context( + agent_type: &str, + workspace: Option<&WorkspaceBinding>, + workspace_services: Option<&WorkspaceServices>, + primary_supports_image_understanding: bool, + context_vars: &HashMap, +) -> ToolUseContext { + let mut custom_data = HashMap::new(); + custom_data.insert( + "primary_model_supports_image_understanding".to_string(), + Value::Bool(primary_supports_image_understanding), + ); + for (key, value) in context_vars { + custom_data.insert(key.clone(), Value::String(value.clone())); + } + + ToolUseContext { + tool_call_id: None, + agent_type: Some(agent_type.to_string()), + session_id: None, + dialog_turn_id: None, + workspace: workspace.cloned(), + unlocked_collapsed_tools: Vec::new(), + custom_data, + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: workspace_services.cloned(), + } +} + +pub(crate) fn build_write_preflight_context( + agent_type: &str, + session_id: &str, + dialog_turn_id: &str, + workspace: Option, + unlocked_collapsed_tools: Vec, + runtime_tool_restrictions: ToolRuntimeRestrictions, + workspace_services: Option, +) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: Some(agent_type.to_string()), + session_id: Some(session_id.to_string()), + dialog_turn_id: Some(dialog_turn_id.to_string()), + workspace, + unlocked_collapsed_tools, + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions, + workspace_services, + } +} + +fn build_tool_context_custom_data(context: &ToolExecutionContext) -> HashMap { + let mut map = HashMap::new(); + + if let Some(turn_index) = context.context_vars.get("turn_index") { + if let Ok(n) = turn_index.parse::() { + map.insert("turn_index".to_string(), serde_json::json!(n)); + } + } + + if let Some(provider) = context.context_vars.get("primary_model_provider") { + if !provider.is_empty() { + map.insert( + "primary_model_provider".to_string(), + serde_json::json!(provider), + ); + } + } + if let Some(supports_images) = context + .context_vars + .get("primary_model_supports_image_understanding") + { + if let Ok(flag) = supports_images.parse::() { + map.insert( + "primary_model_supports_image_understanding".to_string(), + serde_json::json!(flag), + ); + } + } + if let Some(write_tool_mode) = context.context_vars.get("write_tool_mode") { + if !write_tool_mode.is_empty() { + map.insert( + "write_tool_mode".to_string(), + serde_json::json!(write_tool_mode), + ); + } + } + if let Some(acp_transport) = context.context_vars.get("acp_transport") { + if let Ok(flag) = acp_transport.parse::() { + map.insert("acp_transport".to_string(), serde_json::json!(flag)); + } + } + + let deep_review_parent_context = context.subagent_parent_info.as_ref().map(|parent_info| { + tool_context::DeepReviewToolParentContext { + tool_call_id: parent_info.tool_call_id.as_str(), + session_id: parent_info.session_id.as_str(), + dialog_turn_id: parent_info.dialog_turn_id.as_str(), + } + }); + tool_context::append_tool_use_context_data( + &context.context_vars, + deep_review_parent_context, + &mut map, + ); + + map +} + +impl ToolUseContext { + pub fn ws_fs(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceFileSystem> { + self.workspace_services.as_ref().map(|s| s.fs.as_ref()) + } + + pub fn ws_shell(&self) -> Option<&dyn crate::agentic::workspace::WorkspaceShell> { + self.workspace_services.as_ref().map(|s| s.shell.as_ref()) + } + + pub async fn record_light_checkpoint( + &self, + tool_name: &str, + target: &str, + touched_files: Vec, + ) { + let Some(session_id) = self.session_id.as_deref() else { + return; + }; + let Some(turn_id) = self.dialog_turn_id.as_deref() else { + return; + }; + let Some(coordinator) = get_global_coordinator() else { + return; + }; + + let checkpoint = self.build_light_checkpoint(touched_files).await; + coordinator + .get_session_manager() + .record_checkpoint_created(session_id, turn_id, tool_name, target, checkpoint); + } + + async fn build_light_checkpoint(&self, touched_files: Vec) -> EvidenceLedgerCheckpoint { + let mut checkpoint = EvidenceLedgerCheckpoint { + current_branch: None, + dirty_state_summary: "workspace_unavailable".to_string(), + touched_files, + diff_hash: None, + }; + + if self.is_remote() { + checkpoint.dirty_state_summary = + "remote_workspace_git_metadata_unavailable".to_string(); + return checkpoint; + } + + let Some(workspace_root) = self.workspace_root() else { + return checkpoint; + }; + + match GitService::get_status(workspace_root).await { + Ok(status) => { + checkpoint.current_branch = Some(status.current_branch); + checkpoint.dirty_state_summary = format!( + "staged={}, unstaged={}, untracked={}", + status.staged.len(), + status.unstaged.len(), + status.untracked.len() + ); + } + Err(error) => { + checkpoint.dirty_state_summary = format!("git_status_unavailable: {}", error); + } + } + + checkpoint.diff_hash = self + .checkpoint_diff_hash(workspace_root, &checkpoint.touched_files) + .await; + checkpoint + } + + async fn checkpoint_diff_hash( + &self, + workspace_root: &Path, + touched_files: &[String], + ) -> Option { + let files = touched_files + .iter() + .filter_map(|file| git_relative_path(workspace_root, file)) + .collect::>(); + + if files.is_empty() { + return None; + } + + let mut diff = String::new(); + for staged in [false, true] { + let params = GitDiffParams { + files: Some(files.clone()), + staged: Some(staged), + ..Default::default() + }; + match GitService::get_diff(workspace_root, ¶ms).await { + Ok(part) => diff.push_str(&part), + Err(error) => { + warn!( + "Failed to collect checkpoint diff hash: staged={}, error={}", + staged, error + ); + return None; + } + } + } + + if diff.is_empty() { + return None; + } + + Some(hex::encode(Sha256::digest(diff.as_bytes()))) + } + + pub fn enforce_tool_runtime_restrictions(&self, tool_name: &str) -> BitFunResult<()> { + self.runtime_tool_restrictions + .ensure_tool_allowed(tool_name) + .map_err(Into::into) + } + + pub fn enforce_path_operation( + &self, + operation: ToolPathOperation, + resolution: &ToolPathResolution, + ) -> BitFunResult<()> { + let allowed_roots = self + .runtime_tool_restrictions + .path_policy + .roots_for(operation); + if allowed_roots.is_empty() { + return Ok(()); + } + + let mut resolved_roots = Vec::with_capacity(allowed_roots.len()); + for root in allowed_roots { + resolved_roots.push(self.resolve_tool_path(root)?); + } + + let mut is_allowed = false; + for root in &resolved_roots { + if root.backend != resolution.backend { + continue; + } + + let matches_root = match resolution.backend { + ToolPathBackend::Local => is_local_path_within_root( + Path::new(&resolution.resolved_path), + Path::new(&root.resolved_path), + )?, + ToolPathBackend::RemoteWorkspace => { + is_remote_posix_path_within_root(&resolution.resolved_path, &root.resolved_path) + } + }; + + if matches_root { + is_allowed = true; + break; + } + } + + if is_allowed { + return Ok(()); + } + + Err(BitFunError::validation(format!( + "Path '{}' is not allowed for {}. Allowed roots: {}", + resolution.logical_path, + operation.verb(), + allowed_roots.join(", ") + ))) + } + + /// Resolve a user or model-supplied path for file/shell tools. Uses POSIX semantics when the + /// workspace is remote SSH so Windows-hosted clients still resolve `/home/...` correctly. + pub fn resolve_workspace_tool_path(&self, path: &str) -> BitFunResult { + let workspace_root_owned = self + .workspace + .as_ref() + .map(|w| w.root_path_string()) + .ok_or_else(|| { + BitFunError::tool(format!( + "A workspace path is required to resolve tool path: {}", + path + )) + })?; + let resolved_path = crate::agentic::tools::workspace_paths::resolve_workspace_tool_path( + path, + Some(workspace_root_owned.as_str()), + self.is_remote(), + )?; + + // Remote SSH workspaces stay contained to the opened project tree. Local desktop + // sessions may use any host path the OS user can access (Bash already has the same + // reach); optional `path_policy` roots still apply via `enforce_path_operation`. + if self.is_remote() + && !is_remote_posix_path_within_root(&resolved_path, &workspace_root_owned) + { + return Err(BitFunError::tool(format!( + "Path '{}' resolves outside current workspace '{}': {}", + path, workspace_root_owned, resolved_path + ))); + } + + Ok(resolved_path) + } + + pub fn current_workspace_runtime_root(&self) -> BitFunResult { + let workspace = self.workspace.as_ref().ok_or_else(|| { + BitFunError::tool("A workspace is required to resolve runtime artifacts".to_string()) + })?; + + if workspace.is_remote() { + let identity = &workspace.session_identity; + Ok(remote_workspace_runtime_root( + &identity.hostname, + identity.logical_workspace_path(), + )) + } else { + Ok(get_path_manager_arc().project_runtime_root(workspace.root_path())) + } + } + + pub fn current_workspace_scope(&self) -> Option { + self.workspace + .as_ref() + .and_then(|workspace| workspace.workspace_id.clone()) + } + + pub async fn ensure_current_workspace_runtime(&self) -> BitFunResult { + let workspace = self.workspace.as_ref().ok_or_else(|| { + BitFunError::tool("A workspace is required to ensure runtime artifacts".to_string()) + })?; + + let runtime_service = get_workspace_runtime_service_arc(); + Ok(runtime_service + .ensure_runtime_for_workspace_binding(workspace) + .await? + .context) + } + + pub fn should_emit_runtime_uri(&self) -> bool { + self.is_remote() + } + + pub fn build_runtime_uri(&self, relative_path: &str) -> BitFunResult { + let scope = self + .current_workspace_scope() + .unwrap_or_else(|| "current".to_string()); + build_bitfun_runtime_uri(&scope, &normalize_runtime_relative_path(relative_path)?) + } + + pub fn build_runtime_artifact_reference(&self, relative_path: &str) -> BitFunResult { + let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; + if self.should_emit_runtime_uri() { + return self.build_runtime_uri(&normalized_relative_path); + } + + let mut resolved_path = self.current_workspace_runtime_root()?; + for segment in normalized_relative_path.split('/') { + resolved_path.push(segment); + } + + Ok(resolved_path.to_string_lossy().to_string()) + } + + pub fn build_session_runtime_artifact_reference( + &self, + session_id: &str, + relative_path: &str, + ) -> BitFunResult { + let normalized_relative_path = normalize_runtime_relative_path(relative_path)?; + self.build_runtime_artifact_reference(&format!( + "sessions/{}/{}", + session_id, normalized_relative_path + )) + } + + pub fn current_workspace_session_dir(&self, session_id: &str) -> BitFunResult { + Ok(self + .current_workspace_runtime_root()? + .join("sessions") + .join(session_id)) + } + + pub fn current_workspace_session_tool_results_dir( + &self, + session_id: &str, + ) -> BitFunResult { + Ok(self + .current_workspace_session_dir(session_id)? + .join("tool-results")) + } + + pub fn current_workspace_session_tool_result_path( + &self, + session_id: &str, + file_name: &str, + ) -> BitFunResult { + Ok(self + .current_workspace_session_tool_results_dir(session_id)? + .join(file_name)) + } + + pub fn resolve_tool_path(&self, path: &str) -> BitFunResult { + if is_bitfun_runtime_uri(path) { + let parsed = parse_bitfun_runtime_uri(path)?; + let workspace_scope = self.current_workspace_scope(); + let scope_matches = parsed.workspace_scope == "current" + || workspace_scope.as_deref() == Some(parsed.workspace_scope.as_str()); + if !scope_matches { + return Err(BitFunError::tool(format!( + "Runtime URI scope '{}' does not match the current workspace", + parsed.workspace_scope + ))); + } + + let runtime_root = self.current_workspace_runtime_root()?; + let mut resolved_path = runtime_root.clone(); + for segment in parsed.relative_path.split('/') { + resolved_path.push(segment); + } + + let effective_scope = workspace_scope.unwrap_or_else(|| parsed.workspace_scope.clone()); + let logical_path = build_bitfun_runtime_uri(&effective_scope, &parsed.relative_path)?; + + return Ok(ToolPathResolution { + requested_path: path.to_string(), + logical_path, + resolved_path: resolved_path.to_string_lossy().to_string(), + backend: ToolPathBackend::Local, + runtime_scope: Some(effective_scope), + runtime_root: Some(runtime_root), + }); + } + + let resolved_path = self.resolve_workspace_tool_path(path)?; + Ok(ToolPathResolution { + requested_path: path.to_string(), + logical_path: resolved_path.clone(), + resolved_path, + backend: if self.is_remote() { + ToolPathBackend::RemoteWorkspace + } else { + ToolPathBackend::Local + }, + runtime_scope: None, + runtime_root: None, + }) + } + + /// Whether `path` is absolute for the active workspace (POSIX `/` for remote SSH). + pub fn workspace_path_is_effectively_absolute(&self, path: &str) -> bool { + if is_bitfun_runtime_uri(path) { + return true; + } + if self.is_remote() { + crate::agentic::tools::workspace_paths::posix_style_path_is_absolute(path) + } else { + Path::new(path).is_absolute() + } + } +} + +fn git_relative_path(workspace_root: &Path, path: &str) -> Option { + if is_bitfun_runtime_uri(path) { + return None; + } + + let path = Path::new(path); + let relative = if path.is_absolute() { + path.strip_prefix(workspace_root).ok()? + } else { + path + }; + + Some(relative.to_string_lossy().replace('\\', "/")) +} + +#[cfg(test)] +mod path_resolution_tests { + use crate::agentic::tools::framework::ToolUseContext; + use crate::agentic::tools::{ToolPathOperation, ToolPathPolicy, ToolRuntimeRestrictions}; + use crate::agentic::WorkspaceBinding; + use crate::service::remote_ssh::workspace_state::workspace_session_identity; + use std::collections::HashMap; + use std::path::PathBuf; + + fn local_context(root: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new(None, PathBuf::from(root))), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + fn remote_context(root: &str, workspace_id: Option) -> ToolUseContext { + let session_identity = workspace_session_identity(root, Some("conn-1"), Some("ssh.dev")) + .expect("remote identity"); + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: Some(WorkspaceBinding::new_remote( + workspace_id, + PathBuf::from(root), + "conn-1".to_string(), + "Dev SSH".to_string(), + session_identity, + )), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + fn context_with_restrictions( + root: &str, + runtime_tool_restrictions: ToolRuntimeRestrictions, + ) -> ToolUseContext { + ToolUseContext { + runtime_tool_restrictions, + ..local_context(root) + } + } + + fn context_without_workspace() -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + #[test] + fn workspace_path_resolution_allows_absolute_paths_outside_local_workspace() { + let context = local_context("/repo/project"); + + let resolved = context + .resolve_workspace_tool_path("/tmp/pr_body.md") + .expect("local sessions may resolve paths outside the workspace root"); + + assert_eq!(PathBuf::from(resolved), PathBuf::from("/tmp/pr_body.md")); + } + + #[test] + fn workspace_path_resolution_rejects_absolute_paths_outside_remote_workspace() { + let context = remote_context("/home/wsp/projects/test", None); + + let err = context + .resolve_workspace_tool_path("/tmp/pr_body.md") + .expect_err("remote sessions must stay within the workspace root"); + + assert!(err.to_string().contains("outside current workspace")); + } + + #[test] + fn workspace_path_resolution_rejects_root_without_workspace() { + let context = context_without_workspace(); + + let err = context + .resolve_workspace_tool_path("/") + .expect_err("workspace tools must not scan the host root without a workspace"); + + assert!(err.to_string().contains("workspace path is required")); + } + + #[test] + fn workspace_path_resolution_allows_paths_inside_local_workspace() { + let context = local_context("/repo/project"); + + let resolved = context + .resolve_workspace_tool_path("/repo/project/src/main.rs") + .expect("absolute paths inside the workspace remain valid"); + + assert_eq!( + PathBuf::from(resolved), + PathBuf::from("/repo/project/src/main.rs") + ); + } + + #[test] + fn remote_runtime_artifact_reference_uses_runtime_uri_scope() { + let context = remote_context("/home/wsp/projects/test", Some("workspace-123".to_string())); + + let reference = context + .build_runtime_artifact_reference(r"plans\demo.plan.md") + .expect("remote runtime artifacts should use URI references"); + + assert_eq!( + reference, + "bitfun://runtime/workspace-123/plans/demo.plan.md" + ); + } + + #[test] + fn runtime_uri_resolution_rejects_different_workspace_scope() { + let context = remote_context("/home/wsp/projects/test", Some("workspace-123".to_string())); + + let err = context + .resolve_tool_path("bitfun://runtime/workspace-456/plans/demo.plan.md") + .expect_err("runtime artifact scopes must match the active workspace"); + + assert!(err + .to_string() + .contains("does not match the current workspace")); + } + + #[test] + fn workspace_absolute_detection_uses_remote_posix_semantics() { + let context = remote_context("/home/wsp/projects/test", None); + + assert!( + context.workspace_path_is_effectively_absolute("/home/wsp/projects/test/src/lib.rs") + ); + assert!(!context.workspace_path_is_effectively_absolute("src/lib.rs")); + } + + #[test] + fn path_policy_allows_only_configured_local_roots() { + let temp_root = std::env::temp_dir().join(format!( + "bitfun-tool-context-policy-{}", + uuid::Uuid::new_v4() + )); + let allowed_root = temp_root.join("allowed"); + std::fs::create_dir_all(&allowed_root).expect("create allowed root"); + let context = context_with_restrictions( + temp_root.to_string_lossy().as_ref(), + ToolRuntimeRestrictions { + path_policy: ToolPathPolicy { + write_roots: vec![allowed_root.to_string_lossy().to_string()], + ..Default::default() + }, + ..Default::default() + }, + ); + + let allowed = context + .resolve_tool_path(&allowed_root.join("file.txt").to_string_lossy()) + .expect("allowed path should resolve"); + context + .enforce_path_operation(ToolPathOperation::Write, &allowed) + .expect("path within configured root should be allowed"); + + let blocked = context + .resolve_tool_path(&temp_root.join("blocked/file.txt").to_string_lossy()) + .expect("blocked path should still resolve before policy enforcement"); + let err = context + .enforce_path_operation(ToolPathOperation::Write, &blocked) + .expect_err("path outside configured root should be blocked"); + + assert!(err.to_string().contains("is not allowed for write")); + + let _ = std::fs::remove_dir_all(&temp_root); + } +} + +#[cfg(test)] +mod call_runtime_tests { + use super::call_with_tool_runtime_hooks; + use crate::agentic::tools::framework::{ToolResult, ToolUseContext}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::util::errors::{BitFunError, BitFunResult}; + use serde_json::json; + use std::collections::HashMap; + use tokio::time::{sleep, Duration}; + use tokio_util::sync::CancellationToken; + + fn context_with_cancellation(cancellation_token: CancellationToken) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: Some(cancellation_token), + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + #[tokio::test] + async fn tool_call_runtime_hook_returns_cancelled_before_impl_completes() { + let cancellation_token = CancellationToken::new(); + cancellation_token.cancel(); + let context = context_with_cancellation(cancellation_token); + + let result = call_with_tool_runtime_hooks("Read", &json!({}), &context, async { + sleep(Duration::from_secs(30)).await; + Ok(vec![ToolResult::ok(json!({ "unexpected": true }), None)]) + }) + .await; + + assert!( + matches!(result, Err(BitFunError::Cancelled(message)) if message == "Tool execution cancelled") + ); + } + + #[tokio::test] + async fn tool_call_runtime_hook_preserves_success_result_without_cancellation() { + let context = ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: None, + dialog_turn_id: None, + workspace: None, + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + }; + + let result: BitFunResult> = + call_with_tool_runtime_hooks("Read", &json!({}), &context, async { + Ok(vec![ToolResult::ok( + json!({ "ok": true }), + Some("ok".to_string()), + )]) + }) + .await; + + let result = result.expect("tool result should pass through"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].content()["ok"], true); + } +} + +#[cfg(test)] +mod context_builder_tests { + use super::{build_tool_description_context, build_write_preflight_context}; + use crate::agentic::tools::ToolRuntimeRestrictions; + use serde_json::json; + use std::collections::{BTreeSet, HashMap}; + + #[test] + fn tool_description_context_preserves_manifest_custom_data_shape() { + let mut context_vars = HashMap::new(); + context_vars.insert("write_tool_mode".to_string(), "inline_content".to_string()); + context_vars.insert( + "primary_model_supports_image_understanding".to_string(), + "false".to_string(), + ); + + let context = build_tool_description_context("coding", None, None, true, &context_vars); + + assert_eq!(context.agent_type.as_deref(), Some("coding")); + assert!(context.tool_call_id.is_none()); + assert!(context.session_id.is_none()); + assert!(context.dialog_turn_id.is_none()); + assert!(context.workspace.is_none()); + assert!(context.unlocked_collapsed_tools.is_empty()); + assert!(context.cancellation_token.is_none()); + assert!(context.workspace_services.is_none()); + assert!(context.runtime_tool_restrictions.is_tool_allowed("Write")); + assert_eq!( + context.custom_data["primary_model_supports_image_understanding"], + json!("false") + ); + assert_eq!( + context.custom_data["write_tool_mode"], + json!("inline_content") + ); + } + + #[test] + fn write_preflight_context_preserves_minimal_runtime_fields() { + let restrictions = ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::from(["Write".to_string()]), + denied_tool_names: BTreeSet::from(["Delete".to_string()]), + path_policy: Default::default(), + }; + + let context = build_write_preflight_context( + "coding", + "session-1", + "turn-1", + None, + vec!["Write".to_string()], + restrictions, + None, + ); + + assert_eq!(context.agent_type.as_deref(), Some("coding")); + assert_eq!(context.session_id.as_deref(), Some("session-1")); + assert_eq!(context.dialog_turn_id.as_deref(), Some("turn-1")); + assert_eq!(context.unlocked_collapsed_tools, vec!["Write"]); + assert!(context.tool_call_id.is_none()); + assert!(context.custom_data.is_empty()); + assert!(context.cancellation_token.is_none()); + assert!(context.workspace_services.is_none()); + assert!(context.runtime_tool_restrictions.is_tool_allowed("Write")); + assert!(!context.runtime_tool_restrictions.is_tool_allowed("Delete")); + } +} + +#[cfg(test)] +mod task_context_tests { + use super::build_tool_use_context_for_task; + use crate::agentic::core::ToolCall; + use crate::agentic::tools::pipeline::{ + SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolTask, + }; + use crate::agentic::tools::ToolRuntimeRestrictions; + use serde_json::json; + use std::collections::{BTreeSet, HashMap}; + use tokio_util::sync::CancellationToken; + + fn task_with_context_vars() -> ToolTask { + let mut context_vars = HashMap::new(); + context_vars.insert("turn_index".to_string(), "7".to_string()); + context_vars.insert("primary_model_provider".to_string(), "openai".to_string()); + context_vars.insert( + "primary_model_supports_image_understanding".to_string(), + "true".to_string(), + ); + context_vars.insert("write_tool_mode".to_string(), "inline_content".to_string()); + context_vars.insert("acp_transport".to_string(), "true".to_string()); + context_vars.insert( + "deep_review_run_manifest".to_string(), + r#"{"run_id":"run-1"}"#.to_string(), + ); + context_vars.insert( + "deep_review_subagent_role".to_string(), + "reviewer".to_string(), + ); + context_vars.insert( + "deep_review_subagent_type".to_string(), + "ReviewSecurity".to_string(), + ); + + ToolTask::new( + ToolCall { + tool_id: "tool_context_1".to_string(), + tool_name: "WebFetch".to_string(), + arguments: json!({ "url": "https://example.com" }), + raw_arguments: None, + is_error: false, + recovered_from_truncation: false, + }, + ToolExecutionContext { + session_id: "session_1".to_string(), + dialog_turn_id: "turn_1".to_string(), + round_id: "round_1".to_string(), + agent_type: "agent".to_string(), + workspace: None, + context_vars, + subagent_parent_info: Some(SubagentParentInfo { + tool_call_id: "parent_tool".to_string(), + session_id: "parent_session".to_string(), + dialog_turn_id: "parent_turn".to_string(), + }), + collapsed_tools: vec!["WebFetch".to_string()], + unlocked_collapsed_tools: vec!["WebFetch".to_string()], + allowed_tools: vec!["WebFetch".to_string()], + runtime_tool_restrictions: ToolRuntimeRestrictions { + allowed_tool_names: BTreeSet::from(["WebFetch".to_string()]), + denied_tool_names: BTreeSet::from(["Bash".to_string()]), + path_policy: Default::default(), + }, + steering_interrupt: None, + workspace_services: None, + }, + ToolExecutionOptions::default(), + ) + } + + #[test] + fn tool_task_context_materialization_preserves_runtime_fields() { + let task = task_with_context_vars(); + + let context = build_tool_use_context_for_task(&task, None, CancellationToken::new()); + + assert_eq!(context.tool_call_id.as_deref(), Some("tool_context_1")); + assert_eq!(context.agent_type.as_deref(), Some("agent")); + assert_eq!(context.session_id.as_deref(), Some("session_1")); + assert_eq!(context.dialog_turn_id.as_deref(), Some("turn_1")); + assert_eq!(context.unlocked_collapsed_tools, vec!["WebFetch"]); + assert!(context.cancellation_token.is_some()); + assert!(context + .runtime_tool_restrictions + .is_tool_allowed("WebFetch")); + assert!(!context.runtime_tool_restrictions.is_tool_allowed("Bash")); + assert_eq!(context.custom_data["turn_index"], json!(7)); + assert_eq!( + context.custom_data["primary_model_provider"], + json!("openai") + ); + assert_eq!( + context.custom_data["primary_model_supports_image_understanding"], + json!(true) + ); + assert_eq!( + context.custom_data["write_tool_mode"], + json!("inline_content") + ); + assert_eq!(context.custom_data["acp_transport"], json!(true)); + assert_eq!( + context.custom_data["deep_review_run_manifest"], + json!({ "run_id": "run-1" }) + ); + assert_eq!( + context.custom_data["deep_review_parent_tool_call_id"], + json!("parent_tool") + ); + assert_eq!( + context.custom_data["deep_review_parent_session_id"], + json!("parent_session") + ); + assert_eq!( + context.custom_data["deep_review_parent_dialog_turn_id"], + json!("parent_turn") + ); + + let facts = context.to_tool_context_facts(); + let value = serde_json::to_value(&facts).expect("serialize context facts"); + assert_eq!(value["toolCallId"], "tool_context_1"); + assert_eq!(value["sessionId"], "session_1"); + assert!(value.get("unlockedCollapsedTools").is_none()); + assert!(value.get("customData").is_none()); + assert!(value.get("cancellationToken").is_none()); + } +} diff --git a/src/crates/core/src/agentic/tools/tool_result_storage.rs b/src/crates/core/src/agentic/tools/tool_result_storage.rs new file mode 100644 index 0000000000..cfabef7d33 --- /dev/null +++ b/src/crates/core/src/agentic/tools/tool_result_storage.rs @@ -0,0 +1,661 @@ +//! Shared handling for oversized tool results. +//! +//! The model should not receive unbounded tool output. Large outputs are stored +//! under the session runtime directory and replaced, for the assistant only, by +//! a small preview plus a stable reference to the full content. + +use crate::agentic::core::ToolResult; +use crate::agentic::tools::framework::ToolUseContext; +use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_agent_tools::GET_TOOL_SPEC_TOOL_NAME; +use log::{debug, warn}; +use std::collections::HashSet; +use std::path::Path; + +pub(crate) const DEFAULT_MAX_TOOL_RESULT_CHARS: usize = 50_000; +/// Keep in sync with `FileReadTool::DEFAULT_READ_MAX_TOTAL_CHARS` plus wrapper overhead. +pub(crate) const READ_MAX_TOOL_RESULT_CHARS: usize = 72_000; +pub(crate) const MAX_TOOL_RESULTS_PER_ROUND_CHARS: usize = 200_000; +pub(crate) const TOOL_RESULT_PREVIEW_CHARS: usize = 2_000; +pub(crate) const PERSISTED_OUTPUT_TAG: &str = ""; +pub(crate) const PERSISTED_OUTPUT_CLOSING_TAG: &str = ""; + +const READ_TOOL_NAME: &str = "Read"; +const BASH_TOOL_NAME: &str = "Bash"; +const SHELL_MAX_TOOL_RESULT_CHARS: usize = 30_000; + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ToolResultStoragePolicy { + pub per_tool_limit_chars: usize, + pub per_round_limit_chars: usize, + pub preview_chars: usize, +} + +impl Default for ToolResultStoragePolicy { + fn default() -> Self { + Self { + per_tool_limit_chars: DEFAULT_MAX_TOOL_RESULT_CHARS, + per_round_limit_chars: MAX_TOOL_RESULTS_PER_ROUND_CHARS, + preview_chars: TOOL_RESULT_PREVIEW_CHARS, + } + } +} + +#[derive(Debug, Clone)] +struct PersistedToolResult { + reference: String, + original_chars: usize, + line_count: usize, + preview: String, + has_more: bool, + metadata: Vec<(String, String)>, +} + +#[derive(Debug, Clone)] +struct ToolResultCandidate { + index: usize, + visible_chars: usize, +} + +pub(crate) async fn maybe_persist_large_tool_result( + mut result: ToolResult, + context: &ToolUseContext, +) -> ToolResult { + let policy = ToolResultStoragePolicy::default(); + if should_skip_tool_result(&result) || visible_content_is_compacted(&result) { + return result; + } + + let per_tool_limit = effective_per_tool_limit(&result.tool_name, policy); + let visible_chars = result_visible_content(&result).chars().count(); + let content_override = content_override_if_oversized(&result, per_tool_limit); + if visible_chars <= per_tool_limit + && content_override.is_none() + && !json_result_is_oversized(&result, per_tool_limit) + { + return result; + } + + match persist_and_render_replacement(&result, context, policy, content_override).await { + Ok(replacement) => { + result.result_for_assistant = Some(replacement); + result + } + Err(error) => { + warn!( + "Failed to persist oversized tool result: tool_name={}, tool_id={}, error={}", + result.tool_name, result.tool_id, error + ); + result + } + } +} + +pub(crate) async fn apply_round_tool_result_budget( + mut results: Vec, + context: &ToolUseContext, +) -> Vec { + let policy = ToolResultStoragePolicy::default(); + let candidates = collect_round_budget_candidates(&results); + let total_visible_chars = candidates + .iter() + .map(|candidate| candidate.visible_chars) + .sum::(); + + if total_visible_chars <= policy.per_round_limit_chars { + return results; + } + + let selected = select_candidates_to_persist( + &candidates, + total_visible_chars, + policy.per_round_limit_chars, + ); + if selected.is_empty() { + return results; + } + + let selected_indices = selected.into_iter().collect::>(); + let mut replaced_count = 0usize; + for (index, result) in results.iter_mut().enumerate() { + if !selected_indices.contains(&index) { + continue; + } + + match persist_and_render_replacement(result, context, policy, None).await { + Ok(replacement) => { + result.result_for_assistant = Some(replacement); + replaced_count += 1; + } + Err(error) => { + warn!( + "Failed to persist round-budget tool result: tool_name={}, tool_id={}, error={}", + result.tool_name, result.tool_id, error + ); + } + } + } + + if replaced_count > 0 { + debug!( + "Round tool result budget enforced: replaced={}, total_visible_chars={}, limit={}", + replaced_count, total_visible_chars, policy.per_round_limit_chars + ); + } + + results +} + +fn should_skip_tool_result(result: &ToolResult) -> bool { + result.tool_name == GET_TOOL_SPEC_TOOL_NAME + || result + .image_attachments + .as_ref() + .is_some_and(|v| !v.is_empty()) +} + +fn collect_round_budget_candidates(results: &[ToolResult]) -> Vec { + results + .iter() + .enumerate() + .filter(|(_, result)| !should_skip_tool_result(result)) + .filter(|(_, result)| !visible_content_is_compacted(result)) + .map(|(index, result)| ToolResultCandidate { + index, + visible_chars: result_visible_content(result).chars().count(), + }) + .collect() +} + +fn select_candidates_to_persist( + candidates: &[ToolResultCandidate], + total_visible_chars: usize, + limit: usize, +) -> Vec { + let mut sorted = candidates.to_vec(); + sorted.sort_by(|a, b| b.visible_chars.cmp(&a.visible_chars)); + + let mut selected = Vec::new(); + let mut remaining = total_visible_chars; + for candidate in sorted { + if remaining <= limit { + break; + } + selected.push(candidate.index); + remaining = remaining.saturating_sub(candidate.visible_chars); + } + selected +} + +async fn persist_and_render_replacement( + result: &ToolResult, + context: &ToolUseContext, + policy: ToolResultStoragePolicy, + content_override: Option, +) -> BitFunResult { + let persisted = + persist_tool_result(result, context, policy.preview_chars, content_override).await?; + Ok(build_persisted_output_message( + &persisted, + policy.preview_chars, + )) +} + +async fn persist_tool_result( + result: &ToolResult, + context: &ToolUseContext, + preview_chars: usize, + content_override: Option, +) -> BitFunResult { + let session_id = context.session_id.as_deref().ok_or_else(|| { + BitFunError::tool("A session id is required to persist tool results".to_string()) + })?; + + let (serialized, is_json) = if let Some(content) = content_override { + (content, false) + } else { + serialize_tool_result_content(result)? + }; + let file_name = tool_result_file_name(&result.tool_id, is_json); + let path = context.current_workspace_session_tool_result_path(session_id, &file_name)?; + + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|error| { + BitFunError::io(format!( + "Failed to create tool result directory {}: {}", + parent.display(), + error + )) + })?; + } + + write_once(&path, &serialized).await?; + + let reference = context + .build_session_runtime_artifact_reference( + session_id, + &format!("tool-results/{}", file_name), + ) + .unwrap_or_else(|_| path.display().to_string()); + let (preview, has_more) = generate_preview(&serialized, preview_chars); + + debug!( + "Persisted oversized tool result: tool_name={}, tool_id={}, chars={}, path={}", + result.tool_name, + result.tool_id, + serialized.chars().count(), + path.display() + ); + + Ok(PersistedToolResult { + reference, + original_chars: serialized.chars().count(), + line_count: count_text_lines(&serialized), + preview, + has_more, + metadata: tool_result_metadata(result), + }) +} + +async fn write_once(path: &Path, content: &str) -> BitFunResult<()> { + match tokio::fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(path) + .await + { + Ok(mut file) => { + use tokio::io::AsyncWriteExt; + file.write_all(content.as_bytes()).await.map_err(|error| { + BitFunError::io(format!( + "Failed to write tool result file {}: {}", + path.display(), + error + )) + }) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()), + Err(error) => Err(BitFunError::io(format!( + "Failed to create tool result file {}: {}", + path.display(), + error + ))), + } +} + +fn serialize_tool_result_content(result: &ToolResult) -> BitFunResult<(String, bool)> { + if let Some(text) = result.result_for_assistant.as_ref() { + return Ok((text.clone(), false)); + } + + serde_json::to_string_pretty(&result.result) + .or_else(|_| serde_json::to_string(&result.result)) + .map(|text| (text, true)) + .map_err(|error| { + BitFunError::serialization(format!("Failed to serialize tool result: {}", error)) + }) +} + +fn effective_per_tool_limit(tool_name: &str, policy: ToolResultStoragePolicy) -> usize { + match tool_name { + READ_TOOL_NAME => READ_MAX_TOOL_RESULT_CHARS, + BASH_TOOL_NAME => SHELL_MAX_TOOL_RESULT_CHARS, + _ => policy.per_tool_limit_chars, + } +} + +fn content_override_if_oversized(result: &ToolResult, limit: usize) -> Option { + if result.tool_name != BASH_TOOL_NAME { + return None; + } + + let output = result + .result + .get("output") + .and_then(|value| value.as_str())?; + (output.chars().count() > limit).then(|| output.to_string()) +} + +fn json_result_is_oversized(result: &ToolResult, limit: usize) -> bool { + if result.result_for_assistant.is_some() { + return false; + } + + serde_json::to_string_pretty(&result.result) + .or_else(|_| serde_json::to_string(&result.result)) + .map(|text| text.chars().count() > limit) + .unwrap_or(false) +} + +fn result_visible_content(result: &ToolResult) -> String { + if let Some(text) = result + .result_for_assistant + .as_ref() + .filter(|text| !text.is_empty()) + { + return text.clone(); + } + + serde_json::to_string_pretty(&result.result) + .or_else(|_| serde_json::to_string(&result.result)) + .unwrap_or_else(|_| format!("Tool {} execution completed", result.tool_name)) +} + +fn visible_content_is_compacted(result: &ToolResult) -> bool { + result + .result_for_assistant + .as_deref() + .is_some_and(|text| text.starts_with(PERSISTED_OUTPUT_TAG)) +} + +fn tool_result_file_name(tool_id: &str, is_json: bool) -> String { + let safe_id = sanitize_file_component(tool_id); + let ext = if is_json { "json" } else { "txt" }; + format!("{}.{}", safe_id, ext) +} + +fn sanitize_file_component(value: &str) -> String { + let mut sanitized = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '_' + } + }) + .collect::(); + + if sanitized.is_empty() { + sanitized = uuid::Uuid::new_v4().to_string(); + } + sanitized +} + +fn generate_preview(content: &str, max_chars: usize) -> (String, bool) { + if content.chars().count() <= max_chars { + return (content.to_string(), false); + } + + let prefix = content.chars().take(max_chars).collect::(); + let cut_point = prefix + .char_indices() + .filter_map(|(idx, ch)| (ch == '\n').then_some(idx)) + .last() + .filter(|idx| *idx > prefix.len() / 2) + .unwrap_or(prefix.len()); + + (prefix[..cut_point].to_string(), true) +} + +fn count_text_lines(content: &str) -> usize { + if content.is_empty() { + 0 + } else { + content.lines().count() + } +} + +fn build_persisted_output_message(result: &PersistedToolResult, preview_chars: usize) -> String { + let mut message = format!( + "{PERSISTED_OUTPUT_TAG}\nOutput too large ({} chars). Full output saved to: {}\nLine count: {}\n\nPreview (first {} chars):\n{}", + result.original_chars, result.reference, result.line_count, preview_chars, result.preview + ); + if result.has_more { + message.push_str("\n...\n"); + } else { + message.push('\n'); + } + if !result.metadata.is_empty() { + message.push_str("\nMetadata:\n"); + for (key, value) in &result.metadata { + message.push_str(&format!("- {}: {}\n", key, value)); + } + } + message.push_str(PERSISTED_OUTPUT_CLOSING_TAG); + message +} + +fn tool_result_metadata(result: &ToolResult) -> Vec<(String, String)> { + let Some(object) = result.result.as_object() else { + return Vec::new(); + }; + + [ + "success", + "exit_code", + "timed_out", + "working_directory", + "terminal_session_id", + ] + .into_iter() + .filter_map(|key| { + let value = object.get(key)?; + let rendered = value + .as_str() + .map(str::to_string) + .unwrap_or_else(|| value.to_string()); + Some((key.to_string(), rendered)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::ToolRuntimeRestrictions; + use crate::agentic::WorkspaceBinding; + use serde_json::json; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_context(root: PathBuf) -> ToolUseContext { + ToolUseContext { + tool_call_id: Some("call_1".to_string()), + agent_type: Some("agent".to_string()), + session_id: Some("session_1".to_string()), + dialog_turn_id: Some("turn_1".to_string()), + workspace: Some(WorkspaceBinding::new(None, root)), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: ToolRuntimeRestrictions::default(), + workspace_services: None, + } + } + + fn temp_workspace(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "bitfun-tool-result-storage-{}-{}", + name, + uuid::Uuid::new_v4() + )) + } + + fn tool_result(tool_id: &str, tool_name: &str, text: String) -> ToolResult { + ToolResult { + tool_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + result: json!({ "content": text }), + result_for_assistant: Some(text), + is_error: false, + duration_ms: None, + image_attachments: None, + } + } + + fn bash_result(tool_id: &str, output: String, result_for_assistant: String) -> ToolResult { + ToolResult { + tool_id: tool_id.to_string(), + tool_name: "Bash".to_string(), + result: json!({ + "success": false, + "output": output, + "exit_code": 1, + "timed_out": false, + "working_directory": "/repo", + "terminal_session_id": "term_1" + }), + result_for_assistant: Some(result_for_assistant), + is_error: false, + duration_ms: None, + image_attachments: None, + } + } + + #[tokio::test] + async fn single_large_result_persists_and_replaces_assistant_text() { + let root = temp_workspace("single"); + let context = test_context(root.clone()); + let result = tool_result( + "tool/one", + "Bash", + "x".repeat(DEFAULT_MAX_TOOL_RESULT_CHARS + 1), + ); + + let processed = maybe_persist_large_tool_result(result, &context).await; + let assistant = processed.result_for_assistant.unwrap_or_default(); + + assert!(assistant.starts_with(PERSISTED_OUTPUT_TAG)); + assert!(assistant.contains("Full output saved to:")); + assert!(assistant.contains("Preview")); + assert!(assistant.len() < DEFAULT_MAX_TOOL_RESULT_CHARS); + + let session_dir = context + .current_workspace_session_tool_results_dir("session_1") + .expect("session tool-results dir"); + assert!(session_dir.join("tool_one.txt").exists()); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn read_result_is_persisted_when_over_read_limit() { + let root = temp_workspace("read"); + let context = test_context(root.clone()); + let text = "x".repeat(READ_MAX_TOOL_RESULT_CHARS + 1); + let result = tool_result("read_1", "Read", text); + + let processed = maybe_persist_large_tool_result(result, &context).await; + let assistant = processed.result_for_assistant.unwrap_or_default(); + + assert!(assistant.starts_with(PERSISTED_OUTPUT_TAG)); + assert!(assistant.contains("Full output saved to:")); + let session_dir = context + .current_workspace_session_tool_results_dir("session_1") + .expect("session tool-results dir"); + assert!(session_dir.join("read_1.txt").exists()); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn read_result_stays_inline_when_under_read_limit() { + let root = temp_workspace("read-inline"); + let context = test_context(root.clone()); + let text = "x".repeat(READ_MAX_TOOL_RESULT_CHARS); + let result = tool_result("read_1", "Read", text.clone()); + + let processed = maybe_persist_large_tool_result(result, &context).await; + + assert_eq!( + processed.result_for_assistant.as_deref(), + Some(text.as_str()) + ); + let session_dir = context + .current_workspace_session_tool_results_dir("session_1") + .expect("session tool-results dir"); + assert!(!session_dir.join("read_1.txt").exists()); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn get_tool_spec_result_is_not_persisted_even_when_large() { + let root = temp_workspace("get-tool-spec"); + let context = test_context(root.clone()); + let text = "x".repeat(DEFAULT_MAX_TOOL_RESULT_CHARS + 1); + let result = tool_result("get_tool_spec_1", GET_TOOL_SPEC_TOOL_NAME, text.clone()); + + let processed = maybe_persist_large_tool_result(result, &context).await; + + assert_eq!( + processed.result_for_assistant.as_deref(), + Some(text.as_str()) + ); + let session_dir = context + .current_workspace_session_tool_results_dir("session_1") + .expect("session tool-results dir"); + assert!(!session_dir.join("get_tool_spec_1.txt").exists()); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn bash_full_output_persists_even_when_assistant_text_is_already_truncated() { + let root = temp_workspace("bash"); + let context = test_context(root.clone()); + let full_output = format!( + "{}\nfinal-error", + "x".repeat(SHELL_MAX_TOOL_RESULT_CHARS + 1) + ); + let result = bash_result( + "bash_1", + full_output.clone(), + "tail only".to_string(), + ); + + let processed = maybe_persist_large_tool_result(result, &context).await; + let assistant = processed.result_for_assistant.unwrap_or_default(); + + assert!(assistant.starts_with(PERSISTED_OUTPUT_TAG)); + assert!(assistant.contains("exit_code: 1")); + assert!(assistant.contains("working_directory: /repo")); + assert!(assistant.contains("Line count: 2")); + let output_path = context + .current_workspace_session_tool_result_path("session_1", "bash_1.txt") + .expect("tool result path"); + let saved = std::fs::read_to_string(output_path).expect("saved output"); + assert_eq!(saved, full_output); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn round_budget_persists_largest_results_including_read() { + let root = temp_workspace("round"); + let context = test_context(root.clone()); + let read = tool_result("read_1", "Read", "a".repeat(170_000)); + let medium = tool_result("medium_1", "WebFetch", "b".repeat(60_000)); + let bash = tool_result("bash_1", "Bash", "c".repeat(30_000)); + + let processed = + apply_round_tool_result_budget(vec![read, medium, bash], &context).await; + + assert!(processed[0] + .result_for_assistant + .as_deref() + .unwrap_or_default() + .starts_with(PERSISTED_OUTPUT_TAG)); + assert!(!processed[1] + .result_for_assistant + .as_deref() + .unwrap_or_default() + .starts_with(PERSISTED_OUTPUT_TAG)); + assert!(!processed[2] + .result_for_assistant + .as_deref() + .unwrap_or_default() + .starts_with(PERSISTED_OUTPUT_TAG)); + + let session_dir = context + .current_workspace_session_tool_results_dir("session_1") + .expect("session tool-results dir"); + assert!(session_dir.join("read_1.txt").exists()); + assert!(!session_dir.join("medium_1.txt").exists()); + assert!(!session_dir.join("bash_1.txt").exists()); + + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/src/crates/core/src/agentic/tools/workspace_paths.rs b/src/crates/core/src/agentic/tools/workspace_paths.rs index 7393eefbe1..ead797518f 100644 --- a/src/crates/core/src/agentic/tools/workspace_paths.rs +++ b/src/crates/core/src/agentic/tools/workspace_paths.rs @@ -5,164 +5,49 @@ //! incorrectly. Remote sessions must use POSIX path semantics for tool arguments. use crate::util::errors::{BitFunError, BitFunResult}; -use std::path::{Component, Path, PathBuf}; - -pub const BITFUN_RUNTIME_URI_PREFIX: &str = "bitfun://runtime/"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ParsedBitFunRuntimeUri { - pub workspace_scope: String, - pub relative_path: String, -} +pub use bitfun_agent_tools::{ + BITFUN_RUNTIME_URI_PREFIX, ParsedBitFunRuntimeUri, is_bitfun_runtime_uri, +}; +use std::path::Path; pub fn normalize_path(path: &str) -> String { - let path = Path::new(path); - let mut components = Vec::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - if !components.is_empty() { - components.pop(); - } - } - c => components.push(c), - } - } - components - .iter() - .collect::() - .to_string_lossy() - .to_string() + bitfun_agent_tools::normalize_host_path(path) } pub fn resolve_path_with_workspace( path: &str, workspace_root: Option<&Path>, ) -> BitFunResult { - if Path::new(path).is_absolute() { - Ok(normalize_path(path)) - } else { - let base_path = workspace_root.ok_or_else(|| { - BitFunError::tool(format!( - "A workspace path is required to resolve relative path: {}", - path - )) - })?; - - Ok(normalize_path( - base_path.join(path).to_string_lossy().as_ref(), - )) - } + bitfun_agent_tools::resolve_host_path_with_workspace(path, workspace_root) + .map_err(|error| BitFunError::tool(error.to_string())) } pub fn resolve_path(path: &str) -> BitFunResult { - resolve_path_with_workspace(path, None) -} - -pub fn is_bitfun_runtime_uri(path: &str) -> bool { - path.trim().starts_with(BITFUN_RUNTIME_URI_PREFIX) + bitfun_agent_tools::resolve_host_path(path) + .map_err(|error| BitFunError::tool(error.to_string())) } pub fn normalize_runtime_relative_path(path: &str) -> BitFunResult { - let normalized = path.trim().replace('\\', "/"); - let trimmed = normalized.trim_matches('/'); - if trimmed.is_empty() { - return Err(BitFunError::tool( - "Runtime artifact path cannot be empty".to_string(), - )); - } - - let mut segments = Vec::new(); - for part in trimmed.split('/') { - match part { - "" | "." => continue, - ".." => { - return Err(BitFunError::tool( - "Runtime artifact path cannot escape its root".to_string(), - )) - } - value => segments.push(value.to_string()), - } - } - - if segments.is_empty() { - return Err(BitFunError::tool( - "Runtime artifact path cannot be empty".to_string(), - )); - } - - Ok(segments.join("/")) + bitfun_agent_tools::normalize_runtime_relative_path(path) + .map_err(|error| BitFunError::tool(error.to_string())) } pub fn parse_bitfun_runtime_uri(path: &str) -> BitFunResult { - let trimmed = path.trim(); - let suffix = trimmed - .strip_prefix(BITFUN_RUNTIME_URI_PREFIX) - .ok_or_else(|| BitFunError::tool(format!("Unsupported runtime URI: {}", path)))?; - - let mut parts = suffix.splitn(2, '/'); - let workspace_scope = parts - .next() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| BitFunError::tool("Runtime URI is missing workspace scope".to_string()))? - .to_string(); - let relative_path = parts - .next() - .ok_or_else(|| BitFunError::tool("Runtime URI is missing artifact path".to_string()))?; - - Ok(ParsedBitFunRuntimeUri { - workspace_scope, - relative_path: normalize_runtime_relative_path(relative_path)?, - }) + bitfun_agent_tools::parse_bitfun_runtime_uri(path) + .map_err(|error| BitFunError::tool(error.to_string())) } pub fn build_bitfun_runtime_uri( workspace_scope: &str, relative_path: &str, ) -> BitFunResult { - let scope = workspace_scope.trim(); - if scope.is_empty() { - return Err(BitFunError::tool( - "Runtime URI workspace scope cannot be empty".to_string(), - )); - } - - Ok(format!( - "{}{}/{}", - BITFUN_RUNTIME_URI_PREFIX, - scope, - normalize_runtime_relative_path(relative_path)? - )) + bitfun_agent_tools::build_bitfun_runtime_uri(workspace_scope, relative_path) + .map_err(|error| BitFunError::tool(error.to_string())) } /// POSIX absolute: after normalizing backslashes, path starts with `/`. pub fn posix_style_path_is_absolute(path: &str) -> bool { - let p = path.trim().replace('\\', "/"); - p.starts_with('/') -} - -fn posix_normalize_components(path: &str) -> String { - let path = path.trim().replace('\\', "/"); - let is_abs = path.starts_with('/'); - let mut stack: Vec = Vec::new(); - for part in path.split('/') { - if part.is_empty() || part == "." { - continue; - } - if part == ".." { - stack.pop(); - } else { - stack.push(part.to_string()); - } - } - let body = stack.join("/"); - if is_abs { - format!("/{}", body) - } else { - body - } + bitfun_agent_tools::posix_style_path_is_absolute(path) } /// Resolve a path using POSIX rules (for remote SSH workspaces). @@ -170,30 +55,8 @@ pub fn posix_resolve_path_with_workspace( path: &str, workspace_root: Option<&str>, ) -> BitFunResult { - let path = path.trim(); - if path.is_empty() { - return Err(BitFunError::tool("path cannot be empty".to_string())); - } - - let normalized_input = path.replace('\\', "/"); - - let combined = if posix_style_path_is_absolute(&normalized_input) { - normalized_input - } else { - let base = workspace_root - .ok_or_else(|| { - BitFunError::tool(format!( - "A workspace path is required to resolve relative path: {}", - path - )) - })? - .trim() - .replace('\\', "/"); - let base = base.trim_end_matches('/'); - format!("{}/{}", base, normalized_input) - }; - - Ok(posix_normalize_components(&combined)) + bitfun_agent_tools::posix_resolve_path_with_workspace(path, workspace_root) + .map_err(|error| BitFunError::tool(error.to_string())) } /// Unified resolver: POSIX semantics when the workspace is remote SSH; otherwise host `Path`. @@ -202,16 +65,14 @@ pub fn resolve_workspace_tool_path( workspace_root: Option<&str>, workspace_is_remote: bool, ) -> BitFunResult { - if workspace_is_remote { - posix_resolve_path_with_workspace(path, workspace_root) - } else { - resolve_path_with_workspace(path, workspace_root.map(Path::new)) - } + bitfun_agent_tools::resolve_workspace_tool_path(path, workspace_root, workspace_is_remote) + .map_err(|error| BitFunError::tool(error.to_string())) } #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; #[test] fn resolves_relative_paths_from_workspace_root() { diff --git a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs index d280df27ca..f881ed02d7 100644 --- a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs +++ b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs @@ -1,10 +1,7 @@ use super::types::*; use crate::function_agents::common::AgentResult; -use crate::function_agents::port_adapters::{ - CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, -}; use crate::infrastructure::ai::AIClientFactory; -use bitfun_product_domains::function_agents::ports::FunctionAgentRuntimeFacade; +use crate::product_domain_runtime::CoreProductDomainRuntime; /** * Git Function Agent - commit message generator * @@ -27,9 +24,10 @@ impl CommitGenerator { repo_path ); - let git_adapter = CoreFunctionAgentGitAdapter::default(); - let ai_adapter = CoreFunctionAgentAiAdapter::new(factory); - let facade = FunctionAgentRuntimeFacade::new(&git_adapter, &ai_adapter); + let git_adapter = CoreProductDomainRuntime::function_agent_git_adapter(); + let ai_adapter = CoreProductDomainRuntime::function_agent_ai_adapter(factory); + let facade = + CoreProductDomainRuntime::function_agent_runtime_facade(&git_adapter, &ai_adapter); facade .generate_commit_message(repo_path.to_path_buf(), options) .await diff --git a/src/crates/core/src/function_agents/port_adapters.rs b/src/crates/core/src/function_agents/port_adapters.rs index 2dc203a99c..ee99c0581b 100644 --- a/src/crates/core/src/function_agents/port_adapters.rs +++ b/src/crates/core/src/function_agents/port_adapters.rs @@ -234,6 +234,8 @@ mod tests { use std::path::{Path, PathBuf}; use std::process::Command; + use crate::product_domain_runtime::CoreProductDomainRuntime; + use super::CoreFunctionAgentGitAdapter; struct TestTempDir { @@ -382,6 +384,11 @@ mod tests { assert!(snapshot.last_commit_timestamp.is_none()); } + #[test] + fn core_product_domain_runtime_owner_constructs_function_agent_git_adapter() { + let _adapter = CoreProductDomainRuntime::function_agent_git_adapter(); + } + fn init_git_repo(repo: &std::path::Path) { git(repo, &["init", "-b", "main"]); git(repo, &["config", "user.email", "test@example.com"]); diff --git a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs index ddbeb3dd31..14dc3b7134 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs @@ -1,10 +1,7 @@ use super::types::*; use crate::function_agents::common::AgentResult; -use crate::function_agents::port_adapters::{ - CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, -}; use crate::infrastructure::ai::AIClientFactory; -use bitfun_product_domains::function_agents::ports::FunctionAgentRuntimeFacade; +use crate::product_domain_runtime::CoreProductDomainRuntime; use chrono::{Local, Timelike}; /** * Work state analyzer @@ -26,9 +23,10 @@ impl WorkStateAnalyzer { info!("Analyzing work state: repo_path={:?}", repo_path); let now = Local::now(); - let git_adapter = CoreFunctionAgentGitAdapter::default(); - let ai_adapter = CoreFunctionAgentAiAdapter::new(factory); - let facade = FunctionAgentRuntimeFacade::new(&git_adapter, &ai_adapter); + let git_adapter = CoreProductDomainRuntime::function_agent_git_adapter(); + let ai_adapter = CoreProductDomainRuntime::function_agent_ai_adapter(factory); + let facade = + CoreProductDomainRuntime::function_agent_runtime_facade(&git_adapter, &ai_adapter); // Keep the legacy analyzed_at timing in core: assign it after AI analysis completes. let mut analysis = facade .analyze_work_state( diff --git a/src/crates/core/src/lib.rs b/src/crates/core/src/lib.rs index 90720a0213..9e7da0d2b7 100644 --- a/src/crates/core/src/lib.rs +++ b/src/crates/core/src/lib.rs @@ -5,11 +5,18 @@ //! New implementation code should live in owner crates under `src/crates/*`. //! This crate re-exports legacy paths and wires the full BitFun product runtime. +#[cfg(feature = "product-full")] pub mod agentic; // Agent system, tool system, and product runtime orchestration +#[cfg(feature = "product-domains")] pub mod function_agents; // Function-based agents pub mod infrastructure; // AI clients, storage, logging, events +#[cfg(feature = "product-domains")] pub mod miniapp; // AI-generated instant apps (Zero-Dialect Runtime) +#[cfg(feature = "product-domains")] +pub(crate) mod product_domain_runtime; pub mod service; // Workspace, Config, FileSystem, Terminal, Git +#[cfg(feature = "service-integrations")] +pub(crate) mod service_agent_runtime; pub mod util; // General types, errors, helper functions // Re-export debug_log from infrastructure for backward compatibility. @@ -30,6 +37,7 @@ pub use service::{ pub use infrastructure::{ai::AIClient, events::BackendEventManager}; // Export Agentic service core types +#[cfg(feature = "product-full")] pub use agentic::{ core::{Message, Session}, // NOTE: agentic::core::DialogTurn / ModelRound used to be re-exported here @@ -41,7 +49,8 @@ pub use agentic::{ tools::{Tool, ToolPipeline}, }; -// Export ToolRegistry separately +// Export ToolRegistry separately. +#[cfg(feature = "product-full")] pub use agentic::tools::registry::ToolRegistry; // Version information diff --git a/src/crates/core/src/miniapp/js_worker_pool.rs b/src/crates/core/src/miniapp/js_worker_pool.rs index 7fa28dbd1f..9bb49fe0d1 100644 --- a/src/crates/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/core/src/miniapp/js_worker_pool.rs @@ -387,11 +387,7 @@ mod tests { impl TestTempDir { fn new(prefix: &str) -> Self { - let path = std::env::temp_dir().join(format!( - "{}-{}", - prefix, - uuid::Uuid::new_v4() - )); + let path = std::env::temp_dir().join(format!("{}-{}", prefix, uuid::Uuid::new_v4())); fs::create_dir_all(&path).expect("test root should be created"); Self { path } } @@ -410,10 +406,9 @@ mod tests { #[tokio::test] async fn runtime_port_adapter_preserves_existing_runtime_and_noop_install() { let root = TestTempDir::new("bitfun-miniapp-runtime-port"); - let path_manager = - Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests( - root.path().to_path_buf(), - )); + let path_manager = Arc::new( + crate::infrastructure::PathManager::with_user_root_for_tests(root.path().to_path_buf()), + ); let app_id = "demo_app"; tokio::fs::create_dir_all(path_manager.miniapp_dir(app_id)) .await diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index d63ac48af6..7622f592a7 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -6,6 +6,7 @@ use crate::miniapp::storage::MiniAppStorage; use crate::miniapp::types::{ MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, }; +use crate::product_domain_runtime::CoreProductDomainRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_product_domains::miniapp::customization::{ apply_draft_customization_metadata, decline_builtin_update_metadata, @@ -70,7 +71,7 @@ impl MiniAppManager { } fn runtime_facade(&self) -> MiniAppRuntimeFacade<'_> { - MiniAppRuntimeFacade::new(&self.storage) + CoreProductDomainRuntime::miniapp_runtime_facade(&self.storage) } pub fn compile_source( diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs index 19f47bc8b5..3a291f0c9a 100644 --- a/src/crates/core/src/miniapp/storage.rs +++ b/src/crates/core/src/miniapp/storage.rs @@ -325,6 +325,13 @@ impl MiniAppStorage { self.write_package_json_to_dir(app_dir, &app.id, &app.source.npm_dependencies) .await?; + let storage_path = app_dir.join(STORAGE_JSON); + if !storage_path.exists() { + tokio::fs::write(&storage_path, "{}") + .await + .map_err(|e| BitFunError::io(format!("Failed to write storage.json: {}", e)))?; + } + tokio::fs::write(app_dir.join(COMPILED_HTML), &app.compiled_html) .await .map_err(|e| BitFunError::io(format!("Failed to write compiled.html: {}", e)))?; @@ -938,6 +945,11 @@ mod tests { let layout = MiniAppStorageLayout::new(path_manager.miniapps_dir(), "layout_app"); storage.save(&app).await.unwrap(); + assert!(layout.storage_path().is_file()); + assert_eq!( + fs::read_to_string(layout.storage_path()).unwrap(), + "{}".to_string() + ); storage .save_app_storage("layout_app", "answer", serde_json::json!(42)) .await @@ -947,7 +959,6 @@ mod tests { assert!(layout.app_dir().is_dir()); assert!(layout.meta_path().is_file()); assert!(layout.compiled_path().is_file()); - assert!(layout.storage_path().is_file()); assert!(layout.package_json_path().is_file()); assert!(layout.source_file_path(INDEX_HTML).is_file()); assert!(layout.source_file_path(STYLE_CSS).is_file()); @@ -957,6 +968,34 @@ mod tests { assert!(layout.version_path(7).is_file()); } + #[tokio::test] + async fn saving_app_files_preserves_existing_storage_json() { + let root = std::env::temp_dir().join(format!( + "bitfun-miniapp-storage-preserve-{}", + uuid::Uuid::new_v4() + )); + let path_manager = + Arc::new(crate::infrastructure::PathManager::with_user_root_for_tests(root)); + let storage = MiniAppStorage::new(path_manager); + let app = sample_app("storage_app"); + + storage.save(&app).await.unwrap(); + storage + .save_app_storage("storage_app", "answer", serde_json::json!(42)) + .await + .unwrap(); + storage.save(&app).await.unwrap(); + + assert_eq!( + storage + .load_app_storage("storage_app") + .await + .unwrap() + .get("answer"), + Some(&serde_json::json!(42)) + ); + } + #[tokio::test] async fn draft_storage_is_hidden_and_isolated_from_active_storage() { let root = std::env::temp_dir().join(format!( diff --git a/src/crates/core/src/product_domain_runtime.rs b/src/crates/core/src/product_domain_runtime.rs new file mode 100644 index 0000000000..3a188fe3e2 --- /dev/null +++ b/src/crates/core/src/product_domain_runtime.rs @@ -0,0 +1,44 @@ +//! Core-owned adapters for product-domain runtime ports. +//! +//! Product-domain crates own stable contracts and pure orchestration. This +//! module keeps the concrete MiniApp and function-agent runtime bindings in +//! core so filesystem, process, Git, and AI behavior stays on the legacy path. + +use std::sync::Arc; + +use bitfun_product_domains::function_agents::ports::{ + FunctionAgentAiPort, FunctionAgentGitPort, FunctionAgentRuntimeFacade, +}; +use bitfun_product_domains::miniapp::ports::{MiniAppRuntimeFacade, MiniAppStoragePort}; + +use crate::function_agents::port_adapters::{ + CoreFunctionAgentAiAdapter, CoreFunctionAgentGitAdapter, +}; +use crate::infrastructure::ai::AIClientFactory; + +pub(crate) struct CoreProductDomainRuntime; + +impl CoreProductDomainRuntime { + pub(crate) fn miniapp_runtime_facade( + storage: &dyn MiniAppStoragePort, + ) -> MiniAppRuntimeFacade<'_> { + MiniAppRuntimeFacade::new(storage) + } + + pub(crate) fn function_agent_git_adapter() -> CoreFunctionAgentGitAdapter { + CoreFunctionAgentGitAdapter::default() + } + + pub(crate) fn function_agent_ai_adapter( + factory: Arc, + ) -> CoreFunctionAgentAiAdapter { + CoreFunctionAgentAiAdapter::new(factory) + } + + pub(crate) fn function_agent_runtime_facade<'a>( + git: &'a dyn FunctionAgentGitPort, + ai: &'a dyn FunctionAgentAiPort, + ) -> FunctionAgentRuntimeFacade<'a> { + FunctionAgentRuntimeFacade::new(git, ai) + } +} diff --git a/src/crates/core/src/service/agent_memory/auto_memory.rs b/src/crates/core/src/service/agent_memory/auto_memory.rs index 8bf6e34d8e..cb9ed2dab5 100644 --- a/src/crates/core/src/service/agent_memory/auto_memory.rs +++ b/src/crates/core/src/service/agent_memory/auto_memory.rs @@ -303,14 +303,14 @@ async fn build_memory_space_files_section(memory_dir: &Path) -> BitFunResult Ok(()) } +#[cfg(feature = "product-full")] pub(crate) fn is_workspace_bootstrap_pending(workspace_root: &Path) -> bool { workspace_root.join(BOOTSTRAP_FILE_NAME).exists() } +#[cfg(feature = "product-full")] pub(crate) async fn ensure_workspace_persona_files_for_prompt( workspace_root: &Path, ) -> BitFunResult<()> { @@ -233,6 +236,7 @@ pub async fn reset_workspace_persona_files_to_default(workspace_root: &Path) -> Ok(()) } +#[cfg(feature = "product-full")] pub(crate) async fn build_workspace_persona_prompt( workspace_root: &Path, ) -> BitFunResult> { @@ -308,6 +312,7 @@ The following files are located in the workspace root directory and define your ))) } +#[cfg(feature = "product-full")] fn persona_file_description(file_name: &str) -> &'static str { match file_name { BOOTSTRAP_FILE_NAME => "Bootstrap guidance and initialization instructions", @@ -318,7 +323,7 @@ fn persona_file_description(file_name: &str) -> &'static str { } } -#[cfg(test)] +#[cfg(all(test, feature = "product-full"))] mod tests { use super::{ ensure_workspace_gitignore_ignores_bitfun, ensure_workspace_persona_files_for_prompt, diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs index d5195b464a..d26eaf719b 100644 --- a/src/crates/core/src/service/bootstrap/mod.rs +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -1,8 +1,11 @@ mod bootstrap_impl; pub use bootstrap_impl::reset_workspace_persona_files_to_default; +#[cfg(feature = "product-full")] pub(crate) use bootstrap_impl::{ - build_workspace_persona_prompt, ensure_workspace_gitignore_ignores_bitfun, - ensure_workspace_persona_files_for_prompt, initialize_workspace_persona_files, + build_workspace_persona_prompt, ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; +pub(crate) use bootstrap_impl::{ + ensure_workspace_gitignore_ignores_bitfun, initialize_workspace_persona_files, +}; diff --git a/src/crates/core/src/service/config/global.rs b/src/crates/core/src/service/config/global.rs index 76a491a81c..b2f57a7f23 100644 --- a/src/crates/core/src/service/config/global.rs +++ b/src/crates/core/src/service/config/global.rs @@ -4,7 +4,9 @@ use super::service::ConfigService; use crate::util::errors::*; -use log::{debug, info, warn}; +#[cfg(feature = "product-full")] +use log::warn; +use log::{debug, info}; use std::sync::Arc; use std::sync::OnceLock; use tokio::sync::RwLock; @@ -101,18 +103,21 @@ impl GlobalConfigManager { info!("Global config service initialized"); - match super::mode_config_canonicalizer::canonicalize_mode_configs().await { - Ok(report) => { - if !report.removed_mode_configs.is_empty() || !report.updated_modes.is_empty() { - info!( - "Mode config canonicalization completed: removed_modes={}, updated_modes={}", - report.removed_mode_configs.len(), - report.updated_modes.len() - ); + #[cfg(feature = "product-full")] + { + match super::mode_config_canonicalizer::canonicalize_mode_configs().await { + Ok(report) => { + if !report.removed_mode_configs.is_empty() || !report.updated_modes.is_empty() { + info!( + "Mode config canonicalization completed: removed_modes={}, updated_modes={}", + report.removed_mode_configs.len(), + report.updated_modes.len() + ); + } + } + Err(e) => { + warn!("Mode config canonicalization failed: {}", e); } - } - Err(e) => { - warn!("Mode config canonicalization failed: {}", e); } } @@ -156,6 +161,7 @@ impl GlobalConfigManager { pub async fn reload() -> BitFunResult<()> { let service = Self::get_service().await?; service.reload().await?; + #[cfg(feature = "product-full")] if let Err(error) = super::mode_config_canonicalizer::canonicalize_mode_configs().await { warn!( "Mode config canonicalization failed after reload: {}", diff --git a/src/crates/core/src/service/config/mod.rs b/src/crates/core/src/service/config/mod.rs index 74aaaf06fd..0ee1d589dd 100644 --- a/src/crates/core/src/service/config/mod.rs +++ b/src/crates/core/src/service/config/mod.rs @@ -6,6 +6,7 @@ pub mod app_language; pub mod factory; pub mod global; pub mod manager; +#[cfg(feature = "product-full")] pub mod mode_config_canonicalizer; pub mod providers; pub mod service; @@ -20,6 +21,7 @@ pub use global::{ subscribe_config_updates, ConfigUpdateEvent, GlobalConfigManager, }; pub use manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; +#[cfg(feature = "product-full")] pub use mode_config_canonicalizer::{ canonicalize_mode_configs, ModeConfigCanonicalizationReport, ModeConfigUpdateInfo, }; diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index c3341e8a51..93829120b0 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -462,6 +462,31 @@ pub enum ModelCategory { pub use bitfun_ai_adapters::types::ReasoningMode; +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum WriteToolMode { + InlineContent, + #[default] + PlaintextFollowup, +} + +impl WriteToolMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::InlineContent => "inline_content", + Self::PlaintextFollowup => "plaintext_followup", + } + } + + pub fn from_context_var(value: Option<&str>) -> Self { + match value.map(str::trim) { + Some("inline_content") => Self::InlineContent, + Some("plaintext_followup") => Self::PlaintextFollowup, + _ => Self::default(), + } + } +} + /// Default model configuration. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] @@ -593,6 +618,10 @@ pub struct AIConfig { #[serde(default = "default_skip_tool_confirmation")] pub skip_tool_confirmation: bool, + /// Selects how the Write tool obtains file content. + #[serde(default)] + pub write_tool_mode: WriteToolMode, + /// Debug-mode configuration (log path, language templates, etc.). #[serde(default)] pub debug_mode_config: DebugModeConfig, @@ -1554,6 +1583,7 @@ impl Default for AIConfig { tool_execution_timeout_secs: default_tool_execution_timeout(), tool_confirmation_timeout_secs: default_tool_confirmation_timeout(), skip_tool_confirmation: true, + write_tool_mode: WriteToolMode::default(), debug_mode_config: DebugModeConfig::default(), computer_use_enabled: false, browser_control_preferred_browser: String::new(), diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index 98657b44ff..45b72a7e00 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -4,24 +4,36 @@ //! isolated. High-coupling runtime services stay here until their port //! contracts and equivalence tests are explicit. +#[cfg(feature = "product-full")] pub(crate) mod agent_memory; // Agent memory prompt helpers +#[cfg(feature = "service-integrations")] pub mod announcement; // Announcement / feature-demo / tips system pub(crate) mod bootstrap; // Workspace persona bootstrap helpers pub mod config; // Config management +#[cfg(feature = "product-full")] pub mod cron; // Scheduled jobs pub mod filesystem; // FileSystem management +#[cfg(feature = "service-integrations")] pub mod git; // Git service pub mod i18n; // I18n service pub mod lsp; // LSP (Language Server Protocol) system +#[cfg(feature = "service-integrations")] pub mod mcp; // MCP (Model Context Protocol) system +#[cfg(feature = "product-full")] pub mod project_context; // Project context management +#[cfg(feature = "service-integrations")] pub mod remote_connect; // Remote Connect (phone → desktop) pub mod remote_ssh; // Remote SSH (desktop → server) +#[cfg(feature = "service-integrations")] +pub mod review_platform; // Pull request review platform adapters pub mod runtime; // Managed runtime and capability management pub mod search; // Workspace search via managed flashgrep daemon pub mod session; // Session persistence +#[cfg(feature = "product-full")] pub mod session_usage; // Session runtime usage reports +#[cfg(feature = "product-full")] pub mod snapshot; // Snapshot-based change tracking +#[cfg(feature = "product-full")] 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 @@ -31,28 +43,45 @@ pub mod workspace_runtime; // Workspace runtime layout / migration / initializat pub use terminal_core as terminal; // Re-export main components. +#[cfg(feature = "service-integrations")] pub use announcement::{AnnouncementCard, AnnouncementScheduler, AnnouncementSchedulerRef}; pub use bitfun_services_core::{diagnostics, diff, system}; +#[cfg(feature = "service-integrations")] pub use bitfun_services_integrations::file_watch; pub use bootstrap::reset_workspace_persona_files_to_default; pub use config::{ConfigManager, ConfigProvider, ConfigService}; +#[cfg(feature = "product-full")] pub use cron::{ get_global_cron_service, set_global_cron_service, CronEventSubscriber, CronService, }; pub use diff::{ DiffConfig, DiffHunk, DiffLine, DiffLineType, DiffOptions, DiffResult, DiffService, }; +#[cfg(feature = "service-integrations")] pub use file_watch::{ get_global_file_watch_service, get_watched_paths, initialize_file_watch_service, start_file_watch, stop_file_watch, FileWatchEvent, FileWatchEventKind, FileWatchService, FileWatcherConfig, }; pub use filesystem::{DirectoryStats, FileSystemService, FileSystemServiceFactory}; +#[cfg(feature = "service-integrations")] pub use git::GitService; pub use i18n::{get_global_i18n_service, I18nConfig, I18nService, LocaleId, LocaleMetadata}; pub use lsp::LspManager; +#[cfg(feature = "service-integrations")] pub use mcp::MCPService; +#[cfg(feature = "product-full")] pub use project_context::{ContextDocumentStatus, ProjectContextConfig, ProjectContextService}; +#[cfg(feature = "service-integrations")] +pub use review_platform::{ + ReviewAuthSource, ReviewAuthState, ReviewChecks, ReviewDecision, ReviewFileStatus, + ReviewItemState, ReviewPlatformAccount, ReviewPlatformAuthChallenge, + ReviewPlatformAuthChallengeState, ReviewPlatformCapabilities, ReviewPlatformCiLog, + ReviewPlatformCommit, ReviewPlatformError, ReviewPlatformFile, ReviewPlatformKind, + ReviewPlatformPullRequest, ReviewPlatformPullRequestDetail, ReviewPlatformRemote, + ReviewPlatformRepositoryRef, ReviewPlatformService, ReviewPlatformThread, + ReviewPlatformWorkspaceSnapshot, +}; pub use runtime::{ResolvedCommand, RuntimeCommandCapability, RuntimeManager, RuntimeSource}; pub use search::{ get_global_workspace_search_service, set_global_workspace_search_service, ContentSearchRequest, @@ -64,11 +93,13 @@ pub use search::{ WorkspaceSearchTaskKind, WorkspaceSearchTaskPhase, WorkspaceSearchTaskState, WorkspaceSearchTaskStatus, }; +#[cfg(feature = "product-full")] pub use snapshot::SnapshotService; pub use system::{ check_command, check_commands, run_command, run_command_simple, CheckCommandResult, CommandOutput, SystemError, }; +#[cfg(feature = "product-full")] pub use token_usage::{ ModelTokenStats, SessionTokenStats, TimeRange, TokenUsageQuery, TokenUsageRecord, TokenUsageService, TokenUsageSummary, diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index 7b155c618b..d1b4d00b44 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -1380,7 +1380,7 @@ async fn guarded_new( async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleResult { use crate::agentic::coordination::get_global_coordinator; use crate::service::workspace::get_global_workspace_service; - use bitfun_runtime_ports::AgentSubmissionPort; + use crate::service_agent_runtime::CoreServiceAgentRuntime; use bitfun_services_integrations::remote_connect::{ build_remote_session_create_request, RemoteConnectSubmissionSource, }; @@ -1486,7 +1486,7 @@ async fn create_session(state: &mut BotChatState, agent_type: &str) -> HandleRes Some(workspace_path.clone()), RemoteConnectSubmissionSource::Bot, ); - let submission_port: &dyn AgentSubmissionPort = coordinator.as_ref(); + let submission_port = CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); match submission_port.create_session(request).await { Ok(session) => { state.current_session_id = Some(session.session_id.clone()); @@ -1977,10 +1977,10 @@ async fn submit_question_answers( /// to the safe default ("agentic"), so chat keeps working. async fn resolve_session_agent_type(session_id: &str) -> Option { use crate::agentic::coordination::get_global_coordinator; - use bitfun_runtime_ports::AgentSubmissionPort; + use crate::service_agent_runtime::CoreServiceAgentRuntime; let coordinator = get_global_coordinator()?; - let submission_port: &dyn AgentSubmissionPort = coordinator.as_ref(); + let submission_port = CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); submission_port .resolve_session_agent_type(session_id) .await @@ -2060,10 +2060,10 @@ pub async fn execute_forwarded_turn( message_sender: Option, verbose_mode: bool, ) -> ForwardedTurnResult { - use crate::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; use crate::service::remote_connect::remote_server::{ get_or_init_global_dispatcher, TrackerEvent, }; + use bitfun_services_integrations::remote_connect::RemoteConnectSubmissionSource; let language = current_bot_language().await; let s = strings_for(language); @@ -2080,7 +2080,7 @@ pub async fn execute_forwarded_turn( forward.content, Some(&forward.agent_type), forward.image_contexts, - DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot), + RemoteConnectSubmissionSource::Bot, Some(forward.turn_id.clone()), ) .await diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index a4ac0eb05c..cf3fef04d6 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -114,16 +114,6 @@ pub struct WorkspaceFileContent { pub size: u64, } -fn strip_workspace_path_prefix(raw: &str) -> &str { - raw.strip_prefix("computer://") - .or_else(|| raw.strip_prefix("file://")) - .unwrap_or(raw) -} - -fn is_absolute_workspace_path(path: &str) -> bool { - path.starts_with('/') || (path.len() >= 3 && path.as_bytes()[1] == b':') -} - /// Resolve a raw path (with or without `computer://` / `file://` prefix) to an /// absolute `PathBuf`. /// @@ -134,57 +124,12 @@ pub fn resolve_workspace_path( raw: &str, workspace_root: Option<&std::path::Path>, ) -> Option { - let stripped = strip_workspace_path_prefix(raw); - - if is_absolute_workspace_path(stripped) { - return Some(std::path::PathBuf::from(stripped)); - } - - let workspace_root = workspace_root?; - let canonical_root = std::fs::canonicalize(workspace_root).ok()?; - let candidate = canonical_root.join(stripped); - let canonical_candidate = std::fs::canonicalize(candidate).ok()?; - - if canonical_candidate.starts_with(&canonical_root) { - Some(canonical_candidate) - } else { - None - } + bitfun_services_integrations::remote_connect::resolve_remote_workspace_path(raw, workspace_root) } /// Return the best-effort MIME type for a file based on its extension. pub fn detect_mime_type(path: &std::path::Path) -> &'static str { - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - match ext.as_str() { - "txt" | "log" => "text/plain", - "md" => "text/markdown", - "html" | "htm" => "text/html", - "css" => "text/css", - "js" | "mjs" => "text/javascript", - "ts" | "tsx" | "jsx" | "rs" | "py" | "go" | "java" | "c" | "cpp" | "h" | "sh" | "toml" - | "yaml" | "yml" => "text/plain", - "json" => "application/json", - "xml" => "application/xml", - "csv" => "text/csv", - "pdf" => "application/pdf", - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "svg" => "image/svg+xml", - "zip" => "application/zip", - "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", - "mp4" => "video/mp4", - "opus" => "audio/opus", - _ => "application/octet-stream", - } + bitfun_services_integrations::remote_connect::detect_remote_mime_type(path) } /// Read a workspace file, resolving `computer://` prefixes. @@ -199,48 +144,19 @@ pub async fn read_workspace_file( max_size: u64, workspace_root: Option<&std::path::Path>, ) -> anyhow::Result { - let abs_path = resolve_workspace_path(raw_path, workspace_root) - .ok_or_else(|| anyhow::anyhow!("Remote file path could not be resolved: {raw_path}"))?; - - if !abs_path.exists() { - return Err(anyhow::anyhow!("File not found: {}", abs_path.display())); - } - if !abs_path.is_file() { - return Err(anyhow::anyhow!( - "Path is not a regular file: {}", - abs_path.display() - )); - } - - let metadata = tokio::fs::metadata(&abs_path).await.map_err(|e| { - anyhow::anyhow!("Cannot read file metadata for {}: {e}", abs_path.display()) - })?; - - if metadata.len() > max_size { - return Err(anyhow::anyhow!( - "File too large ({} bytes, limit {max_size} bytes): {}", - metadata.len(), - abs_path.display() - )); - } - - let bytes = tokio::fs::read(&abs_path) - .await - .map_err(|e| anyhow::anyhow!("Cannot read file {}: {e}", abs_path.display()))?; - - let name = abs_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string(); - - let mime_type = detect_mime_type(&abs_path); + let content = bitfun_services_integrations::remote_connect::read_remote_workspace_file( + raw_path, + max_size, + workspace_root, + ) + .await + .map_err(anyhow::Error::msg)?; Ok(WorkspaceFileContent { - name, - bytes, - mime_type, - size: metadata.len(), + name: content.name, + bytes: content.bytes, + mime_type: content.mime_type, + size: content.size, }) } diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index aab62d8e6c..0e2c2420fc 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -8,6 +8,7 @@ //! for state changes using the `PollSession` command, receiving only //! incremental updates (new messages + current active turn snapshot). +use crate::service_agent_runtime::CoreServiceAgentRuntime; use anyhow::{anyhow, Result}; use log::{debug, error, info}; use serde_json::Value; @@ -15,11 +16,14 @@ use std::sync::{Arc, OnceLock}; use super::encryption; use bitfun_services_integrations::remote_connect::{ - build_remote_image_contexts, remote_file_display_name, remote_model_catalog_poll_delta, - remote_no_change_poll_response, remote_persisted_poll_response, remote_session_restore_target, - remote_snapshot_poll_response, resolve_remote_cancel_decision, - resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, RemoteCancelDecision, - RemoteImageContext, RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, + build_remote_image_contexts, read_remote_workspace_file, read_remote_workspace_file_chunk, + read_remote_workspace_file_info, remote_model_catalog_poll_delta, + remote_no_change_poll_response, remote_persisted_poll_response, remote_snapshot_poll_response, + resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, submit_remote_dialog, + RemoteCancelDecision, RemoteConnectSubmissionSource, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSubmissionPolicy, + RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, + RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteTerminalPrewarmRequest, REMOTE_FILE_MAX_READ_BYTES, }; pub use bitfun_services_integrations::remote_connect::{ @@ -503,13 +507,7 @@ fn build_core_image_contexts( fn remote_image_context_to_core( context: RemoteImageContext, ) -> crate::agentic::image_analysis::ImageContextData { - crate::agentic::image_analysis::ImageContextData { - id: context.id, - image_path: context.image_path, - data_url: context.data_url, - mime_type: context.mime_type, - metadata: context.metadata, - } + CoreServiceAgentRuntime::remote_image_context(context) } // ── RemoteSessionStateTracker subscriber adapter ───────────────── @@ -562,6 +560,176 @@ impl RemoteSessionTrackerHost for CoreRemoteSessionTrackerHost { } } +pub(crate) struct CoreRemoteDialogRuntimeHost<'a> { + dispatcher: &'a RemoteExecutionDispatcher, + coordinator: Arc, + scheduler: Arc, +} + +impl<'a> CoreRemoteDialogRuntimeHost<'a> { + pub(crate) fn new( + dispatcher: &'a RemoteExecutionDispatcher, + ) -> std::result::Result { + use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; + + let coordinator = get_global_coordinator() + .ok_or_else(|| "Desktop session system not ready".to_string())?; + let scheduler = get_global_scheduler() + .ok_or_else(|| "Dialog scheduler is not initialized".to_string())?; + + Ok(Self { + dispatcher, + coordinator, + scheduler, + }) + } +} + +fn core_dialog_submission_policy( + policy: RemoteDialogSubmissionPolicy, +) -> crate::agentic::coordination::DialogSubmissionPolicy { + use crate::agentic::coordination::{ + DialogQueuePriority, DialogSubmissionPolicy, DialogTriggerSource, + }; + + let trigger_source = match policy.source { + RemoteConnectSubmissionSource::Relay => DialogTriggerSource::RemoteRelay, + RemoteConnectSubmissionSource::Bot => DialogTriggerSource::Bot, + }; + let queue_priority = match policy.queue_priority { + RemoteDialogQueuePriority::Low => DialogQueuePriority::Low, + RemoteDialogQueuePriority::Normal => DialogQueuePriority::Normal, + RemoteDialogQueuePriority::High => DialogQueuePriority::High, + }; + + DialogSubmissionPolicy::new( + trigger_source, + queue_priority, + policy.skip_tool_confirmation, + ) +} + +#[async_trait::async_trait] +impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { + type ImageContext = crate::agentic::image_analysis::ImageContextData; + + fn ensure_tracker(&self, session_id: &str) { + self.dispatcher.ensure_tracker(session_id); + } + + async fn resolve_binding_workspace(&self, session_id: &str) -> Option { + self.coordinator + .resolve_session_workspace_path(session_id) + .await + .map(|path| path.to_string_lossy().into_owned()) + } + + async fn remote_session_exists(&self, session_id: &str) -> std::result::Result { + Ok(self + .coordinator + .get_session_manager() + .get_session(session_id) + .is_some()) + } + + async fn restore_remote_session( + &self, + session_id: &str, + workspace_path: &str, + ) -> std::result::Result<(), String> { + self.coordinator + .restore_session(std::path::Path::new(workspace_path), session_id) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) + } + + fn prewarm_remote_terminal(&self, request: RemoteTerminalPrewarmRequest) { + use terminal_core::session::SessionSource; + use terminal_core::{TerminalApi, TerminalBindingOptions}; + + let sid = request.session_id; + let binding_workspace_for_terminal = request.binding_workspace; + tokio::spawn(async move { + let Ok(api) = TerminalApi::from_singleton() else { + return; + }; + let binding = api.session_manager().binding(); + if binding.get(&sid).is_some() { + return; + } + let workspace = binding_workspace_for_terminal; + let name = format!("Chat-{}", &sid[..8.min(sid.len())]); + match binding + .get_or_create( + &sid, + TerminalBindingOptions { + working_directory: workspace, + session_id: Some(sid.clone()), + session_name: Some(name), + env: Some( + crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(), + ), + source: Some(SessionSource::Agent), + ..Default::default() + }, + ) + .await + { + Ok(_) => info!("Terminal pre-warmed for remote session {sid}"), + Err(e) => debug!("Terminal pre-warm skipped for {sid}: {e}"), + } + }); + } + + fn generate_turn_id(&self) -> String { + format!("turn_{}", chrono::Utc::now().timestamp_millis()) + } + + async fn submit_dialog( + &self, + submission: RemoteDialogResolvedSubmission, + ) -> std::result::Result { + let image_payload = if submission.image_contexts.is_empty() { + None + } else { + Some(submission.image_contexts) + }; + let policy = core_dialog_submission_policy(submission.policy); + + self.scheduler + .submit( + submission.session_id, + submission.content, + None, + Some(submission.turn_id), + submission.resolved_agent_type, + submission.binding_workspace, + policy, + None, + None, + image_payload, + ) + .await + .map(|outcome| match outcome { + crate::agentic::coordination::DialogSubmitOutcome::Started { + session_id, + turn_id, + } => RemoteDialogSubmitOutcome::Started { + session_id, + turn_id, + }, + crate::agentic::coordination::DialogSubmitOutcome::Queued { + session_id, + turn_id, + } => RemoteDialogSubmitOutcome::Queued { + session_id, + turn_id, + }, + }) + } +} + // ── RemoteExecutionDispatcher (global singleton) ──────────────────── /// Shared dispatch layer that owns the session state trackers. @@ -610,10 +778,12 @@ impl RemoteExecutionDispatcher { .remove_tracker_with_host(session_id, &CoreRemoteSessionTrackerHost); } - /// Dispatch a SendMessage command: ensure tracker, restore session, submit via - /// [`DialogScheduler`](crate::agentic::coordination::DialogScheduler) (same as desktop). + /// Dispatch a SendMessage command through the remote-connect runtime owner. + /// + /// `bitfun-services-integrations` owns the orchestration order; core supplies + /// the concrete tracker, session restore, terminal, and scheduler adapters. /// When the session is already processing, the message is queued and the current turn - /// may yield after the current model round (for interactive `submission_policy` sources). + /// may yield after the current model round for interactive remote sources. /// Returns whether this message started immediately or was only queued, plus ids. /// If `turn_id` is `None`, one is auto-generated before queueing. /// @@ -624,103 +794,23 @@ impl RemoteExecutionDispatcher { content: String, agent_type: Option<&str>, image_contexts: Vec, - submission_policy: crate::agentic::coordination::DialogSubmissionPolicy, + source: RemoteConnectSubmissionSource, turn_id: Option, - ) -> std::result::Result { - use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; - - let coordinator = get_global_coordinator() - .ok_or_else(|| "Desktop session system not ready".to_string())?; - - let scheduler = get_global_scheduler() - .ok_or_else(|| "Dialog scheduler is not initialized".to_string())?; - - self.ensure_tracker(session_id); - - let session_mgr = coordinator.get_session_manager(); - let binding_workspace = resolve_session_workspace_path(session_id) - .await - .map(|path| path.to_string_lossy().into_owned()); - - if let Some(workspace_path) = remote_session_restore_target( - session_mgr.get_session(session_id).is_some(), - binding_workspace.as_deref(), - ) { - let _ = coordinator - .restore_session(std::path::Path::new(workspace_path), session_id) - .await - .ok(); - } - - // Pre-warm the terminal so shell integration is ready before BashTool runs. - // Bot/remote sessions have no Terminal panel to pre-create the session, so the - // AI model's processing time (typically 5-15 s) gives shell integration a head - // start. When BashTool eventually calls get_or_create, the binding already - // exists and the 30-second readiness wait is skipped entirely. - { - use terminal_core::session::SessionSource; - use terminal_core::{TerminalApi, TerminalBindingOptions}; - let sid = session_id.to_string(); - let binding_workspace_for_terminal = binding_workspace.clone(); - tokio::spawn(async move { - let Ok(api) = TerminalApi::from_singleton() else { - return; - }; - let binding = api.session_manager().binding(); - if binding.get(&sid).is_some() { - return; - } - let workspace = binding_workspace_for_terminal.clone(); - let name = format!("Chat-{}", &sid[..8.min(sid.len())]); - match binding - .get_or_create( - &sid, - TerminalBindingOptions { - working_directory: workspace, - session_id: Some(sid.clone()), - session_name: Some(name), - env: Some( - crate::agentic::tools::implementations::bash_tool::BashTool::noninteractive_env(), - ), - source: Some(SessionSource::Agent), - ..Default::default() - }, - ) - .await - { - Ok(_) => info!("Terminal pre-warmed for remote session {sid}"), - Err(e) => debug!("Terminal pre-warm skipped for {sid}: {e}"), - } - }); - } - - let resolved_agent_type = agent_type - .map(|t| resolve_agent_type(Some(t)).to_string()) - .unwrap_or_else(|| "agentic".to_string()); - - let turn_id = - turn_id.unwrap_or_else(|| format!("turn_{}", chrono::Utc::now().timestamp_millis())); - - let image_payload = if image_contexts.is_empty() { - None - } else { - Some(image_contexts) - }; + ) -> std::result::Result { + let host = CoreServiceAgentRuntime::remote_dialog_host(self)?; - scheduler - .submit( - session_id.to_string(), + submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: session_id.to_string(), content, - None, - Some(turn_id.clone()), - resolved_agent_type, - binding_workspace, - submission_policy, - None, - None, - image_payload, - ) - .await + agent_type: agent_type.map(ToOwned::to_owned), + image_contexts, + policy: RemoteDialogSubmissionPolicy::for_source(source), + turn_id, + }, + ) + .await } /// Cancel a running dialog turn. @@ -1033,29 +1123,23 @@ impl RemoteServer { /// Relative paths are resolved against the session workspace when possible, /// otherwise the current workspace root. Rejects files larger than 30 MB. async fn handle_read_file(&self, raw_path: &str, session_id: Option<&str>) -> RemoteResponse { - use crate::service::remote_connect::bot::{read_workspace_file, WorkspaceFileContent}; - let workspace_root = resolve_file_workspace_root(session_id).await; - match read_workspace_file( + match read_remote_workspace_file( raw_path, REMOTE_FILE_MAX_READ_BYTES, workspace_root.as_deref(), ) .await { - Ok(WorkspaceFileContent { - name, - bytes, - mime_type, - size, - }) => { + Ok(content) => { use base64::Engine as _; - let content_base64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let content_base64 = + base64::engine::general_purpose::STANDARD.encode(&content.bytes); RemoteResponse::FileContent { - name, + name: content.name, content_base64, - mime_type: mime_type.to_string(), - size, + mime_type: content.mime_type.to_string(), + size: content.size, } } Err(e) => RemoteResponse::Error { @@ -1071,56 +1155,24 @@ impl RemoteServer { offset: u64, limit: u64, ) -> RemoteResponse { - use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; - let workspace_root = resolve_file_workspace_root(session_id).await; - let abs = match resolve_workspace_path(raw_path, workspace_root.as_deref()) { - Some(p) => p, - None => { - return RemoteResponse::Error { - message: format!("Remote file path could not be resolved: {raw_path}"), - }; - } - }; - if !abs.exists() || !abs.is_file() { - return RemoteResponse::Error { - message: format!("File not found or not a regular file: {}", abs.display()), - }; - } - - let total_size = match tokio::fs::metadata(&abs).await { - Ok(m) => m.len(), - Err(e) => { - return RemoteResponse::Error { - message: format!("Cannot read file metadata: {e}"), - }; - } - }; - - let bytes = match tokio::fs::read(&abs).await { - Ok(b) => b, - Err(e) => { - return RemoteResponse::Error { - message: format!("Cannot read file: {e}"), - }; + match read_remote_workspace_file_chunk(raw_path, workspace_root.as_deref(), offset, limit) + .await + { + Ok(chunk) => { + use base64::Engine as _; + let chunk_base64 = base64::engine::general_purpose::STANDARD.encode(&chunk.bytes); + + RemoteResponse::FileChunk { + name: chunk.name, + chunk_base64, + offset: chunk.offset, + chunk_size: chunk.chunk_size, + total_size: chunk.total_size, + mime_type: chunk.mime_type.to_string(), + } } - }; - - let range = resolve_remote_file_chunk_range(bytes.len(), offset, limit); - let chunk = &bytes[range.start..range.end]; - - use base64::Engine as _; - let chunk_base64 = base64::engine::general_purpose::STANDARD.encode(chunk); - - let name = remote_file_display_name(abs.file_name().and_then(|n| n.to_str())); - - RemoteResponse::FileChunk { - name, - chunk_base64, - offset, - chunk_size: range.chunk_size, - total_size, - mime_type: detect_mime_type(&abs).to_string(), + Err(message) => RemoteResponse::Error { message }, } } @@ -1129,44 +1181,14 @@ impl RemoteServer { raw_path: &str, session_id: Option<&str>, ) -> RemoteResponse { - use crate::service::remote_connect::bot::{detect_mime_type, resolve_workspace_path}; - let workspace_root = resolve_file_workspace_root(session_id).await; - let abs = match resolve_workspace_path(raw_path, workspace_root.as_deref()) { - Some(p) => p, - None => { - return RemoteResponse::Error { - message: format!("Remote file path could not be resolved: {raw_path}"), - }; - } - }; - - if !abs.exists() { - return RemoteResponse::Error { - message: format!("File not found: {}", abs.display()), - }; - } - if !abs.is_file() { - return RemoteResponse::Error { - message: format!("Path is not a regular file: {}", abs.display()), - }; - } - - let size = match std::fs::metadata(&abs) { - Ok(m) => m.len(), - Err(e) => { - return RemoteResponse::Error { - message: format!("Cannot read file metadata: {e}"), - }; - } - }; - - let name = remote_file_display_name(abs.file_name().and_then(|n| n.to_str())); - - RemoteResponse::FileInfo { - name, - size, - mime_type: detect_mime_type(&abs).to_string(), + match read_remote_workspace_file_info(raw_path, workspace_root.as_deref()).await { + Ok(info) => RemoteResponse::FileInfo { + name: info.name, + size: info.size, + mime_type: info.mime_type.to_string(), + }, + Err(message) => RemoteResponse::Error { message }, } } @@ -1347,7 +1369,6 @@ impl RemoteServer { async fn handle_session_command(&self, cmd: &RemoteCommand) -> RemoteResponse { use crate::agentic::coordination::get_global_coordinator; - use bitfun_runtime_ports::AgentSubmissionPort; use bitfun_services_integrations::remote_connect::{ build_remote_session_create_request, RemoteConnectSubmissionSource, }; @@ -1511,7 +1532,8 @@ impl RemoteServer { Some(binding_ws_str), RemoteConnectSubmissionSource::Relay, ); - let submission_port: &dyn AgentSubmissionPort = coordinator.as_ref(); + let submission_port = + CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); match submission_port.create_session(request).await { Ok(session) => RemoteResponse::SessionCreated { session_id: session.session_id, @@ -1666,9 +1688,7 @@ impl RemoteServer { // ── Execution commands ────────────────────────────────────────── async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::{ - get_global_coordinator, DialogSubmissionPolicy, DialogTriggerSource, - }; + use crate::agentic::coordination::get_global_coordinator; let dispatcher = get_or_init_global_dispatcher(); @@ -1703,18 +1723,18 @@ impl RemoteServer { content.clone(), requested_agent_type.as_deref(), resolved_contexts, - DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay), + RemoteConnectSubmissionSource::Relay, None, ) .await { Ok(outcome) => { let (sid, turn_id) = match outcome { - crate::agentic::coordination::DialogSubmitOutcome::Started { + RemoteDialogSubmitOutcome::Started { session_id, turn_id, } - | crate::agentic::coordination::DialogSubmitOutcome::Queued { + | RemoteDialogSubmitOutcome::Queued { session_id, turn_id, } => (session_id, turn_id), @@ -1824,6 +1844,7 @@ impl RemoteServer { mod tests { use super::*; use crate::service::remote_connect::encryption::KeyPair; + use bitfun_services_integrations::remote_connect::remote_session_restore_target; #[test] fn test_command_round_trip() { @@ -1872,6 +1893,29 @@ mod tests { assert_eq!(value["_request_id"], "req_xyz"); } + #[test] + fn core_service_agent_runtime_owner_maps_remote_image_context() { + let metadata = serde_json::json!({ "source": "relay" }); + let context = RemoteImageContext { + id: "image-1".to_string(), + image_path: Some("/workspace/screenshot.png".to_string()), + data_url: None, + mime_type: "image/png".to_string(), + metadata: Some(metadata.clone()), + }; + + let mapped = + crate::service_agent_runtime::CoreServiceAgentRuntime::remote_image_context(context); + + assert_eq!(mapped.id, "image-1"); + assert_eq!( + mapped.image_path.as_deref(), + Some("/workspace/screenshot.png") + ); + assert_eq!(mapped.mime_type, "image/png"); + assert_eq!(mapped.metadata, Some(metadata)); + } + #[test] fn remote_execution_prefers_unified_image_contexts_over_legacy_images() { let explicit_context = crate::agentic::image_analysis::ImageContextData { diff --git a/src/crates/core/src/service/remote_ssh/disabled.rs b/src/crates/core/src/service/remote_ssh/disabled.rs new file mode 100644 index 0000000000..c4eacdd05b --- /dev/null +++ b/src/crates/core/src/service/remote_ssh/disabled.rs @@ -0,0 +1,540 @@ +//! Disabled Remote SSH runtime stubs for lightweight feature builds. +//! +//! The shared workspace identity and wire types remain available without the +//! `ssh-remote` feature. Concrete SSH, SFTP, PTY, and port-forward operations +//! stay behind `ssh-remote` and return explicit unsupported errors here. + +use crate::service::remote_ssh::types::{ + RemoteDirEntry, RemoteFileEntry, RemoteTreeNode, SSHCommandOptions, SSHCommandResult, + SSHConfigEntry, SSHConfigLookupResult, SSHConnectionConfig, SSHConnectionResult, + SavedConnection, ServerInfo, +}; +use crate::service::terminal::session::SessionSource; +use std::path::PathBuf; + +fn unsupported() -> anyhow::Error { + anyhow::anyhow!("Remote SSH support is disabled; enable the `ssh-remote` feature") +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KnownHostEntry { + pub host: String, + pub port: u16, + pub key_type: String, + pub fingerprint: String, + pub public_key: String, +} + +#[derive(Debug, Clone)] +pub struct SSHConnectionManager { + data_dir: PathBuf, +} + +impl SSHConnectionManager { + pub fn new(data_dir: PathBuf) -> Self { + Self { data_dir } + } + + pub async fn load_known_hosts(&self) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn add_known_host( + &self, + _host: String, + _port: u16, + _key_type: String, + _fingerprint: String, + _public_key: String, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn is_known_host(&self, _host: &str, _port: u16) -> bool { + false + } + + pub async fn get_known_host(&self, _host: &str, _port: u16) -> Option { + None + } + + pub async fn remove_known_host(&self, _host: &str, _port: u16) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn list_known_hosts(&self) -> Vec { + Vec::new() + } + + pub async fn load_remote_workspace(&self) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn set_remote_workspace( + &self, + _workspace: crate::service::remote_ssh::types::RemoteWorkspace, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn get_remote_workspaces( + &self, + ) -> Vec { + Vec::new() + } + + pub async fn prune_remote_workspaces_without_saved_connections( + &self, + ) -> anyhow::Result> { + Ok(Vec::new()) + } + + pub async fn get_remote_workspace( + &self, + _connection_id: &str, + ) -> Option { + None + } + + pub async fn remove_remote_workspace(&self, _connection_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn clear_remote_workspace(&self) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn get_ssh_config(&self, _host: &str) -> SSHConfigLookupResult { + SSHConfigLookupResult { + found: false, + config: None, + } + } + + pub async fn list_ssh_config_hosts(&self) -> Vec { + Vec::new() + } + + pub async fn load_saved_connections(&self) -> anyhow::Result<()> { + Ok(()) + } + + pub async fn get_saved_connections(&self) -> Vec { + Vec::new() + } + + pub async fn prune_saved_connections_without_credentials(&self) -> anyhow::Result> { + Ok(Vec::new()) + } + + pub async fn get_saved_host_for_connection_id(&self, _connection_id: &str) -> Option { + None + } + + pub async fn save_connection(&self, _config: &SSHConnectionConfig) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn load_stored_password( + &self, + _connection_id: &str, + ) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn has_stored_password(&self, _connection_id: &str) -> bool { + false + } + + pub async fn delete_saved_connection(&self, _connection_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn connect( + &self, + config: SSHConnectionConfig, + ) -> anyhow::Result { + self.connect_with_timeout(config, None).await + } + + pub async fn connect_with_timeout( + &self, + _config: SSHConnectionConfig, + _timeout_ms: Option, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn disconnect(&self, _connection_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn disconnect_all(&self) {} + + pub async fn is_connected(&self, _connection_id: &str) -> bool { + false + } + + pub async fn execute_command( + &self, + _connection_id: &str, + _command: &str, + ) -> anyhow::Result<(String, String, i32)> { + Err(unsupported()) + } + + pub async fn execute_command_with_options( + &self, + _connection_id: &str, + _command: &str, + _options: SSHCommandOptions, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn get_server_info(&self, _connection_id: &str) -> Option { + None + } + + pub async fn resolve_remote_home_if_missing(&self, _connection_id: &str) -> Option { + None + } + + pub async fn get_connection_config(&self, _connection_id: &str) -> Option { + None + } + + pub async fn resolve_sftp_path( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn sftp_read(&self, _connection_id: &str, _path: &str) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn sftp_write( + &self, + _connection_id: &str, + _path: &str, + _content: &[u8], + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_mkdir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_mkdir_all(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_remove(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_rmdir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_rename( + &self, + _connection_id: &str, + _old_path: &str, + _new_path: &str, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn sftp_exists(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn open_pty( + &self, + _connection_id: &str, + _cols: u32, + _rows: u32, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn get_server_key_fingerprint(&self, _connection_id: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub fn data_dir(&self) -> &PathBuf { + &self.data_dir + } +} + +#[derive(Debug, Clone)] +pub struct PTYSession { + connection_id: String, +} + +impl PTYSession { + pub async fn write(&self, _data: &[u8]) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn resize(&self, _cols: u32, _rows: u32) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn read(&self) -> anyhow::Result>> { + Err(unsupported()) + } + + pub async fn close(self) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub fn connection_id(&self) -> &str { + &self.connection_id + } +} + +#[derive(Debug, Clone)] +pub struct PortForward { + pub id: String, + pub local_port: u16, + pub remote_host: String, + pub remote_port: u16, + pub direction: PortForwardDirection, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PortForwardDirection { + Local, + Remote, + Dynamic, +} + +#[derive(Clone, Default)] +pub struct PortForwardManager; + +impl PortForwardManager { + pub fn new() -> Self { + Self + } + + pub fn with_ssh_manager(_ssh_manager: SSHConnectionManager) -> Self { + Self + } + + pub async fn set_ssh_manager(&self, _manager: SSHConnectionManager) {} + + pub async fn start_local_forward( + &self, + _connection_id: &str, + _local_port: u16, + _remote_host: &str, + _remote_port: u16, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn start_remote_forward( + &self, + _connection_id: &str, + _remote_port: u16, + _local_host: &str, + _local_port: u16, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn stop_forward(&self, _forward_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn stop_all(&self) {} + + pub async fn list_forwards(&self) -> Vec { + Vec::new() + } + + pub async fn is_port_forwarded(&self, _port: u16) -> bool { + false + } +} + +#[derive(Debug, Clone)] +pub struct RemoteFileService; + +impl RemoteFileService { + pub fn new( + _manager: std::sync::Arc>>, + ) -> Self { + Self + } + + pub async fn read_file(&self, _connection_id: &str, _path: &str) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn write_file( + &self, + _connection_id: &str, + _path: &str, + _content: &[u8], + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn exists(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn is_file(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn is_dir(&self, _connection_id: &str, _path: &str) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn read_dir( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result> { + Err(unsupported()) + } + + pub async fn build_tree( + &self, + _connection_id: &str, + _path: &str, + _max_depth: Option, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn build_shallow_tree_for_layout_preview( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn create_dir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn create_dir_all(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn remove_file(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn remove_dir_all(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn rename( + &self, + _connection_id: &str, + _old_path: &str, + _new_path: &str, + ) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn stat( + &self, + _connection_id: &str, + _path: &str, + ) -> anyhow::Result> { + Err(unsupported()) + } +} + +#[derive(Debug, Clone)] +pub struct RemoteTerminalSession { + pub id: String, + pub name: String, + pub connection_id: String, + pub cwd: String, + pub pid: Option, + pub status: SessionStatus, + pub cols: u16, + pub rows: u16, + pub source: SessionSource, +} + +pub struct CreateSessionResult { + pub session: RemoteTerminalSession, + pub output_rx: tokio::sync::broadcast::Receiver>, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SessionStatus { + Active, + Inactive, + Closed, +} + +#[derive(Clone, Default)] +pub struct RemoteTerminalManager; + +impl RemoteTerminalManager { + pub fn new(_ssh_manager: SSHConnectionManager) -> Self { + Self + } + + pub async fn set_ssh_manager(&self, _manager: SSHConnectionManager) {} + + #[allow(clippy::too_many_arguments)] + pub async fn create_session( + &self, + _session_id: Option, + _name: Option, + _connection_id: &str, + _cols: u16, + _rows: u16, + _initial_cwd: Option<&str>, + _source: Option, + ) -> anyhow::Result { + Err(unsupported()) + } + + pub async fn get_session(&self, _session_id: &str) -> Option { + None + } + + pub async fn list_sessions(&self) -> Vec { + Vec::new() + } + + pub async fn write(&self, _session_id: &str, _data: &[u8]) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn resize(&self, _session_id: &str, _cols: u16, _rows: u16) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn close_session(&self, _session_id: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + + pub async fn is_pty_active(&self, _session_id: &str) -> bool { + false + } + + pub async fn subscribe_output( + &self, + _session_id: &str, + ) -> anyhow::Result>> { + Err(unsupported()) + } +} diff --git a/src/crates/core/src/service/remote_ssh/mod.rs b/src/crates/core/src/service/remote_ssh/mod.rs index ed662518cd..bc2a4301ff 100644 --- a/src/crates/core/src/service/remote_ssh/mod.rs +++ b/src/crates/core/src/service/remote_ssh/mod.rs @@ -4,18 +4,33 @@ //! This allows BitFun to work with files on remote servers via SSH, //! similar to VSCode's Remote SSH extension. +#[cfg(not(feature = "ssh-remote"))] +mod disabled; +#[cfg(feature = "ssh-remote")] pub mod manager; +#[cfg(feature = "ssh-remote")] mod password_vault; +#[cfg(feature = "ssh-remote")] pub mod remote_fs; +#[cfg(feature = "ssh-remote")] pub mod remote_terminal; pub mod types; pub mod workspace_state; +#[cfg(not(feature = "ssh-remote"))] +pub use disabled::{ + KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, + RemoteFileService, RemoteTerminalManager, RemoteTerminalSession, SSHConnectionManager, + SessionStatus, +}; +#[cfg(feature = "ssh-remote")] pub use manager::{ KnownHostEntry, PTYSession, PortForward, PortForwardDirection, PortForwardManager, SSHConnectionManager, }; +#[cfg(feature = "ssh-remote")] pub use remote_fs::RemoteFileService; +#[cfg(feature = "ssh-remote")] pub use remote_terminal::{RemoteTerminalManager, RemoteTerminalSession, SessionStatus}; pub use types::*; pub use workspace_state::{ diff --git a/src/crates/core/src/service/review_platform/mod.rs b/src/crates/core/src/service/review_platform/mod.rs new file mode 100644 index 0000000000..19907b7127 --- /dev/null +++ b/src/crates/core/src/service/review_platform/mod.rs @@ -0,0 +1,4863 @@ +//! Platform-neutral pull request review data service. +//! +//! This module owns provider detection, token handling, and provider-specific +//! HTTP calls. UI and desktop adapters consume only the common DTOs below. + +use crate::infrastructure::try_get_path_manager_arc; +use crate::service::git::{execute_git_command, get_repository_root}; +use futures::{stream, StreamExt}; +use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::time::Duration; +use tokio::fs; + +const USER_AGENT_VALUE: &str = "ReviewPlatform"; +const DEFAULT_PR_PAGE: u32 = 1; +const DEFAULT_PR_PAGE_SIZE: u32 = 10; +const MAX_PR_PAGE_SIZE: u32 = 50; +const PROVIDER_ENRICH_CONCURRENCY: usize = 4; +const MAX_CI_LOG_CHARS: usize = 80_000; + +#[derive(Debug, thiserror::Error)] +pub enum ReviewPlatformError { + #[error("Invalid repository path: {0}")] + InvalidRepository(String), + #[error("Remote not found: {0}")] + RemoteNotFound(String), + #[error("Unsupported review platform: {0}")] + UnsupportedPlatform(String), + #[error("Provider API failed: {0}")] + Api(String), + #[error("Provider API failed: HTTP {status}{message}")] + Http { status: u16, message: String }, + #[error("Network error: {0}")] + Network(String), + #[error("Parse error: {0}")] + Parse(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformKind { + Github, + Gitlab, + Gitcode, + Unknown, +} + +impl ReviewPlatformKind { + fn as_str(self) -> &'static str { + match self { + Self::Github => "github", + Self::Gitlab => "gitlab", + Self::Gitcode => "gitcode", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewAuthState { + NotConnected, + NotRequired, + Connected, + Expired, + Error, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewAuthSource { + Env, + Stored, + None, + Unsupported, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewItemState { + Open, + Merged, + Closed, + Draft, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewDecision { + Approved, + ChangesRequested, + Commented, + Pending, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewFileStatus { + Added, + Modified, + Deleted, + Renamed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformAccount { + pub id: String, + pub platform: ReviewPlatformKind, + pub label: String, + pub username: Option, + pub host: String, + pub auth_state: ReviewAuthState, + pub auth_source: ReviewAuthSource, + pub scopes: Vec, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRepositoryRef { + pub provider_id: String, + pub platform: ReviewPlatformKind, + pub host: String, + pub owner: String, + pub name: String, + pub project_path: String, + pub default_branch: String, + pub workspace_path: Option, + pub web_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRemote { + pub id: String, + pub name: String, + pub url: String, + pub platform: ReviewPlatformKind, + pub host: String, + pub owner: String, + pub repository_name: String, + pub project_path: String, + pub web_url: String, + pub supported: bool, + pub auth_state: ReviewAuthState, + pub auth_source: ReviewAuthSource, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewChecks { + pub total: i32, + pub passed: i32, + pub failed: i32, + pub pending: i32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCiItem { + pub id: String, + pub name: String, + pub status: String, + pub conclusion: Option, + pub detail: Option, + pub stage: Option, + pub web_url: Option, + pub log: Option, + pub log_truncated: bool, + pub started_at: Option, + pub finished_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequest { + pub id: String, + pub number: i64, + pub title: String, + pub state: ReviewItemState, + pub author: String, + pub source_branch: String, + pub target_branch: String, + pub updated_at: String, + pub web_url: String, + pub additions: i32, + pub deletions: i32, + pub changed_files: i32, + pub comments: i32, + pub review_decision: ReviewDecision, + pub checks: ReviewChecks, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformFile { + pub path: String, + pub old_path: Option, + pub status: ReviewFileStatus, + pub additions: i32, + pub deletions: i32, + pub patch: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCommit { + pub hash: String, + pub short_hash: String, + pub title: String, + pub author: String, + pub committed_at: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformThreadKind { + Review, + Comment, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformThread { + pub id: String, + pub provider_thread_id: Option, + pub provider_comment_id: Option, + pub kind: ReviewPlatformThreadKind, + pub reply_to_provider_comment_id: Option, + pub file_path: Option, + pub line: Option, + pub resolved: bool, + pub author: String, + pub body: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetail { + #[serde(flatten)] + pub pull_request: ReviewPlatformPullRequest, + pub body: String, + pub ci: Vec, + pub files: Vec, + pub commits: Vec, + pub threads: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformDetailSection { + Overview, + Ci, + Files, + Commits, + Reviews, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPullRequestDetailPage { + #[serde(flatten)] + pub pull_request: ReviewPlatformPullRequest, + pub body: String, + pub ci: Vec, + pub files: Vec, + pub commits: Vec, + pub threads: Vec, + pub section: ReviewPlatformDetailSection, + pub pagination: ReviewPlatformPagination, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCiLog { + pub ci_item_id: String, + pub log: Option, + pub truncated: bool, + pub message: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCapabilities { + pub can_create_review: bool, + pub can_create_pull_request: bool, + pub can_reply_to_thread: bool, + pub can_resolve_thread: bool, + pub can_approve: bool, + pub can_revoke_approval: bool, + pub can_request_changes: bool, + pub can_merge: bool, + pub supports_draft_review: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewSubmitEvent { + Comment, + Approve, + RequestChanges, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformCreatePullRequestRequest { + pub repository_path: String, + pub remote_id: Option, + pub title: String, + pub source_branch: String, + pub target_branch: String, + pub body: Option, + pub draft: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformReplyToThreadRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub thread_id: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformSubmitReviewRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub event: ReviewSubmitEvent, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformResolveThreadRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub thread_id: String, + pub resolved: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformApprovalRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub body: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformRequestChangesRequest { + pub repository_path: String, + pub remote_id: String, + pub pull_request_id: String, + pub body: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformActionResult { + pub success: bool, + pub message: String, + pub web_url: Option, + pub pull_request: Option, + pub thread: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewPlatformAuthChallengeState { + Missing, + Invalid, + InsufficientScope, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformAuthChallenge { + pub platform: ReviewPlatformKind, + pub host: String, + pub remote_id: String, + pub project_path: String, + pub state: ReviewPlatformAuthChallengeState, + pub message: String, + pub required_scopes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformWorkspaceSnapshot { + pub remotes: Vec, + pub selected_remote_id: Option, + pub accounts: Vec, + pub repository: Option, + pub pull_requests: Vec, + pub pagination: ReviewPlatformPagination, + pub capabilities: ReviewPlatformCapabilities, + pub message: Option, + pub auth_challenge: Option, +} + +pub struct ReviewPlatformService; + +#[derive(Debug, Clone, Copy)] +struct PullRequestPagination { + page: u32, + per_page: u32, +} + +impl PullRequestPagination { + fn new(page: Option, per_page: Option) -> Self { + Self { + page: page.unwrap_or(DEFAULT_PR_PAGE).max(1), + per_page: per_page + .unwrap_or(DEFAULT_PR_PAGE_SIZE) + .clamp(1, MAX_PR_PAGE_SIZE), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReviewPlatformPagination { + pub page: u32, + pub per_page: u32, + pub total: Option, + pub has_next: bool, +} + +#[derive(Debug, Clone)] +struct ReviewPlatformPullRequestPage { + items: Vec, + pagination: ReviewPlatformPagination, +} + +#[derive(Debug, Clone)] +struct ProviderContext { + remote: ReviewPlatformRemote, + api_base_url: String, + token: Option, +} + +#[derive(Debug, Clone, Default)] +struct ReviewPlatformAuthTokens { + tokens: HashMap, +} + +impl ReviewPlatformAuthTokens { + fn get(&self, platform: ReviewPlatformKind, host: &str) -> Option<&str> { + token_key(platform, host).and_then(|key| self.tokens.get(&key).map(String::as_str)) + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredReviewPlatformTokens { + #[serde(default)] + tokens: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct StoredReviewPlatformToken { + token: String, + updated_at: String, +} + +impl ReviewPlatformService { + pub async fn discover_remotes( + repository_path: &str, + ) -> Result, ReviewPlatformError> { + let auth_tokens = load_stored_tokens().await?; + Self::discover_remotes_with_tokens(repository_path, &auth_tokens).await + } + + async fn discover_remotes_with_tokens( + repository_path: &str, + auth_tokens: &ReviewPlatformAuthTokens, + ) -> Result, ReviewPlatformError> { + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let output = execute_git_command(&root, &["remote", "-v"]) + .await + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + + let mut seen = HashSet::new(); + let mut remotes = Vec::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + if parts.get(2).is_some_and(|kind| *kind != "(fetch)") { + continue; + } + let remote_name = parts[0]; + let remote_url = parts[1]; + let key = format!("{}|{}", remote_name, remote_url); + if !seen.insert(key) { + continue; + } + if let Some(remote) = parse_remote(remote_name, remote_url, auth_tokens) { + remotes.push(remote); + } + } + + Ok(remotes) + } + + pub async fn workspace_snapshot( + repository_path: &str, + remote_id: Option<&str>, + page: Option, + per_page: Option, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Ok(empty_snapshot( + Vec::new(), + None, + None, + "Pull request browsing is not available for remote SSH workspaces yet.", + )); + } + + let pagination_request = PullRequestPagination::new(page, per_page); + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let selected_remote = select_remote(&remotes, remote_id).cloned(); + + let Some(remote) = selected_remote else { + return Ok(empty_snapshot( + remotes, + None, + None, + "No Git remotes were found", + )); + }; + + if !remote.supported { + return Ok(empty_snapshot( + remotes, + Some(remote.id.clone()), + Some(account_for_remote(&remote)), + remote + .message + .as_deref() + .unwrap_or("Unsupported remote provider"), + )); + } + + if remote.platform == ReviewPlatformKind::Gitcode + && token_for_remote(&remote, &auth_tokens).is_none() + { + return Ok(empty_snapshot( + remotes, + Some(remote.id.clone()), + Some(account_for_remote(&remote)), + "GitCode pull request APIs require a Personal Access Token. Add a token for this remote and refresh.", + )); + } + + let ctx = provider_context(remote.clone(), &auth_tokens)?; + let provider = provider_for(ctx.remote.platform); + let repository = Some(repository_ref(&ctx.remote, Some(root))); + let account = account_for_remote(&ctx.remote); + let capabilities = capabilities_for_remote(&remote); + match provider.list_pull_requests(&ctx, pagination_request).await { + Ok(page) => Ok(ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id: Some(remote.id.clone()), + accounts: vec![account], + repository, + pull_requests: page.items, + pagination: page.pagination, + capabilities, + message: None, + auth_challenge: None, + }), + Err(error) if is_auth_http_error(&error) => { + let challenge = auth_challenge_for_remote( + &remote, + &error, + token_for_remote(&remote, &auth_tokens).is_some(), + ); + let mut account = account; + account.auth_state = auth_state_for_challenge(challenge.state); + account.auth_source = + if matches!(challenge.state, ReviewPlatformAuthChallengeState::Missing) { + ReviewAuthSource::None + } else { + account.auth_source + }; + account.message = Some(challenge.message.clone()); + Ok(auth_required_snapshot( + remotes, + remote, + repository, + account, + capabilities, + challenge, + )) + } + Err(error) => Err(error), + } + } + + pub async fn pull_request_detail( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Err(ReviewPlatformError::UnsupportedPlatform( + "remote SSH workspace".to_string(), + )); + } + + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let remote = remotes + .into_iter() + .find(|remote| remote.id == remote_id) + .ok_or_else(|| ReviewPlatformError::RemoteNotFound(remote_id.to_string()))?; + if !remote.supported { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + let ctx = provider_context(remote, &auth_tokens)?; + provider_for(ctx.remote.platform) + .pull_request_detail(&ctx, pull_request_id) + .await + } + + pub async fn pull_request_detail_page( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + page: Option, + per_page: Option, + ) -> Result { + let ctx = Self::provider_context_for_repository(repository_path, Some(remote_id)).await?; + provider_for(ctx.remote.platform) + .pull_request_detail_page( + &ctx, + pull_request_id, + section, + PullRequestPagination::new(page, per_page), + ) + .await + } + + pub async fn pull_request_ci_log( + repository_path: &str, + remote_id: &str, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + let ctx = Self::provider_context_for_repository(repository_path, Some(remote_id)).await?; + provider_for(ctx.remote.platform) + .pull_request_ci_log(&ctx, pull_request_id, ci_item_id, ci_item_name) + .await + } + + pub async fn create_pull_request( + request: ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + request.remote_id.as_deref(), + ) + .await?; + provider_for(ctx.remote.platform) + .create_pull_request(&ctx, &request) + .await + } + + pub async fn reply_to_thread( + request: ReviewPlatformReplyToThreadRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .reply_to_thread(&ctx, &request) + .await + } + + pub async fn submit_review( + request: ReviewPlatformSubmitReviewRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .submit_review(&ctx, &request) + .await + } + + pub async fn resolve_thread( + request: ReviewPlatformResolveThreadRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .resolve_thread(&ctx, &request) + .await + } + + pub async fn approve_pull_request( + request: ReviewPlatformApprovalRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .approve_pull_request(&ctx, &request) + .await + } + + pub async fn revoke_approval( + request: ReviewPlatformApprovalRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .revoke_approval(&ctx, &request) + .await + } + + pub async fn request_changes( + request: ReviewPlatformRequestChangesRequest, + ) -> Result { + let ctx = Self::provider_context_for_repository( + &request.repository_path, + Some(request.remote_id.as_str()), + ) + .await?; + provider_for(ctx.remote.platform) + .request_changes(&ctx, &request) + .await + } + + async fn provider_context_for_repository( + repository_path: &str, + remote_id: Option<&str>, + ) -> Result { + if crate::service::remote_ssh::workspace_state::is_remote_path(repository_path).await { + return Err(ReviewPlatformError::UnsupportedPlatform( + "remote SSH workspace".to_string(), + )); + } + + let auth_tokens = load_stored_tokens().await?; + let root = get_repository_root(repository_path) + .map_err(|error| ReviewPlatformError::InvalidRepository(error.to_string()))?; + let remotes = Self::discover_remotes_with_tokens(&root, &auth_tokens).await?; + let remote = select_remote_for_action(&remotes, remote_id)?.clone(); + if !remote.supported { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + provider_context(remote, &auth_tokens) + } + + pub async fn update_auth_token( + platform: ReviewPlatformKind, + host: &str, + token: &str, + ) -> Result<(), ReviewPlatformError> { + let token = token.trim(); + if token.is_empty() { + return Err(ReviewPlatformError::Api( + "Token cannot be empty".to_string(), + )); + } + let key = token_key(platform, host) + .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; + let mut stored = load_stored_token_file().await?; + stored.tokens.insert( + key, + StoredReviewPlatformToken { + token: token.to_string(), + updated_at: chrono::Utc::now().to_rfc3339(), + }, + ); + save_stored_token_file(&stored).await + } + + pub async fn clear_auth_token( + platform: ReviewPlatformKind, + host: &str, + ) -> Result<(), ReviewPlatformError> { + let key = token_key(platform, host) + .ok_or_else(|| ReviewPlatformError::UnsupportedPlatform(host.to_string()))?; + let mut stored = load_stored_token_file().await?; + stored.tokens.remove(&key); + save_stored_token_file(&stored).await + } +} + +#[async_trait::async_trait] +trait ReviewProvider: Sync { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result; + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result; + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + let detail = self.pull_request_detail(ctx, pull_request_id).await?; + let ci_total = detail.ci.len(); + let file_total = detail.files.len(); + let commit_total = detail.commits.len(); + let thread_total = detail.threads.len(); + let (ci, files, commits, threads) = match section { + ReviewPlatformDetailSection::Overview => { + (Vec::new(), Vec::new(), Vec::new(), Vec::new()) + } + ReviewPlatformDetailSection::Ci => ( + slice_page(detail.ci, pagination), + Vec::new(), + Vec::new(), + Vec::new(), + ), + ReviewPlatformDetailSection::Files => ( + Vec::new(), + slice_page(detail.files, pagination), + Vec::new(), + Vec::new(), + ), + ReviewPlatformDetailSection::Commits => ( + Vec::new(), + Vec::new(), + slice_page(detail.commits, pagination), + Vec::new(), + ), + ReviewPlatformDetailSection::Reviews => ( + Vec::new(), + Vec::new(), + Vec::new(), + slice_page(detail.threads, pagination), + ), + }; + let total = match section { + ReviewPlatformDetailSection::Overview => 0, + ReviewPlatformDetailSection::Ci => ci_total, + ReviewPlatformDetailSection::Files => file_total, + ReviewPlatformDetailSection::Commits => commit_total, + ReviewPlatformDetailSection::Reviews => thread_total, + }; + Ok(ReviewPlatformPullRequestDetailPage { + pull_request: detail.pull_request, + body: detail.body, + ci, + files, + commits, + threads, + section, + pagination: pagination_from_total(pagination, total), + }) + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + _pull_request_id: &str, + _ci_item_id: &str, + _ci_item_name: &str, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} CI logs", + platform_label(ctx.remote.platform) + ))) + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} pull request creation", + platform_label(ctx.remote.platform) + ))) + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} thread replies", + platform_label(ctx.remote.platform) + ))) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} review submission", + platform_label(ctx.remote.platform) + ))) + } + + async fn resolve_thread( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformResolveThreadRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} thread resolution", + platform_label(ctx.remote.platform) + ))) + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformApprovalRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} pull request approval", + platform_label(ctx.remote.platform) + ))) + } + + async fn revoke_approval( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformApprovalRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} approval revocation", + platform_label(ctx.remote.platform) + ))) + } + + async fn request_changes( + &self, + ctx: &ProviderContext, + _request: &ReviewPlatformRequestChangesRequest, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform(format!( + "{} native change requests", + platform_label(ctx.remote.platform) + ))) + } +} + +struct GithubProvider; +struct GitlabProvider; +struct GitcodeProvider; +struct UnsupportedProvider; + +fn provider_for(platform: ReviewPlatformKind) -> &'static dyn ReviewProvider { + match platform { + ReviewPlatformKind::Github => &GithubProvider, + ReviewPlatformKind::Gitlab => &GitlabProvider, + ReviewPlatformKind::Gitcode => &GitcodeProvider, + ReviewPlatformKind::Unknown => &UnsupportedProvider, + } +} + +#[async_trait::async_trait] +impl ReviewProvider for GithubProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + github_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitHub pull response was not an array".to_string()) + })?; + let total = pagination_total_from_links(&response.headers, pagination, items.len()); + let has_next = link_header_has_rel(&response.headers, "next"); + + let pull_requests = items + .iter() + .map(github_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_github_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let client = http_client()?; + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let token = ctx.token.clone(); + let files_url = format!("{}/files", base); + let files = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &files_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let reviews_url = format!("{}/reviews", base); + let reviews = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &reviews_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let review_comments_url = format!("{}/comments", base); + let review_comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &review_comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + let token = ctx.token.clone(); + let issue_comments_url = format!( + "{}/repos/{}/{}/issues/{}/comments", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let issue_comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + github_request(client.clone(), &issue_comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await?; + + let mut pull_request = github_pull_request_from_value(&detail); + pull_request.review_decision = github_review_decision(&reviews); + let (checks, ci) = github_checks_and_ci(ctx, &client, &detail).await; + pull_request.checks = checks; + + Ok(ReviewPlatformPullRequestDetail { + body: value_string(&detail, "body"), + pull_request, + ci, + files: array_items(&files) + .iter() + .map(github_file_from_value) + .collect(), + commits: array_items(&commits) + .iter() + .map(github_commit_from_value) + .collect(), + threads: github_threads(&reviews, &review_comments, &issue_comments), + }) + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + github_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + if ci_item_id.starts_with("status-") { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some( + "GitHub commit statuses do not expose logs; use the linked target URL instead." + .to_string(), + ), + }); + } + + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let sha = nested_string(&detail, &["head", "sha"]); + if sha.trim().is_empty() { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some("GitHub pull request head SHA was not available.".to_string()), + }); + } + + let check_run_id = ci_item_id.strip_prefix("check-run-").unwrap_or(ci_item_id); + github_actions_log_for_check_run_item(ctx, &client, check_run_id, ci_item_name, &sha).await + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let token = require_write_token(ctx, "Creating a pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let payload = json!({ + "title": request.title, + "head": request.source_branch, + "base": request.target_branch, + "body": request.body.clone().unwrap_or_default(), + "draft": request.draft.unwrap_or(false), + }); + let value = + send_json(github_post_request(http_client()?, &url, Some(token)).json(&payload)) + .await?; + let pull_request = github_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created pull request #{}", pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + let token = require_write_token(ctx, "Replying to a pull request thread")?; + let comment_id = parse_provider_comment_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "GitHub replies require a review comment thread id such as comment-123".to_string(), + ) + })?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/comments/{}/replies", + ctx.api_base_url, + ctx.remote.owner, + ctx.remote.repository_name, + request.pull_request_id, + comment_id + ); + let value = send_json( + github_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "body": request.body })), + ) + .await?; + let thread = github_thread_from_review_comment(&value); + Ok(ReviewPlatformActionResult { + success: true, + message: "Replied to pull request thread".to_string(), + web_url: value + .get("html_url") + .and_then(Value::as_str) + .map(str::to_string), + pull_request: None, + thread: Some(thread), + }) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + let event = match request.event { + ReviewSubmitEvent::Comment => "COMMENT", + ReviewSubmitEvent::Approve => "APPROVE", + ReviewSubmitEvent::RequestChanges => "REQUEST_CHANGES", + }; + github_submit_review(ctx, &request.pull_request_id, event, &request.body).await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + github_submit_review( + ctx, + &request.pull_request_id, + "APPROVE", + request.body.as_deref().unwrap_or(""), + ) + .await + } + + async fn request_changes( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformRequestChangesRequest, + ) -> Result { + github_submit_review( + ctx, + &request.pull_request_id, + "REQUEST_CHANGES", + &request.body, + ) + .await + } +} + +async fn github_submit_review( + ctx: &ProviderContext, + pull_request_id: &str, + event: &str, + body: &str, +) -> Result { + let token = require_write_token(ctx, "Submitting a pull request review")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/reviews", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let value = send_json( + github_post_request(http_client()?, &url, Some(token)).json(&json!({ + "body": body, + "event": event, + })), + ) + .await?; + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Submitted GitHub review with event {}", event), + web_url: value + .get("html_url") + .and_then(Value::as_str) + .map(str::to_string), + pull_request: None, + thread: None, + }) +} + +async fn github_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(github_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut pull_request = github_pull_request_from_value(&detail); + let (checks, ci_all) = github_checks_and_ci(ctx, &client, &detail).await; + pull_request.checks = checks; + + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut ci = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + section_pagination = pagination_from_total(pagination, ci_all.len()); + ci = slice_page(ci_all, pagination); + } + ReviewPlatformDetailSection::Files => { + let response = fetch_array_page( + github_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + files = array_items(&response.value) + .iter() + .map(github_file_from_value) + .collect(); + } + ReviewPlatformDetailSection::Commits => { + let response = fetch_array_page( + github_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(github_commit_from_value) + .collect(); + } + ReviewPlatformDetailSection::Reviews => { + let reviews_url = format!("{}/reviews", base); + let reviews = fetch_array_page( + github_request(client.clone(), &reviews_url, ctx.token.as_deref()), + pagination, + ) + .await?; + let review_comments = fetch_array_page( + github_request( + client.clone(), + &format!("{}/comments", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + let issue_comments = fetch_array_page( + github_request( + client.clone(), + &format!( + "{}/repos/{}/{}/issues/{}/comments", + ctx.api_base_url, + ctx.remote.owner, + ctx.remote.repository_name, + pull_request_id + ), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + pull_request.review_decision = github_review_decision(&reviews.value); + section_pagination = combine_page_pagination( + pagination, + &[ + pagination_from_response(&reviews, pagination), + pagination_from_response(&review_comments, pagination), + pagination_from_response(&issue_comments, pagination), + ], + ); + threads = github_threads( + &reviews.value, + &review_comments.value, + &issue_comments.value, + ); + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + pull_request, + body: value_string(&detail, "body"), + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +#[async_trait::async_trait] +impl ReviewProvider for GitlabProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + gitlab_list_pull_requests(ctx, pagination).await + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + gitlab_pull_request_detail(ctx, pull_request_id).await + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + gitlab_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ci_item_id: &str, + ci_item_name: &str, + ) -> Result { + gitlab_pull_request_ci_log(ctx, pull_request_id, ci_item_id, ci_item_name).await + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + gitlab_create_pull_request(ctx, request, "merge request").await + } + + async fn reply_to_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + ) -> Result { + gitlab_reply_to_thread(ctx, request, "merge request").await + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + if request.event != ReviewSubmitEvent::Comment { + return Err(ReviewPlatformError::UnsupportedPlatform( + "GitLab submit_review supports comments only; use approve_pull_request for approvals" + .to_string(), + )); + } + gitlab_add_merge_request_note( + ctx, + &request.pull_request_id, + &request.body, + "Added merge request comment", + ) + .await + } + + async fn resolve_thread( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformResolveThreadRequest, + ) -> Result { + gitlab_resolve_thread(ctx, request, "merge request").await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + gitlab_approve_pull_request(ctx, request, "merge request").await + } + + async fn revoke_approval( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + gitlab_revoke_approval(ctx, request, "merge request").await + } +} + +async fn gitlab_list_pull_requests( + ctx: &ProviderContext, + pagination: PullRequestPagination, +) -> Result { + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!("{}/projects/{}/merge_requests", ctx.api_base_url, project); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + gitlab_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitLab merge request response was not an array".to_string()) + })?; + let total = header_u64(&response.headers, "x-total"); + let has_next = header_string(&response.headers, "x-next-page") + .is_some_and(|value| !value.trim().is_empty()) + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(false); + + let pull_requests = items + .iter() + .map(gitlab_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_gitlab_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) +} + +async fn gitlab_pull_request_detail( + ctx: &ProviderContext, + pull_request_id: &str, +) -> Result { + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path); + let base = format!( + "{}/projects/{}/merge_requests/{}", + ctx.api_base_url, project, pull_request_id + ); + let detail = send_json(gitlab_request(client.clone(), &base, ctx.token.as_deref())).await?; + let changes = send_json(gitlab_request( + client.clone(), + &format!("{}/changes", base), + ctx.token.as_deref(), + )) + .await?; + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + let token = ctx.token.clone(); + let discussions_url = format!("{}/discussions", base); + let discussions = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &discussions_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + let token = ctx.token.clone(); + let notes_url = format!("{}/notes", base); + let notes = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), ¬es_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await?; + + let mut pull_request = gitlab_pull_request_from_value(&detail); + let files = gitlab_files(&changes); + apply_files_stats(&mut pull_request, &files); + let ci = gitlab_pipeline_summary_item(&detail) + .into_iter() + .collect::>(); + pull_request.checks = summarize_ci_items(&ci); + + Ok(ReviewPlatformPullRequestDetail { + body: value_string(&detail, "description"), + pull_request, + ci, + files, + commits: array_items(&commits) + .iter() + .map(gitlab_commit_from_value) + .collect(), + threads: gitlab_threads(&discussions, ¬es), + }) +} + +async fn gitlab_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path); + let base = format!( + "{}/projects/{}/merge_requests/{}", + ctx.api_base_url, project, pull_request_id + ); + let detail = send_json(gitlab_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut pull_request = gitlab_pull_request_from_value(&detail); + let changes = send_json(gitlab_request( + client.clone(), + &format!("{}/changes", base), + ctx.token.as_deref(), + )) + .await?; + let all_files = gitlab_files(&changes); + apply_files_stats(&mut pull_request, &all_files); + let mut ci = gitlab_pipeline_summary_item(&detail) + .into_iter() + .collect::>(); + pull_request.checks = summarize_ci_items(&ci); + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + if let Some(pipeline_id) = detail + .get("head_pipeline") + .and_then(|value| value.get("id")) + .and_then(Value::as_i64) + .map(|id| id.to_string()) + .or_else(|| { + detail + .get("head_pipeline") + .and_then(|value| value.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + }) + { + let jobs = gitlab_pipeline_jobs( + ctx, + client.clone(), + &urlencoding::encode(&ctx.remote.project_path), + &pipeline_id, + ) + .await; + if !jobs.is_empty() { + ci = jobs; + pull_request.checks = summarize_ci_items(&ci); + } + } + section_pagination = pagination_from_total(pagination, ci.len()); + ci = slice_page(ci, pagination); + } + ReviewPlatformDetailSection::Files => { + section_pagination = pagination_from_total(pagination, all_files.len()); + files = slice_page(all_files, pagination); + } + ReviewPlatformDetailSection::Commits => { + let response = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(gitlab_commit_from_value) + .collect(); + } + ReviewPlatformDetailSection::Reviews => { + let discussions = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/discussions", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + let notes = fetch_array_page( + gitlab_request( + client.clone(), + &format!("{}/notes", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await?; + section_pagination = combine_page_pagination( + pagination, + &[ + pagination_from_response(&discussions, pagination), + pagination_from_response(¬es, pagination), + ], + ); + threads = gitlab_threads(&discussions.value, ¬es.value); + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + pull_request, + body: value_string(&detail, "description"), + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +async fn gitlab_create_pull_request( + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Creating a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!("{}/projects/{}/merge_requests", ctx.api_base_url, project); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)).json(&json!({ + "title": request.title, + "source_branch": request.source_branch, + "target_branch": request.target_branch, + "description": request.body.clone().unwrap_or_default(), + })), + ) + .await?; + let pull_request = gitlab_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created {} !{}", label, pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) +} + +async fn gitlab_reply_to_thread( + ctx: &ProviderContext, + request: &ReviewPlatformReplyToThreadRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Replying to a {} thread", label))?; + let discussion_id = parse_provider_thread_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "Replies require a discussion thread id from pull request detail".to_string(), + ) + })?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/discussions/{}/notes", + ctx.api_base_url, project, request.pull_request_id, discussion_id + ); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "body": request.body })), + ) + .await?; + let thread = gitlab_thread_from_note( + &value, + Some(discussion_id.to_string()), + false, + ReviewPlatformThreadKind::Comment, + None, + ); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Replied to {} discussion", label), + web_url: None, + pull_request: None, + thread: Some(thread), + }) +} + +async fn gitlab_add_merge_request_note( + ctx: &ProviderContext, + pull_request_id: &str, + body: &str, + message: &str, +) -> Result { + let token = require_write_token(ctx, "Adding a merge request comment")?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/notes", + ctx.api_base_url, project, pull_request_id + ); + let value = send_json( + gitlab_post_request(http_client()?, &url, Some(token)).json(&json!({ "body": body })), + ) + .await?; + let thread = + gitlab_thread_from_note(&value, None, false, ReviewPlatformThreadKind::Comment, None); + Ok(ReviewPlatformActionResult { + success: true, + message: message.to_string(), + web_url: None, + pull_request: None, + thread: Some(thread), + }) +} + +async fn gitlab_resolve_thread( + ctx: &ProviderContext, + request: &ReviewPlatformResolveThreadRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Resolving a {} thread", label))?; + let discussion_id = parse_provider_thread_id(&request.thread_id).ok_or_else(|| { + ReviewPlatformError::Api( + "Thread resolution requires a discussion thread id from pull request detail" + .to_string(), + ) + })?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/discussions/{}", + ctx.api_base_url, project, request.pull_request_id, discussion_id + ); + send_json( + gitlab_put_request(http_client()?, &url, Some(token)) + .json(&json!({ "resolved": request.resolved })), + ) + .await?; + Ok(ReviewPlatformActionResult { + success: true, + message: if request.resolved { + format!("Resolved {} discussion", label) + } else { + format!("Reopened {} discussion", label) + }, + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitlab_approve_pull_request( + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Approving a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/approve", + ctx.api_base_url, project, request.pull_request_id + ); + send_json(gitlab_post_request(http_client()?, &url, Some(token))).await?; + if let Some(body) = request + .body + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let _ = gitlab_add_merge_request_note( + ctx, + &request.pull_request_id, + body, + "Added approval note", + ) + .await; + } + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Approved {}", label), + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitlab_revoke_approval( + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + label: &str, +) -> Result { + let token = require_write_token(ctx, &format!("Revoking approval for a {}", label))?; + let project = urlencoding::encode(&ctx.remote.project_path); + let url = format!( + "{}/projects/{}/merge_requests/{}/unapprove", + ctx.api_base_url, project, request.pull_request_id + ); + send_json(gitlab_post_request(http_client()?, &url, Some(token))).await?; + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Revoked approval for {}", label), + web_url: None, + pull_request: None, + thread: None, + }) +} + +async fn gitcode_add_pull_request_comment( + ctx: &ProviderContext, + pull_request_id: &str, + body: &str, +) -> Result { + let token = require_write_token(ctx, "Adding a GitCode pull request comment")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/comments", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let value = send_json( + gitcode_post_request(http_client()?, &url, Some(token)).json(&json!({ "body": body })), + ) + .await?; + let thread = gitcode_threads(&Value::Array(vec![value])) + .into_iter() + .next(); + Ok(ReviewPlatformActionResult { + success: true, + message: "Added GitCode pull request comment".to_string(), + web_url: None, + pull_request: None, + thread, + }) +} + +async fn gitcode_pull_request_detail_page( + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = send_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; + let mut ci = gitcode_ci_items(&detail); + let mut pull_request = gitcode_pull_request_from_value(&detail); + pull_request.checks = summarize_ci_items(&ci); + let mut files = Vec::new(); + let mut commits = Vec::new(); + let mut threads = Vec::new(); + let mut section_pagination = empty_detail_pagination(section, pagination); + + match section { + ReviewPlatformDetailSection::Overview => {} + ReviewPlatformDetailSection::Ci => { + section_pagination = pagination_from_total(pagination, ci.len()); + ci = slice_page(ci, pagination); + } + ReviewPlatformDetailSection::Files => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/files", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + files = array_items(&response.value) + .iter() + .map(gitcode_file_from_value) + .collect(); + } + } + ReviewPlatformDetailSection::Commits => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/commits", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + commits = array_items(&response.value) + .iter() + .map(gitcode_commit_from_value) + .collect(); + } + } + ReviewPlatformDetailSection::Reviews => { + if let Ok(response) = fetch_array_page( + gitcode_request( + client.clone(), + &format!("{}/comments", base), + ctx.token.as_deref(), + ), + pagination, + ) + .await + { + section_pagination = pagination_from_response(&response, pagination); + threads = gitcode_threads(&response.value); + } + } + } + + Ok(ReviewPlatformPullRequestDetailPage { + body: first_non_empty(&[ + value_string(&detail, "body"), + value_string(&detail, "description"), + ]), + pull_request, + ci, + files, + commits, + threads, + section, + pagination: section_pagination, + }) +} + +#[async_trait::async_trait] +impl ReviewProvider for GitcodeProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + pagination: PullRequestPagination, + ) -> Result { + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let per_page = pagination.per_page.to_string(); + let page = pagination.page.to_string(); + let response = send_json_response( + gitcode_request(http_client()?, &url, ctx.token.as_deref()).query(&[ + ("state", "all"), + ("per_page", &per_page), + ("page", &page), + ]), + ) + .await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("GitCode pull response was not an array".to_string()) + })?; + let total = header_u64(&response.headers, "x-total").or_else(|| { + link_header_last_page(&response.headers).map(|last_page| { + if last_page == pagination.page { + (u64::from(last_page.saturating_sub(1)) * u64::from(pagination.per_page)) + + items.len() as u64 + } else { + u64::from(last_page) * u64::from(pagination.per_page) + } + }) + }); + let has_next = link_header_has_rel(&response.headers, "next") + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(items.len() == pagination.per_page as usize); + + let pull_requests = items + .iter() + .map(gitcode_pull_request_from_value) + .collect::>(); + let pull_requests = enrich_gitcode_pull_request_counts(ctx, pull_requests).await; + + Ok(ReviewPlatformPullRequestPage { + items: pull_requests, + pagination: ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next, + }, + }) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + ) -> Result { + let client = http_client()?; + let base = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request_id + ); + let detail = + send_json(gitcode_request(client.clone(), &base, ctx.token.as_deref())).await?; + let token = ctx.token.clone(); + let files_url = format!("{}/files", base); + let files = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &files_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let token = ctx.token.clone(); + let commits_url = format!("{}/commits", base); + let commits = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &commits_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let token = ctx.token.clone(); + let comments_url = format!("{}/comments", base); + let comments = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitcode_request(client.clone(), &comments_url, token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + github_next_page, + ) + .await + .unwrap_or(Value::Array(Vec::new())); + let ci = gitcode_ci_items(&detail); + let mut pull_request = gitcode_pull_request_from_value(&detail); + pull_request.checks = summarize_ci_items(&ci); + + Ok(ReviewPlatformPullRequestDetail { + body: first_non_empty(&[ + value_string(&detail, "body"), + value_string(&detail, "description"), + ]), + pull_request, + ci, + files: array_items(&files) + .iter() + .map(gitcode_file_from_value) + .collect(), + commits: array_items(&commits) + .iter() + .map(gitcode_commit_from_value) + .collect(), + threads: gitcode_threads(&comments), + }) + } + + async fn pull_request_detail_page( + &self, + ctx: &ProviderContext, + pull_request_id: &str, + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, + ) -> Result { + gitcode_pull_request_detail_page(ctx, pull_request_id, section, pagination).await + } + + async fn pull_request_ci_log( + &self, + _ctx: &ProviderContext, + _pull_request_id: &str, + ci_item_id: &str, + _ci_item_name: &str, + ) -> Result { + Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some( + "GitCode CI log retrieval is not available through a documented API.".to_string(), + ), + }) + } + + async fn create_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformCreatePullRequestRequest, + ) -> Result { + let token = require_write_token(ctx, "Creating a GitCode pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let value = send_json( + gitcode_post_request(http_client()?, &url, Some(token)).json(&json!({ + "title": request.title, + "head": request.source_branch, + "base": request.target_branch, + "body": request.body.clone().unwrap_or_default(), + "draft": request.draft.unwrap_or(false), + })), + ) + .await?; + let pull_request = gitcode_pull_request_from_value(&value); + let web_url = Some(pull_request.web_url.clone()); + Ok(ReviewPlatformActionResult { + success: true, + message: format!("Created GitCode pull request #{}", pull_request.number), + web_url, + pull_request: Some(pull_request), + thread: None, + }) + } + + async fn submit_review( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformSubmitReviewRequest, + ) -> Result { + if request.event != ReviewSubmitEvent::Comment { + return Err(ReviewPlatformError::UnsupportedPlatform( + "GitCode submit_review supports comments only; use approve_pull_request for review processing" + .to_string(), + )); + } + gitcode_add_pull_request_comment(ctx, &request.pull_request_id, &request.body).await + } + + async fn approve_pull_request( + &self, + ctx: &ProviderContext, + request: &ReviewPlatformApprovalRequest, + ) -> Result { + let token = require_write_token(ctx, "Approving a GitCode pull request")?; + let url = format!( + "{}/repos/{}/{}/pulls/{}/review", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, request.pull_request_id + ); + send_json( + gitcode_post_request(http_client()?, &url, Some(token)) + .json(&json!({ "force": false })), + ) + .await?; + if let Some(body) = request + .body + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let _ = gitcode_add_pull_request_comment(ctx, &request.pull_request_id, body).await; + } + Ok(ReviewPlatformActionResult { + success: true, + message: "Approved GitCode pull request".to_string(), + web_url: None, + pull_request: None, + thread: None, + }) + } +} + +#[async_trait::async_trait] +impl ReviewProvider for UnsupportedProvider { + async fn list_pull_requests( + &self, + ctx: &ProviderContext, + _pagination: PullRequestPagination, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform( + ctx.remote.host.clone(), + )) + } + + async fn pull_request_detail( + &self, + ctx: &ProviderContext, + _pull_request_id: &str, + ) -> Result { + Err(ReviewPlatformError::UnsupportedPlatform( + ctx.remote.host.clone(), + )) + } +} + +fn http_client() -> Result { + reqwest::Client::builder() + .use_native_tls() + .timeout(Duration::from_secs(25)) + .build() + .map_err(|error| ReviewPlatformError::Network(error.to_string())) +} + +struct JsonResponse { + value: Value, + headers: HeaderMap, +} + +async fn send_json(request: reqwest::RequestBuilder) -> Result { + send_json_response(request) + .await + .map(|response| response.value) +} + +async fn send_json_response( + request: reqwest::RequestBuilder, +) -> Result { + let response = request + .send() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let preview = body.chars().take(280).collect::(); + return Err(ReviewPlatformError::Http { + status: status.as_u16(), + message: preview, + }); + } + let headers = response.headers().clone(); + let value = response + .json::() + .await + .map_err(|error| ReviewPlatformError::Parse(error.to_string()))?; + Ok(JsonResponse { value, headers }) +} + +async fn send_text(request: reqwest::RequestBuilder) -> Result { + let response = request + .send() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| ReviewPlatformError::Network(error.to_string()))?; + if !status.is_success() { + let preview = text.chars().take(280).collect::(); + return Err(ReviewPlatformError::Http { + status: status.as_u16(), + message: preview, + }); + } + Ok(text) +} + +async fn fetch_paginated_array( + mut build_request: F, + next_page: fn(&HeaderMap, u32) -> Option, +) -> Result +where + F: FnMut(u32) -> reqwest::RequestBuilder, +{ + let mut page = 1; + let mut values = Vec::new(); + + loop { + let response = send_json_response(build_request(page)).await?; + let items = response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("Provider paginated response was not an array".to_string()) + })?; + values.extend(items.iter().cloned()); + + let Some(next) = next_page(&response.headers, page).filter(|next| *next > page) else { + break; + }; + page = next; + } + + Ok(Value::Array(values)) +} + +async fn fetch_array_page( + request: reqwest::RequestBuilder, + pagination: PullRequestPagination, +) -> Result { + let page = pagination.page.to_string(); + let per_page = pagination.per_page.to_string(); + let response = + send_json_response(request.query(&[("per_page", &per_page), ("page", &page)])).await?; + response.value.as_array().ok_or_else(|| { + ReviewPlatformError::Parse("Provider paginated response was not an array".to_string()) + })?; + Ok(response) +} + +fn pagination_from_response( + response: &JsonResponse, + pagination: PullRequestPagination, +) -> ReviewPlatformPagination { + let item_count = response.value.as_array().map(Vec::len).unwrap_or(0); + let total = header_u64(&response.headers, "x-total") + .or_else(|| pagination_total_from_links(&response.headers, pagination, item_count)); + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total, + has_next: link_header_has_rel(&response.headers, "next") + || header_string(&response.headers, "x-next-page") + .is_some_and(|value| !value.trim().is_empty()) + || total + .map(|total| u64::from(pagination.page) * u64::from(pagination.per_page) < total) + .unwrap_or(false), + } +} + +fn combine_page_pagination( + pagination: PullRequestPagination, + pages: &[ReviewPlatformPagination], +) -> ReviewPlatformPagination { + let totals = if pages.iter().any(|page| page.has_next) { + None + } else { + pages + .iter() + .map(|page| page.total) + .collect::>>() + .map(|values| values.into_iter().sum()) + }; + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: totals, + has_next: pages.iter().any(|page| page.has_next), + } +} + +fn header_string(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) +} + +fn header_u64(headers: &HeaderMap, name: &str) -> Option { + header_string(headers, name).and_then(|value| value.parse::().ok()) +} + +fn link_header_has_rel(headers: &HeaderMap, rel: &str) -> bool { + header_string(headers, "link") + .as_deref() + .is_some_and(|value| { + value + .split(',') + .any(|part| part.contains(&format!("rel=\"{}\"", rel))) + }) +} + +fn link_header_last_page(headers: &HeaderMap) -> Option { + let link = header_string(headers, "link")?; + for part in link.split(',') { + if !part.contains("rel=\"last\"") { + continue; + } + let url = part + .split(';') + .next()? + .trim() + .trim_start_matches('<') + .trim_end_matches('>'); + return query_param_u32(url, "page"); + } + None +} + +fn pagination_total_from_links( + headers: &HeaderMap, + pagination: PullRequestPagination, + item_count: usize, +) -> Option { + if let Some(last_page) = link_header_last_page(headers) { + if pagination.per_page == 1 { + return Some(u64::from(last_page)); + } + if last_page == pagination.page { + return Some( + u64::from(pagination.page.saturating_sub(1)) * u64::from(pagination.per_page) + + item_count as u64, + ); + } + return None; + } + + Some( + u64::from(pagination.page.saturating_sub(1)) * u64::from(pagination.per_page) + + item_count as u64, + ) +} + +fn pagination_from_total( + pagination: PullRequestPagination, + total: usize, +) -> ReviewPlatformPagination { + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: Some(total as u64), + has_next: usize::try_from(pagination.page) + .ok() + .is_some_and(|page| page * (pagination.per_page as usize) < total), + } +} + +fn slice_page(items: Vec, pagination: PullRequestPagination) -> Vec { + let start = pagination + .page + .saturating_sub(1) + .saturating_mul(pagination.per_page) as usize; + items + .into_iter() + .skip(start) + .take(pagination.per_page as usize) + .collect() +} + +fn empty_detail_pagination( + section: ReviewPlatformDetailSection, + pagination: PullRequestPagination, +) -> ReviewPlatformPagination { + ReviewPlatformPagination { + page: pagination.page, + per_page: pagination.per_page, + total: if section == ReviewPlatformDetailSection::Overview { + Some(0) + } else { + None + }, + has_next: false, + } +} + +fn github_next_page(headers: &HeaderMap, current_page: u32) -> Option { + if link_header_has_rel(headers, "next") { + Some(current_page.saturating_add(1)) + } else { + None + } +} + +fn gitlab_next_page(headers: &HeaderMap, _current_page: u32) -> Option { + header_string(headers, "x-next-page").and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + trimmed.parse::().ok() + } + }) +} + +fn query_param_u32(url: &str, name: &str) -> Option { + let query = url.split_once('?')?.1; + for pair in query.split('&') { + if let Some((key, value)) = pair.split_once('=') { + if key == name { + return value.parse::().ok(); + } + } + } + None +} + +async fn enrich_github_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(github_request(client, &url, token.as_deref())).await { + pull_request.additions = value_i64(&value, "additions") as i32; + pull_request.deletions = value_i64(&value, "deletions") as i32; + pull_request.changed_files = value_i64(&value, "changed_files") as i32; + pull_request.comments = + (value_i64(&value, "comments") + value_i64(&value, "review_comments")) as i32; + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +async fn enrich_gitlab_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let project = urlencoding::encode(&ctx.remote.project_path).to_string(); + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/projects/{}/merge_requests/{}/changes", + ctx.api_base_url, project, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(gitlab_request(client, &url, token.as_deref())).await { + let files = gitlab_files(&value); + apply_files_stats(&mut pull_request, &files); + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +async fn enrich_gitcode_pull_request_counts( + ctx: &ProviderContext, + pull_requests: Vec, +) -> Vec { + let Ok(client) = http_client() else { + return pull_requests; + }; + let futures = pull_requests.into_iter().map(|mut pull_request| { + let client = client.clone(); + let url = format!( + "{}/repos/{}/{}/pulls/{}", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, pull_request.id + ); + let token = ctx.token.clone(); + async move { + if let Ok(value) = send_json(gitcode_request(client, &url, token.as_deref())).await { + let detail = gitcode_pull_request_from_value(&value); + pull_request.additions = detail.additions; + pull_request.deletions = detail.deletions; + pull_request.changed_files = detail.changed_files; + pull_request.comments = detail.comments; + } + pull_request + } + }); + stream::iter(futures) + .buffered(PROVIDER_ENRICH_CONCURRENCY) + .collect() + .await +} + +fn github_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = token { + request = request.header(AUTHORIZATION, format!("Bearer {}", token)); + } + request +} + +fn github_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28"); + if let Some(token) = token { + request = request.header(AUTHORIZATION, format!("Bearer {}", token)); + } + request +} + +fn gitlab_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitlab_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitlab_put_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .put(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request.header("PRIVATE-TOKEN", token); + } + request +} + +fn gitcode_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .get(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request + .header("PRIVATE-TOKEN", token) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .query(&[("access_token", token)]); + } + request +} + +fn gitcode_post_request( + client: reqwest::Client, + url: &str, + token: Option<&str>, +) -> reqwest::RequestBuilder { + let mut request = client + .post(url) + .header(USER_AGENT, USER_AGENT_VALUE) + .header(ACCEPT, "application/json"); + if let Some(token) = token { + request = request + .header("PRIVATE-TOKEN", token) + .header(AUTHORIZATION, format!("Bearer {}", token)) + .query(&[("access_token", token)]); + } + request +} + +fn require_write_token<'a>( + ctx: &'a ProviderContext, + action: &str, +) -> Result<&'a str, ReviewPlatformError> { + ctx.token.as_deref().ok_or_else(|| { + ReviewPlatformError::Api(format!( + "{} requires a {} token for {}", + action, + platform_label(ctx.remote.platform), + ctx.remote.host + )) + }) +} + +fn provider_context( + remote: ReviewPlatformRemote, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Result { + let api_base_url = match remote.platform { + ReviewPlatformKind::Github => "https://api.github.com".to_string(), + ReviewPlatformKind::Gitlab => format!("https://{}/api/v4", remote.host), + ReviewPlatformKind::Gitcode => "https://api.gitcode.com/api/v5".to_string(), + ReviewPlatformKind::Unknown => { + return Err(ReviewPlatformError::UnsupportedPlatform(remote.host)); + } + }; + let token = token_for_remote(&remote, auth_tokens); + Ok(ProviderContext { + remote, + api_base_url, + token, + }) +} + +fn token_for_remote( + remote: &ReviewPlatformRemote, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Option { + auth_tokens + .get(remote.platform, &remote.host) + .map(str::to_string) + .or_else(|| env_token_for_platform(remote.platform)) +} + +fn env_token_for_platform(platform: ReviewPlatformKind) -> Option { + let names: &[&str] = match platform { + ReviewPlatformKind::Github => &["GITHUB_TOKEN", "GH_TOKEN"], + ReviewPlatformKind::Gitlab => &["GITLAB_TOKEN", "GITLAB_PRIVATE_TOKEN"], + ReviewPlatformKind::Gitcode => &["GITCODE_TOKEN"], + ReviewPlatformKind::Unknown => &[], + }; + names.iter().find_map(|name| { + std::env::var(name) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + }) +} + +fn auth_for_platform_host( + platform: ReviewPlatformKind, + host: &str, + auth_tokens: &ReviewPlatformAuthTokens, +) -> (ReviewAuthState, ReviewAuthSource) { + if platform == ReviewPlatformKind::Unknown { + return (ReviewAuthState::Unsupported, ReviewAuthSource::Unsupported); + } + if auth_tokens.get(platform, host).is_some() { + return (ReviewAuthState::Connected, ReviewAuthSource::Stored); + } + if env_token_for_platform(platform).is_some() { + return (ReviewAuthState::Connected, ReviewAuthSource::Env); + } + if platform == ReviewPlatformKind::Gitcode { + (ReviewAuthState::NotConnected, ReviewAuthSource::None) + } else { + (ReviewAuthState::NotRequired, ReviewAuthSource::None) + } +} + +fn token_key(platform: ReviewPlatformKind, host: &str) -> Option { + if platform == ReviewPlatformKind::Unknown { + return None; + } + let host = host.trim().to_ascii_lowercase(); + if host.is_empty() { + return None; + } + Some(format!("{}:{}", platform.as_str(), host)) +} + +fn stored_token_file_path() -> Result { + let path_manager = + try_get_path_manager_arc().map_err(|error| ReviewPlatformError::Api(error.to_string()))?; + Ok(path_manager + .user_data_dir() + .join("review-platform-tokens.json")) +} + +async fn load_stored_tokens() -> Result { + let stored = load_stored_token_file().await?; + Ok(ReviewPlatformAuthTokens { + tokens: stored + .tokens + .into_iter() + .filter_map(|(key, entry)| { + let token = entry.token.trim().to_string(); + if token.is_empty() { + None + } else { + Some((key, token)) + } + }) + .collect(), + }) +} + +async fn load_stored_token_file() -> Result { + let path = stored_token_file_path()?; + match fs::read_to_string(&path).await { + Ok(content) => serde_json::from_str::(&content) + .map_err(|error| ReviewPlatformError::Parse(error.to_string())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(StoredReviewPlatformTokens::default()) + } + Err(error) => Err(ReviewPlatformError::Api(format!( + "Failed to read review platform token store: {}", + error + ))), + } +} + +async fn save_stored_token_file( + stored: &StoredReviewPlatformTokens, +) -> Result<(), ReviewPlatformError> { + let path = stored_token_file_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await.map_err(|error| { + ReviewPlatformError::Api(format!( + "Failed to create review platform token store directory: {}", + error + )) + })?; + } + let content = serde_json::to_string_pretty(stored) + .map_err(|error| ReviewPlatformError::Parse(error.to_string()))?; + fs::write(&path, content).await.map_err(|error| { + ReviewPlatformError::Api(format!( + "Failed to write review platform token store: {}", + error + )) + }) +} + +fn select_remote<'a>( + remotes: &'a [ReviewPlatformRemote], + remote_id: Option<&str>, +) -> Option<&'a ReviewPlatformRemote> { + if let Some(remote_id) = remote_id { + if let Some(remote) = remotes.iter().find(|remote| remote.id == remote_id) { + return Some(remote); + } + } + remotes + .iter() + .find(|remote| remote.supported) + .or_else(|| remotes.first()) +} + +fn select_remote_for_action<'a>( + remotes: &'a [ReviewPlatformRemote], + remote_id: Option<&str>, +) -> Result<&'a ReviewPlatformRemote, ReviewPlatformError> { + if let Some(remote_id) = remote_id { + return remotes + .iter() + .find(|remote| remote.id == remote_id) + .ok_or_else(|| ReviewPlatformError::RemoteNotFound(remote_id.to_string())); + } + + let supported = remotes + .iter() + .filter(|remote| remote.supported) + .collect::>(); + match supported.as_slice() { + [] => remotes + .first() + .ok_or_else(|| ReviewPlatformError::RemoteNotFound("default".to_string())), + [remote] => Ok(remote), + _ => Err(ReviewPlatformError::Api(format!( + "Multiple supported review platform remotes were found. Provide remote_id explicitly. Candidate remotes:\n{}", + supported + .iter() + .map(|remote| format!( + "- remote_id: {} | name: {} | platform: {:?} | project: {} | url: {}", + remote.id, remote.name, remote.platform, remote.project_path, remote.web_url + )) + .collect::>() + .join("\n") + ))), + } +} + +fn empty_snapshot( + remotes: Vec, + selected_remote_id: Option, + account: Option, + message: &str, +) -> ReviewPlatformWorkspaceSnapshot { + let mut accounts = account.into_iter().collect::>(); + if let Some(account) = accounts.first_mut() { + if account.message.is_none() && !message.trim().is_empty() { + account.message = Some(message.to_string()); + } + } + + ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id, + accounts, + repository: None, + pull_requests: Vec::new(), + pagination: ReviewPlatformPagination { + page: DEFAULT_PR_PAGE, + per_page: DEFAULT_PR_PAGE_SIZE, + total: Some(0), + has_next: false, + }, + capabilities: ReviewPlatformCapabilities { + can_create_review: false, + can_create_pull_request: false, + can_reply_to_thread: false, + can_resolve_thread: false, + can_approve: false, + can_revoke_approval: false, + can_request_changes: false, + can_merge: false, + supports_draft_review: false, + }, + message: if message.trim().is_empty() { + None + } else { + Some(message.to_string()) + }, + auth_challenge: None, + } +} + +fn auth_required_snapshot( + remotes: Vec, + remote: ReviewPlatformRemote, + repository: Option, + account: ReviewPlatformAccount, + capabilities: ReviewPlatformCapabilities, + challenge: ReviewPlatformAuthChallenge, +) -> ReviewPlatformWorkspaceSnapshot { + ReviewPlatformWorkspaceSnapshot { + remotes, + selected_remote_id: Some(remote.id), + accounts: vec![account], + repository, + pull_requests: Vec::new(), + pagination: ReviewPlatformPagination { + page: DEFAULT_PR_PAGE, + per_page: DEFAULT_PR_PAGE_SIZE, + total: Some(0), + has_next: false, + }, + capabilities, + message: Some(challenge.message.clone()), + auth_challenge: Some(challenge), + } +} + +fn repository_ref( + remote: &ReviewPlatformRemote, + workspace_path: Option, +) -> ReviewPlatformRepositoryRef { + ReviewPlatformRepositoryRef { + provider_id: remote.id.clone(), + platform: remote.platform, + host: remote.host.clone(), + owner: remote.owner.clone(), + name: remote.repository_name.clone(), + project_path: remote.project_path.clone(), + default_branch: "main".to_string(), + workspace_path, + web_url: remote.web_url.clone(), + } +} + +fn account_for_remote(remote: &ReviewPlatformRemote) -> ReviewPlatformAccount { + ReviewPlatformAccount { + id: remote.id.clone(), + platform: remote.platform, + label: format!("{} ({})", platform_label(remote.platform), remote.host), + username: None, + host: remote.host.clone(), + auth_state: remote.auth_state, + auth_source: remote.auth_source, + scopes: if matches!( + remote.auth_source, + ReviewAuthSource::Env | ReviewAuthSource::Stored + ) { + vec!["pull_request:read".to_string()] + } else { + Vec::new() + }, + message: remote.message.clone(), + } +} + +fn capabilities_for_remote(_remote: &ReviewPlatformRemote) -> ReviewPlatformCapabilities { + let platform = _remote.platform; + ReviewPlatformCapabilities { + can_create_review: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_create_pull_request: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_reply_to_thread: matches!(platform, ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab), + can_resolve_thread: matches!(platform, ReviewPlatformKind::Gitlab), + can_approve: matches!( + platform, + ReviewPlatformKind::Github | ReviewPlatformKind::Gitlab | ReviewPlatformKind::Gitcode + ), + can_revoke_approval: matches!(platform, ReviewPlatformKind::Gitlab), + can_request_changes: matches!(platform, ReviewPlatformKind::Github), + can_merge: false, + supports_draft_review: matches!(platform, ReviewPlatformKind::Github), + } +} + +fn platform_label(platform: ReviewPlatformKind) -> &'static str { + match platform { + ReviewPlatformKind::Github => "GitHub", + ReviewPlatformKind::Gitlab => "GitLab", + ReviewPlatformKind::Gitcode => "GitCode", + ReviewPlatformKind::Unknown => "Git", + } +} + +fn required_scopes_for_platform(platform: ReviewPlatformKind) -> Vec { + match platform { + ReviewPlatformKind::Github => vec!["repo".to_string(), "pull_requests:read".to_string()], + ReviewPlatformKind::Gitlab => { + vec!["read_api".to_string(), "api for write actions".to_string()] + } + ReviewPlatformKind::Gitcode => vec!["pull_request".to_string()], + ReviewPlatformKind::Unknown => Vec::new(), + } +} + +fn auth_state_for_challenge(state: ReviewPlatformAuthChallengeState) -> ReviewAuthState { + match state { + ReviewPlatformAuthChallengeState::Missing => ReviewAuthState::NotConnected, + ReviewPlatformAuthChallengeState::Invalid => ReviewAuthState::Expired, + ReviewPlatformAuthChallengeState::InsufficientScope => ReviewAuthState::Error, + } +} + +fn is_auth_http_error(error: &ReviewPlatformError) -> bool { + matches!( + error, + ReviewPlatformError::Http { + status: 401 | 403, + .. + } + ) +} + +fn auth_challenge_for_remote( + remote: &ReviewPlatformRemote, + error: &ReviewPlatformError, + has_token: bool, +) -> ReviewPlatformAuthChallenge { + let status = match error { + ReviewPlatformError::Http { status, .. } => *status, + _ => 0, + }; + let state = if !has_token { + ReviewPlatformAuthChallengeState::Missing + } else if status == 403 { + ReviewPlatformAuthChallengeState::InsufficientScope + } else { + ReviewPlatformAuthChallengeState::Invalid + }; + let action = match state { + ReviewPlatformAuthChallengeState::Missing => "Add", + ReviewPlatformAuthChallengeState::Invalid => "Update", + ReviewPlatformAuthChallengeState::InsufficientScope => "Update", + }; + let reason = match state { + ReviewPlatformAuthChallengeState::Missing => { + "a token is required to access this repository" + } + ReviewPlatformAuthChallengeState::Invalid => "the saved or environment token was rejected", + ReviewPlatformAuthChallengeState::InsufficientScope => { + "the token does not have enough permissions" + } + }; + ReviewPlatformAuthChallenge { + platform: remote.platform, + host: remote.host.clone(), + remote_id: remote.id.clone(), + project_path: remote.project_path.clone(), + state, + message: format!( + "{} {} token for {}: {}.", + action, + platform_label(remote.platform), + remote.host, + reason + ), + required_scopes: required_scopes_for_platform(remote.platform), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CiOutcome { + Passed, + Failed, + Pending, +} + +fn summarize_ci_items(items: &[ReviewPlatformCiItem]) -> ReviewChecks { + let mut checks = empty_checks(); + for item in items { + match ci_item_outcome(item) { + CiOutcome::Passed => checks.passed += 1, + CiOutcome::Failed => checks.failed += 1, + CiOutcome::Pending => checks.pending += 1, + } + } + checks.total = checks.passed + checks.failed + checks.pending; + checks +} + +fn ci_item_outcome(item: &ReviewPlatformCiItem) -> CiOutcome { + let status = item.status.trim().to_ascii_lowercase(); + let conclusion = item + .conclusion + .as_deref() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + + if conclusion.is_empty() { + return ci_status_outcome(&status); + } + + match conclusion.as_str() { + "success" | "neutral" | "skipped" | "passed" => CiOutcome::Passed, + "failure" | "timed_out" | "timed-out" | "cancelled" | "canceled" | "action_required" + | "error" => CiOutcome::Failed, + "queued" + | "pending" + | "running" + | "in_progress" + | "in progress" + | "created" + | "manual" + | "scheduled" + | "waiting_for_resource" + | "preparing" + | "requested" => CiOutcome::Pending, + _ => ci_status_outcome(&status), + } +} + +fn ci_status_outcome(status: &str) -> CiOutcome { + match status.trim().to_ascii_lowercase().as_str() { + "success" | "passed" | "pass" | "skipped" | "ok" | "available" | "can_be_merged" + | "mergeable" | "true" | "enabled" | "active" => CiOutcome::Passed, + "failure" | "failed" | "fail" | "error" | "cancelled" | "canceled" | "cannot_be_merged" + | "conflict" | "blocked" | "false" | "disabled" | "inactive" => CiOutcome::Failed, + "pending" + | "queued" + | "running" + | "in_progress" + | "in progress" + | "created" + | "manual" + | "scheduled" + | "waiting_for_resource" + | "preparing" + | "requested" + | "checking" + | "unchecked" + | "completed" => CiOutcome::Pending, + _ => CiOutcome::Pending, + } +} + +fn ci_log_value(text: String) -> (Option, bool) { + let extracted = ci_error_excerpt(&text); + let Some(excerpt) = extracted else { + return (None, false); + }; + let char_count = excerpt.chars().count(); + if char_count <= MAX_CI_LOG_CHARS { + return (Some(excerpt), false); + } + + ( + Some(format!( + "[Error excerpt truncated: showing first {} of {} chars]\n{}", + MAX_CI_LOG_CHARS, + char_count, + excerpt.chars().take(MAX_CI_LOG_CHARS).collect::() + )), + true, + ) +} + +fn empty_ci_log() -> (Option, bool) { + (None, false) +} + +fn ci_error_excerpt(text: &str) -> Option { + let lines: Vec<&str> = text.lines().collect(); + if lines.is_empty() { + return None; + } + + let mut ranges: Vec<(usize, usize)> = Vec::new(); + for (index, line) in lines.iter().enumerate() { + if !is_ci_error_line(line) { + continue; + } + + let start = index.saturating_sub(2); + let mut end = (index + 6).min(lines.len()); + while end < lines.len() && lines[end].trim().is_empty() { + end += 1; + } + ranges.push((start, end)); + } + + if ranges.is_empty() { + return None; + } + + ranges.sort_unstable_by_key(|range| range.0); + let mut merged: Vec<(usize, usize)> = Vec::new(); + for (start, end) in ranges { + if let Some(last) = merged.last_mut() { + if start <= last.1.saturating_add(1) { + last.1 = last.1.max(end); + continue; + } + } + merged.push((start, end)); + } + + let mut output = String::new(); + for (index, (start, end)) in merged.iter().enumerate() { + if index > 0 { + output.push_str("\n...\n"); + } + for line in &lines[*start..*end] { + output.push_str(line); + output.push('\n'); + } + } + + let output = output.trim_end_matches('\n').trim().to_string(); + if output.is_empty() { + None + } else { + Some(output) + } +} + +fn is_ci_error_line(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + lower.contains("##[error]") + || lower.contains("error:") + || lower.contains(" failed") + || lower.contains("failure") + || lower.contains("fatal") + || lower.contains("exception") + || lower.contains("traceback") + || lower.contains("panic") + || lower.contains("assertion failed") + || lower.contains("command failed") + || lower.contains("exited with code") + || lower.contains("return code") + || lower.contains("build failed") + || lower.contains("test failed") +} + +async fn github_checks_and_ci( + ctx: &ProviderContext, + client: &reqwest::Client, + pull_detail: &Value, +) -> (ReviewChecks, Vec) { + let sha = nested_string(pull_detail, &["head", "sha"]); + if sha.trim().is_empty() { + return (empty_checks(), Vec::new()); + } + + let mut ci_items = Vec::new(); + let status_url = format!( + "{}/repos/{}/{}/commits/{}/status", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, sha + ); + if let Ok(status) = send_json(github_request( + client.clone(), + &status_url, + ctx.token.as_deref(), + )) + .await + { + let statuses = status + .get("statuses") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]); + for (index, item) in statuses.iter().enumerate() { + ci_items.push(ReviewPlatformCiItem { + id: format!( + "status-{}", + first_non_empty(&[value_string(item, "id"), index.to_string()]) + ), + name: first_non_empty(&[ + value_string(item, "context"), + value_string(item, "description"), + "Status".to_string(), + ]), + status: value_string(item, "state"), + conclusion: None, + detail: optional_string(item, "description"), + stage: None, + web_url: optional_string(item, "target_url"), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + } + + let check_runs_url = format!( + "{}/repos/{}/{}/commits/{}/check-runs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, sha + ); + if let Ok(check_runs) = send_json( + github_request(client.clone(), &check_runs_url, ctx.token.as_deref()) + .query(&[("per_page", "100")]), + ) + .await + { + for (index, item) in check_runs + .get("check_runs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + .iter() + .enumerate() + { + ci_items.push(ReviewPlatformCiItem { + id: format!( + "check-run-{}", + first_non_empty(&[value_string(item, "id"), index.to_string()]) + ), + name: first_non_empty(&[value_string(item, "name"), "Check run".to_string()]), + status: value_string(item, "status"), + conclusion: optional_string(item, "conclusion"), + detail: nested_optional_string(item, &["output", "summary"]) + .or_else(|| nested_optional_string(item, &["output", "text"])) + .or_else(|| optional_string(item, "details_url")), + stage: None, + web_url: optional_string(item, "html_url") + .or_else(|| optional_string(item, "details_url")), + log: None, + log_truncated: false, + started_at: optional_string(item, "started_at"), + finished_at: optional_string(item, "completed_at"), + }); + } + } + + let checks = summarize_ci_items(&ci_items); + (checks, ci_items) +} + +async fn github_actions_jobs_for_head_sha( + ctx: &ProviderContext, + client: &reqwest::Client, + sha: &str, +) -> Vec { + let runs_url = format!( + "{}/repos/{}/{}/actions/runs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name + ); + let runs = match send_json( + github_request(client.clone(), &runs_url, ctx.token.as_deref()) + .query(&[("head_sha", sha), ("per_page", "100")]), + ) + .await + { + Ok(value) => value, + Err(_) => return Vec::new(), + }; + + let mut jobs = Vec::new(); + for run in runs + .get("workflow_runs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + { + let run_id = value_string(run, "id"); + if run_id.trim().is_empty() { + continue; + } + let jobs_url = format!( + "{}/repos/{}/{}/actions/runs/{}/jobs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, run_id + ); + if let Ok(value) = send_json( + github_request(client.clone(), &jobs_url, ctx.token.as_deref()) + .query(&[("per_page", "100")]), + ) + .await + { + jobs.extend( + value + .get("jobs") + .and_then(Value::as_array) + .map(|items| items.as_slice()) + .unwrap_or(&[]) + .iter() + .cloned(), + ); + } + } + + jobs +} + +async fn github_actions_log_for_check_run_item( + ctx: &ProviderContext, + client: &reqwest::Client, + check_run_id: &str, + check_run_name: &str, + head_sha: &str, +) -> Result { + let action_jobs = github_actions_jobs_for_head_sha(ctx, client, head_sha).await; + let check_run = action_jobs + .iter() + .find(|job| { + let check_run_url = value_string(job, "check_run_url"); + check_run_url.ends_with(&format!("/check-runs/{}", check_run_id)) + || value_string(job, "name") == check_run_name + }) + .cloned(); + + let Some(job) = check_run else { + return Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log: None, + truncated: false, + message: Some( + "No matching GitHub Actions job was found for this check run.".to_string(), + ), + }); + }; + + let job_id = value_string(&job, "id"); + if job_id.trim().is_empty() { + return Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log: None, + truncated: false, + message: Some("The matching GitHub Actions job does not expose a job id.".to_string()), + }); + } + + let logs_url = format!( + "{}/repos/{}/{}/actions/jobs/{}/logs", + ctx.api_base_url, ctx.remote.owner, ctx.remote.repository_name, job_id + ); + let text = send_text(github_request( + client.clone(), + &logs_url, + ctx.token.as_deref(), + )) + .await?; + let (log, truncated) = ci_log_value(text); + let message = log + .as_ref() + .is_none() + .then_some("No error lines were detected in the GitHub Actions job log.".to_string()); + Ok(ReviewPlatformCiLog { + ci_item_id: format!("check-run-{}", check_run_id), + log, + truncated, + message, + }) +} + +fn gitlab_pipeline_summary_item(detail: &Value) -> Option { + let pipeline = detail.get("head_pipeline")?; + let status = value_string(pipeline, "status"); + if status.trim().is_empty() { + return None; + } + Some(ReviewPlatformCiItem { + id: first_non_empty(&[ + value_string(pipeline, "id"), + value_string(pipeline, "iid"), + "head-pipeline".to_string(), + ]), + name: "Pipeline".to_string(), + status, + conclusion: None, + detail: nested_optional_string(pipeline, &["detailed_status", "text"]) + .or_else(|| nested_optional_string(pipeline, &["detailed_status", "label"])), + stage: None, + web_url: optional_string(pipeline, "web_url"), + log: None, + log_truncated: false, + started_at: optional_string(pipeline, "started_at"), + finished_at: optional_string(pipeline, "finished_at"), + }) +} + +async fn gitlab_pipeline_jobs( + ctx: &ProviderContext, + client: reqwest::Client, + project: &str, + pipeline_id: &str, +) -> Vec { + let jobs_url = format!( + "{}/projects/{}/pipelines/{}/jobs", + ctx.api_base_url, project, pipeline_id + ); + if let Ok(response) = fetch_paginated_array( + |page| { + let page = page.to_string(); + gitlab_request(client.clone(), &jobs_url, ctx.token.as_deref()) + .query(&[("per_page", "100"), ("page", &page)]) + }, + gitlab_next_page, + ) + .await + { + let mut jobs = Vec::new(); + for (index, job) in array_items(&response).iter().enumerate() { + let provider_id = value_string(job, "id"); + let id = first_non_empty(&[provider_id.clone(), index.to_string()]); + jobs.push(ReviewPlatformCiItem { + id, + name: first_non_empty(&[value_string(job, "name"), "Job".to_string()]), + status: value_string(job, "status"), + conclusion: None, + detail: optional_string(job, "failure_reason"), + stage: optional_string(job, "stage"), + web_url: optional_string(job, "web_url"), + log: None, + log_truncated: false, + started_at: optional_string(job, "started_at"), + finished_at: optional_string(job, "finished_at"), + }); + } + return jobs; + } + Vec::new() +} + +async fn gitlab_job_trace( + ctx: &ProviderContext, + client: reqwest::Client, + project: &str, + job_id: &str, +) -> (Option, bool) { + if job_id.trim().is_empty() { + return empty_ci_log(); + } + let trace_url = format!( + "{}/projects/{}/jobs/{}/trace", + ctx.api_base_url, project, job_id + ); + match send_text(gitlab_request(client, &trace_url, ctx.token.as_deref())).await { + Ok(text) => ci_log_value(text), + Err(_) => empty_ci_log(), + } +} + +async fn gitlab_pull_request_ci_log( + ctx: &ProviderContext, + _pull_request_id: &str, + ci_item_id: &str, + _ci_item_name: &str, +) -> Result { + if ci_item_id == "head-pipeline" || ci_item_id == "pipeline" { + return Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log: None, + truncated: false, + message: Some("Pipeline summaries do not expose a separate job trace.".to_string()), + }); + } + + let client = http_client()?; + let project = urlencoding::encode(&ctx.remote.project_path).to_string(); + let (log, truncated) = gitlab_job_trace(ctx, client, &project, ci_item_id).await; + let message = log + .as_ref() + .is_none() + .then_some("No error lines were detected in the job trace.".to_string()); + Ok(ReviewPlatformCiLog { + ci_item_id: ci_item_id.to_string(), + log, + truncated, + message, + }) +} + +fn gitcode_ci_items(detail: &Value) -> Vec { + let mut items = Vec::new(); + let pipeline_status = first_non_empty(&[ + value_string(detail, "pipeline_status"), + value_string(detail, "pipeline_status_with_code_quality"), + ]); + if !pipeline_status.trim().is_empty() { + items.push(ReviewPlatformCiItem { + id: first_non_empty(&[ + value_string(detail, "head_pipeline_id"), + "pipeline".to_string(), + ]), + name: "Pipeline".to_string(), + status: pipeline_status, + conclusion: None, + detail: optional_string(detail, "pipeline_status_with_code_quality"), + stage: None, + web_url: optional_string(detail, "web_url") + .or_else(|| optional_string(detail, "html_url")), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + + let codequality_status = value_string(detail, "codequality_status"); + if !codequality_status.trim().is_empty() { + items.push(ReviewPlatformCiItem { + id: first_non_empty(&[ + format!("{}-codequality", value_string(detail, "head_pipeline_id")), + "codequality".to_string(), + ]), + name: "Code quality".to_string(), + status: codequality_status, + conclusion: None, + detail: None, + stage: None, + web_url: optional_string(detail, "web_url") + .or_else(|| optional_string(detail, "html_url")), + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }); + } + + items +} + +fn parse_remote( + remote_name: &str, + remote_url: &str, + auth_tokens: &ReviewPlatformAuthTokens, +) -> Option { + let parsed = parse_remote_url(remote_url)?; + let host_lower = parsed.host.to_ascii_lowercase(); + let platform = if host_lower.contains("github.com") { + ReviewPlatformKind::Github + } else if host_lower.contains("gitlab") { + ReviewPlatformKind::Gitlab + } else if host_lower.contains("gitcode") { + ReviewPlatformKind::Gitcode + } else { + ReviewPlatformKind::Unknown + }; + + let segments: Vec<&str> = parsed + .path + .trim_matches('/') + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + if segments.len() < 2 { + return None; + } + let owner = segments.first()?.to_string(); + let repository_name = segments.last()?.trim_end_matches(".git").to_string(); + let project_path = segments + .iter() + .map(|segment| segment.trim_end_matches(".git")) + .collect::>() + .join("/"); + + let supported = platform != ReviewPlatformKind::Unknown; + let (auth_state, auth_source) = auth_for_platform_host(platform, &parsed.host, auth_tokens); + let web_url = format!("{}://{}/{}", parsed.scheme, parsed.host, project_path); + + Some(ReviewPlatformRemote { + id: format!( + "{}:{}:{}", + remote_name, + platform.as_str(), + project_path.replace('/', "__") + ), + name: remote_name.to_string(), + url: sanitize_remote_url(remote_url), + platform, + host: parsed.host, + owner, + repository_name, + project_path, + web_url, + supported, + auth_state, + auth_source, + message: if !supported { + Some("This remote is detected, but no provider adapter is available yet.".to_string()) + } else if platform == ReviewPlatformKind::Gitcode + && auth_state == ReviewAuthState::NotConnected + { + Some("Add a GitCode token to load pull requests.".to_string()) + } else { + None + }, + }) +} + +#[derive(Debug)] +struct ParsedRemoteUrl { + scheme: String, + host: String, + path: String, +} + +fn parse_remote_url(remote_url: &str) -> Option { + if let Some(scheme_end) = remote_url.find("://") { + let scheme = &remote_url[..scheme_end]; + let rest = &remote_url[scheme_end + 3..]; + let slash = rest.find('/')?; + let authority = &rest[..slash]; + let host_part = authority.rsplit('@').next().unwrap_or(authority); + let host = host_part.split(':').next().unwrap_or(host_part); + let path = rest[slash + 1..].trim_end_matches(".git").to_string(); + return Some(ParsedRemoteUrl { + scheme: if scheme == "ssh" { "https" } else { scheme }.to_string(), + host: host.to_string(), + path, + }); + } + + if let Some((user_host, path)) = remote_url.split_once(':') { + if user_host.contains('@') && !path.contains('\\') { + let host = user_host.rsplit('@').next()?.to_string(); + return Some(ParsedRemoteUrl { + scheme: "https".to_string(), + host, + path: path.trim_end_matches(".git").to_string(), + }); + } + } + + None +} + +fn sanitize_remote_url(remote_url: &str) -> String { + if let Some(scheme_end) = remote_url.find("://") { + let scheme = &remote_url[..scheme_end]; + let rest = &remote_url[scheme_end + 3..]; + if let Some(slash) = rest.find('/') { + let authority = &rest[..slash]; + if authority.contains('@') { + let host = authority.rsplit('@').next().unwrap_or(authority); + return format!("{}://{}/{}", scheme, host, &rest[slash + 1..]); + } + } + } + remote_url.to_string() +} + +fn github_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = value_i64(value, "number"); + let state = if value_bool(value, "draft") { + ReviewItemState::Draft + } else if !value_string(value, "merged_at").is_empty() { + ReviewItemState::Merged + } else { + match value_string(value, "state").as_str() { + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + } + }; + + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: nested_string(value, &["user", "login"]), + source_branch: nested_string(value, &["head", "ref"]), + target_branch: nested_string(value, &["base", "ref"]), + updated_at: value_string(value, "updated_at"), + web_url: value_string(value, "html_url"), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + changed_files: value_i64(value, "changed_files") as i32, + comments: (value_i64(value, "comments") + value_i64(value, "review_comments")) as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn gitlab_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = value_i64(value, "iid"); + let state = if value_bool(value, "draft") || value_bool(value, "work_in_progress") { + ReviewItemState::Draft + } else { + match value_string(value, "state").as_str() { + "merged" => ReviewItemState::Merged, + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + } + }; + let changed_files = value_string(value, "changes_count") + .parse::() + .unwrap_or(0); + + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: first_non_empty(&[ + nested_string(value, &["author", "username"]), + nested_string(value, &["author", "name"]), + ]), + source_branch: value_string(value, "source_branch"), + target_branch: value_string(value, "target_branch"), + updated_at: value_string(value, "updated_at"), + web_url: value_string(value, "web_url"), + additions: 0, + deletions: 0, + changed_files, + comments: value_i64(value, "user_notes_count") as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn gitcode_pull_request_from_value(value: &Value) -> ReviewPlatformPullRequest { + let number = first_non_zero(&[value_i64(value, "number"), value_i64(value, "id")]); + let state = match value_string(value, "state").as_str() { + "merged" => ReviewItemState::Merged, + "closed" => ReviewItemState::Closed, + _ => ReviewItemState::Open, + }; + ReviewPlatformPullRequest { + id: number.to_string(), + number, + title: value_string(value, "title"), + state, + author: first_non_empty(&[ + nested_string(value, &["user", "login"]), + nested_string(value, &["user", "name"]), + nested_string(value, &["author", "login"]), + ]), + source_branch: first_non_empty(&[ + nested_string(value, &["head", "ref"]), + value_string(value, "head_branch"), + ]), + target_branch: first_non_empty(&[ + nested_string(value, &["base", "ref"]), + value_string(value, "base_branch"), + ]), + updated_at: value_string(value, "updated_at"), + web_url: first_non_empty(&[ + value_string(value, "html_url"), + value_string(value, "web_url"), + ]), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + changed_files: value_i64(value, "changed_files") as i32, + comments: value_i64(value, "comments") as i32, + review_decision: ReviewDecision::Pending, + checks: empty_checks(), + } +} + +fn github_file_from_value(value: &Value) -> ReviewPlatformFile { + ReviewPlatformFile { + path: value_string(value, "filename"), + old_path: value + .get("previous_filename") + .and_then(Value::as_str) + .map(str::to_string), + status: file_status(&value_string(value, "status")), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + patch: optional_string(value, "patch"), + } +} + +fn gitcode_file_from_value(value: &Value) -> ReviewPlatformFile { + ReviewPlatformFile { + path: first_non_empty(&[ + value_string(value, "filename"), + value_string(value, "new_path"), + ]), + old_path: value + .get("previous_filename") + .and_then(Value::as_str) + .map(str::to_string), + status: file_status(&value_string(value, "status")), + additions: value_i64(value, "additions") as i32, + deletions: value_i64(value, "deletions") as i32, + patch: optional_string(value, "patch").or_else(|| optional_string(value, "diff")), + } +} + +fn gitlab_files(value: &Value) -> Vec { + value + .get("changes") + .and_then(Value::as_array) + .unwrap_or(&Vec::new()) + .iter() + .map(|change| { + let diff = value_string(change, "diff"); + let (additions, deletions) = count_diff_lines(&diff); + let status = if value_bool(change, "new_file") { + ReviewFileStatus::Added + } else if value_bool(change, "deleted_file") { + ReviewFileStatus::Deleted + } else if value_bool(change, "renamed_file") { + ReviewFileStatus::Renamed + } else { + ReviewFileStatus::Modified + }; + ReviewPlatformFile { + path: value_string(change, "new_path"), + old_path: change + .get("old_path") + .and_then(Value::as_str) + .map(str::to_string), + status, + additions, + deletions, + patch: Some(diff), + } + }) + .collect() +} + +fn github_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = value_string(value, "sha"); + ReviewPlatformCommit { + short_hash: short_hash(&hash), + hash, + title: first_line(&nested_string(value, &["commit", "message"])), + author: first_non_empty(&[ + nested_string(value, &["author", "login"]), + nested_string(value, &["commit", "author", "name"]), + ]), + committed_at: nested_string(value, &["commit", "author", "date"]), + } +} + +fn gitlab_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = value_string(value, "id"); + ReviewPlatformCommit { + short_hash: first_non_empty(&[value_string(value, "short_id"), short_hash(&hash)]), + hash, + title: first_non_empty(&[ + value_string(value, "title"), + first_line(&value_string(value, "message")), + ]), + author: value_string(value, "author_name"), + committed_at: first_non_empty(&[ + value_string(value, "committed_date"), + value_string(value, "created_at"), + ]), + } +} + +fn gitcode_commit_from_value(value: &Value) -> ReviewPlatformCommit { + let hash = first_non_empty(&[value_string(value, "sha"), value_string(value, "id")]); + ReviewPlatformCommit { + short_hash: short_hash(&hash), + hash, + title: first_non_empty(&[ + nested_string(value, &["commit", "message"]) + .lines() + .next() + .unwrap_or_default() + .to_string(), + value_string(value, "message"), + ]), + author: first_non_empty(&[ + nested_string(value, &["author", "login"]), + nested_string(value, &["commit", "author", "name"]), + ]), + committed_at: first_non_empty(&[ + nested_string(value, &["commit", "author", "date"]), + value_string(value, "created_at"), + ]), + } +} + +fn github_review_decision(reviews: &Value) -> ReviewDecision { + let mut latest_by_author: HashMap = HashMap::new(); + let mut anonymous_states = Vec::new(); + for review in array_items(reviews) { + let state = value_string(review, "state"); + if state == "DISMISSED" || state.trim().is_empty() { + continue; + } + let author = nested_string(review, &["user", "login"]); + if author.trim().is_empty() { + anonymous_states.push(state); + } else { + latest_by_author.insert(author, state); + } + } + + let states = latest_by_author + .values() + .chain(anonymous_states.iter()) + .map(String::as_str) + .collect::>(); + + if states.iter().any(|state| *state == "CHANGES_REQUESTED") { + return ReviewDecision::ChangesRequested; + } + if states.iter().any(|state| *state == "APPROVED") { + return ReviewDecision::Approved; + } + if states.iter().any(|state| *state == "COMMENTED") { + return ReviewDecision::Commented; + } + ReviewDecision::Pending +} + +fn github_threads( + reviews: &Value, + review_comments: &Value, + issue_comments: &Value, +) -> Vec { + let mut threads = Vec::new(); + for review in array_items(reviews) { + let body = github_review_body(review); + threads.push(ReviewPlatformThread { + id: format!("review-{}", value_i64(review, "id")), + provider_thread_id: None, + provider_comment_id: value_i64(review, "id") + .checked_abs() + .map(|id| id.to_string()), + kind: ReviewPlatformThreadKind::Review, + reply_to_provider_comment_id: None, + file_path: None, + line: None, + resolved: false, + author: nested_string(review, &["user", "login"]), + body, + updated_at: first_non_empty(&[ + value_string(review, "submitted_at"), + value_string(review, "updated_at"), + ]), + }); + } + for comment in array_items(review_comments) { + threads.push(github_thread_from_review_comment(comment)); + } + for comment in array_items(issue_comments) { + threads.push(github_thread_from_issue_comment(comment)); + } + threads +} + +fn github_review_body(review: &Value) -> String { + let body = value_string(review, "body"); + if !body.trim().is_empty() { + return body; + } + match value_string(review, "state").as_str() { + "APPROVED" => "Approved this pull request.".to_string(), + "CHANGES_REQUESTED" => "Requested changes.".to_string(), + "COMMENTED" => "Submitted a pull request review.".to_string(), + state if !state.trim().is_empty() => format!("Submitted a {} review.", state), + _ => "Submitted a pull request review.".to_string(), + } +} + +fn github_thread_from_review_comment(comment: &Value) -> ReviewPlatformThread { + let comment_id = first_non_empty(&[ + value_string(comment, "id"), + value_i64(comment, "id").to_string(), + ]); + ReviewPlatformThread { + id: format!("comment-{}", comment_id), + provider_thread_id: None, + provider_comment_id: Some(comment_id), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: value_i64(comment, "in_reply_to_id") + .checked_abs() + .map(|id| id.to_string()) + .or_else(|| { + comment + .get("in_reply_to_id") + .and_then(Value::as_str) + .map(str::to_string) + }), + file_path: comment + .get("path") + .and_then(Value::as_str) + .map(str::to_string), + line: comment + .get("line") + .and_then(Value::as_i64) + .or_else(|| comment.get("original_line").and_then(Value::as_i64)), + resolved: false, + author: nested_string(comment, &["user", "login"]), + body: value_string(comment, "body"), + updated_at: value_string(comment, "updated_at"), + } +} + +fn github_thread_from_issue_comment(comment: &Value) -> ReviewPlatformThread { + let comment_id = first_non_empty(&[ + value_string(comment, "id"), + value_i64(comment, "id").to_string(), + ]); + ReviewPlatformThread { + id: format!("issue-comment-{}", comment_id), + provider_thread_id: None, + provider_comment_id: Some(comment_id), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: None, + file_path: None, + line: None, + resolved: false, + author: nested_string(comment, &["user", "login"]), + body: value_string(comment, "body"), + updated_at: value_string(comment, "updated_at"), + } +} + +fn gitlab_threads(discussions: &Value, notes: &Value) -> Vec { + let mut threads = Vec::new(); + let mut seen_comment_ids = HashSet::new(); + for discussion in array_items(discussions) { + let discussion_id = value_string(discussion, "id"); + let resolved = value_bool(discussion, "resolved"); + let discussion_notes = discussion + .get("notes") + .and_then(Value::as_array) + .map(|notes| notes.as_slice()) + .unwrap_or(&[]); + let mut root_comment_id: Option = None; + for (index, note) in discussion_notes.iter().enumerate() { + let kind = if index == 0 { + ReviewPlatformThreadKind::Review + } else { + ReviewPlatformThreadKind::Comment + }; + let reply_to = if index == 0 { + None + } else { + root_comment_id.clone() + }; + let thread = gitlab_thread_from_note( + note, + Some(discussion_id.clone()), + resolved, + kind, + reply_to, + ); + if root_comment_id.is_none() { + root_comment_id = thread.provider_comment_id.clone(); + } + if let Some(comment_id) = thread.provider_comment_id.clone() { + seen_comment_ids.insert(comment_id); + } + threads.push(thread); + } + } + for note in array_items(notes) { + let thread = + gitlab_thread_from_note(note, None, false, ReviewPlatformThreadKind::Comment, None); + if let Some(comment_id) = thread.provider_comment_id.as_ref() { + if seen_comment_ids.contains(comment_id) { + continue; + } + seen_comment_ids.insert(comment_id.clone()); + } + threads.push(thread); + } + threads +} + +fn gitlab_thread_from_note( + note: &Value, + discussion_id: Option, + discussion_resolved: bool, + kind: ReviewPlatformThreadKind, + reply_to_provider_comment_id: Option, +) -> ReviewPlatformThread { + let note_id = value_string(note, "id"); + let id = match discussion_id.as_deref() { + Some(discussion_id) if !discussion_id.trim().is_empty() => { + format!("discussion-{}:note-{}", discussion_id, note_id) + } + _ => format!("note-{}", note_id), + }; + + ReviewPlatformThread { + id, + provider_thread_id: discussion_id, + provider_comment_id: Some(note_id), + kind, + reply_to_provider_comment_id, + file_path: nested_optional_string(note, &["position", "new_path"]) + .or_else(|| nested_optional_string(note, &["position", "old_path"])), + line: note + .pointer("/position/new_line") + .and_then(Value::as_i64) + .or_else(|| note.pointer("/position/old_line").and_then(Value::as_i64)), + resolved: discussion_resolved || value_bool(note, "resolved"), + author: first_non_empty(&[ + nested_string(note, &["author", "username"]), + nested_string(note, &["author", "name"]), + ]), + body: value_string(note, "body"), + updated_at: first_non_empty(&[ + value_string(note, "updated_at"), + value_string(note, "created_at"), + ]), + } +} + +fn parse_provider_comment_id(thread_id: &str) -> Option<&str> { + let trimmed = thread_id.trim(); + trimmed + .strip_prefix("comment-") + .or_else(|| trimmed.strip_prefix("note-")) + .or_else(|| trimmed.split_once(":note-").map(|(_, note_id)| note_id)) + .or_else(|| { + if trimmed.chars().all(|ch| ch.is_ascii_digit()) { + Some(trimmed) + } else { + None + } + }) + .filter(|value| !value.trim().is_empty()) +} + +fn parse_provider_thread_id(thread_id: &str) -> Option<&str> { + let trimmed = thread_id.trim(); + trimmed + .strip_prefix("discussion-") + .map(|value| { + value + .split_once(":note-") + .map(|(id, _)| id) + .unwrap_or(value) + }) + .or_else(|| { + if trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') + { + Some(trimmed) + } else { + None + } + }) + .filter(|value| !value.trim().is_empty()) +} + +fn gitcode_threads(value: &Value) -> Vec { + array_items(value) + .iter() + .map(|comment| ReviewPlatformThread { + id: value_string(comment, "id"), + provider_thread_id: None, + provider_comment_id: Some(value_string(comment, "id")), + kind: ReviewPlatformThreadKind::Comment, + reply_to_provider_comment_id: comment + .get("in_reply_to_id") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + comment + .get("in_reply_to_id") + .and_then(Value::as_i64) + .map(|id| id.to_string()) + }), + file_path: comment + .get("path") + .and_then(Value::as_str) + .map(str::to_string), + line: comment.get("line").and_then(Value::as_i64), + resolved: false, + author: first_non_empty(&[ + nested_string(comment, &["user", "login"]), + nested_string(comment, &["user", "name"]), + ]), + body: value_string(comment, "body"), + updated_at: first_non_empty(&[ + value_string(comment, "updated_at"), + value_string(comment, "created_at"), + ]), + }) + .collect() +} + +fn empty_checks() -> ReviewChecks { + ReviewChecks { + total: 0, + passed: 0, + failed: 0, + pending: 0, + } +} + +fn file_status(status: &str) -> ReviewFileStatus { + match status { + "added" | "new" => ReviewFileStatus::Added, + "removed" | "deleted" => ReviewFileStatus::Deleted, + "renamed" => ReviewFileStatus::Renamed, + _ => ReviewFileStatus::Modified, + } +} + +fn count_diff_lines(diff: &str) -> (i32, i32) { + let mut additions = 0; + let mut deletions = 0; + for line in diff.lines() { + if line.starts_with("+++") || line.starts_with("---") { + continue; + } + if line.starts_with('+') { + additions += 1; + } else if line.starts_with('-') { + deletions += 1; + } + } + (additions, deletions) +} + +fn apply_files_stats(pull_request: &mut ReviewPlatformPullRequest, files: &[ReviewPlatformFile]) { + pull_request.changed_files = files.len() as i32; + let (additions, deletions) = files.iter().fold((0, 0), |acc, file| { + (acc.0 + file.additions, acc.1 + file.deletions) + }); + pull_request.additions = additions; + pull_request.deletions = deletions; +} + +fn array_items<'a>(value: &'a Value) -> &'a [Value] { + value + .as_array() + .map(|items| items.as_slice()) + .unwrap_or(&[]) +} + +fn value_string(value: &Value, key: &str) -> String { + match value.get(key) { + Some(Value::String(text)) => text.clone(), + Some(Value::Number(number)) => number.to_string(), + Some(Value::Bool(flag)) => flag.to_string(), + _ => String::new(), + } +} + +fn optional_string(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .filter(|value| !value.trim().is_empty()) +} + +fn nested_string(value: &Value, path: &[&str]) -> String { + nested_optional_string(value, path).unwrap_or_default() +} + +fn nested_optional_string(value: &Value, path: &[&str]) -> Option { + let mut current = value; + for key in path { + current = current.get(*key)?; + } + match current { + Value::String(text) => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + } +} + +fn value_i64(value: &Value, key: &str) -> i64 { + value + .get(key) + .and_then(|value| { + value + .as_i64() + .or_else(|| value.as_str()?.parse::().ok()) + }) + .unwrap_or(0) +} + +fn value_bool(value: &Value, key: &str) -> bool { + value + .get(key) + .and_then(|value| { + value + .as_bool() + .or_else(|| value.as_str().map(|text| text.eq_ignore_ascii_case("true"))) + }) + .unwrap_or(false) +} + +fn first_non_empty(values: &[String]) -> String { + values + .iter() + .find(|value| !value.trim().is_empty()) + .cloned() + .unwrap_or_default() +} + +fn first_non_zero(values: &[i64]) -> i64 { + values + .iter() + .copied() + .find(|value| *value != 0) + .unwrap_or(0) +} + +fn first_line(value: &str) -> String { + value.lines().next().unwrap_or_default().to_string() +} + +fn short_hash(hash: &str) -> String { + hash.chars().take(7).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn github_review_decision_uses_latest_review_per_author() { + let reviews = json!([ + { + "id": 1, + "state": "CHANGES_REQUESTED", + "user": { "login": "alice" } + }, + { + "id": 2, + "state": "APPROVED", + "user": { "login": "alice" } + } + ]); + + assert_eq!(github_review_decision(&reviews), ReviewDecision::Approved); + } + + #[test] + fn github_review_decision_keeps_active_change_request_from_any_reviewer() { + let reviews = json!([ + { + "id": 1, + "state": "APPROVED", + "user": { "login": "alice" } + }, + { + "id": 2, + "state": "CHANGES_REQUESTED", + "user": { "login": "bob" } + } + ]); + + assert_eq!( + github_review_decision(&reviews), + ReviewDecision::ChangesRequested + ); + } + + #[test] + fn github_threads_include_issue_comments_and_review_comments() { + let reviews = json!([]); + let review_comments = json!([ + { + "id": 10, + "path": "src/lib.rs", + "line": 8, + "user": { "login": "alice" }, + "body": "Inline comment", + "updated_at": "2026-05-18T01:00:00Z" + } + ]); + let issue_comments = json!([ + { + "id": 20, + "user": { "login": "bob" }, + "body": "Conversation comment", + "updated_at": "2026-05-18T02:00:00Z" + } + ]); + + let threads = github_threads(&reviews, &review_comments, &issue_comments); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].id, "comment-10"); + assert_eq!(threads[0].file_path.as_deref(), Some("src/lib.rs")); + assert_eq!(threads[1].id, "issue-comment-20"); + assert_eq!(threads[1].file_path, None); + assert_eq!(threads[1].body, "Conversation comment"); + } + + #[test] + fn github_threads_keep_empty_body_reviews_visible() { + let reviews = json!([ + { + "id": 30, + "state": "APPROVED", + "user": { "login": "alice" }, + "body": "", + "submitted_at": "2026-05-18T03:00:00Z" + } + ]); + + let threads = github_threads(&reviews, &json!([]), &json!([])); + + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].id, "review-30"); + assert_eq!(threads[0].body, "Approved this pull request."); + } + + #[test] + fn github_review_comment_replies_track_parent_comment() { + let threads = github_threads( + &json!([]), + &json!([ + { + "id": 40, + "in_reply_to_id": 10, + "user": { "login": "alice" }, + "body": "Reply", + "updated_at": "2026-05-18T04:30:00Z" + } + ]), + &json!([]), + ); + + assert_eq!(threads.len(), 1); + assert_eq!(threads[0].kind, ReviewPlatformThreadKind::Comment); + assert_eq!( + threads[0].reply_to_provider_comment_id.as_deref(), + Some("10") + ); + } + + #[test] + fn gitlab_threads_include_top_level_notes_without_duplication() { + let discussions = json!([ + { + "id": "discussion-1", + "resolved": false, + "notes": [ + { + "id": "100", + "author": { "username": "alice" }, + "body": "Inline note", + "updated_at": "2026-05-18T04:00:00Z", + "position": { "new_path": "src/lib.rs", "new_line": 12 } + } + ] + } + ]); + let notes = json!([ + { + "id": "100", + "author": { "username": "alice" }, + "body": "Inline note", + "updated_at": "2026-05-18T04:00:00Z", + "position": { "new_path": "src/lib.rs", "new_line": 12 } + }, + { + "id": "200", + "author": { "username": "bob" }, + "body": "Top-level note", + "updated_at": "2026-05-18T05:00:00Z" + } + ]); + + let threads = gitlab_threads(&discussions, ¬es); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].id, "discussion-discussion-1:note-100"); + assert_eq!(threads[1].id, "note-200"); + assert_eq!(threads[1].file_path, None); + assert_eq!(threads[1].body, "Top-level note"); + } + + #[test] + fn gitlab_discussion_threads_mark_root_as_review_and_replies_as_comments() { + let discussions = json!([ + { + "id": "discussion-2", + "resolved": false, + "notes": [ + { + "id": "300", + "author": { "username": "alice" }, + "body": "Root note", + "updated_at": "2026-05-18T06:00:00Z" + }, + { + "id": "301", + "author": { "username": "bob" }, + "body": "Reply note", + "updated_at": "2026-05-18T06:05:00Z" + } + ] + } + ]); + + let threads = gitlab_threads(&discussions, &json!([])); + + assert_eq!(threads.len(), 2); + assert_eq!(threads[0].kind, ReviewPlatformThreadKind::Review); + assert_eq!(threads[0].reply_to_provider_comment_id, None); + assert_eq!(threads[1].kind, ReviewPlatformThreadKind::Comment); + assert_eq!( + threads[1].reply_to_provider_comment_id.as_deref(), + Some("300") + ); + } + + #[test] + fn summarize_ci_items_counts_provider_outcomes() { + let items = vec![ + ReviewPlatformCiItem { + id: "build".to_string(), + name: "Build".to_string(), + status: "completed".to_string(), + conclusion: Some("success".to_string()), + detail: None, + stage: Some("build".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ReviewPlatformCiItem { + id: "test".to_string(), + name: "Test".to_string(), + status: "failed".to_string(), + conclusion: None, + detail: None, + stage: Some("test".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ReviewPlatformCiItem { + id: "deploy".to_string(), + name: "Deploy".to_string(), + status: "running".to_string(), + conclusion: None, + detail: None, + stage: Some("deploy".to_string()), + web_url: None, + log: None, + log_truncated: false, + started_at: None, + finished_at: None, + }, + ]; + + let checks = summarize_ci_items(&items); + + assert_eq!(checks.total, 3); + assert_eq!(checks.passed, 1); + assert_eq!(checks.failed, 1); + assert_eq!(checks.pending, 1); + } + + #[test] + fn ci_log_value_extracts_error_excerpt_only() { + let text = [ + "running setup", + "downloading dependencies", + "cargo test failed with exit code 101", + "thread 'main' panicked at src/lib.rs:4", + "uploading artifacts", + ] + .join("\n"); + + let (log, truncated) = ci_log_value(text); + + let log = log.expect("log should be present"); + assert!(!truncated); + assert!(log.contains("cargo test failed")); + assert!(log.contains("panicked at src/lib.rs")); + } + + #[test] + fn ci_log_value_reports_when_no_error_lines_match() { + let (log, truncated) = ci_log_value("all checks passed".to_string()); + + assert!(!truncated); + assert!(log.is_none()); + } +} diff --git a/src/crates/core/src/service/search/flashgrep/mod.rs b/src/crates/core/src/service/search/flashgrep/mod.rs index a52446ae71..9b2ecd0406 100644 --- a/src/crates/core/src/service/search/flashgrep/mod.rs +++ b/src/crates/core/src/service/search/flashgrep/mod.rs @@ -1,3 +1,5 @@ +#![cfg_attr(not(feature = "ssh-remote"), allow(unused_imports, dead_code))] + mod client; pub mod error; mod protocol; diff --git a/src/crates/core/src/service/search/mod.rs b/src/crates/core/src/service/search/mod.rs index faff550dfb..118554878f 100644 --- a/src/crates/core/src/service/search/mod.rs +++ b/src/crates/core/src/service/search/mod.rs @@ -1,9 +1,15 @@ pub(crate) mod flashgrep; +#[cfg(feature = "ssh-remote")] mod remote; +#[cfg(not(feature = "ssh-remote"))] +mod remote_disabled; pub mod service; pub mod types; +#[cfg(feature = "ssh-remote")] pub use remote::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; +#[cfg(not(feature = "ssh-remote"))] +pub use remote_disabled::{remote_workspace_search_service_for_path, RemoteWorkspaceSearchService}; pub use service::{ get_global_workspace_search_service, resolve_workspace_search_daemon_program_path, set_global_workspace_search_service, workspace_search_daemon_available, diff --git a/src/crates/core/src/service/search/remote_disabled.rs b/src/crates/core/src/service/search/remote_disabled.rs new file mode 100644 index 0000000000..221469c5c4 --- /dev/null +++ b/src/crates/core/src/service/search/remote_disabled.rs @@ -0,0 +1,53 @@ +//! Disabled remote workspace search surface for lightweight feature builds. + +use crate::service::remote_ssh::workspace_state::RemoteWorkspaceEntry; +use crate::service::search::{ + ContentSearchRequest, ContentSearchResult, GlobSearchRequest, GlobSearchResult, + IndexTaskHandle, WorkspaceIndexStatus, +}; + +fn unsupported() -> String { + "Remote SSH search is disabled; enable the `ssh-remote` feature".to_string() +} + +#[derive(Clone)] +pub struct RemoteWorkspaceSearchService; + +impl RemoteWorkspaceSearchService { + pub async fn get_index_status(&self, _root_path: &str) -> Result { + Err(unsupported()) + } + + pub async fn build_index(&self, _root_path: &str) -> Result { + Err(unsupported()) + } + + pub async fn rebuild_index(&self, _root_path: &str) -> Result { + Err(unsupported()) + } + + pub async fn search_content( + &self, + _request: ContentSearchRequest, + ) -> Result { + Err(unsupported()) + } + + pub async fn glob(&self, _request: GlobSearchRequest) -> Result { + Err(unsupported()) + } + + pub async fn resolve_remote_workspace_entry( + &self, + _repo_root: &str, + ) -> Result { + Err(unsupported()) + } +} + +pub async fn remote_workspace_search_service_for_path( + _root_path: &str, + _preferred_connection_id: Option, +) -> Result { + Err(unsupported()) +} diff --git a/src/crates/core/src/service/token_usage/subscriber.rs b/src/crates/core/src/service/token_usage/subscriber.rs index db1f685632..c263adbc11 100644 --- a/src/crates/core/src/service/token_usage/subscriber.rs +++ b/src/crates/core/src/service/token_usage/subscriber.rs @@ -3,7 +3,7 @@ use crate::agentic::events::{AgenticEvent, EventSubscriber}; use crate::service::token_usage::TokenUsageService; use crate::util::errors::BitFunResult; -use log::{debug, error}; +use log::{debug, warn}; use std::sync::Arc; /// Token usage event subscriber @@ -65,7 +65,7 @@ impl EventSubscriber for TokenUsageSubscriber { ) .await { - error!("Failed to record token usage: {}", e); + warn!("Failed to record token usage: {}", e); } } diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index c1bfa80ecc..bf54f48080 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -1,5 +1,6 @@ //! Workspace manager. +#[cfg(feature = "service-integrations")] use crate::service::git::GitService; use crate::service::remote_ssh::workspace_state::{ canonicalize_local_workspace_root, local_workspace_roots_equal, @@ -74,6 +75,15 @@ pub struct WorkspaceWorktreeInfo { pub is_main: bool, } +/// User-managed related directory reference for the current workspace context. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RelatedPath { + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, +} + #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] struct WorkspaceIdentityFrontmatter { @@ -200,6 +210,8 @@ pub struct WorkspaceInfo { pub identity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, + #[serde(rename = "relatedPaths", default)] + pub related_paths: Vec, pub metadata: HashMap, } @@ -344,6 +356,7 @@ impl WorkspaceInfo { statistics: None, identity: None, worktree: None, + related_paths: Vec::new(), metadata: HashMap::new(), }; @@ -421,26 +434,35 @@ impl WorkspaceInfo { } async fn resolve_worktree_info(workspace_root: &Path) -> Option { - let normalized_workspace_path = workspace_root.to_string_lossy().replace('\\', "/"); - let worktrees = match GitService::list_worktrees(workspace_root).await { - Ok(worktrees) => worktrees, - Err(_) => return None, - }; + #[cfg(not(feature = "service-integrations"))] + { + let _ = workspace_root; + return None; + } - let main_repo_path = worktrees - .iter() - .find(|worktree| worktree.is_main) - .map(|worktree| worktree.path.clone())?; + #[cfg(feature = "service-integrations")] + { + let normalized_workspace_path = workspace_root.to_string_lossy().replace('\\', "/"); + let worktrees = match GitService::list_worktrees(workspace_root).await { + Ok(worktrees) => worktrees, + Err(_) => return None, + }; - worktrees - .into_iter() - .find(|worktree| worktree.path == normalized_workspace_path) - .map(|worktree| WorkspaceWorktreeInfo { - path: worktree.path, - branch: worktree.branch, - main_repo_path: main_repo_path.clone(), - is_main: worktree.is_main, - }) + let main_repo_path = worktrees + .iter() + .find(|worktree| worktree.is_main) + .map(|worktree| worktree.path.clone())?; + + worktrees + .into_iter() + .find(|worktree| worktree.path == normalized_workspace_path) + .map(|worktree| WorkspaceWorktreeInfo { + path: worktree.path, + branch: worktree.branch, + main_repo_path: main_repo_path.clone(), + is_main: worktree.is_main, + }) + } } /// Detects the workspace type. @@ -871,7 +893,8 @@ impl WorkspaceManager { path: PathBuf, options: WorkspaceOpenOptions, ) -> BitFunResult { - self.upsert_workspace_with_options(path, options, true).await + self.upsert_workspace_with_options(path, options, true) + .await } /// Registers or refreshes workspace activity without changing opened UI state. diff --git a/src/crates/core/src/service/workspace/mod.rs b/src/crates/core/src/service/workspace/mod.rs index aabedea550..2372774f92 100644 --- a/src/crates/core/src/service/workspace/mod.rs +++ b/src/crates/core/src/service/workspace/mod.rs @@ -12,9 +12,9 @@ pub mod service; pub use factory::WorkspaceFactory; pub use identity_watch::WorkspaceIdentityWatchService; pub use manager::{ - GitInfo, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, - WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatistics, - WorkspaceStatus, WorkspaceSummary, WorkspaceType, + GitInfo, RelatedPath, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, + WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, + WorkspaceStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; pub use service::{ diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index 21ff217e33..a3d4e80277 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -3,7 +3,7 @@ //! Provides comprehensive workspace management functionality. use super::manager::{ - ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, + RelatedPath, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; @@ -13,7 +13,8 @@ use crate::service::bootstrap::{ ensure_workspace_gitignore_ignores_bitfun, initialize_workspace_persona_files, }; use crate::service::remote_ssh::workspace_state::{ - local_workspace_roots_equal, normalize_remote_workspace_path, remote_workspace_stable_id, + canonicalize_local_workspace_root, get_remote_workspace_manager, local_workspace_roots_equal, + normalize_remote_workspace_path, remote_workspace_stable_id, }; use crate::service::workspace_runtime::{ try_get_workspace_runtime_service_arc, WorkspaceRuntimeService, @@ -322,7 +323,10 @@ impl WorkspaceService { if result.is_ok() { if let Err(e) = self.save_workspace_data().await { - warn!("Failed to save workspace data after tracking activity: {}", e); + warn!( + "Failed to save workspace data after tracking activity: {}", + e + ); } } @@ -879,31 +883,204 @@ impl WorkspaceService { &self, workspace_id: &str, updates: WorkspaceInfoUpdates, - ) -> BitFunResult<()> { - let mut manager = self.manager.write().await; + ) -> BitFunResult { + let WorkspaceInfoUpdates { + name, + description, + tags, + related_paths, + } = updates; + + let existing_workspace = { + let manager = self.manager.read().await; + manager + .get_workspaces() + .get(workspace_id) + .cloned() + .ok_or_else(|| { + BitFunError::service(format!("Workspace not found: {}", workspace_id)) + })? + }; + + let normalized_related_paths = match related_paths { + Some(related_paths) => Some( + self.normalize_related_paths_for_workspace(&existing_workspace, related_paths) + .await?, + ), + None => None, + }; + + let updated_workspace = { + let mut manager = self.manager.write().await; + let workspace = manager + .get_workspaces_mut() + .get_mut(workspace_id) + .ok_or_else(|| { + BitFunError::service(format!("Workspace not found: {}", workspace_id)) + })?; - if let Some(workspace) = manager.get_workspaces_mut().get_mut(workspace_id) { - if let Some(name) = updates.name { + if let Some(name) = name { workspace.name = name; } - if let Some(description) = updates.description { + if let Some(description) = description { workspace.description = Some(description); } - if let Some(tags) = updates.tags { + if let Some(tags) = tags { workspace.tags = tags; } + if let Some(related_paths) = normalized_related_paths { + workspace.related_paths = related_paths; + } + workspace.last_accessed = chrono::Utc::now(); + workspace.clone() + }; - Ok(()) - } else { - Err(BitFunError::service(format!( - "Workspace not found: {}", - workspace_id - ))) + self.save_workspace_data().await?; + + Ok(updated_workspace) + } + + async fn normalize_related_paths_for_workspace( + &self, + workspace: &WorkspaceInfo, + related_paths: Vec, + ) -> BitFunResult> { + let mut normalized = Vec::with_capacity(related_paths.len()); + let mut seen_paths = HashSet::new(); + + match workspace.workspace_kind { + WorkspaceKind::Remote => { + let connection_id = workspace + .remote_ssh_connection_id() + .ok_or_else(|| { + BitFunError::service(format!( + "Remote workspace is missing connectionId metadata: {}", + workspace.id + )) + })? + .to_string(); + let remote_manager = get_remote_workspace_manager().ok_or_else(|| { + BitFunError::service( + "Remote workspace manager is unavailable for related path validation" + .to_string(), + ) + })?; + let file_service = remote_manager.get_file_service().await.ok_or_else(|| { + BitFunError::service( + "Remote file service is unavailable for related path validation" + .to_string(), + ) + })?; + + for related_path in related_paths { + let description = + Self::normalize_related_path_description(related_path.description); + let path = normalize_remote_workspace_path(related_path.path.trim()); + if path.is_empty() { + return Err(BitFunError::service( + "Related directory path cannot be empty".to_string(), + )); + } + if !seen_paths.insert(path.clone()) { + continue; + } + + if !file_service + .exists(&connection_id, &path) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to validate remote related directory '{}': {}", + path, error + )) + })? + { + return Err(BitFunError::service(format!( + "Remote related directory does not exist: {}", + path + ))); + } + + if !file_service + .is_dir(&connection_id, &path) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to inspect remote related directory '{}': {}", + path, error + )) + })? + { + return Err(BitFunError::service(format!( + "Remote related path is not a directory: {}", + path + ))); + } + + normalized.push(RelatedPath { path, description }); + } + } + _ => { + for related_path in related_paths { + let description = + Self::normalize_related_path_description(related_path.description); + let raw_path = related_path.path.trim(); + if raw_path.is_empty() { + return Err(BitFunError::service( + "Related directory path cannot be empty".to_string(), + )); + } + + let path_buf = PathBuf::from(raw_path); + let (canonical_path, normalized_key) = + canonicalize_local_workspace_root(&path_buf) + .map_err(BitFunError::service)?; + + let metadata = tokio::fs::metadata(&canonical_path) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to inspect related directory '{}': {}", + canonical_path.display(), + error + )) + })?; + + if !metadata.is_dir() { + return Err(BitFunError::service(format!( + "Related path is not a directory: {}", + canonical_path.display() + ))); + } + + if !seen_paths.insert(normalized_key) { + continue; + } + + normalized.push(RelatedPath { + path: canonical_path.to_string_lossy().to_string(), + description, + }); + } + } } + + Ok(normalized) + } + + fn normalize_related_path_description(description: Option) -> Option { + description.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) } /// Imports workspaces in batch. @@ -1772,6 +1949,7 @@ pub struct WorkspaceInfoUpdates { pub name: Option, pub description: Option, pub tags: Option>, + pub related_paths: Option>, } /// Batch remove result. @@ -1859,7 +2037,7 @@ pub fn get_global_workspace_service() -> Option> { GLOBAL_WORKSPACE_SERVICE.get().cloned() } -#[cfg(test)] +#[cfg(all(test, feature = "product-full"))] mod tests { use super::*; use crate::agentic::persistence::PersistenceManager; @@ -2128,4 +2306,26 @@ mod tests { assert_eq!(tracked.root_path, remote_workspace_root); assert!(service.get_opened_workspaces().await.is_empty()); } + + #[test] + fn normalize_related_path_description_treats_blank_as_none() { + assert_eq!( + WorkspaceService::normalize_related_path_description(None), + None + ); + assert_eq!( + WorkspaceService::normalize_related_path_description(Some("".to_string())), + None + ); + assert_eq!( + WorkspaceService::normalize_related_path_description(Some(" ".to_string())), + None + ); + assert_eq!( + WorkspaceService::normalize_related_path_description(Some( + " Legacy TypeScript implementation ".to_string() + )), + Some("Legacy TypeScript implementation".to_string()) + ); + } } diff --git a/src/crates/core/src/service/workspace_runtime/service.rs b/src/crates/core/src/service/workspace_runtime/service.rs index 8f75508b61..ca2f8b68e7 100644 --- a/src/crates/core/src/service/workspace_runtime/service.rs +++ b/src/crates/core/src/service/workspace_runtime/service.rs @@ -2,6 +2,7 @@ use super::types::{ RuntimeMigrationRecord, WorkspaceRuntimeContext, WorkspaceRuntimeEnsureResult, WorkspaceRuntimeTarget, WORKSPACE_RUNTIME_LAYOUT_VERSION, }; +#[cfg(feature = "product-full")] use crate::agentic::WorkspaceBinding; use crate::infrastructure::{get_path_manager_arc, PathManager}; use crate::service::remote_ssh::workspace_state::{ @@ -132,6 +133,7 @@ impl WorkspaceRuntimeService { .await } + #[cfg(feature = "product-full")] pub async fn ensure_runtime_for_workspace_binding( &self, workspace: &WorkspaceBinding, diff --git a/src/crates/core/src/service_agent_runtime.rs b/src/crates/core/src/service_agent_runtime.rs new file mode 100644 index 0000000000..3ac35d17a1 --- /dev/null +++ b/src/crates/core/src/service_agent_runtime.rs @@ -0,0 +1,70 @@ +//! Core-owned bindings for service and agent runtime ports. +//! +//! Owner crates keep portable contracts and orchestration policy. This module +//! centralizes the concrete core adapters that still own scheduler execution, +//! session restore, terminal pre-warm, remote image conversion, and runtime-port +//! implementations until a reviewed port/provider migration proves equivalence. + +use bitfun_runtime_ports::AgentSubmissionPort; +use bitfun_services_integrations::remote_connect::{RemoteImageContext, RemoteImageContextAdapter}; + +use crate::agentic::coordination::ConversationCoordinator; +use crate::agentic::image_analysis::ImageContextData; +use crate::service::remote_connect::remote_server::{ + CoreRemoteDialogRuntimeHost, RemoteExecutionDispatcher, +}; + +impl RemoteImageContextAdapter for ImageContextData { + fn from_remote_image_context(context: RemoteImageContext) -> Self { + Self { + id: context.id, + image_path: context.image_path, + data_url: context.data_url, + mime_type: context.mime_type, + metadata: context.metadata, + } + } +} + +pub(crate) struct CoreServiceAgentRuntime; + +impl CoreServiceAgentRuntime { + pub(crate) fn remote_dialog_host( + dispatcher: &RemoteExecutionDispatcher, + ) -> Result, String> { + CoreRemoteDialogRuntimeHost::new(dispatcher) + } + + pub(crate) fn remote_image_context(context: RemoteImageContext) -> ImageContextData { + ImageContextData::from_remote_image_context(context) + } + + pub(crate) fn agent_submission_port( + coordinator: &ConversationCoordinator, + ) -> &(dyn AgentSubmissionPort + '_) { + coordinator + } +} + +#[cfg(test)] +mod tests { + use bitfun_runtime_ports::{ + AgentTurnCancellationPort, RemoteControlStatePort, SessionTranscriptReader, + }; + + use super::*; + + #[test] + fn core_service_agent_runtime_owner_keeps_coordinator_port_contracts() { + fn assert_runtime_ports() + where + T: AgentSubmissionPort + + AgentTurnCancellationPort + + RemoteControlStatePort + + SessionTranscriptReader, + { + } + + assert_runtime_ports::(); + } +} diff --git a/src/crates/core/src/util/errors.rs b/src/crates/core/src/util/errors.rs index ae11a10b25..5b5474cc55 100644 --- a/src/crates/core/src/util/errors.rs +++ b/src/crates/core/src/util/errors.rs @@ -179,6 +179,7 @@ impl From for BitFunError { } } +#[cfg(feature = "service-integrations")] impl From for BitFunError { fn from(error: bitfun_services_integrations::mcp::MCPRuntimeError) -> Self { use bitfun_services_integrations::mcp::MCPRuntimeErrorKind; diff --git a/src/crates/events/src/agentic.rs b/src/crates/events/src/agentic.rs index 9ca1edd30e..ddacb6f565 100644 --- a/src/crates/events/src/agentic.rs +++ b/src/crates/events/src/agentic.rs @@ -205,6 +205,20 @@ pub enum AgenticEvent { error: String, }, + /// Emitted when `/goal` verification begins after a dialog turn completes. + GoalVerificationStarted { + session_id: String, + source_turn_id: String, + }, + + /// Emitted when `/goal` verification finishes. + GoalVerificationFinished { + session_id: String, + source_turn_id: String, + /// One of: `achieved`, `continuing`, `failed`, `limit_reached`. + outcome: String, + }, + ModelRoundStarted { session_id: String, turn_id: String, @@ -666,6 +680,8 @@ impl AgenticEvent { | Self::ContextCompressionStarted { session_id, .. } | Self::ContextCompressionCompleted { session_id, .. } | Self::ContextCompressionFailed { session_id, .. } + | Self::GoalVerificationStarted { session_id, .. } + | Self::GoalVerificationFinished { session_id, .. } | Self::DialogTurnCancelled { session_id, .. } | Self::DialogTurnFailed { session_id, .. } | Self::ModelRoundStarted { session_id, .. } @@ -703,6 +719,8 @@ impl AgenticEvent { | Self::TokenUsageUpdated { .. } | Self::DialogTurnCompleted { .. } | Self::ContextCompressionStarted { .. } + | Self::GoalVerificationStarted { .. } + | Self::GoalVerificationFinished { .. } | Self::UserSteeringInjected { .. } | Self::ContextCompressionCompleted { .. } => AgenticEventPriority::Normal, diff --git a/src/crates/product-domains/AGENTS-CN.md b/src/crates/product-domains/AGENTS-CN.md index 75d24987ad..b59369212e 100644 --- a/src/crates/product-domains/AGENTS-CN.md +++ b/src/crates/product-domains/AGENTS-CN.md @@ -37,14 +37,15 @@ built-in bundle/hash/marker seed plan 与 marker wire helper、built-in source/placeholder payload contract、port trait,以及 storage-backed runtime state facade。 -- `function-agents` 拥有纯 DTO、prompt assembly、commit prompt preparation、 - AI response parsing policy、diff truncation policy、JSON string 到领域 DTO 的 - 解析 helper、本地文件形态分析、Git/AI port trait,以及 port-backed runtime - facade orchestration。 +- `function-agents` 拥有纯 DTO、prompt template 与 assembly、commit prompt + preparation、AI response JSON extraction 与 domain error mapping policy、 + diff truncation policy、JSON string 到领域 DTO 的解析 helper、本地文件形态分析、 + Git/AI port trait,以及 port-backed runtime facade orchestration。 - Core 仍拥有 MiniApp filesystem IO、worker process、host dispatch、built-in asset include/seeding、marker IO、recompile orchestration、source-hash lookup、 - `PathManager` 集成、function-agent Git/AI service adapter、prompt template、 - JSON extraction 和 error mapping。 + `PathManager` 集成、function-agent Git/AI service adapter、AI client 调用、 + provider acquisition 和 AI transport error mapping;core 侧 product-domain + runtime 绑定集中在 `src/crates/core/src/product_domain_runtime.rs`。 ## 验证 diff --git a/src/crates/product-domains/AGENTS.md b/src/crates/product-domains/AGENTS.md index 242ae517dd..d364716d21 100644 --- a/src/crates/product-domains/AGENTS.md +++ b/src/crates/product-domains/AGENTS.md @@ -30,6 +30,8 @@ moves here gradually. - Feature-gated additions must remain narrow. `miniapp` may use MiniApp-only dependencies, `function-agents` may use function-agent-only dependencies, and `product-full` should only aggregate existing product-domain feature groups. + Boundary checks enforce the default-light profile and current `product-full` + feature-group list. ## Current owners @@ -50,7 +52,8 @@ moves here gradually. execution, built-in asset includes/seeding, marker IO, recompile orchestration, source-hash input lookup, `PathManager` integration, function-agent Git/AI service adapters, AI client calls, provider acquisition, and AI transport - error mapping. + error mapping. Core product-domain runtime bindings are centralized in + `src/crates/core/src/product_domain_runtime.rs`. ## Verification diff --git a/src/crates/product-domains/src/function_agents/ports.rs b/src/crates/product-domains/src/function_agents/ports.rs index a62e4b0f65..39f24a5c5b 100644 --- a/src/crates/product-domains/src/function_agents/ports.rs +++ b/src/crates/product-domains/src/function_agents/ports.rs @@ -1,8 +1,10 @@ //! Function-agent service ports for future runtime migration. //! -//! The current core implementation still owns Git commands, AI clients, prompt -//! templates, JSON extraction, and error mapping. These ports define the seam -//! that future adapters must satisfy before those implementations move. +//! The current core implementation still owns Git commands, AI clients, +//! provider acquisition, and AI transport error mapping. Product-domain modules +//! own prompt templates, JSON extraction, and domain error mapping policy; these +//! ports define the runtime boundary that future adapters must satisfy before +//! concrete Git/AI implementations move. use crate::function_agents::common::{AgentError, AgentResult, Language}; use crate::function_agents::git_func_agent::{ @@ -80,7 +82,7 @@ pub trait FunctionAgentGitPort: Send + Sync { /// Future AI boundary for function agents. /// -/// Core still owns AI client selection, prompt templates, response parsing, and +/// Core still owns AI client selection, provider acquisition, and AI transport /// error mapping. Product call sites may route through this trait only after /// focused equivalence tests cover the specific facade path. pub trait FunctionAgentAiPort: Send + Sync { @@ -97,10 +99,10 @@ pub trait FunctionAgentAiPort: Send + Sync { /// Port-backed function-agent facade for future runtime owner migration. /// /// It owns only pure orchestration over function-agent ports and DTO helpers. -/// Core still owns Git/AI service calls, prompt templates, JSON extraction, -/// and concrete error mapping. Startchat product-path rewiring depends on core -/// adapters preserving legacy Git state, diff fallback, time-info, and -/// `analyzed_at` timing semantics. +/// Core still owns Git/AI service calls, provider acquisition, and AI transport +/// errors. Startchat product-path rewiring depends on core adapters preserving +/// legacy Git state, diff fallback, time-info, and `analyzed_at` timing +/// semantics. pub struct FunctionAgentRuntimeFacade<'a> { git: &'a dyn FunctionAgentGitPort, ai: &'a dyn FunctionAgentAiPort, diff --git a/src/crates/services-integrations/AGENTS.md b/src/crates/services-integrations/AGENTS.md index 204d1e073f..43d69f6e83 100644 --- a/src/crates/services-integrations/AGENTS.md +++ b/src/crates/services-integrations/AGENTS.md @@ -11,14 +11,22 @@ slices that are outside pure product logic but still platform-neutral. presentation code. - Keep integration families behind explicit features. The default feature set should not compile heavy Git, MCP, SSH, network, or file-watch runtimes. + Boundary checks enforce `default = []` and the current `product-full` + integration feature-group list. - MCP config/process/transport lifecycle and dynamic provider helpers may live here; product tool registry assembly, manifest filtering, `GetToolSpec` execution, and concrete tool behavior remain core-owned until H1. -- Remote-connect tracker/wire/pure-policy contracts may live here; dialog - submission, file IO/path resolution, terminal pre-warm, and product execution - remain core-owned until H3. +- Remote-connect tracker/wire/pure-policy contracts, dialog submission + orchestration ports, image-context adapter contracts, and portable + workspace-file path/read/chunk/info helpers may live here. Workspace-root + source selection, response/base64 wrapping, concrete scheduler submission, + concrete terminal pre-warm adapters, and product execution remain core-owned + unless a later reviewed port/provider moves them with equivalence tests. Core + bindings for these runtime adapters are centralized in + `src/crates/core/src/service_agent_runtime.rs`. - Remote-SSH path/session identity helpers may live here; SSH channels, SFTP, - remote FS, remote terminal, and manager assembly remain core-owned until H3. + remote FS, remote terminal, and manager assembly remain core-owned unless a + later reviewed port/provider migration proves equivalence. ## Verification diff --git a/src/crates/services-integrations/Cargo.toml b/src/crates/services-integrations/Cargo.toml index e0b038bf40..d108e75e65 100644 --- a/src/crates/services-integrations/Cargo.toml +++ b/src/crates/services-integrations/Cargo.toml @@ -64,7 +64,7 @@ mcp = [ "rmcp/transport-streamable-http-client-reqwest", "sse-stream", ] -remote-connect = ["bitfun-runtime-ports", "uuid"] +remote-connect = ["async-trait", "bitfun-runtime-ports", "uuid"] remote-ssh = ["dunce", "sha2", "tokio-util"] product-full = [ "announcement", diff --git a/src/crates/services-integrations/src/remote_connect.rs b/src/crates/services-integrations/src/remote_connect.rs index 2dbafddcc5..65d224f8a0 100644 --- a/src/crates/services-integrations/src/remote_connect.rs +++ b/src/crates/services-integrations/src/remote_connect.rs @@ -10,6 +10,7 @@ use bitfun_runtime_ports::{ }; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; @@ -114,6 +115,10 @@ pub struct RemoteImageContext { pub metadata: Option, } +pub trait RemoteImageContextAdapter { + fn from_remote_image_context(context: RemoteImageContext) -> Self; +} + pub fn build_remote_image_contexts(images: Option<&[ImageAttachment]>) -> Vec { let Some(images) = images.filter(|images| !images.is_empty()) else { return Vec::new(); @@ -190,9 +195,171 @@ pub fn resolve_remote_cancel_decision( } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteDialogQueuePriority { + Low, + Normal, + High, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RemoteDialogSubmissionPolicy { + pub source: RemoteConnectSubmissionSource, + pub queue_priority: RemoteDialogQueuePriority, + pub skip_tool_confirmation: bool, +} + +impl RemoteDialogSubmissionPolicy { + pub const fn for_source(source: RemoteConnectSubmissionSource) -> Self { + Self { + source, + queue_priority: RemoteDialogQueuePriority::Normal, + skip_tool_confirmation: true, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteDialogSubmissionRequest { + pub session_id: String, + pub content: String, + pub agent_type: Option, + pub image_contexts: Vec, + pub policy: RemoteDialogSubmissionPolicy, + pub turn_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteTerminalPrewarmRequest { + pub session_id: String, + pub binding_workspace: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RemoteDialogResolvedSubmission { + pub session_id: String, + pub content: String, + pub resolved_agent_type: String, + pub binding_workspace: Option, + pub image_contexts: Vec, + pub policy: RemoteDialogSubmissionPolicy, + pub turn_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteDialogSubmitOutcome { + Started { session_id: String, turn_id: String }, + Queued { session_id: String, turn_id: String }, +} + +/// Host callbacks required by remote-connect dialog execution. +/// +/// The owner crate keeps the remote dialog orchestration order stable, while +/// concrete session restore, terminal warmup, and scheduler execution stay in +/// the product runtime adapter. +#[async_trait::async_trait] +pub trait RemoteDialogRuntimeHost: Send + Sync { + type ImageContext: Send + Sync + 'static; + + fn ensure_tracker(&self, session_id: &str); + + async fn resolve_binding_workspace(&self, session_id: &str) -> Option; + + async fn remote_session_exists(&self, session_id: &str) -> Result; + + async fn restore_remote_session( + &self, + session_id: &str, + workspace_path: &str, + ) -> Result<(), String>; + + fn prewarm_remote_terminal(&self, request: RemoteTerminalPrewarmRequest); + + fn generate_turn_id(&self) -> String; + + async fn submit_dialog( + &self, + submission: RemoteDialogResolvedSubmission, + ) -> Result; +} + +pub async fn submit_remote_dialog( + host: &H, + request: RemoteDialogSubmissionRequest, +) -> Result +where + H: RemoteDialogRuntimeHost + ?Sized, +{ + let RemoteDialogSubmissionRequest { + session_id, + content, + agent_type, + image_contexts, + policy, + turn_id, + } = request; + + host.ensure_tracker(&session_id); + + let binding_workspace = host.resolve_binding_workspace(&session_id).await; + let session_exists = host.remote_session_exists(&session_id).await?; + + if let Some(workspace_path) = + remote_session_restore_target(session_exists, binding_workspace.as_deref()) + { + let _ = host + .restore_remote_session(&session_id, workspace_path) + .await; + } + + host.prewarm_remote_terminal(RemoteTerminalPrewarmRequest { + session_id: session_id.clone(), + binding_workspace: binding_workspace.clone(), + }); + + let resolved_agent_type = resolve_remote_agent_type(agent_type.as_deref()).to_string(); + let turn_id = turn_id.unwrap_or_else(|| host.generate_turn_id()); + + host.submit_dialog(RemoteDialogResolvedSubmission { + session_id, + content, + resolved_agent_type, + binding_workspace, + image_contexts, + policy, + turn_id, + }) + .await +} + pub const REMOTE_FILE_MAX_READ_BYTES: u64 = 30 * 1024 * 1024; pub const REMOTE_FILE_MAX_CHUNK_BYTES: u64 = 3 * 1024 * 1024; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteWorkspaceFileContent { + pub name: String, + pub bytes: Vec, + pub mime_type: &'static str, + pub size: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteWorkspaceFileChunk { + pub name: String, + pub bytes: Vec, + pub offset: u64, + pub chunk_size: u64, + pub total_size: u64, + pub mime_type: &'static str, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteWorkspaceFileInfo { + pub name: String, + pub size: u64, + pub mime_type: &'static str, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RemoteFileChunkRange { pub start: usize, @@ -223,6 +390,177 @@ pub fn remote_file_display_name(name: Option<&str>) -> String { } } +fn strip_remote_workspace_path_prefix(raw: &str) -> &str { + raw.strip_prefix("computer://") + .or_else(|| raw.strip_prefix("file://")) + .unwrap_or(raw) +} + +fn is_remote_absolute_workspace_path(path: &str) -> bool { + path.starts_with('/') || (path.len() >= 3 && path.as_bytes()[1] == b':') +} + +pub fn resolve_remote_workspace_path(raw: &str, workspace_root: Option<&Path>) -> Option { + let stripped = strip_remote_workspace_path_prefix(raw); + + if is_remote_absolute_workspace_path(stripped) { + return Some(PathBuf::from(stripped)); + } + + let workspace_root = workspace_root?; + let canonical_root = std::fs::canonicalize(workspace_root).ok()?; + let candidate = canonical_root.join(stripped); + let canonical_candidate = std::fs::canonicalize(candidate).ok()?; + + if canonical_candidate.starts_with(&canonical_root) { + Some(canonical_candidate) + } else { + None + } +} + +pub fn detect_remote_mime_type(path: &Path) -> &'static str { + let ext = path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + match ext.as_str() { + "txt" | "log" => "text/plain", + "md" => "text/markdown", + "html" | "htm" => "text/html", + "css" => "text/css", + "js" | "mjs" => "text/javascript", + "ts" | "tsx" | "jsx" | "rs" | "py" | "go" | "java" | "c" | "cpp" | "h" | "sh" | "toml" + | "yaml" | "yml" => "text/plain", + "json" => "application/json", + "xml" => "application/xml", + "csv" => "text/csv", + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "zip" => "application/zip", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "mp4" => "video/mp4", + "opus" => "audio/opus", + _ => "application/octet-stream", + } +} + +pub async fn read_remote_workspace_file( + raw_path: &str, + max_size: u64, + workspace_root: Option<&Path>, +) -> Result { + let abs_path = resolve_remote_workspace_path(raw_path, workspace_root) + .ok_or_else(|| format!("Remote file path could not be resolved: {raw_path}"))?; + + if !abs_path.exists() { + return Err(format!("File not found: {}", abs_path.display())); + } + if !abs_path.is_file() { + return Err(format!( + "Path is not a regular file: {}", + abs_path.display() + )); + } + + let metadata = tokio::fs::metadata(&abs_path) + .await + .map_err(|e| format!("Cannot read file metadata for {}: {e}", abs_path.display()))?; + + if metadata.len() > max_size { + return Err(format!( + "File too large ({} bytes, limit {max_size} bytes): {}", + metadata.len(), + abs_path.display() + )); + } + + let bytes = tokio::fs::read(&abs_path) + .await + .map_err(|e| format!("Cannot read file {}: {e}", abs_path.display()))?; + + Ok(RemoteWorkspaceFileContent { + name: remote_file_display_name(abs_path.file_name().and_then(|n| n.to_str())), + bytes, + mime_type: detect_remote_mime_type(&abs_path), + size: metadata.len(), + }) +} + +pub async fn read_remote_workspace_file_chunk( + raw_path: &str, + workspace_root: Option<&Path>, + offset: u64, + limit: u64, +) -> Result { + let abs_path = resolve_remote_workspace_path(raw_path, workspace_root) + .ok_or_else(|| format!("Remote file path could not be resolved: {raw_path}"))?; + + if !abs_path.exists() || !abs_path.is_file() { + return Err(format!( + "File not found or not a regular file: {}", + abs_path.display() + )); + } + + let total_size = tokio::fs::metadata(&abs_path) + .await + .map_err(|e| format!("Cannot read file metadata: {e}"))? + .len(); + + let bytes = tokio::fs::read(&abs_path) + .await + .map_err(|e| format!("Cannot read file: {e}"))?; + let range = resolve_remote_file_chunk_range(bytes.len(), offset, limit); + let chunk = bytes[range.start..range.end].to_vec(); + + Ok(RemoteWorkspaceFileChunk { + name: remote_file_display_name(abs_path.file_name().and_then(|n| n.to_str())), + bytes: chunk, + offset, + chunk_size: range.chunk_size, + total_size, + mime_type: detect_remote_mime_type(&abs_path), + }) +} + +pub async fn read_remote_workspace_file_info( + raw_path: &str, + workspace_root: Option<&Path>, +) -> Result { + let abs_path = resolve_remote_workspace_path(raw_path, workspace_root) + .ok_or_else(|| format!("Remote file path could not be resolved: {raw_path}"))?; + + if !abs_path.exists() { + return Err(format!("File not found: {}", abs_path.display())); + } + if !abs_path.is_file() { + return Err(format!( + "Path is not a regular file: {}", + abs_path.display() + )); + } + + let size = tokio::fs::metadata(&abs_path) + .await + .map_err(|e| format!("Cannot read file metadata: {e}"))? + .len(); + + Ok(RemoteWorkspaceFileInfo { + name: remote_file_display_name(abs_path.file_name().and_then(|n| n.to_str())), + size, + mime_type: detect_remote_mime_type(&abs_path), + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RemoteDefaultModelsConfig { pub primary: Option, diff --git a/src/crates/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services-integrations/tests/remote_connect_contracts.rs index db143cc07d..6c5420e204 100644 --- a/src/crates/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services-integrations/tests/remote_connect_contracts.rs @@ -5,18 +5,23 @@ use bitfun_runtime_ports::AgentSubmissionSource; use bitfun_services_integrations::remote_connect::{ build_remote_image_attachment, build_remote_image_contexts, build_remote_image_submission_request, build_remote_session_create_request, - build_remote_submission_request, make_slim_tool_params, remote_file_display_name, + build_remote_submission_request, make_slim_tool_params, read_remote_workspace_file, + read_remote_workspace_file_chunk, read_remote_workspace_file_info, remote_file_display_name, remote_model_catalog_poll_delta, remote_no_change_poll_response, remote_persisted_poll_response, remote_session_restore_target, remote_snapshot_poll_response, resolve_remote_agent_type, resolve_remote_cancel_decision, resolve_remote_execution_image_contexts, resolve_remote_file_chunk_range, - should_send_remote_model_catalog, ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, - ChatMessageItem, ImageAttachment, RemoteCancelDecision, RemoteCommand, - RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteImageContext, - RemoteModelCatalog, RemoteModelConfig, RemoteResponse, RemoteSessionStateTracker, - RemoteSessionTrackerHost, RemoteSessionTrackerRegistry, RemoteToolStatus, TrackerEvent, + resolve_remote_workspace_path, should_send_remote_model_catalog, submit_remote_dialog, + ActiveTurnSnapshot, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, + RemoteCancelDecision, RemoteCommand, RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, + RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, + RemoteDialogSubmissionPolicy, RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, + RemoteImageContext, RemoteImageContextAdapter, RemoteModelCatalog, RemoteModelConfig, + RemoteResponse, RemoteSessionStateTracker, RemoteSessionTrackerHost, + RemoteSessionTrackerRegistry, RemoteTerminalPrewarmRequest, RemoteToolStatus, TrackerEvent, REMOTE_FILE_MAX_CHUNK_BYTES, REMOTE_FILE_MAX_READ_BYTES, }; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; #[test] @@ -143,6 +148,52 @@ fn remote_connect_image_context_policy_prefers_explicit_contexts() { assert_eq!(contexts, vec![explicit]); } +#[derive(Debug, Clone, PartialEq)] +struct TestImageContext { + id: String, + image_path: Option, + data_url: Option, + mime_type: String, + metadata: Option, +} + +impl RemoteImageContextAdapter for TestImageContext { + fn from_remote_image_context(context: RemoteImageContext) -> Self { + Self { + id: context.id, + image_path: context.image_path, + data_url: context.data_url, + mime_type: context.mime_type, + metadata: context.metadata, + } + } +} + +#[test] +fn remote_connect_image_context_adapter_owns_portable_conversion_shape() { + let context = RemoteImageContext { + id: "ctx-1".to_string(), + image_path: Some("D:/workspace/project/screenshot.png".to_string()), + data_url: Some("data:image/png;base64,abc".to_string()), + mime_type: "image/png".to_string(), + metadata: Some(serde_json::json!({ "source": "remote" })), + }; + + let adapted = TestImageContext::from_remote_image_context(context); + + assert_eq!(adapted.id, "ctx-1"); + assert_eq!( + adapted.image_path.as_deref(), + Some("D:/workspace/project/screenshot.png") + ); + assert_eq!( + adapted.data_url.as_deref(), + Some("data:image/png;base64,abc") + ); + assert_eq!(adapted.mime_type, "image/png"); + assert_eq!(adapted.metadata.as_ref().unwrap()["source"], "remote"); +} + #[test] fn remote_connect_cancel_and_restore_policy_preserve_runtime_decisions() { assert_eq!( @@ -177,6 +228,265 @@ fn remote_connect_cancel_and_restore_policy_preserve_runtime_decisions() { ); } +struct RecordingDialogHost { + session_exists: bool, + binding_workspace: Option, + generated_turn_id: String, + restore_error: bool, + submit_outcome: RemoteDialogSubmitOutcome, + events: Mutex>, + submitted: Mutex>>, +} + +impl RecordingDialogHost { + fn new(session_exists: bool, binding_workspace: Option<&str>) -> Self { + Self { + session_exists, + binding_workspace: binding_workspace.map(ToOwned::to_owned), + generated_turn_id: "turn-generated".to_string(), + restore_error: false, + submit_outcome: RemoteDialogSubmitOutcome::Started { + session_id: "session-1".to_string(), + turn_id: "turn-generated".to_string(), + }, + events: Mutex::new(Vec::new()), + submitted: Mutex::new(None), + } + } + + fn with_restore_error(mut self) -> Self { + self.restore_error = true; + self + } + + fn with_submit_outcome(mut self, submit_outcome: RemoteDialogSubmitOutcome) -> Self { + self.submit_outcome = submit_outcome; + self + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + + fn submitted(&self) -> RemoteDialogResolvedSubmission { + self.submitted + .lock() + .unwrap() + .clone() + .expect("dialog submitted") + } +} + +#[async_trait::async_trait] +impl RemoteDialogRuntimeHost for RecordingDialogHost { + type ImageContext = String; + + fn ensure_tracker(&self, session_id: &str) { + self.events + .lock() + .unwrap() + .push(format!("ensure_tracker:{session_id}")); + } + + async fn resolve_binding_workspace(&self, session_id: &str) -> Option { + self.events + .lock() + .unwrap() + .push(format!("resolve_workspace:{session_id}")); + self.binding_workspace.clone() + } + + async fn remote_session_exists(&self, session_id: &str) -> Result { + self.events + .lock() + .unwrap() + .push(format!("session_exists:{session_id}")); + Ok(self.session_exists) + } + + async fn restore_remote_session( + &self, + session_id: &str, + workspace_path: &str, + ) -> Result<(), String> { + self.events + .lock() + .unwrap() + .push(format!("restore:{session_id}:{workspace_path}")); + if self.restore_error { + Err("restore failed".to_string()) + } else { + Ok(()) + } + } + + fn prewarm_remote_terminal(&self, request: RemoteTerminalPrewarmRequest) { + self.events.lock().unwrap().push(format!( + "prewarm:{}:{}", + request.session_id, + request.binding_workspace.as_deref().unwrap_or("") + )); + } + + fn generate_turn_id(&self) -> String { + self.events + .lock() + .unwrap() + .push("generate_turn".to_string()); + self.generated_turn_id.clone() + } + + async fn submit_dialog( + &self, + submission: RemoteDialogResolvedSubmission, + ) -> Result { + self.events + .lock() + .unwrap() + .push(format!("submit:{}", submission.session_id)); + *self.submitted.lock().unwrap() = Some(submission); + Ok(self.submit_outcome.clone()) + } +} + +#[tokio::test] +async fn remote_connect_dialog_runtime_owns_restore_prewarm_and_submit_order() { + let host = RecordingDialogHost::new(false, Some("D:/workspace/project")); + + let outcome = submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: "session-1".to_string(), + content: "hello".to_string(), + agent_type: Some("code".to_string()), + image_contexts: vec!["image-1".to_string()], + policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), + turn_id: None, + }, + ) + .await + .expect("dialog submit succeeds"); + + assert_eq!( + outcome, + RemoteDialogSubmitOutcome::Started { + session_id: "session-1".to_string(), + turn_id: "turn-generated".to_string() + } + ); + assert_eq!( + host.events(), + vec![ + "ensure_tracker:session-1", + "resolve_workspace:session-1", + "session_exists:session-1", + "restore:session-1:D:/workspace/project", + "prewarm:session-1:D:/workspace/project", + "generate_turn", + "submit:session-1", + ] + ); + + let submitted = host.submitted(); + assert_eq!(submitted.session_id, "session-1"); + assert_eq!(submitted.content, "hello"); + assert_eq!(submitted.resolved_agent_type, "agentic"); + assert_eq!( + submitted.binding_workspace.as_deref(), + Some("D:/workspace/project") + ); + assert_eq!(submitted.image_contexts, vec!["image-1".to_string()]); + assert_eq!(submitted.turn_id, "turn-generated"); + assert_eq!( + submitted.policy.source, + RemoteConnectSubmissionSource::Relay + ); + assert_eq!( + submitted.policy.queue_priority, + RemoteDialogQueuePriority::Normal + ); + assert!(submitted.policy.skip_tool_confirmation); +} + +#[tokio::test] +async fn remote_connect_dialog_runtime_preserves_explicit_turn_without_restore() { + let host = RecordingDialogHost::new(true, Some("D:/workspace/project")).with_submit_outcome( + RemoteDialogSubmitOutcome::Queued { + session_id: "session-1".to_string(), + turn_id: "turn-bot".to_string(), + }, + ); + + let outcome = submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: "session-1".to_string(), + content: "from bot".to_string(), + agent_type: Some("Cowork".to_string()), + image_contexts: Vec::new(), + policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Bot), + turn_id: Some("turn-bot".to_string()), + }, + ) + .await + .expect("dialog submit succeeds"); + + assert_eq!( + outcome, + RemoteDialogSubmitOutcome::Queued { + session_id: "session-1".to_string(), + turn_id: "turn-bot".to_string() + } + ); + assert_eq!( + host.events(), + vec![ + "ensure_tracker:session-1", + "resolve_workspace:session-1", + "session_exists:session-1", + "prewarm:session-1:D:/workspace/project", + "submit:session-1", + ] + ); + + let submitted = host.submitted(); + assert_eq!(submitted.resolved_agent_type, "Cowork"); + assert_eq!(submitted.turn_id, "turn-bot"); + assert_eq!(submitted.policy.source, RemoteConnectSubmissionSource::Bot); +} + +#[tokio::test] +async fn remote_connect_dialog_runtime_keeps_legacy_restore_failure_tolerance() { + let host = RecordingDialogHost::new(false, Some("D:/workspace/project")).with_restore_error(); + + submit_remote_dialog( + &host, + RemoteDialogSubmissionRequest { + session_id: "session-1".to_string(), + content: "hello".to_string(), + agent_type: None, + image_contexts: Vec::new(), + policy: RemoteDialogSubmissionPolicy::for_source(RemoteConnectSubmissionSource::Relay), + turn_id: Some("turn-1".to_string()), + }, + ) + .await + .expect("restore failure is still tolerated before scheduler submit"); + + assert_eq!( + host.events(), + vec![ + "ensure_tracker:session-1", + "resolve_workspace:session-1", + "session_exists:session-1", + "restore:session-1:D:/workspace/project", + "prewarm:session-1:D:/workspace/project", + "submit:session-1", + ] + ); + assert_eq!(host.submitted().turn_id, "turn-1"); +} + #[test] fn remote_connect_file_transfer_policy_preserves_limits_and_chunk_ranges() { assert_eq!(REMOTE_FILE_MAX_READ_BYTES, 30 * 1024 * 1024); @@ -206,6 +516,87 @@ fn remote_connect_file_transfer_policy_preserves_name_fallback() { assert_eq!(remote_file_display_name(Some("")), "file"); } +fn make_temp_remote_workspace() -> (PathBuf, PathBuf, PathBuf) { + let base = std::env::temp_dir().join(format!( + "bitfun-remote-connect-contract-{}", + uuid::Uuid::new_v4() + )); + let workspace = base.join("workspace"); + let artifacts = workspace.join("artifacts"); + std::fs::create_dir_all(&artifacts).expect("create remote workspace"); + let report = artifacts.join("report.md"); + std::fs::write(&report, b"hello remote file").expect("write remote file"); + (base, workspace, report) +} + +#[test] +fn remote_connect_file_path_resolution_stays_within_workspace_root() { + let (base, workspace, report) = make_temp_remote_workspace(); + + let resolved = + resolve_remote_workspace_path("computer://artifacts/report.md", Some(&workspace)) + .expect("workspace-relative file resolves"); + assert_eq!(resolved, report.canonicalize().expect("canonical report")); + + assert!(resolve_remote_workspace_path("../secret.md", Some(&workspace)).is_none()); + assert!(resolve_remote_workspace_path("artifacts/report.md", None).is_none()); + + std::fs::remove_dir_all(base).expect("cleanup remote workspace"); +} + +#[tokio::test] +async fn remote_connect_file_read_helpers_preserve_current_wire_inputs() { + let (base, workspace, report) = make_temp_remote_workspace(); + + let content = read_remote_workspace_file( + "computer://artifacts/report.md", + REMOTE_FILE_MAX_READ_BYTES, + Some(&workspace), + ) + .await + .expect("read remote file"); + + assert_eq!(content.name, "report.md"); + assert_eq!(content.bytes, b"hello remote file"); + assert_eq!(content.mime_type, "text/markdown"); + assert_eq!(content.size, 17); + + let err = read_remote_workspace_file("computer://artifacts/report.md", 3, Some(&workspace)) + .await + .expect_err("size limit rejects large file"); + assert!(err.contains("File too large")); + assert!(err.contains(&report.display().to_string())); + + std::fs::remove_dir_all(base).expect("cleanup remote workspace"); +} + +#[tokio::test] +async fn remote_connect_file_chunk_and_info_helpers_preserve_response_facts() { + let (base, workspace, _report) = make_temp_remote_workspace(); + + let chunk = + read_remote_workspace_file_chunk("computer://artifacts/report.md", Some(&workspace), 6, 99) + .await + .expect("read remote file chunk"); + + assert_eq!(chunk.name, "report.md"); + assert_eq!(chunk.bytes, b"remote file"); + assert_eq!(chunk.offset, 6); + assert_eq!(chunk.chunk_size, 11); + assert_eq!(chunk.total_size, 17); + assert_eq!(chunk.mime_type, "text/markdown"); + + let info = read_remote_workspace_file_info("computer://artifacts/report.md", Some(&workspace)) + .await + .expect("read remote file info"); + + assert_eq!(info.name, "report.md"); + assert_eq!(info.size, 17); + assert_eq!(info.mime_type, "text/markdown"); + + std::fs::remove_dir_all(base).expect("cleanup remote workspace"); +} + #[test] fn remote_connect_session_create_contract_preserves_workspace_binding() { let request = build_remote_session_create_request( diff --git a/src/crates/tool-packs/AGENTS.md b/src/crates/tool-packs/AGENTS.md index 6ae93de21c..7b50bb1145 100644 --- a/src/crates/tool-packs/AGENTS.md +++ b/src/crates/tool-packs/AGENTS.md @@ -8,7 +8,8 @@ tool provider group plan. It does not own concrete tool implementations yet. ## Guardrails - Keep `default = []`; `product-full` may aggregate feature groups but must not - silently enable new runtime behavior. + silently enable new runtime behavior. Boundary checks enforce the current + feature-group list. - Do not depend on `bitfun-core`, concrete service crates, app crates, Tauri, Git, MCP, network clients, or CLI UI dependencies unless H1 explicitly moves a reviewed tool runtime owner here. diff --git a/src/crates/tool-packs/src/lib.rs b/src/crates/tool-packs/src/lib.rs index aff05d94d2..1ec31ec489 100644 --- a/src/crates/tool-packs/src/lib.rs +++ b/src/crates/tool-packs/src/lib.rs @@ -153,6 +153,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", @@ -302,6 +303,7 @@ mod tests { "GetMCPPrompt", "GenerativeUI", "Git", + "ReviewPlatform", "InitMiniApp", "ControlHub", "ComputerUse", diff --git a/src/crates/tool-runtime/src/fs/edit_file.rs b/src/crates/tool-runtime/src/fs/edit_file.rs index e6731c058e..746eb56af1 100644 --- a/src/crates/tool-runtime/src/fs/edit_file.rs +++ b/src/crates/tool-runtime/src/fs/edit_file.rs @@ -1,9 +1,14 @@ +use crate::util::read_line_prefix::{ + read_tool_output_to_file_content, strip_read_line_number_prefix, +}; use crate::util::string::normalize_string; use std::fs; const MAX_MATCH_CONTEXTS: usize = 5; const CONTEXT_LINES_BEFORE: usize = 2; const CONTEXT_LINES_AFTER: usize = 2; +const NOT_FOUND_DIAGNOSTIC_SNIPPETS: usize = 1; +const NOT_FOUND_MIN_SUBSTRING_LEN: usize = 8; /// Edit result, contains line number range information #[derive(Debug, Clone, PartialEq, Eq)] @@ -68,7 +73,184 @@ fn match_contexts(content: &str, old_string: &str, matches: &[(usize, &str)]) -> ) } -pub fn apply_edit_to_content( +/// Remove Read-tool cat -n prefixes line-by-line when present. +pub fn sanitize_read_tool_copied_text(text: &str) -> Option { + let sanitized = read_tool_output_to_file_content(text); + (sanitized != text).then_some(sanitized) +} + +fn normalize_quote_char(ch: char) -> char { + match ch { + '\u{2018}' | '\u{2019}' => '\'', + '\u{201C}' | '\u{201D}' => '"', + other => other, + } +} + +fn find_actual_string(file_content: &str, search_string: &str) -> Option { + if file_content.contains(search_string) { + return Some(search_string.to_string()); + } + + let file_chars: Vec = file_content.chars().collect(); + let search_chars: Vec = search_string.chars().collect(); + if search_chars.is_empty() || file_chars.len() < search_chars.len() { + return None; + } + + let normalized_search: Vec = search_chars + .iter() + .copied() + .map(normalize_quote_char) + .collect(); + + for start in 0..=file_chars.len() - search_chars.len() { + let window_matches = file_chars[start..start + search_chars.len()] + .iter() + .copied() + .map(normalize_quote_char) + .eq(normalized_search.iter().copied()); + if window_matches { + return Some(file_chars[start..start + search_chars.len()].iter().collect()); + } + } + + None +} + +fn edit_string_candidates(content: &str, old_string: &str, new_string: &str) -> Vec<(String, String)> { + let mut candidates = Vec::new(); + let mut push_candidate = |old: String, new: String| { + if !candidates.iter().any(|(existing_old, existing_new)| { + existing_old == &old && existing_new == &new + }) { + candidates.push((old, new)); + } + }; + + push_candidate(old_string.to_string(), new_string.to_string()); + + if let Some(sanitized_old) = sanitize_read_tool_copied_text(old_string) { + let sanitized_new = sanitize_read_tool_copied_text(new_string) + .unwrap_or_else(|| new_string.to_string()); + push_candidate(sanitized_old, sanitized_new); + } + + if let Some(actual_old) = find_actual_string(content, old_string) { + push_candidate(actual_old, new_string.to_string()); + } + + if !old_string.ends_with('\n') { + let with_newline = format!("{old_string}\n"); + if content.contains(&with_newline) { + push_candidate(with_newline, format!("{new_string}\n")); + } + } + + candidates +} + +fn contains_read_tool_line_prefixes(text: &str) -> bool { + text.lines() + .any(|line| strip_read_line_number_prefix(line) != line) +} + +fn contains_read_truncation_marker(text: &str) -> bool { + text.contains(" [truncated]") +} + +fn longest_shared_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(a, b)| a == b) + .count() +} + +fn longest_shared_suffix_len(left: &str, right: &str) -> usize { + longest_shared_prefix_len( + &left.chars().rev().collect::(), + &right.chars().rev().collect::(), + ) +} + +fn snippet_context(lines: &[&str], line_idx: usize) -> String { + let start = line_idx.saturating_sub(CONTEXT_LINES_BEFORE); + let end = (line_idx + CONTEXT_LINES_AFTER + 1).min(lines.len()); + lines[start..end].join("\n") +} + +fn build_not_found_diagnostics(content: &str, old_string: &str) -> String { + let mut hints = vec![ + "Re-read the target lines with Read (use start_line/limit if needed), then copy the exact text after the tab on each line into old_string without reformatting indentation.".to_string(), + ]; + + if contains_read_tool_line_prefixes(old_string) { + hints.push( + "Detected Read-tool line-number prefixes inside `old_string`. Copy only the text after the tab on each line.".to_string(), + ); + } + + if contains_read_truncation_marker(old_string) { + hints.push( + "Detected a Read-tool `[truncated]` marker inside `old_string`. Re-read with start_line/limit so the target lines are complete.".to_string(), + ); + } + + let normalized_content = normalize_string(content); + let lines: Vec<&str> = normalized_content.split('\n').collect(); + let anchor_line = old_string + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(old_string) + .trim(); + + if !anchor_line.is_empty() { + let mut candidates = Vec::new(); + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let shared_prefix = longest_shared_prefix_len(anchor_line, trimmed); + let shared_suffix = longest_shared_suffix_len(anchor_line, trimmed); + let score = shared_prefix.max(shared_suffix); + + if anchor_line.contains(trimmed) + || trimmed.contains(anchor_line) + || score >= NOT_FOUND_MIN_SUBSTRING_LEN + { + candidates.push((score, idx)); + } + } + + candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + candidates.dedup_by_key(|candidate| candidate.1); + + let snippets: Vec = candidates + .into_iter() + .take(NOT_FOUND_DIAGNOSTIC_SNIPPETS) + .map(|(_, idx)| { + format!( + "[nearby content around line {}]\n{}", + idx + 1, + snippet_context(&lines, idx) + ) + }) + .collect(); + + if !snippets.is_empty() { + hints.push(format!( + "Closest current file snippet:\n{}", + snippets.join("\n---\n") + )); + } + } + + hints.join("\n\n") +} + +fn apply_match_and_replace( content: &str, old_string: &str, new_string: &str, @@ -125,6 +307,31 @@ pub fn apply_edit_to_content( }) } +pub fn apply_edit_to_content( + content: &str, + old_string: &str, + new_string: &str, + replace_all: bool, +) -> Result { + let mut last_error = String::from("old_string not found in file."); + + for (candidate_old, candidate_new) in edit_string_candidates(content, old_string, new_string) { + match apply_match_and_replace(content, &candidate_old, &candidate_new, replace_all) { + Ok(result) => return Ok(result), + Err(error) if error == "old_string not found in file." => { + last_error = error; + } + Err(error) => return Err(error), + } + } + + Err(format!( + "{}\n{}", + last_error, + build_not_found_diagnostics(content, old_string) + )) +} + pub fn edit_file( file_path: &str, old_string: &str, @@ -143,7 +350,9 @@ pub fn edit_file( #[cfg(test)] mod tests { - use super::{apply_edit_to_content, edit_file, EditResult}; + use super::{ + apply_edit_to_content, edit_file, sanitize_read_tool_copied_text, EditResult, + }; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -158,6 +367,31 @@ mod tests { path } + #[test] + fn sanitize_read_tool_copied_text_strips_cat_n_prefixes() { + let sanitized = sanitize_read_tool_copied_text(" 1\talpha\n 2\tbeta") + .expect("read prefixes should be stripped"); + + assert_eq!(sanitized, "alpha\nbeta"); + } + + #[test] + fn sanitize_read_tool_copied_text_allows_mixed_lines() { + let sanitized = sanitize_read_tool_copied_text(" 1\talpha\nplain") + .expect("partial prefixes should still be stripped"); + + assert_eq!(sanitized, "alpha\nplain"); + } + + #[test] + fn apply_edit_to_content_matches_curly_quotes() { + let content = "msg := “hello”\n"; + let result = apply_edit_to_content(content, "msg := \"hello\"", "msg := \"hi\"", false) + .expect("quote-normalized edit should succeed"); + + assert_eq!(result.new_content, "msg := \"hi\"\n"); + } + #[test] fn apply_edit_to_content_matches_multiline_lf_input_against_crlf_file() { let content = "header\r\nalpha\r\nbeta\r\nfooter\r\n"; @@ -176,6 +410,15 @@ mod tests { assert_eq!(result.new_content, "header\r\nalpha\r\nBETA\r\nfooter\r\n"); } + #[test] + fn apply_edit_to_content_accepts_read_tool_line_prefixes() { + let content = "alpha\nbeta\n"; + let result = apply_edit_to_content(content, " 1\talpha\n 2\tbeta", "alpha\nBETA", false) + .expect("edit should succeed with read prefixes"); + + assert_eq!(result.new_content, "alpha\nBETA\n"); + } + #[test] fn apply_edit_to_content_replace_all_reports_match_count() { let result = apply_edit_to_content("one\r\ntwo\r\none\r\n", "one", "ONE", true) @@ -211,6 +454,34 @@ mod tests { assert!(error.contains("second block")); } + #[test] + fn apply_edit_to_content_not_found_includes_nearby_diagnostics() { + let error = apply_edit_to_content( + "fn main() {\n println!(\"hello\");\n}\n", + "println!(\"goodbye\");", + "println!(\"hi\");", + false, + ) + .expect_err("missing text should fail"); + + assert!(error.contains("old_string not found in file.")); + assert!(error.contains("[nearby content around line 2]")); + assert!(error.contains("println!(\"hello\");")); + } + + #[test] + fn apply_edit_to_content_not_found_calls_out_read_prefixes() { + let error = apply_edit_to_content( + "alpha\nbeta\n", + " 1\talpha\n 2\tgamma", + "alpha\nBETA", + false, + ) + .expect_err("missing text should fail"); + + assert!(error.contains("Read-tool line-number prefixes")); + } + #[test] fn edit_file_preserves_crlf_when_editing_with_lf_old_string() { let path = write_temp_file("first\r\nalpha\r\nbeta\r\n"); diff --git a/src/crates/tool-runtime/src/util/mod.rs b/src/crates/tool-runtime/src/util/mod.rs index b9c9427432..923ac5a25f 100644 --- a/src/crates/tool-runtime/src/util/mod.rs +++ b/src/crates/tool-runtime/src/util/mod.rs @@ -1,2 +1,3 @@ pub mod ansi_cleaner; +pub mod read_line_prefix; pub mod string; diff --git a/src/crates/tool-runtime/src/util/read_line_prefix.rs b/src/crates/tool-runtime/src/util/read_line_prefix.rs new file mode 100644 index 0000000000..32cbaac109 --- /dev/null +++ b/src/crates/tool-runtime/src/util/read_line_prefix.rs @@ -0,0 +1,97 @@ +/// Strip a Read-tool line prefix (`spaces + line_number + tab|→`) from one line. +pub fn strip_read_line_number_prefix(line: &str) -> String { + let mut chars = line.chars().peekable(); + + while matches!(chars.peek(), Some(' ')) { + chars.next(); + } + + let mut saw_digits = false; + while matches!(chars.peek(), Some(ch) if ch.is_ascii_digit()) { + saw_digits = true; + chars.next(); + } + + if !saw_digits { + return line.to_string(); + } + + match chars.peek().copied() { + Some('\t') => { + chars.next(); + chars.collect() + } + Some('\u{2192}') => { + chars.next(); + chars.collect() + } + _ => line.to_string(), + } +} + +/// Convert Read-tool cat -n output into raw file content (one line at a time). +pub fn read_tool_output_to_file_content(formatted: &str) -> String { + formatted + .lines() + .map(strip_read_line_number_prefix) + .collect::>() + .join("\n") +} + +/// True when every non-empty line still carries a Read-tool prefix. +pub fn all_lines_have_read_prefix(text: &str) -> bool { + if text.is_empty() { + return false; + } + + text.lines().all(|line| line_has_read_prefix(line)) +} + +fn line_has_read_prefix(line: &str) -> bool { + strip_read_line_number_prefix(line) != line +} + +#[cfg(test)] +mod tests { + use super::{ + all_lines_have_read_prefix, read_tool_output_to_file_content, + strip_read_line_number_prefix, + }; + + #[test] + fn strip_tab_prefix() { + assert_eq!(strip_read_line_number_prefix(" 1\talpha"), "alpha"); + } + + #[test] + fn strip_arrow_prefix() { + assert_eq!(strip_read_line_number_prefix(" 2→beta"), "beta"); + } + + #[test] + fn leaves_unprefixed_lines_unchanged() { + assert_eq!(strip_read_line_number_prefix("plain"), "plain"); + } + + #[test] + fn read_tool_output_to_file_content_strips_each_line() { + assert_eq!( + read_tool_output_to_file_content(" 1\talpha\n 2\tbeta"), + "alpha\nbeta" + ); + } + + #[test] + fn read_tool_output_to_file_content_strips_crlf_line_endings() { + assert_eq!( + read_tool_output_to_file_content(" 1\talpha\r\n 2\tbeta\r\n"), + "alpha\nbeta" + ); + } + + #[test] + fn all_lines_have_read_prefix_requires_every_line() { + assert!(all_lines_have_read_prefix(" 1\ta\n 2\tb")); + assert!(!all_lines_have_read_prefix(" 1\ta\nplain")); + } +} diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index 985c964219..b90006fc76 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -360,6 +360,32 @@ impl TransportAdapter for TauriTransportAdapter { }), )?; } + AgenticEvent::GoalVerificationStarted { + session_id, + source_turn_id, + } => { + self.app_handle.emit( + "agentic://goal-verification-started", + json!({ + "sessionId": session_id, + "sourceTurnId": source_turn_id, + }), + )?; + } + AgenticEvent::GoalVerificationFinished { + session_id, + source_turn_id, + outcome, + } => { + self.app_handle.emit( + "agentic://goal-verification-finished", + json!({ + "sessionId": session_id, + "sourceTurnId": source_turn_id, + "outcome": outcome, + }), + )?; + } AgenticEvent::SessionStateChanged { session_id, new_state, diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 5df9523867..c050f6f8ea 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -143,6 +143,7 @@ export const messages: Record = { fileDownloading: 'Downloading...', fileDownloaded: 'Downloaded', clickToDownload: 'Click to download', + scrollToBottom: 'Scroll to bottom', }, tools: { explore: 'Explore', @@ -297,6 +298,7 @@ export const messages: Record = { fileDownloading: '下载中...', fileDownloaded: '已下载', clickToDownload: '点击下载', + scrollToBottom: '滚动到底部', }, tools: { explore: '探索', @@ -451,6 +453,7 @@ export const messages: Record = { fileDownloading: '下載中...', fileDownloaded: '已下載', clickToDownload: '點擊下載', + scrollToBottom: '捲動到底部', }, tools: { explore: '探索', diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 538fad9f04..bcb5aaf186 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -2001,6 +2001,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const messagesContainerRef = useRef(null); const [expandedMsgIds, setExpandedMsgIds] = useState>(new Set()); const [infoToast, setInfoToast] = useState(null); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); const isStreaming = activeTurn != null && activeTurn.status === 'active'; @@ -2137,6 +2138,8 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, }, [sessionMgr, sessionId, setMessages, setError, getMessages]); const isNearBottomRef = useRef(true); + const programmaticScrollRef = useRef(false); + const lastShowScrollToBottomRef = useRef(false); const BOTTOM_THRESHOLD = 80; const handleScroll = useCallback(() => { @@ -2144,7 +2147,18 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, if (!container) return; const gap = container.scrollHeight - container.scrollTop - container.clientHeight; - isNearBottomRef.current = gap < BOTTOM_THRESHOLD; + const nearBottom = gap < BOTTOM_THRESHOLD; + isNearBottomRef.current = nearBottom; + if (nearBottom) { + programmaticScrollRef.current = false; + } + if (!programmaticScrollRef.current) { + const show = !nearBottom; + if (show !== lastShowScrollToBottomRef.current) { + lastShowScrollToBottomRef.current = show; + setShowScrollToBottom(show); + } + } if (container.scrollTop < 100 && hasMore && !isLoadingMore) { const msgs = getMessages(sessionId); @@ -2152,6 +2166,14 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } }, [hasMore, isLoadingMore, getMessages, sessionId, loadMessages]); + const scrollToBottom = useCallback(() => { + programmaticScrollRef.current = true; + isNearBottomRef.current = true; + setShowScrollToBottom(false); + lastShowScrollToBottomRef.current = false; + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + // Initial load + start poller const initialScrollDone = useRef(false); const pendingInitialScroll = useRef(false); @@ -2231,6 +2253,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const isNewAppend = messages.length > prevMsgCountRef.current; prevMsgCountRef.current = messages.length; if (isNewAppend && !isLoadingMore && isNearBottomRef.current) { + programmaticScrollRef.current = true; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } } @@ -2239,11 +2262,13 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, useEffect(() => { if (!initialScrollDone.current || !isStreaming) return; if (!isNearBottomRef.current) return; + programmaticScrollRef.current = true; messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); }, [activeTurn, isStreaming]); useEffect(() => { if (optimisticMsg) { + programmaticScrollRef.current = true; isNearBottomRef.current = true; messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); } @@ -2257,6 +2282,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, if (!isNearBottomRef.current) return; const gap = container.scrollHeight - container.scrollTop - container.clientHeight; if (gap > 10 && gap < 400) { + programmaticScrollRef.current = true; container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' }); } }, 300); @@ -2419,9 +2445,9 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Tab' && e.shiftKey) { - // Skip when the textarea is focused — its own React handler already fired. - const tag = (e.target as HTMLElement)?.tagName; - if (tag === 'TEXTAREA') return; + const target = e.target as HTMLElement; + const tag = target?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return; e.preventDefault(); cycleAgentModeRef.current(); } @@ -2692,8 +2718,22 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, )}
+
+ {showScrollToBottom && ( + + )} + {/* Floating Input Bar — two-stage (matches desktop ChatInput) */} = ({ connectionId={sshRemote.connectionId} initialPath={sshRemote.remoteFileBrowserInitialPath} homePath={sshRemote.remoteFileBrowserInitialPath} + selectDirectoriesOnly onSelect={handleSelectRemoteWorkspace} onCancel={() => { const hasActiveRemoteWorkspace = diff --git a/src/web-ui/src/app/components/NavPanel/NavPanel.scss b/src/web-ui/src/app/components/NavPanel/NavPanel.scss index 97fcfd2418..f8ffda3da0 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.scss +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.scss @@ -1085,11 +1085,12 @@ $_section-header-height: 24px; &.is-collapsed { grid-template-rows: 0fr; + pointer-events: none; } } &__collapsible-inner { - overflow: visible; + overflow: hidden; min-height: 0; min-width: 0; max-width: 100%; diff --git a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts index 63e740e3d9..cedb2d554f 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts +++ b/src/web-ui/src/app/components/NavPanel/NavPanelLayout.test.ts @@ -48,6 +48,15 @@ describe('NavPanel layout styles', () => { expect(sectionHeaderBlock).toContain('margin: 0 $size-gap-1;'); }); + it('clips collapsible section content when collapsed via grid 0fr', () => { + const stylesheet = readNavPanelStylesheet(); + const collapsibleInnerBlock = extractBlock(stylesheet, '&__collapsible-inner'); + + expect(stylesheet).toContain('&.is-collapsed {\n grid-template-rows: 0fr;'); + expect(collapsibleInnerBlock).toContain('overflow: hidden;'); + expect(collapsibleInnerBlock).toContain('min-height: 0;'); + }); + it('uses one shared row-action size for root action buttons', () => { const stylesheet = readNavPanelStylesheet(); const rootBlock = extractBlock(stylesheet, '.bitfun-nav-panel'); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index a459608f49..478e56c419 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy, FileText, GitBranch, Bot, Link2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { DotMatrixArrowRightIcon } from './DotMatrixArrowRightIcon'; import { Button, ConfirmDialog, Modal, Tooltip } from '@/component-library'; @@ -33,6 +33,7 @@ import { import { SSHContext } from '@/features/ssh-remote/SSHRemoteContext'; import { useWorkspaceSearchIndex } from '@/tools/file-explorer'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; +import WorkspaceRelatedPathsDialog from './WorkspaceRelatedPathsDialog'; interface WorkspaceItemProps { @@ -78,6 +79,7 @@ const WorkspaceItem: React.FC = ({ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteWorktreeDialogOpen, setDeleteWorktreeDialogOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); + const [relatedPathsDialogOpen, setRelatedPathsDialogOpen] = useState(false); const [isDeletingAssistant, setIsDeletingAssistant] = useState(false); const [isDeletingWorktree, setIsDeletingWorktree] = useState(false); const [isResettingWorkspace, setIsResettingWorkspace] = useState(false); @@ -103,6 +105,7 @@ const WorkspaceItem: React.FC = ({ ? workspace.identity?.name?.trim() || workspace.name : workspace.name; const isLinkedWorktree = isLinkedWorktreeWorkspace(workspace); + const relatedPathCount = workspace.relatedPaths?.length ?? 0; const canShowSearchIndex = isActive && workspaceSearchEnabled @@ -864,6 +867,11 @@ const WorkspaceItem: React.FC = ({ > {workspaceDisplayName} + {relatedPathCount > 0 ? ( + + {t('nav.workspaces.relatedPaths.badge', { count: relatedPathCount })} + + ) : null} @@ -1048,6 +1056,19 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.initAgents')} +
{isLinkedWorktree ? (
); }; diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss new file mode 100644 index 0000000000..6fa85e36d7 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.scss @@ -0,0 +1,161 @@ +.workspace-related-paths-dialog { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; + color: var(--color-text-primary); + + &__modal { + overflow-x: hidden; + } + + &__intro { + display: flex; + gap: 12px; + padding: 4px 0 2px; + } + + &__intro-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + flex-shrink: 0; + border-radius: 12px; + background: color-mix(in srgb, var(--color-accent-500) 14%, transparent); + color: var(--color-accent-500); + } + + &__intro-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + &__intro-title { + font-size: var(--font-size-sm); + font-weight: 700; + line-height: 1.3; + } + + &__intro-text, + &__scope { + font-size: var(--font-size-xs); + line-height: 1.5; + color: var(--color-text-secondary); + } + + &__scope { + color: var(--color-text-muted); + } + + &__empty { + padding: 18px 16px; + border-radius: 14px; + border: 1px dashed color-mix(in srgb, var(--border-subtle) 78%, transparent); + background: color-mix(in srgb, var(--element-bg-subtle) 80%, transparent); + color: var(--color-text-secondary); + font-size: var(--font-size-xs); + text-align: center; + } + + &__list { + display: flex; + flex-direction: column; + gap: 12px; + } + + &__card { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + border-radius: 14px; + border: 1px solid color-mix(in srgb, var(--border-subtle) 82%, transparent); + background: color-mix(in srgb, var(--element-bg-medium) 52%, transparent); + } + + &__card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + &__card-index { + font-size: var(--font-size-xxs); + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-muted); + } + + &__remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + + &:hover { + background: color-mix(in srgb, var(--color-error, #e05d5d) 10%, transparent); + color: var(--color-error, #e05d5d); + } + } + + &__path-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: start; + } + + &__error { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid color-mix(in srgb, var(--color-error, #e05d5d) 22%, transparent); + background: color-mix(in srgb, var(--color-error, #e05d5d) 9%, transparent); + color: color-mix(in srgb, var(--color-error, #e05d5d) 88%, var(--color-text-primary)); + font-size: var(--font-size-xs); + line-height: 1.45; + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 4px; + } + + &__footer-actions { + display: flex; + align-items: center; + gap: 8px; + } +} + +@media (max-width: 720px) { + .workspace-related-paths-dialog { + &__path-row { + grid-template-columns: 1fr; + } + + &__footer { + flex-direction: column; + align-items: stretch; + } + + &__footer-actions { + width: 100%; + justify-content: flex-end; + } + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx new file mode 100644 index 0000000000..e670b7142f --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceRelatedPathsDialog.tsx @@ -0,0 +1,315 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { open as openDirectoryDialog } from '@tauri-apps/plugin-dialog'; +import { Button, Input, Modal, Textarea } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; +import { sshApi } from '@/features/ssh-remote/sshApi'; +import RemoteFileBrowser from '@/features/ssh-remote/RemoteFileBrowser'; +import { createLogger } from '@/shared/utils/logger'; +import { isRemoteWorkspace, type RelatedPath, type WorkspaceInfo } from '@/shared/types'; +import { FolderOpen, Link2, Plus, Trash2 } from 'lucide-react'; +import './WorkspaceRelatedPathsDialog.scss'; + +const log = createLogger('WorkspaceRelatedPathsDialog'); + +interface WorkspaceRelatedPathsDialogProps { + workspace: WorkspaceInfo; + isOpen: boolean; + onClose: () => void; +} + +interface DraftRelatedPath { + id: string; + path: string; + description: string; +} + +function createDraft(path?: Partial): DraftRelatedPath { + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + path: path?.path ?? '', + description: path?.description ?? '', + }; +} + +function normalizeDrafts(drafts: DraftRelatedPath[]): RelatedPath[] { + return drafts.map(draft => ({ + path: draft.path.trim(), + ...(draft.description.trim() + ? { description: draft.description.trim() } + : {}), + })); +} + +export const WorkspaceRelatedPathsDialog: React.FC = ({ + workspace, + isOpen, + onClose, +}) => { + const { t } = useI18n('common'); + const { updateWorkspaceRelatedPaths } = useWorkspaceContext(); + const [drafts, setDrafts] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [browsingIndex, setBrowsingIndex] = useState(null); + const [remoteHomePath, setRemoteHomePath] = useState(undefined); + + const remoteWorkspace = isRemoteWorkspace(workspace); + const connectionId = workspace.connectionId?.trim() || undefined; + + useEffect(() => { + if (!isOpen) { + return; + } + + setDrafts((workspace.relatedPaths ?? []).map(path => createDraft(path))); + setSaving(false); + setError(null); + }, [isOpen, workspace.relatedPaths]); + + useEffect(() => { + if (!isOpen || !remoteWorkspace || !connectionId) { + setRemoteHomePath(undefined); + return; + } + + let cancelled = false; + void sshApi + .getServerInfo(connectionId) + .then(info => { + if (!cancelled) { + setRemoteHomePath(info?.homeDir?.trim() || undefined); + } + }) + .catch(fetchError => { + log.warn('Failed to load remote server info for related directories', { + workspaceId: workspace.id, + error: fetchError, + }); + }); + + return () => { + cancelled = true; + }; + }, [connectionId, isOpen, remoteWorkspace, workspace.id]); + + const normalizedDrafts = useMemo(() => normalizeDrafts(drafts), [drafts]); + const hasInvalidDraft = normalizedDrafts.some(draft => !draft.path); + const isUnchanged = JSON.stringify(normalizedDrafts) === JSON.stringify(workspace.relatedPaths ?? []); + + const setDraftValue = ( + draftId: string, + field: 'path' | 'description', + value: string + ) => { + setDrafts(current => + current.map(draft => (draft.id === draftId ? { ...draft, [field]: value } : draft)) + ); + setError(null); + }; + + const handleAddDraft = () => { + setDrafts(current => [...current, createDraft()]); + setError(null); + }; + + const handleRemoveDraft = (draftId: string) => { + setDrafts(current => current.filter(draft => draft.id !== draftId)); + setError(null); + }; + + const handleSelectLocalDirectory = async (index: number) => { + try { + const selected = await openDirectoryDialog({ + directory: true, + multiple: false, + title: t('nav.workspaces.relatedPaths.dialog.selectDirectoryTitle'), + defaultPath: drafts[index]?.path || workspace.rootPath, + }); + + if (typeof selected === 'string' && selected.trim()) { + setDraftValue(drafts[index].id, 'path', selected); + } + } catch (selectionError) { + log.error('Failed to select related directory', { workspaceId: workspace.id, error: selectionError }); + setError(t('nav.workspaces.relatedPaths.messages.selectFailed')); + } + }; + + const handleSave = async () => { + if (hasInvalidDraft) { + setError(t('nav.workspaces.relatedPaths.validation.pathRequired')); + return; + } + + setSaving(true); + setError(null); + try { + await updateWorkspaceRelatedPaths(workspace.id, normalizedDrafts); + onClose(); + } catch (saveError) { + log.error('Failed to save related directories', { workspaceId: workspace.id, error: saveError }); + setError( + saveError instanceof Error + ? saveError.message + : t('nav.workspaces.relatedPaths.messages.saveFailed') + ); + } finally { + setSaving(false); + } + }; + + return ( + <> + { + if (!saving) { + onClose(); + } + }} + title={t('nav.workspaces.relatedPaths.dialog.title')} + size="large" + contentInset + contentClassName="workspace-related-paths-dialog__modal" + > +
+
+
+ +
+
+
+ {t('nav.workspaces.relatedPaths.dialog.heading')} +
+
+ {t('nav.workspaces.relatedPaths.dialog.description')} +
+
+ {remoteWorkspace + ? t('nav.workspaces.relatedPaths.dialog.remoteScope', { + connectionName: workspace.connectionName || workspace.name, + }) + : t('nav.workspaces.relatedPaths.dialog.localScope')} +
+
+
+ + {drafts.length === 0 ? ( +
+ {t('nav.workspaces.relatedPaths.dialog.empty')} +
+ ) : ( +
+ {drafts.map((draft, index) => ( +
+
+ + {t('nav.workspaces.relatedPaths.dialog.itemLabel', { index: index + 1 })} + + +
+ +
+ setDraftValue(draft.id, 'path', event.target.value)} + placeholder={t('nav.workspaces.relatedPaths.dialog.pathPlaceholder')} + disabled={saving} + /> + +
+ +