diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 8253cf6f0..daf6700ca 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -34,8 +34,9 @@ Execution、Extension、Cross-platform Adapter 的边界以 - 接口与入口层暴露选定产品行为;可复用行为应下移。 - 组装层只接线下层并选择产品能力事实,不实现具体 adapter、OS 或 service 细节。 - 产品特性只在内核能力之上组装用户侧命令、UI contribution、设置和默认策略;长程任务、scheduler、permission、session/workspace、memory、DFX、hook 和 event 事实属于 Agent Kernel owner。 -- 适配层翻译协议和外部系统,不拥有产品能力选择或可复用 OS service 行为。 +- 适配层翻译协议和外部 provider 形状,不拥有产品能力选择或可复用 OS service 行为。 - 服务实现层负责可复用的 OS、process、terminal、MCP、remote、git、filesystem、session persistence primitives 和 MiniApp runtime IO 能力。 +- 外部系统是边界外资源,不是仓库内层级。只有已注册的 adapter、service 或 app-local provider 应调用它们;其他层消费 port 和稳定契约。 - 执行原语层只放可移植运行时构件,不拥有宿主或交付形态。 - 契约层保持轻行为,不得向上依赖。 diff --git a/AGENTS.md b/AGENTS.md index a685b5bef..bd9319380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,8 +35,9 @@ Boundary rules: - Interfaces and app entrypoints expose selected product behavior; reusable behavior moves down. - Assembly wires lower layers and selects product capability facts; it must not implement concrete adapter, OS, or service details. - Product features assemble user-facing commands, UI contributions, settings, and default policy on top of kernel capabilities; long-running task, scheduler, permission, session/workspace, memory, DFX, hook, and event facts stay in Agent Kernel owners. -- Adapters translate protocols and external systems; they should not own product capability selection or reusable OS service behavior. +- Adapters translate protocols and external-provider shapes; they should not own product capability selection or reusable OS service behavior. - Services implement reusable concrete OS, process, terminal, MCP, remote, git, filesystem, and MiniApp runtime IO capabilities. +- External systems are boundary resources, not repository layers. Only registered adapters/services/app-local providers should call them; other layers consume ports and stable contracts. - Execution crates are portable runtime building blocks, not host-specific or delivery-profile owners. - Contracts stay behavior-light and must not depend upward. diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index f365c2f75..844649f73 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -91,11 +91,11 @@ OpenCode adapter 只产出可注册的 descriptor、provider 和 contribution; | API / owner | 主要 crate | 允许依赖 | 不允许依赖 | 对外承诺 | |---|---|---|---|---| | Product Assembly API | `src/crates/assembly/*` | feature packs、Kernel API、Execution API、Runtime Services、platform providers | Agent 内部状态机、具体 UI 组件实现作为下层依赖 | 按产品形态组装能力,输出 typed runtime parts | -| Product Feature API | `product-capabilities`、`product-domains`、对应 UI contribution owner | Kernel API、Execution API、UI Extension Contract、domain contract | OS concrete、Tauri handle、permission 最终策略 | 把底层能力映射为用户功能和默认策略 | +| Product Feature API | `product-capabilities`、`product-domains`、对应 UI contribution owner | Kernel API、UI Extension Contract、Capability/Effect contract、domain contract | OS concrete、Tauri handle、Execution concrete、permission 最终策略 | 把内核能力和稳定 descriptor 映射为用户功能和默认策略 | | Rust Kernel API | `agent-runtime`、`agent-stream`、`runtime-services`、`runtime-ports`、`events`、`core-types` | stable contracts、tool/harness registry、typed services | `bitfun-core`、Tauri、Web UI、ACP protocol、provider concrete | session / turn / event / permission / scheduler / context 等 SDK 候选接口 | | Execution API | `tool-contracts`、`tool-provider-groups`、`tool-execution`、`harness` | stable contracts、runtime ports、注入的 service ports | product registry、UI、具体 filesystem/Git/terminal/MCP client | tool、skills、MCP tool bridge、sandbox、harness 执行语义 | | Extension API | extension host / OpenCode / ACP adapter owner | Rust Kernel API contract、UI Extension Contract、Capability/Effect contract | Web UI React implementation、Tauri state、kernel 权威状态写入 | 把外部生态能力转换为 descriptor、provider 和 candidate effect | -| Cross-platform Adapter API | `services/*`、`adapters/*`、app-local provider | runtime ports、core DTO、允许的第三方库 | Product Feature、Agent Kernel 状态机、UI command | 实现 filesystem、terminal、network、remote、Git、MCP transport、AI provider 等外部 I/O | +| Platform / Provider Adapter API | `services/*`、`adapters/*`、app-local provider | runtime ports、stable DTO、允许的第三方库 | Product Feature、Agent Kernel 状态机、UI command | 实现 filesystem、terminal、network、remote、Git、MCP transport、AI provider 等边界外 I/O | | Stable Contract API | `contracts/*` | 低层无行为依赖或标准序列化依赖 | 上层 crate、concrete manager、UI rendering | DTO、event、port、capability/effect、permission、sandbox、audit、typed error | 禁止依赖: @@ -105,7 +105,7 @@ OpenCode adapter 只产出可注册的 descriptor、provider 和 contribution; - `tool-contracts` 依赖具体 service crate;`tool-execution` 依赖产品 registry、产品 permission policy 或具体 UI。 - `harness` 依赖具体 filesystem/Git/terminal manager;它只通过 ports 和 provider contract 获取能力。 - Extension Host 依赖 Web UI React component implementation、Tauri app state 或 concrete core manager。 -- Product Feature 直接依赖 platform adapter concrete、全局 mutable runtime state 或外部系统 client。 +- Product Feature 直接依赖 platform adapter concrete、execution concrete、全局 mutable runtime state 或边界外资源 client。 接口暴露原则: @@ -114,6 +114,21 @@ OpenCode adapter 只产出可注册的 descriptor、provider 和 contribution; - 注册接口接收 typed provider / descriptor / policy,不接收 `Any`、无类型 service name 或全局 mutable registry。 - 兼容 facade 可以保留旧路径导出,但旧路径不得成为新 API 的真实 owner。 +### 1.5 平台适配与边界外资源 + +Platform / Provider Adapter 是仓库内实现层,负责把稳定 port 转换为 OS、network、terminal、remote、MCP +transport、AI provider、browser runtime 或第三方库调用。边界外资源不是 crate、不是逻辑层,也不是所有模块可依赖的 +基础设施。 + +实现规则: + +- Product Assembly 是唯一可以选择具体 platform provider 的位置;选择结果以 typed runtime parts 注入。 +- Kernel、Execution、Extension 和 Product Feature 只消费 stable contract、port handle 或 descriptor,不导入具体 + provider crate。 +- Platform adapter 不读取 delivery profile、feature pack 或 UI command;形态差异由 Product Assembly 注入。 +- 外部资源错误必须在 adapter 边界转换为 typed error、unsupported/unavailable 或 capability/effect fact,不能泄漏为 + 产品层专用分支。 + ## 2. 稳定接口与运行时服务 ### 2.1 稳定契约(Stable Contracts) diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 807df28a4..3ec48b6e1 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -149,58 +149,81 @@ Codex 官方文档把 sandbox、approval、network control 和 auto-review 放 都交给 UI 或模型判断。对 BitFun 而言,权限确认、沙箱、网络、凭据、插件执行域和远程执行域必须由稳定控制面表达, 再由产品入口投影给用户。 +### 4.4 对照结论 + +Claude Code、OpenCode 和 Codex 的共同信号不是“所有能力放入一个 runtime”,而是把 agent 内核、安全控制、 +扩展声明、工具执行和平台 provider 分开。用户可见功能通过命令、设置、UI contribution 或 SDK API 组装;外部插件 +通过 descriptor、hook、tool provider 或 MCP/ACP bridge 接入;OS、network、remote、browser 和 provider client +留在边界 adapter 中。BitFun 的目标架构也应遵循这一点:Product Assembly 负责组装 concrete provider,普通层级 +只消费稳定 API、port 和 capability/effect contract。 + ## 5. 目标逻辑视图 -目标逻辑按概念层表达。概念层决定职责和依赖方向;物理目录决定 crate 的放置位置。二者必须一致,但不要求 +目标逻辑按概念层表达。概念层决定职责、接口和依赖方向;物理目录决定 crate 的放置位置。二者必须一致,但不要求 一层只对应一个 crate。 +本图区分两类关系: + +- **编译依赖**:普通模块只能依赖本层公开 API、下层稳定契约或被注入的 port,不直接依赖更底层 concrete 实现。 +- **组装依赖**:Product Assembly 是 composition root,允许在构建期认识具体 feature、kernel、execution、 + extension 和 platform provider,并把它们装配为 typed runtime parts。 + +外部系统不是仓库内层级。OS、Git、MCP server、AI provider、remote host、browser/desktop runtime 和 plugin +package 都是边界外资源,只能由被注入的跨平台或协议 adapter 调用。 + ```mermaid flowchart TB - Product["产品组装与接口层(Product Assembly and Interfaces)
Desktop / CLI / Web / ACP / Remote / SDK / feature bundles / UI host"] + Surface["产品入口(Product Surfaces)
Desktop / CLI / Web / ACP / Remote / SDK / UI host"] + Assembly["产品组装层(Product Assembly)
delivery profile / feature bundle / provider registration / capability matrix"] Feature["产品特性层(Product Feature Layer)
/goal / DeepReview / MiniApp / commands / settings / product workflows"] - Extension["扩展层(Extension Layer)
plugin runtime / OpenCode adapter / ACP bridge / external skills / UI contribution descriptors"] - Kernel["核心层(Agent Kernel)
long-running task / scheduler / permission / model routing / context / session / workspace / memory / DFX / hook-event bus"] - Execution["执行层(Execution Layer)
built-in tools / tool contracts / skills / MCP tool bridge / sandbox / local-remote runtime / harness"] - Platform["跨平台适配层(Cross-platform Adapter Layer)
filesystem / network / process / thread-time / terminal / remote provider / provider protocol"] - Contracts["稳定契约与安全控制面(Stable Contracts and Security Control Plane)
DTO / ports / events / UI extension contract / capability-effect model / audit facts"] - External["外部系统(External Systems)
OS / Git / MCP server / AI provider / remote host / browser-desktop environment / plugin package"] - - Product --> Feature - Product --> Extension - Product --> Kernel - Product --> Execution - Product --> Platform + Kernel["核心层(Agent Kernel)
session / workspace / scheduler / permission / model routing / context / memory / DFX / hook-event bus"] + Execution["执行层(Execution Layer)
tool runtime / skills / MCP tool bridge / sandbox / harness / artifact semantics"] + Extension["扩展层(Extension Layer)
plugin host / OpenCode adapter / ACP bridge / external skills / UI contribution descriptors"] + Platform["跨平台适配实现(Platform and Provider Adapters)
filesystem / network / process / terminal / remote / Git / MCP transport / AI provider"] + Contracts["稳定契约与安全控制面(Stable Contracts and Security Control Plane)
DTO / ports / events / capability-effect / permission / audit / UI extension contract"] + External[("边界外资源(External Resources)
OS / Git binary / MCP server / AI provider / remote host / browser runtime / plugin package")] + + Surface --> Assembly + Assembly --> Feature + Assembly --> Kernel + Assembly --> Execution + Assembly --> Extension + Assembly --> Platform Feature --> Kernel - Feature --> Execution Feature --> Contracts - Extension --> Kernel - Extension --> Execution - Extension --> Contracts - Kernel --> Execution Kernel --> Contracts - Execution --> Platform Execution --> Contracts + Extension --> Contracts Platform --> Contracts - Platform --> External + Kernel -. "通过注入的 execution port 调用" .-> Execution + Execution -. "通过注入的 platform port 调用" .-> Platform + Assembly -. "注册 extension descriptor/provider" .-> Extension + Platform -. "边界调用,不是仓库依赖层" .-> External ``` -依赖方向只允许从产品入口流向更底层能力: +普通模块的依赖方向只允许流向稳定接口,而不是直接认识所有下层实现: ```text -Product Assembly and Interfaces - -> Product Feature / Extension / Agent Kernel / Execution / Cross-platform Adapter +Product Surfaces + -> Product Assembly API +Product Assembly + -> Product Feature API / Agent Kernel API / Execution API / Extension API / Platform Provider API Product Feature - -> Agent Kernel / Execution / Stable Contracts and Security Control Plane -Extension - -> Agent Kernel / Execution / Stable Contracts and Security Control Plane + -> Agent Kernel API / Stable Contracts and Security Control Plane Agent Kernel - -> Execution / Stable Contracts and Security Control Plane + -> Stable Contracts and Security Control Plane Execution - -> Cross-platform Adapter / Stable Contracts and Security Control Plane -Cross-platform Adapter - -> Stable Contracts and Security Control Plane / External Systems + -> Stable Contracts and Security Control Plane +Extension + -> Stable Contracts and Security Control Plane +Platform and Provider Adapters + -> Stable Contracts and Security Control Plane ``` +运行时调用可以通过 Product Assembly 注入的 typed port 从 Kernel 进入 Execution、从 Execution 进入 Platform +provider,但这不是对 concrete crate 的编译依赖。除 Product Assembly 外,任何层都不应为了“方便调用”直接依赖下层 +concrete implementation。外部系统只存在于 adapter 的 I/O 边界,不参与仓库内层级依赖。 + 同层 crate 之间也必须按 owner 最小化依赖,禁止为了复用 helper 形成循环依赖或让下层读取产品形态。 ## 6. 层级放置规则 @@ -237,7 +260,7 @@ platform provider,并把它们组装成产品可用能力。 应该放这里: - `/goal`、DeepReview、DeepResearch、MiniApp、input command、settings、review UI、custom agent/mode 的产品编排。 -- feature pack / capability pack、默认策略、UI contribution descriptor、命令到 kernel/execution request 的映射。 +- feature pack / capability pack、默认策略、UI contribution descriptor、命令到 kernel request 或稳定 descriptor 的映射。 - 特性级 DTO 和纯规则,例如 MiniApp domain contract、review report domain contract。 不应该放这里: @@ -308,7 +331,12 @@ runtime 和 harness 的执行语义,但不决定产品形态。 ### 6.6 跨平台适配层(Cross-platform Adapter Layer) -放置仓库与外部系统交互的具体实现。该层实现下层 ports,并由 Product Assembly 按产品形态注册。 +放置仓库内与边界外资源交互的具体实现。该层不是“外部系统层”,也不是所有模块都应依赖的底座;它只是实现稳定 +ports 的 provider / driver / protocol adapter。Product Assembly 按产品形态选择并注册具体实现,Kernel、Execution、 +Extension 和 Product Feature 只通过稳定 port 或 provider contract 消费能力。 + +外部系统指仓库外资源,例如 OS API、Git binary、MCP server、AI provider、remote host、browser/desktop runtime、 +plugin package 或 cloud service。它们不属于仓库内依赖层,不能被普通模块作为上游依赖建模。 当前主要路径:`src/crates/services/services-core`、`src/crates/services/services-integrations`、 `src/crates/services/terminal`、`src/crates/adapters/ai-adapters`、`src/crates/adapters/api-layer`、 @@ -318,13 +346,14 @@ runtime 和 harness 的执行语义,但不决定产品形态。 - filesystem、network、process/thread/time、terminal、remote、Git、MCP transport、AI/provider protocol。 - browser/desktop automation、WebDriver、HTTP/transport adapter、OS-specific permission/projection provider。 -- local/remote provider 的 concrete implementation 和第三方库适配。 +- local/remote provider 的 concrete implementation、第三方库适配、外部协议错误映射和不可用状态转换。 不应该放这里: - 产品能力选择、feature pack、UI 命令、Agent Kernel 状态机。 - Tool manifest 的产品曝光策略或 permission 最终策略。 - 直接依赖上层产品入口来决定行为;形态差异由 Product Assembly 注入。 +- 被其他层当作“必须依赖的基础层”。除组装层外,调用方应依赖 port / contract,而不是依赖 concrete adapter。 ### 6.7 稳定契约与安全控制面(Stable Contracts and Security Control Plane) @@ -347,22 +376,26 @@ provider-neutral contract。 本文中的安全控制面指稳定契约和注入式策略边界,不表示具体策略实现必须位于 contracts crate。具体策略实现由 Product Assembly 注入,通过稳定 port 供 Kernel、Execution、Extension、Platform Adapter 和 UI projection 消费。 -最终授权和审计不能由模型输出或外部插件直接写入。 +最终授权和审计不能由模型输出、外部插件或平台 adapter 直接写入。 ## 7. 接口与实现关系 -接口定义在稳定 owner;实现由上层组装点注入。注册发生在构建期,运行时热路径应消费 typed parts,而不是通过 -无类型 service locator 动态查找。 +接口定义在稳定 owner;实现由 Product Assembly 或产品宿主在组装边界注入。注册发生在构建期,运行时热路径应消费 +typed parts 和 port handle,而不是通过无类型 service locator 动态查找。 + +除 Product Assembly 外,普通层级不应同时依赖接口和实现。Kernel 只依赖 kernel/runtime contracts;Execution 只依赖 +tool/harness/sandbox contracts 和被注入的 platform ports;Extension 只产出 descriptor、provider 和 candidate effect; +Platform adapter 只实现 ports 并调用边界外资源。 | 接口 / API | 定义 owner | 实现 owner | 注册者 | 消费者 | 边界 | |---|---|---|---|---|---| | `ProductAssembler` / `ProductAssemblyPlan` | Product Assembly | 产品入口或 assembly crate | 产品入口 | Desktop / CLI / Web / SDK / ACP | 选择能力,不写 Agent 状态机 | -| `ProductFeaturePack` | Product Feature | feature owner | Product Assembly | 产品入口、Kernel、Execution、UI host | 编排 feature,不拥有 OS concrete 或 Extension Host | +| `ProductFeaturePack` | Product Feature | feature owner | Product Assembly | 产品入口、Kernel API、UI host | 编排 feature,不拥有 OS concrete 或 Extension Host | | `AgentRuntimeBuilder` / Kernel API | Agent Kernel | `bitfun-agent-runtime` | Product Assembly / SDK host | Product Feature、SDK、Extension adapter | 接收 typed parts,不创建 concrete manager | -| `ToolRuntimeBuilder` / Tool contracts | Execution | tool/execution owner | Product Assembly | Agent Kernel、Harness | 执行 tool,不选择产品形态 | -| `HarnessRegistryBuilder` | Execution | harness owner | Product Assembly | Agent Kernel、Product Feature | 注册 workflow provider,不执行 UI 展示 | -| `ExtensionHost` / OpenCode adapter | Extension | extension host / adapter | Product Assembly | Product Assembly、Execution、UI host | 产出 descriptor/provider,不写权威状态 | -| `RuntimeServicesBuilder` / platform providers | Cross-platform Adapter | services/adapters/app provider | Product Assembly | Kernel、Execution、Harness | 实现外部系统 I/O,不读取 product profile | +| `ToolRuntimeBuilder` / Tool contracts | Execution | tool/execution owner | Product Assembly | Agent Kernel 通过 execution port、Harness | 执行 tool,不选择产品形态 | +| `HarnessRegistryBuilder` | Execution | harness owner | Product Assembly | Agent Kernel 通过 registry contract、Product Feature | 注册 workflow provider,不执行 UI 展示 | +| `ExtensionHost` / OpenCode adapter | Extension | extension host / adapter | Product Assembly | Product Assembly、UI host、Execution provider registry | 产出 descriptor/provider,不写权威状态 | +| `RuntimeServicesBuilder` / platform providers | Cross-platform Adapter | services/adapters/app provider | Product Assembly | Kernel/Execution/Harness 通过 port handle | 实现边界外 I/O,不读取 product profile | | `SecurityDecisionPort` / `CapabilityEffectPolicy` | Stable Contracts and Security Control Plane | 注入式策略 owner | Product Assembly | Kernel、Execution、Extension、UI projection | 决策可审计,模型/插件不能直接授权 | 典型调用链: @@ -370,10 +403,12 @@ Product Assembly 注入,通过稳定 port 供 Kernel、Execution、Extension 1. 产品入口选择 `DeliveryProfile` 和 feature bundle。 2. Product Assembly 创建或接收 concrete providers,并构建 `RuntimeServices`、tool registry、harness registry、 extension host、UI contribution registry 和 security policy。 -3. Product Feature 把 command、settings 和 UI contribution 映射为 kernel / execution request 或稳定 descriptor。 +3. Product Feature 把 command、settings 和 UI contribution 映射为 kernel request、feature policy 或稳定 descriptor。 4. Agent Kernel 消费 typed runtime parts 和稳定 contract,产生 event、permission request、tool request 和 task facts。 -5. Execution 处理 tool/harness/sandbox/MCP tool bridge,具体 I/O 通过 Cross-platform Adapter 完成。 -6. Extension 只产出 descriptor、provider 和 candidate effects;最终授权、状态写入和审计仍走安全控制面。 +5. Execution 通过 execution contract 处理 tool/harness/sandbox/MCP tool bridge;具体 I/O 只通过被注入的 platform + port 完成。 +6. Platform adapter 实现 platform/provider ports 并调用边界外资源;它不读取产品 profile,也不决定产品能力。 +7. Extension 只产出 descriptor、provider 和 candidate effects;最终授权、状态写入和审计仍走安全控制面。 ## 8. 安全与鲁棒性约束 @@ -395,9 +430,11 @@ Product Assembly 注入,通过稳定 port 供 Kernel、Execution、Extension | 新 Agent Kernel 膨胀为新的巨型 core | Kernel 只拥有平台无关 Agent 状态机;feature、tool concrete、platform adapter 和 UI 扩展必须留在对应层 | | 产品特性下沉到内核 | 用 feature pack / capability matrix 表达 `/goal`、MiniApp、DeepReview 等产品功能;内核只暴露通用能力 | | Product Assembly 变成全局状态中心 | assembly 只做构建期注册,输出不可变 runtime parts;产品状态归 surface、feature 或 kernel owner | +| Product Assembly 之外的层直接依赖 concrete provider | 将 concrete provider 依赖限制在组装边界;普通调用方只消费 typed port、descriptor 或 stable contract | | Extension Host 绕过安全边界 | 插件只能产出候选效果和 contribution;授权、审计和状态写入由内核事实与安全控制面决定 | | UI API 和 Rust API 分裂 | Product API 必须同时定义 Rust Kernel API 与 UI Extension Contract,并由 assembly 统一注册 | -| Adapter / Services 边界继续模糊 | 协议/provider translation 和 OS/service implementation 都归跨平台适配边界;不得拥有产品能力选择 | +| Adapter / Services 边界继续模糊 | 协议/provider translation 和 OS/service implementation 都属于平台/provider adapter 实现边界;不得拥有产品能力选择 | +| 外部系统被误建模为底层依赖层 | 外部资源只在 adapter I/O 边界出现;架构图和依赖规则不得要求 Kernel、Execution、Feature 直接依赖外部系统 | | 多产品形态能力漂移 | capability matrix、unsupported/unavailable contract、Desktop/CLI/Web/SDK/ACP/Remote 验证矩阵同步维护 | | 权限、工具、MCP、ACP 语义迁移后不等价 | 保留兼容 facade,补 manifest snapshot、permission decision、event mapping 和 product shape focused tests | | 抽象只增不替换 owner | 设计不能只新增 facade 或空接口;新 owner 必须能承接真实职责,并让旧主体路径退化为兼容入口 | diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 2feb3f133..1a80284d7 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -22,7 +22,7 @@ - `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、workspace search concrete owner、remote SSH/SFTP/PTY owner、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO。 - `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy 和 provider-entry materialization;core 只保留 Computer Use 旧 public path re-export / compatibility shim 与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、process manager / host adapter 调用、read-state adapter、remote shell/FS 调用和旧工具入口。 -- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、thread-goal facts、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation / user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 +- `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 - `harness` 已建立 descriptor、route plan 和 legacy provider registry。 - `product-domains` 已承接 MiniApp state/workflow planning、compile / permission adaptation、import lifecycle、AI / Agent permission、rate-limit、model/message/session/workspace/turn-text bridge rules、AI / Agent 请求计划、stream / runtime event payload、worker restart / draft key / workspace input 规则、function-agent prompt/parser/response policy 和部分 Git snapshot/fallback 逻辑。 - `bitfun-core` 的 function-agent AI concrete acquisition 已从旧 `runtime_services` 路径收拢到明确的 core port adapter;Git / AI compatibility re-export 仍保留旧 public path。 @@ -36,7 +36,7 @@ - owner crate 不得依赖回 `bitfun-core`。 - `product-full` 保持完整产品能力集合。 -- boundary check 覆盖 owner crate 禁止依赖、旧路径 facade-only、feature gate、six-layer path 解析、Product Assembly 收口和高风险 owner 回流。 +- boundary check 覆盖 owner crate 禁止依赖、旧路径 facade-only、feature gate、six-layer path 解析、Product Assembly 收口、session/config/context fact owner、tool confirmation gate owner 和高风险 owner 回流。 - focused tests 覆盖当前 delivery profile 能力裁剪、ProductAssembler 缺失 service 报告、无直接 core 入口的空 capability plan、SDK fake provider / services / tool / harness / hook / workspace-scoped agent registry 闭环,以及 runtime hook 顺序、timeout、错误策略和重复 id 拦截。 - focused baseline 覆盖 tool manifest、GetToolSpec、execution admission、workspace search、remote workspace fallback、MCP config/catalog、prompt cache、custom agent / mode / subagent、thread-goal tools、AskUserQuestion、DeepReview policy、tool confirmation、session restore、MiniApp storage/builtin/import、function-agent Git、scheduled-job state 等路径。 - H4 已完成 Agent Runtime SDK 发布准备的 workspace 内收口:`sdk` facade 暴露 v1 preview 兼容元数据、空默认 feature、稳定注入 registry/service 类型、最小外部 embedder 示例,以及 boundary required rules / self-test 保护。 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 7f2ca5fa8..991fdd220 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -10,6 +10,8 @@ - `bitfun-core` 最终收敛为 compatibility facade、`product-full` 组装边界和少量迁移期 adapter。 - 迁移按概念 owner 判断:Product Feature、Agent Kernel、Execution、Extension、Cross-platform Adapter、Stable Contracts。 +- 外部系统不是 owner 层级;OS、Git、MCP server、AI provider、remote host、browser runtime 和 plugin package 只在 + adapter I/O 边界出现。除 Product Assembly 外,调用方应依赖 port、descriptor 或 stable contract,而不是 concrete provider。 - 新抽象必须同步删除、迁移或显著简化旧 core 主体路径;纯 facade、纯 guard、纯文档或空接口不算完成。 - 产品特性和内核能力必须分开:长程任务、调度、权限、上下文、session/workspace、memory、DFX、hook/event 属于内核;`/goal`、UI、settings、命令和默认策略属于产品特性。 - Product API 同时覆盖 Rust Kernel API、UI Extension Contract 和 Capability/Effect API;不能把所有能力堆进单一后端 API。 @@ -24,7 +26,7 @@ - Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立;部分 concrete 生命周期仍由 core concrete manager 或产品命令路径持有。 - Custom agent / mode / skill、Agent lifecycle、tool side-effect、Computer Use、file tool、MiniApp、DeepReview、DeepResearch、remote-connect、workspace search、remote SSH/SFTP/PTY 等多批 provider-neutral 或 concrete owner 已迁出。 - Root boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件、custom agent owner / custom subagent wrapper 保护和重点 feature gate。 -- Agent Runtime session workspace resolution、Cron / SessionControl / SessionMessage / SessionHistory 的 target session/workspace owner routing、`/goal` tool management runtime-port routing,以及 `services-integrations` workspace search preview/result conversion 已纳入已完成摘要;后续计划只保留仍需迁移的 feature/kernel、security/control-plane、execution、extension 和 cross-platform adapter 主体工作。 +- Agent Runtime session workspace resolution、Cron / SessionControl / SessionMessage / SessionHistory 的 target session/workspace owner routing、`/goal` tool management runtime-port routing、session/config/context/lifecycle fact owner 收口,以及 `services-integrations` workspace search preview/result conversion 已纳入已完成摘要;后续计划只保留仍需迁移的 feature/kernel、security/control-plane、execution、extension 和 cross-platform adapter 主体工作。 ## 3. 后续大块 PR 节奏 @@ -47,7 +49,7 @@ 目标: -- 收敛 Kernel API 的 builder / runner / event stream / permission / session / workspace / memory / DFX contract。 +- 继续收敛 Kernel API 的 builder / runner / event stream / permission / session / workspace / memory / DFX contract;session/config/summary facts、persisted session state sidecar、dialog-turn facts、side-question runtime tracking、context profile policy 和 round-level tool confirmation gate 已完成 owner 收口。 - 把安全控制面所需的 capability/effect/security decision facts 下沉为稳定 contract 或 kernel facts,而不是散落在 UI、tool、MCP、hook 和 plugin 路径。 - 迁移或显著简化仍在 core 中的 provider-neutral scheduler、permission coordination、event routing、memory/context 或 long-running task 事实路径。 @@ -89,6 +91,7 @@ - 收口 filesystem、network、process/thread/time、terminal、remote、Git、MCP transport、AI/provider protocol、browser/desktop automation 的 adapter/provider 边界。 - 进一步移除 `bitfun-core` 对 OS/provider concrete 的直接依赖。 - 建立 Desktop、CLI、Web、ACP、Remote、SDK 的最小能力矩阵和验证口径。 +- 确认 Kernel、Execution、Extension、Product Feature 不直接依赖 platform concrete;具体 provider 只由 Product Assembly 注册。 保护: diff --git a/scripts/core-boundaries/rules/source/facade-rules.mjs b/scripts/core-boundaries/rules/source/facade-rules.mjs index 512aefa2f..8a9b9e859 100644 --- a/scripts/core-boundaries/rules/source/facade-rules.mjs +++ b/scripts/core-boundaries/rules/source/facade-rules.mjs @@ -1,6 +1,21 @@ // Boundary rules for source ownership, facades, and required owner content. export const facadeOnlyFiles = [ + { + path: 'src/crates/assembly/core/src/agentic/context_profile.rs', + importPrefix: 'bitfun_agent_runtime::context_profile', + reason: 'core context profile path must only re-export the agent-runtime owner crate', + }, + { + path: 'src/crates/assembly/core/src/agentic/core/dialog_turn.rs', + importPrefix: 'bitfun_agent_runtime::dialog_turn', + reason: 'core dialog-turn facts path must only re-export the agent-runtime owner crate', + }, + { + path: 'src/crates/assembly/core/src/agentic/core/session.rs', + importPrefix: 'bitfun_agent_runtime::session', + reason: 'core session facts path must only re-export the agent-runtime owner crate', + }, { path: 'src/crates/assembly/core/src/infrastructure/filesystem/mod.rs', importPrefix: 'bitfun_services_core::filesystem', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 4753a5acf..82e90a436 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -345,6 +345,133 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/agent-runtime/src/context_profile.rs', + reason: + 'agent-runtime must own provider-neutral context profile and model capability policy facts', + patterns: [ + { + regex: /\bpub enum ContextProfile\b/, + message: 'missing context profile fact', + }, + { + regex: /\bpub enum ModelCapabilityProfile\b/, + message: 'missing model capability profile fact', + }, + { + regex: /\bpub struct ContextProfilePolicy\b/, + message: 'missing context profile policy fact', + }, + { + regex: /\bfor_subagent_context_and_models\b/, + message: 'missing subagent context/model policy helper', + }, + { + regex: /\bmodel_capability_weak_for_mini\b/, + message: 'missing weak-model regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/session_state.rs', + reason: + 'agent-runtime must own provider-neutral session state facts and event-label projection', + patterns: [ + { + regex: /\bpub enum SessionState\b/, + message: 'missing session state fact', + }, + { + regex: /\bpub enum ProcessingPhase\b/, + message: 'missing processing phase fact', + }, + { + regex: /\bdialog_state_fact\b/, + message: 'missing session state fact projection', + }, + { + regex: /\bsession_state_label_for_state\b/, + message: 'missing session state label projection', + }, + { + regex: /\bprocessing_state_serialization_stays_compatible\b/, + message: 'missing session state serialization regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/session.rs', + reason: + 'agent-runtime must own provider-neutral session config, summary, and persisted state facts', + patterns: [ + { + regex: /\bpub struct Session\b/, + message: 'missing session fact', + }, + { + regex: /\bpub struct SessionConfig\b/, + message: 'missing session config fact', + }, + { + regex: /\bpub struct SessionSummary\b/, + message: 'missing session summary fact', + }, + { + regex: /\bpub use bitfun_core_types::SessionKind\b/, + message: 'missing session kind compatibility export', + }, + { + regex: /\bpub struct PersistedSessionStateFile\b/, + message: 'missing persisted session state sidecar fact', + }, + { + regex: /\bsanitize_persisted_session_state\b/, + message: 'missing persisted session state sanitization owner', + }, + { + regex: /\bpersisted_session_state_file_shape_stays_compatible\b/, + message: 'missing persisted session state wire-shape regression', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/dialog_turn.rs', + reason: + 'agent-runtime must own provider-neutral dialog-turn id and statistics facts', + patterns: [ + { + regex: /\bpub fn new_turn_id\b/, + message: 'missing dialog-turn id helper', + }, + { + regex: /\bpub struct TurnStats\b/, + message: 'missing dialog-turn statistics fact', + }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/side_question.rs', + reason: + 'agent-runtime must own runtime-only side-question cancellation and active-turn tracking', + patterns: [ + { + regex: /\bpub struct SideQuestionRuntime\b/, + message: 'missing side-question runtime owner', + }, + { + regex: /\bpub struct ActiveBtwTurn\b/, + message: 'missing active /btw turn fact', + }, + { + regex: /\bregister_btw_turn\b/, + message: 'missing active /btw turn registration', + }, + { + regex: /\bregistering_same_request_cancels_previous_token\b/, + message: 'missing side-question cancellation regression', + }, + ], + }, { path: 'src/crates/execution/agent-runtime/examples/sdk_minimal.rs', reason: @@ -1240,6 +1367,14 @@ export const requiredContentRules = [ regex: /\bpub struct ToolConfirmationRequestFacts\b/, message: 'missing tool confirmation request facts', }, + { + regex: /\bpub struct ToolConfirmationGateFacts\b/, + message: 'missing tool confirmation gate facts', + }, + { + regex: /\bpub enum ToolConfirmationGatePlan\b/, + message: 'missing tool confirmation gate plan', + }, { regex: /\bpub enum ToolConfirmationPlan\b/, message: 'missing tool confirmation plan contract', @@ -1268,6 +1403,10 @@ export const requiredContentRules = [ regex: /\bpub fn resolve_tool_confirmation_plan\b/, message: 'missing tool confirmation plan resolver', }, + { + regex: /\bpub fn resolve_tool_confirmation_gate\b/, + message: 'missing tool confirmation gate resolver', + }, { regex: /\bpub fn resolve_confirmation_failure\b/, message: 'missing tool confirmation failure resolver', @@ -1314,6 +1453,14 @@ export const requiredContentRules = [ regex: /\bconfirmation_plan_requires_permission_only_when_both_flags_are_true\b/, message: 'missing tool confirmation gate regression', }, + { + regex: /\bconfirmation_gate_preserves_skip_policy_precedence\b/, + message: 'missing tool confirmation skip-policy regression', + }, + { + regex: /\bconfirmation_gate_requires_confirmation_only_for_permissioned_tools\b/, + message: 'missing tool confirmation permissioned-tool regression', + }, { regex: /\bconfirmation_plan_preserves_legacy_no_timeout_one_year_deadline\b/, message: 'missing tool confirmation no-timeout regression', @@ -2226,7 +2373,7 @@ export const requiredContentRules = [ 'core event types must preserve legacy import path while agent-runtime owns session-state labels', patterns: [ { - regex: /bitfun_agent_runtime::events::session_state_label/, + regex: /bitfun_agent_runtime::session_state::session_state_label_for_state/, message: 'missing session-state label owner delegation', }, ], diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index a561b603a..c38051f31 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1072,6 +1072,51 @@ export function runManifestParserSelfTest({ path: 'src/crates/execution/agent-runtime/Cargo.toml', contracts: ['[features]', 'default = []'], }, + { + path: 'src/crates/execution/agent-runtime/src/context_profile.rs', + contracts: [ + 'ContextProfile', + 'ModelCapabilityProfile', + 'ContextProfilePolicy', + 'for_subagent_context_and_models', + 'model_capability_weak_for_mini', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/session_state.rs', + contracts: [ + 'SessionState', + 'ProcessingPhase', + 'dialog_state_fact', + 'session_state_label_for_state', + 'processing_state_serialization_stays_compatible', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/session.rs', + contracts: [ + 'Session', + 'SessionConfig', + 'SessionSummary', + 'SessionKind', + 'PersistedSessionStateFile', + 'sanitize_persisted_session_state', + 'persisted_session_state_file_shape_stays_compatible', + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/dialog_turn.rs', + contracts: ['new_turn_id', 'TurnStats'], + }, + { + path: 'src/crates/execution/agent-runtime/src/side_question.rs', + contracts: [ + 'SideQuestionRuntime', + 'ActiveBtwTurn', + 'register_btw_turn', + 'registering_same_request_cancels_previous_token', + ], + }, { path: 'src/crates/execution/agent-runtime/tests/sdk_smoke.rs', contracts: [ @@ -1203,12 +1248,15 @@ export function runManifestParserSelfTest({ path: 'src/crates/execution/agent-runtime/src/tool_confirmation.rs', contracts: [ 'ToolConfirmationRequestFacts', + 'ToolConfirmationGateFacts', + 'ToolConfirmationGatePlan', 'ToolConfirmationPlan', 'ToolConfirmationOutcome', 'ToolConfirmationWaitResult', 'ToolConfirmationResponse', 'ToolConfirmationChannelStore', 'ConfirmationFailureKind', + 'resolve_tool_confirmation_gate', 'resolve_tool_confirmation_plan', 'resolve_confirmation_failure', 'resolve_confirmation_wait_result', @@ -1241,6 +1289,8 @@ export function runManifestParserSelfTest({ { path: 'src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs', contracts: [ + 'confirmation_gate_preserves_skip_policy_precedence', + 'confirmation_gate_requires_confirmation_only_for_permissioned_tools', 'confirmation_plan_requires_permission_only_when_both_flags_are_true', 'confirmation_plan_preserves_legacy_no_timeout_one_year_deadline', 'confirmation_failure_mapping_preserves_legacy_reasons_and_errors', @@ -1527,7 +1577,9 @@ export function runManifestParserSelfTest({ }, { path: 'src/crates/assembly/core/src/agentic/events/types.rs', - contracts: ['bitfun_agent_runtime::events::session_state_label'], + contracts: [ + 'bitfun_agent_runtime::session_state::session_state_label_for_state', + ], }, { path: 'src/crates/assembly/core/src/agentic/agents/prompt_builder/user_context.rs', diff --git a/src/crates/assembly/core/src/agentic/context_profile.rs b/src/crates/assembly/core/src/agentic/context_profile.rs index 3b108af97..4a83b60c9 100644 --- a/src/crates/assembly/core/src/agentic/context_profile.rs +++ b/src/crates/assembly/core/src/agentic/context_profile.rs @@ -1,328 +1,5 @@ -//! Adaptive context profile policy. -//! -//! Profiles keep context behavior aligned with the shape of the agent workload -//! without exposing more knobs to the UI. +//! Compatibility facade for Agent Kernel context profile policy. -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContextProfile { - LongTask, - Conversation, -} - -impl ContextProfile { - pub fn for_agent_type(agent_type: &str) -> Self { - Self::for_agent_context(agent_type, false) - } - - pub fn for_agent_context(agent_type: &str, is_review_subagent: bool) -> Self { - if is_review_subagent || is_long_task_agent(agent_type) { - Self::LongTask - } else { - Self::Conversation - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModelCapabilityProfile { - Standard, - Weak, -} - -impl ModelCapabilityProfile { - pub fn from_model_id(model_id: Option<&str>) -> Self { - let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) else { - return Self::Standard; - }; - let normalized = model_id.to_ascii_lowercase(); - if matches!(normalized.as_str(), "auto" | "fast" | "primary") { - return Self::Standard; - } - - // Weak model detection: match suffix-based markers (e.g., "gpt-4o-mini", - // "gemini-1.5-flash") and exact markers (e.g., "haiku", "mini"). - // Avoid false positives from substring matches (e.g., "gemini-pro" should - // NOT match "mini" inside "gemini"). - let weak_suffixes = ["-haiku", "-mini", "-small", "-lite", "-flash", "-nano"]; - let weak_exact = ["haiku", "mini", "small", "lite", "flash", "nano"]; - // Also match known weak model name patterns where the marker appears - // mid-string but is a genuine weak model (e.g., "claude-3-haiku-20240307"). - let weak_mid_patterns = [ - "-haiku-", "-mini-", "-small-", "-lite-", "-flash-", "-nano-", - ]; - if weak_suffixes.iter().any(|s| normalized.ends_with(s)) - || weak_exact.iter().any(|e| normalized == *e) - || weak_mid_patterns.iter().any(|p| normalized.contains(p)) - { - Self::Weak - } else { - Self::Standard - } - } - - pub fn from_resolved_model(resolved_model_id: &str, provider_model_name: &str) -> Self { - let resolved = Self::from_model_id(Some(resolved_model_id)); - if resolved == Self::Weak { - resolved - } else { - Self::from_model_id(Some(provider_model_name)) - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct ContextProfilePolicy { - pub profile: ContextProfile, - pub compression_contract_limit: usize, - pub subagent_concurrency_cap: usize, - pub repeated_tool_signature_threshold: usize, - pub consecutive_failed_command_threshold: usize, -} - -impl ContextProfilePolicy { - pub fn for_agent_context( - agent_type: &str, - is_review_subagent: bool, - model_capability: ModelCapabilityProfile, - ) -> Self { - let profile = ContextProfile::for_agent_context(agent_type, is_review_subagent); - let mut policy = match profile { - ContextProfile::LongTask => Self::long_task(), - ContextProfile::Conversation => Self::conversation(), - }; - - if model_capability == ModelCapabilityProfile::Weak { - policy.apply_weak_model_override(); - } - - policy - } - - pub fn for_agent_context_and_model( - agent_type: &str, - is_review_subagent: bool, - resolved_model_id: &str, - provider_model_name: &str, - ) -> Self { - Self::for_agent_context( - agent_type, - is_review_subagent, - ModelCapabilityProfile::from_resolved_model(resolved_model_id, provider_model_name), - ) - } - - pub fn for_subagent_context_and_models( - agent_type: &str, - is_review_subagent: bool, - subagent_model_id: Option<&str>, - parent_agent_type: Option<&str>, - parent_is_review_subagent: bool, - parent_model_id: Option<&str>, - ) -> Self { - let child_profile = ContextProfile::for_agent_context(agent_type, is_review_subagent); - let parent_profile = parent_agent_type - .map(|agent_type| { - ContextProfile::for_agent_context(agent_type, parent_is_review_subagent) - }) - .unwrap_or(ContextProfile::Conversation); - let profile = if child_profile == ContextProfile::LongTask - || parent_profile == ContextProfile::LongTask - { - ContextProfile::LongTask - } else { - ContextProfile::Conversation - }; - let model_capability = subagent_model_id - .map(str::trim) - .filter(|model_id| !model_id.is_empty()) - .map(|model_id| ModelCapabilityProfile::from_model_id(Some(model_id))) - .or_else(|| { - parent_model_id - .map(str::trim) - .filter(|model_id| !model_id.is_empty()) - .map(|model_id| ModelCapabilityProfile::from_model_id(Some(model_id))) - }) - .unwrap_or(ModelCapabilityProfile::Standard); - - let mut policy = match profile { - ContextProfile::LongTask => Self::long_task(), - ContextProfile::Conversation => Self::conversation(), - }; - if model_capability == ModelCapabilityProfile::Weak { - policy.apply_weak_model_override(); - } - policy - } - - pub fn effective_subagent_max_concurrency(&self, configured: usize) -> usize { - configured.clamp(1, self.subagent_concurrency_cap) - } - - pub fn effective_loop_threshold(&self, configured: usize) -> usize { - configured - .max(1) - .min(self.repeated_tool_signature_threshold.max(1)) - } - - pub fn has_repeated_tool_loop(&self, repeated_tool_signature_count: usize) -> bool { - repeated_tool_signature_count >= self.repeated_tool_signature_threshold.max(1) - } - - pub fn has_consecutive_command_failure_loop(&self, consecutive_failed_commands: usize) -> bool { - consecutive_failed_commands >= self.consecutive_failed_command_threshold.max(1) - } - - fn long_task() -> Self { - Self { - profile: ContextProfile::LongTask, - compression_contract_limit: 8, - subagent_concurrency_cap: 5, - repeated_tool_signature_threshold: 3, - consecutive_failed_command_threshold: 2, - } - } - - fn conversation() -> Self { - Self { - profile: ContextProfile::Conversation, - compression_contract_limit: 4, - subagent_concurrency_cap: 2, - repeated_tool_signature_threshold: 4, - consecutive_failed_command_threshold: 3, - } - } - - fn apply_weak_model_override(&mut self) { - self.compression_contract_limit = self.compression_contract_limit.min(4); - self.subagent_concurrency_cap = self.subagent_concurrency_cap.min(2); - self.repeated_tool_signature_threshold = self.repeated_tool_signature_threshold.min(2); - self.consecutive_failed_command_threshold = - self.consecutive_failed_command_threshold.min(2); - } -} - -fn is_long_task_agent(agent_type: &str) -> bool { - matches!( - agent_type, - "agentic" | "Multitask" | "DeepReview" | "DeepResearch" | "ComputerUse" | "Team" - ) || agent_type.starts_with("Review") -} - -#[cfg(test)] -mod tests { - use super::ModelCapabilityProfile; - - #[test] - fn model_capability_standard_for_empty_or_none() { - assert_eq!( - ModelCapabilityProfile::from_model_id(None), - ModelCapabilityProfile::Standard - ); - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("")), - ModelCapabilityProfile::Standard - ); - assert_eq!( - ModelCapabilityProfile::from_model_id(Some(" ")), - ModelCapabilityProfile::Standard - ); - } - - #[test] - fn model_capability_standard_for_strong_models() { - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("gpt-4o")), - ModelCapabilityProfile::Standard - ); - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("claude-sonnet-4")), - ModelCapabilityProfile::Standard - ); - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("gemini-pro")), - ModelCapabilityProfile::Standard - ); - } - - #[test] - fn model_capability_weak_for_haiku() { - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("claude-3-haiku-20240307")), - ModelCapabilityProfile::Weak - ); - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("anthropic/claude-3-haiku")), - ModelCapabilityProfile::Weak - ); - } - - #[test] - fn model_capability_weak_for_mini() { - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("gpt-4o-mini")), - ModelCapabilityProfile::Weak - ); - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("openai/gpt-4o-mini")), - ModelCapabilityProfile::Weak - ); - } - - #[test] - fn model_capability_weak_for_flash() { - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("gemini-1.5-flash")), - ModelCapabilityProfile::Weak - ); - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("google/gemini-flash")), - ModelCapabilityProfile::Weak - ); - } - - #[test] - fn model_capability_weak_for_lite() { - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("qwen-lite")), - ModelCapabilityProfile::Weak - ); - } - - #[test] - fn model_capability_weak_for_small() { - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("llama-small")), - ModelCapabilityProfile::Weak - ); - } - - #[test] - fn model_capability_weak_for_nano() { - assert_eq!( - ModelCapabilityProfile::from_model_id(Some("gemini-nano")), - ModelCapabilityProfile::Weak - ); - } - - #[test] - fn model_capability_from_resolved_model_prefers_resolved() { - // resolved is weak → returns weak regardless of provider name - assert_eq!( - ModelCapabilityProfile::from_resolved_model("gpt-4o-mini", "gpt-4o"), - ModelCapabilityProfile::Weak - ); - // resolved is standard, provider is weak → returns weak - assert_eq!( - ModelCapabilityProfile::from_resolved_model("gpt-4o", "gpt-4o-mini"), - ModelCapabilityProfile::Weak - ); - // both standard → returns standard - assert_eq!( - ModelCapabilityProfile::from_resolved_model("gpt-4o", "claude-sonnet"), - ModelCapabilityProfile::Standard - ); - } -} +pub use bitfun_agent_runtime::context_profile::{ + ContextProfile, ContextProfilePolicy, ModelCapabilityProfile, +}; diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 29883935f..150ea8402 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -429,9 +429,7 @@ impl DialogScheduler { fn session_state_fact(state: Option<&SessionState>) -> DialogSessionStateFact { match state { None => DialogSessionStateFact::Missing, - Some(SessionState::Idle) => DialogSessionStateFact::Idle, - Some(SessionState::Processing { .. }) => DialogSessionStateFact::Processing, - Some(SessionState::Error { .. }) => DialogSessionStateFact::Error, + Some(state) => state.dialog_state_fact(), } } diff --git a/src/crates/assembly/core/src/agentic/core/dialog_turn.rs b/src/crates/assembly/core/src/agentic/core/dialog_turn.rs index 54add2e67..c43cfb41c 100644 --- a/src/crates/assembly/core/src/agentic/core/dialog_turn.rs +++ b/src/crates/assembly/core/src/agentic/core/dialog_turn.rs @@ -1,25 +1,3 @@ -//! Dialog turn helpers and statistics types. -//! -//! Historical note: this module used to define `DialogTurn` and -//! `DialogTurnState` structs that were never persisted nor read back — -//! the actual on-disk shape lives in `service::session::DialogTurnData`, -//! and turn lifecycle state is tracked through `SessionState::Processing` -//! and `TurnStatus`. The orphan structs were removed; only `TurnStats` -//! and a small id-helper survive because they are still referenced by -//! `SessionManager::complete_dialog_turn` and friends. +//! Compatibility facade for provider-neutral dialog-turn facts. -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -/// Generate a fresh turn id when callers do not supply one. -pub fn new_turn_id(provided: Option) -> String { - provided.unwrap_or_else(|| Uuid::new_v4().to_string()) -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TurnStats { - pub total_rounds: usize, - pub total_tools: usize, - pub total_tokens: usize, - pub duration_ms: u64, -} +pub use bitfun_agent_runtime::dialog_turn::{new_turn_id, TurnStats}; diff --git a/src/crates/assembly/core/src/agentic/core/mod.rs b/src/crates/assembly/core/src/agentic/core/mod.rs index 11ec7af20..6fecbd7cc 100644 --- a/src/crates/assembly/core/src/agentic/core/mod.rs +++ b/src/crates/assembly/core/src/agentic/core/mod.rs @@ -11,7 +11,6 @@ pub use bitfun_agent_runtime::prompt_markup::{ has_prompt_markup, is_system_reminder_only, render_system_reminder, render_user_query, strip_prompt_markup, PromptBlock, PromptBlockKind, PromptEnvelope, }; -pub use bitfun_core_types::SessionKind; pub use dialog_turn::{new_turn_id, TurnStats}; pub use message::{ CompressedMessage, CompressedMessageRole, CompressedTodoItem, CompressedTodoSnapshot, @@ -20,5 +19,8 @@ pub use message::{ MessageSemanticKind, ToolCall, ToolResult, }; pub use messages_helper::{MessageHelper, RequestReasoningTokenPolicy}; -pub use session::{CompressionState, Session, SessionConfig, SessionSummary}; +pub use session::{ + sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, + SessionConfig, SessionKind, SessionSummary, +}; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/assembly/core/src/agentic/core/session.rs b/src/crates/assembly/core/src/agentic/core/session.rs index b677c0a1a..aed956149 100644 --- a/src/crates/assembly/core/src/agentic/core/session.rs +++ b/src/crates/assembly/core/src/agentic/core/session.rs @@ -1,212 +1,6 @@ -use super::state::SessionState; -pub use bitfun_core_types::SessionKind; -use serde::{Deserialize, Serialize}; -use std::time::SystemTime; -use uuid::Uuid; +//! Compatibility facade for runtime-owned session facts. -// ============ Session ============ - -/// Session: contains multiple dialog turns -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Session { - pub session_id: String, - pub session_name: String, - /// Current/default mode selection for the session. - /// - /// This is the mode the next dialog turn should run with by default. It is - /// not required to match either the last surviving history turn or the last - /// message submission accepted by the scheduler. - pub agent_type: String, - /// Cached mode of the last surviving user dialog turn in history. - /// - /// Reminder builders use this value for `previous_agent_type` so - /// first-entry vs ongoing mode prompts follow the surviving transcript - /// after rollbacks or turn truncation. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_user_dialog_agent_type: Option, - /// Mode of the most recent user submission accepted by the scheduler. - /// - /// Unlike `last_user_dialog_agent_type`, this value is not rewound by - /// history rollback. It tracks session-level prompt-cache compatibility for - /// the next accepted submission. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_submitted_agent_type: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - alias = "created_by", - alias = "createdBy" - )] - pub created_by: Option, - #[serde(default, alias = "session_kind", alias = "sessionKind")] - pub kind: SessionKind, - - /// Associated resources - #[serde( - skip_serializing_if = "Option::is_none", - alias = "sandbox_session_id", - alias = "sandboxSessionId" - )] - pub snapshot_session_id: Option, - - /// Dialog turn ID list - pub dialog_turn_ids: Vec, - - /// Session state - pub state: SessionState, - - /// Configuration - pub config: SessionConfig, - - /// Context compression related - pub compression_state: CompressionState, - - /// Lifecycle - pub created_at: SystemTime, - pub updated_at: SystemTime, - pub last_activity_at: SystemTime, -} - -/// Context compression state -#[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct CompressionState { - /// Time of last compression - pub last_compression_at: Option, - /// Compression trigger count - pub compression_count: usize, -} - -impl CompressionState { - pub fn increment_compression_count(&mut self) { - self.last_compression_at = Some(SystemTime::now()); - self.compression_count += 1; - } -} - -impl Session { - pub fn new(session_name: String, agent_type: String, config: SessionConfig) -> Self { - let now = SystemTime::now(); - Self { - session_id: Uuid::new_v4().to_string(), - session_name, - agent_type, - last_user_dialog_agent_type: None, - last_submitted_agent_type: None, - created_by: None, - kind: SessionKind::Standard, - snapshot_session_id: None, - dialog_turn_ids: vec![], - state: SessionState::Idle, - config, - compression_state: CompressionState::default(), - created_at: now, - updated_at: now, - last_activity_at: now, - } - } - - pub fn new_with_id( - session_id: String, - session_name: String, - agent_type: String, - config: SessionConfig, - ) -> Self { - let now = SystemTime::now(); - Self { - session_id, - session_name, - agent_type, - last_user_dialog_agent_type: None, - last_submitted_agent_type: None, - created_by: None, - kind: SessionKind::Standard, - snapshot_session_id: None, - dialog_turn_ids: vec![], - state: SessionState::Idle, - config, - compression_state: CompressionState::default(), - created_at: now, - updated_at: now, - last_activity_at: now, - } - } -} - -/// Session configuration -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionConfig { - pub max_context_tokens: usize, - pub auto_compact: bool, - pub enable_tools: bool, - pub safe_mode: bool, - pub max_turns: usize, - pub enable_context_compression: bool, - /// Compression threshold (token usage rate), compression triggered when exceeded - pub compression_threshold: f32, - /// Workspace path bound to this session. Used to run AI in the correct workspace - /// 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 - /// `workspace_path` on different hosts (e.g. two `/` roots). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_connection_id: Option, - /// SSH config `host` for locating `~/.bitfun/remote_ssh/{host}/.../sessions` when disconnected. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub remote_ssh_host: Option, - /// Model config ID used by this session (for token usage tracking) - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_id: Option, -} - -impl Default for SessionConfig { - fn default() -> Self { - Self { - max_context_tokens: 128128, - auto_compact: true, - enable_tools: true, - safe_mode: true, - max_turns: 200, - 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, - } - } -} - -/// Session summary (for list display) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionSummary { - pub session_id: String, - pub session_name: String, - /// Current/default mode selection for the session. - pub agent_type: String, - /// Mode of the last surviving user dialog turn in the session history. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_user_dialog_agent_type: Option, - /// Mode of the most recent user submission accepted by the scheduler. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_submitted_agent_type: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - alias = "created_by", - alias = "createdBy" - )] - pub created_by: Option, - #[serde(default, alias = "session_kind", alias = "sessionKind")] - pub kind: SessionKind, - pub turn_count: usize, - pub created_at: SystemTime, - pub last_activity_at: SystemTime, - pub state: SessionState, -} +pub use bitfun_agent_runtime::session::{ + sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, + SessionConfig, SessionKind, SessionSummary, +}; diff --git a/src/crates/assembly/core/src/agentic/core/state.rs b/src/crates/assembly/core/src/agentic/core/state.rs index f7c5ffa6d..5b846a03a 100644 --- a/src/crates/assembly/core/src/agentic/core/state.rs +++ b/src/crates/assembly/core/src/agentic/core/state.rs @@ -1,38 +1,12 @@ -//! State definitions +//! State definitions. //! -//! Defines session state, tool execution state, etc. +//! Keeps core-owned tool execution state and re-exports runtime-owned session state facts. use crate::agentic::tools::framework::ToolResult; +pub use bitfun_agent_runtime::session_state::{ProcessingPhase, SessionState}; use serde::{Deserialize, Serialize}; use std::time::SystemTime; -// ============ Session State (aligned with frontend) ============ - -/// Session state -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum SessionState { - Idle, - Processing { - current_turn_id: String, - phase: ProcessingPhase, - }, - Error { - error: String, - recoverable: bool, - }, -} - -/// Processing phase -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum ProcessingPhase { - Starting, // Starting - Compacting, // Context compaction - Thinking, // AI thinking - Streaming, // Streaming output - ToolCalling, // Tool calling - ToolConfirming, // Waiting for tool confirmation -} - // ============ Tool Execution State ============ /// Tool execution state diff --git a/src/crates/assembly/core/src/agentic/events/types.rs b/src/crates/assembly/core/src/agentic/events/types.rs index 687c69d4a..53c93d504 100644 --- a/src/crates/assembly/core/src/agentic/events/types.rs +++ b/src/crates/assembly/core/src/agentic/events/types.rs @@ -3,8 +3,7 @@ //! Uses bitfun-events layer event definitions, extending core-specific functionality here use crate::agentic::core::SessionState; -use bitfun_agent_runtime::events::session_state_label; -use bitfun_runtime_ports::DialogSessionStateFact; +use bitfun_agent_runtime::session_state::session_state_label_for_state; // ============ Re-export events layer types ============ pub use bitfun_events::agentic::ErrorCategory; @@ -26,10 +25,5 @@ pub type AgenticEvent = BaseAgenticEvent; /// Convert SessionState to String (for transmission) pub fn session_state_to_string(state: &SessionState) -> String { - let fact = match state { - SessionState::Idle => DialogSessionStateFact::Idle, - SessionState::Processing { .. } => DialogSessionStateFact::Processing, - SessionState::Error { .. } => DialogSessionStateFact::Error, - }; - session_state_label(fact).to_string() + session_state_label_for_state(state).to_string() } diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 5bef81b3e..0cc50bb49 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -23,6 +23,9 @@ use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; +use bitfun_agent_runtime::tool_confirmation::{ + resolve_tool_confirmation_gate, ToolConfirmationGateFacts, +}; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; use bitfun_ai_adapters::{ ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, ModelExchangeTraceConfig, @@ -774,25 +777,25 @@ impl RoundExecutor { .map(|v| v == "true") .unwrap_or(false); - let needs_confirm = if skip_confirmation || skip_from_context { + let any_tool_needs_permission = if skip_confirmation || skip_from_context { false } else { - // Otherwise judge based on tool's needs_permissions() let registry = get_global_tool_registry(); let tool_registry = registry.read().await; - let mut requires_permission = false; - - for tool_call in &stream_result.tool_calls { - if let Some(tool) = tool_registry.get_tool(&tool_call.tool_name) { - if tool.needs_permissions(Some(&tool_call.arguments)) { - requires_permission = true; - break; - } - } - } - requires_permission + stream_result.tool_calls.iter().any(|tool_call| { + tool_registry + .get_tool(&tool_call.tool_name) + .map(|tool| tool.needs_permissions(Some(&tool_call.arguments))) + .unwrap_or(false) + }) }; + let needs_confirm = resolve_tool_confirmation_gate(ToolConfirmationGateFacts { + global_skip_tool_confirmation: skip_confirmation, + context_skip_tool_confirmation: skip_from_context, + any_tool_needs_permission, + }) + .confirm_before_run(); (needs_confirm, exec_timeout, confirm_timeout, task_policy) }; diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 33980852d..629de15ce 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -3,7 +3,8 @@ //! Responsible for project-scoped session persistence. use crate::agentic::core::{ - strip_prompt_markup, CompressionState, Message, MessageContent, Session, SessionConfig, + sanitize_persisted_session_state, strip_prompt_markup, CompressionState, Message, + MessageContent, PersistedSessionStateFile as StoredSessionStateFile, Session, SessionConfig, SessionState, SessionSummary, }; use crate::agentic::session::{SessionPromptCache, PROMPT_CACHE_SCHEMA_VERSION}; @@ -63,24 +64,6 @@ struct ReadTurnPathsResult { max_turn_read_duration_ms: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] -struct StoredSessionStateFile { - schema_version: u32, - config: SessionConfig, - snapshot_session_id: Option, - // Derived runtime cache for reminder semantics. The source of truth lives - // on persisted dialog turns via `DialogTurnData.agent_type`. - #[serde(default, skip_serializing_if = "Option::is_none")] - last_user_dialog_agent_type: Option, - // Session-level prompt-cache guard state. This records the most recent user - // submission accepted by the scheduler and intentionally does not rewind on - // history rollback. - #[serde(default, skip_serializing_if = "Option::is_none")] - last_submitted_agent_type: Option, - compression_state: CompressionState, - runtime_state: SessionState, -} - #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionPromptCacheFile { schema_version: u32, @@ -597,13 +580,6 @@ impl PersistenceManager { } } - fn sanitize_runtime_state(state: &SessionState) -> SessionState { - match state { - SessionState::Processing { .. } => SessionState::Idle, - other => other.clone(), - } - } - async fn build_session_metadata( &self, workspace_path: &Path, @@ -1622,7 +1598,7 @@ impl PersistenceManager { last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), last_submitted_agent_type: session.last_submitted_agent_type.clone(), compression_state: session.compression_state.clone(), - runtime_state: Self::sanitize_runtime_state(&session.state), + runtime_state: sanitize_persisted_session_state(&session.state), }; self.save_stored_session_state(workspace_path, &session.session_id, &state) .await @@ -1668,7 +1644,7 @@ impl PersistenceManager { .unwrap_or_default(); let runtime_state = stored_state .as_ref() - .map(|value| Self::sanitize_runtime_state(&value.runtime_state)) + .map(|value| sanitize_persisted_session_state(&value.runtime_state)) .unwrap_or(SessionState::Idle); let created_at = Self::unix_ms_to_system_time(metadata.created_at); let last_activity_at = Self::unix_ms_to_system_time(metadata.last_active_at); @@ -1958,7 +1934,7 @@ impl PersistenceManager { runtime_state: SessionState::Idle, }); stored_state.schema_version = SESSION_STORAGE_SCHEMA_VERSION; - stored_state.runtime_state = Self::sanitize_runtime_state(state); + stored_state.runtime_state = sanitize_persisted_session_state(state); self.save_stored_session_state(workspace_path, session_id, &stored_state) .await } @@ -1986,7 +1962,7 @@ impl PersistenceManager { let state = self .load_stored_session_state(workspace_path, &metadata.session_id) .await? - .map(|value| Self::sanitize_runtime_state(&value.runtime_state)) + .map(|value| sanitize_persisted_session_state(&value.runtime_state)) .unwrap_or(SessionState::Idle); summaries.push(SessionSummary { diff --git a/src/crates/assembly/core/src/agentic/side_question.rs b/src/crates/assembly/core/src/agentic/side_question.rs index 8d29b27ea..a87d2fbdf 100644 --- a/src/crates/assembly/core/src/agentic/side_question.rs +++ b/src/crates/assembly/core/src/agentic/side_question.rs @@ -1,86 +1,7 @@ -//! Shared `/btw` helpers and runtime-only request tracking. +//! Shared `/btw` prompt helpers and compatibility exports for runtime tracking. use crate::agentic::core::{InternalReminderKind, Message}; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::Mutex; -use tokio_util::sync::CancellationToken; - -#[derive(Debug, Clone)] -pub struct SideQuestionRuntime { - tokens: Arc>>, - btw_turns: Arc>>, -} - -#[derive(Debug, Clone)] -pub struct ActiveBtwTurn { - pub session_id: String, - pub turn_id: String, -} - -impl Default for SideQuestionRuntime { - fn default() -> Self { - Self::new() - } -} - -impl SideQuestionRuntime { - pub fn new() -> Self { - Self { - tokens: Arc::new(Mutex::new(HashMap::new())), - btw_turns: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub async fn register(&self, request_id: String) -> CancellationToken { - let token = CancellationToken::new(); - - let old = { - let mut guard = self.tokens.lock().await; - guard.insert(request_id, token.clone()) - }; - if let Some(old) = old { - old.cancel(); - } - - token - } - - pub async fn cancel(&self, request_id: &str) { - let token = { - let guard = self.tokens.lock().await; - guard.get(request_id).cloned() - }; - if let Some(token) = token { - token.cancel(); - } - } - - pub async fn remove(&self, request_id: &str) { - { - let mut guard = self.tokens.lock().await; - guard.remove(request_id); - } - let mut btw_turns = self.btw_turns.lock().await; - btw_turns.remove(request_id); - } - - pub async fn register_btw_turn(&self, request_id: String, session_id: String, turn_id: String) { - let mut guard = self.btw_turns.lock().await; - guard.insert( - request_id, - ActiveBtwTurn { - session_id, - turn_id, - }, - ); - } - - pub async fn get_btw_turn(&self, request_id: &str) -> Option { - let guard = self.btw_turns.lock().await; - guard.get(request_id).cloned() - } -} +pub use bitfun_agent_runtime::side_question::{ActiveBtwTurn, SideQuestionRuntime}; pub fn btw_system_reminder() -> &'static str { r#"This is a side question from the user. You must answer this question directly. diff --git a/src/crates/execution/AGENTS.md b/src/crates/execution/AGENTS.md index 1e69197ee..d9ab0929f 100644 --- a/src/crates/execution/AGENTS.md +++ b/src/crates/execution/AGENTS.md @@ -12,7 +12,7 @@ delivery form. | Crate | Responsibility | Local doc | |---|---|---| -| `agent-runtime` | Agent registry, scheduler, prompt cache, hooks, goals, prompt facts, port-backed `AgentRuntime` facade, DeepReview provider-neutral state, DeepResearch citation renumbering, and runtime control contracts | [AGENTS.md](agent-runtime/AGENTS.md) | +| `agent-runtime` | Agent registry, scheduler, session/config/context facts, prompt cache, hooks, goals, prompt facts, port-backed `AgentRuntime` facade, DeepReview provider-neutral state, DeepResearch citation renumbering, and runtime control / confirmation contracts | [AGENTS.md](agent-runtime/AGENTS.md) | | `agent-stream` | Provider-neutral stream DTOs, tool-call accumulation, and replay contracts | [AGENTS.md](agent-stream/AGENTS.md) | | `tool-contracts` | Tool contracts, execution gates, input validation, and result presentation contracts. Cargo package remains `bitfun-agent-tools`. | [AGENTS.md](tool-contracts/AGENTS.md) | | `harness` | Harness workflow contracts and registry primitives | [AGENTS.md](harness/AGENTS.md) | diff --git a/src/crates/execution/agent-runtime/AGENTS.md b/src/crates/execution/agent-runtime/AGENTS.md index 9482244d7..cb75ffd56 100644 --- a/src/crates/execution/agent-runtime/AGENTS.md +++ b/src/crates/execution/agent-runtime/AGENTS.md @@ -2,7 +2,8 @@ Scope: this guide applies to `src/crates/execution/agent-runtime`. -`bitfun-agent-runtime` owns portable agent runtime decisions and the narrow +`bitfun-agent-runtime` owns portable agent runtime decisions, +session/config/context facts, lifecycle helper state, and the narrow port-backed `sdk` / `AgentRuntime` facade that can be built and tested without `bitfun-core`. @@ -20,7 +21,7 @@ port-backed `sdk` / `AgentRuntime` facade that can be built and tested without - Keep concrete scheduler/session lifecycle execution, session metadata IO, event emitter wiring, permission UI presentation, and product `Tool` adapter execution in `bitfun-core` until a reviewed owner migration proves behavior - equivalence. Provider-neutral confirmation/user-question wait channel state + equivalence. Provider-neutral confirmation gate/wait-channel and user-question state may live here. - Prefer pure facts and decisions first: queue policy, background delivery, dialog-turn queue state, active-turn facts, cancellation routing and @@ -31,12 +32,13 @@ port-backed `sdk` / `AgentRuntime` facade that can be built and tested without builtin agent definition catalog, skill catalog/root/mode/selection facts, thread-goal metadata / event payload / token usage / scheduler delivery plans, thread-goal tool wire contracts, + session config/defaults/summary and persisted session-state sidecar shape, user-question validation/result/channel contracts, SessionControl input/cancel-route/result contracts, DeepReview policy/manifest/budget/queue/report/cache/shared-context/task-execution shaping decisions, DeepResearch citation renumbering, custom subagent markdown front-matter IO, custom subagent discovery/loading, post-call hook routing/executor orchestration, - tool confirmation planning/failure/wait-result/channel mapping, light checkpoint + tool confirmation gate/planning/failure/wait-result/channel mapping, light checkpoint summary policy, dialog-turn cancellation token state, round-boundary yield/injection state, turn-outcome queue decisions, registry source/profile facts, prompt-loop user-context diff --git a/src/crates/execution/agent-runtime/Cargo.toml b/src/crates/execution/agent-runtime/Cargo.toml index d699395b8..49dd47b0d 100644 --- a/src/crates/execution/agent-runtime/Cargo.toml +++ b/src/crates/execution/agent-runtime/Cargo.toml @@ -16,6 +16,7 @@ default = [] async-trait = { workspace = true } bitfun-agent-stream = { path = "../agent-stream" } bitfun-agent-tools = { path = "../tool-contracts" } +bitfun-core-types = { path = "../../contracts/core-types" } bitfun-events = { path = "../../contracts/events" } bitfun-harness = { path = "../harness" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } diff --git a/src/crates/execution/agent-runtime/src/context_profile.rs b/src/crates/execution/agent-runtime/src/context_profile.rs new file mode 100644 index 000000000..3b108af97 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/context_profile.rs @@ -0,0 +1,328 @@ +//! Adaptive context profile policy. +//! +//! Profiles keep context behavior aligned with the shape of the agent workload +//! without exposing more knobs to the UI. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContextProfile { + LongTask, + Conversation, +} + +impl ContextProfile { + pub fn for_agent_type(agent_type: &str) -> Self { + Self::for_agent_context(agent_type, false) + } + + pub fn for_agent_context(agent_type: &str, is_review_subagent: bool) -> Self { + if is_review_subagent || is_long_task_agent(agent_type) { + Self::LongTask + } else { + Self::Conversation + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelCapabilityProfile { + Standard, + Weak, +} + +impl ModelCapabilityProfile { + pub fn from_model_id(model_id: Option<&str>) -> Self { + let Some(model_id) = model_id.map(str::trim).filter(|id| !id.is_empty()) else { + return Self::Standard; + }; + let normalized = model_id.to_ascii_lowercase(); + if matches!(normalized.as_str(), "auto" | "fast" | "primary") { + return Self::Standard; + } + + // Weak model detection: match suffix-based markers (e.g., "gpt-4o-mini", + // "gemini-1.5-flash") and exact markers (e.g., "haiku", "mini"). + // Avoid false positives from substring matches (e.g., "gemini-pro" should + // NOT match "mini" inside "gemini"). + let weak_suffixes = ["-haiku", "-mini", "-small", "-lite", "-flash", "-nano"]; + let weak_exact = ["haiku", "mini", "small", "lite", "flash", "nano"]; + // Also match known weak model name patterns where the marker appears + // mid-string but is a genuine weak model (e.g., "claude-3-haiku-20240307"). + let weak_mid_patterns = [ + "-haiku-", "-mini-", "-small-", "-lite-", "-flash-", "-nano-", + ]; + if weak_suffixes.iter().any(|s| normalized.ends_with(s)) + || weak_exact.iter().any(|e| normalized == *e) + || weak_mid_patterns.iter().any(|p| normalized.contains(p)) + { + Self::Weak + } else { + Self::Standard + } + } + + pub fn from_resolved_model(resolved_model_id: &str, provider_model_name: &str) -> Self { + let resolved = Self::from_model_id(Some(resolved_model_id)); + if resolved == Self::Weak { + resolved + } else { + Self::from_model_id(Some(provider_model_name)) + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ContextProfilePolicy { + pub profile: ContextProfile, + pub compression_contract_limit: usize, + pub subagent_concurrency_cap: usize, + pub repeated_tool_signature_threshold: usize, + pub consecutive_failed_command_threshold: usize, +} + +impl ContextProfilePolicy { + pub fn for_agent_context( + agent_type: &str, + is_review_subagent: bool, + model_capability: ModelCapabilityProfile, + ) -> Self { + let profile = ContextProfile::for_agent_context(agent_type, is_review_subagent); + let mut policy = match profile { + ContextProfile::LongTask => Self::long_task(), + ContextProfile::Conversation => Self::conversation(), + }; + + if model_capability == ModelCapabilityProfile::Weak { + policy.apply_weak_model_override(); + } + + policy + } + + pub fn for_agent_context_and_model( + agent_type: &str, + is_review_subagent: bool, + resolved_model_id: &str, + provider_model_name: &str, + ) -> Self { + Self::for_agent_context( + agent_type, + is_review_subagent, + ModelCapabilityProfile::from_resolved_model(resolved_model_id, provider_model_name), + ) + } + + pub fn for_subagent_context_and_models( + agent_type: &str, + is_review_subagent: bool, + subagent_model_id: Option<&str>, + parent_agent_type: Option<&str>, + parent_is_review_subagent: bool, + parent_model_id: Option<&str>, + ) -> Self { + let child_profile = ContextProfile::for_agent_context(agent_type, is_review_subagent); + let parent_profile = parent_agent_type + .map(|agent_type| { + ContextProfile::for_agent_context(agent_type, parent_is_review_subagent) + }) + .unwrap_or(ContextProfile::Conversation); + let profile = if child_profile == ContextProfile::LongTask + || parent_profile == ContextProfile::LongTask + { + ContextProfile::LongTask + } else { + ContextProfile::Conversation + }; + let model_capability = subagent_model_id + .map(str::trim) + .filter(|model_id| !model_id.is_empty()) + .map(|model_id| ModelCapabilityProfile::from_model_id(Some(model_id))) + .or_else(|| { + parent_model_id + .map(str::trim) + .filter(|model_id| !model_id.is_empty()) + .map(|model_id| ModelCapabilityProfile::from_model_id(Some(model_id))) + }) + .unwrap_or(ModelCapabilityProfile::Standard); + + let mut policy = match profile { + ContextProfile::LongTask => Self::long_task(), + ContextProfile::Conversation => Self::conversation(), + }; + if model_capability == ModelCapabilityProfile::Weak { + policy.apply_weak_model_override(); + } + policy + } + + pub fn effective_subagent_max_concurrency(&self, configured: usize) -> usize { + configured.clamp(1, self.subagent_concurrency_cap) + } + + pub fn effective_loop_threshold(&self, configured: usize) -> usize { + configured + .max(1) + .min(self.repeated_tool_signature_threshold.max(1)) + } + + pub fn has_repeated_tool_loop(&self, repeated_tool_signature_count: usize) -> bool { + repeated_tool_signature_count >= self.repeated_tool_signature_threshold.max(1) + } + + pub fn has_consecutive_command_failure_loop(&self, consecutive_failed_commands: usize) -> bool { + consecutive_failed_commands >= self.consecutive_failed_command_threshold.max(1) + } + + fn long_task() -> Self { + Self { + profile: ContextProfile::LongTask, + compression_contract_limit: 8, + subagent_concurrency_cap: 5, + repeated_tool_signature_threshold: 3, + consecutive_failed_command_threshold: 2, + } + } + + fn conversation() -> Self { + Self { + profile: ContextProfile::Conversation, + compression_contract_limit: 4, + subagent_concurrency_cap: 2, + repeated_tool_signature_threshold: 4, + consecutive_failed_command_threshold: 3, + } + } + + fn apply_weak_model_override(&mut self) { + self.compression_contract_limit = self.compression_contract_limit.min(4); + self.subagent_concurrency_cap = self.subagent_concurrency_cap.min(2); + self.repeated_tool_signature_threshold = self.repeated_tool_signature_threshold.min(2); + self.consecutive_failed_command_threshold = + self.consecutive_failed_command_threshold.min(2); + } +} + +fn is_long_task_agent(agent_type: &str) -> bool { + matches!( + agent_type, + "agentic" | "Multitask" | "DeepReview" | "DeepResearch" | "ComputerUse" | "Team" + ) || agent_type.starts_with("Review") +} + +#[cfg(test)] +mod tests { + use super::ModelCapabilityProfile; + + #[test] + fn model_capability_standard_for_empty_or_none() { + assert_eq!( + ModelCapabilityProfile::from_model_id(None), + ModelCapabilityProfile::Standard + ); + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("")), + ModelCapabilityProfile::Standard + ); + assert_eq!( + ModelCapabilityProfile::from_model_id(Some(" ")), + ModelCapabilityProfile::Standard + ); + } + + #[test] + fn model_capability_standard_for_strong_models() { + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("gpt-4o")), + ModelCapabilityProfile::Standard + ); + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("claude-sonnet-4")), + ModelCapabilityProfile::Standard + ); + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("gemini-pro")), + ModelCapabilityProfile::Standard + ); + } + + #[test] + fn model_capability_weak_for_haiku() { + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("claude-3-haiku-20240307")), + ModelCapabilityProfile::Weak + ); + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("anthropic/claude-3-haiku")), + ModelCapabilityProfile::Weak + ); + } + + #[test] + fn model_capability_weak_for_mini() { + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("gpt-4o-mini")), + ModelCapabilityProfile::Weak + ); + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("openai/gpt-4o-mini")), + ModelCapabilityProfile::Weak + ); + } + + #[test] + fn model_capability_weak_for_flash() { + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("gemini-1.5-flash")), + ModelCapabilityProfile::Weak + ); + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("google/gemini-flash")), + ModelCapabilityProfile::Weak + ); + } + + #[test] + fn model_capability_weak_for_lite() { + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("qwen-lite")), + ModelCapabilityProfile::Weak + ); + } + + #[test] + fn model_capability_weak_for_small() { + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("llama-small")), + ModelCapabilityProfile::Weak + ); + } + + #[test] + fn model_capability_weak_for_nano() { + assert_eq!( + ModelCapabilityProfile::from_model_id(Some("gemini-nano")), + ModelCapabilityProfile::Weak + ); + } + + #[test] + fn model_capability_from_resolved_model_prefers_resolved() { + // resolved is weak → returns weak regardless of provider name + assert_eq!( + ModelCapabilityProfile::from_resolved_model("gpt-4o-mini", "gpt-4o"), + ModelCapabilityProfile::Weak + ); + // resolved is standard, provider is weak → returns weak + assert_eq!( + ModelCapabilityProfile::from_resolved_model("gpt-4o", "gpt-4o-mini"), + ModelCapabilityProfile::Weak + ); + // both standard → returns standard + assert_eq!( + ModelCapabilityProfile::from_resolved_model("gpt-4o", "claude-sonnet"), + ModelCapabilityProfile::Standard + ); + } +} diff --git a/src/crates/execution/agent-runtime/src/dialog_turn.rs b/src/crates/execution/agent-runtime/src/dialog_turn.rs new file mode 100644 index 000000000..d0a2a6806 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/dialog_turn.rs @@ -0,0 +1,24 @@ +//! Dialog turn helpers and statistics types. +//! +//! Historical note: this module used to define `DialogTurn` and +//! `DialogTurnState` structs that were never persisted nor read back — +//! the product on-disk shape lives in the core session persistence adapter, +//! and turn lifecycle state is tracked through `SessionState::Processing` +//! and `TurnStatus`. The orphan structs were removed; only `TurnStats` +//! and a small id-helper survive as provider-neutral turn facts. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Generate a fresh turn id when callers do not supply one. +pub fn new_turn_id(provided: Option) -> String { + provided.unwrap_or_else(|| Uuid::new_v4().to_string()) +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TurnStats { + pub total_rounds: usize, + pub total_tools: usize, + pub total_tokens: usize, + pub duration_ms: u64, +} diff --git a/src/crates/execution/agent-runtime/src/lib.rs b/src/crates/execution/agent-runtime/src/lib.rs index 15fe83f92..0b4c7a079 100644 --- a/src/crates/execution/agent-runtime/src/lib.rs +++ b/src/crates/execution/agent-runtime/src/lib.rs @@ -5,10 +5,12 @@ pub mod agents; pub mod checkpoint; +pub mod context_profile; pub mod custom_agent; pub mod custom_subagent; pub mod deep_research; pub mod deep_review; +pub mod dialog_turn; pub mod event_bus; pub mod event_queue; pub mod event_router; @@ -24,7 +26,10 @@ pub mod runtime; pub mod scheduled_job; pub mod scheduler; pub mod sdk; +pub mod session; pub mod session_control; +pub mod session_state; +pub mod side_question; pub mod skill_agent_snapshot; pub mod skills; pub mod thread_goal; diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 94913d708..08f9807d1 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -30,6 +30,7 @@ impl AgentRuntimeSdkCompatibility { } } +pub use crate::context_profile::{ContextProfile, ContextProfilePolicy, ModelCapabilityProfile}; pub use crate::post_call_hooks::{ RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistry, RuntimeHookRegistryBuildError, @@ -39,6 +40,7 @@ pub use crate::runtime::{ RuntimeAgentRegistry, RuntimeAgentRegistryQuery, RuntimeBuildError, RuntimeError, RuntimeToolRegistry, SessionSelector, }; +pub use crate::session_state::{session_state_label_for_state, ProcessingPhase, SessionState}; pub use bitfun_agent_tools::{ToolRegistry, ToolRegistryItem}; pub use bitfun_harness::{ build_descriptor_harness_registry, HarnessCapability, HarnessProviderDescriptor, diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs new file mode 100644 index 000000000..836621976 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -0,0 +1,354 @@ +use crate::session_state::SessionState; +pub use bitfun_core_types::SessionKind; +use serde::{Deserialize, Serialize}; +use std::time::SystemTime; +use uuid::Uuid; + +// ============ Session ============ + +/// Session: contains multiple dialog turns +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Session { + pub session_id: String, + pub session_name: String, + /// Current/default mode selection for the session. + /// + /// This is the mode the next dialog turn should run with by default. It is + /// not required to match either the last surviving history turn or the last + /// message submission accepted by the scheduler. + pub agent_type: String, + /// Cached mode of the last surviving user dialog turn in history. + /// + /// Reminder builders use this value for `previous_agent_type` so + /// first-entry vs ongoing mode prompts follow the surviving transcript + /// after rollbacks or turn truncation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_user_dialog_agent_type: Option, + /// Mode of the most recent user submission accepted by the scheduler. + /// + /// Unlike `last_user_dialog_agent_type`, this value is not rewound by + /// history rollback. It tracks session-level prompt-cache compatibility for + /// the next accepted submission. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_submitted_agent_type: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "created_by", + alias = "createdBy" + )] + pub created_by: Option, + #[serde(default, alias = "session_kind", alias = "sessionKind")] + pub kind: SessionKind, + + /// Associated resources + #[serde( + skip_serializing_if = "Option::is_none", + alias = "sandbox_session_id", + alias = "sandboxSessionId" + )] + pub snapshot_session_id: Option, + + /// Dialog turn ID list + pub dialog_turn_ids: Vec, + + /// Session state + pub state: SessionState, + + /// Configuration + pub config: SessionConfig, + + /// Context compression related + pub compression_state: CompressionState, + + /// Lifecycle + pub created_at: SystemTime, + pub updated_at: SystemTime, + pub last_activity_at: SystemTime, +} + +/// Context compression state +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CompressionState { + /// Time of last compression + pub last_compression_at: Option, + /// Compression trigger count + pub compression_count: usize, +} + +impl CompressionState { + pub fn increment_compression_count(&mut self) { + self.last_compression_at = Some(SystemTime::now()); + self.compression_count += 1; + } +} + +impl Session { + pub fn new(session_name: String, agent_type: String, config: SessionConfig) -> Self { + let now = SystemTime::now(); + Self { + session_id: Uuid::new_v4().to_string(), + session_name, + agent_type, + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + kind: SessionKind::Standard, + snapshot_session_id: None, + dialog_turn_ids: vec![], + state: SessionState::Idle, + config, + compression_state: CompressionState::default(), + created_at: now, + updated_at: now, + last_activity_at: now, + } + } + + pub fn new_with_id( + session_id: String, + session_name: String, + agent_type: String, + config: SessionConfig, + ) -> Self { + let now = SystemTime::now(); + Self { + session_id, + session_name, + agent_type, + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, + created_by: None, + kind: SessionKind::Standard, + snapshot_session_id: None, + dialog_turn_ids: vec![], + state: SessionState::Idle, + config, + compression_state: CompressionState::default(), + created_at: now, + updated_at: now, + last_activity_at: now, + } + } +} + +/// Session configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + pub max_context_tokens: usize, + pub auto_compact: bool, + pub enable_tools: bool, + pub safe_mode: bool, + pub max_turns: usize, + pub enable_context_compression: bool, + /// Compression threshold (token usage rate), compression triggered when exceeded + pub compression_threshold: f32, + /// Workspace path bound to this session. Used to run AI in the correct workspace + /// 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 + /// `workspace_path` on different hosts (e.g. two `/` roots). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + /// SSH config `host` for locating `~/.bitfun/remote_ssh/{host}/.../sessions` when disconnected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, + /// Model config ID used by this session (for token usage tracking) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + max_context_tokens: 128128, + auto_compact: true, + enable_tools: true, + safe_mode: true, + max_turns: 200, + 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, + } + } +} + +/// Session summary (for list display) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSummary { + pub session_id: String, + pub session_name: String, + /// Current/default mode selection for the session. + pub agent_type: String, + /// Mode of the last surviving user dialog turn in the session history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_user_dialog_agent_type: Option, + /// Mode of the most recent user submission accepted by the scheduler. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_submitted_agent_type: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "created_by", + alias = "createdBy" + )] + pub created_by: Option, + #[serde(default, alias = "session_kind", alias = "sessionKind")] + pub kind: SessionKind, + pub turn_count: usize, + pub created_at: SystemTime, + pub last_activity_at: SystemTime, + pub state: SessionState, +} + +/// Persisted session state sidecar used by product session storage. +/// +/// The runtime owns this wire shape because it contains provider-neutral session +/// facts. Product persistence code still owns file I/O and path resolution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistedSessionStateFile { + pub schema_version: u32, + pub config: SessionConfig, + pub snapshot_session_id: Option, + /// Derived runtime cache for reminder semantics. The source of truth lives + /// on persisted dialog turns via `DialogTurnData.agent_type`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_user_dialog_agent_type: Option, + /// Session-level prompt-cache guard state. This records the most recent user + /// submission accepted by the scheduler and intentionally does not rewind on + /// history rollback. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_submitted_agent_type: Option, + pub compression_state: CompressionState, + pub runtime_state: SessionState, +} + +pub fn sanitize_persisted_session_state(state: &SessionState) -> SessionState { + match state { + SessionState::Processing { .. } => SessionState::Idle, + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, + SessionConfig, + }; + use crate::session_state::{ProcessingPhase, SessionState}; + use serde_json::json; + + #[test] + fn session_config_default_preserves_existing_context_budget() { + let config = SessionConfig::default(); + + assert_eq!(config.max_context_tokens, 128128); + assert!(config.auto_compact); + assert!(config.enable_tools); + assert!(config.safe_mode); + assert_eq!(config.max_turns, 200); + assert!(config.enable_context_compression); + assert_eq!(config.compression_threshold, 0.8); + assert!(config.workspace_path.is_none()); + assert!(config.workspace_id.is_none()); + assert!(config.remote_connection_id.is_none()); + assert!(config.remote_ssh_host.is_none()); + assert!(config.model_id.is_none()); + } + + #[test] + fn new_session_preserves_legacy_runtime_defaults() { + let session = Session::new( + "Session".to_string(), + "agentic".to_string(), + SessionConfig::default(), + ); + + assert_eq!(session.session_name, "Session"); + assert_eq!(session.agent_type, "agentic"); + assert_eq!(session.dialog_turn_ids, Vec::::new()); + assert_eq!(session.state, SessionState::Idle); + assert_eq!(session.compression_state.compression_count, 0); + assert!(session.last_user_dialog_agent_type.is_none()); + assert!(session.last_submitted_agent_type.is_none()); + assert!(session.created_by.is_none()); + assert!(session.snapshot_session_id.is_none()); + } + + #[test] + fn persisted_session_state_sanitizes_processing_to_idle() { + let sanitized = sanitize_persisted_session_state(&SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }); + + assert_eq!(sanitized, SessionState::Idle); + assert_eq!( + sanitize_persisted_session_state(&SessionState::Error { + error: "boom".to_string(), + recoverable: true, + }), + SessionState::Error { + error: "boom".to_string(), + recoverable: true, + } + ); + } + + #[test] + fn persisted_session_state_file_shape_stays_compatible() { + let file = PersistedSessionStateFile { + schema_version: 1, + config: SessionConfig { + workspace_path: Some("/workspace".to_string()), + model_id: Some("model-a".to_string()), + ..SessionConfig::default() + }, + snapshot_session_id: Some("snapshot-1".to_string()), + last_user_dialog_agent_type: Some("agentic".to_string()), + last_submitted_agent_type: Some("DeepReview".to_string()), + compression_state: CompressionState { + last_compression_at: None, + compression_count: 2, + }, + runtime_state: SessionState::Idle, + }; + + assert_eq!( + serde_json::to_value(file).expect("persisted session state should serialize"), + json!({ + "schema_version": 1, + "config": { + "max_context_tokens": 128128, + "auto_compact": true, + "enable_tools": true, + "safe_mode": true, + "max_turns": 200, + "enable_context_compression": true, + "compression_threshold": 0.800000011920929, + "workspace_path": "/workspace", + "model_id": "model-a" + }, + "snapshot_session_id": "snapshot-1", + "last_user_dialog_agent_type": "agentic", + "last_submitted_agent_type": "DeepReview", + "compression_state": { + "last_compression_at": null, + "compression_count": 2 + }, + "runtime_state": "Idle" + }) + ); + } +} diff --git a/src/crates/execution/agent-runtime/src/session_state.rs b/src/crates/execution/agent-runtime/src/session_state.rs new file mode 100644 index 000000000..8924303dd --- /dev/null +++ b/src/crates/execution/agent-runtime/src/session_state.rs @@ -0,0 +1,86 @@ +//! Provider-neutral session state facts. + +use bitfun_runtime_ports::DialogSessionStateFact; +use serde::{Deserialize, Serialize}; + +/// Session state shared by runtime coordination and product event projection. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SessionState { + Idle, + Processing { + current_turn_id: String, + phase: ProcessingPhase, + }, + Error { + error: String, + recoverable: bool, + }, +} + +impl SessionState { + pub const fn dialog_state_fact(&self) -> DialogSessionStateFact { + match self { + Self::Idle => DialogSessionStateFact::Idle, + Self::Processing { .. } => DialogSessionStateFact::Processing, + Self::Error { .. } => DialogSessionStateFact::Error, + } + } +} + +/// Runtime processing phase, aligned with the existing product event payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum ProcessingPhase { + Starting, + Compacting, + Thinking, + Streaming, + ToolCalling, + ToolConfirming, +} + +pub fn session_state_label_for_state(state: &SessionState) -> &'static str { + crate::events::session_state_label(state.dialog_state_fact()) +} + +#[cfg(test)] +mod tests { + use super::{session_state_label_for_state, ProcessingPhase, SessionState}; + use serde_json::json; + + #[test] + fn session_state_labels_match_existing_event_wire_values() { + assert_eq!(session_state_label_for_state(&SessionState::Idle), "idle"); + assert_eq!( + session_state_label_for_state(&SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::Thinking, + }), + "processing" + ); + assert_eq!( + session_state_label_for_state(&SessionState::Error { + error: "boom".to_string(), + recoverable: true, + }), + "error" + ); + } + + #[test] + fn processing_state_serialization_stays_compatible() { + let state = SessionState::Processing { + current_turn_id: "turn-1".to_string(), + phase: ProcessingPhase::ToolCalling, + }; + + assert_eq!( + serde_json::to_value(&state).expect("session state should serialize"), + json!({ + "Processing": { + "current_turn_id": "turn-1", + "phase": "ToolCalling" + } + }) + ); + } +} diff --git a/src/crates/execution/agent-runtime/src/side_question.rs b/src/crates/execution/agent-runtime/src/side_question.rs new file mode 100644 index 000000000..a67a70fe8 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/side_question.rs @@ -0,0 +1,148 @@ +//! Runtime-only `/btw` request tracking. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, Clone)] +pub struct SideQuestionRuntime { + tokens: Arc>>, + btw_turns: Arc>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActiveBtwTurn { + pub session_id: String, + pub turn_id: String, +} + +impl Default for SideQuestionRuntime { + fn default() -> Self { + Self::new() + } +} + +impl SideQuestionRuntime { + pub fn new() -> Self { + Self { + tokens: Arc::new(Mutex::new(HashMap::new())), + btw_turns: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn register(&self, request_id: String) -> CancellationToken { + let token = CancellationToken::new(); + + let old = { + let mut guard = self.tokens.lock().await; + guard.insert(request_id, token.clone()) + }; + if let Some(old) = old { + old.cancel(); + } + + token + } + + pub async fn cancel(&self, request_id: &str) { + let token = { + let guard = self.tokens.lock().await; + guard.get(request_id).cloned() + }; + if let Some(token) = token { + token.cancel(); + } + } + + pub async fn remove(&self, request_id: &str) { + { + let mut guard = self.tokens.lock().await; + guard.remove(request_id); + } + let mut btw_turns = self.btw_turns.lock().await; + btw_turns.remove(request_id); + } + + pub async fn register_btw_turn(&self, request_id: String, session_id: String, turn_id: String) { + let mut guard = self.btw_turns.lock().await; + guard.insert( + request_id, + ActiveBtwTurn { + session_id, + turn_id, + }, + ); + } + + pub async fn get_btw_turn(&self, request_id: &str) -> Option { + let guard = self.btw_turns.lock().await; + guard.get(request_id).cloned() + } +} + +#[cfg(test)] +mod tests { + use super::{ActiveBtwTurn, SideQuestionRuntime}; + + #[tokio::test] + async fn registering_same_request_cancels_previous_token() { + let runtime = SideQuestionRuntime::new(); + + let first = runtime.register("req-1".to_string()).await; + let second = runtime.register("req-1".to_string()).await; + + assert!(first.is_cancelled()); + assert!(!second.is_cancelled()); + } + + #[tokio::test] + async fn remove_clears_token_and_btw_turn_mapping() { + let runtime = SideQuestionRuntime::new(); + let token = runtime.register("req-1".to_string()).await; + runtime + .register_btw_turn( + "req-1".to_string(), + "session-1".to_string(), + "turn-1".to_string(), + ) + .await; + + assert_eq!( + runtime.get_btw_turn("req-1").await, + Some(ActiveBtwTurn { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }) + ); + + runtime.remove("req-1").await; + + assert!(!token.is_cancelled()); + assert_eq!(runtime.get_btw_turn("req-1").await, None); + } + + #[tokio::test] + async fn cancel_marks_registered_token_without_removing_turn_mapping() { + let runtime = SideQuestionRuntime::new(); + let token = runtime.register("req-1".to_string()).await; + runtime + .register_btw_turn( + "req-1".to_string(), + "session-1".to_string(), + "turn-1".to_string(), + ) + .await; + + runtime.cancel("req-1").await; + + assert!(token.is_cancelled()); + assert_eq!( + runtime.get_btw_turn("req-1").await, + Some(ActiveBtwTurn { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + }) + ); + } +} diff --git a/src/crates/execution/agent-runtime/src/tool_confirmation.rs b/src/crates/execution/agent-runtime/src/tool_confirmation.rs index 8c6c902f2..db47283c8 100644 --- a/src/crates/execution/agent-runtime/src/tool_confirmation.rs +++ b/src/crates/execution/agent-runtime/src/tool_confirmation.rs @@ -15,6 +15,26 @@ pub struct ToolConfirmationRequestFacts { pub now: SystemTime, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ToolConfirmationGateFacts { + pub global_skip_tool_confirmation: bool, + pub context_skip_tool_confirmation: bool, + pub any_tool_needs_permission: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolConfirmationGatePlan { + SkipByPolicy, + SkipNoPermissionedTool, + AwaitPermissionedTool, +} + +impl ToolConfirmationGatePlan { + pub const fn confirm_before_run(self) -> bool { + matches!(self, Self::AwaitPermissionedTool) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolConfirmationPlan { Skip, @@ -102,6 +122,20 @@ impl ToolConfirmationChannelStore { } } +pub fn resolve_tool_confirmation_gate( + facts: ToolConfirmationGateFacts, +) -> ToolConfirmationGatePlan { + if facts.global_skip_tool_confirmation || facts.context_skip_tool_confirmation { + return ToolConfirmationGatePlan::SkipByPolicy; + } + + if facts.any_tool_needs_permission { + ToolConfirmationGatePlan::AwaitPermissionedTool + } else { + ToolConfirmationGatePlan::SkipNoPermissionedTool + } +} + pub fn resolve_tool_confirmation_plan( request: ToolConfirmationRequestFacts, ) -> ToolConfirmationPlan { diff --git a/src/crates/execution/agent-runtime/tests/prompt_contracts.rs b/src/crates/execution/agent-runtime/tests/prompt_contracts.rs index 26f63a2d1..2bbcbeae1 100644 --- a/src/crates/execution/agent-runtime/tests/prompt_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/prompt_contracts.rs @@ -49,7 +49,7 @@ fn tool_listing_sections_render_only_present_sections() { assert!(sections .render_skill_listing_reminder() .expect("skill listing should render") - .starts_with("# Skill Listing\nThe following skills are available")); + .starts_with("# Skill Listing\nA skill is a set of instructions")); assert!(sections.render_agent_listing_reminder().is_none()); assert!(sections .render_collapsed_tool_listing_reminder() diff --git a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs b/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs index 172d07e51..599ad58ce 100644 --- a/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/tool_confirmation_contracts.rs @@ -1,10 +1,55 @@ use bitfun_agent_runtime::tool_confirmation::{ - resolve_confirmation_failure, resolve_confirmation_wait_result, resolve_tool_confirmation_plan, - ConfirmationFailureKind, ToolConfirmationOutcome, ToolConfirmationPlan, + resolve_confirmation_failure, resolve_confirmation_wait_result, resolve_tool_confirmation_gate, + resolve_tool_confirmation_plan, ConfirmationFailureKind, ToolConfirmationGateFacts, + ToolConfirmationGatePlan, ToolConfirmationOutcome, ToolConfirmationPlan, ToolConfirmationRequestFacts, ToolConfirmationWaitResult, }; use std::time::{Duration, UNIX_EPOCH}; +#[test] +fn confirmation_gate_preserves_skip_policy_precedence() { + assert_eq!( + resolve_tool_confirmation_gate(ToolConfirmationGateFacts { + global_skip_tool_confirmation: true, + context_skip_tool_confirmation: false, + any_tool_needs_permission: true, + }), + ToolConfirmationGatePlan::SkipByPolicy + ); + assert_eq!( + resolve_tool_confirmation_gate(ToolConfirmationGateFacts { + global_skip_tool_confirmation: false, + context_skip_tool_confirmation: true, + any_tool_needs_permission: true, + }), + ToolConfirmationGatePlan::SkipByPolicy + ); +} + +#[test] +fn confirmation_gate_requires_confirmation_only_for_permissioned_tools() { + let permissioned = resolve_tool_confirmation_gate(ToolConfirmationGateFacts { + global_skip_tool_confirmation: false, + context_skip_tool_confirmation: false, + any_tool_needs_permission: true, + }); + + assert_eq!( + permissioned, + ToolConfirmationGatePlan::AwaitPermissionedTool + ); + assert!(permissioned.confirm_before_run()); + + let readonly = resolve_tool_confirmation_gate(ToolConfirmationGateFacts { + global_skip_tool_confirmation: false, + context_skip_tool_confirmation: false, + any_tool_needs_permission: false, + }); + + assert_eq!(readonly, ToolConfirmationGatePlan::SkipNoPermissionedTool); + assert!(!readonly.confirm_before_run()); +} + #[test] fn confirmation_plan_requires_permission_only_when_both_flags_are_true() { let plan = resolve_tool_confirmation_plan(ToolConfirmationRequestFacts {