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 {